Files
2026-07-13 13:39:12 +08:00

834 lines
29 KiB
TypeScript

/**
* 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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>).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<string, string> {
const headers: Record<string, string> = {
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<string> {
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<boolean> {
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<string | null> {
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, unknown>): string {
const messages = Array.isArray(body.messages)
? (body.messages as Array<Record<string, unknown>>)
: [];
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<Uint8Array> | null | undefined
): Promise<Response> {
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<Uint8Array>({
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<string, unknown>;
// Content block delta — contains the actual text.
if (parsed.type === "content_block_delta") {
const delta = parsed.delta as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>;
if (parsed.type === "content_block_delta") {
const delta = parsed.delta as Record<string, unknown> | 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<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
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<string, unknown>;
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" ||
/<title>\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,
};
}
}
}