/** * ClaudeWebExecutor — Claude Web Session Provider * * Routes requests through Claude's web interface using session credentials, * translating between OpenAI chat completions format and Claude's real API format. * * Real API Structure: * Endpoint: https://claude.ai/api/organizations/{orgId}/chat_conversations/{convId}/completion * Method: POST * Content-Type: application/json * Accept: text/event-stream * * Auth pipeline (per request): * 1. Extract session cookie and device ID from credentials * 2. Build conversation URL with orgId and convId * 3. Construct full request payload with model, tools, UUID references * 4. Make authenticated POST request to Claude Web API * 5. Handle SSE response stream with proper message parsing * * Response is streamed as server-sent events (SSE format). */ import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { tlsFetchClaude } from "../services/claudeTlsClient.ts"; import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { tryBackedChat } from "../services/browserBackedChat.ts"; import { type ClaudeWebRequestPayload, transformToClaude, transformFromClaude, } from "./claude-web/payload.ts"; // ─── Constants ────────────────────────────────────────────────────────────── const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; const CLAUDE_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Session cookie constants const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; /** * Read the Claude Web session cookie from the credentials object. * * Lookup order (most-specific first): * 1. `credentials.cookie` — direct field (programmatic / older callers) * 2. `credentials.apiKey` — what the dashboard form posts (the * connection row's `api_key` column stores the cookie string after * encryption; `getProviderCredentials()` decrypts and surfaces it * back as `apiKey`) * 3. `credentials.providerSpecificData.cookie` — escape hatch for * callers that route the cookie through the per-provider metadata * * Without the apiKey fallback, the executor could never see a real * cookie through the standard /api/v1/chat/completions path — the * dashboard posts the cookie in the connection's `apiKey` field, but * this executor was historically reading `cookie` only. */ function readClaudeWebCookie(credentials: unknown): string { if (!credentials || typeof credentials !== "object") return ""; const c = credentials as Record; const direct = typeof c.cookie === "string" ? c.cookie : ""; if (direct.trim()) return direct; const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; if (apiKey.trim()) return apiKey; const psd = c.providerSpecificData; if (psd && typeof psd === "object") { const nested = (psd as Record).cookie; if (typeof nested === "string" && nested.trim()) return nested; } return ""; } /** * Read the optional Claude Web device ID from the credentials object. * Mirrors `readClaudeWebCookie` so callers can use the same priority * chain (direct → apiKey → providerSpecificData). */ function readClaudeWebDeviceId(credentials: unknown): string | undefined { if (!credentials || typeof credentials !== "object") return undefined; const c = credentials as Record; if (typeof c.deviceId === "string" && c.deviceId.trim()) return c.deviceId; const psd = c.providerSpecificData; if (psd && typeof psd === "object") { const nested = (psd as Record).deviceId; if (typeof nested === "string" && nested.trim()) return nested; } return undefined; } // ─── Helper Functions ─────────────────────────────────────────────────────── /** * Build browser-like headers for Claude Web API */ function getBrowserHeaders(deviceId?: string): Record { const headers: Record = { Accept: "text/event-stream", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", "Cache-Control": "no-cache", "Content-Type": "application/json", Origin: "https://claude.ai", Pragma: "no-cache", Priority: "u=1, i", Referer: "https://claude.ai/new", "Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"Linux"', "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": CLAUDE_USER_AGENT, // Anthropic-specific headers "anthropic-client-platform": "web_claude_ai", }; if (deviceId) { headers["anthropic-device-id"] = deviceId; } return headers; } /** * Normalize cookie header for Claude Web API */ function normalizeClaudeSessionCookie(rawValue: string): string { return normalizeSessionCookieHeader(rawValue, CLAUDE_SESSION_COOKIE_NAME); } /** * Normalize cookie and auto-inject cf_clearance if missing */ async function normalizeClaudeSessionCookieWithAutoRefresh( rawValue: string, options?: { allowAutoSolve?: boolean; log?: any } ): Promise { let normalized = normalizeClaudeSessionCookie(rawValue); // Check if cf_clearance is already in the cookie if (normalized.includes("cf_clearance=")) { return normalized; } // If auto-solve is enabled, try to solve Turnstile and get fresh cf_clearance if (options?.allowAutoSolve !== false) { try { options?.log?.info?.("CLAUDE-WEB", "cf_clearance missing, attempting to solve Turnstile..."); const cfClearance = await getCfClearanceToken(); // Append cf_clearance to existing cookies const cfCookie = `cf_clearance=${cfClearance}`; normalized = normalized ? `${normalized}; ${cfCookie}` : cfCookie; options?.log?.info?.("CLAUDE-WEB", "cf_clearance injected successfully"); } catch (error) { const message = error instanceof Error ? error.message : String(error); options?.log?.warn?.("CLAUDE-WEB", `cf_clearance injection failed: ${message}`); // Continue anyway - the retry wrapper will handle 403 } } return normalized; } /** * Verify session is still valid by checking if the organizations endpoint * returns a successful response. Claude's API does not have a /api/auth/session * endpoint (unlike ChatGPT), so we use /api/organizations which requires a * valid session cookie and returns 200 only with valid credentials. */ async function verifyCookieValidity( cookieHeader: string, deviceId: string | undefined, signal?: AbortSignal ): Promise { try { const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; const response = await tlsFetchClaude(CLAUDE_WEB_ORGS_URL, { method: "GET", headers: { ...getBrowserHeaders(deviceId), Cookie: cookieHeader, }, timeoutMs: FETCH_TIMEOUT_MS, signal: combinedSignal, }); return response.status === 200; } catch (error) { return false; } } /** * Get user's organization ID from session */ async function getOrganizationId( cookieHeader: string, deviceId: string | undefined, signal?: AbortSignal ): Promise { try { const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; const response = await tlsFetchClaude(CLAUDE_WEB_ORGS_URL, { method: "GET", headers: { ...getBrowserHeaders(deviceId), Cookie: cookieHeader, }, timeoutMs: FETCH_TIMEOUT_MS, signal: combinedSignal, }); if (response.status !== 200) { return null; } const data = JSON.parse(response.text ?? "[]") as Array<{ id: string; uuid?: string; [key: string]: unknown; }>; return data?.[0]?.uuid || data?.[0]?.id || null; } catch (error) { return null; } } function shouldUseBrowserBacked(): boolean { const flag = process.env.WEB_COOKIE_USE_BROWSER; if (flag === "1" || flag === "true" || flag === "on") return true; const poolFlag = process.env.OMNIROUTE_BROWSER_POOL; return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; } function extractLastUserText(body: Record): string { const messages = Array.isArray(body.messages) ? (body.messages as Array>) : []; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { const content = messages[i].content; if (typeof content === "string") return content; if (Array.isArray(content)) { return content .map((c) => (typeof c === "object" && c && "text" in c ? String(c.text) : "")) .filter(Boolean) .join("\n"); } } } return "Reply with OK"; } /** * Read Claude Web SSE chunks from an upstream response body and pipe them * through transformFromClaude to produce OpenAI chat.completion.chunk SSE. * * The upstream body may arrive as a ReadableStream directly (tlsBody) or * via upstreamResp.body (browser-backed path, where the Response type is * accurate). tlsBody takes priority when non-null. * * Returns a Response whose body is the transformed SSE stream. The client * receives standard OpenAI-format streaming chunks. */ async function buildClaudeStreamingResponse( upstreamResp: Response, model: string, log: | { warn?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } | null | undefined, tlsBody: ReadableStream | null | undefined ): Promise { const src = tlsBody ?? upstreamResp.body; if (!src) { return new Response( JSON.stringify({ error: { message: "No upstream response body available", type: "upstream_error", }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); } let finished = false; const transformed = new ReadableStream({ async start(controller) { const reader = src!.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE lines are separated by \n or \r\n; Claude sends // data: {...}\n\n (double newline separating events). const lines = buffer.split("\n"); // Keep the last potentially-incomplete line in the buffer. buffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith("data: ")) continue; const jsonStr = trimmed.slice(6); // strip "data: " try { const parsed = JSON.parse(jsonStr) as Record; // Content block delta — contains the actual text. if (parsed.type === "content_block_delta") { const delta = parsed.delta as Record | undefined; const text = delta?.text as string | undefined; if (text) { const chunk = transformFromClaude(text, model); const out = `data: ${JSON.stringify(chunk)}\n\n`; controller.enqueue(new TextEncoder().encode(out)); } } // message_stop — final event from Claude. else if (parsed.type === "message_stop") { const chunk = transformFromClaude("", model, "end_turn"); const out = `data: ${JSON.stringify(chunk)}\n\n`; controller.enqueue(new TextEncoder().encode(out)); finished = true; } // message_delta — may carry a stop_reason. else if (parsed.type === "message_delta") { const delta = parsed.delta as Record | undefined; const stopReason = delta?.stop_reason as string | undefined; if (stopReason) { const chunk = transformFromClaude("", model, stopReason); const out = `data: ${JSON.stringify(chunk)}\n\n`; controller.enqueue(new TextEncoder().encode(out)); } } } catch { // Skip lines that aren't valid JSON (metadata, ping, etc.) } } } // Flush remaining buffer. if (buffer.trim()) { const trimmed = buffer.trim(); if (trimmed.startsWith("data: ")) { const jsonStr = trimmed.slice(6); try { const parsed = JSON.parse(jsonStr) as Record; if (parsed.type === "content_block_delta") { const delta = parsed.delta as Record | undefined; const text = delta?.text as string | undefined; if (text) { const chunk = transformFromClaude(text, model); controller.enqueue( new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`) ); } } } catch { /* skip */ } } } // Send terminal DONE marker if we haven't sent a message_stop. if (!finished) { controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); } } catch (err) { log?.error?.("CLAUDE-WEB-STREAM", `Stream error: ${String(err)}`); controller.error(err); } finally { try { reader.releaseLock(); } catch { /* ok */ } try { controller.close(); } catch {} } }, }); return new Response(transformed, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } // ─── Main Executor Class ──────────────────────────────────────────────────── export class ClaudeWebExecutor extends BaseExecutor { constructor() { super("claude-web", { baseUrl: CLAUDE_WEB_API_BASE, }); } /** * Test connection to Claude Web API */ async testConnection( credentials: Record, signal?: AbortSignal ): Promise { try { const rawCookie = readClaudeWebCookie(credentials); if (!rawCookie.trim()) { return false; } const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { allowAutoSolve: false, }); const deviceId = readClaudeWebDeviceId(credentials); return await verifyCookieValidity(cookieHeader, deviceId, signal); } catch (error) { return false; } } /** * Get user's organization ID from session */ async execute({ model, body, stream: _stream, credentials, signal, log }: ExecuteInput) { const bodyObj = (body || {}) as Record; try { // Validate input if (!credentials || typeof credentials !== "object") { const errorResp = new Response( JSON.stringify({ error: { message: "Invalid credentials", type: "invalid_request_error", }, }), { status: 400, statusText: "Bad Request", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: "", headers: {}, transformedBody: bodyObj, }; } const rawCookie = readClaudeWebCookie(credentials); if (!rawCookie.trim()) { const errorResp = new Response( JSON.stringify({ error: { message: "Missing session cookie", type: "authentication_error", }, }), { status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: "", headers: {}, transformedBody: bodyObj, }; } const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { allowAutoSolve: true, log, }); const deviceId = readClaudeWebDeviceId(credentials); // Transform request to Claude format let claudePayload: ClaudeWebRequestPayload; try { claudePayload = transformToClaude(bodyObj, model); } catch (transformError) { const errorResp = new Response( JSON.stringify({ error: { message: transformError instanceof Error ? transformError.message : "Invalid request format", type: "invalid_request_error", }, }), { status: 400, statusText: "Bad Request", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: "", headers: {}, transformedBody: bodyObj, }; } // Get organization and conversation IDs let orgId = (credentials as any)?.orgId as string | undefined; let conversationId = (credentials as any)?.conversationId as string | undefined; if (!orgId) { orgId = await getOrganizationId(cookieHeader, deviceId, signal); if (!orgId) { log?.warn?.("CLAUDE-WEB", "Could not retrieve organization ID, using fallback"); // Fallback: use empty org ID, API might create conversation orgId = ""; } } if (!conversationId) { // Generate a new conversation ID if not provided conversationId = randomUUID(); } // Prepare browser-emulated headers (used by both paths) const headers = getBrowserHeaders(deviceId); // Browser-backed path: opt-in via OMNIROUTE_BROWSER_POOL=on or // WEB_COOKIE_USE_BROWSER=1. Routes the chat through a shared // Playwright/Cloakbrowser page with the user's session cookies // injected, so Claude's Cloudflare Turnstile / session fingerprint // checks are satisfied by a real browser context. This is the // only way to get HTTP 200 from a sandbox/VPS IP where the // pasted cf_clearance is bound to a different fingerprint and // Cloudflare refuses the request. The browser's fetch hits the // /completion endpoint directly (the conversation id is created // by the page itself), so the placeholder in the matcher is // harmless. if (shouldUseBrowserBacked()) { const userText = extractLastUserText(bodyObj); const completionUrl = orgId ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/PLACEHOLDER/completion` : `${CLAUDE_WEB_API_BASE}/chat_conversations/PLACEHOLDER/completion`; const result = await tryBackedChat({ poolKey: "claude-web", chatPageUrl: "https://claude.ai/new", chatUrl: completionUrl, chatUrlMatchDomain: "claude.ai", cookieString: rawCookie, cookieDomain: ".claude.ai", userMessage: userText, inputSelector: "div[contenteditable='true']", postSubmitWaitMs: 15000, signal: signal ?? null, }); if (result.status > 0) { // Wrap captured SSE body as a Response so the existing // stream parser (transformFromClaude) can be reused. const upstreamResp = new Response(result.body, { status: result.status, headers: { "Content-Type": result.contentType || "text/event-stream", }, }); // Reuse the streaming transformer from below. return { response: await buildClaudeStreamingResponse(upstreamResp, model, log, null), url: completionUrl, headers, transformedBody: claudePayload, }; } const errorResp = new Response( JSON.stringify({ error: { message: `Claude Web browser-backed chat captured no upstream response (timing: ${JSON.stringify( result.timing )})`, type: "upstream_error", }, }), { status: 502, headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: completionUrl, headers, transformedBody: claudePayload, }; } // Build completion URL const completionUrl = orgId && conversationId ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/${conversationId}/completion` : `${CLAUDE_WEB_API_BASE}/chat_conversations/new/completion`; // Prepare request const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; log?.debug?.("CLAUDE-WEB", `Making request to ${completionUrl}`); // cf_clearance is already injected via normalizeClaudeSessionCookieWithAutoRefresh above const fetchResponse = await tlsFetchClaude(completionUrl, { method: "POST", headers: { ...headers, Cookie: cookieHeader, }, body: JSON.stringify(claudePayload), timeoutMs: FETCH_TIMEOUT_MS, stream: true, signal: combinedSignal, }); // Handle errors if (fetchResponse.status < 200 || fetchResponse.status >= 300) { log?.error?.("CLAUDE-WEB", `HTTP ${fetchResponse.status}`); if (fetchResponse.status === 401) { const errorResp = new Response( JSON.stringify({ error: { message: "Session expired or invalid", type: "authentication_error", }, }), { status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: completionUrl, headers, transformedBody: claudePayload, }; } if (fetchResponse.status === 429) { const errorResp = new Response( JSON.stringify({ error: { message: "Rate limited by Claude Web API", type: "rate_limit_error", }, }), { status: 429, statusText: "Too Many Requests", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: completionUrl, headers, transformedBody: claudePayload, }; } // Read the upstream body. `tlsFetchClaude` writes non-SSE error // bodies (Cloudflare challenge pages, HTML rate-limit responses, // Anthropic JSON errors) into `body`, not `text` — `text` is only // populated for non-streaming requests. Reading the wrong field // here produced the cryptic "[403]: Claude Web API error: " with // no diagnostic info. The earlier code did exactly that and made // 403 from Cloudflare look indistinguishable from a real Claude // rejection. // // `body` may be a ReadableStream (SSE path) even when status is // non-2xx — drain a small prefix so we can classify the failure // (Cloudflare challenge vs upstream JSON) without buffering the // whole response. If the read fails or times out, fall back to an // empty string and let the generic error message stand. let errorText = ""; let cfMitigated: string | null = null; try { if (fetchResponse.body) { const reader = fetchResponse.body.getReader(); const decoder = new TextDecoder(); const chunks: Uint8Array[] = []; let total = 0; const maxBytes = 2048; // enough to identify a Cloudflare challenge while (total < maxBytes) { const { value, done } = await reader.read(); if (done || !value) break; chunks.push(value); total += value.byteLength; } // Release the reader so the underlying tls-client connection // can be reused for the next request. Without cancel() the // connection can hang on a still-open stream. try { reader.releaseLock(); } catch { /* already released */ } if (chunks.length === 1) { errorText = decoder.decode(chunks[0]); } else { const combined = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } errorText = decoder.decode(combined); } } else if (fetchResponse.text) { errorText = fetchResponse.text; } } catch { errorText = ""; } // Surface Cloudflare challenge pages as a distinct, actionable // error so dashboards / logs don't show an empty "Claude Web API // error:" body. The most common cause is a sandbox / data-center // IP that Cloudflare has flagged; the cf_clearance cookie bound // to a different IP won't pass the challenge. cfMitigated = fetchResponse.headers.get("cf-mitigated"); const isCloudflareChallenge = fetchResponse.status === 403 && (cfMitigated === "challenge" || /\s*Just a moment/i.test(errorText) || /<title>\s*Attention Required/i.test(errorText)); let errorMessage: string; if (isCloudflareChallenge) { errorMessage = "Claude Web returned a Cloudflare bot-management challenge " + `(cf-mitigated=${cfMitigated ?? "challenge"}). ` + "The sandbox / VPS IP appears to be flagged; the cf_clearance " + "cookie pasted from a residential IP won't pass. Probe from a " + "residential network, or use the official Anthropic API " + "(provider: 'claude') instead."; } else { // Trim the body to keep the error message compact but still // useful — most real Claude errors are short JSON; Cloudflare // HTML bodies are caught above. const trimmed = errorText.trim().slice(0, 500); errorMessage = trimmed ? `Claude Web API error (${fetchResponse.status}): ${trimmed}` : `Claude Web API error (${fetchResponse.status}) with no response body`; } const errorResp = new Response( JSON.stringify({ error: { message: errorMessage, type: isCloudflareChallenge ? "cloudflare_challenge" : "api_error", code: isCloudflareChallenge ? "cf_mitigated_challenge" : `HTTP_${fetchResponse.status}`, ...(cfMitigated ? { cfMitigated } : {}), }, }), { status: fetchResponse.status, statusText: "HTTP Error", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: completionUrl, headers, transformedBody: claudePayload, }; } // Stream the response (shared with the browser-backed path). return { response: await buildClaudeStreamingResponse(fetchResponse, model, log, fetchResponse.body), url: completionUrl, headers, transformedBody: claudePayload, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log?.error?.("CLAUDE-WEB", `Fetch failed: ${errorMessage}`); const errorResp = new Response( JSON.stringify({ error: { message: `Claude Web connection failed: ${sanitizeErrorMessage(errorMessage)}`, type: "api_connection_error", }, }), { status: 500, statusText: "Internal Server Error", headers: { "Content-Type": "application/json" }, } ); return { response: errorResp, url: "", headers: {}, transformedBody: bodyObj, }; } } }