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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/prompt-cache-key-official-openai-only.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -60,6 +63,7 @@ import {
extractUsage,
hasModelPrefix,
isFunctionToolCall,
isOfficialOpenAIBaseUrl,
isOpenAIReasoningModel,
normalizeOpenAIFinishReason,
OPENAI_REASONING_CAPABILITY,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
convertOpenAIError,
hasModelPrefix,
isMediaPart,
isOfficialOpenAIBaseUrl,
isOpenAIReasoningModel,
OPENAI_REASONING_CAPABILITY,
OPENAI_VISION_TOOL_CAPABILITY,
Expand Down Expand Up @@ -1060,7 +1062,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
let kwargs: Record<string, unknown> = { ...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) {
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
51 changes: 40 additions & 11 deletions packages/agent-core/src/session/provider-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string> | undefined,
Expand Down
69 changes: 69 additions & 0 deletions packages/agent-core/test/harness/runtime-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> })
.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({
Expand Down