diff --git a/.changeset/prompt-cache-key-official-openai-only.md b/.changeset/prompt-cache-key-official-openai-only.md new file mode 100644 index 0000000000..0b0f429fb2 --- /dev/null +++ b/.changeset/prompt-cache-key-official-openai-only.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop sending the OpenAI prompt cache key to custom OpenAI-compatible endpoints, which reject the unknown field with a 400 error; the key is still sent to the official OpenAI API. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts index 68eed46d31..511e3ff793 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts @@ -3,7 +3,13 @@ * * Everything the Chat Completions and Responses bases share: content-part and * tool conversion, usage extraction, finish-reason normalization, the - * capability constants, and the error converter. + * capability constants, the error converter, and the official-endpoint gate + * for the OpenAI-only `prompt_cache_key` request field + * (`isOfficialOpenAIBaseUrl`): the bases only fall back to that field when + * the effective base URL targets `api.openai.com` or a regional + * `*.api.openai.com` variant (or is unset, which the client defaults there), + * because strictly-validating OpenAI-compatible endpoints reject unknown + * parameters with a 400. * * `convertOpenAIError`'s FIRST line is the contract's `throwIfAbortError` * guard: a user cancellation (SDK `APIUserAbortError`, bare `AbortError`, the @@ -262,3 +268,15 @@ export function isOpenAIReasoningModel(normalizedModelName: string): boolean { export function hasModelPrefix(modelName: string, prefixes: readonly string[]): boolean { return prefixes.some((prefix) => modelName.startsWith(prefix)); } + +export function isOfficialOpenAIBaseUrl(baseUrl: string | undefined): boolean { + if (baseUrl === undefined) { + return true; + } + try { + const hostname = new URL(baseUrl).hostname; + return hostname === 'api.openai.com' || hostname.endsWith('.api.openai.com'); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 7c7215005a..d790949796 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -9,6 +9,9 @@ * * Per-turn intent assembly (`_resolveRequestKwargs`) applies overlays in the * fixed contract order: cacheKey → sampling → thinking → maxCompletionTokens. + * The bare `prompt_cache_key` cacheKey fallback is gated by + * `isOfficialOpenAIBaseUrl`; vendors that support the field on other hosts + * encode it through their `cacheKey` hook. * The context-window clamp on the completion budget (floor 1) runs BEFORE any * hook and cannot be skipped; the 128k ceiling clamp can be taken over by the * `withMaxCompletionTokens` hook. @@ -60,6 +63,7 @@ import { extractUsage, hasModelPrefix, isFunctionToolCall, + isOfficialOpenAIBaseUrl, isOpenAIReasoningModel, normalizeOpenAIFinishReason, OPENAI_REASONING_CAPABILITY, @@ -704,7 +708,11 @@ export class OpenAILegacyChatProvider implements ChatProvider { if (options?.cacheKey !== undefined) { const hooked = this._hooks?.cacheKey?.(options.cacheKey); - kwargs = { ...kwargs, ...(hooked ?? { prompt_cache_key: options.cacheKey }) }; + if (hooked !== undefined) { + kwargs = { ...kwargs, ...hooked }; + } else if (isOfficialOpenAIBaseUrl(this._baseUrl)) { + kwargs = { ...kwargs, prompt_cache_key: options.cacheKey }; + } } if (options?.sampling?.temperature !== undefined) { diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index dfb482f2dd..c7b3dd22b5 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -4,9 +4,10 @@ * Speaks the Responses wire format: `input` items, `instructions`, * `reasoning` blocks with encrypted content, and the native * `prompt_cache_key` field (a cache key is encoded directly — no hook - * needed). This base carries no hook surface today; per-turn intents are - * encoded inline in the fixed contract order. The developer-role model - * detection lives here. + * needed — and only for official `api.openai.com` hosts, via + * `isOfficialOpenAIBaseUrl`). This base carries no hook surface today; + * per-turn intents are encoded inline in the fixed contract order. The + * developer-role model detection lives here. */ import OpenAI from 'openai'; @@ -41,6 +42,7 @@ import { convertOpenAIError, hasModelPrefix, isMediaPart, + isOfficialOpenAIBaseUrl, isOpenAIReasoningModel, OPENAI_REASONING_CAPABILITY, OPENAI_VISION_TOOL_CAPABILITY, @@ -1060,7 +1062,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { let kwargs: Record = { ...this._generationKwargs }; // Per-turn intent overlays in the fixed contract order. - if (options?.cacheKey !== undefined) { + if (options?.cacheKey !== undefined && isOfficialOpenAIBaseUrl(this._baseUrl)) { kwargs = { ...kwargs, prompt_cache_key: options.cacheKey }; } if (options?.sampling?.temperature !== undefined) { diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 331281ef5a..67b92a648d 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -700,6 +700,33 @@ describe('per-turn intent wire encoding (behavior probes)', () => { expect(body['prompt_cache_key']).toBe('session-probe'); }); + it('omits prompt_cache_key on OpenAI-compatible custom endpoints (chat completions + responses)', async () => { + const legacy = registry.createChatProvider({ + protocol: 'openai', + modelName: 'gpt-4o', + apiKey: 'sk-probe', + baseUrl: 'https://openai-compatible.example.test/v1', + }); + const legacyBody = await captureOpenAIBody(legacy, { cacheKey: 'session-probe' }); + expect(legacyBody).not.toHaveProperty('prompt_cache_key'); + + const responses = new OpenAIResponsesChatProvider({ + model: 'gpt-4.1', + apiKey: 'sk-probe', + baseUrl: 'https://openai-compatible.example.test/v1', + }); + const responsesBody = await captureResponsesBody(responses, { cacheKey: 'session-probe' }); + expect(responsesBody).not.toHaveProperty('prompt_cache_key'); + }); + + it('keeps prompt_cache_key on the official OpenAI Responses endpoint', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe' }); + + const body = await captureResponsesBody(provider, { cacheKey: 'session-probe' }); + + expect(body['prompt_cache_key']).toBe('session-probe'); + }); + it('encodes cacheKey on Anthropic as metadata.user_id', async () => { const provider = registry.createChatProvider({ protocol: 'anthropic', diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index e8028c76c1..7a3ae6a0ee 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -303,28 +303,34 @@ function toKosongProviderConfig( ), }; } - case 'openai': + case 'openai': { + // A per-model endpoint (catalog gateway override) wins over the + // provider-level base URL, same as the Anthropic branch. + const baseUrl = + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'); return { type: 'openai', model, - // A per-model endpoint (catalog gateway override) wins over the - // provider-level base URL, same as the Anthropic branch. - baseUrl: - modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + baseUrl, apiKey: providerApiKey(provider), reasoningKey, offEffort, // Session affinity: route every request of this session through the // same provider-side prompt cache (the OpenAI analog of Anthropic // `metadata.user_id` above). Undefined values are stripped at - // generate time, matching the `kimi` branch below. - generationKwargs: { prompt_cache_key: promptCacheKey }, + // generate time, matching the `kimi` branch below. Official-endpoint + // only: strictly-validating OpenAI-compatible servers reject the + // unknown `prompt_cache_key` field with a 400 (#2166). + generationKwargs: { + prompt_cache_key: isOfficialOpenAIBaseUrl(baseUrl) ? promptCacheKey : undefined, + }, ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders, }), }; + } case 'kimi': return { type: 'kimi', @@ -351,23 +357,27 @@ function toKosongProviderConfig( ...provider.customHeaders, }), }; - case 'openai_responses': + case 'openai_responses': { + const baseUrl = + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'); return { type: 'openai_responses', model, - baseUrl: - modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + baseUrl, apiKey: providerApiKey(provider), offEffort, // Session affinity: same `prompt_cache_key` intent as the `openai` // branch; the Responses API accepts it as a top-level request field. - generationKwargs: { prompt_cache_key: promptCacheKey }, + generationKwargs: { + prompt_cache_key: isOfficialOpenAIBaseUrl(baseUrl) ? promptCacheKey : undefined, + }, ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders, }), }; + } case 'vertexai': { // Resolve the effective endpoint once (config `base_url` or the // GOOGLE_VERTEX_BASE_URL env fallback) and use it for BOTH forwarding and @@ -467,6 +477,25 @@ function vertexAILocation( return envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); } +/** + * `prompt_cache_key` is an official-OpenAI request field: strictly-validating + * OpenAI-compatible endpoints reject unknown parameters with a 400, so session + * cache affinity is only requested when the effective base URL targets + * `api.openai.com` or a regional variant like `eu.api.openai.com` (or is + * unset, which the transport defaults there). + */ +function isOfficialOpenAIBaseUrl(baseUrl: string | undefined): boolean { + if (baseUrl === undefined) { + return true; + } + try { + const hostname = new URL(baseUrl).hostname; + return hostname === 'api.openai.com' || hostname.endsWith('.api.openai.com'); + } catch { + return false; + } +} + function providerValue( configured: string | undefined, env: Record | undefined, diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 1d20903234..a06e13c100 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -968,6 +968,75 @@ describe('ProviderManager prompt cache key', () => { } }); + it('omits the prompt cache key for OpenAI providers on custom base URLs', () => { + for (const type of ['openai', 'openai_responses'] as const) { + const manager = new ProviderManager({ + promptCacheKey: 'session-test', + config: { + defaultModel: 'gpt-alias', + providers: { + openai: { + type, + apiKey: 'sk-compat', + baseUrl: 'https://openai-compatible.example.test/v1', + }, + }, + models: { + 'gpt-alias': { + provider: 'openai', + model: 'gpt-runtime', + maxContextSize: 200000, + }, + }, + }, + }); + const resolved = manager.resolveProviderConfig('gpt-alias'); + + // Strictly-validating OpenAI-compatible endpoints reject the unknown + // `prompt_cache_key` field with a 400, so it must stay official-only. + const kwargs = (resolved.provider as { generationKwargs?: Record }) + .generationKwargs; + expect(kwargs?.['prompt_cache_key']).toBeUndefined(); + } + }); + + it('keeps the prompt cache key when the base URL targets the official OpenAI API', () => { + for (const [type, baseUrl] of [ + ['openai', 'https://api.openai.com/v1'], + ['openai_responses', 'https://api.openai.com/v1'], + ['openai', 'https://eu.api.openai.com/v1'], + ] as const) { + const manager = new ProviderManager({ + promptCacheKey: 'session-test', + config: { + defaultModel: 'gpt-alias', + providers: { + openai: { + type, + apiKey: 'sk-openai', + baseUrl, + }, + }, + models: { + 'gpt-alias': { + provider: 'openai', + model: 'gpt-runtime', + maxContextSize: 200000, + }, + }, + }, + }); + const resolved = manager.resolveProviderConfig('gpt-alias'); + + expect(resolved.provider).toMatchObject({ + type, + generationKwargs: { + prompt_cache_key: 'session-test', + }, + }); + } + }); + it('reads the current config when constructed with a function', () => { let sharedConfig: KimiConfig = { providers: {} }; const manager = new ProviderManager({