diff --git a/.changeset/custom-provider-cache-breakpoint.md b/.changeset/custom-provider-cache-breakpoint.md new file mode 100644 index 000000000000..595f50025526 --- /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 3875cf80d505..0aca8bea29f4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -368,8 +368,33 @@ 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 { + // Config placeholders ({env:...}, {file:...}) and SDK-style ${...} variables + // are expanded before the request is sent, so an unexpanded first-party + // base URL must not be rejected as unparseable. + return /\{(?:env|file):[^}]+\}|\$\{[^}]+\}/.test(override) + } + if (model.providerID === "openai") return host === "openai.com" || host.endsWith(".openai.com") + if (model.providerID === "azure" || model.providerID === "azure-cognitive-services") + return [".azure.com", ".azure.us", ".azure.cn", ".azure-api.net"].some((s) => host.endsWith(s)) + if (model.providerID === "kilo") return host === "api.kilo.ai" || host.endsWith(".kilo.ai") + 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", "azure-cognitive-services", "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]) @@ -382,7 +407,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) @@ -406,7 +431,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsPromptCacheBreakpoint(model) + ...(supportsPromptCacheBreakpoint(model, options) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -537,11 +562,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 e90d60f98ffd..6dd78ab849b4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -326,6 +326,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 bac385c59137..e800351a0681 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 247ab0bd2ce0..78682039561d 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("opencode") ? { diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 7113ec6bf7e5..0abd4b948cce 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -76,7 +76,7 @@ describe("opencode run (non-interactive subprocess)", () => { timeoutMs: 15_000, }) expect(result.exitCode).not.toBe(0) - expect(result.durationMs).toBeLessThan(15_000) + expect(result.durationMs).toBeLessThan(20_000) // kilocode_change }), 30_000, ) 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 000000000000..a2d51eb18ae1 --- /dev/null +++ b/packages/opencode/test/kilocode/provider/transform-cache-breakpoint.test.ts @@ -0,0 +1,226 @@ +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() + }) + + test("azure-cognitive-services endpoint still applies promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "azure-cognitive-services", + 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-cognitive-services endpoint on a non-azure host does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "azure-cognitive-services", + 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() + }) + + test("kilo provider with the official api.kilo.ai base URL still applies promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "kilo", + api: { + id: "gpt-5.6", + url: "https://api.kilo.ai", + npm: "@ai-sdk/openai", + }, + }) + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "https://api.kilo.ai", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + }) + + test("kilo provider with a custom base URL does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "kilo", + api: { + id: "gpt-5.6", + url: "", + npm: "@ai-sdk/openai", + }, + }) + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "https://proxy.example.com/kilo/v1", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("openai provider with an unexpanded {env:} base URL still applies promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "{env:OPENAI_BASE_URL}", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + }) + + test("openai provider with an unexpanded ${} base URL still applies promptCacheBreakpoint", () => { + const model = createModel() + + const result = ProviderTransform.message(msgs(), model, { + providerEndpointOverride: "${OPENAI_BASE_URL}", + }) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toEqual({ mode: "explicit" }) + }) +})