Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/custom-provider-body-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a `custom_body` provider configuration field for merging provider-specific JSON values into generated requests.
14 changes: 14 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ Each entry in the `providers` table defines an API provider, keyed by a unique n
| `oauth` | `table` | No | OAuth credential reference (`storage` and `key` fields); injected automatically by the login flow — normally no need to write this by hand |
| `env` | `table<string, string>` | No | Fallback source for provider credentials; see below |
| `custom_headers` | `table<string, string>` | No | Custom HTTP headers attached to each request |
| `custom_body` | `table` | No | JSON custom-body patch applied after Kimi Code generates the provider request. Objects merge recursively; arrays and scalar values (including `false`, `0`, empty strings, and TOML-expressible values) replace generated values. It can override generated fields such as `model`, `messages`, `tools`, and `stream`. TOML has no `null` literal; SDK/RPC configuration may use nested `null`. |

### `custom_body`

Use `custom_body` to add provider-specific fields or deliberately override a generated request field:

```toml
[providers.gateway]
type = "openai"
base_url = "https://gateway.example/v1"
custom_body = { stream = false, metadata = { route = "batch" }, tools = [] }
```

The patch is applied last. Object fields merge recursively, while arrays and scalar values replace the generated value. It can therefore override system fields such as `model`, `messages`, `input`, `contents`, `tools`, or `stream`; use this carefully, because an incompatible override can make a provider request invalid. TOML cannot express `null`; SDK/RPC configuration can use nested `null`. The reserved object keys `__proto__`, `prototype`, and `constructor` are rejected at every nesting level. For Google GenAI and Vertex AI, a boolean `stream` selects the SDK's streaming or non-streaming method and is not forwarded as a `generateContent` parameter. Other patch fields apply to the SDK `generateContent` parameter object (for example `model`, `contents`, and `config`), not a documented raw HTTP body.

**`env` sub-table**: You can write provider-conventional key names (such as `KIMI_API_KEY`) inside `[providers.<name>.env]` as a fallback source for `api_key` / `base_url`. This sub-table is **read only from the config file** and does not modify the shell environment:

Expand Down
14 changes: 14 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ timeout = 5
| `oauth` | `table` | 否 | OAuth 凭据引用(`storage`、`key` 两个字段),由登录流程自动注入,通常无需手写 |
| `env` | `table<string, string>` | 否 | 供应商凭证的备用来源,详见下文 |
| `custom_headers` | `table<string, string>` | 否 | 每次请求附加的自定义 HTTP 头 |
| `custom_body` | `table` | 否 | 在 Kimi Code 生成供应商请求后应用的 JSON 自定义请求体补丁。对象递归合并;数组和标量值(包括 `false`、`0`、空字符串以及 TOML 可表达的值)会替换生成值。可覆盖 `model`、`messages`、`tools`、`stream` 等生成字段。TOML 没有 `null` 字面量;SDK/RPC 配置可使用嵌套 `null`。 |

### `custom_body`

使用 `custom_body` 添加供应商特定字段,或有意覆盖已生成的请求字段:

```toml
[providers.gateway]
type = "openai"
base_url = "https://gateway.example/v1"
custom_body = { stream = false, metadata = { route = "batch" }, tools = [] }
```

补丁最后应用。对象字段递归合并;数组和标量值会替换生成值。因此它可以覆盖 `model`、`messages`、`input`、`contents`、`tools`、`stream` 等系统字段;请谨慎使用,不兼容的覆盖可能使供应商请求无效。TOML 不能表达 `null`;SDK/RPC 配置可以使用嵌套 `null`。所有嵌套层级均拒绝保留对象键 `__proto__`、`prototype` 和 `constructor`。对于 Google GenAI 和 Vertex AI,布尔值 `stream` 会选择 SDK 的流式或非流式方法,且不会作为 `generateContent` 参数透传。其他补丁字段作用于 SDK 的 `generateContent` 参数对象(例如 `model`、`contents`、`config`),并非声明为原始 HTTP 请求体。

**`env` 子表**:可以把供应商惯用的键名(如 `KIMI_API_KEY`)写在 `[providers.<name>.env]` 里,作为 `api_key` / `base_url` 的备用来源。这个子表**只在配置文件里读取**,不会修改 shell 环境:

Expand Down
13 changes: 11 additions & 2 deletions packages/agent-core-v2/src/app/config/configPure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export function deepEqual(a: unknown, b: unknown): boolean {
return false;
}

function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}

