UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

341 lines (340 loc) 14.4 kB
/** * Shared OAuth fetch wrapper for Anthropic API requests. * * Extracted from `providers/anthropic.ts` so that any provider or proxy layer * that needs OAuth-authenticated Anthropic requests can reuse the same logic. * * Applies full "cloaking" to make requests indistinguishable from the * official Claude CLI / CLIProxyAPI, which is required for OAuth + tools * to work correctly. * * @module proxy/oauthFetch */ import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, MCP_TOOL_PREFIX, } from "../auth/anthropicOAuth.js"; import { logger } from "../utils/logger.js"; import { createProxyFetch } from "./proxyFetch.js"; // Re-export constants for consumers that previously imported them alongside // the function from `providers/anthropic.ts`. export { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX }; function resolveOAuthRequestUrl(input) { try { if (typeof input === "string" || input instanceof URL) { return new URL(input.toString()); } if (input instanceof Request) { return new URL(input.url); } } catch { return null; } return null; } function mergeRequestHeaders(input, init) { const requestHeaders = new Headers(); if (input instanceof Request) { input.headers.forEach((value, key) => { requestHeaders.set(key, value); }); } if (!init?.headers) { return requestHeaders; } if (init.headers instanceof Headers) { init.headers.forEach((value, key) => { requestHeaders.set(key, value); }); return requestHeaders; } if (Array.isArray(init.headers)) { for (const [key, value] of init.headers) { if (typeof value !== "undefined") { requestHeaders.set(key, String(value)); } } return requestHeaders; } for (const [key, value] of Object.entries(init.headers)) { if (typeof value !== "undefined") { requestHeaders.set(key, String(value)); } } return requestHeaders; } function applyOAuthHeaders(requestHeaders, getToken, includeOptionalBetas, skipBodyTransform) { const existingBetas = (requestHeaders.get("anthropic-beta") ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); const requiredBetas = ["oauth-2025-04-20"]; if (!skipBodyTransform) { requiredBetas.push(...(includeOptionalBetas ? CLAUDE_CODE_OAUTH_BETAS.filter((beta) => beta !== "oauth-2025-04-20") : [])); } requestHeaders.set("authorization", `Bearer ${getToken()}`); requestHeaders.set("anthropic-beta", [...new Set([...existingBetas, ...requiredBetas])].join(",")); requestHeaders.delete("x-api-key"); if (skipBodyTransform) { return; } requestHeaders.set("user-agent", CLAUDE_CLI_USER_AGENT); requestHeaders.set("anthropic-version", "2023-06-01"); requestHeaders.set("accept", "application/json"); requestHeaders.set("anthropic-dangerous-direct-browser-access", "true"); requestHeaders.set("x-app", "cli"); requestHeaders.set("connection", "keep-alive"); requestHeaders.set("x-stainless-retry-count", "0"); requestHeaders.set("x-stainless-runtime-version", "v24.3.0"); requestHeaders.set("x-stainless-package-version", "0.74.0"); requestHeaders.set("x-stainless-runtime", "node"); requestHeaders.set("x-stainless-lang", "js"); requestHeaders.set("x-stainless-arch", process.arch === "x64" ? "x64" : process.arch); requestHeaders.set("x-stainless-os", process.platform === "darwin" ? "MacOS" : process.platform === "win32" ? "Windows" : "Linux"); requestHeaders.set("x-stainless-timeout", "600"); } async function resolveOAuthRequestBody(input, init) { const sourceRequest = input instanceof Request ? input : undefined; const method = init?.method ?? sourceRequest?.method; let body = init?.body; if (body === undefined && sourceRequest && method !== "GET" && method !== "HEAD") { const contentType = sourceRequest.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { body = (await sourceRequest.clone().text()) || undefined; } else { body = sourceRequest.clone().body ?? undefined; } } return { sourceRequest, method, body: body ?? undefined }; } function transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix) { const parsed = JSON.parse(body); if (enableMcpPrefix) { if (parsed.tools && Array.isArray(parsed.tools)) { parsed.tools = parsed.tools.map((tool) => ({ ...tool, name: tool.name ? `${MCP_TOOL_PREFIX}${tool.name}` : tool.name, })); } 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) => { const b = block; if (b.type === "tool_use" && b.name) { return { ...b, name: `${MCP_TOOL_PREFIX}${b.name}`, }; } return block; }); } return msg; }); } } if (parsed.tool_choice?.type === "any" || parsed.tool_choice?.type === "tool") { delete parsed.thinking; } const agentBlock = { type: "text", text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", }; // Normalise `system` to an array and APPEND billing + agent blocks. // IMPORTANT: We append (not prepend) to preserve the client's cache // prefix chain. Anthropic's prompt caching uses prefix matching — if // we insert anything before the client's system blocks, we invalidate // all cached content (tools, system prompt, message history). // // Claude Code sends a billing block with a `cch=<hash>` value that // changes on every request. We remove any existing billing/agent // blocks from their positions and always append our stable // Claude-Code-shaped versions at the end. if (parsed.system) { if (typeof parsed.system === "string") { parsed.system = [{ type: "text", text: parsed.system }]; } if (Array.isArray(parsed.system)) { // Find and remove existing billing/agent blocks from wherever // the client placed them (typically at system[0]) const billingIdx = parsed.system.findIndex((b) => typeof b.text === "string" && b.text.includes("x-anthropic-billing-header")); const agentIdx = parsed.system.findIndex((b) => typeof b.text === "string" && b.text.includes("Claude Agent SDK")); const billingBlock = { type: "text", text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text), }; // Remove in reverse index order so indices stay valid const indicesToRemove = [billingIdx, agentIdx] .filter((i) => i >= 0) .sort((a, b) => b - a); for (const idx of indicesToRemove) { parsed.system.splice(idx, 1); } // Always append deterministic billing + agent blocks at the end parsed.system = [...parsed.system, billingBlock, agentBlock]; } } else { const billingBlock = { type: "text", text: buildStableClaudeCodeBillingHeader(), }; parsed.system = [billingBlock, agentBlock]; } const token = getToken(); const stableId = parsed.metadata?.user_id ?? token.substring(0, Math.min(20, token.length)); const identity = getOrCreateClaudeCodeIdentity(stableId, { existingUserId: parsed.metadata?.user_id, preferredSessionId: requestHeaders.get("x-claude-code-session-id") ?? undefined, }); parsed.metadata = { ...parsed.metadata, user_id: identity.metadataUserId, }; requestHeaders.set("x-claude-code-session-id", identity.sessionId); return JSON.stringify(parsed); } async function injectOtelHeaders(requestHeaders) { try { const { propagation: otelPropagation, context: otelContext } = await import("@opentelemetry/api"); const carrier = {}; otelPropagation.inject(otelContext.active(), carrier); for (const [key, value] of Object.entries(carrier)) { if (!requestHeaders.has(key)) { requestHeaders.set(key, value); } } } catch { // OTel not available — skip silently } } function rewriteMcpPrefixedStreamingResponse(response) { if (!response.body) { return response; } const reader = response.body.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); const responseHeaders = new Headers(response.headers); responseHeaders.delete("content-length"); let carry = ""; const stream = new ReadableStream({ async pull(controller) { const { done, value } = await reader.read(); if (done) { if (carry) { controller.enqueue(encoder.encode(carry.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'))); carry = ""; } controller.close(); return; } const chunkText = decoder.decode(value, { stream: true }); const combined = carry + chunkText; const partialMatch = combined.match(/"name"\s*:\s*"mcp_[^"]*$/); let safeText; if (partialMatch && partialMatch.index !== undefined) { safeText = combined.slice(0, partialMatch.index); carry = combined.slice(partialMatch.index); } else { const lastQuote = combined.lastIndexOf('"'); const safeLen = lastQuote >= 0 ? lastQuote + 1 : combined.length; safeText = combined.slice(0, safeLen); carry = combined.slice(safeLen); } const replaced = safeText.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'); if (replaced) { controller.enqueue(encoder.encode(replaced)); } }, async cancel(reason) { await reader.cancel(reason); }, }); return new Response(stream, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); } async function executeOAuthFetch(input, init, getToken, includeOptionalBetas, enableMcpPrefix, skipBodyTransform) { const requestUrl = resolveOAuthRequestUrl(input); if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) { requestUrl.searchParams.set("beta", "true"); } const requestHeaders = mergeRequestHeaders(input, init); applyOAuthHeaders(requestHeaders, getToken, includeOptionalBetas, skipBodyTransform); logger.debug("[createOAuthFetch] Making OAuth request:", { url: requestUrl?.toString() || input.toString(), hasAuthorization: requestHeaders.has("authorization"), authType: "Bearer", anthropicBeta: requestHeaders.get("anthropic-beta"), userAgent: requestHeaders.get("user-agent"), }); const { sourceRequest, method, body: initialBody, } = await resolveOAuthRequestBody(input, init); let body = initialBody; if (body && typeof body === "string" && !skipBodyTransform) { try { body = transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix); } catch { // Ignore JSON parse errors — pass body through unchanged } } requestHeaders.delete("content-length"); await injectOtelHeaders(requestHeaders); const proxyFetch = createProxyFetch(); const response = await proxyFetch(requestUrl?.toString() || (input instanceof Request ? input.url : input.toString()), { ...init, method, body, signal: init?.signal ?? sourceRequest?.signal, headers: requestHeaders, }); return enableMcpPrefix ? rewriteMcpPrefixedStreamingResponse(response) : response; } // --------------------------------------------------------------------------- // Main factory // --------------------------------------------------------------------------- /** * Creates a custom fetch function for OAuth-authenticated requests. * This wrapper applies all required transformations for OAuth mode: * - Uses Authorization: Bearer header (NOT x-api-key) * - Adds OAuth-required beta headers * - Sets User-Agent to Claude CLI * - Adds ?beta=true query parameter to /v1/messages * - Injects billing header & agent block into system prompt * - Injects Claude-Code-shaped user ID into metadata * - Adds Stainless SDK headers for fingerprint matching * - Disables thinking when tool_choice is forced * * Accepts a getter function instead of a static token so that refreshed * tokens are picked up automatically on each request. * * @param getToken - Function returning the current OAuth access token * @param includeOptionalBetas - Whether to include optional beta headers (default true) * @param enableMcpPrefix - Whether to apply mcp_ prefix/strip logic to tool names (default false) * @param skipBodyTransform - When true, skip ALL body modifications (billing header, user ID, tool prefix). * Used for proxy passthrough where the request body must be forwarded as-is. */ export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMcpPrefix = false, skipBodyTransform = false) { return async (input, init) => executeOAuthFetch(input, init, getToken, includeOptionalBetas, enableMcpPrefix, skipBodyTransform); }