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") + }) +})