diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 545adb3f7cf..c883a3011d6 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -715,7 +715,10 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode const lowerOptions = (request: LLMRequest, supportsStore: boolean) => { const options = OpenAIOptions.resolve(request) - const cacheKey = ProviderShared.promptCacheKey(request) + // Default off: strict providers 400 on unknown body fields, so only send + // the key where compatibility explicitly allows it. Header-based affinity + // (x-session-affinity, x-grok-conv-id, ...) is unaffected. + const cacheKey = (request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined return { ...(supportsStore && options.store !== undefined ? { store: options.store } : {}), // For providers that support `store`, ensure stateless `store:false` is sent diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts index 1c438fa1b77..aa2ee472830 100644 --- a/packages/ai/src/providers/azure.ts +++ b/packages/ai/src/providers/azure.ts @@ -135,7 +135,7 @@ export const configure = (input: Config) => { const chat = (modelID: string | ModelID) => configuredRoute(chatRoute, input, modelID) .with(withOpenAIOptions(modelID, modelDefaults)) - .model({ id: modelID }) + .model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }) return { id, diff --git a/packages/ai/src/providers/cerebras.ts b/packages/ai/src/providers/cerebras.ts index f9a83f04548..456c3a0ee82 100644 --- a/packages/ai/src/providers/cerebras.ts +++ b/packages/ai/src/providers/cerebras.ts @@ -44,7 +44,7 @@ export const configure = (input: LanguageModelOptions = {}) => { model: (modelID: string | ModelID) => configured.model({ id: modelID, - compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false }, + compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false, supportsPromptCacheKey: true }, }), configure, } diff --git a/packages/ai/src/providers/deepinfra.ts b/packages/ai/src/providers/deepinfra.ts index 184b2554731..821a4a92128 100644 --- a/packages/ai/src/providers/deepinfra.ts +++ b/packages/ai/src/providers/deepinfra.ts @@ -47,7 +47,7 @@ export const configure = (input: LanguageModelOptions = {}) => { model: (modelID: string | ModelID) => configured.model({ id: modelID, - compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false }, + compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false, supportsPromptCacheKey: true }, }), configure, } diff --git a/packages/ai/src/providers/fireworks.ts b/packages/ai/src/providers/fireworks.ts index ff3c28720d6..68e4d98c17a 100644 --- a/packages/ai/src/providers/fireworks.ts +++ b/packages/ai/src/providers/fireworks.ts @@ -41,7 +41,8 @@ export const configure = (input: LanguageModelOptions = {}) => { }) return { id, - model: (modelID: string | ModelID) => configured.model({ id: modelID }), + model: (modelID: string | ModelID) => + configured.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }), configure, } } diff --git a/packages/ai/src/providers/meta.ts b/packages/ai/src/providers/meta.ts index 297de7a3452..693d511ce81 100644 --- a/packages/ai/src/providers/meta.ts +++ b/packages/ai/src/providers/meta.ts @@ -132,7 +132,7 @@ export const configure = (input: LanguageModelOptions = {}) => { const chat = (modelID: string | ModelID) => configuredChat.model({ id: modelID, - compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false }, + compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true }, }) const messages = (modelID: string | ModelID) => configuredMessages.model({ diff --git a/packages/ai/src/providers/moonshot.ts b/packages/ai/src/providers/moonshot.ts index df77c2129e6..99b3bbdcc02 100644 --- a/packages/ai/src/providers/moonshot.ts +++ b/packages/ai/src/providers/moonshot.ts @@ -112,6 +112,7 @@ export const configure = (input: Config = {}) => { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false, + supportsPromptCacheKey: true, toolSchema: "moonshot", reasoningField: "reasoning_content", }, diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index 613429fac35..0416f554a03 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -91,7 +91,10 @@ export const configure = (input: Config = {}) => { .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) .model({ id }) const chat = (id: string | ModelID) => - chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id }) + chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ + id, + compatibility: { supportsPromptCacheKey: true }, + }) const image = (modelID: string | ModelID) => OpenAIImages.model({ id: modelID, diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 765d8524d68..4bec70e3601 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -183,7 +183,8 @@ export const configure = (input: LanguageModelOptions = {}) => { const route = configuredRoute(input) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => + route.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }), configure, } } diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 15a30cb6d5b..736f741c20b 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -168,6 +168,9 @@ export class LanguageModelCompatibility extends Schema.Class { }), ) - it.effect("maps the request prompt cache key", () => + it.effect("maps the request prompt cache key when the compatibility flag is set", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") }) + .model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }), + prompt: "Hello", + promptCacheKey: "session_123", + }), + ) + + expect(prepared.body.prompt_cache_key).toBe("session_123") + }), + ) + + it.effect("omits the prompt cache key without the compatibility flag", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ @@ -261,7 +277,7 @@ describe("OpenAI Chat route", () => { }), ) - expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body).not.toHaveProperty("prompt_cache_key") }), ) @@ -280,7 +296,7 @@ describe("OpenAI Chat route", () => { }), ) - it.effect("maps the xAI Chat prompt cache key to conversation affinity", () => + it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () => LLMClient.generate( LLM.request({ model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"), @@ -294,7 +310,8 @@ describe("OpenAI Chat route", () => { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) expect(web.headers.get("x-grok-conv-id")).toBe("session_123") const body = decodeJson(yield* Effect.promise(() => web.text())) - expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123") + // Chat uses the header; prompt_cache_key is Responses-only. + expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined() return input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" }, }) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index d91e920fbe7..fd415abf83f 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -22,6 +22,7 @@ import { OpencodePlugin } from "./provider/opencode.js" import { OpenRouterPlugin } from "./provider/openrouter.js" import { PerplexityPlugin } from "./provider/perplexity.js" import { PoePlugin } from "./provider/poe.js" +import { PromptCacheKeyPlugin } from "./provider/prompt-cache-key.js" import { SapAICorePlugin } from "./provider/sap-ai-core.js" import { VercelPlugin } from "./provider/vercel.js" import { VenicePlugin } from "./provider/venice.js" @@ -54,6 +55,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ OpenRouterPlugin, PerplexityPlugin, PoePlugin, + PromptCacheKeyPlugin, SapAICorePlugin, VercelPlugin, VenicePlugin, diff --git a/packages/core/src/plugin/provider/prompt-cache-key.ts b/packages/core/src/plugin/provider/prompt-cache-key.ts new file mode 100644 index 00000000000..48046248f13 --- /dev/null +++ b/packages/core/src/plugin/provider/prompt-cache-key.ts @@ -0,0 +1,39 @@ +import { Effect } from "effect" +import { define } from "@opencode/plugin/effect/plugin" +import { Provider } from "../../provider.js" + +// Chat-completions `prompt_cache_key` body support for catalog providers +// without a dedicated AI package. Audited per provider (docs + OpenAPI + +// SDK request types); only unscoped body-key support is listed. Scoped-only +// support is left off because the boolean cannot express route scope: +// fastrouter (GPT-5.6 routes only), frogbot (Google/Vertex routes only). +const providerIDs = [ + "abliteration-ai", + "aihubmix", + "anyapi", + "auriko", + "edenai", + "jalapeno", + "llama", + "morph", + "snowflake-cortex", + "tencent-tokenhub", + "upstage", + "venice", +] as const + +export const PromptCacheKeyPlugin = define({ + id: "opencode.provider.prompt-cache-key", + effect: Effect.fn(function* (ctx) { + yield* ctx.model.transform((models) => { + for (const id of providerIDs) { + const providerID = Provider.ID.make(id) + for (const model of models.list(providerID)) { + models.update(model.providerID, model.id, (draft) => { + draft.compatibility = { ...draft.compatibility, supportsPromptCacheKey: true } + }) + } + } + }) + }), +}) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 9a0273be094..f28db2b0da2 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -62,7 +62,7 @@ const model = OpenAIChat.route auth: Auth.bearer(process.env.OPENAI_API_KEY ?? "fixture"), generation: { maxTokens: 20, temperature: 0 }, }) - .model({ id: "gpt-4o-mini" }) + .model({ id: "gpt-4o-mini", compatibility: { supportsPromptCacheKey: true } }) const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed( diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 5a7e6b687fc..49045ac6169 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -64,6 +64,7 @@ export const Compatibility = Schema.Struct({ maxTokensField: MaxTokensField.pipe(optional), requireFinishReason: Schema.Boolean.pipe(optional), requireAssistantAfterTool: Schema.Boolean.pipe(optional), + supportsPromptCacheKey: Schema.Boolean.pipe(optional), }).annotate({ identifier: "Model.Compatibility" }) export interface Capabilities extends Schema.Schema.Type {}