diff --git a/index.mjs b/index.mjs index a053f8e..ffe17b5 100644 --- a/index.mjs +++ b/index.mjs @@ -1,61 +1,970 @@ import { generatePKCE } from "@openauthjs/openauth/pkce"; +// ============================================================================ +// Constants +// ============================================================================ + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const BASE_FETCH = globalThis.fetch?.bind(globalThis); +const FETCH_PATCH_STATE = { + installed: false, + getAuth: null, + client: null, +}; + +const MODEL_ID_OVERRIDES = new Map([ + ["claude-sonnet-4-5", "claude-sonnet-4-5-20250929"], + ["claude-opus-4-5", "claude-opus-4-5-20251101"], + ["claude-haiku-4-5", "claude-haiku-4-5-20251001"], +]); +const MODEL_ID_REVERSE_OVERRIDES = new Map( + Array.from(MODEL_ID_OVERRIDES, ([base, full]) => [full, base]), +); + +const CLAUDE_CODE_TOOL_NAMES = new Map([ + ["bash", "Bash"], + ["read", "Read"], + ["edit", "Edit"], + ["write", "Write"], + ["task", "Task"], + ["glob", "Glob"], + ["grep", "Grep"], + ["webfetch", "WebFetch"], + ["websearch", "WebSearch"], + ["todowrite", "TodoWrite"], + ["question", "AskUserQuestion"], +]); +const OPENCODE_TOOL_NAMES = new Map( + Array.from(CLAUDE_CODE_TOOL_NAMES, ([key, value]) => [value, key]), +); + +const TOOL_NAME_CACHE_MAX_SIZE = 1000; +const TOOL_NAME_CACHE = new Map(); +const TOOL_PREFIX_REGEX = /^(?:oc_|mcp_)/i; + +let cachedMetadataUserIdPromise; +let tokenRefreshPromise = null; + +// ============================================================================ +// Debug Logging +// ============================================================================ + +function debugLog(context, error) { + if (globalThis.process?.env?.OPENCODE_DEBUG === "true") { + console.debug(`[opencode-anthropic-auth] ${context}:`, error); + } +} + +// ============================================================================ +// Low-level Utilities +// ============================================================================ + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripToolPrefix(value) { + if (!value) return value; + return value.replace(TOOL_PREFIX_REGEX, ""); +} + +function toPascalCase(value) { + if (!value) return value; + const normalized = value.replace(/[^a-zA-Z0-9]+/g, " "); + const tokens = normalized + .split(" ") + .flatMap((token) => + token + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(" ") + .filter(Boolean), + ); + if (tokens.length === 0) return value; + return tokens + .map((token) => { + const lower = token.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(""); +} + +function toSnakeCase(value) { + if (!value) return value; + return value + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^a-zA-Z0-9]+/g, "_") + .toLowerCase(); +} + +function getBaseFetch() { + return BASE_FETCH ?? globalThis.fetch; +} + +// ============================================================================ +// Environment & Headers +// ============================================================================ + +function getEnvConfig() { + 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); + + return { + os, + arch: env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64", + lang: env.OPENCODE_STAINLESS_LANG ?? "js", + packageVersion: env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0", + runtime: env.OPENCODE_STAINLESS_RUNTIME ?? "node", + runtimeVersion: + env.OPENCODE_STAINLESS_RUNTIME_VERSION ?? + globalThis.process?.version ?? + "v24.3.0", + retryCount: env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0", + timeout: env.OPENCODE_STAINLESS_TIMEOUT ?? "600", + }; +} + +function applyStainlessHeaders(headers, isStream = false) { + const config = getEnvConfig(); + + headers.set("accept", "application/json"); + headers.set("user-agent", "claude-cli/2.1.7 (external, cli)"); + headers.set("x-app", "cli"); + headers.set("anthropic-dangerous-direct-browser-access", "true"); + headers.set("x-stainless-arch", config.arch); + headers.set("x-stainless-lang", config.lang); + headers.set("x-stainless-os", config.os); + headers.set("x-stainless-package-version", config.packageVersion); + headers.set("x-stainless-runtime", config.runtime); + headers.set("x-stainless-runtime-version", config.runtimeVersion); + headers.set("x-stainless-retry-count", config.retryCount); + headers.set("x-stainless-timeout", config.timeout); + + if (isStream) { + headers.set("x-stainless-helper-method", "stream"); + } +} + +function getBetaHeadersForPath(pathname, hasTools = false) { + if (pathname === "/v1/messages") { + // Claude Code only includes claude-code-20250219 when tools are present + if (hasTools) { + return ["claude-code-20250219", "oauth-2025-04-20", "interleaved-thinking-2025-05-14"]; + } + return ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"]; + } + if (pathname === "/v1/messages/count_tokens") { + return [ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "token-counting-2024-11-01", + ]; + } + if (pathname.startsWith("/api/") && pathname !== "/api/hello") { + return ["oauth-2025-04-20"]; + } + return []; +} + +function mergeHeaders(request, init) { + const headers = new Headers(); + + if (request instanceof Request) { + request.headers.forEach((value, key) => headers.set(key, value)); + } + + const initHeaders = init?.headers; + if (initHeaders) { + if (initHeaders instanceof Headers) { + initHeaders.forEach((value, key) => headers.set(key, value)); + } else if (Array.isArray(initHeaders)) { + for (const [key, value] of initHeaders) { + if (value !== undefined) headers.set(key, String(value)); + } + } else { + for (const [key, value] of Object.entries(initHeaders)) { + if (value !== undefined) headers.set(key, String(value)); + } + } + } + + return headers; +} + +function extractUrl(input) { + try { + if (typeof input === "string" || input instanceof URL) { + return new URL(input.toString()); + } + if (input instanceof Request) { + return new URL(input.url); + } + } catch (error) { + debugLog("extractUrl", error); + } + return null; +} + +// ============================================================================ +// Tool Name Normalization +// ============================================================================ + +function normalizeToolNameForClaude(name) { + if (!name) return name; + const stripped = stripToolPrefix(name); + const mapped = CLAUDE_CODE_TOOL_NAMES.get(stripped.toLowerCase()); + const pascal = mapped ?? toPascalCase(stripped); + if (pascal && pascal !== stripped) { + // LRU-like eviction: remove oldest entries when cache is full + if (TOOL_NAME_CACHE.size >= TOOL_NAME_CACHE_MAX_SIZE) { + const firstKey = TOOL_NAME_CACHE.keys().next().value; + TOOL_NAME_CACHE.delete(firstKey); + } + TOOL_NAME_CACHE.set(pascal, stripped); + } + return pascal; +} + +function normalizeToolNameForOpenCode(name) { + if (!name) return name; + const cached = TOOL_NAME_CACHE.get(name); + if (cached) return cached; + return OPENCODE_TOOL_NAMES.get(name) ?? toSnakeCase(name); +} + +/** + * Sanitize tool description to remove opencode-specific strings and file paths. + * This prevents detection by Anthropic that the request is not from Claude Code. + */ +function sanitizeToolDescription(description) { + if (!description || typeof description !== "string") return description; + return description + // Remove absolute file paths (Linux/Mac/Windows) + .replace(/\/(?:home|Users|tmp|var|opt|usr|etc)\/[^\s,)"'\]]+/g, "[path]") + .replace(/[A-Z]:\\[^\s,)"'\]]+/gi, "[path]") + // Replace opencode references + .replace(/opencode/gi, "Claude") + .replace(/OpenCode/g, "Claude Code"); +} + +/** + * Convert tool input_schema property names from camelCase to snake_case. + * Claude Code sends snake_case parameter names (e.g., file_path, old_string). + */ +function normalizeToolInputSchema(inputSchema) { + if (!inputSchema || typeof inputSchema !== "object") return inputSchema; + if (!inputSchema.properties) return inputSchema; + + const result = { ...inputSchema }; + const newProperties = {}; + + for (const [key, value] of Object.entries(result.properties)) { + const snakeKey = toSnakeCase(key); + newProperties[snakeKey] = value; + // Cache the mapping for reverse conversion in responses + if (snakeKey !== key) { + if (TOOL_NAME_CACHE.size >= TOOL_NAME_CACHE_MAX_SIZE) { + const firstKey = TOOL_NAME_CACHE.keys().next().value; + TOOL_NAME_CACHE.delete(firstKey); + } + TOOL_NAME_CACHE.set(snakeKey, key); + } + } + + result.properties = newProperties; + + if (Array.isArray(result.required)) { + result.required = result.required.map(toSnakeCase); + } + + return result; +} + +/** + * Normalize a single tool object for Claude compatibility. + */ +function normalizeTool(tool) { + if (!tool || typeof tool !== "object") return tool; + + const normalized = { ...tool }; + + // Normalize tool name + if (normalized.name) { + normalized.name = normalizeToolNameForClaude(normalized.name); + } + + // Sanitize description + if (normalized.description) { + normalized.description = sanitizeToolDescription(normalized.description); + } + + // Convert parameter names to snake_case (Claude Code uses snake_case) + if (normalized.input_schema) { + normalized.input_schema = normalizeToolInputSchema(normalized.input_schema); + } + + return normalized; +} + +function normalizeTools(tools) { + if (Array.isArray(tools)) { + return tools.map(normalizeTool); + } + + if (tools && typeof tools === "object") { + const mapped = {}; + for (const [key, value] of Object.entries(tools)) { + const mappedKey = normalizeToolNameForClaude(key); + mapped[mappedKey] = normalizeTool(value); + // Ensure the name property matches the key + if (mapped[mappedKey] && typeof mapped[mappedKey] === "object") { + mapped[mappedKey].name = mappedKey; + } + } + return mapped; + } + + return tools; +} + +function normalizeMessagesForClaude(messages) { + if (!Array.isArray(messages)) return messages; + return messages.map((message) => { + if (!message || !Array.isArray(message.content)) return message; + return { + ...message, + content: message.content.map((block) => + block?.type === "tool_use" && block.name + ? { ...block, name: normalizeToolNameForClaude(block.name) } + : block, + ), + }; + }); +} + +function normalizeModelId(id) { + if (!id) return id; + return MODEL_ID_OVERRIDES.get(id) ?? id; +} + +function replaceToolNamesInText(text) { + let output = text.replace(/"name"\s*:\s*"(?:oc_|mcp_)([^"]+)"/g, '"name": "$1"'); + + output = output.replace( + /"name"\s*:\s*"(Bash|Read|Edit|Write|Task|Glob|Grep|WebFetch|WebSearch|TodoWrite|AskUserQuestion)"/g, + (_, name) => `"name": "${normalizeToolNameForOpenCode(name)}"`, + ); + + // Reverse mappings from cache (handles both tool names and parameter names) + for (const [mapped, original] of TOOL_NAME_CACHE.entries()) { + if (mapped && mapped !== original) { + // Tool names: "name": "PascalName" → "name": "original_name" + output = output.replace( + new RegExp(`"name"\\s*:\\s*"${escapeRegExp(mapped)}"`, "g"), + `"name": "${original}"`, + ); + // Parameter names in input: "snake_case": → "camelCase": + // Note: partial_json content is now handled by buffering in processSSEEvent + output = output.replace( + new RegExp(`"${escapeRegExp(mapped)}"\\s*:`, "g"), + `"${original}":`, + ); + } + } + + for (const [full, base] of MODEL_ID_REVERSE_OVERRIDES.entries()) { + output = output.replace( + new RegExp(`"model"\\s*:\\s*"${escapeRegExp(full)}"`, "g"), + `"model": "${base}"`, + ); + } + + return output; +} + +// ============================================================================ +// Request/Response Processing +// ============================================================================ + +function stripCacheControlFromSystem(system) { + if (!Array.isArray(system)) return system; + return system.map((block) => { + if (block && typeof block === "object" && "cache_control" in block) { + const { cache_control, ...rest } = block; + return rest; + } + return block; + }); +} + +/** + * Apply common Claude API normalizations to a request body/options object. + * Mutates the object in place for efficiency. + * @param {Object} body - Request body or options object to normalize + * @param {Object} options - Configuration options + * @param {string|null} options.modelFallback - Fallback model ID if body.model is not set + */ +function applyClaudeNormalization(body, { modelFallback = null } = {}) { + // Normalize model + const modelId = body.model || modelFallback; + if (modelId) { + body.model = normalizeModelId(modelId); + } + + // Normalize tools - ensure empty array if not present (Claude Code always sends tools) + if (body.tools) { + body.tools = normalizeTools(body.tools); + } else { + body.tools = []; + } + + // Normalize messages + if (Array.isArray(body.messages)) { + body.messages = normalizeMessagesForClaude(body.messages); + } + + // Remove temperature - Claude Code doesn't send it + if ("temperature" in body) { + delete body.temperature; + } + + // Remove cache_control from system blocks - Claude Code doesn't use it + if (Array.isArray(body.system)) { + body.system = stripCacheControlFromSystem(body.system); + } + + // OAuth API does not support tool_choice - remove to prevent API errors + if (body.tool_choice) { + delete body.tool_choice; + } +} + +async function normalizeRequestBody(parsed, injectMetadata = false) { + // Sanitize system prompt - server blocks "OpenCode" string + if (parsed.system && Array.isArray(parsed.system)) { + parsed.system = parsed.system.map((item) => { + if (item.type === "text" && item.text) { + return { + ...item, + text: item.text + .replace(/OpenCode/g, "Claude Code") + .replace(/opencode/gi, "Claude"), + }; + } + return item; + }); + } + + // Apply common Claude normalizations + applyClaudeNormalization(parsed); + + if (injectMetadata) { + const userId = await resolveMetadataUserId(); + if (userId) { + parsed.metadata = parsed.metadata && typeof parsed.metadata === "object" + ? { ...parsed.metadata } + : {}; + if (!parsed.metadata.user_id) { + parsed.metadata.user_id = userId; + } + } + } + + return { body: parsed, isStream: !!parsed.stream }; +} + +/** + * Convert snake_case keys to camelCase in a tool input object. + * Uses TOOL_NAME_CACHE for mapping. + */ +function convertToolInputKeys(input) { + if (!input || typeof input !== "object") return input; + + const result = {}; + for (const [key, value] of Object.entries(input)) { + // Look up camelCase version from cache, fallback to original key + const camelKey = TOOL_NAME_CACHE.get(key) ?? key; + result[camelKey] = value; + } + return result; +} + +/** + * Parse SSE event text and extract type and data. + * Returns { eventType, data } or null if not a data event. + */ +function parseSSEEvent(eventText) { + const lines = eventText.split("\n"); + let eventType = null; + let dataLine = null; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataLine = line.slice(5).trim(); + } + } + + if (!dataLine) return null; + + try { + return { eventType, data: JSON.parse(dataLine) }; + } catch { + return null; + } +} /** - * @param {"max" | "console"} mode + * Reconstruct SSE event text from parsed data. */ +function reconstructSSEEvent(eventType, data) { + const lines = []; + if (eventType) { + lines.push(`event: ${eventType}`); + } + lines.push(`data: ${JSON.stringify(data)}`); + return lines.join("\n"); +} + +/** + * Process a single SSE event, handling tool input buffering and transformation. + * Returns { output, emit } where output is the text to emit (or null to suppress) + * and emit is whether to emit now or buffer. + */ +function processSSEEvent(eventText, toolInputBuffers) { + const parsed = parseSSEEvent(eventText); + + // If we can't parse, apply basic text replacement and pass through + if (!parsed) { + return { output: replaceToolNamesInText(eventText), emit: true }; + } + + const { data } = parsed; + const eventType = data.type; + + // Handle input_json_delta: buffer the partial_json, suppress emission + if (eventType === "content_block_delta" && data.delta?.type === "input_json_delta") { + const index = data.index; + const partialJson = data.delta.partial_json ?? ""; + + // Accumulate partial_json + const existing = toolInputBuffers.get(index) ?? ""; + toolInputBuffers.set(index, existing + partialJson); + + // Don't emit this event - we'll emit transformed version at content_block_stop + return { output: null, emit: false }; + } + + // Handle content_block_stop: emit buffered & transformed tool input + if (eventType === "content_block_stop") { + const index = data.index; + const bufferedJson = toolInputBuffers.get(index); + + if (bufferedJson) { + // We have buffered tool input - transform and emit + toolInputBuffers.delete(index); + + try { + const parsedInput = JSON.parse(bufferedJson); + const transformedInput = convertToolInputKeys(parsedInput); + + // Emit a synthetic content_block_delta with the full transformed input + const syntheticDelta = { + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: JSON.stringify(transformedInput), + }, + }; + + const deltaEvent = reconstructSSEEvent("content_block_delta", syntheticDelta); + const stopEvent = replaceToolNamesInText(eventText); + + // Emit both: the transformed delta, then the stop event + return { output: deltaEvent + "\n\n" + stopEvent, emit: true }; + } catch (e) { + debugLog("processSSEEvent.parseBufferedJson", e); + // If parsing fails, emit original buffered content with text replacement + // This shouldn't happen in normal operation + toolInputBuffers.delete(index); + } + } + + // No buffered input or parse failed - just pass through with text replacement + return { output: replaceToolNamesInText(eventText), emit: true }; + } + + // All other events: apply text replacement and pass through + return { output: replaceToolNamesInText(eventText), emit: true }; +} + +function createTransformedResponse(response) { + if (!response.body) return response; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + // Buffer for incomplete SSE events (handles chunk boundary issues) + let buffer = ""; + + // Buffer for tool input JSON per content block index + const toolInputBuffers = new Map(); + + const stream = new ReadableStream({ + async pull(controller) { + // Loop until we have something to emit or stream ends + // This prevents hanging when buffering input_json_delta events + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Flush any remaining bytes from the decoder + const flushed = decoder.decode(new Uint8Array(), { stream: false }); + if (flushed) { + buffer += flushed; + } + // Process any remaining buffered content + if (buffer.length > 0) { + const { output } = processSSEEvent(buffer, toolInputBuffers); + if (output) { + controller.enqueue(encoder.encode(output)); + } + } + // Flush any remaining tool input buffers + for (const [index, bufferedJson] of toolInputBuffers.entries()) { + try { + const parsedInput = JSON.parse(bufferedJson); + const transformedInput = convertToolInputKeys(parsedInput); + const syntheticDelta = { + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: JSON.stringify(transformedInput), + }, + }; + const deltaEvent = reconstructSSEEvent("content_block_delta", syntheticDelta); + controller.enqueue(encoder.encode(deltaEvent + "\n\n")); + } catch (e) { + debugLog("createTransformedResponse.flushToolBuffers", e); + } + } + toolInputBuffers.clear(); + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + + // SSE events are separated by double newlines + // Process only complete events, keep incomplete ones in buffer + const events = buffer.split("\n\n"); + + // Keep the last potentially incomplete event in buffer + buffer = events.pop() ?? ""; + + // Process each complete event + const outputs = []; + for (const eventText of events) { + if (!eventText.trim()) continue; + + const { output, emit } = processSSEEvent(eventText, toolInputBuffers); + if (emit && output) { + outputs.push(output); + } + } + + // Emit all outputs and return (let pull be called again) + if (outputs.length > 0) { + controller.enqueue(encoder.encode(outputs.join("\n\n") + "\n\n")); + return; + } + // If no outputs, continue loop to read more data + } + }, + }); + + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +// ============================================================================ +// OAuth Token Management +// ============================================================================ + +async function resolveMetadataUserId() { + const env = globalThis.process?.env ?? {}; + const direct = env.OPENCODE_ANTHROPIC_USER_ID ?? env.CLAUDE_CODE_USER_ID ?? env.ANTHROPIC_USER_ID; + if (direct) return direct; + if (cachedMetadataUserIdPromise) return cachedMetadataUserIdPromise; + + cachedMetadataUserIdPromise = (async () => { + const home = env.HOME ?? env.USERPROFILE; + if (!home) return undefined; + + try { + const { readFile } = await import("node:fs/promises"); + const data = JSON.parse(await readFile(env.OPENCODE_CLAUDE_CONFIG ?? `${home}/.claude.json`, "utf8")); + const userId = data?.userID; + const accountUuid = data?.oauthAccount?.accountUuid; + + let sessionId; + const cwd = globalThis.process?.cwd?.(); + if (cwd && data?.projects?.[cwd]?.lastSessionId) { + sessionId = data.projects[cwd].lastSessionId; + } else if (data?.projects) { + for (const project of Object.values(data.projects)) { + if (project?.lastSessionId) { + sessionId = project.lastSessionId; + break; + } + } + } + + if (userId && accountUuid && sessionId) { + return `user_${userId}_account_${accountUuid}_session_${sessionId}`; + } + } catch (error) { + debugLog("resolveMetadataUserId", error); + } + return undefined; + })(); + + return cachedMetadataUserIdPromise; +} + +async function refreshOAuthToken(auth, baseFetch) { + const response = await baseFetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: auth.refresh, + client_id: CLIENT_ID, + }), + }); + if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`); + return response.json(); +} + +async function ensureOAuthAccess(getAuth, client) { + if (!getAuth) return null; + const auth = await getAuth(); + if (!auth || auth.type !== "oauth") return auth ?? null; + if (auth.access && auth.expires > Date.now()) return auth; + + const json = await refreshOAuthToken(auth, getBaseFetch()); + const newExpires = Date.now() + json.expires_in * 1000; + + if (client?.auth?.set) { + await client.auth.set({ + path: { id: "anthropic" }, + body: { + type: "oauth", + refresh: json.refresh_token, + access: json.access_token, + expires: newExpires, + }, + }); + } + + // Update auth object in place (intentional mutation for caller's reference) + // This ensures the caller's reference stays in sync with stored credentials + auth.refresh = json.refresh_token; + auth.access = json.access_token; + auth.expires = newExpires; + return auth; +} + +// ============================================================================ +// Anthropic Request Handler (shared logic) +// ============================================================================ + +async function handleAnthropicRequest(input, init, auth, baseFetch) { + const requestUrl = extractUrl(input); + + // Safety check: if URL extraction failed, fall back to base fetch + if (!requestUrl) { + debugLog("handleAnthropicRequest", "Failed to extract URL from input"); + return baseFetch(input, init); + } + + const requestHeaders = mergeHeaders(input instanceof Request ? input : null, init); + + // Auth & stainless headers + requestHeaders.set("authorization", `Bearer ${auth.access}`); + requestHeaders.delete("x-api-key"); + + // Process body + const requestInit = init ?? {}; + let body = requestInit.body; + + if (!body && input instanceof Request) { + try { + body = await input.clone().text(); + } catch (error) { + debugLog("handleAnthropicRequest.cloneBody", error); + body = requestInit.body; + } + } + + let isStream = false; + let hasTools = false; + if (body && typeof body === "string") { + try { + const parsed = JSON.parse(body); + // Check if tools array has items BEFORE normalization + hasTools = Array.isArray(parsed.tools) && parsed.tools.length > 0; + const result = await normalizeRequestBody( + parsed, + requestUrl.pathname === "/v1/messages", + ); + body = JSON.stringify(result.body); + isStream = result.isStream; + } catch (error) { + debugLog("handleAnthropicRequest.normalizeBody", error); + } + } + + // Set beta headers AFTER body parsing to know if tools are present + const betaHeaders = getBetaHeadersForPath(requestUrl.pathname, hasTools); + if (betaHeaders.length > 0) { + requestHeaders.set("anthropic-beta", betaHeaders.join(",")); + } else { + requestHeaders.delete("anthropic-beta"); + } + + applyStainlessHeaders(requestHeaders, isStream); + + // Beta query param + if ( + (requestUrl.pathname === "/v1/messages" || requestUrl.pathname === "/v1/messages/count_tokens") && + !requestUrl.searchParams.has("beta") + ) { + requestUrl.searchParams.set("beta", "true"); + } + + // Build request + 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); + return createTransformedResponse(response); +} + +// ============================================================================ +// Global Fetch Patch +// ============================================================================ + +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) => { + const requestUrl = extractUrl(input); + + if (!requestUrl || requestUrl.hostname !== "api.anthropic.com") { + return baseFetch(input, init); + } + + let auth = null; + try { + auth = await ensureOAuthAccess(FETCH_PATCH_STATE.getAuth, FETCH_PATCH_STATE.client); + } catch (error) { + debugLog("installAnthropicFetchPatch.ensureOAuthAccess", error); + auth = null; + } + + const requestHeaders = mergeHeaders(input instanceof Request ? input : null, init); + const authorization = requestHeaders.get("authorization") ?? ""; + const shouldPatch = auth?.type === "oauth" || authorization.includes("sk-ant-oat"); + + if (!shouldPatch) { + return baseFetch(input, init); + } + + return handleAnthropicRequest(input, init, auth, baseFetch); + }; + + patchedFetch.__opencodeAnthropicPatched = true; + globalThis.fetch = patchedFetch; +} + +// ============================================================================ +// OAuth Flow +// ============================================================================ + async function authorize(mode) { const pkce = await generatePKCE(); - const url = new URL( `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`, import.meta.url, ); + url.searchParams.set("code", "true"); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("response_type", "code"); - url.searchParams.set( - "redirect_uri", - "https://console.anthropic.com/oauth/code/callback", - ); - url.searchParams.set( - "scope", - "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 user:sessions:claude_code"); url.searchParams.set("code_challenge", pkce.challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("state", pkce.verifier); - return { - url: url.toString(), - verifier: pkce.verifier, - }; + + return { url: url.toString(), verifier: pkce.verifier }; } -/** - * @param {string} code - * @param {string} verifier - */ async function exchange(code, verifier) { - const splits = code.split("#"); - const result = await fetch("https://console.anthropic.com/v1/oauth/token", { + // Safely parse code#state format - handle missing or multiple # characters + const hashIndex = code.indexOf("#"); + const authCode = hashIndex >= 0 ? code.slice(0, hashIndex) : code; + const state = hashIndex >= 0 ? code.slice(hashIndex + 1) : undefined; + + // Use baseFetch to avoid infinite loop if global fetch is already patched + const baseFetch = getBaseFetch(); + const result = await baseFetch("https://console.anthropic.com/v1/oauth/token", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - code: splits[0], - state: splits[1], + code: authCode, + state, grant_type: "authorization_code", client_id: CLIENT_ID, redirect_uri: "https://console.anthropic.com/oauth/code/callback", code_verifier: verifier, }), }); - if (!result.ok) - return { - type: "failed", - }; + + if (!result.ok) return { type: "failed" }; + const json = await result.json(); return { type: "success", @@ -65,232 +974,71 @@ async function exchange(code, verifier) { }; } -/** - * @type {import('@opencode-ai/plugin').Plugin} - */ +// ============================================================================ +// Plugin Export +// ============================================================================ + +/** @type {import('@opencode-ai/plugin').Plugin} */ export async function AnthropicAuthPlugin({ client }) { return { auth: { provider: "anthropic", + async loader(getAuth, provider) { const auth = await getAuth(); + if (auth.type === "oauth") { - // zero out cost for max plan + installAnthropicFetchPatch(getAuth, client); + + // Zero out cost for max plan (mutates provider.models intentionally + // as OpenCode expects this side effect for cost tracking) for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, - }; + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } }; } + return { apiKey: "", - /** - * @param {any} input - * @param {any} init - */ async fetch(input, init) { const auth = await getAuth(); if (auth.type !== "oauth") return fetch(input, init); - if (!auth.access || auth.expires < Date.now()) { - const response = await fetch( - "https://console.anthropic.com/v1/oauth/token", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: auth.refresh, - client_id: CLIENT_ID, - }), - }, - ); - if (!response.ok) { - throw new Error(`Token refresh failed: ${response.status}`); - } - const json = await response.json(); - await client.auth.set({ - path: { - id: "anthropic", - }, - body: { - type: "oauth", - refresh: json.refresh_token, - access: json.access_token, - expires: Date.now() + json.expires_in * 1000, - }, - }); - auth.access = json.access_token; - } - 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)); - } - } - } - } - - const incomingBeta = requestHeaders.get("anthropic-beta") || ""; - const incomingBetasList = incomingBeta - .split(",") - .map((b) => b.trim()) - .filter(Boolean); - - const includeClaudeCode = incomingBetasList.includes( - "claude-code-20250219", - ); - - const mergedBetas = [ - "oauth-2025-04-20", - "interleaved-thinking-2025-05-14", - ...(includeClaudeCode ? ["claude-code-20250219"] : []), - ].join(","); - - requestHeaders.set("authorization", `Bearer ${auth.access}`); - requestHeaders.set("anthropic-beta", mergedBetas); - requestHeaders.set( - "user-agent", - "claude-cli/2.1.2 (external, cli)", - ); - requestHeaders.delete("x-api-key"); - - const TOOL_PREFIX = "mcp_"; - let body = requestInit.body; - if (body && typeof body === "string") { - try { - const parsed = JSON.parse(body); - - // Sanitize system prompt - server blocks "OpenCode" string - if (parsed.system && Array.isArray(parsed.system)) { - parsed.system = parsed.system.map(item => { - if (item.type === 'text' && item.text) { - return { - ...item, - text: item.text - .replace(/OpenCode/g, 'Claude Code') - .replace(/opencode/gi, 'Claude') - }; - } - return item; - }); - } - - // Add prefix to tools definitions - if (parsed.tools && Array.isArray(parsed.tools)) { - parsed.tools = parsed.tools.map((tool) => ({ - ...tool, - name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name, - })); - } - // Add prefix to tool_use blocks in messages - if (parsed.messages && Array.isArray(parsed.messages)) { - parsed.messages = parsed.messages.map((msg) => { - if (msg.content && Array.isArray(msg.content)) { - msg.content = msg.content.map((block) => { - if (block.type === "tool_use" && block.name) { - return { ...block, name: `${TOOL_PREFIX}${block.name}` }; - } - return block; - }); - } - return msg; - }); - } - body = JSON.stringify(parsed); - } catch (e) { - // ignore parse errors - } - } - - let requestInput = input; - 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.pathname === "/v1/messages" && - !requestUrl.searchParams.has("beta") - ) { - requestUrl.searchParams.set("beta", "true"); - requestInput = - input instanceof Request - ? new Request(requestUrl.toString(), input) - : requestUrl; - } + const baseFetch = getBaseFetch(); - const response = await fetch(requestInput, { - ...requestInit, - body, - headers: requestHeaders, - }); - - // Transform streaming response to rename tools back - 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; + if (!auth.access || auth.expires < Date.now()) { + // Prevent race condition: cache the refresh promise + if (!tokenRefreshPromise) { + tokenRefreshPromise = (async () => { + try { + const json = await refreshOAuthToken(auth, baseFetch); + const newExpires = Date.now() + json.expires_in * 1000; + await client.auth.set({ + path: { id: "anthropic" }, + body: { + type: "oauth", + refresh: json.refresh_token, + access: json.access_token, + expires: newExpires, + }, + }); + auth.access = json.access_token; + auth.expires = newExpires; + return json; + } finally { + tokenRefreshPromise = null; } - - let text = decoder.decode(value, { stream: true }); - text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'); - controller.enqueue(encoder.encode(text)); - }, - }); - - return new Response(stream, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); + })(); + } + await tokenRefreshPromise; } - return response; + return handleAnthropicRequest(input, init, auth, baseFetch); }, }; } return {}; }, + methods: [ { label: "Claude Pro/Max", @@ -298,13 +1046,10 @@ export async function AnthropicAuthPlugin({ client }) { authorize: async () => { const { url, verifier } = await authorize("max"); return { - url: url, + url, instructions: "Paste the authorization code here: ", method: "code", - callback: async (code) => { - const credentials = await exchange(code, verifier); - return credentials; - }, + callback: (code) => exchange(code, verifier), }; }, }, @@ -314,14 +1059,17 @@ export async function AnthropicAuthPlugin({ client }) { authorize: async () => { const { url, verifier } = await authorize("console"); return { - url: url, + url, instructions: "Paste the authorization code here: ", method: "code", callback: async (code) => { const credentials = await exchange(code, verifier); if (credentials.type === "failed") return credentials; - const result = await fetch( - `https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, + + // Use baseFetch to avoid patched fetch intercepting this request + const baseFetch = getBaseFetch(); + const result = await baseFetch( + "https://api.anthropic.com/api/oauth/claude_cli/create_api_key", { method: "POST", headers: { @@ -330,6 +1078,7 @@ export async function AnthropicAuthPlugin({ client }) { }, }, ).then((r) => r.json()); + return { type: "success", key: result.raw_key }; }, }; @@ -342,5 +1091,36 @@ export async function AnthropicAuthPlugin({ client }) { }, ], }, + + async "chat.params"(input, output) { + const providerId = input.provider?.id ?? ""; + if (providerId && !providerId.includes("anthropic")) return; + + const options = output.options ?? {}; + output.options = options; + + // Check hasTools BEFORE normalization (applyClaudeNormalization sets empty array) + const hasTools = Array.isArray(options.tools) && options.tools.length > 0; + + // Apply common Claude normalizations + applyClaudeNormalization(options, { modelFallback: input.model?.id }); + + // Headers - set AFTER knowing if tools are present + const headers = options.headers instanceof Headers + ? options.headers + : new Headers(options.headers ?? {}); + + const betaHeaders = getBetaHeadersForPath("/v1/messages", hasTools); + headers.set("anthropic-beta", betaHeaders.join(",")); + applyStainlessHeaders(headers, !!options.stream); + + options.headers = headers; + + // Metadata + const userId = await resolveMetadataUserId(); + if (userId) { + options.metadata = { ...(options.metadata ?? {}), user_id: userId }; + } + }, }; }