From 168864b5b04874eaca3cba2c5943ef3128b4788c Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 29 Jul 2026 00:50:03 -0700 Subject: [PATCH] feat(anthropic): native Claude Pro/Max subscription support Adds Claude Pro/Max as a first-class Anthropic integration method, so a subscription can be used without an API key and without an external plugin. Auth is an OAuth method (`claude-pro-max`, PKCE + code exchange + refresh), plus CLAUDE_CODE_OAUTH_TOKEN added to the integration's env method so a headless host can use `claude setup-token`. A setup token arrives in the key slot but is an OAuth token -- sent as x-api-key it 401s -- so isSubscription recognizes the sk-ant-oat prefix and routes it accordingly. No token plumbing is added: Integration.connection.resolve already refreshes and persists credentials, and ModelResolver already injects the resolved value. Why the seam is a route transport --------------------------------- A subscription only draws on the plan when the request presents as genuine Claude Code; otherwise it is silently billed as pay-as-you-go "extra usage". That requires Bearer auth, Claude Code's headers, a rewritten request body (Claude Code identity, canonical block, Claude Code tool casing) and the inverse tool-name mapping on the response. `aisdk.hook("sdk")` cannot do this: ModelResolver short-circuits the `@ai-sdk/anthropic` package to the native AnthropicMessages route, so that hook is never invoked for Anthropic (the existing one in AnthropicPlugin was already dead code on this path). The seam is therefore a wrapped route transport, selected by a guarded branch in ModelResolver -- the same place the ChatGPT-plan branch is selected. Shaping the decoded body leaves encoding with the protocol, and framing splits SSE into whole events before the reverse mapping runs, so it needs no boundary buffering. The branch is guarded on isSubscription: without a credential of that shape, behaviour is unchanged. Billing liability ----------------- normalizeEnv reproduces the block Anthropic fuzzy-matches to decide whether a request is really Claude Code. If opencode's prompt format drifts, the request still succeeds and just costs money -- a silent failure. isCanonical is the canary: it warns when normalization no longer produces the expected shape, and a golden fixture in the tests pins the format. Verified against a live Claude Pro/Max subscription: models are catalogued, a completion returns, and a tool-using turn round-trips (opencode `read` -> Claude Code `Read` on the wire -> `read` restored from the response frames). --- packages/core/src/model-resolver.ts | 17 + .../plugin/provider/anthropic-claude-code.ts | 329 ++++++++++++++++++ .../core/src/plugin/provider/anthropic.ts | 105 +++++- .../provider-anthropic-claude-code.test.ts | 217 ++++++++++++ 4 files changed, 660 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/plugin/provider/anthropic-claude-code.ts create mode 100644 packages/core/test/provider-anthropic-claude-code.test.ts diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 024ab8b048f8..7aabcbd9b762 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -17,6 +17,7 @@ import { Credential } from "./credential" import { Integration } from "./integration" import { Capabilities, ID, Info, Ref, VariantID } from "./model" import { Npm } from "@opencode-ai/util/npm" +import { AnthropicClaudeCode } from "./plugin/provider/anthropic-claude-code" import { OpenAICodex } from "./plugin/provider/openai-codex" import { Provider } from "./provider" @@ -165,6 +166,22 @@ export const fromCatalogModel = ( ) } if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { + // A Claude Pro/Max subscription authenticates as Bearer and only draws on + // the plan when the request presents as Claude Code, so it needs different + // headers and a shaped body. Same seam as the ChatGPT-plan branch above. + if (AnthropicClaudeCode.isSubscription(credential)) { + const shaped = produce(resolved, (draft) => { + draft.headers = Provider.mergeHeaders(draft.headers, AnthropicClaudeCode.headers(draft.headers)) + }) + return Effect.succeed( + withDefaults(shaped, AnthropicMessages.route) + .with({ + auth: key === undefined ? Auth.none : Auth.bearer(key), + transport: AnthropicClaudeCode.transport(AnthropicMessages.route.transport), + }) + .model({ id: shaped.modelID ?? shaped.id, compatibility: shaped.compatibility }), + ) + } return Effect.succeed( withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) diff --git a/packages/core/src/plugin/provider/anthropic-claude-code.ts b/packages/core/src/plugin/provider/anthropic-claude-code.ts new file mode 100644 index 000000000000..9c66bd36c9ab --- /dev/null +++ b/packages/core/src/plugin/provider/anthropic-claude-code.ts @@ -0,0 +1,329 @@ +export * as AnthropicClaudeCode from "./anthropic-claude-code" + +// Claude Pro/Max subscription support. +// +// Unlike an API key, a subscription token only draws from the plan when the +// request looks like one genuine Claude Code would send. Anthropic inspects the +// system prompt, the tool names and the headers; a request that authenticates +// correctly but *presents* differently is accepted and then billed as +// pay-as-you-go "extra usage" instead of against the subscription. So this +// module is two things at once: an OAuth method, and a wire-shaping middleware. +// +// Why middleware and not a ModelResolver branch like OpenAICodex: the ChatGPT +// plan only needs a different baseURL and auth, which route construction can +// express. This needs the request body rewritten *and* the streaming response +// rewritten back, which is inherently request/response middleware. The provider +// is nominally `aisdk:@ai-sdk/anthropic`, but ModelResolver short-circuits that +// package to the native AnthropicMessages route, so `aisdk.hook("sdk")` never +// runs for it. The seam is therefore a wrapped route transport, selected by a +// guarded branch in ModelResolver -- the same place OpenAICodex is selected. +// +// Token plumbing deliberately lives nowhere in here. Integration.connection +// .resolve refreshes and persists the credential, and ModelResolver injects the +// resolved value into the apiKey slot, which @ai-sdk/anthropic sends as +// `x-api-key`. The middleware simply moves that value to `Authorization: +// Bearer`. Platform owns the lifecycle; this file owns the disguise. + +import { createHash, randomBytes } from "node:crypto" +import type { TransportDef } from "@opencode-ai/ai/route" +import { Stream } from "effect" +import { Integration } from "../../integration" + +export const methodID = Integration.MethodID.make("claude-pro-max") +export const integrationID = Integration.ID.make("anthropic") + +const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +const authorizeEndpoint = "https://claude.ai/oauth/authorize" +const tokenEndpoint = "https://platform.claude.com/v1/oauth/token" +const redirectURI = "https://platform.claude.com/oauth/code/callback" +const scopes = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + +/** Claude Code's own UA and beta flags; the subscription path expects both. */ +export const userAgent = "claude-cli/2.1.81 (external, cli)" +export const betaFlags = + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05" + +/** + * A `claude setup-token` value. It lives in the key slot because that is the + * only place a headless host can paste one, but it is an OAuth token: sent as + * `x-api-key` it 401s, so it must take the subscription path. + */ +const setupTokenPrefix = "sk-ant-oat" + +/** Structural credential shape so core and plugin-facing types both fit. */ +type CredentialLike = { + readonly type: string + readonly methodID?: string + readonly key?: string +} + +export const isSubscription = (credential: CredentialLike | undefined) => { + if (!credential) return false + if (credential.type === "oauth") return credential.methodID === methodID + if (credential.type === "key") return credential.key?.startsWith(setupTokenPrefix) === true + return false +} + +// --------------------------------------------------------------------------- +// OAuth +// --------------------------------------------------------------------------- + +export type Tokens = { access: string; refresh: string; expires: number } + +const base64url = (buf: Buffer) => buf.toString("base64url").replace(/=+$/, "") + +export const pkce = () => { + const verifier = base64url(randomBytes(32)) + return { verifier, challenge: base64url(createHash("sha256").update(verifier).digest()) } +} + +export const authorizeURL = (challenge: string, state: string) => + `${authorizeEndpoint}?${new URLSearchParams({ + code: "true", + response_type: "code", + client_id: clientID, + redirect_uri: redirectURI, + scope: scopes, + code_challenge: challenge, + code_challenge_method: "S256", + state, + })}` + +/** The callback page renders `code#state`; accept either form. */ +export const parseCode = (raw: string) => { + const trimmed = raw.trim() + const hash = trimmed.indexOf("#") + return hash >= 0 ? trimmed.slice(0, hash) : trimmed +} + +async function token(body: URLSearchParams): Promise { + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent }, + body: body.toString(), + }) + if (!response.ok) throw new Error(`Claude OAuth failed with HTTP ${response.status}`) + const data = (await response.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + if (!data.access_token || !Number.isFinite(data.expires_in)) + throw new Error("Claude OAuth returned an invalid credential response") + return { + access: data.access_token, + refresh: data.refresh_token ?? "", + expires: Date.now() + data.expires_in! * 1000, + } +} + +export const exchange = (code: string, verifier: string) => + token( + new URLSearchParams({ + grant_type: "authorization_code", + code: parseCode(code), + code_verifier: verifier, + client_id: clientID, + redirect_uri: redirectURI, + state: verifier, + }), + ) + +export const refresh = async (refreshToken: string) => { + const tokens = await token( + new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientID }), + ) + return { ...tokens, refresh: tokens.refresh || refreshToken } +} + +// --------------------------------------------------------------------------- +// Wire shaping +// --------------------------------------------------------------------------- + +export const systemIdentity = "You are Claude Code, Anthropic's official CLI for Claude." + +/** Claude Code 2.x canonical tool names. */ +const tools = [ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "AskUserQuestion", + "EnterPlanMode", + "ExitPlanMode", + "KillShell", + "NotebookEdit", + "Skill", + "Task", + "TaskOutput", + "TodoWrite", + "WebFetch", + "WebSearch", +] +const canonical = new Map(tools.map((name) => [name.toLowerCase(), name])) +const toCanonical = (name: string) => canonical.get(name.toLowerCase()) ?? name + +/** + * The canonical `` shape genuine Claude Code sends. + * + * MAINTENANCE LIABILITY. Anthropic fuzzy-matches this block to decide whether a + * request is really Claude Code. A block that diverges — an extra harness key, + * different indentation, the date outside the tag — is billed as "extra usage" + * against the account instead of drawing from the subscription. The failure is + * silent: requests still succeed, they just cost money. Header, tool and + * identity shaping alone do not satisfy the check. + * + * Verified against build 0.0.0-next-15329 on 2026-07-11. If opencode's own + * prompt format changes, `isCanonical` below stops matching and the middleware + * warns; that warning is the only early signal of a billing regression, so do + * not silence it without re-verifying against a real subscription. + */ +export function normalizeEnv(text: string): string { + return text.replace( + /(?:[^\n]*(?:useful )?information about the environment you are running in:\n)?\n([\s\S]*?)\n<\/env>((?:\n+Today's date:[^\n]*)?)/, + (_match, inner: string, trailingDate: string) => { + const lines = String(inner) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + // Harness-specific keys never appear in genuine Claude Code. + .filter((line) => !/^Workspace root folder:/i.test(line)) + const date = trailingDate.match(/Today's date:\s*(.+)\s*$/) + if (date && !lines.some((line) => /^Today's date:/i.test(line))) lines.push(`Today's date: ${date[1].trim()}`) + return `Here is useful information about the environment you are running in:\n\n${lines.join("\n")}\n` + }, + ) +} + +/** + * Canary for the above. True when the text carries no `` block at all, or + * carries one already in canonical form. False means normalization did not + * produce what Anthropic expects and billing has probably silently moved to + * "extra usage". + */ +export function isCanonical(text: string): boolean { + if (!text.includes("")) return true + return /Here is useful information about the environment you are running in:\n\n(?:[^\n ][^\n]*\n)*<\/env>/.test( + text, + ) +} + +/** + * Shape the outgoing Anthropic payload into what genuine Claude Code sends: + * the Claude Code identity first in `system`, a canonical `` block, and + * Claude Code's tool casing. Operates on the decoded payload rather than + * request text, so the protocol keeps ownership of encoding. + */ +export function shapeRequestBody(body: unknown, warn?: (message: string) => void): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body + const parsed = { ...(body as Record) } as { + system?: unknown + tools?: Array<{ name?: string } & Record> + messages?: Array<{ content?: Array> }> + apiKey?: unknown + } + + const identity = { type: "text", text: systemIdentity } + const normalize = (text: string) => { + const next = normalizeEnv(text) + if (!isCanonical(next)) + warn?.( + "Claude Code block is not in canonical form after normalization; " + + "subscription requests may be billed as extra usage. See normalizeEnv in anthropic-claude-code.ts.", + ) + return next + } + + if (Array.isArray(parsed.system) && parsed.system.length > 0) { + parsed.system = parsed.system.map((entry: unknown, index: number) => { + if (index === 0) return identity + if (entry && typeof entry === "object" && typeof (entry as { text?: unknown }).text === "string") { + const block = entry as { type?: string; text: string } + return { ...block, text: normalize(block.text) } + } + return entry + }) + } else if (typeof parsed.system === "string" && parsed.system.length > 0) { + parsed.system = [identity, { type: "text", text: normalize(parsed.system) }] + } else { + parsed.system = [identity] + } + + // A configured provider `body.apiKey` is merged into the payload upstream and + // the Anthropic API rejects it as an unexpected input. + delete parsed.apiKey + + if (Array.isArray(parsed.tools)) + parsed.tools = parsed.tools.map((tool) => ({ ...tool, name: tool.name ? toCanonical(tool.name) : tool.name })) + + if (Array.isArray(parsed.messages)) + parsed.messages = parsed.messages.map((message) => { + if (!Array.isArray(message.content)) return message + return { + ...message, + content: message.content.map((block) => + block.type === "tool_use" && typeof block.name === "string" + ? { ...block, name: toCanonical(block.name) } + : block, + ), + } + }) + + return parsed +} + +/** Reverse the canonical casing so the rest of opencode sees its own names. */ +export function restoreToolNames(text: string): string { + let out = text + for (const name of tools) + out = out.replace(new RegExp(`"name"\\s*:\\s*"${name}"`, "g"), `"name": "${name.toLowerCase()}"`) + return out +} + +/** + * Headers Claude Code sends. `anthropic-beta` is merged rather than replaced so + * a provider- or config-supplied flag survives alongside the required ones. + */ +export function headers(existing?: Record): Record { + const incoming = Object.entries(existing ?? {}).find(([key]) => key.toLowerCase() === "anthropic-beta")?.[1] ?? "" + const merged = [ + ...new Set([ + ...betaFlags.split(","), + ...incoming + .split(",") + .map((flag) => flag.trim()) + .filter(Boolean), + ]), + ].join(",") + return { + "anthropic-beta": merged, + "anthropic-dangerous-direct-browser-access": "true", + "user-agent": userAgent, + "x-app": "cli", + } +} + +/** + * Wrap a route transport so requests present as Claude Code and responses are + * mapped back to opencode's tool names. + * + * This is the seam because ModelResolver dispatches `@ai-sdk/anthropic` through + * the native AnthropicMessages route, not the AI SDK -- `aisdk.hook("sdk")` is + * never invoked for it. Framing splits the SSE stream into whole events before + * this sees them, so the reverse mapping needs no boundary buffering. + */ +export function transport( + base: TransportDef, + warn?: (message: string) => void, +): TransportDef { + return { + id: `${base.id}/claude-code`, + prepare: (input) => base.prepare({ ...input, body: shapeRequestBody(input.body, warn) as Body }), + frames: (prepared, request, runtime) => + base + .frames(prepared, request, runtime) + .pipe(Stream.map((frame) => (typeof frame === "string" ? restoreToolNames(frame) : frame))), + } +} diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 06ba5042014c..17aa5fab0c9d 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,10 +1,90 @@ -import { Effect } from "effect" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect, Semaphore, Stream } from "effect" +import { Bus } from "../../bus" +import { Integration } from "../../integration" import { Provider } from "../../provider" +import { AnthropicClaudeCode } from "./anthropic-claude-code" + +const claudeProMax = { + integrationID: AnthropicClaudeCode.integrationID, + method: { + id: AnthropicClaudeCode.methodID, + type: "oauth", + label: "Claude Pro/Max", + }, + authorize: () => + Effect.sync(() => { + const { verifier, challenge } = AnthropicClaudeCode.pkce() + return { + // Anthropic's callback page displays the code rather than redirecting + // to a loopback port, so this cannot be "auto" like ChatGPT's. + mode: "code" as const, + url: AnthropicClaudeCode.authorizeURL(challenge, verifier), + instructions: "Authorize with Claude, then paste the code shown.", + callback: (code: string) => + Effect.tryPromise({ + try: async () => ({ + type: "oauth" as const, + methodID: AnthropicClaudeCode.methodID, + ...(await AnthropicClaudeCode.exchange(code, verifier)), + }), + catch: () => new Error("Claude authorization failed"), + }), + } + }), + refresh: (value) => + Effect.tryPromise({ + try: async () => ({ ...value, ...(await AnthropicClaudeCode.refresh(value.refresh)) }), + catch: () => new Error("Claude token refresh failed"), + }), +} satisfies IntegrationOAuthMethodRegistration export const AnthropicPlugin = define({ id: "opencode.provider.anthropic", effect: Effect.fn(function* (ctx) { + const bus = yield* Bus.Service + const loading = Semaphore.makeUnsafe(1) + let subscription = false + + // Resolved rather than cached at every use: a CLAUDE_CODE_OAUTH_TOKEN in the + // environment surfaces as an `env` connection, which is derived at resolve + // time and never publishes ConnectionUpdated, so a setup-at-startup flag + // would stay false forever on exactly the headless hosts that need it. + const load = Effect.fn("AnthropicPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active(AnthropicClaudeCode.integrationID) + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + subscription = AnthropicClaudeCode.isSubscription(credential) + return subscription + }) + + yield* ctx.integration.transform((draft) => { + draft.method.update(claudeProMax) + // A `claude setup-token` value is the only credential a headless host can + // obtain without a browser, so accept it from the environment too. It + // arrives as a key credential and isSubscription routes it accordingly. + draft.method.update({ + integrationID: AnthropicClaudeCode.integrationID, + method: { type: "env", names: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }, + }) + }) + yield* load() + + // Note: no `aisdk.hook("sdk")` here. ModelResolver short-circuits the + // `@ai-sdk/anthropic` package to the native AnthropicMessages route, so + // that hook is never invoked for this provider; subscription request + // shaping lives in the route transport instead. + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/anthropic") return + const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) + evt.sdk = mod.createAnthropic(evt.options) + }), + ) + yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { if (!Provider.isAISDK(item.provider.package)) continue @@ -16,14 +96,23 @@ export const AnthropicPlugin = define({ } }) } + if (!subscription) return + const item = evt.provider.get(Provider.ID.make("anthropic")) + if (!item) return + for (const model of item.models.values()) { + // The subscription covers usage, so per-token cost is not meaningful. + evt.model.update(item.provider.id, model.id, (draft) => { + draft.cost = [] + }) + } }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/anthropic") return - const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) - evt.sdk = mod.createAnthropic(evt.options) - }), + + const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === AnthropicClaudeCode.integrationID), + Stream.runForEach(reload), + Effect.forkScoped({ startImmediately: true }), ) + }), }) diff --git a/packages/core/test/provider-anthropic-claude-code.test.ts b/packages/core/test/provider-anthropic-claude-code.test.ts new file mode 100644 index 000000000000..a9175db09a54 --- /dev/null +++ b/packages/core/test/provider-anthropic-claude-code.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Stream } from "effect" +import { AnthropicClaudeCode } from "@opencode-ai/core/plugin/provider/anthropic-claude-code" + +const shape = (body: unknown, warn?: (message: string) => void) => + AnthropicClaudeCode.shapeRequestBody(body, warn) as Record + +describe("AnthropicClaudeCode.isSubscription", () => { + test("recognizes the Claude Pro/Max OAuth credential", () => { + expect(AnthropicClaudeCode.isSubscription({ type: "oauth", methodID: "claude-pro-max" })).toBe(true) + }) + + test("ignores another provider's OAuth credential", () => { + expect(AnthropicClaudeCode.isSubscription({ type: "oauth", methodID: "chatgpt-headless" })).toBe(false) + }) + + test("treats a setup-token in the key slot as a subscription", () => { + // `claude setup-token` is the only credential a headless host can get, and + // the key slot is the only place to paste it. Sent as x-api-key it 401s. + expect(AnthropicClaudeCode.isSubscription({ type: "key", key: "sk-ant-oat01-abc" })).toBe(true) + }) + + test("leaves a genuine API key on the normal path", () => { + expect(AnthropicClaudeCode.isSubscription({ type: "key", key: "sk-ant-api03-abc" })).toBe(false) + }) + + test("does not throw on a key credential with no value", () => { + expect(AnthropicClaudeCode.isSubscription({ type: "key" })).toBe(false) + expect(AnthropicClaudeCode.isSubscription(undefined)).toBe(false) + }) +}) + +describe("AnthropicClaudeCode.normalizeEnv", () => { + // Golden fixture. Anthropic fuzzy-matches this block to decide whether the + // request is really Claude Code; a divergent block is billed as "extra usage" + // instead of the subscription, silently. If opencode's prompt format changes + // and this test fails, the fix is to make normalizeEnv produce the canonical + // shape again -- not to update the expectation. + const canonical = [ + "Here is useful information about the environment you are running in:", + "", + "Working directory: /home/user/project", + "Platform: linux", + "Today's date: 2026-07-29", + "", + ].join("\n") + + test("strips harness-only keys and indentation", () => { + const host = [ + "Here is useful information about the environment you are running in:", + "", + " Working directory: /home/user/project", + " Workspace root folder: /home/user/project", + " Platform: linux", + " Today's date: 2026-07-29", + "", + ].join("\n") + expect(AnthropicClaudeCode.normalizeEnv(host)).toBe(canonical) + }) + + test("moves a trailing date inside the block", () => { + const host = [ + "Here is useful information about the environment you are running in:", + "", + "Working directory: /home/user/project", + "Platform: linux", + "", + "", + "Today's date: 2026-07-29", + ].join("\n") + expect(AnthropicClaudeCode.normalizeEnv(host)).toBe(canonical) + }) + + test("is idempotent", () => { + expect(AnthropicClaudeCode.normalizeEnv(canonical)).toBe(canonical) + }) + + test("normalizes an indented block that lacks the lead-in line", () => { + expect(AnthropicClaudeCode.normalizeEnv("\n Platform: linux\n")).toBe( + "Here is useful information about the environment you are running in:\n\nPlatform: linux\n", + ) + }) + + test("leaves text without an env block alone", () => { + expect(AnthropicClaudeCode.normalizeEnv("You are a helpful assistant.")).toBe("You are a helpful assistant.") + }) + + test("isCanonical is the billing-regression canary", () => { + expect(AnthropicClaudeCode.isCanonical(canonical)).toBe(true) + expect(AnthropicClaudeCode.isCanonical("no env here")).toBe(true) + expect(AnthropicClaudeCode.isCanonical("\n indented: yes\n")).toBe(false) + }) +}) + +describe("AnthropicClaudeCode.shapeRequestBody", () => { + test("forces the Claude Code identity as the first system entry", () => { + const body = { system: [{ type: "text", text: "You are opencode." }] } + const parsed = shape(body) + expect(parsed.system[0]).toEqual({ type: "text", text: AnthropicClaudeCode.systemIdentity }) + }) + + test("preserves later system entries so the agent keeps its instructions", () => { + const body = { + system: [ + { type: "text", text: "You are opencode." }, + { type: "text", text: "Follow the project conventions." }, + ], + } + const parsed = shape(body) + expect(parsed.system[1].text).toBe("Follow the project conventions.") + }) + + test("promotes a string system prompt to identity + normalized block", () => { + const parsed = shape({ system: "You are opencode." }) + expect(parsed.system).toEqual([ + { type: "text", text: AnthropicClaudeCode.systemIdentity }, + { type: "text", text: "You are opencode." }, + ]) + }) + + test("adds the identity when there is no system prompt at all", () => { + const parsed = shape({}) + expect(parsed.system).toEqual([{ type: "text", text: AnthropicClaudeCode.systemIdentity }]) + }) + + test("renames tools to Claude Code casing", () => { + const body = { tools: [{ name: "bash" }, { name: "read" }, { name: "mcp_custom" }] } + const parsed = shape(body) + expect(parsed.tools.map((tool: { name: string }) => tool.name)).toEqual(["Bash", "Read", "mcp_custom"]) + }) + + test("renames tool_use blocks in prior messages", () => { + const body = { + messages: [{ content: [{ type: "tool_use", name: "webfetch" }, { type: "text", text: "hi" }] }], + } + const parsed = shape(body) + expect(parsed.messages[0].content[0].name).toBe("WebFetch") + expect(parsed.messages[0].content[1]).toEqual({ type: "text", text: "hi" }) + }) + + test("drops a merged provider apiKey the API would reject", () => { + const parsed = shape({ apiKey: "sk-ant-oat01-x" }) + expect(parsed.apiKey).toBeUndefined() + }) + + test("warns when the env block cannot be made canonical", () => { + const warnings: string[] = [] + // An inline block the normalizer's line-oriented regex cannot parse, so it + // passes through unchanged and reaches Anthropic in non-Claude-Code shape. + const body = { + system: [{ type: "text", text: "opencode" }, { type: "text", text: "inline: yes" }], + } + AnthropicClaudeCode.shapeRequestBody(body, (message) => warnings.push(message)) + expect(warnings.length).toBe(1) + expect(warnings[0]).toContain("extra usage") + }) +}) + +describe("AnthropicClaudeCode.restoreToolNames", () => { + test("maps canonical names back so opencode sees its own", () => { + expect(AnthropicClaudeCode.restoreToolNames('{"name": "Bash"}')).toBe('{"name": "bash"}') + }) + + test("leaves unknown names untouched", () => { + expect(AnthropicClaudeCode.restoreToolNames('{"name": "mcp_custom"}')).toBe('{"name": "mcp_custom"}') + }) +}) + +describe("AnthropicClaudeCode.headers", () => { + test("sends the Claude Code identity headers", () => { + const result = AnthropicClaudeCode.headers() + expect(result["user-agent"]).toBe(AnthropicClaudeCode.userAgent) + expect(result["x-app"]).toBe("cli") + expect(result["anthropic-beta"]).toContain("oauth-2025-04-20") + }) + + test("merges caller beta flags rather than replacing them", () => { + const result = AnthropicClaudeCode.headers({ "anthropic-beta": "custom-flag-1" }) + expect(result["anthropic-beta"]).toContain("custom-flag-1") + expect(result["anthropic-beta"]).toContain("claude-code-20250219") + }) + + test("matches the beta header case-insensitively", () => { + expect(AnthropicClaudeCode.headers({ "Anthropic-Beta": "custom-flag-2" })["anthropic-beta"]).toContain( + "custom-flag-2", + ) + }) +}) + +describe("AnthropicClaudeCode.transport", () => { + const base = { + id: "http-json", + prepare: (input: any) => Effect.succeed({ seen: input.body }), + frames: () => Stream.fromIterable(['{"name": "Bash"}', '{"name": "mcp_x"}']), + } + + test("shapes the request body before the protocol encodes it", async () => { + const wrapped = AnthropicClaudeCode.transport(base as any) + const prepared: any = await Effect.runPromise( + wrapped.prepare({ body: { tools: [{ name: "bash" }] } } as any) as any, + ) + expect(prepared.seen.tools[0].name).toBe("Bash") + expect(prepared.seen.system[0].text).toBe(AnthropicClaudeCode.systemIdentity) + }) + + test("restores opencode tool names on the response frames", async () => { + const wrapped = AnthropicClaudeCode.transport(base as any) + const frames = await Effect.runPromise( + Stream.runCollect(wrapped.frames({} as any, {} as any, {} as any)) as any, + ) + expect(Array.from(frames as any)).toEqual(['{"name": "bash"}', '{"name": "mcp_x"}']) + }) + + test("tags its id so the wrapping is visible in traces", () => { + expect(AnthropicClaudeCode.transport(base as any).id).toBe("http-json/claude-code") + }) +})