diff --git a/.changeset/custom-provider-cache-breakpoint.md b/.changeset/custom-provider-cache-breakpoint.md new file mode 100644 index 00000000000..595f5002552 --- /dev/null +++ b/.changeset/custom-provider-cache-breakpoint.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop sending prompt_cache_breakpoint to custom OpenAI-compatible providers, and to first-party provider IDs rerouted through custom endpoint overrides; both reject the parameter with HTTP 400. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 998cffcac3d..8791a2149ac 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -367,8 +367,29 @@ function isLikelyChatGPTSubscription(model: Provider.Model): boolean { return model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0 } -function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { +// Endpoint overrides (options.endpoint / options.baseURL) reroute a first-party +// provider ID through a proxy that may reject prompt_cache_breakpoint (#13285). +function isFirstPartyBreakpointEndpoint(model: Provider.Model, options: Record): boolean { + const override = options["providerEndpointOverride"] ?? options["endpoint"] ?? options["baseURL"] + if (typeof override !== "string" || override.length === 0) return true + let host: string + try { + host = new URL(override).hostname.toLowerCase() + } catch { + return false + } + if (model.providerID === "openai") return host === "openai.com" || host.endsWith(".openai.com") + if (model.providerID === "azure") + return [".azure.com", ".azure.us", ".azure.cn", ".azure-api.net"].some((s) => host.endsWith(s)) + return false +} + +function supportsPromptCacheBreakpoint(model: Provider.Model, options: Record = {}): boolean { if (isLikelyChatGPTSubscription(model)) return false + // Only first-party OpenAI-family deployments support explicit breakpoints; + // custom @ai-sdk/openai endpoints reject prompt_cache_breakpoint (#13285). + if (!["openai", "azure", "kilo"].includes(model.providerID)) return false + if (!isFirstPartyBreakpointEndpoint(model, options)) return false const match = model.api.id.match(/gpt-(\d+)\.(\d+)/) if (match) { const major = Number(match[1]) @@ -381,7 +402,7 @@ function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { } // kilocode_change end -function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { +function applyCaching(msgs: ModelMessage[], model: Provider.Model, options: Record = {}): ModelMessage[] { // kilocode_change const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -405,7 +426,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsPromptCacheBreakpoint(model) + ...(supportsPromptCacheBreakpoint(model, options) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -536,11 +557,11 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && - supportsPromptCacheBreakpoint(model))) && + supportsPromptCacheBreakpoint(model, options))) && model.api.npm !== "@ai-sdk/gateway" && !usesAnthropicAutomaticCaching ) { - msgs = applyCaching(msgs, model) + msgs = applyCaching(msgs, model, options) } // kilocode_change end diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index beb788e5715..4b6fd4eda59 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -320,6 +320,7 @@ const live: Layer.Layer< }), // kilocode_change end providerOptions: prepared.params.options, + messageTransformOptions: prepared.messageTransformOptions, // kilocode_change headers: prepared.headers, abort: input.abort, }) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c5913..e800351a068 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -39,6 +39,7 @@ type StreamInput = { readonly topK?: number readonly maxOutputTokens?: number readonly providerOptions?: Record + readonly messageTransformOptions?: Record // kilocode_change - endpoint-override-aware transform context readonly headers: Record readonly abort: AbortSignal } @@ -91,7 +92,7 @@ export function stream(input: StreamInput): StreamResult { model: input.model, apiKey: current.apiKey, baseURL: current.baseURL, - messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}), + messages: ProviderTransform.message(input.messages, input.model, input.messageTransformOptions ?? input.providerOptions ?? {}), // kilocode_change toolChoice: input.toolChoice, temperature: input.temperature, topP: input.topP, diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 91da911f23c..6e1de66c2e4 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -229,7 +229,15 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre messages, tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), params, - messageTransformOptions: options, + // kilocode_change start - surface provider-level endpoint overrides to message + // transforms without leaking them into the wire params (options is also params.options) + messageTransformOptions: { + ...options, + ...(typeof (input.provider.options?.endpoint ?? input.provider.options?.baseURL) === "string" + ? { providerEndpointOverride: input.provider.options?.endpoint ?? input.provider.options?.baseURL } + : {}), + }, + // kilocode_change end headers: { ...(input.model.providerID.startsWith("kilo") // kilocode_change ? { diff --git a/packages/opencode/test/kilocode/provider/transform-cache-breakpoint.test.ts b/packages/opencode/test/kilocode/provider/transform-cache-breakpoint.test.ts new file mode 100644 index 00000000000..47261124566 --- /dev/null +++ b/packages/opencode/test/kilocode/provider/transform-cache-breakpoint.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "@/provider/transform" + +describe("ProviderTransform.message - prompt cache breakpoint endpoint gating", () => { + const createModel = (overrides: Partial = {}) => + ({ + id: "gpt-5.6", + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + name: "GPT 5.6", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.00125, output: 0.01, cache: { read: 0.000125, write: 0 } }, + limit: { context: 400_000, output: 128_000 }, + status: "active", + options: {}, + headers: {}, + ...overrides, + }) as any + + const msgs = () => + [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + ] as any[] + + test("custom @ai-sdk/openai provider does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "custom", + api: { + id: "gpt-5.6-sol", + url: "https://redacted/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6-sol", + }) + + const result = ProviderTransform.message(msgs(), model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("openai provider with a custom endpoint override does not apply promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "https://proxy.example.com/v1", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("openai provider with a model-level baseURL override does not apply promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, { + baseURL: "https://gateway.internal:8443/openai/v1", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("openai provider with an unparseable override does not apply promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "not a url", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("first-party openai without overrides still applies promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + }) + + test("azure endpoint override on an azure host still applies promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "gpt-5.6", + url: "", + npm: "@ai-sdk/azure", + }, + }) + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "https://myresource.cognitiveservices.azure.com/openai/v1", + }) as any[] + + expect(result[0].providerOptions?.azure?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + expect(result[1].providerOptions?.azure?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + }) + + test("azure endpoint override on a non-azure host does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "gpt-5.6", + url: "", + npm: "@ai-sdk/azure", + }, + }) + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "https://proxy.example.com/azure/v1", + }) as any[] + + expect(result[0].providerOptions?.azure?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.azure?.promptCacheBreakpoint).toBeUndefined() + }) +})