From 2a7c4c897c9939e0bb728007eaaa3d9b41ad63f6 Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Thu, 19 Mar 2026 20:03:35 -0400 Subject: [PATCH 1/2] fix: skip Anthropic cache control for OAuth --- packages/opencode/src/provider/transform.ts | 6 +- packages/opencode/src/session/llm.ts | 5 +- .../opencode/test/provider/transform.test.ts | 26 ++++ packages/opencode/test/session/llm.test.ts | 120 ++++++++++++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031fe6..3f16c95936b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -171,7 +171,9 @@ export namespace ProviderTransform { return msgs } - function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + function applyCaching(msgs: ModelMessage[], model: Provider.Model, options: Record): ModelMessage[] { + if (model.providerID === "anthropic" && options.authType === "oauth") return msgs + const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -261,7 +263,7 @@ export namespace ProviderTransform { model.api.npm === "@ai-sdk/anthropic") && model.api.npm !== "@ai-sdk/gateway" ) { - msgs = applyCaching(msgs, model) + msgs = applyCaching(msgs, model, options) } // Remap providerOptions keys from stored providerID to expected SDK key diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index daf70180e52..6d796b877b6 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -233,7 +233,10 @@ export namespace LLM { async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, { + ...options, + authType: auth?.type, + }) } return args.params }, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 917d357eafa..58a845b840f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1660,6 +1660,32 @@ describe("ProviderTransform.message - cache control on gateway", () => { }, }) }) + + test("anthropic oauth skips ephemeral cache control", () => { + const model = createModel({ + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { authType: "oauth" }) as any[] + + expect(result[0].content[0]?.providerOptions).toBeUndefined() + expect(result[0].providerOptions).toBeUndefined() + }) }) describe("ProviderTransform.variants", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 5202c06dd93..3cbfa37b8ff 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -654,6 +654,126 @@ describe("session.llm.stream", () => { }) }) + test("skips cache control in Anthropic messages when auth is oauth", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "anthropic" + const modelID = "claude-3-5-sonnet-20241022" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const chunks = [ + { + type: "message_start", + message: { + id: "msg-oauth-1", + model: model.id, + usage: { + input_tokens: 3, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { + input_tokens: 3, + output_tokens: 2, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await Filesystem.writeJson(path.join(Global.Path.data, "auth.json"), { + anthropic: { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-3-oauth") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-3-oauth"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + expect(JSON.stringify(capture.body)).not.toContain("\"cache_control\"") + }, + }) + }) + test("sends Google API payload for Gemini models", async () => { const server = state.server if (!server) { From 0941035d0d50489d04fca2c7084fadf32a301cb4 Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Fri, 20 Mar 2026 20:52:24 -0400 Subject: [PATCH 2/2] fix: add Anthropic OAuth billing metadata --- packages/opencode/src/provider/transform.ts | 31 ++++++++++++++++++ packages/opencode/src/session/llm.ts | 4 +++ .../opencode/test/provider/transform.test.ts | 32 +++++++++++++++++++ packages/opencode/test/session/llm.test.ts | 5 +++ 4 files changed, 72 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3f16c95936b..03e0fcb20b1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,5 @@ import type { ModelMessage } from "ai" +import { createHash } from "crypto" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" @@ -19,6 +20,9 @@ function mimeToModality(mime: string): Modality | undefined { export namespace ProviderTransform { export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + export const CLAUDE_CODE_VERSION = "2.1.76" + export const CLAUDE_CODE_USER_AGENT = `claude-code/${CLAUDE_CODE_VERSION}` + const CLAUDE_CODE_SALT = "59cf53e54c78" // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { @@ -213,6 +217,32 @@ export namespace ProviderTransform { return msgs } + function billing(msgs: ModelMessage[]) { + const msg = msgs.find((msg) => msg.role === "user") + const text = (() => { + if (!msg) return "" + if (typeof msg.content === "string") return msg.content + return msg.content.find((part) => part.type === "text")?.text ?? "" + })() + const code = [4, 7, 20].map((idx) => text.charAt(idx) || "0").join("") + const hash = createHash("sha256") + .update(CLAUDE_CODE_SALT + code + CLAUDE_CODE_VERSION) + .digest("hex") + .slice(0, 3) + return `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${hash}; cc_entrypoint=cli; cch=00000;` + } + + function oauth(msgs: ModelMessage[], model: Provider.Model, options: Record) { + if (model.providerID !== "anthropic" || options.authType !== "oauth") return msgs + return [ + { + role: "system" as const, + content: billing(msgs), + }, + ...msgs, + ] + } + function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { return msgs.map((msg) => { if (msg.role !== "user" || !Array.isArray(msg.content)) return msg @@ -254,6 +284,7 @@ export namespace ProviderTransform { export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + msgs = oauth(msgs, model, options) if ( (model.providerID === "anthropic" || model.api.id.includes("anthropic") || diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 6d796b877b6..57a087780fa 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -213,6 +213,10 @@ export namespace LLM { "x-opencode-request": input.user.id, "x-opencode-client": Flag.OPENCODE_CLIENT, }), + ...(input.model.providerID === "anthropic" && + auth?.type === "oauth" && { + "User-Agent": ProviderTransform.CLAUDE_CODE_USER_AGENT, + }), ...input.model.headers, ...headers, }, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 58a845b840f..00cf883083b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1686,6 +1686,38 @@ describe("ProviderTransform.message - cache control on gateway", () => { expect(result[0].content[0]?.providerOptions).toBeUndefined() expect(result[0].providerOptions).toBeUndefined() }) + + test("anthropic oauth prepends Claude Code billing system text", () => { + const model = createModel({ + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "hey", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { authType: "oauth" }) as any[] + + expect(result[0]).toEqual({ + role: "system", + content: "x-anthropic-billing-header: cc_version=2.1.76.4dc; cc_entrypoint=cli; cch=00000;", + }) + expect(result[1]).toEqual({ + role: "system", + content: "You are a helpful assistant", + }) + }) }) describe("ProviderTransform.variants", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3cbfa37b8ff..30ae14229b6 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -768,8 +768,13 @@ describe("session.llm.stream", () => { } const capture = await request + const headers = capture.headers expect(capture.url.pathname.endsWith("/messages")).toBe(true) + expect(headers.get("User-Agent")).toContain("claude-code/2.1.76") expect(JSON.stringify(capture.body)).not.toContain("\"cache_control\"") + expect(JSON.stringify(capture.body)).toContain( + "x-anthropic-billing-header: cc_version=2.1.76.02c; cc_entrypoint=cli; cch=00000;", + ) }, }) })