Files
OmniRoute/open-sse/executors/claude-web.ts
Diego Rodrigues de Sa e Souza 7db430a352 Release v3.8.14 (#3340)
* chore(release): open v3.8.14 development cycle

Version bump 3.8.13 -> 3.8.14 (root + electron + open-sse + openapi + lockfiles).
Seed the v3.8.14 changelog with the four post-tag hotfixes that shipped to
Docker/Electron in v3.8.13 but missed the immutable npm 3.8.13 (#3336 SSRF /
CodeQL #323, #3334/#3335/#3339 Electron packaging). i18n CHANGELOG mirrors get
the in-progress placeholder section.

* feat: add per-provider custom headers support for OpenAI/Anthropic-compatible nodes (#3338)

Integrated into release/v3.8.14

* fix: Kiro Builder ID token import fails with Bad credentials (#3333)

Integrated into release/v3.8.14 — adds Builder ID cached-creds + OIDC refresh path for Kiro token import, with regression tests (#3333).

* Improve code quality: auto-pr/docstrings-1780792063 (#3337)

Integrated into release/v3.8.14 — docstring for context analytics route re-export.

* fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 upstream) (#3329) (#3341)

NVIDIA NIM does not host minimaxai/minimax-m3 — every request returns
404 page not found, while sibling minimaxai/minimax-m2.7 on the same provider
works. Advertising a model that 404s is a catalog bug; remove it from the nvidia
tier (it remains on the tiers that actually serve MiniMax M3). Re-add only once
NVIDIA serves it.

Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com>

* fix(cli): write OpenCode config to ~/.config on all platforms incl. Windows (#3330) (#3343)

resolveOpencodeConfigDir used %APPDATA% on Windows, but OpenCode reads its
config from XDG ~/.config/opencode/ on every platform (on Windows:
%USERPROFILE%\.config\opencode\, NOT %APPDATA%). So a Windows user who
configured OpenCode via the dashboard had the file written where OpenCode never
looks — it silently had no effect.

Use the XDG path (XDG_CONFIG_HOME || ~/.config) unconditionally. Update the UI
note + route JSDoc, and flip the three tests that encoded the old %APPDATA%
behavior (t40 per-platform + card-note, cli-runtime-extended getCliConfigPaths).

Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com>

* fix(proxy): make auto-selection fallback opt-in (#3332) (#3344)

selectWorkingProxyFallback (Step 11 of resolveProxyForConnection) listed ALL
registry proxies, ignoring assignments and per-connection proxy_enabled, and
returned the first working one with level:'autoSelect'. So a single proxy added
to the registry silently became a global fallback for every connection's traffic.

Gate it behind a new PROXY_AUTO_SELECT_ENABLED feature flag (default off): the
fallback now no-ops unless the operator opts in. No registry proxy becomes a
silent global default anymore.

Co-authored-by: hertznsk <hertznsk@users.noreply.github.com>

* fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3328) (#3342)

MiniMax M3 via the opencode provider (oc/minimax-m3-free) appeared blind:
image inputs didn't reach the model, while the same model in Cline could
see them. Verified empirically that MiniMax M3 on the opencode upstream IS
multimodal -- a base64 image is described correctly (it returns 403 only
for remote image URLs, which it doesn't accept).

Root cause: OmniRoute treated MiniMax M3 as a non-vision model in two
places, so when compression was active the image was replaced with a text
placeholder before dispatch:
- compression's modelSupportsVision() heuristic (lite.ts) only matched
  gpt-4/4o/claude-3/gemini/vision -- minimax was absent -> replaceImageUrls
  stripped the image.
- the opencode minimax-m3-free catalog entry lacked supportsVision, so the
  combo vision-capability gate could also exclude/mishandle it.

Add 'minimax-m3' to the vision heuristic and supportsVision: true to the
opencode minimax-m3-free entry. TDD: a failing-then-passing test in
compression/lite.test.ts proves replaceImageUrls now keeps images for
minimax-m3 ids, plus a registry assertion mirroring the #2822 qwen test.

Reported-by: @mikmaneggahommie

* docs(i18n): translate 25 core documentation files to Indonesian (#3348)

Integrated into release/v3.8.14 — Indonesian i18n docs.

* fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350)

Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332).

* fix(provider-proxy): honor per-account proxy toggles (#3349)

Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED.

* fix(dashboard): remove duplicate Distribute Proxies button on provider page (#3352)

* fix(providers): reduce proxy label noise (#3346)

Integrated into release/v3.8.14 — reduce proxy label noise + a11y (aria-label/sr-only).

* fix(duckduckgo): restore bare Response contract and rebase onto release/v3.8.14 (#3323)

Integrated into release/v3.8.14 — browser-backed cookie providers (duckduckgo/claude-web) with restored executor contract + unit tests.

* fix(noauth): expose only usable model aliases (#3345)

Integrated into release/v3.8.14 — noauth usable-alias filtering + registry alias plumbing (veo-free).

* fix(dashboard): stop infinite config-load loop on Hermes Agent detail page (#3353)

* fix(electron): tree-kill the server on exit/update to release the omniroute.exe lock (#3347) (#3354)

* chore(release): finalize v3.8.14 changelog + clear release-gate drift

- CHANGELOG: finalize the v3.8.14 section (date, full New Features/Bug Fixes/
  Maintenance coverage of all 16 cycle commits, Contributors hall of 12).
- docs: document OMNIROUTE_BROWSER_POOL + WEB_COOKIE_USE_BROWSER (#3323) in
  .env.example + ENVIRONMENT.md; regenerate the id/llm.txt strict mirror (#3348
  had translated it; llm.txt mirrors must match root).
- test(proxy-fetch): #3323 made tlsClient.available a computed getter — stub it
  via Object.defineProperty instead of assignment (5 tests were red on the base).

* fix(translator): coerce Gemini functionDeclaration parameters to an OBJECT schema (#3357) (#3360)

* fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358)

Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering).

* docs(changelog): add #3358 Gemini textual tool-call normalization to v3.8.14

* fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361)

The Analytics page discarded the server's error body on a non-OK response and
rendered a generic "An error occurred", so users (and maintainers) could not see
why /api/usage/analytics 500'd after an upgrade. Now the route returns the real
reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via
a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy
error shapes.

Reported-by: @superti4r

---------

Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com>
Co-authored-by: Dong Mengzhe <154944819+Lang-Qiu@users.noreply.github.com>
Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com>
Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com>
Co-authored-by: hertznsk <hertznsk@users.noreply.github.com>
Co-authored-by: Krisna Santosa <54174372+KrisnaSantosa15@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Ardem2025 <ardemb22@gmail.com>
2026-06-07 07:20:02 -03:00

1057 lines
34 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";
// ─── 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/146.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;
}
// Default model when not specified
const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
// ─── Types ──────────────────────────────────────────────────────────────────
/**
* Extended credentials to include organization and conversation context
*/
interface ClaudeWebRequestPayload {
prompt: string;
model: string;
timezone: string;
personalized_styles: Array<{
type: string;
key: string;
name: string;
nameKey: string;
prompt: string;
summary: string;
summaryKey: string;
isDefault: boolean;
}>;
locale: string;
tools: Array<{
name?: string;
description?: string;
input_schema?: Record<string, unknown>;
integration_name?: string;
is_mcp_app?: boolean;
type?: string;
}>;
turn_message_uuids: {
human_message_uuid: string;
assistant_message_uuid: string;
};
attachments: unknown[];
effort: string;
files: unknown[];
sync_sources: unknown[];
rendering_mode: string;
thinking_mode: string;
create_conversation_params: {
name: string;
model: string;
include_conversation_preferences: boolean;
paprika_mode: unknown;
compass_mode: unknown;
is_temporary: boolean;
enabled_imagine: boolean;
tool_search_mode: string;
};
}
/**
* Stream chunk from Claude Web API
*/
interface ClaudeWebStreamChunk {
type?: string;
index?: number;
completion?: string;
stop_reason?: string | null;
model?: string;
delta?: {
type?: string;
text?: string;
};
[key: string]: unknown;
}
// ─── 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="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"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;
}
/**
* Generate UUIDs for turn message tracking
*/
function generateMessageUUIDs() {
return {
human_message_uuid: randomUUID(),
assistant_message_uuid: randomUUID(),
};
}
/**
* Get default tool definitions for Claude Web API
*/
function getDefaultTools(): ClaudeWebRequestPayload["tools"] {
return [
{
name: "show_widget",
description: "Display interactive widgets and visualizations",
input_schema: {
type: "object",
properties: {
widget_type: {
type: "string",
description: "Type of widget to display",
},
},
},
integration_name: "visualize",
is_mcp_app: true,
},
{
name: "read_me",
description: "Read and reference documents",
input_schema: {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the file to read",
},
},
},
integration_name: "visualize",
is_mcp_app: false,
},
{
type: "web_search_v0",
name: "web_search",
},
{
type: "artifacts_v0",
name: "artifacts",
},
{
type: "repl_v0",
name: "repl",
},
{ type: "widget", name: "weather_fetch" },
{ type: "widget", name: "recipe_display_v0" },
{ type: "widget", name: "places_map_display_v0" },
{ type: "widget", name: "message_compose_v1" },
{ type: "widget", name: "ask_user_input_v0" },
{ type: "widget", name: "recommend_claude_apps" },
{ type: "widget", name: "places_search" },
{ type: "widget", name: "fetch_sports_data" },
];
}
/**
* Get default personalized style
*/
function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personalized_styles"] {
return [
{
type: "default",
key: "Default",
name: "Normal",
nameKey: "normal_style_name",
prompt: "Normal\n",
summary: "Default responses from Claude",
summaryKey: "normal_style_summary",
isDefault: true,
},
];
}
/**
* Transform OpenAI format to Claude Web format
*/
function transformToClaude(body: Record<string, unknown>, model: string): ClaudeWebRequestPayload {
const messages = Array.isArray(body.messages) ? body.messages : [];
// Extract the last user message as the prompt
let prompt = "";
for (const msg of messages) {
if (typeof msg === "object" && msg !== null) {
const message = msg as Record<string, unknown>;
if (message.role === "user") {
prompt = String(message.content || "");
}
}
}
if (!prompt.trim()) {
throw new Error("No user message found in request");
}
return {
prompt,
model: model || DEFAULT_CLAUDE_MODEL,
timezone: "Asia/Jakarta",
personalized_styles: getDefaultPersonalizedStyle(),
locale: "en-US",
tools: getDefaultTools(),
turn_message_uuids: generateMessageUUIDs(),
attachments: [],
effort: "low",
files: [],
sync_sources: [],
rendering_mode: "messages",
thinking_mode: "off",
create_conversation_params: {
name: "",
model: model || DEFAULT_CLAUDE_MODEL,
include_conversation_preferences: true,
paprika_mode: null,
compass_mode: null,
is_temporary: false,
enabled_imagine: true,
tool_search_mode: "auto",
},
};
}
/**
* Transform Claude Web response to OpenAI format
*/
function transformFromClaude(
claudeContent: string,
model: string,
stopReason?: string
): Record<string, unknown> {
return {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
content: claudeContent,
},
finish_reason: stopReason === "end_turn" ? "stop" : null,
logprobs: null,
},
],
};
}
/**
* 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,
};
}
}
}