mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
* 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>
1057 lines
34 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|
|
}
|