From 393972d6423d3694a88c7acadcbc9fc1119fa060 Mon Sep 17 00:00:00 2001 From: ayu Date: Thu, 16 Jul 2026 20:20:42 +0800 Subject: [PATCH] feat(provider): add custom body overrides --- .changeset/custom-provider-body-overrides.md | 5 + docs/en/configuration/config-files.md | 14 + docs/zh/configuration/config-files.md | 14 + .../src/app/config/configPure.ts | 13 +- .../app/llmProtocol/providers/anthropic.ts | 17 +- .../app/llmProtocol/providers/custom-body.ts | 76 ++++ .../app/llmProtocol/providers/google-genai.ts | 15 +- .../src/app/llmProtocol/providers/kimi.ts | 13 +- .../llmProtocol/providers/openai-legacy.ts | 13 +- .../llmProtocol/providers/openai-responses.ts | 11 +- .../src/app/model/modelResolverService.ts | 1 + .../src/app/protocol/protocol.ts | 1 + .../src/app/provider/configSection.ts | 11 +- .../src/app/provider/provider.ts | 37 ++ .../llmProtocol/providers/custom-body.test.ts | 376 ++++++++++++++++++ .../test/app/model/modelResolver.test.ts | 15 + .../protocol/protocolAdapterRegistry.test.ts | 13 + .../test/app/provider/provider.test.ts | 11 + packages/agent-core/src/config/merge.ts | 15 +- packages/agent-core/src/config/schema.ts | 37 ++ packages/agent-core/src/config/toml.ts | 7 +- .../src/services/config/configService.ts | 3 +- .../src/session/provider-manager.ts | 6 + .../agent-core/test/config/configs.test.ts | 85 +++- .../test/harness/runtime-provider.test.ts | 19 + packages/kap-server/src/routes/config.ts | 3 +- packages/kap-server/test/config.test.ts | 36 ++ .../klient/src/contract/global/providers.ts | 37 ++ packages/klient/test/facade.test.ts | 24 ++ packages/klient/test/helpers/conformance.ts | 15 +- packages/kosong/src/providers/anthropic.ts | 17 +- packages/kosong/src/providers/custom-body.ts | 72 ++++ packages/kosong/src/providers/google-genai.ts | 15 +- packages/kosong/src/providers/kimi.ts | 13 +- .../kosong/src/providers/openai-legacy.ts | 13 +- .../kosong/src/providers/openai-responses.ts | 11 +- packages/kosong/test/custom-body.test.ts | 372 +++++++++++++++++ 37 files changed, 1413 insertions(+), 43 deletions(-) create mode 100644 .changeset/custom-provider-body-overrides.md create mode 100644 packages/agent-core-v2/src/app/llmProtocol/providers/custom-body.ts create mode 100644 packages/agent-core-v2/test/app/llmProtocol/providers/custom-body.test.ts create mode 100644 packages/kosong/src/providers/custom-body.ts create mode 100644 packages/kosong/test/custom-body.test.ts diff --git a/.changeset/custom-provider-body-overrides.md b/.changeset/custom-provider-body-overrides.md new file mode 100644 index 0000000000..715371fcbe --- /dev/null +++ b/.changeset/custom-provider-body-overrides.md @@ -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. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 933f282cc6..d643c6d1d6 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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` | No | Fallback source for provider credentials; see below | | `custom_headers` | `table` | 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..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: diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 529b16676e..efcdfd4fb6 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -118,6 +118,20 @@ timeout = 5 | `oauth` | `table` | 否 | OAuth 凭据引用(`storage`、`key` 两个字段),由登录流程自动注入,通常无需手写 | | `env` | `table` | 否 | 供应商凭证的备用来源,详见下文 | | `custom_headers` | `table` | 否 | 每次请求附加的自定义 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..env]` 里,作为 `api_key` / `base_url` 的备用来源。这个子表**只在配置文件里读取**,不会修改 shell 环境: diff --git a/packages/agent-core-v2/src/app/config/configPure.ts b/packages/agent-core-v2/src/app/config/configPure.ts index 798f1ba318..816c602b53 100644 --- a/packages/agent-core-v2/src/app/config/configPure.ts +++ b/packages/agent-core-v2/src/app/config/configPure.ts @@ -30,6 +30,15 @@ export function deepEqual(a: unknown, b: unknown): boolean { return false; } +function setOwn(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + export function deepMerge(base: T | undefined, patch: unknown): T { if (!isPlainObject(base) || !isPlainObject(patch)) { return (patch ?? base) as T; @@ -38,7 +47,7 @@ export function deepMerge(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; } @@ -48,7 +57,7 @@ export function omitUndefined>(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; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index 011fb34ea1..e0d33018be 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -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, @@ -91,6 +92,7 @@ export interface AnthropicOptions { defaultMaxTokens?: number | undefined; betaFeatures?: string[] | undefined; defaultHeaders?: Record; + customBody?: CustomBody; metadata?: Record | undefined; stream?: boolean | undefined; adaptiveThinking?: boolean | undefined; @@ -755,6 +757,7 @@ export class AnthropicChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; private readonly _supportEfforts: readonly string[] | undefined; @@ -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; @@ -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 = {}; const headers = mergeRequestHeaders(extraHeaders, options?.auth?.headers); if (headers !== undefined) { @@ -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); @@ -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); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/custom-body.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/custom-body.ts new file mode 100644 index 0000000000..48d049b8d3 --- /dev/null +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/custom-body.ts @@ -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; + +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>, + 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); +} diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index c7b998aed8..2e330d3e21 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -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'; @@ -69,6 +75,7 @@ export interface GoogleGenAIOptions { location?: string | undefined; stream?: boolean | undefined; defaultHeaders?: Record; + customBody?: CustomBody; clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; } @@ -635,6 +642,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { private _project: string | undefined; private _location: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; constructor(options: GoogleGenAIOptions) { @@ -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; @@ -746,10 +755,12 @@ export class GoogleGenAIChatProvider implements ChatProvider { generateContentStream(params: Record): Promise; }; - 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), diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index e02bb343f5..ed783d6469 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -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 | undefined; + customBody?: CustomBody; generationKwargs?: GenerationKwargs | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -366,6 +368,7 @@ export class KimiChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _generationKwargs: GenerationKwargs; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; @@ -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; @@ -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); @@ -493,7 +500,7 @@ 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(); @@ -501,7 +508,7 @@ export class KimiChatProvider implements ChatProvider { data as unknown as | OpenAI.Chat.ChatCompletion | AsyncIterable, - this._stream, + stream, parseTraceId(response.headers), ); } catch (error: unknown) { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index 8f6b4b0341..a7a961c161 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -41,6 +41,7 @@ import { sanitizeToolCallId, type ToolCallIdPolicy, } from './tool-call-id'; +import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body'; const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const; const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0]; @@ -74,6 +75,7 @@ export interface OpenAILegacyOptions { reasoningKey?: string | undefined; httpClient?: unknown; defaultHeaders?: Record; + customBody?: CustomBody; toolMessageConversion?: ToolMessageConversion | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -432,6 +434,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _reasoningKey: string | undefined; private _thinkingEffort: ThinkingEffort | undefined; private _generationKwargs: OpenAILegacyGenerationKwargs; @@ -445,6 +448,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; this._defaultHeaders = options.defaultHeaders; + this._customBody = options.customBody; this._model = options.model; this._stream = options.stream ?? true; const normalizedReasoningKey = options.reasoningKey?.trim(); @@ -547,22 +551,25 @@ export class OpenAILegacyChatProvider 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 }; } if (reasoningEffort !== undefined) { createParams['reasoning_effort'] = reasoningEffort; } + const finalCreateParams = applyCustomBody(createParams, this._customBody); try { const client = this._createClient(options?.auth); options?.onRequestSent?.(); const 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, )) as unknown as OpenAI.Chat.ChatCompletion | AsyncIterable; - return new OpenAILegacyStreamedMessage(response, this._stream, this._reasoningKey); + return new OpenAILegacyStreamedMessage(response, stream, this._reasoningKey); } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 9953a0d148..eb9e500037 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -37,6 +37,7 @@ import { sanitizeOpenAIResponsesCallId, type ToolCallIdPolicy, } from './tool-call-id'; +import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body'; function normalizeResponsesFinishReason( status: string | null | undefined, @@ -337,6 +338,7 @@ export interface OpenAIResponsesOptions { maxOutputTokens?: number | undefined; httpClient?: unknown; defaultHeaders?: Record; + customBody?: CustomBody; toolMessageConversion?: ToolMessageConversion | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -962,6 +964,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -973,6 +976,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; this._defaultHeaders = options.defaultHeaders; + this._customBody = options.customBody; this._model = options.model; this._stream = true; this._generationKwargs = {}; @@ -1063,6 +1067,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { ...responseFormatToResponsesText(options.responseFormat), }; } + const stream = resolveCustomBodyStream(this._customBody, createParams['stream'] === true); + createParams['stream'] = stream; + const finalCreateParams = applyCustomBody(createParams, this._customBody); if ( !('responses' in client) || @@ -1078,8 +1085,8 @@ export class OpenAIResponsesChatProvider implements ChatProvider { client.responses as { create(params: unknown, opts?: unknown): Promise; } - ).create(createParams, options?.signal ? { signal: options.signal } : undefined); - return new OpenAIResponsesStreamedMessage(response, this._stream); + ).create(finalCreateParams, options?.signal ? { signal: options.signal } : undefined); + return new OpenAIResponsesStreamedMessage(response, stream); } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index a7d7b1e9af..64a5ac0a9d 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -338,6 +338,7 @@ function buildProtocolProviderOptions( baseUrl: string | undefined, ): ProtocolProviderOptions | undefined { const options: MutableProtocolProviderOptions = {}; + if (provider?.customBody !== undefined) options.customBody = provider.customBody; switch (protocol) { case 'anthropic': diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index 13d7f9a409..a8d443ce9b 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -38,6 +38,7 @@ export interface ProtocolProviderOptions { readonly kimiThinking?: boolean; readonly betaApi?: boolean; readonly metadata?: Readonly>; + readonly customBody?: Readonly>; readonly vertexai?: boolean; readonly project?: string; readonly location?: string; diff --git a/packages/agent-core-v2/src/app/provider/configSection.ts b/packages/agent-core-v2/src/app/provider/configSection.ts index 38ab6349de..ddb288a284 100644 --- a/packages/agent-core-v2/src/app/provider/configSection.ts +++ b/packages/agent-core-v2/src/app/provider/configSection.ts @@ -61,7 +61,11 @@ function providerEntryFromToml(data: Record): Record; const StringRecordSchema = z.record(z.string(), z.string()); +export type JSONValue = string | number | boolean | null | JSONValue[] | JSONObject; +export interface JSONObject { + [key: string]: JSONValue; +} + +const JSONValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(JSONValueSchema), + z.record(z.string(), JSONValueSchema), + ]), +); +function hasUnsafeCustomBodyKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasUnsafeCustomBodyKey); + if (typeof value !== 'object' || value === null) return false; + return Object.entries(value).some( + ([key, entryValue]) => + key === '__proto__' || + key === 'prototype' || + key === 'constructor' || + hasUnsafeCustomBodyKey(entryValue), + ); +} + +const CustomBodySchema: z.ZodType = z + .unknown() + .superRefine((value, ctx) => { + if (hasUnsafeCustomBodyKey(value)) { + ctx.addIssue({ code: 'custom', message: 'customBody cannot contain unsafe object keys' }); + } + }) + .pipe(z.record(z.string(), JSONValueSchema)); + export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); export type ModelSource = z.infer; @@ -56,6 +92,7 @@ export const ProviderConfigSchema = z.object({ baseUrl: z.string().optional(), customHeaders: StringRecordSchema.optional(), + customBody: CustomBodySchema.optional(), defaultModel: z.string().optional(), type: ProviderTypeSchema.optional(), diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/custom-body.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/custom-body.test.ts new file mode 100644 index 0000000000..a7105b48de --- /dev/null +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/custom-body.test.ts @@ -0,0 +1,376 @@ +/** + * Scenario: provider custom-body patches preserve user-requested request semantics. + * Exercises real provider adapters with only their SDK client boundary stubbed. + * Run: pnpm exec vitest run packages/agent-core-v2/test/app/llmProtocol/providers/custom-body.test.ts + */ + +import { describe, expect, it } from 'vitest'; + +import type { Message } from '#/app/llmProtocol/message'; +import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; +import { GoogleGenAIChatProvider } from '#/app/llmProtocol/providers/google-genai'; +import { KimiChatProvider } from '#/app/llmProtocol/providers/kimi'; +import { OpenAILegacyChatProvider } from '#/app/llmProtocol/providers/openai-legacy'; +import { OpenAIResponsesChatProvider } from '#/app/llmProtocol/providers/openai-responses'; +import { + applyCustomBody, + resolveCustomBodyStream, + type CustomBody, +} from '#/app/llmProtocol/providers/custom-body'; + +const HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'generated input' }], toolCalls: [] }, +]; + +const CHAT_PATCH: CustomBody = { + model: 'configured-model', + stream: false, + messages: [], + input: [], + tools: null, + nested: { enabled: false, retries: 0, empty: '', nullable: null, items: ['replacement'] }, +}; + +function chatCompletionResponse(): Record { + return { + id: 'chatcmpl-test', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + }; +} + +function anthropicResponse(): Record { + return { + id: 'msg-test', + content: [], + stop_reason: 'end_turn', + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + +async function collect(message: AsyncIterable): Promise { + const parts: unknown[] = []; + for await (const part of message) { + parts.push(part); + } + return parts; +} + +async function* chatCompletionStream(): AsyncIterable> { + yield { + id: 'chatcmpl-stream-test', + choices: [{ delta: { content: 'streamed' }, finish_reason: 'stop' }], + }; +} + +async function* anthropicStream(): AsyncIterable> { + yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'streamed' } }; +} + +describe('applyCustomBody', () => { + it('deep-merges object fields, replaces arrays and scalar values, and clones both inputs', () => { + const generated = { + model: 'generated-model', + nested: { generated: { value: 1 }, replaced: ['generated'] }, + }; + const customBody: CustomBody = { + model: 'configured-model', + nested: { + generated: { configured: true }, + replaced: ['configured'], + falseValue: false, + zeroValue: 0, + emptyValue: '', + nullValue: null, + }, + }; + + const result = applyCustomBody(generated, customBody); + + expect(result).toEqual({ + model: 'configured-model', + nested: { + generated: { value: 1, configured: true }, + replaced: ['configured'], + falseValue: false, + zeroValue: 0, + emptyValue: '', + nullValue: null, + }, + }); + + (result['nested'] as Record)['replaced'] = ['mutated']; + expect(generated.nested.replaced).toEqual(['generated']); + expect(customBody['nested']).toMatchObject({ replaced: ['configured'] }); + }); + + it('returns a detached clone when customBody is absent', () => { + const generated = { contents: [{ role: 'user', parts: [{ text: 'generated' }] }] }; + const result = applyCustomBody(generated, undefined); + + ((result['contents'] as Array>)[0]!)['role'] = 'model'; + expect(generated.contents[0]?.role).toBe('user'); + }); + + it('preserves an own __proto__ patch key without changing the result prototype', () => { + const customBody = JSON.parse('{"__proto__":{"enabled":true}}') as CustomBody; + const result = applyCustomBody({}, customBody); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.hasOwn(result, '__proto__')).toBe(true); + expect(JSON.stringify(result)).toBe('{"__proto__":{"enabled":true}}'); + }); +}); + +describe('resolveCustomBodyStream', () => { + it('uses a boolean custom-body stream value instead of the provider default', () => { + expect(resolveCustomBodyStream({ stream: true }, false)).toBe(true); + expect(resolveCustomBodyStream({ stream: false }, true)).toBe(false); + }); +}); + +describe('v2 provider custom-body serialization', () => { + it('returns non-stream text when customBody disables OpenAI Chat Completions streaming', async () => { + let body: Record | undefined; + const provider = new OpenAILegacyChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: CHAT_PATCH, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(chatCompletionResponse()); + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(CHAT_PATCH['nested']); + expect(body).not.toHaveProperty('stream_options'); + expect(parts).toContainEqual({ type: 'text', text: 'ok' }); + }); + + it('sends the patched OpenAI Responses request when customBody disables streaming', async () => { + let body: Record | undefined; + const provider = new OpenAIResponsesChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: CHAT_PATCH, + clientFactory: () => + ({ + responses: { + create(params: unknown) { + body = params as Record; + return Promise.resolve({ id: 'resp-test', output: [], status: 'completed' }); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, input: [], tools: null }); + expect(body?.['nested']).toEqual(CHAT_PATCH['nested']); + }); + + it('returns non-stream text when customBody disables Kimi Chat Completions streaming', async () => { + let body: Record | undefined; + const provider = new KimiChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: CHAT_PATCH, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return { + withResponse: () => + Promise.resolve({ data: chatCompletionResponse(), response: new Response() }), + }; + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(CHAT_PATCH['nested']); + expect(body).not.toHaveProperty('stream_options'); + expect(parts).toContainEqual({ type: 'text', text: 'ok' }); + }); + + it('sends the patched Anthropic Messages request when customBody disables streaming', async () => { + let body: Record | undefined; + const provider = new AnthropicChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: CHAT_PATCH, + clientFactory: () => + ({ + messages: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(anthropicResponse()); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(CHAT_PATCH['nested']); + }); + + it('selects non-stream Google GenAI generation when customBody disables streaming', async () => { + let body: Record | undefined; + const customBody: CustomBody = { + model: 'configured-model', + stream: false, + contents: [], + config: { + systemInstruction: 'configured system', + tools: null, + nested: { enabled: false, values: ['replacement'], nullable: null }, + }, + }; + const provider = new GoogleGenAIChatProvider({ + model: 'generated-model', + apiKey: '', + customBody, + clientFactory: () => + ({ + models: { + generateContent(params: unknown) { + body = params as Record; + return Promise.resolve({ candidates: [] }); + }, + generateContentStream() { + throw new Error('expected non-stream generation'); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', contents: [] }); + expect(body?.['config']).toEqual(customBody['config']); + expect(body).not.toHaveProperty('stream'); + }); + + it('enables OpenAI Chat Completions streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new OpenAILegacyChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return chatCompletionStream(); + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true, stream_options: { include_usage: true } }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('enables Kimi Chat Completions streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new KimiChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return { + withResponse: () => + Promise.resolve({ data: chatCompletionStream(), response: new Response() }), + }; + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true, stream_options: { include_usage: true } }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('enables Anthropic Messages streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new AnthropicChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + messages: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(anthropicStream()); + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('selects Google GenAI streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new GoogleGenAIChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + models: { + generateContent() { + throw new Error('expected streaming generation'); + }, + generateContentStream(params: unknown) { + body = params as Record; + return Promise.resolve(chatCompletionStream()); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).not.toHaveProperty('stream'); + }); +}); diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index 790f715c34..436e18d2ef 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -528,6 +528,21 @@ describe('ModelResolverService', () => { }); describe('provider options', () => { + it('passes provider customBody through to the protocol adapter', async () => { + const customBody = { nested: { enabled: false, retries: 0 }, tools: null }; + providers['p'] = { + type: 'openai', + baseUrl: 'https://example.test/v1', + apiKey: 'sk', + customBody, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ providerOptions: { customBody } }); + }); + it('passes an OpenAI reasoningKey through to the protocol adapter', async () => { providers['p'] = { type: 'openai', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { diff --git a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts index 6865bacb85..692f1e193f 100644 --- a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts @@ -20,6 +20,19 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_defaultHeaders')).toEqual({ 'X-Test': '1' }); }); + it('maps customBody through to transport providers', () => { + const customBody = { nested: { enabled: false }, tools: null }; + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'openai', + baseUrl: 'https://example.test/v1', + modelName: 'wire-name', + apiKey: 'sk', + providerOptions: { customBody }, + }); + + expect(Reflect.get(provider, '_customBody')).toEqual(customBody); + }); + it('maps providerOptions into OpenAI provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'openai', diff --git a/packages/agent-core-v2/test/app/provider/provider.test.ts b/packages/agent-core-v2/test/app/provider/provider.test.ts index 98d3f301c4..2a7c17f1dd 100644 --- a/packages/agent-core-v2/test/app/provider/provider.test.ts +++ b/packages/agent-core-v2/test/app/provider/provider.test.ts @@ -19,6 +19,7 @@ import { import { ENV_MODEL_PROVIDER_KEY, IProviderService, + ProviderConfigSchema, type ProviderConfig, PROVIDERS_SECTION, } from '#/app/provider/provider'; @@ -196,6 +197,12 @@ describe('provider config section helpers', () => { }); }); + it('rejects unsafe keys in provider customBody', () => { + const customBody = JSON.parse('{"__proto__":{"enabled":true}}'); + + expect(ProviderConfigSchema.safeParse({ customBody }).success).toBe(false); + }); + it('maps provider entries from TOML snake_case to camelCase', () => { expect( providersFromToml({ @@ -204,6 +211,7 @@ describe('provider config section helpers', () => { api_key: 'sk', base_url: 'https://api.example.com/v1', custom_headers: { 'X-Test': '1' }, + custom_body: { nested: { enabled: false }, values: [0, ''] }, oauth: { storage: 'file', key: 'token', oauth_host: 'https://auth.example.com' }, }, }), @@ -213,6 +221,7 @@ describe('provider config section helpers', () => { apiKey: 'sk', baseUrl: 'https://api.example.com/v1', customHeaders: { 'X-Test': '1' }, + customBody: { nested: { enabled: false }, values: [0, ''] }, oauth: { storage: 'file', key: 'token', oauthHost: 'https://auth.example.com' }, }, }); @@ -227,6 +236,7 @@ describe('provider config section helpers', () => { apiKey: 'sk', baseUrl: 'https://api.example.com/v1', customHeaders: { 'X-Test': '1' }, + customBody: { nested: { enabled: false }, values: [0, ''] }, oauth: { storage: 'file', key: 'token', oauthHost: 'https://auth.example.com' }, }, }, @@ -238,6 +248,7 @@ describe('provider config section helpers', () => { api_key: 'sk', base_url: 'https://api.example.com/v1', custom_headers: { 'X-Test': '1' }, + custom_body: { nested: { enabled: false }, values: [0, ''] }, oauth: { storage: 'file', key: 'token', oauth_host: 'https://auth.example.com' }, }, }); diff --git a/packages/agent-core/src/config/merge.ts b/packages/agent-core/src/config/merge.ts index ce4880b960..9c54360168 100644 --- a/packages/agent-core/src/config/merge.ts +++ b/packages/agent-core/src/config/merge.ts @@ -24,6 +24,15 @@ function parsePatch(patch: KimiConfigPatch): KimiConfigPatch { } } +function setOwn(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + function deepMerge( target: Record, source: Record, @@ -33,9 +42,9 @@ function deepMerge( if (sourceValue === undefined) continue; const targetValue = result[key]; if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { - result[key] = deepMerge(targetValue, sourceValue); + setOwn(result, key, deepMerge(targetValue, sourceValue)); } else { - result[key] = sourceValue; + setOwn(result, key, sourceValue); } } return result; @@ -51,7 +60,7 @@ function stripUndefinedDeep(value: unknown): unknown { const out: Record = {}; for (const [key, entryValue] of Object.entries(value)) { if (entryValue !== undefined) { - out[key] = stripUndefinedDeep(entryValue); + setOwn(out, key, stripUndefinedDeep(entryValue)); } } return out; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..a2e68ab098 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -24,6 +24,42 @@ export type OAuthRef = z.infer; const StringRecordSchema = z.record(z.string(), z.string()); +type JSONValue = string | number | boolean | null | JSONValue[] | JSONObject; +interface JSONObject { + [key: string]: JSONValue; +} + +const JSONValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(JSONValueSchema), + z.record(z.string(), JSONValueSchema), + ]), +); +function hasUnsafeCustomBodyKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasUnsafeCustomBodyKey); + if (typeof value !== 'object' || value === null) return false; + return Object.entries(value).some( + ([key, entryValue]) => + key === '__proto__' || + key === 'prototype' || + key === 'constructor' || + hasUnsafeCustomBodyKey(entryValue), + ); +} + +const CustomBodySchema: z.ZodType = z + .unknown() + .superRefine((value, ctx) => { + if (hasUnsafeCustomBodyKey(value)) { + ctx.addIssue({ code: 'custom', message: 'customBody cannot contain unsafe object keys' }); + } + }) + .pipe(z.record(z.string(), JSONValueSchema)); + export const ProviderConfigSchema = z.object({ type: ProviderTypeSchema, apiKey: z.string().optional(), @@ -32,6 +68,7 @@ export const ProviderConfigSchema = z.object({ oauth: OAuthRefSchema.optional(), env: StringRecordSchema.optional(), customHeaders: StringRecordSchema.optional(), + customBody: CustomBodySchema.optional(), source: z.record(z.string(), z.unknown()).optional(), }); diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..cd1395e72c 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -355,7 +355,7 @@ function transformProviderData(data: Record): Record = {}; for (const [key, value] of Object.entries(obj)) { - result[snakeToCamel(key)] = convertKeysSnakeToCamel(value); + const targetKey = snakeToCamel(key); + result[targetKey] = targetKey === 'customBody' ? value : convertKeysSnakeToCamel(value); } return result; } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 4a4bdcdc72..11c63469b5 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -273,6 +273,7 @@ function toKosongProviderConfig( ? baseUrl.replace(/\/v1\/?$/, '') : baseUrl, apiKey: providerApiKey(provider), + customBody: provider.customBody, ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), supportEfforts, ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), @@ -301,6 +302,7 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), + customBody: provider.customBody, reasoningKey, ...defaultHeadersField({ ...envCustomHeaders, @@ -314,6 +316,7 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), + customBody: provider.customBody, generationKwargs: { prompt_cache_key: promptCacheKey }, ...defaultHeadersField({ ...envCustomHeaders, @@ -327,6 +330,7 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'GOOGLE_GEMINI_BASE_URL'), apiKey: providerApiKey(provider), + customBody: provider.customBody, ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), @@ -339,6 +343,7 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), + customBody: provider.customBody, ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), @@ -359,6 +364,7 @@ function toKosongProviderConfig( vertexai: useServiceAccount, baseUrl, apiKey: useServiceAccount ? undefined : providerApiKey(provider), + customBody: provider.customBody, project: vertexAIProject(provider), location: vertexAILocation(provider, baseUrl), ...defaultHeadersField({ diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 71f7f8d003..bc5de52188 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -3,9 +3,10 @@ import { readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ErrorCodes, KimiError } from '../../src/errors'; +import { ConfigService } from '../../src/services/config/configService'; import { KimiConfigSchema, applyPrintModeConfigDefaults, @@ -50,6 +51,39 @@ function expectKimiErrorCode(fn: () => unknown, code: string): void { throw new Error('expected function to throw'); } +it('preserves provider-native custom_body keys when updating config through the service', async () => { + const setKimiConfig = vi.fn(async () => ({ + providers: { + gateway: { + type: 'openai' as const, + customBody: { service_tier: 'priority', nested: { cache_control: 'strict' } }, + }, + }, + })); + const service = new ConfigService( + { rpc: { setKimiConfig } } as never, + { publish: vi.fn() } as never, + ); + + await service.set({ + providers: { + gateway: { + type: 'openai', + custom_body: { service_tier: 'priority', nested: { cache_control: 'strict' } }, + }, + }, + }); + + expect(setKimiConfig).toHaveBeenCalledWith({ + providers: { + gateway: { + type: 'openai', + customBody: { service_tier: 'priority', nested: { cache_control: 'strict' } }, + }, + }, + }); +}); + const COMPLETE_TOML = ` default_model = "kimi-code/kimi-for-coding" default_permission_mode = "auto" @@ -258,6 +292,45 @@ source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey = }); }); + it('validates and round-trips provider custom_body JSON objects', async () => { + const dir = makeTempDir(); + const configPath = join(dir, 'custom-body.toml'); + const config = parseConfigString(` +[providers.gateway] +type = "openai" +custom_body = { model = "configured-model", enabled = false, retries = 0, empty = "", nested = { mode = "strict", values = [true, 1, "two"] } } +`, configPath); + + expect(config.providers['gateway']?.customBody).toEqual({ + model: 'configured-model', + enabled: false, + retries: 0, + empty: '', + nested: { mode: 'strict', values: [true, 1, 'two'] }, + }); + + await writeConfigFile(configPath, config); + const roundTripped = parseConfigString(await readFile(configPath, 'utf-8'), configPath); + expect(roundTripped.providers['gateway']?.customBody).toEqual( + config.providers['gateway']?.customBody, + ); + + expect( + KimiConfigSchema.parse({ + providers: { memory: { type: 'openai', customBody: { nested: { nullValue: null } } } }, + }).providers['memory']?.customBody, + ).toEqual({ nested: { nullValue: null } }); + expect( + KimiConfigSchema.safeParse({ providers: { invalid: { type: 'openai', customBody: [] } } }) + .success, + ).toBe(false); + expect( + KimiConfigSchema.safeParse({ + providers: { invalid: { type: 'openai', customBody: { invalid: Number.NaN } } }, + }).success, + ).toBe(false); + }); + it('round-trips OAuth refs with scoped OAuth hosts', async () => { const dir = makeTempDir(); const configPath = join(dir, 'oauth-ref.toml'); @@ -535,6 +608,16 @@ describe('harness config schema and patch merge', () => { expect(merged.raw?.['theme']).toBe('dark'); }); + it('rejects unsafe keys in customBody patches', () => { + const customBody = JSON.parse('{"__proto__":{"enabled":true}}') as Record; + + expect( + KimiConfigSchema.safeParse({ + providers: { gateway: { type: 'openai', customBody: customBody as never } }, + }).success, + ).toBe(false); + }); + it('deep-merges experimental config patches', () => { const base = parseConfigString(` [experimental] diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index b16547a807..486af09b6a 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -70,6 +70,25 @@ describe('resolveRuntimeProvider model metadata', () => { expect(resolved.provider.model).toBe('kimi-for-coding'); }); + it('forwards provider customBody to the Kosong provider config', () => { + const customBody = { nested: { enabled: false, retries: 0 }, tools: null }; + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + 'managed:kimi-code': { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example/v1', + customBody, + }, + }, + }, + }); + + expect(resolved.provider).toMatchObject({ customBody }); + }); + it('resolves requested aliases to the configured provider and provider model', () => { const resolved = resolveRuntimeProvider({ config: { diff --git a/packages/kap-server/src/routes/config.ts b/packages/kap-server/src/routes/config.ts index c39347f076..e07b4258d8 100644 --- a/packages/kap-server/src/routes/config.ts +++ b/packages/kap-server/src/routes/config.ts @@ -195,7 +195,8 @@ function convertKeysSnakeToCamel(obj: unknown): unknown { if (isPlainObject(obj)) { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - result[snakeToCamel(key)] = convertKeysSnakeToCamel(value); + const targetKey = snakeToCamel(key); + result[targetKey] = targetKey === 'customBody' ? value : convertKeysSnakeToCamel(value); } return result; } diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 2ccced1839..f92163ca1b 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { IConfigService } from '@moonshot-ai/agent-core-v2'; import { configResponseSchema, type ConfigResponse } from '../src/protocol/rest-config'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -96,4 +97,39 @@ describe('server-v2 /api/v1/config default_permission_mode + yolo', () => { expect(after.default_permission_mode).toBe('auto'); expect(after.yolo).toBe(false); }); + + it('preserves provider-native custom_body keys when patching config', async () => { + await boot(); + const customBody = { service_tier: 'priority', nested: { cache_control: 'strict' } }; + await patchConfig({ + providers: { + gateway: { + type: 'openai', + custom_body: customBody, + }, + }, + }); + + const config = server!.core.accessor.get(IConfigService); + await config.ready; + const providers = config.get('providers') as Record }>; + expect(providers['gateway']?.customBody).toMatchObject({ + service_tier: 'priority', + nested: { cache_control: 'strict' }, + }); + }); + + it('rejects unsafe custom_body keys', async () => { + await boot(); + const customBody = JSON.parse('{"__proto__":{"enabled":true}}'); + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ providers: { gateway: { type: 'openai', custom_body: customBody } } }), + }); + const body = (await res.json()) as Envelope; + + expect(res.status).toBe(200); + expect(body.code).toBe(50001); + }); }); diff --git a/packages/klient/src/contract/global/providers.ts b/packages/klient/src/contract/global/providers.ts index 72f158de0d..d6abd83bbe 100644 --- a/packages/klient/src/contract/global/providers.ts +++ b/packages/klient/src/contract/global/providers.ts @@ -25,6 +25,42 @@ const oAuthRefSchema = z.object({ const stringRecordSchema = z.record(z.string(), z.string()); +type JSONValue = string | number | boolean | null | JSONValue[] | JSONObject; +interface JSONObject { + [key: string]: JSONValue; +} + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); +function hasUnsafeCustomBodyKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasUnsafeCustomBodyKey); + if (typeof value !== 'object' || value === null) return false; + return Object.entries(value).some( + ([key, entryValue]) => + key === '__proto__' || + key === 'prototype' || + key === 'constructor' || + hasUnsafeCustomBodyKey(entryValue), + ); +} + +const customBodySchema: z.ZodType = z + .unknown() + .superRefine((value, ctx) => { + if (hasUnsafeCustomBodyKey(value)) { + ctx.addIssue({ code: 'custom', message: 'customBody cannot contain unsafe object keys' }); + } + }) + .pipe(z.record(z.string(), jsonValueSchema)); + const modelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); export const providerConfigSchema = z.object({ @@ -33,6 +69,7 @@ export const providerConfigSchema = z.object({ baseUrl: z.string().optional(), customHeaders: stringRecordSchema.optional(), + customBody: customBodySchema.optional(), defaultModel: z.string().optional(), type: providerTypeSchema.optional(), diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 2dc3559564..38fada3264 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -121,6 +121,30 @@ describe('contract validation', () => { expect(channel.calls).toHaveLength(0); }); + it('rejects unsafe customBody keys before the provider call leaves the client', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const customBody = JSON.parse('{"metadata":{"__proto__":{"enabled":true}}}'); + + await expect( + klient.global.providers.set({ name: 'gateway', config: { customBody } }), + ).rejects.toBeInstanceOf(KlientValidationError); + expect(channel.calls).toHaveLength(0); + }); + + it('rejects non-finite customBody numbers before the provider call leaves the client', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + + await expect( + klient.global.providers.set({ + name: 'gateway', + config: { customBody: { retryAfter: Number.POSITIVE_INFINITY } }, + }), + ).rejects.toBeInstanceOf(KlientValidationError); + expect(channel.calls).toHaveLength(0); + }); + it('rejects drifted output payloads', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index a2d8d8d737..5500218a6d 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -93,9 +93,22 @@ export function defineKlientConformance( const name = '__klient_conformance__'; try { - await target.klient.global.providers.set({ name, config: { apiKey: 'conf-key' } }); + await target.klient.global.providers.set({ + name, + config: { + apiKey: 'conf-key', + customBody: { service_tier: 'priority', nested: { cache_control: 'strict' } }, + }, + }); const got = await target.klient.global.providers.get(name); expect(got?.apiKey).toBe('conf-key'); + expect(got?.customBody).toEqual({ + service_tier: 'priority', + nested: { cache_control: 'strict' }, + }); + expect((await target.klient.global.providers.list())[name]?.customBody).toEqual( + got?.customBody, + ); await waitFor( () => events.some((event) => [...event.added, ...event.changed].includes(name)), diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 43e4d7ca81..fa4bdfa2ad 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -50,6 +50,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, @@ -94,6 +95,7 @@ export interface AnthropicOptions { defaultMaxTokens?: number | undefined; betaFeatures?: string[] | undefined; defaultHeaders?: Record; + customBody?: CustomBody; metadata?: Record | undefined; /** Use streaming API. Defaults to true. Set to false for non-streaming (test/fallback). */ stream?: boolean | undefined; @@ -892,6 +894,7 @@ export class AnthropicChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; private readonly _supportEfforts: readonly string[] | undefined; @@ -911,6 +914,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; @@ -1070,6 +1074,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 = {}; const headers = mergeRequestHeaders(extraHeaders, options?.auth?.headers); if (headers !== undefined) { @@ -1082,18 +1089,18 @@ export class AnthropicChatProvider implements ChatProvider { const client = this._createClient(options?.auth); options?.onRequestSent?.(); - if (this._stream) { + if (stream) { // Use the raw Messages stream instead of the SDK MessageStream helper. // The helper reparses accumulated input_json_delta buffers on every chunk, // which becomes synchronous O(n^2) work for large streamed tool arguments. 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); @@ -1106,11 +1113,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); diff --git a/packages/kosong/src/providers/custom-body.ts b/packages/kosong/src/providers/custom-body.ts new file mode 100644 index 0000000000..9b46381aa5 --- /dev/null +++ b/packages/kosong/src/providers/custom-body.ts @@ -0,0 +1,72 @@ +export interface JSONObject { + readonly [key: string]: JSONValue; +} + +export type JSONValue = string | number | boolean | null | readonly JSONValue[] | JSONObject; + +export type CustomBody = JSONObject; + +type MutableObject = Record; + +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>, + 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); +} diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 88a26e6e0e..4f95a3b3bb 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -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'; @@ -90,6 +96,7 @@ export interface GoogleGenAIOptions { location?: string | undefined; stream?: boolean | undefined; defaultHeaders?: Record; + customBody?: CustomBody; clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; } @@ -745,6 +752,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { private _project: string | undefined; private _location: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; constructor(options: GoogleGenAIOptions) { @@ -760,6 +768,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; @@ -864,14 +873,16 @@ export class GoogleGenAIChatProvider implements ChatProvider { generateContentStream(params: Record): Promise; }; - 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); // The Google GenAI SDK does not accept an AbortSignal, so we must race // the initial SDK request against the caller's abort signal ourselves. // Once we have a response/stream object, the wrapper below continues to // check the signal at each chunk boundary. options?.onRequestSent?.(); - if (this._stream) { + if (effectiveStream) { const stream = await Promise.race([ models.generateContentStream(params), abortPromise(options?.signal), diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 0bd6a14ebb..73388cfe6b 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -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 | undefined; + customBody?: CustomBody; generationKwargs?: GenerationKwargs | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -408,6 +410,7 @@ export class KimiChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _generationKwargs: GenerationKwargs; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; @@ -418,6 +421,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; @@ -534,9 +538,12 @@ export class KimiChatProvider implements ChatProvider { createParams['tools'] = tools.map((t) => convertTool(t)); } - 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); @@ -548,7 +555,7 @@ export class KimiChatProvider implements ChatProvider { // even for a stream the caller later cancels mid-flight. 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(); @@ -556,7 +563,7 @@ export class KimiChatProvider implements ChatProvider { data as unknown as | OpenAI.Chat.ChatCompletion | AsyncIterable, - this._stream, + stream, parseTraceId(response.headers), ); } catch (error: unknown) { diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 02c5e5f1af..fada0bd127 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -41,6 +41,7 @@ import { sanitizeToolCallId, type ToolCallIdPolicy, } from './tool-call-id'; +import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body'; // Inbound: scan in priority order; first string value wins. Outbound: the first // entry doubles as the default field we serialize ThinkPart back into. Both @@ -97,6 +98,7 @@ export interface OpenAILegacyOptions { reasoningKey?: string | undefined; httpClient?: unknown; defaultHeaders?: Record; + customBody?: CustomBody; toolMessageConversion?: ToolMessageConversion | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -481,6 +483,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _reasoningKey: string | undefined; private _thinkingEffort: ThinkingEffort | undefined; private _generationKwargs: OpenAILegacyGenerationKwargs; @@ -494,6 +497,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; this._defaultHeaders = options.defaultHeaders; + this._customBody = options.customBody; this._model = options.model; this._stream = options.stream ?? true; // Normalize blank/whitespace reasoningKey to unset. ModelAliasSchema @@ -606,22 +610,25 @@ export class OpenAILegacyChatProvider implements ChatProvider { createParams['tools'] = tools.map((t) => toolToOpenAI(t)); } - if (this._stream) { + const stream = resolveCustomBodyStream(this._customBody, createParams['stream'] === true); + createParams['stream'] = stream; + if (stream) { createParams['stream_options'] = { include_usage: true }; } if (reasoningEffort !== undefined) { createParams['reasoning_effort'] = reasoningEffort; } + const finalCreateParams = applyCustomBody(createParams, this._customBody); try { const client = this._createClient(options?.auth); options?.onRequestSent?.(); const 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, )) as unknown as OpenAI.Chat.ChatCompletion | AsyncIterable; - return new OpenAILegacyStreamedMessage(response, this._stream, this._reasoningKey); + return new OpenAILegacyStreamedMessage(response, stream, this._reasoningKey); } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index baf8666abe..b41f6c9c3f 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -37,6 +37,7 @@ import { sanitizeOpenAIResponsesCallId, type ToolCallIdPolicy, } from './tool-call-id'; +import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from './custom-body'; /** * Normalize the Responses API status / incomplete_details into the unified @@ -346,6 +347,7 @@ export interface OpenAIResponsesOptions { maxOutputTokens?: number | undefined; httpClient?: unknown; defaultHeaders?: Record; + customBody?: CustomBody; toolMessageConversion?: ToolMessageConversion | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -1024,6 +1026,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _apiKey: string | undefined; private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; + private _customBody: CustomBody | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -1035,6 +1038,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; this._defaultHeaders = options.defaultHeaders; + this._customBody = options.customBody; this._model = options.model; this._stream = true; // Responses API always supports streaming this._generationKwargs = {}; @@ -1122,6 +1126,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { ...responseFormatToResponsesText(options.responseFormat), }; } + const stream = resolveCustomBodyStream(this._customBody, createParams['stream'] === true); + createParams['stream'] = stream; + const finalCreateParams = applyCustomBody(createParams, this._customBody); if ( !('responses' in client) || @@ -1137,8 +1144,8 @@ export class OpenAIResponsesChatProvider implements ChatProvider { client.responses as { create(params: unknown, opts?: unknown): Promise; } - ).create(createParams, options?.signal ? { signal: options.signal } : undefined); - return new OpenAIResponsesStreamedMessage(response, this._stream); + ).create(finalCreateParams, options?.signal ? { signal: options.signal } : undefined); + return new OpenAIResponsesStreamedMessage(response, stream); } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/kosong/test/custom-body.test.ts b/packages/kosong/test/custom-body.test.ts new file mode 100644 index 0000000000..93b5c558b6 --- /dev/null +++ b/packages/kosong/test/custom-body.test.ts @@ -0,0 +1,372 @@ +/** + * Scenario: provider custom-body patches preserve user-requested request semantics. + * Exercises real provider adapters with only their SDK client boundary stubbed. + * Run: pnpm exec vitest run packages/kosong/test/custom-body.test.ts + */ + +import { describe, expect, it } from 'vitest'; + +import type { Message } from '#/message'; +import { AnthropicChatProvider } from '#/providers/anthropic'; +import { GoogleGenAIChatProvider } from '#/providers/google-genai'; +import { KimiChatProvider } from '#/providers/kimi'; +import { OpenAILegacyChatProvider } from '#/providers/openai-legacy'; +import { OpenAIResponsesChatProvider } from '#/providers/openai-responses'; +import { applyCustomBody, resolveCustomBodyStream, type CustomBody } from '#/providers/custom-body'; + +const PATCH: CustomBody = { + model: 'configured-model', + stream: false, + messages: [], + input: [], + contents: [], + tools: null, + nested: { + enabled: false, + retries: 0, + empty: '', + nullable: null, + items: ['replacement'], + }, +}; + +const HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'generated input' }], toolCalls: [] }, +]; + +function chatCompletionResponse(): Record { + return { + id: 'chatcmpl-test', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + }; +} + +function anthropicResponse(): Record { + return { + id: 'msg-test', + content: [], + stop_reason: 'end_turn', + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + +async function collect(message: AsyncIterable): Promise { + const parts: unknown[] = []; + for await (const part of message) { + parts.push(part); + } + return parts; +} + +async function* chatCompletionStream(): AsyncIterable> { + yield { + id: 'chatcmpl-stream-test', + choices: [{ delta: { content: 'streamed' }, finish_reason: 'stop' }], + }; +} + +async function* anthropicStream(): AsyncIterable> { + yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'streamed' } }; +} + +describe('applyCustomBody', () => { + it('deep-merges objects, replaces arrays and scalars, and does not retain mutable input references', () => { + const generated = { + nested: { keep: { generated: true }, replace: ['generated'] }, + generatedOnly: { value: 1 }, + }; + const customBody: CustomBody = { + nested: { + keep: { configured: true }, + replace: ['configured'], + falseValue: false, + zeroValue: 0, + emptyValue: '', + nullValue: null, + }, + }; + + const result = applyCustomBody(generated, customBody); + + expect(result).toEqual({ + nested: { + keep: { generated: true, configured: true }, + replace: ['configured'], + falseValue: false, + zeroValue: 0, + emptyValue: '', + nullValue: null, + }, + generatedOnly: { value: 1 }, + }); + + (result['nested'] as Record)['replace'] = ['changed']; + ((result['generatedOnly'] as Record)['value']) = 2; + expect(generated).toEqual({ + nested: { keep: { generated: true }, replace: ['generated'] }, + generatedOnly: { value: 1 }, + }); + expect(customBody['nested']).toMatchObject({ replace: ['configured'] }); + }); + + it('preserves the generated body when the patch is absent while still cloning it', () => { + const generated = { nested: { value: 1 } }; + const result = applyCustomBody(generated, undefined); + + (result['nested'] as Record)['value'] = 2; + expect(generated).toEqual({ nested: { value: 1 } }); + }); + + it('preserves an own __proto__ patch key without changing the result prototype', () => { + const customBody = JSON.parse('{"__proto__":{"enabled":true}}') as CustomBody; + const result = applyCustomBody({}, customBody); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.hasOwn(result, '__proto__')).toBe(true); + expect(JSON.stringify(result)).toBe('{"__proto__":{"enabled":true}}'); + }); +}); + +describe('resolveCustomBodyStream', () => { + it('uses a boolean custom-body stream value instead of the provider default', () => { + expect(resolveCustomBodyStream({ stream: true }, false)).toBe(true); + expect(resolveCustomBodyStream({ stream: false }, true)).toBe(false); + }); +}); + +describe('provider custom_body serialization', () => { + it('returns non-stream text when customBody disables OpenAI Chat Completions streaming', async () => { + let body: Record | undefined; + const provider = new OpenAILegacyChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: PATCH, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(chatCompletionResponse()); + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(PATCH['nested']); + expect(body).not.toHaveProperty('stream_options'); + expect(parts).toContainEqual({ type: 'text', text: 'ok' }); + }); + + it('returns non-stream text when customBody disables Kimi Chat Completions streaming', async () => { + let body: Record | undefined; + const provider = new KimiChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: PATCH, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return { + withResponse: () => + Promise.resolve({ data: chatCompletionResponse(), response: new Response() }), + }; + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(PATCH['nested']); + expect(body).not.toHaveProperty('stream_options'); + expect(parts).toContainEqual({ type: 'text', text: 'ok' }); + }); + + it('sends the patched OpenAI Responses request when customBody disables streaming', async () => { + let body: Record | undefined; + const provider = new OpenAIResponsesChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: PATCH, + clientFactory: () => + ({ + responses: { + create(params: unknown) { + body = params as Record; + return Promise.resolve({ id: 'resp-test', output: [], status: 'completed' }); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, input: [], tools: null }); + expect(body?.['nested']).toEqual(PATCH['nested']); + }); + + it('sends the patched Anthropic Messages request when customBody disables streaming', async () => { + let body: Record | undefined; + const provider = new AnthropicChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: PATCH, + clientFactory: () => + ({ + messages: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(anthropicResponse()); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', stream: false, messages: [], tools: null }); + expect(body?.['nested']).toEqual(PATCH['nested']); + }); + + it('selects non-stream Google GenAI generation when customBody disables streaming', async () => { + let body: Record | undefined; + const provider = new GoogleGenAIChatProvider({ + model: 'generated-model', + apiKey: '', + customBody: PATCH, + clientFactory: () => + ({ + models: { + generateContent(params: unknown) { + body = params as Record; + return Promise.resolve({ candidates: [] }); + }, + generateContentStream() { + throw new Error('expected non-stream generation'); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ model: 'configured-model', contents: [], tools: null }); + expect(body?.['nested']).toEqual(PATCH['nested']); + expect(body).not.toHaveProperty('stream'); + }); + + it('enables OpenAI Chat Completions streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new OpenAILegacyChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return chatCompletionStream(); + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true, stream_options: { include_usage: true } }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('enables Kimi Chat Completions streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new KimiChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + chat: { + completions: { + create(params: unknown) { + body = params as Record; + return { + withResponse: () => + Promise.resolve({ data: chatCompletionStream(), response: new Response() }), + }; + }, + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true, stream_options: { include_usage: true } }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('enables Anthropic Messages streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new AnthropicChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + messages: { + create(params: unknown) { + body = params as Record; + return Promise.resolve(anthropicStream()); + }, + }, + }) as never, + }); + + const parts = await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).toMatchObject({ stream: true }); + expect(parts).toContainEqual({ type: 'text', text: 'streamed' }); + }); + + it('selects Google GenAI streaming when customBody overrides a non-stream provider', async () => { + let body: Record | undefined; + const provider = new GoogleGenAIChatProvider({ + model: 'generated-model', + apiKey: '', + stream: false, + customBody: { stream: true }, + clientFactory: () => + ({ + models: { + generateContent() { + throw new Error('expected streaming generation'); + }, + generateContentStream(params: unknown) { + body = params as Record; + return Promise.resolve(chatCompletionStream()); + }, + }, + }) as never, + }); + + await collect(await provider.generate('generated system', [], HISTORY)); + + expect(body).not.toHaveProperty('stream'); + }); +});