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 00000000000..0b0f429fb25 --- /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/.gitignore b/.gitignore index c6f2a483e42..f32ac39235d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,9 @@ coverage/ .claude .conductor .kimi-stash-dir -plugins/cdn/ +plugins/* +!plugins/official/ +!plugins/marketplace.json .worktrees/ .kimi-code/local.toml .kimi-sandbox/ 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 ea99d7fd7f5..4e14bcda277 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 @@ -260,3 +260,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 f2b36111cab..dd27566ff3f 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 @@ -34,6 +34,7 @@ import { extractUsage, hasModelPrefix, isFunctionToolCall, + isOfficialOpenAIBaseUrl, isOpenAIReasoningModel, normalizeOpenAIFinishReason, OPENAI_REASONING_CAPABILITY, @@ -651,7 +652,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 19808bc2f1e..b93b96dac50 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 @@ -33,6 +33,7 @@ import { convertOpenAIError, hasModelPrefix, isMediaPart, + isOfficialOpenAIBaseUrl, isOpenAIInsufficientQuotaCode, isOpenAIReasoningModel, OPENAI_REASONING_CAPABILITY, @@ -1079,7 +1080,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { let kwargs: Record = { ...this._generationKwargs }; - 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 0b98da9266e..fa910211ea5 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -669,6 +669,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 7fb313b4619..5f1b9e6351a 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -314,35 +314,42 @@ 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 }, + // `metadata.user_id` above). Only sent to official OpenAI API + // endpoints — strictly-validating OpenAI-compatible third-party + // endpoints (NVIDIA, Azure Foundry, etc.) reject unknown parameters (#2166). + ...(promptCacheKey !== undefined && isOfficialOpenAIBaseUrl(baseUrl) + ? { generationKwargs: { prompt_cache_key: promptCacheKey } } + : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders, }), }; + } case 'kimi': return { type: 'kimi', model, baseUrl: modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), - generationKwargs: { prompt_cache_key: promptCacheKey }, + ...(promptCacheKey !== undefined + ? { generationKwargs: { prompt_cache_key: promptCacheKey } } + : {}), ...defaultHeadersField({ ...envCustomHeaders, ...kimiRequestHeaders, @@ -362,23 +369,29 @@ 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 }, + // Only sent to official OpenAI API endpoints — third-party endpoints + // reject unknown parameters. + ...(promptCacheKey !== undefined && isOfficialOpenAIBaseUrl(baseUrl) + ? { generationKwargs: { prompt_cache_key: promptCacheKey } } + : {}), ...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 @@ -478,6 +491,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 5f26a2d599d..ed889737477 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -969,6 +969,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({