diff --git a/.pr-assets/36517-test-results.png b/.pr-assets/36517-test-results.png new file mode 100644 index 000000000000..3d537b1c450d Binary files /dev/null and b/.pr-assets/36517-test-results.png differ diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 60f96dc69aaa..2c27036ca649 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -62,14 +62,17 @@ const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"] messages.findLastIndex((m) => m.role === role) // Mark the last text part of `messages[index]`. If no text part exists, mark -// the last content part regardless of type — that's the breakpoint position -// in tool-result-only messages too. +// the last non-reasoning content part — Bedrock rejects cache markers that +// sit immediately after a reasoning block, and tool-result-only messages still +// need a breakpoint on their last result part. const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { if (index < 0 || index >= messages.length) return messages const target = messages[index]! if (target.content.length === 0) return messages const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") - const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 + const lastSafeIndex = target.content.findLastIndex((part) => part.type !== "reasoning") + const markAt = lastTextIndex >= 0 ? lastTextIndex : lastSafeIndex + if (markAt < 0) return messages const existing = target.content[markAt]! if ("cache" in existing && existing.cache) return messages const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index c447a1a39d34..b9d50697dba4 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -348,6 +348,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( continue } if (part.type === "reasoning") { + // Never attach cachePoint after reasoning — Bedrock returns + // ValidationException: "Cache point cannot be inserted after reasoning block." content.push({ reasoningContent: { reasoningText: { text: part.text, signature: reasoningSignature(part) }, diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index a126d9502c5e..9015a374f85a 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -251,6 +251,55 @@ describe("applyCachePolicy", () => { }), ) + it.effect("'latest-assistant' anchors on text before trailing reasoning, not the reasoning block", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: bedrockModel, + messages: [ + Message.user("u1"), + Message.assistant([ + { type: "text", text: "visible" }, + { type: "reasoning", text: "hidden", encrypted: "sig" }, + ]), + ], + cache: { messages: "latest-assistant" }, + }), + ) + + const body = prepared.body as { messages: Array<{ role: string; content: Array> }> } + const assistant = body.messages.find((message) => message.role === "assistant") + expect(assistant?.content).toEqual([ + { text: "visible" }, + { cachePoint: { type: "default" } }, + { + reasoningContent: { + reasoningText: { text: "hidden", signature: "sig" }, + }, + }, + ]) + expect(assistant?.content.at(-1)).not.toHaveProperty("cachePoint") + }), + ) + + it.effect("skips cache markers for reasoning-only assistant messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: bedrockModel, + messages: [ + Message.user("u1"), + Message.assistant([{ type: "reasoning", text: "only thinking", encrypted: "sig" }]), + ], + cache: { messages: "latest-assistant" }, + }), + ) + + const flat = JSON.stringify(prepared.body) + expect(flat).not.toContain("cachePoint") + }), + ) + test("returns the same request reference when policy is a no-op (pure function)", () => { const request = LLM.request({ model: anthropicModel, diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 46657331a344..534d1f64d4da 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -452,6 +452,42 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("does not place cachePoint after a trailing reasoning block (#36517)", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_cache_reasoning", + model, + cache: "none", + messages: [ + Message.user("hi"), + Message.assistant([ + { type: "text", text: "reply", cache }, + { type: "reasoning", text: "thoughts", encrypted: "sig" }, + ]), + ], + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + const assistant = (prepared.body as { messages: Array<{ role: string; content: unknown[] }> }).messages.find( + (message) => message.role === "assistant", + ) + expect(assistant?.content).toEqual([ + { text: "reply" }, + { cachePoint: { type: "default" } }, + { + reasoningContent: { + reasoningText: { text: "thoughts", signature: "sig" }, + }, + }, + ]) + // Final block must not be a cachePoint (Bedrock ValidationException). + expect(assistant?.content.at(-1)).not.toHaveProperty("cachePoint") + }), + ) + it.effect("does not emit cachePoint when no cache hint is set", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(baseRequest) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 20c73d021671..67ca4486ad87 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -320,6 +320,33 @@ function normalizeMessages( return msgs } +function isBedrockModel(model: Provider.Model) { + return model.providerID.includes("bedrock") || model.api.npm === "@ai-sdk/amazon-bedrock" +} + +// Bedrock rejects cachePoint immediately after a reasoning block. The AI SDK +// emits message-level bedrock.cachePoint after every content part, so when an +// assistant message ends in reasoning we must anchor the breakpoint on the +// last non-reasoning part instead (or skip if the message is reasoning-only). +function applyBedrockCacheOptions(msg: ModelMessage, providerOptions: Record): "applied" | "skipped" { + if (msg.role !== "assistant" || !Array.isArray(msg.content) || msg.content.length === 0) return "skipped" + const last = msg.content[msg.content.length - 1] + if (!last || typeof last !== "object" || last.type !== "reasoning") return "skipped" + + for (let i = msg.content.length - 1; i >= 0; i--) { + const part = msg.content[i] + if (!part || typeof part !== "object" || part.type === "reasoning") continue + // Same shape as the content-level path below; cast keeps mergeDeep assignable + // to SharedV3ProviderOptions without widening the whole message transform. + ;(part as { providerOptions?: Record }).providerOptions = mergeDeep( + (part as { providerOptions?: Record }).providerOptions ?? {}, + providerOptions, + ) + return "applied" + } + return "applied" +} + function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -365,6 +392,8 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage } } + if (isBedrockModel(model) && applyBedrockCacheOptions(msg, providerOptions) === "applied") continue + msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e69dab70f65d..6bacd39d50e2 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2821,6 +2821,92 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI }) }) +describe("ProviderTransform.message - bedrock cachePoint after reasoning (#36517)", () => { + const bedrockModel = { + id: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-sonnet-4-20250514-v1:0", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Sonnet 4", + capabilities: {}, + options: {}, + headers: {}, + } as any + + test("anchors cachePoint on last non-reasoning part when assistant ends with reasoning", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "Visible reply" }, + { + type: "reasoning", + text: "internal thoughts", + providerOptions: { bedrock: { signature: "sig" } }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + const assistant = result.find((msg) => msg.role === "assistant") + expect(assistant?.providerOptions?.bedrock?.cachePoint).toBeUndefined() + expect(assistant?.content[0].providerOptions?.bedrock).toEqual({ + cachePoint: { type: "default" }, + }) + expect(assistant?.content[1].providerOptions?.bedrock?.cachePoint).toBeUndefined() + }) + + test("skips cachePoint when assistant content is reasoning-only", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "only thinking", + providerOptions: { bedrock: { signature: "sig" } }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + const assistant = result.find((msg) => msg.role === "assistant") + expect(assistant?.providerOptions?.bedrock?.cachePoint).toBeUndefined() + expect(assistant?.content[0].providerOptions?.bedrock?.cachePoint).toBeUndefined() + }) + + test("keeps message-level cachePoint when assistant does not end with reasoning", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "internal thoughts", + providerOptions: { bedrock: { signature: "sig" } }, + }, + { type: "text", text: "Visible reply" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + const assistant = result.find((msg) => msg.role === "assistant") + expect(assistant?.providerOptions?.bedrock).toEqual({ + cachePoint: { type: "default" }, + }) + expect(assistant?.content[1].providerOptions?.bedrock?.cachePoint).toBeUndefined() + }) +}) + describe("ProviderTransform.message - cache control on gateway", () => { const createModel = (overrides: Partial = {}) => ({