-
Notifications
You must be signed in to change notification settings - Fork 37
fix: Align Anthropic OAuth requests with Claude Code #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR aligns Anthropic OAuth requests with Claude Code's expected format to satisfy OAuth validation requirements. The changes normalize headers, beta flags, metadata, tool naming conventions, and model IDs to match what Anthropic's OAuth validation expects.
Key changes:
- Add global fetch patching to intercept and transform all Anthropic API requests
- Normalize tool names between OpenCode's snake_case and Claude Code's PascalCase formats
- Inject Claude Code-specific headers (x-stainless-*, x-app, user-agent) and metadata.user_id
- Transform streaming responses to reverse tool/model name changes for UI compatibility
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (cachedMetadataUserIdPromise) return cachedMetadataUserIdPromise; | ||
|
|
||
| cachedMetadataUserIdPromise = (async () => { | ||
| const home = env.HOME ?? env.USERPROFILE; | ||
| if (!home) return undefined; | ||
| const configPath = env.OPENCODE_CLAUDE_CONFIG ?? `${home}/.claude.json`; | ||
| try { | ||
| const { readFile } = await import("node:fs/promises"); | ||
| const raw = await readFile(configPath, "utf8"); | ||
| const data = JSON.parse(raw); | ||
| const userId = data?.userID; | ||
| const accountUuid = data?.oauthAccount?.accountUuid; | ||
| let sessionId = undefined; | ||
| const cwd = globalThis.process?.cwd?.(); | ||
| if (cwd && data?.projects?.[cwd]?.lastSessionId) { | ||
| sessionId = data.projects[cwd].lastSessionId; | ||
| } else if (data?.projects && typeof data.projects === "object") { | ||
| for (const value of Object.values(data.projects)) { | ||
| if (value && typeof value === "object" && value.lastSessionId) { | ||
| sessionId = value.lastSessionId; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (userId && accountUuid && sessionId) { | ||
| return `user_${userId}_account_${accountUuid}_session_${sessionId}`; | ||
| } | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| return undefined; | ||
| })(); | ||
|
|
||
| return cachedMetadataUserIdPromise; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a race condition in how cachedMetadataUserIdPromise is used. If resolveMetadataUserId() is called multiple times before the file read completes, the first call creates the promise, but if the file read fails (caught in the try-catch), subsequent calls will return the same cached promise that resolved to undefined. This means a transient error (like temporary permission issues) will permanently cache undefined for the lifetime of the process. Consider resetting cachedMetadataUserIdPromise to null in the catch block if the read fails, allowing retry on subsequent calls.
index.mjs
Outdated
| if (metadataUserId) { | ||
| if (!parsed.metadata || typeof parsed.metadata !== "object") { | ||
| parsed.metadata = {}; | ||
| } | ||
| if (!parsed.metadata.user_id) { | ||
| parsed.metadata.user_id = metadataUserId; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function unconditionally injects metadata.user_id on line 362 even if metadataUserId is undefined or falsy. The check if (metadataUserId) on line 357 guards the creation of the metadata object, but if parsed.metadata already exists, line 362 will set user_id to undefined when metadataUserId is falsy. This differs from the similar code at lines 785-787 which correctly checks if (metadataUserId && !parsed.metadata.user_id). The guard should be moved to wrap line 362 as well.
| if (metadataUserId) { | |
| if (!parsed.metadata || typeof parsed.metadata !== "object") { | |
| parsed.metadata = {}; | |
| } | |
| if (!parsed.metadata.user_id) { | |
| parsed.metadata.user_id = metadataUserId; | |
| } | |
| if (metadataUserId && (!parsed.metadata || !parsed.metadata.user_id)) { | |
| if (!parsed.metadata || typeof parsed.metadata !== "object") { | |
| parsed.metadata = {}; | |
| } | |
| parsed.metadata.user_id = metadataUserId; |
| } | ||
| if (options.tool_choice) { | ||
| delete options.tool_choice; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The chat.params hook modifies the outgoing request but doesn't transform the incoming streaming responses to reverse the tool name and model ID transformations. This means responses from the chat.params path will have Claude Code format names (PascalCase) but the UI expects OpenCode format (snake_case). The other code paths (installAnthropicFetchPatch and loader.fetch) handle this via replaceToolNamesInText, but this hook has no equivalent response transformation. Either streaming responses need to be handled elsewhere for this path, or response transformation should be added here.
| } | |
| } | |
| // Ensure streamed responses have tool names/model IDs converted back | |
| // from Claude Code format to the OpenCode format expected by the UI. | |
| if (output) { | |
| if (typeof output.transformStream === "function") { | |
| const originalTransformStream = output.transformStream; | |
| output.transformStream = async function* (stream) { | |
| for await (let chunk of originalTransformStream.call(this, stream)) { | |
| if (typeof chunk === "string") { | |
| chunk = replaceToolNamesInText(chunk); | |
| } | |
| yield chunk; | |
| } | |
| }; | |
| } else if (typeof output.transform === "function") { | |
| const originalTransform = output.transform; | |
| output.transform = async function* (stream) { | |
| for await (let chunk of originalTransform.call(this, stream)) { | |
| if (typeof chunk === "string") { | |
| chunk = replaceToolNamesInText(chunk); | |
| } | |
| yield chunk; | |
| } | |
| }; | |
| } | |
| } |
| function replaceToolNamesInText(text) { | ||
| let output = text.replace(/"name"\s*:\s*"oc_([^"]+)"/g, '"name": "$1"'); | ||
| output = output.replace( | ||
| /"name"\s*:\s*"(Bash|Read|Edit|Write|Task|Glob|Grep|WebFetch|WebSearch|TodoWrite)"/g, | ||
| (match, name) => `"name": "${normalizeToolNameForOpenCode(name)}"`, | ||
| ); | ||
| for (const [pascal, original] of TOOL_NAME_CACHE.entries()) { | ||
| if (!pascal || pascal === original) continue; | ||
| const pattern = new RegExp( | ||
| `"name"\\s*:\\s*"${escapeRegExp(pascal)}"`, | ||
| "g", | ||
| ); | ||
| output = output.replace(pattern, `"name": "${original}"`); | ||
| } | ||
| for (const [full, base] of MODEL_ID_REVERSE_OVERRIDES.entries()) { | ||
| const pattern = new RegExp( | ||
| `"model"\\s*:\\s*"${escapeRegExp(full)}"`, | ||
| "g", | ||
| ); | ||
| output = output.replace(pattern, `"model": "${base}"`); | ||
| } | ||
| return output; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The replaceToolNamesInText function performs string replacement on streaming data using multiple regex patterns (lines 85-104). Each chunk of the stream is processed independently, which means if a JSON object boundary falls across chunk boundaries, the replacements may miss patterns or produce invalid JSON. For example, if one chunk ends with "name": "Ba and the next starts with sh", the pattern won't match. Consider buffering incomplete JSON structures or using a proper streaming JSON parser instead of regex-based text replacement.
| function installAnthropicFetchPatch(getAuth, client) { | ||
| if (FETCH_PATCH_STATE.installed) { | ||
| if (getAuth) FETCH_PATCH_STATE.getAuth = getAuth; | ||
| if (client) FETCH_PATCH_STATE.client = client; | ||
| return; | ||
| } | ||
| if (!globalThis.fetch) return; | ||
| FETCH_PATCH_STATE.installed = true; | ||
| FETCH_PATCH_STATE.getAuth = getAuth ?? null; | ||
| FETCH_PATCH_STATE.client = client ?? null; | ||
| const baseFetch = getBaseFetch(); | ||
|
|
||
| const patchedFetch = async (input, init) => { | ||
| let requestUrl = null; | ||
| try { | ||
| if (typeof input === "string" || input instanceof URL) { | ||
| requestUrl = new URL(input.toString()); | ||
| } else if (input instanceof Request) { | ||
| requestUrl = new URL(input.url); | ||
| } | ||
| } catch { | ||
| requestUrl = null; | ||
| } | ||
|
|
||
| if (!requestUrl || requestUrl.hostname !== "api.anthropic.com") { | ||
| return baseFetch(input, init); | ||
| } | ||
|
|
||
| const requestInit = init ?? {}; | ||
| const requestHeaders = new Headers(); | ||
| if (input instanceof Request) { | ||
| input.headers.forEach((value, key) => { | ||
| requestHeaders.set(key, value); | ||
| }); | ||
| } | ||
| if (requestInit.headers) { | ||
| if (requestInit.headers instanceof Headers) { | ||
| requestInit.headers.forEach((value, key) => { | ||
| requestHeaders.set(key, value); | ||
| }); | ||
| } else if (Array.isArray(requestInit.headers)) { | ||
| for (const [key, value] of requestInit.headers) { | ||
| if (typeof value !== "undefined") { | ||
| requestHeaders.set(key, String(value)); | ||
| } | ||
| } | ||
| } else { | ||
| for (const [key, value] of Object.entries(requestInit.headers)) { | ||
| if (typeof value !== "undefined") { | ||
| requestHeaders.set(key, String(value)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let auth = null; | ||
| try { | ||
| auth = await ensureOAuthAccess( | ||
| FETCH_PATCH_STATE.getAuth, | ||
| FETCH_PATCH_STATE.client, | ||
| ); | ||
| } catch { | ||
| auth = null; | ||
| } | ||
|
|
||
| const authorization = requestHeaders.get("authorization") ?? ""; | ||
| const shouldPatch = | ||
| auth?.type === "oauth" || authorization.includes("sk-ant-oat"); | ||
| if (!shouldPatch) { | ||
| return baseFetch(input, init); | ||
| } | ||
|
|
||
| const incomingBeta = requestHeaders.get("anthropic-beta") || ""; | ||
| const incomingBetasList = incomingBeta | ||
| .split(",") | ||
| .map((b) => b.trim()) | ||
| .filter(Boolean); | ||
| let mergedBetasList = [...incomingBetasList]; | ||
|
|
||
| if (requestUrl.pathname === "/v1/messages") { | ||
| mergedBetasList = [ | ||
| "oauth-2025-04-20", | ||
| "interleaved-thinking-2025-05-14", | ||
| ]; | ||
| } else if (requestUrl.pathname === "/v1/messages/count_tokens") { | ||
| mergedBetasList = [ | ||
| "claude-code-20250219", | ||
| "oauth-2025-04-20", | ||
| "interleaved-thinking-2025-05-14", | ||
| "token-counting-2024-11-01", | ||
| ]; | ||
| } else if ( | ||
| requestUrl.pathname.startsWith("/api/") && | ||
| requestUrl.pathname !== "/api/hello" | ||
| ) { | ||
| mergedBetasList = ["oauth-2025-04-20"]; | ||
| } | ||
|
|
||
| if (auth?.type === "oauth" && auth.access) { | ||
| requestHeaders.set("authorization", `Bearer ${auth.access}`); | ||
| } | ||
| if (mergedBetasList.length > 0) { | ||
| requestHeaders.set("anthropic-beta", mergedBetasList.join(",")); | ||
| } else { | ||
| requestHeaders.delete("anthropic-beta"); | ||
| } | ||
| requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)"); | ||
| requestHeaders.set("x-app", "cli"); | ||
| requestHeaders.set("anthropic-dangerous-direct-browser-access", "true"); | ||
|
|
||
| const env = globalThis.process?.env ?? {}; | ||
| const platform = globalThis.process?.platform ?? "linux"; | ||
| const os = | ||
| env.OPENCODE_STAINLESS_OS ?? | ||
| (platform === "darwin" | ||
| ? "Darwin" | ||
| : platform === "win32" | ||
| ? "Windows" | ||
| : platform === "linux" | ||
| ? "Linux" | ||
| : platform); | ||
|
|
||
| requestHeaders.set( | ||
| "x-stainless-arch", | ||
| env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64", | ||
| ); | ||
| requestHeaders.set("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js"); | ||
| requestHeaders.set("x-stainless-os", os); | ||
| requestHeaders.set( | ||
| "x-stainless-package-version", | ||
| env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-runtime", | ||
| env.OPENCODE_STAINLESS_RUNTIME ?? "node", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-runtime-version", | ||
| env.OPENCODE_STAINLESS_RUNTIME_VERSION ?? | ||
| globalThis.process?.version ?? | ||
| "v24.3.0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-retry-count", | ||
| env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-timeout", | ||
| env.OPENCODE_STAINLESS_TIMEOUT ?? "600", | ||
| ); | ||
| requestHeaders.delete("x-api-key"); | ||
|
|
||
| let body = requestInit.body; | ||
| if (!body && input instanceof Request) { | ||
| try { | ||
| body = await input.clone().text(); | ||
| } catch { | ||
| body = requestInit.body; | ||
| } | ||
| } | ||
|
|
||
| let shouldSetHelperMethod = false; | ||
| if (body && typeof body === "string") { | ||
| try { | ||
| const parsed = JSON.parse(body); | ||
| if (parsed.model) { | ||
| parsed.model = normalizeModelId(parsed.model); | ||
| } | ||
| if (parsed.tools && Array.isArray(parsed.tools)) { | ||
| parsed.tools = parsed.tools.map((tool) => ({ | ||
| ...tool, | ||
| name: tool.name ? normalizeToolNameForClaude(tool.name) : tool.name, | ||
| })); | ||
| } else if (parsed.tools && typeof parsed.tools === "object") { | ||
| const mappedTools = {}; | ||
| for (const [key, value] of Object.entries(parsed.tools)) { | ||
| const mappedKey = normalizeToolNameForClaude(key); | ||
| const mappedValue = | ||
| value && typeof value === "object" | ||
| ? { | ||
| ...value, | ||
| name: value.name | ||
| ? normalizeToolNameForClaude(value.name) | ||
| : mappedKey, | ||
| } | ||
| : value; | ||
| mappedTools[mappedKey] = mappedValue; | ||
| } | ||
| parsed.tools = mappedTools; | ||
| } | ||
| if (parsed.tool_choice) { | ||
| delete parsed.tool_choice; | ||
| } | ||
|
|
||
| if (requestUrl.pathname === "/v1/messages") { | ||
| const metadataUserId = await resolveMetadataUserId(); | ||
| if (metadataUserId) { | ||
| if (!parsed.metadata || typeof parsed.metadata !== "object") { | ||
| parsed.metadata = {}; | ||
| } | ||
| if (!parsed.metadata.user_id) { | ||
| parsed.metadata.user_id = metadataUserId; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (parsed.stream) shouldSetHelperMethod = true; | ||
| body = JSON.stringify(parsed); | ||
| } catch { | ||
| // ignore parse errors | ||
| } | ||
| } | ||
|
|
||
| if (shouldSetHelperMethod) { | ||
| requestHeaders.set("x-stainless-helper-method", "stream"); | ||
| } | ||
|
|
||
| if ( | ||
| (requestUrl.pathname === "/v1/messages" || | ||
| requestUrl.pathname === "/v1/messages/count_tokens") && | ||
| !requestUrl.searchParams.has("beta") | ||
| ) { | ||
| requestUrl.searchParams.set("beta", "true"); | ||
| } | ||
|
|
||
| let requestInput = requestUrl; | ||
| let requestInitOut = { | ||
| ...requestInit, | ||
| headers: requestHeaders, | ||
| body, | ||
| }; | ||
|
|
||
| if (input instanceof Request) { | ||
| requestInput = new Request(requestUrl.toString(), { | ||
| ...requestInit, | ||
| headers: requestHeaders, | ||
| body, | ||
| }); | ||
| requestInitOut = undefined; | ||
| } | ||
|
|
||
| const response = await baseFetch(requestInput, requestInitOut); | ||
| if (response.body) { | ||
| const reader = response.body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
| const encoder = new TextEncoder(); | ||
|
|
||
| const stream = new ReadableStream({ | ||
| async pull(controller) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
|
|
||
| let text = decoder.decode(value, { stream: true }); | ||
| text = replaceToolNamesInText(text); | ||
| controller.enqueue(encoder.encode(text)); | ||
| }, | ||
| }); | ||
|
|
||
| return new Response(stream, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: response.headers, | ||
| }); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| patchedFetch.__opencodeAnthropicPatched = true; | ||
| globalThis.fetch = patchedFetch; |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The installAnthropicFetchPatch function mutates the global globalThis.fetch (line 433), which affects all code in the same JavaScript runtime, not just OpenCode's Anthropic requests. If any other code in the process makes fetch requests to api.anthropic.com (for example, a monitoring service or another library), those requests will be unexpectedly modified by this patch. This could lead to hard-to-debug issues. Consider using a more localized approach or documenting this global side effect clearly.
| function installAnthropicFetchPatch(getAuth, client) { | ||
| if (FETCH_PATCH_STATE.installed) { | ||
| if (getAuth) FETCH_PATCH_STATE.getAuth = getAuth; | ||
| if (client) FETCH_PATCH_STATE.client = client; | ||
| return; | ||
| } | ||
| if (!globalThis.fetch) return; | ||
| FETCH_PATCH_STATE.installed = true; | ||
| FETCH_PATCH_STATE.getAuth = getAuth ?? null; | ||
| FETCH_PATCH_STATE.client = client ?? null; | ||
| const baseFetch = getBaseFetch(); | ||
|
|
||
| const patchedFetch = async (input, init) => { | ||
| let requestUrl = null; | ||
| try { | ||
| if (typeof input === "string" || input instanceof URL) { | ||
| requestUrl = new URL(input.toString()); | ||
| } else if (input instanceof Request) { | ||
| requestUrl = new URL(input.url); | ||
| } | ||
| } catch { | ||
| requestUrl = null; | ||
| } | ||
|
|
||
| if (!requestUrl || requestUrl.hostname !== "api.anthropic.com") { | ||
| return baseFetch(input, init); | ||
| } | ||
|
|
||
| const requestInit = init ?? {}; | ||
| const requestHeaders = new Headers(); | ||
| if (input instanceof Request) { | ||
| input.headers.forEach((value, key) => { | ||
| requestHeaders.set(key, value); | ||
| }); | ||
| } | ||
| if (requestInit.headers) { | ||
| if (requestInit.headers instanceof Headers) { | ||
| requestInit.headers.forEach((value, key) => { | ||
| requestHeaders.set(key, value); | ||
| }); | ||
| } else if (Array.isArray(requestInit.headers)) { | ||
| for (const [key, value] of requestInit.headers) { | ||
| if (typeof value !== "undefined") { | ||
| requestHeaders.set(key, String(value)); | ||
| } | ||
| } | ||
| } else { | ||
| for (const [key, value] of Object.entries(requestInit.headers)) { | ||
| if (typeof value !== "undefined") { | ||
| requestHeaders.set(key, String(value)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let auth = null; | ||
| try { | ||
| auth = await ensureOAuthAccess( | ||
| FETCH_PATCH_STATE.getAuth, | ||
| FETCH_PATCH_STATE.client, | ||
| ); | ||
| } catch { | ||
| auth = null; | ||
| } | ||
|
|
||
| const authorization = requestHeaders.get("authorization") ?? ""; | ||
| const shouldPatch = | ||
| auth?.type === "oauth" || authorization.includes("sk-ant-oat"); | ||
| if (!shouldPatch) { | ||
| return baseFetch(input, init); | ||
| } | ||
|
|
||
| const incomingBeta = requestHeaders.get("anthropic-beta") || ""; | ||
| const incomingBetasList = incomingBeta | ||
| .split(",") | ||
| .map((b) => b.trim()) | ||
| .filter(Boolean); | ||
| let mergedBetasList = [...incomingBetasList]; | ||
|
|
||
| if (requestUrl.pathname === "/v1/messages") { | ||
| mergedBetasList = [ | ||
| "oauth-2025-04-20", | ||
| "interleaved-thinking-2025-05-14", | ||
| ]; | ||
| } else if (requestUrl.pathname === "/v1/messages/count_tokens") { | ||
| mergedBetasList = [ | ||
| "claude-code-20250219", | ||
| "oauth-2025-04-20", | ||
| "interleaved-thinking-2025-05-14", | ||
| "token-counting-2024-11-01", | ||
| ]; | ||
| } else if ( | ||
| requestUrl.pathname.startsWith("/api/") && | ||
| requestUrl.pathname !== "/api/hello" | ||
| ) { | ||
| mergedBetasList = ["oauth-2025-04-20"]; | ||
| } | ||
|
|
||
| if (auth?.type === "oauth" && auth.access) { | ||
| requestHeaders.set("authorization", `Bearer ${auth.access}`); | ||
| } | ||
| if (mergedBetasList.length > 0) { | ||
| requestHeaders.set("anthropic-beta", mergedBetasList.join(",")); | ||
| } else { | ||
| requestHeaders.delete("anthropic-beta"); | ||
| } | ||
| requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)"); | ||
| requestHeaders.set("x-app", "cli"); | ||
| requestHeaders.set("anthropic-dangerous-direct-browser-access", "true"); | ||
|
|
||
| const env = globalThis.process?.env ?? {}; | ||
| const platform = globalThis.process?.platform ?? "linux"; | ||
| const os = | ||
| env.OPENCODE_STAINLESS_OS ?? | ||
| (platform === "darwin" | ||
| ? "Darwin" | ||
| : platform === "win32" | ||
| ? "Windows" | ||
| : platform === "linux" | ||
| ? "Linux" | ||
| : platform); | ||
|
|
||
| requestHeaders.set( | ||
| "x-stainless-arch", | ||
| env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64", | ||
| ); | ||
| requestHeaders.set("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js"); | ||
| requestHeaders.set("x-stainless-os", os); | ||
| requestHeaders.set( | ||
| "x-stainless-package-version", | ||
| env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-runtime", | ||
| env.OPENCODE_STAINLESS_RUNTIME ?? "node", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-runtime-version", | ||
| env.OPENCODE_STAINLESS_RUNTIME_VERSION ?? | ||
| globalThis.process?.version ?? | ||
| "v24.3.0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-retry-count", | ||
| env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0", | ||
| ); | ||
| requestHeaders.set( | ||
| "x-stainless-timeout", | ||
| env.OPENCODE_STAINLESS_TIMEOUT ?? "600", | ||
| ); | ||
| requestHeaders.delete("x-api-key"); | ||
|
|
||
| let body = requestInit.body; | ||
| if (!body && input instanceof Request) { | ||
| try { | ||
| body = await input.clone().text(); | ||
| } catch { | ||
| body = requestInit.body; | ||
| } | ||
| } | ||
|
|
||
| let shouldSetHelperMethod = false; | ||
| if (body && typeof body === "string") { | ||
| try { | ||
| const parsed = JSON.parse(body); | ||
| if (parsed.model) { | ||
| parsed.model = normalizeModelId(parsed.model); | ||
| } | ||
| if (parsed.tools && Array.isArray(parsed.tools)) { | ||
| parsed.tools = parsed.tools.map((tool) => ({ | ||
| ...tool, | ||
| name: tool.name ? normalizeToolNameForClaude(tool.name) : tool.name, | ||
| })); | ||
| } else if (parsed.tools && typeof parsed.tools === "object") { | ||
| const mappedTools = {}; | ||
| for (const [key, value] of Object.entries(parsed.tools)) { | ||
| const mappedKey = normalizeToolNameForClaude(key); | ||
| const mappedValue = | ||
| value && typeof value === "object" | ||
| ? { | ||
| ...value, | ||
| name: value.name | ||
| ? normalizeToolNameForClaude(value.name) | ||
| : mappedKey, | ||
| } | ||
| : value; | ||
| mappedTools[mappedKey] = mappedValue; | ||
| } | ||
| parsed.tools = mappedTools; | ||
| } | ||
| if (parsed.tool_choice) { | ||
| delete parsed.tool_choice; | ||
| } | ||
|
|
||
| if (requestUrl.pathname === "/v1/messages") { | ||
| const metadataUserId = await resolveMetadataUserId(); | ||
| if (metadataUserId) { | ||
| if (!parsed.metadata || typeof parsed.metadata !== "object") { | ||
| parsed.metadata = {}; | ||
| } | ||
| if (!parsed.metadata.user_id) { | ||
| parsed.metadata.user_id = metadataUserId; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (parsed.stream) shouldSetHelperMethod = true; | ||
| body = JSON.stringify(parsed); | ||
| } catch { | ||
| // ignore parse errors | ||
| } | ||
| } | ||
|
|
||
| if (shouldSetHelperMethod) { | ||
| requestHeaders.set("x-stainless-helper-method", "stream"); | ||
| } | ||
|
|
||
| if ( | ||
| (requestUrl.pathname === "/v1/messages" || | ||
| requestUrl.pathname === "/v1/messages/count_tokens") && | ||
| !requestUrl.searchParams.has("beta") | ||
| ) { | ||
| requestUrl.searchParams.set("beta", "true"); | ||
| } | ||
|
|
||
| let requestInput = requestUrl; | ||
| let requestInitOut = { | ||
| ...requestInit, | ||
| headers: requestHeaders, | ||
| body, | ||
| }; | ||
|
|
||
| if (input instanceof Request) { | ||
| requestInput = new Request(requestUrl.toString(), { | ||
| ...requestInit, | ||
| headers: requestHeaders, | ||
| body, | ||
| }); | ||
| requestInitOut = undefined; | ||
| } | ||
|
|
||
| const response = await baseFetch(requestInput, requestInitOut); | ||
| if (response.body) { | ||
| const reader = response.body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
| const encoder = new TextEncoder(); | ||
|
|
||
| const stream = new ReadableStream({ | ||
| async pull(controller) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
|
|
||
| let text = decoder.decode(value, { stream: true }); | ||
| text = replaceToolNamesInText(text); | ||
| controller.enqueue(encoder.encode(text)); | ||
| }, | ||
| }); | ||
|
|
||
| return new Response(stream, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: response.headers, | ||
| }); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| patchedFetch.__opencodeAnthropicPatched = true; | ||
| globalThis.fetch = patchedFetch; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The installAnthropicFetchPatch function duplicates significant logic that already exists in the loader.fetch override (lines 571-843). Both functions perform identical operations: header manipulation, beta header construction, tool name normalization, model ID normalization, metadata injection, and streaming response transformation. This creates a maintenance burden where bug fixes or changes must be applied to both locations. Consider extracting the shared logic into reusable helper functions or consolidating into a single implementation.
index.mjs
Outdated
| async pull(controller) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
|
|
||
| let text = decoder.decode(value, { stream: true }); | ||
| text = replaceToolNamesInText(text); | ||
| controller.enqueue(encoder.encode(text)); | ||
| }, |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the streaming response transformation, the decoder is called with { stream: true } on line 416, but the controller never calls decoder.decode('', { stream: false }) after the stream ends. This can cause incomplete character sequences at the end of the stream to be lost. When the final chunk is read and done is true (line 411), the controller closes immediately without flushing any remaining bytes in the decoder's internal buffer. Consider decoding any remaining bytes before closing the controller.
| async "chat.params"(input, output) { | ||
| const providerId = input.provider?.id ?? ""; | ||
| if (providerId && !providerId.includes("anthropic")) return; | ||
|
|
||
| const options = output.options ?? {}; | ||
| output.options = options; | ||
|
|
||
| const env = globalThis.process?.env ?? {}; | ||
| const platform = globalThis.process?.platform ?? "linux"; | ||
| const os = | ||
| env.OPENCODE_STAINLESS_OS ?? | ||
| (platform === "darwin" | ||
| ? "Darwin" | ||
| : platform === "win32" | ||
| ? "Windows" | ||
| : platform === "linux" | ||
| ? "Linux" | ||
| : platform); | ||
|
|
||
| const existingHeaders = options.headers; | ||
| const headers = | ||
| existingHeaders instanceof Headers | ||
| ? new Headers(existingHeaders) | ||
| : { ...(existingHeaders ?? {}) }; | ||
|
|
||
| const getHeader = (name) => { | ||
| if (headers instanceof Headers) return headers.get(name); | ||
| const lower = name.toLowerCase(); | ||
| for (const [key, value] of Object.entries(headers)) { | ||
| if (key.toLowerCase() === lower) return value; | ||
| } | ||
| return undefined; | ||
| }; | ||
|
|
||
| const setHeader = (name, value) => { | ||
| if (!value) return; | ||
| if (headers instanceof Headers) { | ||
| headers.set(name, value); | ||
| return; | ||
| } | ||
| headers[name] = value; | ||
| }; | ||
|
|
||
| const incomingBeta = getHeader("anthropic-beta") || ""; | ||
| const incomingBetasList = incomingBeta | ||
| .split(",") | ||
| .map((b) => b.trim()) | ||
| .filter(Boolean); | ||
| const mergedBetasList = incomingBetasList.filter((beta) => | ||
| [ | ||
| "oauth-2025-04-20", | ||
| "interleaved-thinking-2025-05-14", | ||
| "claude-code-20250219", | ||
| ].includes(beta), | ||
| ); | ||
| if (!mergedBetasList.includes("oauth-2025-04-20")) { | ||
| mergedBetasList.push("oauth-2025-04-20"); | ||
| } | ||
| if (!mergedBetasList.includes("interleaved-thinking-2025-05-14")) { | ||
| mergedBetasList.push("interleaved-thinking-2025-05-14"); | ||
| } | ||
| if (mergedBetasList.length > 0) { | ||
| setHeader("anthropic-beta", mergedBetasList.join(",")); | ||
| } | ||
|
|
||
| setHeader("user-agent", "claude-cli/2.1.2 (external, cli)"); | ||
| setHeader("x-app", "cli"); | ||
| setHeader("anthropic-dangerous-direct-browser-access", "true"); | ||
| setHeader( | ||
| "x-stainless-arch", | ||
| env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64", | ||
| ); | ||
| setHeader("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js"); | ||
| setHeader("x-stainless-os", os); | ||
| setHeader( | ||
| "x-stainless-package-version", | ||
| env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0", | ||
| ); | ||
| setHeader( | ||
| "x-stainless-runtime", | ||
| env.OPENCODE_STAINLESS_RUNTIME ?? "node", | ||
| ); | ||
| setHeader( | ||
| "x-stainless-runtime-version", | ||
| env.OPENCODE_STAINLESS_RUNTIME_VERSION ?? | ||
| globalThis.process?.version ?? | ||
| "v24.3.0", | ||
| ); | ||
| setHeader( | ||
| "x-stainless-retry-count", | ||
| env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0", | ||
| ); | ||
| setHeader( | ||
| "x-stainless-timeout", | ||
| env.OPENCODE_STAINLESS_TIMEOUT ?? "600", | ||
| ); | ||
| if (options.stream && !getHeader("x-stainless-helper-method")) { | ||
| setHeader("x-stainless-helper-method", "stream"); | ||
| } | ||
|
|
||
| options.headers = headers; | ||
|
|
||
| const metadataUserId = await resolveMetadataUserId(); | ||
| if (metadataUserId) { | ||
| const metadata = | ||
| options.metadata && typeof options.metadata === "object" | ||
| ? { ...options.metadata } | ||
| : {}; | ||
| if (!metadata.user_id) metadata.user_id = metadataUserId; | ||
| options.metadata = metadata; | ||
| } | ||
|
|
||
| const selectedModel = options.model ?? input.model?.id; | ||
| if (selectedModel) { | ||
| options.model = normalizeModelId(selectedModel); | ||
| } | ||
|
|
||
| if (Array.isArray(options.tools)) { | ||
| options.tools = options.tools.map((tool) => ({ | ||
| ...tool, | ||
| name: tool?.name ? normalizeToolNameForClaude(tool.name) : tool?.name, | ||
| })); | ||
| } else if (options.tools && typeof options.tools === "object") { | ||
| const mappedTools = {}; | ||
| for (const [key, value] of Object.entries(options.tools)) { | ||
| const mappedKey = normalizeToolNameForClaude(key); | ||
| const mappedValue = | ||
| value && typeof value === "object" | ||
| ? { | ||
| ...value, | ||
| name: value.name | ||
| ? normalizeToolNameForClaude(value.name) | ||
| : mappedKey, | ||
| } | ||
| : value; | ||
| mappedTools[mappedKey] = mappedValue; | ||
| } | ||
| options.tools = mappedTools; | ||
| } | ||
| if (options.tool_choice) { | ||
| delete options.tool_choice; | ||
| } | ||
| }, |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The chat.params hook duplicates significant logic from both installAnthropicFetchPatch and the loader.fetch override: header construction (lines 917-1010), metadata injection (lines 1012-1020), model ID normalization (lines 1022-1025), tool name normalization (lines 1027-1048), and tool_choice deletion (lines 1049-1051). This is the third location where this logic appears, creating a severe maintenance burden. Any changes to the request normalization logic must now be replicated across three separate functions.
index.mjs
Outdated
| function normalizeToolNameForClaude(name) { | ||
| if (!name) return name; | ||
| const mapped = CLAUDE_CODE_TOOL_NAMES.get(name.toLowerCase()); | ||
| const pascal = mapped ?? toPascalCase(name); | ||
| if (pascal && pascal !== name) { | ||
| TOOL_NAME_CACHE.set(pascal, name); | ||
| } | ||
| return pascal; | ||
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tool name cache (TOOL_NAME_CACHE) grows unbounded. Every time a tool name is normalized via normalizeToolNameForClaude, the mapping is stored in the cache (lines 39-41), but the cache is never cleared. In a long-running process that handles many different tools, this could lead to excessive memory usage. Consider implementing a cache size limit or using a WeakMap if the tool names are object-based, or periodically clearing the cache.
index.mjs
Outdated
| } catch { | ||
| // ignore parse errors |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When parsing the request body, if JSON parsing fails (line 369), the error is silently caught and ignored. However, if the body was intended to be JSON but is malformed, this could result in requests being sent with incorrect data. At minimum, consider logging parse errors in development mode or adding a comment explaining why it's safe to ignore these errors (e.g., if non-JSON bodies are expected).
| } catch { | |
| // ignore parse errors | |
| } catch (error) { | |
| // Some requests legitimately have non-JSON string bodies; in those cases JSON.parse will | |
| // fail and we intentionally leave the body unchanged. Log in development to aid debugging | |
| // when malformed JSON was intended. | |
| if ( | |
| typeof process !== "undefined" && | |
| process?.env?.NODE_ENV !== "production" | |
| ) { | |
| // eslint-disable-next-line no-console | |
| console.warn( | |
| "Failed to parse request body as JSON; sending original body unmodified.", | |
| error, | |
| ); | |
| } |
Normalize Anthropic requests to Claude Code headers, betas, metadata, model IDs, and tool casing, while rewriting streaming responses back to OpenCode expectations so UI rendering and OAuth validation succeed.
|
any particular reason why this would only allow to access some sonnet model even when opus is requested ? |
|
That's likely a subscription tier limit,
What's your plan tier? Some tiers have Opus rate limits. |
- Fix SSE stream chunk boundary issue with event-based buffering - Add null check for URL extraction in handleAnthropicRequest - Use baseFetch in exchange() and create_api_key to prevent infinite loop - Add TOOL_NAME_CACHE size limit (1000) with LRU-like eviction - Add debug logging (OPENCODE_DEBUG=true) instead of silent error swallowing - Fix code#state parsing to handle edge cases safely - Add explanatory comments for intentional mutations and API limitations - Refactor duplicated logic into shared helper functions
Max20, claude code is ok, plenty of quotas left |
|
Can you share more details?
Sonnet is missing from MODEL_ID_OVERRIDES, I'll add it if that's the issue. |
|
Just added Sonnet mapping. Can you pull the latest and test? @stranger2904
|
|
Integrated latest, still the same: Sonnet % opencode run --model anthropic/claude-opus-4-5-20251101 "What's your model name, OPUS or sonnet, reply one word only" Sonnet Opus |
|
Investigated this, and confirmed it's not a PR #15 issue. Claude Code injects model info into system prompt:
OpenCode doesn't do this. Claude can't see the API request's This is an OpenCode core issue, not the auth plugin. |
|
So this one is actually something I was investigating as well at some point a few weeks ago for other project. But you can also do: “What’s your knowledge cutoff date” “when was opus 4.5 released” my understanding that it might be something with TLS fingerprints/ or pre-API request which Claude code is sending even before first prompt being sent out. |
|
I can say that prior to yesterday- replies from both open code / Claude code were matching |
Interesting theory, but I compared the actual traffic via mitm: Claude Code system prompt:
OpenCode system prompt:
The API request body shows Claude can't introspect the API request's model parameter, it only knows what's in its context. So when asked "what model are you", it's guessing based on its training, not actual runtime info. If Anthropic were silently routing to Sonnet, the response quality/speed would be noticeably different, and the API response headers would likely differ too. This is fixable in OpenCode core by injecting model info into the system prompt like Claude Code does. |
|
Ok, I have mitm setup as well, let me try to test this |
|
Is it possible to use anthropic agent sdk to power opencode just for anthropic's model? I think this will be a perma and risky free solution. |
|
@aw338WoWmUI No, Claude Agent SDK explicitly doesn't support OAuth/subscription auth: From docs:
Only API key auth is supported. Same policy, different wrapper. |
|
Isn't this missing the "AskUserQuestion" tool in CLAUDE_CODE_TOOL_NAMES? opencode has an equivalent tool called question in the latest versions |
I added it just now. Thanks |
|
This isn't the right way of doing this this is more of a hack than anything |
|
my solution was just to proxy the request to the claude binary, so technically claude is just responding directly to the opencode plugin to intercept back to the runtime tloop |
- Add accept: application/json header - Ensure tools array is always present (empty if none) - Remove temperature parameter (Claude Code doesn't send it) - Strip cache_control from system blocks
|
(Just in case anyone doubt about this patch and re-routing or else, than maybe change the model under-the-hood) One way to fast check and know (for sure) if you are into sonnet-4.5 or 'anything else'. Set temperature and top_p, sonnet-4.5 fails (Sonnet 4.5 doesn't allow both parameters) |
index.mjs
Outdated
| "org:create_api_key user:profile user:inference", | ||
| ); | ||
| url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback"); | ||
| url.searchParams.set("scope", "org:create_api_key user:profile user:inference"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
claude code now adds user:sessions:claude_code scope
- Add tokenRefreshPromise caching to prevent concurrent refresh calls - Update auth.expires after token refresh (was missing) - Add user:sessions:claude_code to OAuth scope (matches Claude Code CLI) - Add AskUserQuestion to response tool name regex - Add TextDecoder flush to handle incomplete multi-byte sequences
|
Just pushed a fix addressing several issues found during code review: Token Refresh Issues (by Copilot review)
OAuth Scope (by @aryasaatvik review) Response Transform Streaming Feel free to report any error! |
Summary
tool_choiceand injectmetadata.user_idfrom~/.claude.jsonto satisfy OAuth validation.Why
Anthropic now validates Claude Code–specific request shape for OAuth tokens. Matching only tool prefixes is insufficient; headers, betas, metadata, tool casing, and model IDs must also align to avoid 400s and missing UI output.
Test Plan
git clone https://github.com/deveworld/opencode-anthropic-auth"plugin": ["file:///path/to/opencode-anthropic-auth/index.mjs"]OPENCODE_DISABLE_DEFAULT_PLUGINS=true opencode/v1/messagesand verify streaming renders in the UI.