Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -171,7 +175,9 @@ export namespace ProviderTransform {
return msgs
}

function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
function applyCaching(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>): 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)

Expand Down Expand Up @@ -211,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<string, unknown>) {
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
Expand Down Expand Up @@ -252,6 +284,7 @@ export namespace ProviderTransform {
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
msgs = oauth(msgs, model, options)
if (
(model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
Expand All @@ -261,7 +294,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
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -233,7 +237,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
},
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,64 @@ 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()
})

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", () => {
Expand Down
125 changes: 125 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,131 @@ 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
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;",
)
},
})
})

test("sends Google API payload for Gemini models", async () => {
const server = state.server
if (!server) {
Expand Down
Loading