Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/custom-provider-cache-breakpoint.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 26 additions & 5 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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<string, unknown> = {}): 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
Comment thread
maphew marked this conversation as resolved.
if (!isFirstPartyBreakpointEndpoint(model, options)) return false
const match = model.api.id.match(/gpt-(\d+)\.(\d+)/)
if (match) {
const major = Number(match[1])
Expand All @@ -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<string, unknown> = {}): ModelMessage[] { // kilocode_change
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)

Expand All @@ -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" },
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type StreamInput = {
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: Record<string, any>
readonly messageTransformOptions?: Record<string, any> // kilocode_change - endpoint-override-aware transform context
readonly headers: Record<string, string>
readonly abort: AbortSignal
}
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any> = {}) =>
({
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()
})
})
Loading