export function deepMerge<T>(base: T | undefined, patch: unknown): T {
if (!isPlainObject(base) || !isPlainObject(patch)) {
return (patch ?? base) as T;
Expand All @@ -38,7 +47,7 @@ export function deepMerge<T>(base: T | undefined, patch: unknown): T {
for (const key of Object.keys(patch)) {
const pv = patch[key];
const bv = out[key];
out[key] = isPlainObject(bv) && isPlainObject(pv) ? deepMerge(bv, pv) : pv;
setOwn(out, key, isPlainObject(bv) && isPlainObject(pv) ? deepMerge(bv, pv) : pv);
}
return out as T;
}
Expand All @@ -48,7 +57,7 @@ export function omitUndefined<T extends Record<string, unknown>>(value: T): Part
for (const key of Object.keys(value)) {
const v = value[key];
if (v !== undefined) {
out[key as keyof T] = v as T[keyof T];
setOwn(out, key, v);
}
}
return out;
Expand Down
17 changes: 12 additions & 5 deletions packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
type AnthropicModelVersion,
} from './anthropic-profile';
import { mergeConsecutiveUserMessages } from './merge-user-messages';
import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body';
import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth';
import {
normalizeToolCallIdsForProvider,
Expand Down Expand Up @@ -91,6 +92,7 @@ export interface AnthropicOptions {
defaultMaxTokens?: number | undefined;
betaFeatures?: string[] | undefined;
defaultHeaders?: Record<string, string>;
customBody?: CustomBody;
metadata?: Record<string, string> | undefined;
stream?: boolean | undefined;
adaptiveThinking?: boolean | undefined;
Expand Down Expand Up @@ -755,6 +757,7 @@ export class AnthropicChatProvider implements ChatProvider {
private _apiKey: string | undefined;
private _baseUrl: string | undefined;
private _defaultHeaders: Record<string, string | null> | undefined;
private _customBody: CustomBody | undefined;
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
private _adaptiveThinking: boolean | undefined;
private readonly _supportEfforts: readonly string[] | undefined;
Expand All @@ -774,6 +777,7 @@ export class AnthropicChatProvider implements ChatProvider {
options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey;
this._baseUrl = options.baseUrl;
this._defaultHeaders = options.defaultHeaders;
this._customBody = options.customBody;
this._clientFactory = options.clientFactory;
this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey);
this._explicitMaxTokens = options.defaultMaxTokens !== undefined;
Expand Down Expand Up @@ -925,6 +929,9 @@ export class AnthropicChatProvider implements ChatProvider {
createParams['betas'] = betas;
}

const stream = resolveCustomBodyStream(this._customBody, this._stream);
const finalCreateParams = applyCustomBody({ ...createParams, stream }, this._customBody);

const requestOptions: Record<string, unknown> = {};
const headers = mergeRequestHeaders(extraHeaders, options?.auth?.headers);
if (headers !== undefined) {
Expand All @@ -937,15 +944,15 @@ export class AnthropicChatProvider implements ChatProvider {
const client = this._createClient(options?.auth);
options?.onRequestSent?.();

if (this._stream) {
if (stream) {
try {
const stream = this._betaApi
? await client.beta.messages.create(
{ ...createParams, stream: true } as unknown as MessageCreateParamsStreaming,
finalCreateParams as unknown as MessageCreateParamsStreaming,
finalRequestOptions,
)
: await client.messages.create(
{ ...createParams, stream: true } as unknown as MessageCreateParamsStreaming,
finalCreateParams as unknown as MessageCreateParamsStreaming,
finalRequestOptions,
);
return new AnthropicStreamedMessage(stream, true);
Expand All @@ -957,11 +964,11 @@ export class AnthropicChatProvider implements ChatProvider {
try {
const response = this._betaApi
? await client.beta.messages.create(
{ ...createParams, stream: false } as unknown as MessageCreateParams,
finalCreateParams as unknown as MessageCreateParams,
finalRequestOptions,
)
: await client.messages.create(
{ ...createParams, stream: false } as unknown as MessageCreateParams,
finalCreateParams as unknown as MessageCreateParams,
finalRequestOptions,
);
return new AnthropicStreamedMessage(response, false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* `llmProtocol` domain (L0) — final custom-body patching for provider transports.
*/

export interface JSONObject {
readonly [key: string]: JSONValue;
}

export type JSONValue = string | number | boolean | null | readonly JSONValue[] | JSONObject;

export type CustomBody = JSONObject;

type MutableObject = Record<string, unknown>;

function setOwn(target: MutableObject, key: string, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}

export function applyCustomBody(
generated: Readonly<Record<string, unknown>>,
customBody: CustomBody | undefined,
): MutableObject {
const result = cloneValue(generated) as MutableObject;
if (customBody === undefined) return result;

for (const [key, value] of Object.entries(customBody)) {
setOwn(result, key, mergeValue(result[key], value));
}
return result;
}

export function resolveCustomBodyStream(customBody: CustomBody | undefined, fallback: boolean): boolean {
const stream = customBody?.['stream'];
return typeof stream === 'boolean' ? stream : fallback;
}

export function withoutCustomBodyStream(customBody: CustomBody | undefined): CustomBody | undefined {
if (typeof customBody?.['stream'] !== 'boolean') return customBody;
const result: MutableObject = {};
for (const [key, value] of Object.entries(customBody)) {
if (key !== 'stream') setOwn(result, key, value);
}
return result as CustomBody;
}

function mergeValue(generated: unknown, patch: JSONValue): unknown {
if (isObject(generated) && isObject(patch)) {
const result = cloneValue(generated) as MutableObject;
for (const [key, value] of Object.entries(patch)) {
setOwn(result, key, mergeValue(result[key], value));
}
return result;
}
return cloneValue(patch);
}

function cloneValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneValue);
if (isObject(value)) {
const result: MutableObject = {};
for (const [key, item] of Object.entries(value)) {
setOwn(result, key, cloneValue(item));
}
return result;
}
return value;
}

function isObject(value: unknown): value is MutableObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import type { Tool } from '../tool';
import type { TokenUsage } from '../usage';
import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai';
import { mergeConsecutiveUserMessages } from './merge-user-messages';
import {
applyCustomBody,
resolveCustomBodyStream,
withoutCustomBodyStream,
type CustomBody,
} from './custom-body';

import { requireProviderApiKey, resolveAuthBackedClient } from './request-auth';

Expand Down Expand Up @@ -69,6 +75,7 @@ export interface GoogleGenAIOptions {
location?: string | undefined;
stream?: boolean | undefined;
defaultHeaders?: Record<string, string>;
customBody?: CustomBody;
clientFactory?: (auth: ProviderRequestAuth) => GenAIClient;
}

Expand Down Expand Up @@ -635,6 +642,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
private _project: string | undefined;
private _location: string | undefined;
private _defaultHeaders: Record<string, string> | undefined;
private _customBody: CustomBody | undefined;
private _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined;

constructor(options: GoogleGenAIOptions) {
Expand All @@ -650,6 +658,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
this._project = options.project;
this._location = options.location;
this._defaultHeaders = options.defaultHeaders;
this._customBody = options.customBody;
this._clientFactory = options.clientFactory;
this._client =
this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined;
Expand Down Expand Up @@ -746,10 +755,12 @@ export class GoogleGenAIChatProvider implements ChatProvider {
generateContentStream(params: Record<string, unknown>): Promise<AsyncGenerator>;
};

const params = { model: this._model, contents, config };
const effectiveStream = resolveCustomBodyStream(this._customBody, this._stream);
const customBody = withoutCustomBodyStream(this._customBody);
const params = applyCustomBody({ model: this._model, contents, config }, customBody);

options?.onRequestSent?.();
if (this._stream) {
if (effectiveStream) {
const stream = await Promise.race([
models.generateContentStream(params),
abortPromise(options?.signal),
Expand Down
13 changes: 10 additions & 3 deletions packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ import {
sanitizeToolCallId,
type ToolCallIdPolicy,
} from './tool-call-id';
import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body';
export interface KimiOptions {
apiKey?: string | undefined;
baseUrl?: string | undefined;
model: string;
stream?: boolean | undefined;
defaultHeaders?: Record<string, string> | undefined;
customBody?: CustomBody;
generationKwargs?: GenerationKwargs | undefined;
clientFactory?: (auth: ProviderRequestAuth) => OpenAI;
}
Expand Down Expand Up @@ -366,6 +368,7 @@ export class KimiChatProvider implements ChatProvider {
private _apiKey: string | undefined;
private _baseUrl: string;
private _defaultHeaders: Record<string, string> | undefined;
private _customBody: CustomBody | undefined;
private _generationKwargs: GenerationKwargs;
private _client: OpenAI | undefined;
private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined;
Expand All @@ -376,6 +379,7 @@ export class KimiChatProvider implements ChatProvider {
this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey;
this._baseUrl = options.baseUrl ?? process.env['KIMI_BASE_URL'] ?? 'https://api.moonshot.ai/v1';
this._defaultHeaders = options.defaultHeaders;
this._customBody = options.customBody;
this._clientFactory = options.clientFactory;
this._model = options.model;
this._stream = options.stream ?? true;
Expand Down Expand Up @@ -482,9 +486,12 @@ export class KimiChatProvider implements ChatProvider {
createParams['response_format'] = responseFormatToOpenAI(options.responseFormat);
}

if (this._stream) {
const stream = resolveCustomBodyStream(this._customBody, createParams['stream'] === true);
createParams['stream'] = stream;
if (stream) {
createParams['stream_options'] = { include_usage: true };
}
const finalCreateParams = applyCustomBody(createParams, this._customBody);

try {
const client = this._createClient(options?.auth);
Expand All @@ -493,15 +500,15 @@ export class KimiChatProvider implements ChatProvider {
// (before the stream body), so the trace id is available mid-stream.
const { data, response } = await client.chat.completions
.create(
createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
finalCreateParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
options?.signal ? { signal: options.signal } : undefined,
)
.withResponse();
return new KimiStreamedMessage(
data as unknown as
| OpenAI.Chat.ChatCompletion
| AsyncIterable<OpenAI.Chat.ChatCompletionChunk>,
this._stream,
stream,
parseTraceId(response.headers),
);
} catch (error: unknown) {
Expand Down
Loading