mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(claude-web): align session transport and fallback (#8230)
This commit is contained in:
@@ -4,16 +4,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/executors/claude-web-with-auto-refresh.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"open-sse/executors/claude-web.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"open-sse/executors/claudeIdentity.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -1,116 +1,185 @@
|
||||
---
|
||||
title: "Providers — Claude Web"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.49
|
||||
lastUpdated: 2026-07-14
|
||||
---
|
||||
|
||||
# Providers — Claude Web
|
||||
|
||||
## claude-web
|
||||
## `claude-web`
|
||||
|
||||
Web-cookie-based provider for **Claude AI** (`claude.ai`) using session cookie authentication.
|
||||
`claude-web` sends OpenAI-format chat requests through an authenticated `claude.ai`
|
||||
browser session. The executor normalizes the supplied cookie, resolves one authenticated
|
||||
organization, prepares conversation state, selects a direct or browser transport, and
|
||||
strictly translates the upstream SSE response. The orchestration is in
|
||||
`open-sse/executors/claude-web.ts:320`.
|
||||
|
||||
> **New to Web Cookie providers?**
|
||||
>
|
||||
> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, authentication guidance, limitations, and troubleshooting before following this provider-specific guide.
|
||||
|
||||
### How It Works
|
||||
### Model catalog
|
||||
|
||||
1. User pastes their `claude.ai` session cookies into the OmniRoute dashboard
|
||||
2. `ClaudeWebExecutor` transforms OpenAI-format requests to Claude Web API format
|
||||
3. Requests are sent via **`tls-client-node`** with **Chrome 124 TLS fingerprint** to bypass Cloudflare Turnstile
|
||||
4. Responses are streamed back via SSE (`text/event-stream`)
|
||||
The provider registry currently exposes exactly these seven static model IDs
|
||||
(`open-sse/config/providers/registry/claude/web/index.ts:11`):
|
||||
|
||||
### Required Cookies
|
||||
| Model ID | Display name |
|
||||
| --------------------------- | ----------------------- |
|
||||
| `claude-fable-5` | Claude Fable 5 (web) |
|
||||
| `claude-opus-4-8` | Claude Opus 4.8 (web) |
|
||||
| `claude-sonnet-5` | Claude Sonnet 5 (web) |
|
||||
| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 (web) |
|
||||
| `claude-opus-4-7` | Claude Opus 4.7 (web) |
|
||||
| `claude-opus-4-6` | Claude Opus 4.6 (web) |
|
||||
| `claude-sonnet-4-6` | Claude Sonnet 4.6 (web) |
|
||||
|
||||
| Cookie | Purpose | Source |
|
||||
| -------------- | ------------------------------ | -------------------------------------- |
|
||||
| `sessionKey` | Main authentication | `claude.ai` browser session |
|
||||
| `routingHint` | Anthropic routing | `claude.ai` browser session |
|
||||
| `cf_clearance` | Cloudflare Turnstile clearance | Auto-set by Cloudflare after challenge |
|
||||
| `__cf_bm` | Cloudflare bot management | Auto-set by Cloudflare |
|
||||
| `_cfuvid` | Cloudflare visitor ID | Auto-set by Cloudflare |
|
||||
Dynamic model discovery is not implemented for this provider. The list above is the
|
||||
runtime catalog.
|
||||
|
||||
> **Note**: `cf_clearance` is bound to the TLS fingerprint of the browser that solved Cloudflare's Turnstile challenge. The `tls-client-node` library (via `claudeTlsClient.ts`) spoofs a Chrome 124 TLS handshake so the clearance token works from the OmniRoute server.
|
||||
### Credentials and organization resolution
|
||||
|
||||
### API Reference
|
||||
Supply either the full `claude.ai` Cookie header or a bare session value. Bare values are
|
||||
normalized to `sessionKey`; other cookies are preserved if supplied. The executor accepts the
|
||||
cookie through `cookie` or `apiKey` and
|
||||
reads optional `deviceId` and `orgId` values from the connection data
|
||||
(`open-sse/executors/claude-web.ts:72`).
|
||||
|
||||
**Endpoint**: `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
|
||||
If `orgId` is absent, the executor calls `GET https://claude.ai/api/organizations` and uses the first
|
||||
organization returned by the authenticated Claude Web session
|
||||
(`open-sse/executors/claude-web.ts:141`). It fails closed when no valid organization is
|
||||
returned, reports rejected session authorization as 401, and distinguishes a Cloudflare
|
||||
challenge from an authentication failure.
|
||||
|
||||
**Required Headers**:
|
||||
### Conversation operations
|
||||
|
||||
```
|
||||
accept: text/event-stream
|
||||
anthropic-client-platform: web_claude_ai
|
||||
anthropic-device-id: <uuid>
|
||||
content-type: application/json
|
||||
Referer: https://claude.ai/chat/{convId}
|
||||
```
|
||||
The optional top-level `claude_web` object is strict. Unknown fields are rejected. Its
|
||||
accepted fields are defined in `open-sse/executors/claude-web/session.ts:50`:
|
||||
|
||||
**Request Body**:
|
||||
| Field | Meaning |
|
||||
| --------------------- | --------------------------------------------------------- |
|
||||
| `operation` | `completion` by default; use `retry` for a retry turn |
|
||||
| `conversation_id` | Explicit UUID for an existing conversation |
|
||||
| `parent_message_uuid` | Explicit UUID for the parent assistant message |
|
||||
| `timezone` | Valid IANA time-zone name |
|
||||
| `locale` | Structurally valid locale |
|
||||
| `tool_states` | Optional account tool-state array, limited to 128 entries |
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "user message",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"timezone": "Asia/Jakarta",
|
||||
"locale": "en-US",
|
||||
"personalized_styles": [...],
|
||||
"tools": [...],
|
||||
"rendering_mode": "messages",
|
||||
"create_conversation_params": {
|
||||
"name": "",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"is_temporary": false
|
||||
}
|
||||
}
|
||||
```
|
||||
Prepared requests use one of two upstream endpoints
|
||||
(`open-sse/executors/claude-web.ts:203`):
|
||||
|
||||
### Architecture
|
||||
- A new or follow-up turn posts to
|
||||
`POST https://claude.ai/api/organizations/{orgId}/chat_conversations/{conversationId}/completion`.
|
||||
- A retry posts to
|
||||
`POST https://claude.ai/api/organizations/{orgId}/chat_conversations/{conversationId}/retry_completion`.
|
||||
|
||||
```
|
||||
User Cookies (claude.ai)
|
||||
↓
|
||||
OmniRoute Dashboard
|
||||
↓
|
||||
ClaudeWebExecutor (open-sse/executors/claude-web.ts)
|
||||
↓ Request transformation (OpenAI → Claude Web format)
|
||||
↓
|
||||
tlsFetchClaude() (open-sse/services/claudeTlsClient.ts)
|
||||
↓ Chrome 124 TLS fingerprint spoofing
|
||||
↓
|
||||
tls-client-node (Go native binding, koffi)
|
||||
↓
|
||||
claude.ai API
|
||||
↓ SSE stream
|
||||
```
|
||||
A new turn includes `create_conversation_params`. A cached or explicitly linked follow-up
|
||||
includes `parent_message_uuid` and omits `create_conversation_params`. Retry requires both
|
||||
conversation and parent-message state and sends no prompt
|
||||
(`open-sse/executors/claude-web/session.ts:254`). New conversations open the authenticated
|
||||
UI at `/new`; cached or explicitly linked follow-ups open the exact conversation page
|
||||
(`open-sse/executors/claude-web/session.ts:324`).
|
||||
|
||||
Conversation state is an in-memory cache keyed by a SHA-256 account scope and the canonical
|
||||
caller transcript. Entries expire after 30 minutes and the cache is capped at 5,000 entries
|
||||
(`open-sse/executors/claude-web/session.ts:12`). State is committed only after the strict
|
||||
stream parser observes `message_stop`; process restarts discard it. On a cache miss, a
|
||||
multi-message request is serialized into one recovery prompt instead of silently dropping
|
||||
earlier messages.
|
||||
|
||||
Locale and time zone use this precedence: request `claude_web` value, connection value,
|
||||
runtime value, then `en-US` for locale or `UTC` for time zone
|
||||
(`open-sse/executors/claude-web/session.ts:218`).
|
||||
|
||||
### Tools and request payloads
|
||||
|
||||
Direct requests transform only structurally valid OpenAI function tools supplied by the
|
||||
caller. There is no fabricated static default tool list
|
||||
(`open-sse/executors/claude-web/payload.ts:102`).
|
||||
|
||||
Browser requests instead capture the authenticated UI request and retain its account tools,
|
||||
tool states, and personalized styles. Prepared conversation, model, reasoning, prompt, and
|
||||
message UUID fields still override the captured request
|
||||
(`open-sse/executors/claude-web/browserTransport.ts:175`). Browser templates are scoped by a
|
||||
hash of account, organization, cookie, locale, and time zone and expire after 30 minutes
|
||||
(`open-sse/executors/claude-web/browserTransport.ts:11`,
|
||||
`open-sse/executors/claude-web/browserTransport.ts:158`). When a direct request has no caller
|
||||
tools, it can reuse that scoped template; explicit caller tools take precedence
|
||||
(`open-sse/executors/claude-web/browserTransport.ts:214`).
|
||||
|
||||
### Transport selection
|
||||
|
||||
The default path is `sendClaudeWebDirect()`, which calls `tlsFetchClaude()` with the configured
|
||||
Chrome 146 profile and the supplied cookie (`open-sse/services/claudeTlsClient.ts:23`). It does
|
||||
not launch a solver or manufacture a replacement cookie.
|
||||
|
||||
Set `WEB_COOKIE_USE_BROWSER` to `1`, `true`, or `on` to make the account-scoped browser
|
||||
adapter the primary transport. Set `OMNIROUTE_BROWSER_POOL` to one of the same values to
|
||||
allow a recognized Cloudflare 403 challenge to fall back from direct transport to the
|
||||
browser adapter (`open-sse/executors/claude-web.ts:195`). Other HTTP failures do not trigger
|
||||
that fallback.
|
||||
|
||||
The browser adapter keeps cookies inside the same pooled Playwright context, uses the scoped
|
||||
hashed key described above, and sends the completion from that context
|
||||
(`open-sse/executors/claude-web/browserTransport.ts:444`). It never exports a browser-solved
|
||||
cookie into the direct TLS client. Browser retries require a non-expired UI template bound to
|
||||
the same actual Playwright context (`open-sse/executors/claude-web/browserTransport.ts:467`).
|
||||
Browser response reads run incrementally in the authenticated page, honor request cancellation,
|
||||
and cancel the upstream body as soon as it exceeds 16 MiB
|
||||
(`open-sse/executors/claude-web/browserTransport.ts:259`).
|
||||
|
||||
The executor returns a redacted audit projection to the shared request logger: organization,
|
||||
conversation and message UUIDs, prompt text, tool definitions, cookies, and device identifiers
|
||||
are excluded (`open-sse/executors/claude-web.ts:237`,
|
||||
`open-sse/executors/claude-web.ts:252`). Transport exceptions also return a generic connection
|
||||
error rather than the thrown message.
|
||||
|
||||
### SSE behavior
|
||||
|
||||
`createClaudeWebResponse()` handles LF or CRLF framing and multiline `data:` fields. It maps
|
||||
text deltas to `content`, thinking deltas to `reasoning_content`, and known metadata events
|
||||
to the `claude_web` response extension. Each metadata event is projected through its own field
|
||||
allowlist (`open-sse/executors/claude-web/stream.ts:37`). The
|
||||
conversation, parent-message, assistant-message, and operation metadata are also returned in
|
||||
`X-OmniRoute-Claude-Web-*` headers (`open-sse/executors/claude-web/stream.ts:364`).
|
||||
|
||||
The parser fails closed on malformed JSON, upstream `error` events, unknown event types,
|
||||
invalid ordering, content-block mismatches, or EOF before `message_stop`. Streaming output
|
||||
emits one finish chunk and one `[DONE]`; buffered output uses the same parser. The parser treats
|
||||
`message_stop` as terminal immediately, cancels trailing upstream data, and propagates
|
||||
downstream cancellation to the upstream reader (`open-sse/executors/claude-web/stream.ts:461`,
|
||||
`open-sse/executors/claude-web/stream.ts:563`). Unterminated SSE lines and accumulated events
|
||||
are capped at 1 MiB (`open-sse/executors/claude-web/stream.ts:17`,
|
||||
`open-sse/executors/claude-web/stream.ts:62`).
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `src/shared/constants/providers.ts` | Provider registration (WEB_COOKIE_PROVIDERS) |
|
||||
| `src/lib/providers/webCookieAuth.ts` | Cookie utilities (normalize/extract session cookies) |
|
||||
| `open-sse/executors/claude-web.ts` | Executor implementation |
|
||||
| `open-sse/executors/index.ts` | Executor registration |
|
||||
| `open-sse/services/claudeTlsClient.ts` | TLS fingerprint spoofing via tls-client-node |
|
||||
| `open-sse/services/__tests__/claudeTlsClient.test.ts` | TLS client tests |
|
||||
| `tests/unit/claude-web.test.ts` | Executor tests |
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------------- | ------------------------------- |
|
||||
| `open-sse/config/providers/registry/claude/web/index.ts` | Static provider model registry |
|
||||
| `open-sse/executors/claude-web.ts` | Executor orchestration |
|
||||
| `open-sse/executors/claude-web/payload.ts` | Payload and tool transformation |
|
||||
| `open-sse/executors/claude-web/session.ts` | Turn state and transcript cache |
|
||||
| `open-sse/executors/claude-web/transport.ts` | Direct transport adapter |
|
||||
| `open-sse/executors/claude-web/browserTransport.ts` | Account-scoped browser adapter |
|
||||
| `open-sse/executors/claude-web/stream.ts` | Strict SSE translation |
|
||||
| `open-sse/services/claudeTlsClient.ts` | Native TLS transport |
|
||||
| `open-sse/services/browserPool.ts` | Pooled Playwright contexts |
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
node --import tsx/esm --test tests/unit/claude-web.test.ts
|
||||
Run the deterministic Claude Web suite without real credentials:
|
||||
|
||||
# TLS client tests
|
||||
npx vitest run open-sse/services/__tests__/claudeTlsClient.test.ts
|
||||
```powershell
|
||||
node --import tsx/esm --test tests/unit/claude-web-auto-refresh.test.ts tests/unit/claude-web-browser-transport.test.ts tests/unit/claude-web-executor-split.test.ts tests/unit/claude-web-live-alignment.test.ts tests/unit/claude-web-payload-runtime.test.ts tests/unit/claude-web-session.test.ts tests/unit/claude-web-sonnet5-registry-6209.test.ts tests/unit/claude-web-stream.test.ts tests/unit/claude-web-transport.test.ts tests/unit/claude-web.test.ts tests/unit/issue-6662-repro.test.ts
|
||||
```
|
||||
|
||||
The Playwright-dependent cases in `tests/unit/claude-web-auto-refresh.test.ts` are explicitly
|
||||
skipped. This repository does not currently define a credentialed Claude Web live-test
|
||||
script, so those skipped cases are not runtime proof.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Start OmniRoute: `omniroute`
|
||||
2. Go to Dashboard → Providers → Add Provider
|
||||
3. Select "Web Cookie" category
|
||||
4. Choose "Claude Web"
|
||||
5. Paste your full cookie header from `claude.ai` browser DevTools (Network tab → Copy as fetch → Cookie header)
|
||||
1. Start OmniRoute with `npm run dev` or a built installation.
|
||||
2. Open Dashboard → Providers → Add Provider.
|
||||
3. Select the Web Cookie category and Claude Web.
|
||||
4. Paste the full Cookie header copied from an authenticated `claude.ai` request.
|
||||
|
||||
@@ -9,8 +9,16 @@ export const claude_webProvider: RegistryEntry = {
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "claude-sonnet-5", name: "Claude 5 Sonnet (web)", toolCalling: false },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet (web)", toolCalling: false },
|
||||
{ id: "claude-haiku-4-5", name: "Claude 4.5 Haiku (web)", toolCalling: false },
|
||||
{ id: "claude-fable-5", name: "Claude Fable 5 (web)", toolCalling: false },
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8 (web)", toolCalling: false },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (web)", toolCalling: false },
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (web)", toolCalling: false },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 (web)", toolCalling: false },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (web)", toolCalling: false },
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5 (web)",
|
||||
toolCalling: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Claude Web Auto-Refresh Wrapper
|
||||
*
|
||||
* Enhances the existing ClaudeWebExecutor with automatic cf_clearance solving.
|
||||
* Intercepts 403/401 responses and attempts Turnstile challenge solve.
|
||||
*/
|
||||
|
||||
import type { ExecuteInput } from "./base.ts";
|
||||
import { ClaudeWebExecutor } from "./claude-web.ts";
|
||||
import { getCfClearanceToken, getCacheStatus } from "../services/claudeTurnstileSolver.ts";
|
||||
|
||||
class ClaudeWebWithAutoRefresh extends ClaudeWebExecutor {
|
||||
private retryCount = 0;
|
||||
private maxRetries = 2;
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { credentials, log } = input;
|
||||
this.retryCount = 0;
|
||||
return this.executeWithRetry(input);
|
||||
}
|
||||
|
||||
private async executeWithRetry(input: ExecuteInput) {
|
||||
const { credentials, log } = input;
|
||||
|
||||
// Execute request
|
||||
let result = await super.execute(input);
|
||||
|
||||
// If success (200), return immediately
|
||||
if (result.response.status === 200) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If challenge (403) or auth error (401), and retries remain
|
||||
if (
|
||||
(result.response.status === 403 || result.response.status === 401) &&
|
||||
this.retryCount < this.maxRetries
|
||||
) {
|
||||
this.retryCount++;
|
||||
log?.warn?.(
|
||||
"CLAUDE-WEB",
|
||||
`HTTP ${result.response.status} detected - attempt ${this.retryCount}/${this.maxRetries}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Get fresh cf_clearance
|
||||
const cacheStatus = getCacheStatus();
|
||||
const shouldForce = this.retryCount > 1;
|
||||
|
||||
log?.info?.(
|
||||
"CLAUDE-WEB",
|
||||
`Solving Turnstile (cache: ${cacheStatus.hasCached ? `${Math.round((cacheStatus.expiresIn || 0) / 1000)}s left` : "empty"})...`
|
||||
);
|
||||
|
||||
const freshCfClearance = await getCfClearanceToken({ force: shouldForce });
|
||||
|
||||
// Update credentials
|
||||
const rawCookie = String((credentials as any)?.cookie || "");
|
||||
const hasCfClearance = rawCookie.includes("cf_clearance=");
|
||||
|
||||
let newCookie: string;
|
||||
if (hasCfClearance) {
|
||||
// Replace existing cf_clearance
|
||||
newCookie = rawCookie.replace(/cf_clearance=[^;]+/, `cf_clearance=${freshCfClearance}`);
|
||||
} else {
|
||||
// Append new cf_clearance
|
||||
newCookie = `${rawCookie}; cf_clearance=${freshCfClearance}`;
|
||||
}
|
||||
|
||||
log?.info?.("CLAUDE-WEB", "cf_clearance injected, retrying...");
|
||||
|
||||
// Retry with fresh cookie
|
||||
const updatedInput: ExecuteInput = {
|
||||
...input,
|
||||
credentials: {
|
||||
...credentials,
|
||||
cookie: newCookie,
|
||||
},
|
||||
};
|
||||
|
||||
result = await this.executeWithRetry(updatedInput);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
log?.error?.("CLAUDE-WEB", `Auto-refresh failed: ${msg}`);
|
||||
// Fall through to return error response
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
credentials: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Try basic connection first
|
||||
const basicTest = await super.testConnection(credentials, signal);
|
||||
if (basicTest) return true;
|
||||
|
||||
// Try with fresh cf_clearance
|
||||
const rawCookie = String((credentials as any)?.cookie || "");
|
||||
if (!rawCookie.trim()) return false;
|
||||
|
||||
const freshCfClearance = await getCfClearanceToken();
|
||||
const newCookie = rawCookie.includes("cf_clearance=")
|
||||
? rawCookie.replace(/cf_clearance=[^;]+/, `cf_clearance=${freshCfClearance}`)
|
||||
: `${rawCookie}; cf_clearance=${freshCfClearance}`;
|
||||
|
||||
return await super.testConnection({ ...credentials, cookie: newCookie }, signal);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ClaudeWebWithAutoRefresh };
|
||||
export const createClaudeWebExecutor = () => new ClaudeWebWithAutoRefresh();
|
||||
File diff suppressed because it is too large
Load Diff
494
open-sse/executors/claude-web/browserTransport.ts
Normal file
494
open-sse/executors/claude-web/browserTransport.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import {
|
||||
acquireBrowserContext,
|
||||
openPage,
|
||||
type BrowserPoolContextOptions,
|
||||
type PooledContext,
|
||||
} from "../../services/browserPool.ts";
|
||||
import type { ClaudeWebRequestPayload } from "./payload.ts";
|
||||
|
||||
const CLAUDE_WEB_TEMPLATE_TTL_MS = 30 * 60 * 1000;
|
||||
const CLAUDE_WEB_TEMPLATE_MAX = 5000;
|
||||
const MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
const CLAUDE_WEB_INPUT_SELECTOR = "div[contenteditable='true']";
|
||||
|
||||
type Page = import("playwright").Page;
|
||||
|
||||
type BrowserTemplate = {
|
||||
tools: ClaudeWebRequestPayload["tools"];
|
||||
toolStates?: unknown[];
|
||||
personalizedStyles: ClaudeWebRequestPayload["personalized_styles"];
|
||||
};
|
||||
|
||||
type CachedBrowserTemplate = {
|
||||
template: BrowserTemplate;
|
||||
context: PooledContext["context"];
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const browserTemplateCache = new Map<string, CachedBrowserTemplate>();
|
||||
let testNow: number | null = null;
|
||||
|
||||
export interface ClaudeWebTransportRequest {
|
||||
scopeKey: string;
|
||||
organizationId: string;
|
||||
conversationId: string;
|
||||
endpointSuffix: "completion" | "retry_completion";
|
||||
pageUrl: string;
|
||||
url: string;
|
||||
cookieString: string;
|
||||
headers: Record<string, string>;
|
||||
payload: ClaudeWebRequestPayload;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
signal?: AbortSignal | null;
|
||||
}
|
||||
|
||||
export interface ClaudeWebTransportResult {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
/** Buffered only for non-success responses; never returned directly to clients. */
|
||||
bodyText?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeWebBrowserDeps {
|
||||
acquireContext(key: string, options: BrowserPoolContextOptions): Promise<PooledContext>;
|
||||
openPage(pooled: PooledContext): Promise<Page>;
|
||||
fetchResponse(
|
||||
page: Page,
|
||||
input: ClaudeWebBrowserFetchInput
|
||||
): Promise<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Uint8Array;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClaudeWebBrowserFetchInput {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
const defaultDeps: ClaudeWebBrowserDeps = {
|
||||
acquireContext: acquireBrowserContext,
|
||||
openPage,
|
||||
fetchResponse: fetchClaudeWebPageResponse,
|
||||
};
|
||||
|
||||
function now(): number {
|
||||
return testNow ?? Date.now();
|
||||
}
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function expectedRequestUrl(request: ClaudeWebTransportRequest): string {
|
||||
return (
|
||||
"https://claude.ai/api/organizations/" +
|
||||
`${encodeURIComponent(request.organizationId)}/chat_conversations/` +
|
||||
`${encodeURIComponent(request.conversationId)}/${request.endpointSuffix}`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyRequestUrl(request: ClaudeWebTransportRequest): void {
|
||||
if (request.url !== expectedRequestUrl(request)) {
|
||||
throw new Error("Claude Web browser request endpoint does not match prepared state");
|
||||
}
|
||||
}
|
||||
|
||||
function extractBrowserTemplate(uiPayload: Record<string, unknown>): BrowserTemplate {
|
||||
return {
|
||||
tools: Array.isArray(uiPayload.tools)
|
||||
? cloneValue(uiPayload.tools as ClaudeWebRequestPayload["tools"])
|
||||
: [],
|
||||
...(Array.isArray(uiPayload.tool_states)
|
||||
? { toolStates: cloneValue(uiPayload.tool_states) }
|
||||
: {}),
|
||||
personalizedStyles: Array.isArray(uiPayload.personalized_styles)
|
||||
? cloneValue(uiPayload.personalized_styles as ClaudeWebRequestPayload["personalized_styles"])
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function pruneExpiredTemplates(): void {
|
||||
const currentTime = now();
|
||||
for (const [key, entry] of browserTemplateCache) {
|
||||
if (currentTime >= entry.expiresAt) browserTemplateCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberBrowserTemplate(
|
||||
poolKey: string,
|
||||
template: BrowserTemplate,
|
||||
context: PooledContext["context"]
|
||||
): void {
|
||||
pruneExpiredTemplates();
|
||||
if (browserTemplateCache.has(poolKey)) browserTemplateCache.delete(poolKey);
|
||||
while (browserTemplateCache.size >= CLAUDE_WEB_TEMPLATE_MAX) {
|
||||
const oldestKey = browserTemplateCache.keys().next().value;
|
||||
if (typeof oldestKey !== "string") break;
|
||||
browserTemplateCache.delete(oldestKey);
|
||||
}
|
||||
browserTemplateCache.set(poolKey, {
|
||||
template: cloneValue(template),
|
||||
context,
|
||||
expiresAt: now() + CLAUDE_WEB_TEMPLATE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function lookupBrowserTemplate(poolKey: string): CachedBrowserTemplate | null {
|
||||
const entry = browserTemplateCache.get(poolKey);
|
||||
if (!entry) return null;
|
||||
if (now() >= entry.expiresAt) {
|
||||
browserTemplateCache.delete(poolKey);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
template: cloneValue(entry.template),
|
||||
context: entry.context,
|
||||
expiresAt: entry.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClaudeWebBrowserPoolKey(input: {
|
||||
scopeKey: string;
|
||||
organizationId: string;
|
||||
cookieString: string;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const digest = createHash("sha256")
|
||||
.update(
|
||||
[input.scopeKey, input.organizationId, input.cookieString, input.locale, input.timezone].join(
|
||||
String.fromCharCode(31)
|
||||
)
|
||||
)
|
||||
.digest("hex");
|
||||
return `claude-web:${digest}`;
|
||||
}
|
||||
|
||||
export function mergeClaudeWebBrowserPayload(
|
||||
uiPayload: Record<string, unknown>,
|
||||
preparedPayload: ClaudeWebRequestPayload
|
||||
): ClaudeWebRequestPayload {
|
||||
const template = extractBrowserTemplate(uiPayload);
|
||||
const merged = {
|
||||
...uiPayload,
|
||||
...preparedPayload,
|
||||
tools: template.tools,
|
||||
personalized_styles: template.personalizedStyles,
|
||||
...(template.toolStates ? { tool_states: template.toolStates } : {}),
|
||||
} as unknown as ClaudeWebRequestPayload;
|
||||
|
||||
if (!("parent_message_uuid" in preparedPayload)) {
|
||||
delete merged.parent_message_uuid;
|
||||
}
|
||||
if (!("create_conversation_params" in preparedPayload)) {
|
||||
delete merged.create_conversation_params;
|
||||
}
|
||||
if (!template.toolStates && !("tool_states" in preparedPayload)) {
|
||||
delete merged.tool_states;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeTemplateIntoPrepared(
|
||||
template: BrowserTemplate,
|
||||
preparedPayload: ClaudeWebRequestPayload
|
||||
): ClaudeWebRequestPayload {
|
||||
return {
|
||||
...preparedPayload,
|
||||
tools: cloneValue(template.tools),
|
||||
personalized_styles: cloneValue(template.personalizedStyles),
|
||||
...(!("tool_states" in preparedPayload) && template.toolStates
|
||||
? { tool_states: cloneValue(template.toolStates) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyClaudeWebBrowserTemplate(
|
||||
request: ClaudeWebTransportRequest
|
||||
): ClaudeWebTransportRequest {
|
||||
if (request.payload.tools.length > 0) return request;
|
||||
const cached = lookupBrowserTemplate(buildClaudeWebBrowserPoolKey(request));
|
||||
if (!cached) return request;
|
||||
return {
|
||||
...request,
|
||||
payload: mergeTemplateIntoPrepared(cached.template, request.payload),
|
||||
};
|
||||
}
|
||||
|
||||
function browserFetchHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const forbidden = new Set([
|
||||
"accept-encoding",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"host",
|
||||
"origin",
|
||||
"referer",
|
||||
"user-agent",
|
||||
]);
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (forbidden.has(normalized) || normalized.startsWith("sec-")) continue;
|
||||
filtered[name] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function makeBrowserFetchInput(
|
||||
request: ClaudeWebTransportRequest,
|
||||
payload: ClaudeWebRequestPayload,
|
||||
capturedHeaders: Record<string, string> = {}
|
||||
): ClaudeWebBrowserFetchInput {
|
||||
return {
|
||||
url: request.url,
|
||||
headers: browserFetchHeaders({ ...capturedHeaders, ...request.headers }),
|
||||
body: JSON.stringify(payload),
|
||||
maxBytes: MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchClaudeWebPageResponse(
|
||||
page: Page,
|
||||
input: ClaudeWebBrowserFetchInput
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }> {
|
||||
const captured = await page.evaluate(async ({ url, headers, body, maxBytes }) => {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
credentials: "include",
|
||||
});
|
||||
const responseHeaders = Object.fromEntries(response.headers.entries());
|
||||
const declaredLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error("Claude Web browser response exceeded the size limit");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const bodyChunks: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = "";
|
||||
const sliceSize = 32 * 1024;
|
||||
for (let offset = 0; offset < bytes.byteLength; offset += sliceSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + sliceSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
try {
|
||||
while (reader) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error("Claude Web browser response exceeded the size limit");
|
||||
}
|
||||
bodyChunks.push(encodeBase64(value));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader?.releaseLock();
|
||||
} catch {
|
||||
// The reader may already be released after cancellation.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
bodyChunks,
|
||||
};
|
||||
}, input);
|
||||
|
||||
const chunks = captured.bodyChunks.map((chunk) => Buffer.from(chunk, "base64"));
|
||||
return {
|
||||
status: captured.status,
|
||||
headers: captured.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal | null): void {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function withAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
throwIfAborted(signal);
|
||||
let abortListener: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, aborted]);
|
||||
} finally {
|
||||
if (abortListener) signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureCompletion(
|
||||
page: Page,
|
||||
request: ClaudeWebTransportRequest,
|
||||
poolKey: string,
|
||||
context: PooledContext["context"]
|
||||
): Promise<ClaudeWebBrowserFetchInput> {
|
||||
let resolveInterception: ((input: ClaudeWebBrowserFetchInput) => void) | undefined;
|
||||
let rejectInterception: ((error: Error) => void) | undefined;
|
||||
const intercepted = new Promise<ClaudeWebBrowserFetchInput>((resolve, reject) => {
|
||||
resolveInterception = resolve;
|
||||
rejectInterception = reject;
|
||||
});
|
||||
|
||||
const matchesPreparedRoute = (url: URL): boolean => {
|
||||
if (!("create_conversation_params" in request.payload)) {
|
||||
return url.toString() === request.url;
|
||||
}
|
||||
if (url.origin !== "https://claude.ai" || url.search || url.hash) return false;
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (
|
||||
segments.length !== 6 ||
|
||||
segments[0] !== "api" ||
|
||||
segments[1] !== "organizations" ||
|
||||
segments[3] !== "chat_conversations" ||
|
||||
!segments[4] ||
|
||||
segments[5] !== "completion"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(segments[2]) === request.organizationId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
await page.route(matchesPreparedRoute, async (route) => {
|
||||
try {
|
||||
const outgoing = route.request();
|
||||
if (outgoing.method() !== "POST" || !matchesPreparedRoute(new URL(outgoing.url()))) {
|
||||
throw new Error("Claude Web browser interception target changed");
|
||||
}
|
||||
const rawPayload = outgoing.postData();
|
||||
if (!rawPayload) throw new Error("Claude Web browser request body is missing");
|
||||
const uiPayload = JSON.parse(rawPayload) as unknown;
|
||||
if (!uiPayload || typeof uiPayload !== "object" || Array.isArray(uiPayload)) {
|
||||
throw new Error("Claude Web browser request body is invalid");
|
||||
}
|
||||
const capturedHeaders = await outgoing.allHeaders();
|
||||
const template = extractBrowserTemplate(uiPayload as Record<string, unknown>);
|
||||
const merged = mergeClaudeWebBrowserPayload(
|
||||
uiPayload as Record<string, unknown>,
|
||||
request.payload
|
||||
);
|
||||
await route.abort();
|
||||
rememberBrowserTemplate(poolKey, template, context);
|
||||
resolveInterception?.(makeBrowserFetchInput(request, merged, capturedHeaders));
|
||||
} catch {
|
||||
await route.abort().catch(() => {});
|
||||
rejectInterception?.(new Error("Claude Web browser request interception failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const guardedInterception = withAbort(intercepted, request.signal);
|
||||
void guardedInterception.catch(() => {});
|
||||
const input = page.locator(CLAUDE_WEB_INPUT_SELECTOR).first();
|
||||
try {
|
||||
await withAbort(input.waitFor({ state: "visible", timeout: 10000 }), request.signal);
|
||||
await withAbort(input.fill(request.payload.prompt), request.signal);
|
||||
await withAbort(page.keyboard.press("Enter"), request.signal);
|
||||
return await guardedInterception;
|
||||
} catch (error) {
|
||||
rejectInterception?.(new Error("Claude Web browser request capture failed"));
|
||||
await guardedInterception.catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await page.unroute(matchesPreparedRoute).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function captureRetry(
|
||||
request: ClaudeWebTransportRequest,
|
||||
template: BrowserTemplate
|
||||
): ClaudeWebBrowserFetchInput {
|
||||
const payload = mergeTemplateIntoPrepared(template, request.payload);
|
||||
return makeBrowserFetchInput(request, payload);
|
||||
}
|
||||
|
||||
function toTransportResult(captured: {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Uint8Array;
|
||||
}): ClaudeWebTransportResult {
|
||||
const bytes = new Uint8Array(captured.body);
|
||||
return {
|
||||
status: captured.status,
|
||||
headers: new Headers(captured.headers),
|
||||
body: new Response(bytes).body,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendClaudeWebBrowser(
|
||||
request: ClaudeWebTransportRequest,
|
||||
deps: ClaudeWebBrowserDeps = defaultDeps
|
||||
): Promise<ClaudeWebTransportResult> {
|
||||
verifyRequestUrl(request);
|
||||
throwIfAborted(request.signal);
|
||||
const poolKey = buildClaudeWebBrowserPoolKey(request);
|
||||
const retryEntry =
|
||||
request.endpointSuffix === "retry_completion" ? lookupBrowserTemplate(poolKey) : null;
|
||||
if (request.endpointSuffix === "retry_completion" && !retryEntry) {
|
||||
throw new Error("Claude Web browser retry requires a scoped UI template");
|
||||
}
|
||||
|
||||
const pooled = await deps.acquireContext(poolKey, {
|
||||
cookieDomain: ".claude.ai",
|
||||
cookieString: request.cookieString,
|
||||
warmupUrl: request.pageUrl,
|
||||
locale: request.locale,
|
||||
timezone: request.timezone,
|
||||
proxyProviderKey: "claude-web",
|
||||
});
|
||||
const page = await deps.openPage(pooled);
|
||||
try {
|
||||
if (retryEntry && retryEntry.context !== pooled.context) {
|
||||
throw new Error("Claude Web browser context no longer matches scoped UI template");
|
||||
}
|
||||
await page.goto(request.pageUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
throwIfAborted(request.signal);
|
||||
const fetchInput = retryEntry
|
||||
? captureRetry(request, retryEntry.template)
|
||||
: await captureCompletion(page, request, poolKey, pooled.context);
|
||||
const captured = await withAbort(deps.fetchResponse(page, fetchInput), request.signal);
|
||||
if (captured.body.byteLength > MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES) {
|
||||
throw new Error("Claude Web browser response exceeded the size limit");
|
||||
}
|
||||
return toTransportResult(captured);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetClaudeWebBrowserTemplatesForTesting(): void {
|
||||
browserTemplateCache.clear();
|
||||
}
|
||||
|
||||
export function __setClaudeWebBrowserNowForTesting(value: number | null): void {
|
||||
testNow = value;
|
||||
}
|
||||
@@ -5,6 +5,20 @@ import { randomUUID } from "crypto";
|
||||
// Default model when not specified
|
||||
export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
export type ClaudeWebOperation = "completion" | "retry_completion";
|
||||
|
||||
export interface ClaudeWebTurnFields {
|
||||
operation: ClaudeWebOperation;
|
||||
prompt: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
parentMessageUuid?: string;
|
||||
humanMessageUuid?: string;
|
||||
assistantMessageUuid: string;
|
||||
isNewConversation: boolean;
|
||||
toolStates?: unknown[];
|
||||
}
|
||||
|
||||
export interface ClaudeWebRequestPayload {
|
||||
prompt: string;
|
||||
model: string;
|
||||
@@ -29,16 +43,18 @@ export interface ClaudeWebRequestPayload {
|
||||
type?: string;
|
||||
}>;
|
||||
turn_message_uuids: {
|
||||
human_message_uuid: string;
|
||||
human_message_uuid?: string;
|
||||
assistant_message_uuid: string;
|
||||
};
|
||||
parent_message_uuid?: string;
|
||||
attachments: unknown[];
|
||||
effort: string;
|
||||
files: unknown[];
|
||||
sync_sources: unknown[];
|
||||
rendering_mode: string;
|
||||
thinking_mode: string;
|
||||
create_conversation_params: {
|
||||
tool_states?: unknown[];
|
||||
create_conversation_params?: {
|
||||
name: string;
|
||||
model: string;
|
||||
include_conversation_preferences: boolean;
|
||||
@@ -66,6 +82,34 @@ export interface ClaudeWebStreamChunk {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ClaudeWebTool = ClaudeWebRequestPayload["tools"][number];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function transformOpenAiTool(candidate: unknown): ClaudeWebTool | null {
|
||||
if (!isRecord(candidate) || candidate.type !== "function" || !isRecord(candidate.function)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = candidate.function;
|
||||
if (typeof definition.name !== "string" || !definition.name.trim()) return null;
|
||||
|
||||
const description =
|
||||
typeof definition.description === "string" && definition.description.trim()
|
||||
? definition.description
|
||||
: undefined;
|
||||
const schemaCandidate = definition.parameters ?? definition.input_schema;
|
||||
const inputSchema = isRecord(schemaCandidate) ? schemaCandidate : undefined;
|
||||
|
||||
return {
|
||||
name: definition.name.trim(),
|
||||
...(description ? { description } : {}),
|
||||
...(inputSchema ? { input_schema: inputSchema } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUIDs for turn message tracking
|
||||
*/
|
||||
@@ -77,61 +121,22 @@ export function generateMessageUUIDs() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default tool definitions for Claude Web API
|
||||
* Convert caller-provided OpenAI function tools to Claude Web tool definitions.
|
||||
*
|
||||
* Claude's own browser tools are account- and rollout-dependent. Inventing a
|
||||
* static list makes the request diverge from the authenticated UI, so only
|
||||
* explicit, structurally valid caller tools are forwarded here.
|
||||
*/
|
||||
export 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" },
|
||||
];
|
||||
export function transformOpenAiTools(tools: unknown): ClaudeWebRequestPayload["tools"] {
|
||||
if (!Array.isArray(tools)) return [];
|
||||
|
||||
const transformed: ClaudeWebRequestPayload["tools"] = [];
|
||||
for (const candidate of tools) {
|
||||
const transformedTool = transformOpenAiTool(candidate);
|
||||
if (transformedTool) transformed.push(transformedTool);
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,19 +172,114 @@ export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personal
|
||||
* because it was never requested in the first place (#6662).
|
||||
*/
|
||||
export function wantsExtendedThinking(body: Record<string, unknown>): boolean {
|
||||
return resolveClaudeWebReasoningEffort(body) !== null;
|
||||
}
|
||||
|
||||
const CLAUDE_WEB_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
||||
|
||||
/**
|
||||
* Resolve the caller's explicit reasoning level into the effort values
|
||||
* accepted by Claude Web. An explicit `none` disables thinking, while a
|
||||
* native `thinking: { type: "enabled" }` request uses the existing low
|
||||
* default when no graduated effort was supplied.
|
||||
*/
|
||||
export function resolveClaudeWebReasoningEffort(
|
||||
body: Record<string, unknown>
|
||||
): (typeof CLAUDE_WEB_REASONING_EFFORTS)[number] | null {
|
||||
const reasoning =
|
||||
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
|
||||
? (body.reasoning as Record<string, unknown>)
|
||||
: null;
|
||||
const effort = body.reasoning_effort ?? reasoning?.effort;
|
||||
if (typeof effort === "string" && effort.trim() && effort.toLowerCase() !== "none") {
|
||||
return true;
|
||||
if (typeof effort === "string" && effort.trim()) {
|
||||
const normalized = effort.trim().toLowerCase();
|
||||
if (normalized === "none") return null;
|
||||
if ((CLAUDE_WEB_REASONING_EFFORTS as readonly string[]).includes(normalized)) {
|
||||
return normalized as (typeof CLAUDE_WEB_REASONING_EFFORTS)[number];
|
||||
}
|
||||
}
|
||||
const thinking = body.thinking;
|
||||
if (thinking && typeof thinking === "object" && !Array.isArray(thinking)) {
|
||||
if ((thinking as Record<string, unknown>).type === "enabled") return true;
|
||||
if ((thinking as Record<string, unknown>).type === "enabled") return "low";
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function contentPartText(part: unknown): string {
|
||||
if (!isRecord(part)) return "";
|
||||
return typeof part.text === "string" ? part.text : "";
|
||||
}
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content.map(contentPartText).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function latestUserPrompt(messages: unknown[]): string {
|
||||
let prompt = "";
|
||||
for (const candidate of messages) {
|
||||
if (!isRecord(candidate) || candidate.role !== "user") continue;
|
||||
prompt = messageText(candidate.content);
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function defaultTurn(prompt: string): ClaudeWebTurnFields {
|
||||
const messageUuids = generateMessageUUIDs();
|
||||
return {
|
||||
operation: "completion",
|
||||
prompt,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
locale: "en-US",
|
||||
humanMessageUuid: messageUuids.human_message_uuid,
|
||||
assistantMessageUuid: messageUuids.assistant_message_uuid,
|
||||
isNewConversation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createConversationParams(model: string) {
|
||||
return {
|
||||
name: "",
|
||||
model,
|
||||
include_conversation_preferences: true,
|
||||
paprika_mode: null,
|
||||
compass_mode: null,
|
||||
is_temporary: false,
|
||||
enabled_imagine: true,
|
||||
tool_search_mode: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeWebPayload(
|
||||
body: Record<string, unknown>,
|
||||
model: string,
|
||||
reasoningEffort: ReturnType<typeof resolveClaudeWebReasoningEffort>,
|
||||
turn: ClaudeWebTurnFields
|
||||
): ClaudeWebRequestPayload {
|
||||
return {
|
||||
prompt: turn.prompt,
|
||||
model,
|
||||
timezone: turn.timezone,
|
||||
personalized_styles: getDefaultPersonalizedStyle(),
|
||||
locale: turn.locale,
|
||||
tools: transformOpenAiTools(body.tools),
|
||||
turn_message_uuids: {
|
||||
...(turn.humanMessageUuid ? { human_message_uuid: turn.humanMessageUuid } : {}),
|
||||
assistant_message_uuid: turn.assistantMessageUuid,
|
||||
},
|
||||
...(turn.parentMessageUuid ? { parent_message_uuid: turn.parentMessageUuid } : {}),
|
||||
attachments: [],
|
||||
effort: reasoningEffort ?? "low",
|
||||
files: [],
|
||||
sync_sources: [],
|
||||
rendering_mode: "messages",
|
||||
thinking_mode: reasoningEffort ? "extended" : "off",
|
||||
...(turn.toolStates ? { tool_states: turn.toolStates } : {}),
|
||||
...(turn.isNewConversation
|
||||
? { create_conversation_params: createConversationParams(model) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,50 +287,19 @@ export function wantsExtendedThinking(body: Record<string, unknown>): boolean {
|
||||
*/
|
||||
export function transformToClaude(
|
||||
body: Record<string, unknown>,
|
||||
model: string
|
||||
model: string,
|
||||
turn?: ClaudeWebTurnFields
|
||||
): ClaudeWebRequestPayload {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const reasoningEffort = resolveClaudeWebReasoningEffort(body);
|
||||
const resolvedModel = model || DEFAULT_CLAUDE_MODEL;
|
||||
const resolvedTurn = turn ?? defaultTurn(latestUserPrompt(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()) {
|
||||
if (resolvedTurn.operation === "completion" && !resolvedTurn.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: wantsExtendedThinking(body) ? "on" : "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",
|
||||
},
|
||||
};
|
||||
return buildClaudeWebPayload(body, resolvedModel, reasoningEffort, resolvedTurn);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
459
open-sse/executors/claude-web/session.ts
Normal file
459
open-sse/executors/claude-web/session.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ProviderCredentials } from "../base.ts";
|
||||
import {
|
||||
transformToClaude,
|
||||
type ClaudeWebOperation,
|
||||
type ClaudeWebRequestPayload,
|
||||
type ClaudeWebTurnFields,
|
||||
} from "./payload.ts";
|
||||
|
||||
const CLAUDE_WEB_SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const CLAUDE_WEB_SESSION_MAX = 5000;
|
||||
const RECOVERY_PROMPT_HEADER =
|
||||
"Conversation context supplied by the caller follows. These serialized role blocks are not " +
|
||||
"native Claude Web message fields. Continue from the final user message.";
|
||||
|
||||
type NormalizedMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type CachedClaudeWebConversation = {
|
||||
accountScope: string;
|
||||
conversationId: string;
|
||||
parentMessageUuid: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const conversationCache = new Map<string, CachedClaudeWebConversation>();
|
||||
let testNow: number | null = null;
|
||||
|
||||
function isValidTimezone(value: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalLocale(value: string): string | null {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(value)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const claudeWebExtensionSchema = z
|
||||
.object({
|
||||
operation: z.enum(["completion", "retry"]).default("completion"),
|
||||
conversation_id: z.string().uuid().optional(),
|
||||
parent_message_uuid: z.string().uuid().optional(),
|
||||
timezone: z.string().min(1).max(128).refine(isValidTimezone).optional(),
|
||||
locale: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((value) => canonicalLocale(value) !== null)
|
||||
.optional(),
|
||||
tool_states: z.array(z.unknown()).max(128).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type ClaudeWebExtension = z.infer<typeof claudeWebExtensionSchema>;
|
||||
|
||||
export interface PrepareClaudeWebTurnInput {
|
||||
body: Record<string, unknown>;
|
||||
model: string;
|
||||
credentials: ProviderCredentials;
|
||||
organizationId: string;
|
||||
normalizedCookie: string;
|
||||
}
|
||||
|
||||
export interface PreparedClaudeWebTurn {
|
||||
operation: ClaudeWebOperation;
|
||||
conversationId: string;
|
||||
assistantMessageUuid: string;
|
||||
parentMessageUuid?: string;
|
||||
accountScope: string;
|
||||
pageUrl: string;
|
||||
endpointSuffix: "completion" | "retry_completion";
|
||||
payload: ClaudeWebRequestPayload;
|
||||
responseMetadata: Record<string, string>;
|
||||
/** @internal Canonical request state used only to commit a completed turn. */
|
||||
commitTranscript: ReadonlyArray<NormalizedMessage>;
|
||||
/** @internal Cache entry that supplied this turn's continuation state. */
|
||||
sourceCacheKey?: string;
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return testNow ?? Date.now();
|
||||
}
|
||||
|
||||
function hash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) {
|
||||
if (content && typeof content === "object") {
|
||||
const record = content as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
if ("content" in record) return normalizeContent(record.content);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) return "";
|
||||
const record = part as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
if ("content" in record) return normalizeContent(record.content);
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function normalizeMessages(body: Record<string, unknown>): NormalizedMessage[] {
|
||||
if (!Array.isArray(body.messages)) return [];
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (const candidate of body.messages) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const message = candidate as Record<string, unknown>;
|
||||
if (typeof message.role !== "string" || !message.role.trim()) continue;
|
||||
normalized.push({
|
||||
role: message.role.trim().toLowerCase(),
|
||||
content: normalizeContent(message.content),
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function canonicalizeTranscript(messages: ReadonlyArray<NormalizedMessage>): string {
|
||||
return messages
|
||||
.map(
|
||||
({ role, content }) =>
|
||||
`${role.length}:${role}${String.fromCharCode(30)}${content.length}:${content}`
|
||||
)
|
||||
.join(String.fromCharCode(31));
|
||||
}
|
||||
|
||||
function makeAccountScope(input: PrepareClaudeWebTurnInput): string {
|
||||
const connectionId = input.credentials.connectionId?.trim();
|
||||
const credentialScope = connectionId
|
||||
? `connection:${connectionId}`
|
||||
: `cookie:${hash(input.normalizedCookie)}`;
|
||||
return hash(
|
||||
`${credentialScope}${String.fromCharCode(31)}${input.organizationId}${String.fromCharCode(31)}${input.model}`
|
||||
);
|
||||
}
|
||||
|
||||
function makeCacheKey(accountScope: string, messages: ReadonlyArray<NormalizedMessage>): string {
|
||||
return hash(`${accountScope}${String.fromCharCode(31)}${canonicalizeTranscript(messages)}`);
|
||||
}
|
||||
|
||||
function lookupCache(key: string): CachedClaudeWebConversation | null {
|
||||
const entry = conversationCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (now() >= entry.expiresAt) {
|
||||
conversationCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function pruneExpiredEntries(): void {
|
||||
const currentTime = now();
|
||||
for (const [key, entry] of conversationCache) {
|
||||
if (currentTime >= entry.expiresAt) conversationCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCache(key: string, entry: Omit<CachedClaudeWebConversation, "expiresAt">): void {
|
||||
pruneExpiredEntries();
|
||||
if (conversationCache.has(key)) conversationCache.delete(key);
|
||||
while (conversationCache.size >= CLAUDE_WEB_SESSION_MAX) {
|
||||
const oldestKey = conversationCache.keys().next().value;
|
||||
if (typeof oldestKey !== "string") break;
|
||||
conversationCache.delete(oldestKey);
|
||||
}
|
||||
conversationCache.set(key, {
|
||||
...entry,
|
||||
expiresAt: now() + CLAUDE_WEB_SESSION_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function buildRecoveryPrompt(messages: ReadonlyArray<NormalizedMessage>): string {
|
||||
const blocks = messages.map(
|
||||
({ role, content }) => `<message role="${escapeXml(role)}">\n${escapeXml(content)}\n</message>`
|
||||
);
|
||||
return `${RECOVERY_PROMPT_HEADER}\n\n${blocks.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function readExtension(body: Record<string, unknown>): ClaudeWebExtension {
|
||||
return claudeWebExtensionSchema.parse(body.claude_web ?? {});
|
||||
}
|
||||
|
||||
function readProviderString(credentials: ProviderCredentials, key: string): string | undefined {
|
||||
const value = credentials.providerSpecificData?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveTimezone(extension: ClaudeWebExtension, credentials: ProviderCredentials): string {
|
||||
if (extension.timezone) return extension.timezone;
|
||||
const providerTimezone = readProviderString(credentials, "timezone");
|
||||
if (providerTimezone && isValidTimezone(providerTimezone)) return providerTimezone;
|
||||
const runtimeTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return runtimeTimezone && isValidTimezone(runtimeTimezone) ? runtimeTimezone : "UTC";
|
||||
}
|
||||
|
||||
function resolveLocale(extension: ClaudeWebExtension, credentials: ProviderCredentials): string {
|
||||
if (extension.locale) return canonicalLocale(extension.locale) ?? "en-US";
|
||||
const providerLocale = readProviderString(credentials, "locale");
|
||||
const canonicalProviderLocale = providerLocale ? canonicalLocale(providerLocale) : null;
|
||||
if (canonicalProviderLocale) return canonicalProviderLocale;
|
||||
const runtimeLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
return (runtimeLocale && canonicalLocale(runtimeLocale)) || "en-US";
|
||||
}
|
||||
|
||||
function readLegacyConversationId(credentials: ProviderCredentials): string | undefined {
|
||||
const candidate = (credentials as ProviderCredentials & { conversationId?: unknown })
|
||||
.conversationId;
|
||||
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
|
||||
}
|
||||
|
||||
function latestUserIndex(messages: ReadonlyArray<NormalizedMessage>): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "user") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function retryCommitTranscript(messages: ReadonlyArray<NormalizedMessage>): NormalizedMessage[] {
|
||||
const copy = messages.map((message) => ({ ...message }));
|
||||
if (copy.at(-1)?.role === "assistant") copy.pop();
|
||||
return copy;
|
||||
}
|
||||
|
||||
interface PrepareTurnContext {
|
||||
extension: ClaudeWebExtension;
|
||||
messages: NormalizedMessage[];
|
||||
accountScope: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
explicitConversationId?: string;
|
||||
explicitParentMessageUuid?: string;
|
||||
assistantMessageUuid: string;
|
||||
}
|
||||
|
||||
function prepareTurnContext(input: PrepareClaudeWebTurnInput): PrepareTurnContext {
|
||||
const extension = readExtension(input.body);
|
||||
return {
|
||||
extension,
|
||||
messages: normalizeMessages(input.body),
|
||||
accountScope: makeAccountScope(input),
|
||||
timezone: resolveTimezone(extension, input.credentials),
|
||||
locale: resolveLocale(extension, input.credentials),
|
||||
explicitConversationId:
|
||||
extension.conversation_id ?? readLegacyConversationId(input.credentials),
|
||||
explicitParentMessageUuid: extension.parent_message_uuid,
|
||||
assistantMessageUuid: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareRetryTurn(
|
||||
input: PrepareClaudeWebTurnInput,
|
||||
context: PrepareTurnContext
|
||||
): PreparedClaudeWebTurn {
|
||||
const retryKey = makeCacheKey(context.accountScope, context.messages);
|
||||
const cached = lookupCache(retryKey);
|
||||
const conversationId = context.explicitConversationId ?? cached?.conversationId;
|
||||
const parentMessageUuid = context.explicitParentMessageUuid ?? cached?.parentMessageUuid;
|
||||
if (!conversationId || !parentMessageUuid) {
|
||||
throw new Error("Claude Web retry requires both conversation and parent message state");
|
||||
}
|
||||
|
||||
const operation: ClaudeWebOperation = "retry_completion";
|
||||
const turnFields: ClaudeWebTurnFields = {
|
||||
operation,
|
||||
prompt: "",
|
||||
timezone: context.timezone,
|
||||
locale: context.locale,
|
||||
parentMessageUuid,
|
||||
assistantMessageUuid: context.assistantMessageUuid,
|
||||
isNewConversation: false,
|
||||
toolStates: context.extension.tool_states,
|
||||
};
|
||||
const responseMetadata = {
|
||||
operation,
|
||||
conversation_id: conversationId,
|
||||
parent_message_uuid: parentMessageUuid,
|
||||
assistant_message_uuid: context.assistantMessageUuid,
|
||||
};
|
||||
|
||||
return {
|
||||
operation,
|
||||
conversationId,
|
||||
assistantMessageUuid: context.assistantMessageUuid,
|
||||
parentMessageUuid,
|
||||
accountScope: context.accountScope,
|
||||
pageUrl: `https://claude.ai/chat/${encodeURIComponent(conversationId)}`,
|
||||
endpointSuffix: "retry_completion",
|
||||
payload: transformToClaude(input.body, input.model, turnFields),
|
||||
responseMetadata,
|
||||
commitTranscript: retryCommitTranscript(context.messages),
|
||||
...(cached ? { sourceCacheKey: retryKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface CompletionConversationState {
|
||||
conversationId: string;
|
||||
parentMessageUuid?: string;
|
||||
isNewConversation: boolean;
|
||||
sourceCacheKey?: string;
|
||||
}
|
||||
|
||||
function resolveCompletionConversation(
|
||||
context: PrepareTurnContext,
|
||||
userIndex: number
|
||||
): CompletionConversationState {
|
||||
const lookupKey = makeCacheKey(context.accountScope, context.messages.slice(0, userIndex));
|
||||
const cached = lookupCache(lookupKey);
|
||||
const cacheMatchesExplicitConversation =
|
||||
!context.explicitConversationId || cached?.conversationId === context.explicitConversationId;
|
||||
const cachedParent = cacheMatchesExplicitConversation ? cached?.parentMessageUuid : undefined;
|
||||
const conversationId = context.explicitConversationId ?? cached?.conversationId ?? randomUUID();
|
||||
const parentMessageUuid = context.explicitParentMessageUuid ?? cachedParent;
|
||||
|
||||
if (
|
||||
context.explicitParentMessageUuid &&
|
||||
!context.explicitConversationId &&
|
||||
!cached?.conversationId
|
||||
) {
|
||||
throw new Error("Claude Web parent message state requires a conversation");
|
||||
}
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
parentMessageUuid,
|
||||
isNewConversation: !parentMessageUuid,
|
||||
...(cached && cacheMatchesExplicitConversation ? { sourceCacheKey: lookupKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareCompletionTurn(
|
||||
input: PrepareClaudeWebTurnInput,
|
||||
context: PrepareTurnContext
|
||||
): PreparedClaudeWebTurn {
|
||||
const userIndex = latestUserIndex(context.messages);
|
||||
if (userIndex < 0 || !context.messages[userIndex].content.trim()) {
|
||||
throw new Error("No user message found in request");
|
||||
}
|
||||
|
||||
const conversation = resolveCompletionConversation(context, userIndex);
|
||||
const prompt =
|
||||
conversation.isNewConversation && context.messages.length > 1
|
||||
? buildRecoveryPrompt(context.messages)
|
||||
: context.messages[userIndex].content;
|
||||
const humanMessageUuid = randomUUID();
|
||||
const operation: ClaudeWebOperation = "completion";
|
||||
const turnFields: ClaudeWebTurnFields = {
|
||||
operation,
|
||||
prompt,
|
||||
timezone: context.timezone,
|
||||
locale: context.locale,
|
||||
parentMessageUuid: conversation.parentMessageUuid,
|
||||
humanMessageUuid,
|
||||
assistantMessageUuid: context.assistantMessageUuid,
|
||||
isNewConversation: conversation.isNewConversation,
|
||||
toolStates: context.extension.tool_states,
|
||||
};
|
||||
const responseMetadata = {
|
||||
operation,
|
||||
conversation_id: conversation.conversationId,
|
||||
...(conversation.parentMessageUuid
|
||||
? { parent_message_uuid: conversation.parentMessageUuid }
|
||||
: {}),
|
||||
assistant_message_uuid: context.assistantMessageUuid,
|
||||
};
|
||||
|
||||
return {
|
||||
operation,
|
||||
conversationId: conversation.conversationId,
|
||||
assistantMessageUuid: context.assistantMessageUuid,
|
||||
...(conversation.parentMessageUuid
|
||||
? { parentMessageUuid: conversation.parentMessageUuid }
|
||||
: {}),
|
||||
accountScope: context.accountScope,
|
||||
pageUrl: conversation.isNewConversation
|
||||
? "https://claude.ai/new"
|
||||
: `https://claude.ai/chat/${encodeURIComponent(conversation.conversationId)}`,
|
||||
endpointSuffix: "completion",
|
||||
payload: transformToClaude(input.body, input.model, turnFields),
|
||||
responseMetadata,
|
||||
commitTranscript: context.messages.map((message) => ({ ...message })),
|
||||
...(conversation.sourceCacheKey ? { sourceCacheKey: conversation.sourceCacheKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareClaudeWebTurn(input: PrepareClaudeWebTurnInput): PreparedClaudeWebTurn {
|
||||
const context = prepareTurnContext(input);
|
||||
return context.extension.operation === "retry"
|
||||
? prepareRetryTurn(input, context)
|
||||
: prepareCompletionTurn(input, context);
|
||||
}
|
||||
|
||||
export function commitClaudeWebTurn(turn: PreparedClaudeWebTurn, assistantText: string): void {
|
||||
const completedTranscript = [
|
||||
...turn.commitTranscript.map((message) => ({ ...message })),
|
||||
{ role: "assistant", content: assistantText },
|
||||
];
|
||||
const cacheKey = makeCacheKey(turn.accountScope, completedTranscript);
|
||||
if (turn.operation === "retry_completion" && turn.sourceCacheKey) {
|
||||
conversationCache.delete(turn.sourceCacheKey);
|
||||
}
|
||||
rememberCache(cacheKey, {
|
||||
accountScope: turn.accountScope,
|
||||
conversationId: turn.conversationId,
|
||||
parentMessageUuid: turn.assistantMessageUuid,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateClaudeWebTurn(
|
||||
turn: PreparedClaudeWebTurn,
|
||||
scope: "turn" | "conversation" = "turn"
|
||||
): void {
|
||||
// A failed in-flight branch has not committed cache state, so its normal
|
||||
// invalidation is intentionally a no-op. Authentication failures are the
|
||||
// only callers that invalidate every cached branch for the conversation.
|
||||
if (scope === "turn") return;
|
||||
for (const [key, entry] of conversationCache) {
|
||||
if (entry.accountScope === turn.accountScope && entry.conversationId === turn.conversationId) {
|
||||
conversationCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetClaudeWebSessionForTesting(): void {
|
||||
conversationCache.clear();
|
||||
}
|
||||
|
||||
export function __setClaudeWebSessionNowForTesting(value: number | null): void {
|
||||
testNow = value;
|
||||
}
|
||||
686
open-sse/executors/claude-web/stream.ts
Normal file
686
open-sse/executors/claude-web/stream.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { buildErrorBody } from "../../utils/error.ts";
|
||||
import type { ExecutorLog } from "../base.ts";
|
||||
|
||||
export interface ClaudeWebStreamOptions {
|
||||
model: string;
|
||||
stream: boolean;
|
||||
responseMetadata: Record<string, string>;
|
||||
onComplete(result: { assistantText: string; stopReason: string }): void;
|
||||
onFailure(): void;
|
||||
log?: ExecutorLog | null;
|
||||
}
|
||||
|
||||
type StreamPhase = "awaiting_message" | "in_message" | "stopped" | "failed";
|
||||
type BlockKind = "thinking" | "text" | "other";
|
||||
const MAX_CLAUDE_WEB_SSE_PENDING_CHARS = 1024 * 1024;
|
||||
type SemanticEvent =
|
||||
| { kind: "content"; text: string }
|
||||
| { kind: "reasoning"; text: string }
|
||||
| { kind: "metadata"; eventType: string; data: Record<string, unknown> }
|
||||
| { kind: "finish"; stopReason: string };
|
||||
|
||||
const KNOWN_METADATA_EVENTS = new Set([
|
||||
"ping",
|
||||
"completion",
|
||||
"message_limit",
|
||||
"content_block_retract",
|
||||
"model_fallback",
|
||||
"model_update",
|
||||
"compaction_status",
|
||||
"conversation_ready",
|
||||
"cache_performance",
|
||||
"tool_approval",
|
||||
]);
|
||||
|
||||
const METADATA_EVENT_FIELDS: Record<string, readonly string[]> = {
|
||||
ping: ["latency_ms"],
|
||||
completion: [],
|
||||
message_limit: ["remaining", "limit", "reset_at"],
|
||||
content_block_retract: ["index"],
|
||||
model_fallback: ["model", "fallback_model"],
|
||||
model_update: ["model"],
|
||||
compaction_status: ["status"],
|
||||
conversation_ready: ["status"],
|
||||
cache_performance: ["hit", "read_tokens", "write_tokens"],
|
||||
tool_approval: ["status"],
|
||||
};
|
||||
|
||||
interface StreamControl {
|
||||
reader: ReadableStreamDefaultReader<Uint8Array> | null;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
class ClaudeWebProtocolError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ClaudeWebProtocolError";
|
||||
}
|
||||
}
|
||||
|
||||
async function* decodeSseData(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
control: StreamControl
|
||||
): AsyncGenerator<string, void, void> {
|
||||
const reader = source.getReader();
|
||||
control.reader = reader;
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let dataLines: string[] = [];
|
||||
let dataChars = 0;
|
||||
let reachedEof = false;
|
||||
|
||||
const consumeLine = (rawLine: string): string | null => {
|
||||
if (rawLine.length > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) {
|
||||
throw new ClaudeWebProtocolError("SSE line exceeded the size limit");
|
||||
}
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line === "") {
|
||||
if (dataLines.length === 0) return null;
|
||||
const data = dataLines.join("\n");
|
||||
dataLines = [];
|
||||
dataChars = 0;
|
||||
return data;
|
||||
}
|
||||
if (line.startsWith(":")) return null;
|
||||
|
||||
const separatorIndex = line.indexOf(":");
|
||||
const field = separatorIndex < 0 ? line : line.slice(0, separatorIndex);
|
||||
let value = separatorIndex < 0 ? "" : line.slice(separatorIndex + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "data") {
|
||||
dataChars += value.length + (dataLines.length > 0 ? 1 : 0);
|
||||
if (dataChars > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) {
|
||||
throw new ClaudeWebProtocolError("SSE event exceeded the size limit");
|
||||
}
|
||||
dataLines.push(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
reachedEof = true;
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const frame = consumeLine(line);
|
||||
if (frame !== null) yield frame;
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
if (buffer.length > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) {
|
||||
throw new ClaudeWebProtocolError("SSE line exceeded the size limit");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer) {
|
||||
const frame = consumeLine(buffer);
|
||||
if (frame !== null) yield frame;
|
||||
}
|
||||
const finalFrame = consumeLine("");
|
||||
if (finalFrame !== null) yield finalFrame;
|
||||
} finally {
|
||||
if (!reachedEof) await reader.cancel().catch(() => {});
|
||||
if (control.reader === reader) control.reader = null;
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// The source may already have released its reader after an abort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeMetadataValue(value: unknown): string | number | boolean | null | undefined {
|
||||
if (value === null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.length <= 128 && /^[A-Za-z0-9._:+/@-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function projectMetadataEvent(
|
||||
eventType: string,
|
||||
event: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const projected: Record<string, unknown> = { type: eventType };
|
||||
for (const field of METADATA_EVENT_FIELDS[eventType] ?? []) {
|
||||
const value = safeMetadataValue(event[field]);
|
||||
if (value !== undefined) projected[field] = value;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, context: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ClaudeWebProtocolError(`${context} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireBlockIndex(event: Record<string, unknown>): number {
|
||||
if (!Number.isInteger(event.index) || (event.index as number) < 0) {
|
||||
throw new ClaudeWebProtocolError("Content block index is invalid");
|
||||
}
|
||||
return event.index as number;
|
||||
}
|
||||
|
||||
function deltaText(delta: Record<string, unknown>, fields: string[]): string {
|
||||
for (const field of fields) {
|
||||
const value = delta[field];
|
||||
if (typeof value === "string") return value;
|
||||
}
|
||||
throw new ClaudeWebProtocolError("Content delta text is invalid");
|
||||
}
|
||||
|
||||
interface ProtocolState {
|
||||
phase: StreamPhase;
|
||||
openBlocks: Map<number, BlockKind>;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
function protocolFailure(state: ProtocolState, message: string): never {
|
||||
state.phase = "failed";
|
||||
throw new ClaudeWebProtocolError(message);
|
||||
}
|
||||
|
||||
function assertInMessage(
|
||||
state: ProtocolState,
|
||||
eventType: string,
|
||||
blocksMustBeClosed = false
|
||||
): void {
|
||||
if (state.phase !== "in_message" || (blocksMustBeClosed && state.openBlocks.size > 0)) {
|
||||
protocolFailure(state, `${eventType} is out of order`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProtocolEvent(
|
||||
data: string,
|
||||
state: ProtocolState
|
||||
): { event: Record<string, unknown>; eventType: string } {
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = requireRecord(JSON.parse(data), "SSE event");
|
||||
} catch (error) {
|
||||
if (error instanceof ClaudeWebProtocolError) throw error;
|
||||
protocolFailure(state, "SSE event contains malformed JSON");
|
||||
}
|
||||
|
||||
const eventType = event.type;
|
||||
if (typeof eventType !== "string" || !eventType) {
|
||||
protocolFailure(state, "SSE event type is missing");
|
||||
}
|
||||
return { event, eventType };
|
||||
}
|
||||
|
||||
function handleMessageStart(state: ProtocolState): null {
|
||||
if (state.phase !== "awaiting_message") {
|
||||
protocolFailure(state, "message_start is out of order");
|
||||
}
|
||||
state.phase = "in_message";
|
||||
return null;
|
||||
}
|
||||
|
||||
function blockKind(block: Record<string, unknown>): BlockKind {
|
||||
if (block.type === "thinking") return "thinking";
|
||||
if (block.type === "text") return "text";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function handleContentBlockStart(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent | null {
|
||||
assertInMessage(state, "content_block_start");
|
||||
const index = requireBlockIndex(event);
|
||||
if (state.openBlocks.has(index)) protocolFailure(state, "Content block was opened twice");
|
||||
|
||||
const kind = blockKind(requireRecord(event.content_block, "content_block"));
|
||||
state.openBlocks.set(index, kind);
|
||||
return kind === "thinking" ? { kind: "reasoning", text: "" } : null;
|
||||
}
|
||||
|
||||
function handleContentBlockDelta(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent {
|
||||
assertInMessage(state, "content_block_delta");
|
||||
const block = state.openBlocks.get(requireBlockIndex(event));
|
||||
if (!block) protocolFailure(state, "Content delta has no open block");
|
||||
|
||||
const delta = requireRecord(event.delta, "delta");
|
||||
if (delta.type === "text_delta" && block === "text") {
|
||||
return { kind: "content", text: deltaText(delta, ["text"]) };
|
||||
}
|
||||
if (delta.type === "thinking_delta" && block === "thinking") {
|
||||
return { kind: "reasoning", text: deltaText(delta, ["thinking", "text"]) };
|
||||
}
|
||||
if (delta.type === "thinking_summary_delta" && block === "thinking") {
|
||||
return { kind: "reasoning", text: deltaText(delta, ["summary", "text", "thinking"]) };
|
||||
}
|
||||
return protocolFailure(state, "Content delta type does not match its block");
|
||||
}
|
||||
|
||||
function handleContentBlockStop(event: Record<string, unknown>, state: ProtocolState): null {
|
||||
assertInMessage(state, "content_block_stop");
|
||||
if (!state.openBlocks.delete(requireBlockIndex(event))) {
|
||||
protocolFailure(state, "Content block stop has no open block");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleMessageDelta(event: Record<string, unknown>, state: ProtocolState): null {
|
||||
assertInMessage(state, "message_delta", true);
|
||||
const delta = requireRecord(event.delta, "message_delta.delta");
|
||||
const stopReason = delta.stop_reason;
|
||||
if (stopReason === null || stopReason === undefined) return null;
|
||||
if (typeof stopReason !== "string" || !stopReason) {
|
||||
protocolFailure(state, "Stop reason is invalid");
|
||||
}
|
||||
state.stopReason = stopReason;
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleMessageStop(state: ProtocolState): SemanticEvent {
|
||||
assertInMessage(state, "message_stop", true);
|
||||
state.phase = "stopped";
|
||||
return { kind: "finish", stopReason: state.stopReason };
|
||||
}
|
||||
|
||||
function dispatchProtocolEvent(
|
||||
eventType: string,
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent | null {
|
||||
switch (eventType) {
|
||||
case "message_start":
|
||||
return handleMessageStart(state);
|
||||
case "content_block_start":
|
||||
return handleContentBlockStart(event, state);
|
||||
case "content_block_delta":
|
||||
return handleContentBlockDelta(event, state);
|
||||
case "content_block_stop":
|
||||
return handleContentBlockStop(event, state);
|
||||
case "message_delta":
|
||||
return handleMessageDelta(event, state);
|
||||
case "message_stop":
|
||||
return handleMessageStop(state);
|
||||
case "error":
|
||||
return protocolFailure(state, "Upstream reported a stream error");
|
||||
default:
|
||||
return protocolFailure(state, "Unknown Claude Web stream event");
|
||||
}
|
||||
}
|
||||
|
||||
async function* parseClaudeWebEvents(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
control: StreamControl
|
||||
): AsyncGenerator<SemanticEvent, void, void> {
|
||||
const state: ProtocolState = {
|
||||
phase: "awaiting_message",
|
||||
openBlocks: new Map(),
|
||||
stopReason: "end_turn",
|
||||
};
|
||||
|
||||
for await (const data of decodeSseData(source, control)) {
|
||||
if (data === "[DONE]") {
|
||||
protocolFailure(state, "DONE arrived before message_stop");
|
||||
}
|
||||
|
||||
const { event, eventType } = parseProtocolEvent(data, state);
|
||||
if (KNOWN_METADATA_EVENTS.has(eventType)) {
|
||||
yield { kind: "metadata", eventType, data: projectMetadataEvent(eventType, event) };
|
||||
continue;
|
||||
}
|
||||
|
||||
const semanticEvent = dispatchProtocolEvent(eventType, event, state);
|
||||
if (!semanticEvent) continue;
|
||||
yield semanticEvent;
|
||||
if (semanticEvent.kind === "finish") return;
|
||||
}
|
||||
|
||||
if (control.cancelled) return;
|
||||
throw new ClaudeWebProtocolError("Claude Web stream ended before message_stop");
|
||||
}
|
||||
|
||||
function openAiFinishReason(stopReason: string): string {
|
||||
if (stopReason === "max_tokens") return "length";
|
||||
if (stopReason === "tool_use") return "tool_calls";
|
||||
return "stop";
|
||||
}
|
||||
|
||||
function makeChunk(
|
||||
id: string,
|
||||
created: number,
|
||||
options: ClaudeWebStreamOptions,
|
||||
delta: Record<string, unknown>,
|
||||
finishReason: string | null,
|
||||
event?: { type: string; data: Record<string, unknown> }
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: options.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta,
|
||||
finish_reason: finishReason,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
claude_web: {
|
||||
...options.responseMetadata,
|
||||
...(event ? { event } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function protocolErrorBody(): Record<string, unknown> {
|
||||
const body = buildErrorBody(502, "Claude Web stream protocol error");
|
||||
body.error.type = "upstream_protocol_error";
|
||||
body.error.code = "claude_web_protocol_error";
|
||||
return body as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function responseHeaders(contentType: string, metadata: Record<string, string>): Headers {
|
||||
const headers = new Headers({
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
});
|
||||
const headerNames: Record<string, string> = {
|
||||
operation: "X-OmniRoute-Claude-Web-Operation",
|
||||
conversation_id: "X-OmniRoute-Claude-Web-Conversation-Id",
|
||||
parent_message_uuid: "X-OmniRoute-Claude-Web-Parent-Message-Uuid",
|
||||
assistant_message_uuid: "X-OmniRoute-Claude-Web-Assistant-Message-Uuid",
|
||||
};
|
||||
for (const [key, headerName] of Object.entries(headerNames)) {
|
||||
const value = metadata[key];
|
||||
if (value) headers.set(headerName, value.replace(/[\r\n]/g, ""));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function notifyFailure(options: ClaudeWebStreamOptions): void {
|
||||
try {
|
||||
options.onFailure();
|
||||
} catch {
|
||||
options.log?.error?.("CLAUDE-WEB-STREAM", "Failure callback threw an error");
|
||||
}
|
||||
}
|
||||
|
||||
function notifyComplete(
|
||||
options: ClaudeWebStreamOptions,
|
||||
result: { assistantText: string; stopReason: string }
|
||||
): void {
|
||||
try {
|
||||
options.onComplete(result);
|
||||
} catch {
|
||||
options.log?.error?.("CLAUDE-WEB-STREAM", "Completion callback threw an error");
|
||||
}
|
||||
}
|
||||
|
||||
async function createBufferedResponse(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
options: ClaudeWebStreamOptions
|
||||
): Promise<Response> {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
let assistantText = "";
|
||||
let reasoningText = "";
|
||||
let stopReason = "end_turn";
|
||||
const metadataEvents: Array<{ type: string; data: Record<string, unknown> }> = [];
|
||||
const control: StreamControl = { reader: null, cancelled: false };
|
||||
|
||||
try {
|
||||
for await (const event of parseClaudeWebEvents(source, control)) {
|
||||
if (event.kind === "content") assistantText += event.text;
|
||||
if (event.kind === "reasoning") reasoningText += event.text;
|
||||
if (event.kind === "metadata") {
|
||||
metadataEvents.push({ type: event.eventType, data: event.data });
|
||||
}
|
||||
if (event.kind === "finish") stopReason = event.stopReason;
|
||||
}
|
||||
notifyComplete(options, { assistantText, stopReason });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model: options.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
...(reasoningText ? { reasoning_content: reasoningText } : {}),
|
||||
},
|
||||
finish_reason: openAiFinishReason(stopReason),
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
claude_web: {
|
||||
...options.responseMetadata,
|
||||
events: metadataEvents,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: responseHeaders("application/json", options.responseMetadata),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
options.log?.error?.("CLAUDE-WEB-STREAM", "Claude Web stream protocol validation failed");
|
||||
notifyFailure(options);
|
||||
return new Response(JSON.stringify(protocolErrorBody()), {
|
||||
status: 502,
|
||||
headers: responseHeaders("application/json", options.responseMetadata),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamingState {
|
||||
encoder: TextEncoder;
|
||||
id: string;
|
||||
created: number;
|
||||
control: StreamControl;
|
||||
iterator: AsyncIterator<SemanticEvent, void, void>;
|
||||
pendingChunks: Uint8Array[];
|
||||
assistantText: string;
|
||||
outcome: "pending" | "completed" | "failed";
|
||||
terminal: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
function encodeStreamEvent(state: StreamingState, value: Record<string, unknown>): Uint8Array {
|
||||
return state.encoder.encode(`data: ${JSON.stringify(value)}\n\n`);
|
||||
}
|
||||
|
||||
function failStreamOnce(state: StreamingState, options: ClaudeWebStreamOptions): void {
|
||||
if (state.outcome !== "pending") return;
|
||||
state.outcome = "failed";
|
||||
notifyFailure(options);
|
||||
}
|
||||
|
||||
function closeStreamIfDrained(
|
||||
state: StreamingState,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
): void {
|
||||
if (!state.closed && state.terminal && state.pendingChunks.length === 0) {
|
||||
state.closed = true;
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
function flushStreamChunk(
|
||||
state: StreamingState,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
): boolean {
|
||||
const chunk = state.pendingChunks.shift();
|
||||
if (!chunk) {
|
||||
closeStreamIfDrained(state, controller);
|
||||
return false;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
closeStreamIfDrained(state, controller);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function queueSemanticEvent(
|
||||
state: StreamingState,
|
||||
event: SemanticEvent,
|
||||
options: ClaudeWebStreamOptions
|
||||
): Promise<void> {
|
||||
if (event.kind === "content") {
|
||||
state.assistantText += event.text;
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(state.id, state.created, options, { content: event.text }, null)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "reasoning") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(state.id, state.created, options, { reasoning_content: event.text }, null)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "metadata") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(state.id, state.created, options, {}, null, {
|
||||
type: event.eventType,
|
||||
data: event.data,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await state.iterator.return?.();
|
||||
state.outcome = "completed";
|
||||
notifyComplete(options, { assistantText: state.assistantText, stopReason: event.stopReason });
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(state.id, state.created, options, {}, openAiFinishReason(event.stopReason))
|
||||
)
|
||||
);
|
||||
state.pendingChunks.push(state.encoder.encode("data: [DONE]\n\n"));
|
||||
state.terminal = true;
|
||||
}
|
||||
|
||||
function queueStreamFailure(state: StreamingState, options: ClaudeWebStreamOptions): void {
|
||||
options.log?.error?.("CLAUDE-WEB-STREAM", "Claude Web stream protocol validation failed");
|
||||
failStreamOnce(state, options);
|
||||
state.pendingChunks.push(encodeStreamEvent(state, protocolErrorBody()));
|
||||
state.pendingChunks.push(state.encoder.encode("data: [DONE]\n\n"));
|
||||
state.terminal = true;
|
||||
}
|
||||
|
||||
async function pullStreamingChunk(
|
||||
state: StreamingState,
|
||||
options: ClaudeWebStreamOptions,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
): Promise<void> {
|
||||
if (state.closed || flushStreamChunk(state, controller)) return;
|
||||
if (state.terminal) {
|
||||
closeStreamIfDrained(state, controller);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
while (!state.terminal) {
|
||||
const next = await state.iterator.next();
|
||||
if (state.control.cancelled) return;
|
||||
if (next.done) {
|
||||
throw new ClaudeWebProtocolError("Claude Web stream ended without a terminal event");
|
||||
}
|
||||
await queueSemanticEvent(state, next.value, options);
|
||||
if (state.pendingChunks.length > 0) {
|
||||
flushStreamChunk(state, controller);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (state.control.cancelled) return;
|
||||
queueStreamFailure(state, options);
|
||||
flushStreamChunk(state, controller);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelStreaming(
|
||||
state: StreamingState,
|
||||
options: ClaudeWebStreamOptions,
|
||||
reason: unknown
|
||||
): Promise<void> {
|
||||
if (state.closed) return;
|
||||
state.terminal = true;
|
||||
state.control.cancelled = true;
|
||||
failStreamOnce(state, options);
|
||||
if (state.control.reader) await state.control.reader.cancel(reason).catch(() => {});
|
||||
try {
|
||||
await state.iterator.return?.();
|
||||
} catch {
|
||||
// Cancellation is best-effort; the source reader was already cancelled.
|
||||
}
|
||||
state.closed = true;
|
||||
}
|
||||
|
||||
function createStreamingResponse(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
options: ClaudeWebStreamOptions
|
||||
): Response {
|
||||
const control: StreamControl = { reader: null, cancelled: false };
|
||||
const state: StreamingState = {
|
||||
encoder: new TextEncoder(),
|
||||
id: `chatcmpl-${randomUUID()}`,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
control,
|
||||
iterator: parseClaudeWebEvents(source, control)[Symbol.asyncIterator](),
|
||||
pendingChunks: [],
|
||||
assistantText: "",
|
||||
outcome: "pending",
|
||||
terminal: false,
|
||||
closed: false,
|
||||
};
|
||||
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
return pullStreamingChunk(state, options, controller);
|
||||
},
|
||||
cancel(reason) {
|
||||
return cancelStreaming(state, options, reason);
|
||||
},
|
||||
});
|
||||
|
||||
const headers = responseHeaders("text/event-stream", options.responseMetadata);
|
||||
headers.set("Connection", "keep-alive");
|
||||
return new Response(output, { status: 200, headers });
|
||||
}
|
||||
|
||||
export async function createClaudeWebResponse(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
options: ClaudeWebStreamOptions
|
||||
): Promise<Response> {
|
||||
return options.stream
|
||||
? createStreamingResponse(source, options)
|
||||
: createBufferedResponse(source, options);
|
||||
}
|
||||
104
open-sse/executors/claude-web/transport.ts
Normal file
104
open-sse/executors/claude-web/transport.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { FETCH_TIMEOUT_MS } from "../../config/constants.ts";
|
||||
import {
|
||||
tlsFetchClaude,
|
||||
type TlsFetchOptions,
|
||||
type TlsFetchResult,
|
||||
} from "../../services/claudeTlsClient.ts";
|
||||
import type { ClaudeWebTransportRequest, ClaudeWebTransportResult } from "./browserTransport.ts";
|
||||
|
||||
const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
||||
|
||||
export interface ClaudeWebDirectDeps {
|
||||
tlsFetch(url: string, options: TlsFetchOptions): Promise<TlsFetchResult>;
|
||||
}
|
||||
|
||||
const defaultDeps: ClaudeWebDirectDeps = {
|
||||
tlsFetch: tlsFetchClaude,
|
||||
};
|
||||
|
||||
function expectedRequestUrl(request: ClaudeWebTransportRequest): string {
|
||||
return (
|
||||
"https://claude.ai/api/organizations/" +
|
||||
`${encodeURIComponent(request.organizationId)}/chat_conversations/` +
|
||||
`${encodeURIComponent(request.conversationId)}/${request.endpointSuffix}`
|
||||
);
|
||||
}
|
||||
|
||||
async function readErrorBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
text: string | null
|
||||
): Promise<string> {
|
||||
if (!body) return text ?? "";
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let output = "";
|
||||
try {
|
||||
while (total < MAX_ERROR_BODY_BYTES) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done || !value) break;
|
||||
const remaining = MAX_ERROR_BODY_BYTES - total;
|
||||
const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value;
|
||||
total += chunk.byteLength;
|
||||
output += decoder.decode(chunk, { stream: total < MAX_ERROR_BODY_BYTES });
|
||||
if (chunk.byteLength < value.byteLength) break;
|
||||
}
|
||||
output += decoder.decode();
|
||||
return output;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// The stream may already have released the reader after cancel().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isClaudeWebChallenge(result: ClaudeWebTransportResult): boolean {
|
||||
if (result.status !== 403) return false;
|
||||
if (result.headers.get("cf-mitigated")?.toLowerCase() === "challenge") return true;
|
||||
const body = result.bodyText ?? "";
|
||||
return (
|
||||
/<title>\s*Just a moment/i.test(body) ||
|
||||
/<title>\s*Attention Required/i.test(body) ||
|
||||
/\b(?:cf-chl|challenge-platform)\b/i.test(body)
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendClaudeWebDirect(
|
||||
request: ClaudeWebTransportRequest,
|
||||
deps: ClaudeWebDirectDeps = defaultDeps
|
||||
): Promise<ClaudeWebTransportResult> {
|
||||
if (request.url !== expectedRequestUrl(request)) {
|
||||
throw new Error("Claude Web direct request endpoint does not match prepared state");
|
||||
}
|
||||
|
||||
const response = await deps.tlsFetch(request.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...request.headers,
|
||||
Cookie: request.cookieString,
|
||||
},
|
||||
body: JSON.stringify(request.payload),
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
stream: true,
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.body,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyText = await readErrorBody(response.body, response.text);
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: bodyText ? new Response(bodyText).body : null,
|
||||
bodyText,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { AuggieExecutor } from "./auggie.ts";
|
||||
import { DeepSeekWebExecutor } from "./deepseek-web.ts";
|
||||
import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
|
||||
import { AdaptaWebExecutor } from "./adapta-web.ts";
|
||||
import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts";
|
||||
import { CopilotWebExecutor } from "./copilot-web.ts";
|
||||
import { CopilotM365WebExecutor } from "./copilot-m365-web.ts";
|
||||
import { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts";
|
||||
@@ -109,8 +108,8 @@ const executors = {
|
||||
"perplexity-web": new PerplexityWebExecutor(),
|
||||
"pplx-web": new PerplexityWebExecutor(), // Alias
|
||||
"grok-web": new GrokWebExecutor(),
|
||||
"claude-web": new ClaudeWebWithAutoRefresh(),
|
||||
"cw-web": new ClaudeWebWithAutoRefresh(), // Alias
|
||||
"claude-web": new ClaudeWebExecutor(),
|
||||
"cw-web": new ClaudeWebExecutor(), // Alias
|
||||
"gemini-web": new GeminiWebExecutor(),
|
||||
gweb: new GeminiWebExecutor(), // Alias
|
||||
"gemini-business": new GeminiBusinessExecutor(),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* so the server rejects the request.
|
||||
*
|
||||
* This pool keeps one Chromium instance warm and serves "browser contexts"
|
||||
* (one per provider) on demand. Each context owns one or more pages; the
|
||||
* (one per caller-defined isolation key) on demand. Each context owns one or more pages; the
|
||||
* caller is expected to be polite (one page per request, close on done).
|
||||
*
|
||||
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
|
||||
@@ -38,6 +38,7 @@ export interface BrowserPoolContextOptions {
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
preferCloakbrowser?: boolean;
|
||||
proxyProviderKey?: string;
|
||||
}
|
||||
|
||||
export interface PooledContext {
|
||||
@@ -149,8 +150,7 @@ function evictStaleContexts(): void {
|
||||
for (const [key, pooled] of state.contexts) {
|
||||
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
|
||||
console.log(
|
||||
"[BrowserPool] Evicted stale context:",
|
||||
key,
|
||||
"[BrowserPool] Evicted stale context",
|
||||
"(idle",
|
||||
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
|
||||
);
|
||||
@@ -214,6 +214,14 @@ export async function resolvePlaywrightProxy(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBrowserContextProxy(
|
||||
contextKey: string,
|
||||
options: Pick<BrowserPoolContextOptions, "proxyProviderKey">,
|
||||
deps?: ResolvePlaywrightProxyDeps
|
||||
): Promise<import("playwright").LaunchOptions["proxy"] | undefined> {
|
||||
return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps);
|
||||
}
|
||||
|
||||
async function launchBrowser(): Promise<Browser> {
|
||||
if (state.browser) return state.browser;
|
||||
if (state.launching) return state.launching;
|
||||
@@ -329,7 +337,10 @@ export async function acquireBrowserContext(
|
||||
if (pending) return pending;
|
||||
|
||||
const createPromise = (async (): Promise<PooledContext> => {
|
||||
const [browser, proxy] = await Promise.all([launchBrowser(), resolvePlaywrightProxy(key)]);
|
||||
const [browser, proxy] = await Promise.all([
|
||||
launchBrowser(),
|
||||
resolveBrowserContextProxy(key, options),
|
||||
]);
|
||||
const isStealth = state.cloakLaunch !== null;
|
||||
const context = await browser.newContext({
|
||||
userAgent: options.userAgent || DEFAULT_USER_AGENT,
|
||||
|
||||
@@ -20,7 +20,8 @@ import { randomUUID } from "node:crypto";
|
||||
let clientPromise: Promise<unknown> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
|
||||
const CLAUDE_PROFILE = "chrome_146"; // closest supported wreq-js profile (chrome_149 absent in 2.3.1, #5591)
|
||||
export const CLAUDE_TLS_BROWSER_MAJOR_VERSION = "146";
|
||||
const CLAUDE_PROFILE = `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`;
|
||||
const DEFAULT_TIMEOUT_MS =
|
||||
Number.parseInt(process.env.OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS || "", 10) || 60_000;
|
||||
// Grace period added to the binding's wire-level timeout before our JS-level
|
||||
@@ -246,7 +247,7 @@ export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a single HTTP request to claude.ai with a Firefox-like TLS fingerprint.
|
||||
* Make a single HTTP request to claude.ai with the configured Chrome TLS profile.
|
||||
*
|
||||
* Throws TlsClientUnavailableError if the native binary failed to load.
|
||||
*/
|
||||
|
||||
@@ -402,9 +402,9 @@ function allScanFiles(root = ROOT) {
|
||||
const files = [];
|
||||
for (const p of SCAN_PATHS) walkMarkdown(p, files, root);
|
||||
return files.filter((f) => {
|
||||
const rel = path.relative(root, f);
|
||||
const rel = path.relative(root, f).split(path.sep).join("/");
|
||||
for (const skip of SKIP_DOC_FILES) {
|
||||
if (rel === skip || rel.startsWith(skip + path.sep)) return false;
|
||||
if (rel === skip || rel.startsWith(skip + "/")) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -861,9 +861,13 @@ export function formatHumanReport(result) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function isDirectExecution(moduleUrl, argvEntry) {
|
||||
if (!argvEntry) return false;
|
||||
return path.resolve(fileURLToPath(moduleUrl)) === path.resolve(argvEntry);
|
||||
}
|
||||
|
||||
// CLI entry — only run when invoked directly (not when imported for tests).
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
if (isDirectExecution(import.meta.url, process.argv[1])) {
|
||||
main();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolvePlaywrightProxy } from "../../open-sse/services/browserPool.ts";
|
||||
import {
|
||||
resolveBrowserContextProxy,
|
||||
resolvePlaywrightProxy,
|
||||
} from "../../open-sse/services/browserPool.ts";
|
||||
|
||||
describe("resolvePlaywrightProxy", () => {
|
||||
it("returns undefined when no proxy is configured", async () => {
|
||||
@@ -84,4 +87,21 @@ describe("resolvePlaywrightProxy", () => {
|
||||
});
|
||||
assert.strictEqual(capturedKey, "gemini-web");
|
||||
});
|
||||
|
||||
it("allows a scoped context key to use its stable provider key for proxy lookup", async () => {
|
||||
let capturedKey = "";
|
||||
const proxy = await resolveBrowserContextProxy(
|
||||
"claude-web:account-scope",
|
||||
{ proxyProviderKey: "claude-web" },
|
||||
{
|
||||
resolveProxy: async (id) => {
|
||||
capturedKey = id;
|
||||
return { type: "http", host: "proxy.example.com", port: 8080 };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(capturedKey, "claude-web");
|
||||
assert.deepEqual(proxy, { server: "http://proxy.example.com:8080" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,12 @@ import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
runFabricatedDocsCheck,
|
||||
formatHumanReport,
|
||||
isDirectExecution,
|
||||
} from "../../scripts/check/check-fabricated-docs.mjs";
|
||||
|
||||
// ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
@@ -63,6 +65,20 @@ test("runFabricatedDocsCheck: runs without throwing on the real repo", () => {
|
||||
assert.ok(result.index.cliCommands instanceof Set);
|
||||
});
|
||||
|
||||
test("runFabricatedDocsCheck: real documentation has no fabricated claims", () => {
|
||||
const result = runFabricatedDocsCheck();
|
||||
assert.equal(result.totalFindings, 0, formatHumanReport(result));
|
||||
});
|
||||
|
||||
test("isDirectExecution: matches a module URL to its filesystem argv path", () => {
|
||||
const scriptPath = path.resolve("scripts/check/check-fabricated-docs.mjs");
|
||||
const testPath = path.resolve("tests/unit/check-fabricated-docs.test.ts");
|
||||
|
||||
assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, scriptPath), true);
|
||||
assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, testPath), false);
|
||||
assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, undefined), false);
|
||||
});
|
||||
|
||||
test("runFabricatedDocsCheck: index contains real OmniRoute routes", () => {
|
||||
const result = runFabricatedDocsCheck();
|
||||
// The real repo has /api/v1/chat/completions — a known truth
|
||||
|
||||
510
tests/unit/claude-web-browser-transport.test.ts
Normal file
510
tests/unit/claude-web-browser-transport.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
__resetClaudeWebBrowserTemplatesForTesting,
|
||||
__setClaudeWebBrowserNowForTesting,
|
||||
applyClaudeWebBrowserTemplate,
|
||||
buildClaudeWebBrowserPoolKey,
|
||||
mergeClaudeWebBrowserPayload,
|
||||
sendClaudeWebBrowser,
|
||||
type ClaudeWebBrowserDeps,
|
||||
type ClaudeWebTransportRequest,
|
||||
} from "../../open-sse/executors/claude-web/browserTransport.ts";
|
||||
import {
|
||||
transformToClaude,
|
||||
type ClaudeWebRequestPayload,
|
||||
} from "../../open-sse/executors/claude-web/payload.ts";
|
||||
|
||||
const ORGANIZATION_ID = "organization-secret-a";
|
||||
const CONVERSATION_ID = "00000000-0000-4000-8000-000000000010";
|
||||
const PARENT_UUID = "00000000-0000-4000-8000-000000000011";
|
||||
const HUMAN_UUID = "00000000-0000-4000-8000-000000000012";
|
||||
const ASSISTANT_UUID = "00000000-0000-4000-8000-000000000013";
|
||||
|
||||
function preparedPayload(
|
||||
operation: "completion" | "retry_completion" = "completion"
|
||||
): ClaudeWebRequestPayload {
|
||||
return transformToClaude(
|
||||
{
|
||||
messages: [{ role: "user", content: "prepared prompt" }],
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
operation,
|
||||
prompt: operation === "retry_completion" ? "" : "prepared prompt",
|
||||
timezone: "Asia/Seoul",
|
||||
locale: "ko-KR",
|
||||
parentMessageUuid: operation === "retry_completion" ? PARENT_UUID : undefined,
|
||||
humanMessageUuid: operation === "completion" ? HUMAN_UUID : undefined,
|
||||
assistantMessageUuid: ASSISTANT_UUID,
|
||||
isNewConversation: operation === "completion",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function uiPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prompt: "ui prompt",
|
||||
model: "ui-model",
|
||||
timezone: "America/New_York",
|
||||
locale: "en-US",
|
||||
effort: "low",
|
||||
thinking_mode: "off",
|
||||
tools: [{ name: "account_tool", input_schema: { type: "object" } }],
|
||||
tool_states: [{ name: "account_tool", enabled: true }],
|
||||
personalized_styles: [{ type: "default", key: "AccountStyle" }],
|
||||
turn_message_uuids: {
|
||||
human_message_uuid: "ui-human",
|
||||
assistant_message_uuid: "ui-assistant",
|
||||
},
|
||||
parent_message_uuid: "ui-parent",
|
||||
create_conversation_params: { model: "ui-model" },
|
||||
attachments: [],
|
||||
files: [],
|
||||
sync_sources: [],
|
||||
rendering_mode: "messages",
|
||||
};
|
||||
}
|
||||
|
||||
function request(
|
||||
endpointSuffix: "completion" | "retry_completion" = "completion",
|
||||
overrides: Partial<ClaudeWebTransportRequest> = {}
|
||||
): ClaudeWebTransportRequest {
|
||||
const url = `https://claude.ai/api/organizations/${ORGANIZATION_ID}/chat_conversations/${CONVERSATION_ID}/${endpointSuffix}`;
|
||||
const payload = preparedPayload(endpointSuffix);
|
||||
return {
|
||||
scopeKey: "account-scope-digest-a",
|
||||
organizationId: ORGANIZATION_ID,
|
||||
conversationId: CONVERSATION_ID,
|
||||
endpointSuffix,
|
||||
pageUrl:
|
||||
endpointSuffix === "completion"
|
||||
? "https://claude.ai/new"
|
||||
: `https://claude.ai/chat/${CONVERSATION_ID}`,
|
||||
url,
|
||||
cookieString: "sessionKey=secret-cookie; cf_clearance=browser-cookie",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
Cookie: "must-not-be-forwarded-by-page-fetch",
|
||||
},
|
||||
payload,
|
||||
locale: payload.locale,
|
||||
timezone: payload.timezone,
|
||||
signal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type RouteMatcher = string | RegExp | ((url: URL) => boolean);
|
||||
|
||||
function matcherAccepts(matcher: RouteMatcher, url: string): boolean {
|
||||
if (typeof matcher === "string") return matcher === url;
|
||||
if (matcher instanceof RegExp) return matcher.test(url);
|
||||
return matcher(new URL(url));
|
||||
}
|
||||
|
||||
function createBrowserHarness(
|
||||
initialUiPayload = uiPayload(),
|
||||
contextIdentity: object = {},
|
||||
responseBody = new TextEncoder().encode('data: {"type":"message_stop"}\n\n')
|
||||
): {
|
||||
deps: ClaudeWebBrowserDeps;
|
||||
acquired: Array<{ key: string; options: Record<string, unknown> }>;
|
||||
continued: Array<{ url?: string; postData?: string }>;
|
||||
evaluated: Array<Record<string, unknown>>;
|
||||
navigated: string[];
|
||||
filled: string[];
|
||||
routeMatchers: RouteMatcher[];
|
||||
aborted: () => number;
|
||||
closed: () => number;
|
||||
responseWaits: () => number;
|
||||
} {
|
||||
const acquired: Array<{ key: string; options: Record<string, unknown> }> = [];
|
||||
const continued: Array<{ url?: string; postData?: string }> = [];
|
||||
const evaluated: Array<Record<string, unknown>> = [];
|
||||
const navigated: string[] = [];
|
||||
const filled: string[] = [];
|
||||
const routeMatchers: RouteMatcher[] = [];
|
||||
let closeCount = 0;
|
||||
let abortCount = 0;
|
||||
let responseWaitCount = 0;
|
||||
let routeHandler:
|
||||
| ((route: {
|
||||
request(): {
|
||||
url(): string;
|
||||
method(): string;
|
||||
postData(): string;
|
||||
allHeaders(): Promise<Record<string, string>>;
|
||||
};
|
||||
continue(options: { url?: string; postData?: string }): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const page = {
|
||||
async goto(url: string): Promise<void> {
|
||||
navigated.push(url);
|
||||
},
|
||||
async route(matcher: RouteMatcher, handler: typeof routeHandler): Promise<void> {
|
||||
routeMatchers.push(matcher);
|
||||
routeHandler = handler;
|
||||
},
|
||||
async unroute(): Promise<void> {},
|
||||
waitForResponse(): Promise<unknown> {
|
||||
responseWaitCount += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
locator() {
|
||||
return {
|
||||
first() {
|
||||
return {
|
||||
async waitFor(): Promise<void> {},
|
||||
async fill(value: string): Promise<void> {
|
||||
filled.push(value);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
keyboard: {
|
||||
async press(): Promise<void> {
|
||||
assert.ok(routeHandler, "completion must install an interception route");
|
||||
await routeHandler({
|
||||
request: () => ({
|
||||
url: () => request().url,
|
||||
method: () => "POST",
|
||||
postData: () => JSON.stringify(initialUiPayload),
|
||||
async allHeaders() {
|
||||
return { "x-ui-session": "scoped" };
|
||||
},
|
||||
}),
|
||||
async continue(options) {
|
||||
continued.push(options);
|
||||
},
|
||||
async abort() {
|
||||
abortCount += 1;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
async evaluate(_fn: unknown, argument: Record<string, unknown>): Promise<void> {
|
||||
evaluated.push(argument);
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
closeCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const deps: ClaudeWebBrowserDeps = {
|
||||
async acquireContext(key, options) {
|
||||
acquired.push({ key, options: options as unknown as Record<string, unknown> });
|
||||
return {
|
||||
id: key,
|
||||
context: contextIdentity,
|
||||
warmupPage: null,
|
||||
lastUsed: 0,
|
||||
isStealth: false,
|
||||
} as never;
|
||||
},
|
||||
async openPage() {
|
||||
return page as never;
|
||||
},
|
||||
async fetchResponse(_page, input) {
|
||||
evaluated.push(input as unknown as Record<string, unknown>);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream", "x-browser": "scoped" },
|
||||
body: responseBody,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
acquired,
|
||||
continued,
|
||||
evaluated,
|
||||
navigated,
|
||||
filled,
|
||||
routeMatchers,
|
||||
aborted: () => abortCount,
|
||||
closed: () => closeCount,
|
||||
responseWaits: () => responseWaitCount,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetClaudeWebBrowserTemplatesForTesting();
|
||||
__setClaudeWebBrowserNowForTesting(1_000_000);
|
||||
});
|
||||
|
||||
describe("Claude Web account-scoped browser transport", () => {
|
||||
it("hashes account and organization scope without exposing either identifier", () => {
|
||||
const base = request();
|
||||
const first = buildClaudeWebBrowserPoolKey(base);
|
||||
const otherAccount = buildClaudeWebBrowserPoolKey({ ...base, scopeKey: "other-account" });
|
||||
const otherOrganization = buildClaudeWebBrowserPoolKey({
|
||||
...base,
|
||||
organizationId: "organization-secret-b",
|
||||
});
|
||||
const otherCookie = buildClaudeWebBrowserPoolKey({
|
||||
...base,
|
||||
cookieString: "sessionKey=rotated-cookie",
|
||||
});
|
||||
const otherProfile = buildClaudeWebBrowserPoolKey({
|
||||
...base,
|
||||
locale: "en-US",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
|
||||
assert.notEqual(first, otherAccount);
|
||||
assert.notEqual(first, otherOrganization);
|
||||
assert.notEqual(first, otherCookie);
|
||||
assert.notEqual(first, otherProfile);
|
||||
assert.match(first, /^claude-web:[a-f0-9]{64}$/);
|
||||
assert.doesNotMatch(first, /connection|cookie|organization|secret/);
|
||||
});
|
||||
|
||||
it("preserves account tools, states, and styles while replacing prepared turn state", () => {
|
||||
const merged = mergeClaudeWebBrowserPayload(uiPayload(), preparedPayload("retry_completion"));
|
||||
|
||||
assert.deepEqual(merged.tools, uiPayload().tools);
|
||||
assert.deepEqual(merged.tool_states, uiPayload().tool_states);
|
||||
assert.deepEqual(merged.personalized_styles, uiPayload().personalized_styles);
|
||||
assert.equal(merged.model, "claude-opus-4-8");
|
||||
assert.equal(merged.effort, "high");
|
||||
assert.equal(merged.thinking_mode, "extended");
|
||||
assert.equal(merged.prompt, "");
|
||||
assert.equal(merged.parent_message_uuid, PARENT_UUID);
|
||||
assert.deepEqual(merged.turn_message_uuids, { assistant_message_uuid: ASSISTANT_UUID });
|
||||
assert.equal("create_conversation_params" in merged, false);
|
||||
});
|
||||
|
||||
it("intercepts only a verified-organization new completion and rewrites its endpoint", async () => {
|
||||
const harness = createBrowserHarness();
|
||||
const transportRequest = request();
|
||||
const result = await sendClaudeWebBrowser(transportRequest, harness.deps);
|
||||
|
||||
const expectedPoolKey = buildClaudeWebBrowserPoolKey(transportRequest);
|
||||
assert.equal(harness.acquired[0].key, expectedPoolKey);
|
||||
assert.equal(harness.acquired[0].options.proxyProviderKey, "claude-web");
|
||||
assert.equal(harness.acquired[0].options.cookieString, transportRequest.cookieString);
|
||||
assert.equal(harness.acquired[0].options.locale, "ko-KR");
|
||||
assert.equal(harness.acquired[0].options.timezone, "Asia/Seoul");
|
||||
assert.equal(matcherAccepts(harness.routeMatchers[0], transportRequest.url), true);
|
||||
assert.equal(
|
||||
matcherAccepts(
|
||||
harness.routeMatchers[0],
|
||||
transportRequest.url.replace(ORGANIZATION_ID, "other-organization")
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
matcherAccepts(
|
||||
harness.routeMatchers[0],
|
||||
transportRequest.url.replace(CONVERSATION_ID, "00000000-0000-4000-8000-000000000099")
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
matcherAccepts(
|
||||
harness.routeMatchers[0],
|
||||
transportRequest.url.replace("/completion", "/retry_completion")
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(harness.continued.length, 0);
|
||||
assert.equal(harness.aborted(), 1);
|
||||
const continuedPayload = JSON.parse(String(harness.evaluated[0].body ?? "{}")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.deepEqual(continuedPayload.tools, uiPayload().tools);
|
||||
assert.equal(continuedPayload.prompt, "prepared prompt");
|
||||
assert.deepEqual(harness.navigated, [transportRequest.pageUrl]);
|
||||
assert.deepEqual(harness.filled, ["prepared prompt"]);
|
||||
assert.equal(harness.evaluated[0].url, transportRequest.url);
|
||||
assert.equal(harness.evaluated[0].maxBytes, 16 * 1024 * 1024);
|
||||
const fetchedHeaders = harness.evaluated[0].headers as Record<string, string>;
|
||||
assert.equal(fetchedHeaders["x-ui-session"], "scoped");
|
||||
assert.equal("Cookie" in fetchedHeaders, false);
|
||||
assert.equal(harness.closed(), 1);
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.headers.get("x-browser"), "scoped");
|
||||
assert.equal(await new Response(result.body).text(), 'data: {"type":"message_stop"}\n\n');
|
||||
assert.equal("cookieString" in result, false);
|
||||
});
|
||||
|
||||
it("reuses only a non-expired same-scope UI template for retry", async () => {
|
||||
const sharedContext = {};
|
||||
const completionHarness = createBrowserHarness(uiPayload(), sharedContext);
|
||||
await sendClaudeWebBrowser(request(), completionHarness.deps);
|
||||
|
||||
const retryHarness = createBrowserHarness(uiPayload(), sharedContext);
|
||||
const retryRequest = request("retry_completion");
|
||||
await sendClaudeWebBrowser(retryRequest, retryHarness.deps);
|
||||
|
||||
assert.equal(retryHarness.acquired[0].key, completionHarness.acquired[0].key);
|
||||
assert.equal(retryHarness.continued.length, 0);
|
||||
assert.equal(retryHarness.evaluated.length, 1);
|
||||
const evaluatedPayload = JSON.parse(String(retryHarness.evaluated[0].body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.deepEqual(evaluatedPayload.tools, uiPayload().tools);
|
||||
assert.equal(evaluatedPayload.prompt, "");
|
||||
const evaluatedHeaders = retryHarness.evaluated[0].headers as Record<string, string>;
|
||||
assert.equal("Cookie" in evaluatedHeaders, false);
|
||||
|
||||
await assert.rejects(
|
||||
() => sendClaudeWebBrowser(retryRequest, createBrowserHarness(uiPayload(), {}).deps),
|
||||
/browser context|scoped UI template/i
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
sendClaudeWebBrowser(
|
||||
request("retry_completion", { scopeKey: "different-account-scope" }),
|
||||
createBrowserHarness().deps
|
||||
),
|
||||
/scoped UI template/i
|
||||
);
|
||||
|
||||
__setClaudeWebBrowserNowForTesting(1_000_000 + 30 * 60 * 1000 + 1);
|
||||
await assert.rejects(
|
||||
() => sendClaudeWebBrowser(retryRequest, createBrowserHarness().deps),
|
||||
/scoped UI template/i
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses a scoped browser template for direct requests only when caller tools are absent", async () => {
|
||||
const sharedContext = {};
|
||||
await sendClaudeWebBrowser(request(), createBrowserHarness(uiPayload(), sharedContext).deps);
|
||||
|
||||
const withoutCallerTools = applyClaudeWebBrowserTemplate(request());
|
||||
assert.deepEqual(withoutCallerTools.payload.tools, uiPayload().tools);
|
||||
|
||||
const callerTool = { name: "caller_tool", input_schema: { type: "object" } };
|
||||
const withCallerTools = request();
|
||||
withCallerTools.payload = { ...withCallerTools.payload, tools: [callerTool] };
|
||||
assert.strictEqual(applyClaudeWebBrowserTemplate(withCallerTools), withCallerTools);
|
||||
});
|
||||
|
||||
it("fails closed when a buffered browser response exceeds the hard size limit", async () => {
|
||||
const oversized = new Uint8Array(16 * 1024 * 1024 + 1);
|
||||
await assert.rejects(
|
||||
() => sendClaudeWebBrowser(request(), createBrowserHarness(uiPayload(), {}, oversized).deps),
|
||||
/response.*large|size limit/i
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a bounded page fetch instead of attaching a Playwright response waiter", async () => {
|
||||
const harness = createBrowserHarness();
|
||||
let fetchInput: Record<string, unknown> | undefined;
|
||||
const deps = {
|
||||
...harness.deps,
|
||||
async fetchResponse(_page: unknown, input: Record<string, unknown>) {
|
||||
fetchInput = input;
|
||||
return {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
body: new TextEncoder().encode('data: {"type":"message_stop"}\n\n'),
|
||||
};
|
||||
},
|
||||
} as ClaudeWebBrowserDeps;
|
||||
|
||||
await sendClaudeWebBrowser(request(), deps);
|
||||
|
||||
assert.equal(harness.responseWaits(), 0);
|
||||
assert.equal(fetchInput?.maxBytes, 16 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("cancels a page fetch as soon as the incremental byte limit is exceeded", async () => {
|
||||
const browserTransport =
|
||||
(await import("../../open-sse/executors/claude-web/browserTransport.ts")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const boundedFetch = browserTransport.fetchClaudeWebPageResponse;
|
||||
assert.equal(typeof boundedFetch, "function");
|
||||
if (typeof boundedFetch !== "function") return;
|
||||
|
||||
let reads = 0;
|
||||
let cancelled = false;
|
||||
const page = {
|
||||
async evaluate(
|
||||
callback: (input: Record<string, unknown>) => Promise<unknown>,
|
||||
input: Record<string, unknown>
|
||||
) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
reads += 1;
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
try {
|
||||
return await callback(input);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
(boundedFetch as (page: unknown, input: Record<string, unknown>) => Promise<unknown>)(
|
||||
page,
|
||||
{
|
||||
url: request().url,
|
||||
headers: {},
|
||||
body: "{}",
|
||||
maxBytes: 8,
|
||||
}
|
||||
),
|
||||
/size limit/i
|
||||
);
|
||||
assert.ok(reads >= 3 && reads <= 4, `unexpected prefetch count: ${reads}`);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
it("aborts a pending browser response read and closes the page", async () => {
|
||||
const harness = createBrowserHarness();
|
||||
let releaseRead:
|
||||
| ((value: { status: number; headers: Record<string, string>; body: Uint8Array }) => void)
|
||||
| undefined;
|
||||
harness.deps.fetchResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = sendClaudeWebBrowser(
|
||||
request("completion", { signal: controller.signal }),
|
||||
harness.deps
|
||||
);
|
||||
setTimeout(() => controller.abort(), 0);
|
||||
|
||||
const outcome = await Promise.race([
|
||||
pending.then(
|
||||
() => "resolved",
|
||||
() => "rejected"
|
||||
),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
|
||||
]);
|
||||
if (outcome === "timeout") {
|
||||
releaseRead?.({ status: 200, headers: {}, body: new Uint8Array() });
|
||||
await pending.catch(() => {});
|
||||
}
|
||||
assert.equal(outcome, "rejected");
|
||||
assert.equal(harness.closed(), 1);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Split-guard for the claude-web executor payload extraction.
|
||||
// The pure payload types + transforms + default tools/style live in the leaf
|
||||
// The pure payload types + transforms + caller-tool/style helpers live in the leaf
|
||||
// claude-web/payload.ts (no host state, no fetch/auth). Host imports back the
|
||||
// symbols it uses (ClaudeWebRequestPayload, transformToClaude, transformFromClaude).
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -15,7 +15,7 @@ const LEAF = join(EXE, "claude-web/payload.ts");
|
||||
|
||||
test("leaf hosts the payload builders/transforms and does not import the host", () => {
|
||||
const src = readFileSync(LEAF, "utf8");
|
||||
for (const sym of ["transformToClaude", "transformFromClaude", "getDefaultTools"]) {
|
||||
for (const sym of ["transformToClaude", "transformFromClaude", "transformOpenAiTools"]) {
|
||||
assert.match(src, new RegExp(`export function ${sym}\\b`));
|
||||
}
|
||||
assert.match(src, /export interface ClaudeWebRequestPayload\b/);
|
||||
|
||||
258
tests/unit/claude-web-live-alignment.test.ts
Normal file
258
tests/unit/claude-web-live-alignment.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { CLAUDE_WEB_FINGERPRINT } from "../../open-sse/config/claudeWebFingerprint.ts";
|
||||
import { ClaudeWebExecutor } from "../../open-sse/executors/claude-web.ts";
|
||||
import { transformToClaude } from "../../open-sse/executors/claude-web/payload.ts";
|
||||
import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/claudeTlsClient.ts";
|
||||
|
||||
const originalBrowserFlag = process.env.WEB_COOKIE_USE_BROWSER;
|
||||
const originalPoolFlag = process.env.OMNIROUTE_BROWSER_POOL;
|
||||
|
||||
function textStream(text: string): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function claudeSseStream(): ReadableStream<Uint8Array> {
|
||||
const events = [
|
||||
{ type: "message_start" },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
return textStream(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""));
|
||||
}
|
||||
|
||||
function clearBrowserFlags(): void {
|
||||
delete process.env.WEB_COOKIE_USE_BROWSER;
|
||||
delete process.env.OMNIROUTE_BROWSER_POOL;
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setTlsFetchOverrideForTesting(null);
|
||||
restoreEnv("WEB_COOKIE_USE_BROWSER", originalBrowserFlag);
|
||||
restoreEnv("OMNIROUTE_BROWSER_POOL", originalPoolFlag);
|
||||
});
|
||||
|
||||
describe("Claude Web live request alignment", () => {
|
||||
it("maps an explicit reasoning effort to Claude Web extended thinking", () => {
|
||||
const payload = transformToClaude(
|
||||
{
|
||||
messages: [{ role: "user", content: "Think carefully" }],
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
"claude-opus-4-8"
|
||||
);
|
||||
|
||||
assert.equal(payload.effort, "high");
|
||||
assert.equal(payload.thinking_mode, "extended");
|
||||
});
|
||||
|
||||
it("uses the shared browser fingerprint and the new-chat referer", async () => {
|
||||
clearBrowserFlags();
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (_url, options) => {
|
||||
capturedHeaders = options.headers;
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
text: null,
|
||||
body: claudeSseStream(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await new ClaudeWebExecutor().execute({
|
||||
model: "claude-opus-4-8",
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
cookie: "sessionKey=fake-session; cf_clearance=fake-clearance",
|
||||
orgId: "org-test",
|
||||
conversationId: "conv-test",
|
||||
},
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(capturedHeaders?.["User-Agent"], CLAUDE_WEB_FINGERPRINT.userAgent);
|
||||
assert.equal(capturedHeaders?.["Sec-Ch-Ua"], CLAUDE_WEB_FINGERPRINT.secChUa);
|
||||
assert.equal(capturedHeaders?.["Sec-Ch-Ua-Platform"], CLAUDE_WEB_FINGERPRINT.secChUaPlatform);
|
||||
assert.equal(capturedHeaders?.Referer, "https://claude.ai/new");
|
||||
});
|
||||
|
||||
it("fails closed when the authenticated organization cannot be resolved", async () => {
|
||||
clearBrowserFlags();
|
||||
let completionCalls = 0;
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (url) => {
|
||||
if (url.endsWith("/organizations")) {
|
||||
return {
|
||||
status: 503,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
text: '{"error":"unavailable"}',
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
|
||||
completionCalls += 1;
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
text: null,
|
||||
body: claudeSseStream(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await new ClaudeWebExecutor().execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
stream: true,
|
||||
credentials: { cookie: "sessionKey=fake-session; cf_clearance=fake-clearance" },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 502);
|
||||
assert.equal(completionCalls, 0);
|
||||
assert.match(await result.response.text(), /organization/i);
|
||||
});
|
||||
|
||||
it("uses the first organization returned by the current Claude Web session", async () => {
|
||||
clearBrowserFlags();
|
||||
let completionCalls = 0;
|
||||
let completionUrl = "";
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (url) => {
|
||||
if (url.endsWith("/organizations")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify([{ uuid: "org-first" }, { uuid: "org-second" }]),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
|
||||
completionCalls += 1;
|
||||
completionUrl = url;
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
text: null,
|
||||
body: claudeSseStream(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await new ClaudeWebExecutor().execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
stream: true,
|
||||
credentials: { cookie: "sessionKey=fake-session; cf_clearance=fake-clearance" },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(completionCalls, 1);
|
||||
assert.match(completionUrl, /\/api\/organizations\/org-first\/chat_conversations\//);
|
||||
});
|
||||
|
||||
it("reports an invalid organization-session authorization as 401", async () => {
|
||||
clearBrowserFlags();
|
||||
let completionCalls = 0;
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (url) => {
|
||||
if (url.endsWith("/organizations")) {
|
||||
return {
|
||||
status: 403,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify({
|
||||
error: {
|
||||
type: "permission_error",
|
||||
message: "Invalid authorization",
|
||||
details: { error_code: "invalid_auth" },
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
|
||||
completionCalls += 1;
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
text: null,
|
||||
body: claudeSseStream(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await new ClaudeWebExecutor().execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
stream: true,
|
||||
credentials: { cookie: "sessionKey=expired-session" },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 401);
|
||||
assert.equal(completionCalls, 0);
|
||||
assert.match(await result.response.text(), /session expired|invalid/i);
|
||||
});
|
||||
|
||||
it("sanitizes structured upstream error details before returning them", async () => {
|
||||
clearBrowserFlags();
|
||||
const upstreamError = {
|
||||
error: {
|
||||
message: "upstream failed\n at C:\\Users\\admin\\private\\source.ts:10:2",
|
||||
stack: "Error: upstream failed\n at C:\\Users\\admin\\private\\source.ts:10:2",
|
||||
path: "C:\\Users\\admin\\private\\source.ts",
|
||||
organization_id: "private-organization-id",
|
||||
prompt: "private prompt",
|
||||
},
|
||||
};
|
||||
|
||||
__setTlsFetchOverrideForTesting(async () => ({
|
||||
status: 500,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
text: null,
|
||||
body: textStream(JSON.stringify(upstreamError)),
|
||||
}));
|
||||
|
||||
const result = await new ClaudeWebExecutor().execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
cookie: "sessionKey=fake-session; cf_clearance=fake-clearance",
|
||||
orgId: "org-test",
|
||||
conversationId: "conv-test",
|
||||
},
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 500);
|
||||
const rawBody = await result.response.text();
|
||||
assert.doesNotMatch(rawBody, /C:\\\\Users/);
|
||||
assert.doesNotMatch(rawBody, /"stack"/);
|
||||
assert.doesNotMatch(rawBody, /"path"/);
|
||||
assert.doesNotMatch(rawBody, /private-organization-id|private prompt/);
|
||||
|
||||
const responseBody = JSON.parse(rawBody) as {
|
||||
error: { message: string };
|
||||
upstream_details?: unknown;
|
||||
};
|
||||
assert.equal(responseBody.error.message, "Claude Web API error (500)");
|
||||
assert.equal(responseBody.upstream_details, undefined);
|
||||
});
|
||||
});
|
||||
153
tests/unit/claude-web-payload-runtime.test.ts
Normal file
153
tests/unit/claude-web-payload-runtime.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
transformOpenAiTools,
|
||||
transformToClaude,
|
||||
} from "../../open-sse/executors/claude-web/payload.ts";
|
||||
|
||||
const PARENT_UUID = "00000000-0000-4000-8000-000000000001";
|
||||
const HUMAN_UUID = "00000000-0000-4000-8000-000000000002";
|
||||
const ASSISTANT_UUID = "00000000-0000-4000-8000-000000000003";
|
||||
|
||||
describe("Claude Web runtime payloads", () => {
|
||||
it("builds a new conversation with creation parameters and both turn UUIDs", () => {
|
||||
const payload = transformToClaude(
|
||||
{ messages: [{ role: "user", content: "start" }] },
|
||||
"claude-sonnet-5",
|
||||
{
|
||||
operation: "completion",
|
||||
prompt: "start",
|
||||
timezone: "Asia/Seoul",
|
||||
locale: "ko-KR",
|
||||
humanMessageUuid: HUMAN_UUID,
|
||||
assistantMessageUuid: ASSISTANT_UUID,
|
||||
isNewConversation: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(payload.prompt, "start");
|
||||
assert.equal(payload.timezone, "Asia/Seoul");
|
||||
assert.equal(payload.locale, "ko-KR");
|
||||
assert.deepEqual(payload.turn_message_uuids, {
|
||||
human_message_uuid: HUMAN_UUID,
|
||||
assistant_message_uuid: ASSISTANT_UUID,
|
||||
});
|
||||
assert.equal(payload.create_conversation_params?.model, "claude-sonnet-5");
|
||||
assert.equal("parent_message_uuid" in payload, false);
|
||||
});
|
||||
|
||||
it("builds a follow-up with a parent and no conversation creation parameters", () => {
|
||||
const payload = transformToClaude(
|
||||
{ messages: [{ role: "user", content: "next" }] },
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
operation: "completion",
|
||||
prompt: "next",
|
||||
timezone: "Asia/Seoul",
|
||||
locale: "ko-KR",
|
||||
parentMessageUuid: PARENT_UUID,
|
||||
humanMessageUuid: HUMAN_UUID,
|
||||
assistantMessageUuid: ASSISTANT_UUID,
|
||||
isNewConversation: false,
|
||||
toolStates: [],
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(payload.parent_message_uuid, PARENT_UUID);
|
||||
assert.equal("create_conversation_params" in payload, false);
|
||||
assert.equal(payload.timezone, "Asia/Seoul");
|
||||
assert.deepEqual(payload.tool_states, []);
|
||||
});
|
||||
|
||||
it("builds a retry with an empty prompt, parent, and assistant UUID only", () => {
|
||||
const payload = transformToClaude(
|
||||
{ messages: [{ role: "user", content: "ignored for retry" }] },
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
operation: "retry_completion",
|
||||
prompt: "",
|
||||
timezone: "UTC",
|
||||
locale: "en-US",
|
||||
parentMessageUuid: PARENT_UUID,
|
||||
assistantMessageUuid: ASSISTANT_UUID,
|
||||
isNewConversation: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(payload.prompt, "");
|
||||
assert.equal(payload.parent_message_uuid, PARENT_UUID);
|
||||
assert.deepEqual(payload.turn_message_uuids, {
|
||||
assistant_message_uuid: ASSISTANT_UUID,
|
||||
});
|
||||
assert.equal("create_conversation_params" in payload, false);
|
||||
});
|
||||
|
||||
it("converts only valid OpenAI function tools", () => {
|
||||
assert.deepEqual(
|
||||
transformOpenAiTools([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Read the weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "function", function: { description: "missing name" } },
|
||||
{ type: "web_search_preview" },
|
||||
null,
|
||||
]),
|
||||
[
|
||||
{
|
||||
name: "get_weather",
|
||||
description: "Read the weather",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invent Claude UI tools when the caller supplied none", () => {
|
||||
const payload = transformToClaude(
|
||||
{ messages: [{ role: "user", content: "hello" }] },
|
||||
"claude-sonnet-5"
|
||||
);
|
||||
|
||||
assert.deepEqual(payload.tools, []);
|
||||
assert.equal(
|
||||
payload.tools.some((tool) => tool.name === "show_widget" || tool.name === "web_search"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit reasoning effort for prepared turns", () => {
|
||||
const payload = transformToClaude(
|
||||
{
|
||||
messages: [{ role: "user", content: "think" }],
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
operation: "completion",
|
||||
prompt: "think",
|
||||
timezone: "UTC",
|
||||
locale: "en-US",
|
||||
humanMessageUuid: HUMAN_UUID,
|
||||
assistantMessageUuid: ASSISTANT_UUID,
|
||||
isNewConversation: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(payload.effort, "high");
|
||||
assert.equal(payload.thinking_mode, "extended");
|
||||
});
|
||||
});
|
||||
291
tests/unit/claude-web-session.test.ts
Normal file
291
tests/unit/claude-web-session.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
|
||||
import type { ProviderCredentials } from "../../open-sse/executors/base.ts";
|
||||
import {
|
||||
__resetClaudeWebSessionForTesting,
|
||||
__setClaudeWebSessionNowForTesting,
|
||||
commitClaudeWebTurn,
|
||||
invalidateClaudeWebTurn,
|
||||
prepareClaudeWebTurn,
|
||||
type PrepareClaudeWebTurnInput,
|
||||
} from "../../open-sse/executors/claude-web/session.ts";
|
||||
|
||||
const CONVERSATION_UUID = "00000000-0000-4000-8000-000000000010";
|
||||
const PARENT_UUID = "00000000-0000-4000-8000-000000000011";
|
||||
|
||||
function input(
|
||||
body: Record<string, unknown>,
|
||||
overrides: Partial<PrepareClaudeWebTurnInput> = {}
|
||||
): PrepareClaudeWebTurnInput {
|
||||
return {
|
||||
body,
|
||||
model: "claude-sonnet-5",
|
||||
credentials: { connectionId: "connection-a" },
|
||||
organizationId: "organization-a",
|
||||
normalizedCookie: "sessionKey=cookie-a",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function firstTurnBody(question = "first question"): Record<string, unknown> {
|
||||
return { messages: [{ role: "user", content: question }] };
|
||||
}
|
||||
|
||||
function followUpBody(
|
||||
firstQuestion = "first question",
|
||||
firstAnswer = "first answer",
|
||||
nextQuestion = "second question"
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: firstQuestion },
|
||||
{ role: "assistant", content: firstAnswer },
|
||||
{ role: "user", content: nextQuestion },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetClaudeWebSessionForTesting();
|
||||
__setClaudeWebSessionNowForTesting(1_000_000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetClaudeWebSessionForTesting();
|
||||
__setClaudeWebSessionNowForTesting(null);
|
||||
});
|
||||
|
||||
describe("Claude Web conversation sessions", () => {
|
||||
it("reuses a committed conversation for the matching transcript prefix", () => {
|
||||
const first = prepareClaudeWebTurn(input(firstTurnBody()));
|
||||
assert.equal(first.endpointSuffix, "completion");
|
||||
assert.equal(first.pageUrl, "https://claude.ai/new");
|
||||
assert.equal("parent_message_uuid" in first.payload, false);
|
||||
commitClaudeWebTurn(first, "first answer");
|
||||
|
||||
const followUp = prepareClaudeWebTurn(input(followUpBody()));
|
||||
|
||||
assert.equal(followUp.conversationId, first.conversationId);
|
||||
assert.equal(followUp.parentMessageUuid, first.assistantMessageUuid);
|
||||
assert.equal(followUp.pageUrl, `https://claude.ai/chat/${first.conversationId}`);
|
||||
assert.equal(followUp.payload.prompt, "second question");
|
||||
assert.equal("create_conversation_params" in followUp.payload, false);
|
||||
});
|
||||
|
||||
it("recovers every normalized message when multi-turn history misses the cache", () => {
|
||||
const recovered = prepareClaudeWebTurn(
|
||||
input({
|
||||
messages: [
|
||||
{ role: "system", content: "system rules" },
|
||||
{ role: "user", content: "old question" },
|
||||
{ role: "assistant", content: "old answer" },
|
||||
{ role: "tool", content: [{ type: "text", text: "tool result" }] },
|
||||
{ role: "user", content: "new question" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.notEqual(recovered.payload.prompt, "new question");
|
||||
for (const expected of [
|
||||
"system rules",
|
||||
"old question",
|
||||
"old answer",
|
||||
"tool result",
|
||||
"new question",
|
||||
]) {
|
||||
assert.match(recovered.payload.prompt, new RegExp(expected));
|
||||
}
|
||||
assert.ok(recovered.payload.create_conversation_params);
|
||||
});
|
||||
|
||||
it("isolates cache entries by connection, cookie fingerprint, organization, and model", () => {
|
||||
const first = prepareClaudeWebTurn(input(firstTurnBody()));
|
||||
commitClaudeWebTurn(first, "first answer");
|
||||
|
||||
const isolatedInputs = [
|
||||
input(followUpBody(), { credentials: { connectionId: "connection-b" } }),
|
||||
input(followUpBody(), { organizationId: "organization-b" }),
|
||||
input(followUpBody(), { model: "claude-opus-4-8" }),
|
||||
];
|
||||
for (const isolated of isolatedInputs) {
|
||||
assert.notEqual(prepareClaudeWebTurn(isolated).conversationId, first.conversationId);
|
||||
}
|
||||
|
||||
const cookieScoped = prepareClaudeWebTurn(
|
||||
input(firstTurnBody("cookie question"), {
|
||||
credentials: {},
|
||||
normalizedCookie: "sessionKey=cookie-one",
|
||||
})
|
||||
);
|
||||
commitClaudeWebTurn(cookieScoped, "cookie answer");
|
||||
const otherCookie = prepareClaudeWebTurn(
|
||||
input(followUpBody("cookie question", "cookie answer", "cookie follow-up"), {
|
||||
credentials: {},
|
||||
normalizedCookie: "sessionKey=cookie-two",
|
||||
})
|
||||
);
|
||||
assert.notEqual(otherCookie.conversationId, cookieScoped.conversationId);
|
||||
});
|
||||
|
||||
it("validates the strict claude_web extension", () => {
|
||||
const invalidExtensions = [
|
||||
{ operation: "duplicate" },
|
||||
{ conversation_id: "not-a-uuid" },
|
||||
{ parent_message_uuid: "not-a-uuid" },
|
||||
{ timezone: "Mars/Olympus_Mons" },
|
||||
{ locale: "not_a_locale" },
|
||||
{ tool_states: Array.from({ length: 129 }, () => null) },
|
||||
{ unknown_field: true },
|
||||
];
|
||||
|
||||
for (const claudeWeb of invalidExtensions) {
|
||||
assert.throws(() =>
|
||||
prepareClaudeWebTurn(input({ ...firstTurnBody(), claude_web: claudeWeb }))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves locale and timezone from extension, provider data, then runtime", () => {
|
||||
const credentials: ProviderCredentials = {
|
||||
connectionId: "connection-a",
|
||||
providerSpecificData: { timezone: "America/New_York", locale: "fr-FR" },
|
||||
};
|
||||
const explicit = prepareClaudeWebTurn(
|
||||
input(
|
||||
{
|
||||
...firstTurnBody(),
|
||||
claude_web: { timezone: "Asia/Seoul", locale: "ko-KR", tool_states: [] },
|
||||
},
|
||||
{ credentials }
|
||||
)
|
||||
);
|
||||
assert.equal(explicit.payload.timezone, "Asia/Seoul");
|
||||
assert.equal(explicit.payload.locale, "ko-KR");
|
||||
assert.deepEqual(explicit.payload.tool_states, []);
|
||||
|
||||
const provider = prepareClaudeWebTurn(input(firstTurnBody("provider"), { credentials }));
|
||||
assert.equal(provider.payload.timezone, "America/New_York");
|
||||
assert.equal(provider.payload.locale, "fr-FR");
|
||||
|
||||
const runtime = prepareClaudeWebTurn(
|
||||
input(firstTurnBody("runtime"), { credentials: { connectionId: "connection-a" } })
|
||||
);
|
||||
const runtimeOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
assert.equal(runtime.payload.timezone, runtimeOptions.timeZone || "UTC");
|
||||
assert.equal(runtime.payload.locale, runtimeOptions.locale || "en-US");
|
||||
});
|
||||
|
||||
it("builds explicit retry state and fails closed when retry state is absent", () => {
|
||||
const retry = prepareClaudeWebTurn(
|
||||
input({
|
||||
...followUpBody(),
|
||||
claude_web: {
|
||||
operation: "retry",
|
||||
conversation_id: CONVERSATION_UUID,
|
||||
parent_message_uuid: PARENT_UUID,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(retry.endpointSuffix, "retry_completion");
|
||||
assert.equal(retry.conversationId, CONVERSATION_UUID);
|
||||
assert.equal(retry.parentMessageUuid, PARENT_UUID);
|
||||
assert.equal(retry.payload.prompt, "");
|
||||
assert.deepEqual(retry.payload.turn_message_uuids, {
|
||||
assistant_message_uuid: retry.assistantMessageUuid,
|
||||
});
|
||||
assert.equal("create_conversation_params" in retry.payload, false);
|
||||
|
||||
assert.throws(
|
||||
() => prepareClaudeWebTurn(input({ ...firstTurnBody(), claude_web: { operation: "retry" } })),
|
||||
/conversation.*parent|parent.*conversation/i
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the legacy credentials conversationId with an explicit parent", () => {
|
||||
const credentials = {
|
||||
connectionId: "connection-a",
|
||||
conversationId: "legacy-conversation",
|
||||
} as ProviderCredentials;
|
||||
const turn = prepareClaudeWebTurn(
|
||||
input(
|
||||
{
|
||||
...firstTurnBody(),
|
||||
claude_web: { parent_message_uuid: PARENT_UUID },
|
||||
},
|
||||
{ credentials }
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(turn.conversationId, "legacy-conversation");
|
||||
assert.equal(turn.parentMessageUuid, PARENT_UUID);
|
||||
assert.equal("create_conversation_params" in turn.payload, false);
|
||||
});
|
||||
|
||||
it("invalidates reusable continuation state", () => {
|
||||
const first = prepareClaudeWebTurn(input(firstTurnBody()));
|
||||
commitClaudeWebTurn(first, "first answer");
|
||||
const reusable = prepareClaudeWebTurn(input(followUpBody()));
|
||||
assert.equal(reusable.conversationId, first.conversationId);
|
||||
|
||||
invalidateClaudeWebTurn(reusable, "conversation");
|
||||
const afterInvalidation = prepareClaudeWebTurn(input(followUpBody()));
|
||||
assert.notEqual(afterInvalidation.conversationId, first.conversationId);
|
||||
});
|
||||
|
||||
it("expires entries after 30 minutes", () => {
|
||||
const first = prepareClaudeWebTurn(input(firstTurnBody()));
|
||||
commitClaudeWebTurn(first, "first answer");
|
||||
|
||||
__setClaudeWebSessionNowForTesting(1_000_000 + 30 * 60 * 1000 + 1);
|
||||
const expired = prepareClaudeWebTurn(input(followUpBody()));
|
||||
assert.notEqual(expired.conversationId, first.conversationId);
|
||||
});
|
||||
|
||||
it("evicts the oldest entry when the 5,000-entry cap is exceeded", () => {
|
||||
const oldest = prepareClaudeWebTurn(input(firstTurnBody("question-0")));
|
||||
commitClaudeWebTurn(oldest, "answer-0");
|
||||
|
||||
for (let index = 1; index <= 5_000; index += 1) {
|
||||
const turn = prepareClaudeWebTurn(input(firstTurnBody(`question-${index}`)));
|
||||
commitClaudeWebTurn(turn, `answer-${index}`);
|
||||
}
|
||||
|
||||
const evicted = prepareClaudeWebTurn(
|
||||
input(followUpBody("question-0", "answer-0", "after eviction"))
|
||||
);
|
||||
assert.notEqual(evicted.conversationId, oldest.conversationId);
|
||||
});
|
||||
|
||||
it("prepares concurrent branches without mutating their shared parent", () => {
|
||||
const first = prepareClaudeWebTurn(input(firstTurnBody()));
|
||||
commitClaudeWebTurn(first, "first answer");
|
||||
|
||||
const left = prepareClaudeWebTurn(
|
||||
input(followUpBody("first question", "first answer", "left branch"))
|
||||
);
|
||||
const right = prepareClaudeWebTurn(
|
||||
input(followUpBody("first question", "first answer", "right branch"))
|
||||
);
|
||||
|
||||
assert.equal(left.parentMessageUuid, first.assistantMessageUuid);
|
||||
assert.equal(right.parentMessageUuid, first.assistantMessageUuid);
|
||||
assert.notEqual(left.assistantMessageUuid, right.assistantMessageUuid);
|
||||
|
||||
commitClaudeWebTurn(left, "left answer");
|
||||
invalidateClaudeWebTurn(right);
|
||||
const leftFollowUp = prepareClaudeWebTurn(
|
||||
input({
|
||||
messages: [
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "assistant", content: "first answer" },
|
||||
{ role: "user", content: "left branch" },
|
||||
{ role: "assistant", content: "left answer" },
|
||||
{ role: "user", content: "continue left" },
|
||||
],
|
||||
})
|
||||
);
|
||||
assert.equal(leftFollowUp.conversationId, first.conversationId);
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,28 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts";
|
||||
|
||||
// #6209 (#6200): the Claude Web provider (claude.ai scrape) now offers Claude 5 Sonnet.
|
||||
// Regression guard: the claude-web registry must expose `claude-sonnet-5` alongside the
|
||||
// pre-existing 4.6 Sonnet / 4.5 Haiku web entries. Fails without the registry line.
|
||||
test("claude-web registry exposes claude-sonnet-5 (Claude 5 Sonnet web)", () => {
|
||||
const models = getModelsByProviderId("claude-web");
|
||||
const ids = new Set(models.map((m) => m.id));
|
||||
assert.ok(ids.has("claude-sonnet-5"), "claude-web must expose claude-sonnet-5");
|
||||
// the prior web lineup must survive
|
||||
assert.ok(ids.has("claude-sonnet-4-6"), "claude-web must keep claude-sonnet-4-6");
|
||||
assert.ok(ids.has("claude-haiku-4-5"), "claude-web must keep claude-haiku-4-5");
|
||||
test("claude-web registry matches the current selectable model set", () => {
|
||||
const ids = getModelsByProviderId("claude-web")
|
||||
.map((model) => model.id)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
ids,
|
||||
[
|
||||
"claude-fable-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-5",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test("claude-web registry excludes the requested legacy Opus models", () => {
|
||||
const ids = new Set(getModelsByProviderId("claude-web").map((model) => model.id));
|
||||
|
||||
assert.equal(ids.has("claude-3-opus-20240229"), false);
|
||||
assert.equal(ids.has("claude-opus-4-1-20250805-claude-ai"), false);
|
||||
assert.equal(ids.has("claude-opus-4-5-20251101"), false);
|
||||
});
|
||||
|
||||
325
tests/unit/claude-web-stream.test.ts
Normal file
325
tests/unit/claude-web-stream.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { createClaudeWebResponse } from "../../open-sse/executors/claude-web/stream.ts";
|
||||
|
||||
type ParsedSse = {
|
||||
json: Array<Record<string, unknown>>;
|
||||
doneCount: number;
|
||||
};
|
||||
|
||||
function byteStream(text: string, chunkSizes: number[] = []): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let offset = 0;
|
||||
for (const size of chunkSizes) {
|
||||
if (offset >= bytes.length) break;
|
||||
controller.enqueue(bytes.slice(offset, Math.min(offset + size, bytes.length)));
|
||||
offset += size;
|
||||
}
|
||||
if (offset < bytes.length) controller.enqueue(bytes.slice(offset));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function frames(events: Array<Record<string, unknown>>, newline = "\n"): string {
|
||||
return events.map((event) => `data: ${JSON.stringify(event)}${newline}${newline}`).join("");
|
||||
}
|
||||
|
||||
function validEvents(): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ type: "message_start", message: { model: "claude-sonnet-5" } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "deep " },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_summary_delta", summary: "summary" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "text_delta", text: "hello" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_limit", remaining: 42 },
|
||||
{ type: "model_update", model: "claude-sonnet-5" },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
}
|
||||
|
||||
function parseSse(output: string): ParsedSse {
|
||||
const json: Array<Record<string, unknown>> = [];
|
||||
let doneCount = 0;
|
||||
for (const frame of output.split(/\r?\n\r?\n/)) {
|
||||
const data = frame
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n");
|
||||
if (!data) continue;
|
||||
if (data === "[DONE]") {
|
||||
doneCount += 1;
|
||||
} else {
|
||||
json.push(JSON.parse(data) as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
return { json, doneCount };
|
||||
}
|
||||
|
||||
describe("Claude Web strict stream protocol", () => {
|
||||
it("handles split CRLF frames, reasoning, text, metadata, and one terminal marker", async () => {
|
||||
const events = validEvents();
|
||||
const prefix =
|
||||
'data: {"type":"ping",\r\ndata: "latency_ms":12,"prompt":"never expose me"}\r\n\r\n';
|
||||
const source = prefix + frames(events, "\r\n") + "data: [DONE]\r\n\r\n";
|
||||
const completions: Array<{ assistantText: string; stopReason: string }> = [];
|
||||
let failures = 0;
|
||||
const response = await createClaudeWebResponse(byteStream(source, [1, 2, 5, 3, 11, 7]), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: true,
|
||||
responseMetadata: { conversation_id: "conversation-test" },
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const output = await response.text();
|
||||
const parsed = parseSse(output);
|
||||
const deltas = parsed.json.flatMap((chunk) => {
|
||||
const choices = chunk.choices as Array<{ delta?: Record<string, unknown> }> | undefined;
|
||||
return choices?.map((choice) => choice.delta ?? {}) ?? [];
|
||||
});
|
||||
assert.equal(deltas.map((delta) => delta.reasoning_content ?? "").join(""), "deep summary");
|
||||
assert.equal(deltas.map((delta) => delta.content ?? "").join(""), "hello");
|
||||
|
||||
const metadataEvents = parsed.json
|
||||
.map((chunk) => chunk.claude_web as Record<string, unknown> | undefined)
|
||||
.map((metadata) => metadata?.event as Record<string, unknown> | undefined)
|
||||
.map((event) => event?.type)
|
||||
.filter(Boolean);
|
||||
assert.ok(metadataEvents.includes("ping"));
|
||||
assert.ok(metadataEvents.includes("message_limit"));
|
||||
assert.ok(metadataEvents.includes("model_update"));
|
||||
assert.doesNotMatch(output, /never expose me|tool_states|transcript_hash/);
|
||||
assert.equal((output.match(/"finish_reason":"stop"/g) ?? []).length, 1);
|
||||
assert.equal(parsed.doneCount, 1);
|
||||
assert.deepEqual(completions, [{ assistantText: "hello", stopReason: "end_turn" }]);
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
it("projects known metadata through per-event allowlists", async () => {
|
||||
const events = [
|
||||
{ type: "message_start" },
|
||||
{
|
||||
type: "tool_approval",
|
||||
status: "required",
|
||||
prompt: "private prompt",
|
||||
conversation_id: "private-conversation",
|
||||
tool_states: [{ name: "private_tool" }],
|
||||
transcript_hash: "private-hash",
|
||||
},
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
const response = await createClaudeWebResponse(byteStream(frames(events)), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: {},
|
||||
onComplete() {},
|
||||
onFailure() {},
|
||||
});
|
||||
const raw = await response.text();
|
||||
assert.match(raw, /tool_approval/);
|
||||
assert.match(raw, /required/);
|
||||
assert.doesNotMatch(raw, /private prompt|private-conversation|private_tool|private-hash/);
|
||||
});
|
||||
|
||||
it("finishes and cancels upstream immediately after message_stop", async () => {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let cancelled = false;
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
value.enqueue(new TextEncoder().encode(frames(validEvents())));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const response = await createClaudeWebResponse(source, {
|
||||
model: "claude-sonnet-5",
|
||||
stream: true,
|
||||
responseMetadata: {},
|
||||
onComplete() {},
|
||||
onFailure() {},
|
||||
});
|
||||
const pending = response.text();
|
||||
const outcome = await Promise.race([
|
||||
pending.then(() => "completed" as const),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
|
||||
]);
|
||||
if (outcome === "timeout") {
|
||||
controller?.close();
|
||||
await pending;
|
||||
}
|
||||
assert.equal(outcome, "completed");
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
it("cancels upstream when the downstream consumer disconnects", async () => {
|
||||
let sourceController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let cancelled = false;
|
||||
const partial = frames([
|
||||
{ type: "message_start" },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "partial" } },
|
||||
]);
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
sourceController = value;
|
||||
value.enqueue(new TextEncoder().encode(partial));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const response = await createClaudeWebResponse(source, {
|
||||
model: "claude-sonnet-5",
|
||||
stream: true,
|
||||
responseMetadata: {},
|
||||
onComplete() {},
|
||||
onFailure() {},
|
||||
});
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
const cancelPromise = reader.cancel("client disconnected");
|
||||
const outcome = await Promise.race([
|
||||
cancelPromise.then(() => "cancelled" as const),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
|
||||
]);
|
||||
if (outcome === "timeout") {
|
||||
sourceController?.close();
|
||||
await cancelPromise.catch(() => {});
|
||||
}
|
||||
assert.equal(outcome, "cancelled");
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
it("cancels an oversized unterminated SSE frame before reading the whole source", async () => {
|
||||
const chunk = new TextEncoder().encode("x".repeat(64 * 1024));
|
||||
const availableChunks = 40;
|
||||
let reads = 0;
|
||||
let cancelled = false;
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= availableChunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const response = await createClaudeWebResponse(source, {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: {},
|
||||
onComplete() {},
|
||||
onFailure() {},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal(cancelled, true);
|
||||
assert.ok(reads < availableChunks);
|
||||
});
|
||||
|
||||
it("builds buffered output from the same semantic events", async () => {
|
||||
const completions: Array<{ assistantText: string; stopReason: string }> = [];
|
||||
let failures = 0;
|
||||
const response = await createClaudeWebResponse(byteStream(frames(validEvents())), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: { assistant_message_uuid: "assistant-test" },
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("Content-Type") ?? "", /application\/json/);
|
||||
const body = (await response.json()) as {
|
||||
choices: Array<{
|
||||
message: { content: string; reasoning_content?: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
claude_web: { assistant_message_uuid: string; events: unknown[] };
|
||||
};
|
||||
assert.equal(body.choices[0].message.content, "hello");
|
||||
assert.equal(body.choices[0].message.reasoning_content, "deep summary");
|
||||
assert.equal(body.choices[0].finish_reason, "stop");
|
||||
assert.equal(body.claude_web.assistant_message_uuid, "assistant-test");
|
||||
assert.equal(body.claude_web.events.length, 2);
|
||||
assert.deepEqual(completions, [{ assistantText: "hello", stopReason: "end_turn" }]);
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
it("fails closed for malformed, unknown, unordered, upstream error, and premature EOF", async () => {
|
||||
const badStreams = [
|
||||
'data: {"type":\n\n',
|
||||
frames([{ type: "message_start" }, { type: "future_event" }]),
|
||||
frames([
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "too early" },
|
||||
},
|
||||
]),
|
||||
frames([
|
||||
{ type: "message_start" },
|
||||
{
|
||||
type: "error",
|
||||
error: { message: "secret at C:\\Users\\private\\source.ts:10" },
|
||||
},
|
||||
]),
|
||||
frames([{ type: "message_start" }]),
|
||||
frames([{ type: "message_start" }]) + "data: [DONE]\n\n",
|
||||
];
|
||||
|
||||
for (const badStream of badStreams) {
|
||||
const completions: unknown[] = [];
|
||||
let failures = 0;
|
||||
const response = await createClaudeWebResponse(byteStream(badStream), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: true,
|
||||
responseMetadata: {},
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
const output = await response.text();
|
||||
|
||||
assert.match(output, /Claude Web stream protocol error/);
|
||||
assert.doesNotMatch(output, /C:\\\\Users|private|source\.ts/);
|
||||
assert.equal((output.match(/"finish_reason"/g) ?? []).length, 0);
|
||||
assert.deepEqual(completions, []);
|
||||
assert.equal(failures, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
410
tests/unit/claude-web-transport.test.ts
Normal file
410
tests/unit/claude-web-transport.test.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
|
||||
import { ClaudeWebExecutor } from "../../open-sse/executors/claude-web.ts";
|
||||
import {
|
||||
__resetClaudeWebBrowserTemplatesForTesting,
|
||||
type ClaudeWebTransportRequest,
|
||||
type ClaudeWebTransportResult,
|
||||
} from "../../open-sse/executors/claude-web/browserTransport.ts";
|
||||
import { transformToClaude } from "../../open-sse/executors/claude-web/payload.ts";
|
||||
import { __resetClaudeWebSessionForTesting } from "../../open-sse/executors/claude-web/session.ts";
|
||||
import {
|
||||
isClaudeWebChallenge,
|
||||
sendClaudeWebDirect,
|
||||
} from "../../open-sse/executors/claude-web/transport.ts";
|
||||
|
||||
const originalBrowserFlag = process.env.WEB_COOKIE_USE_BROWSER;
|
||||
const originalPoolFlag = process.env.OMNIROUTE_BROWSER_POOL;
|
||||
|
||||
function textStream(text: string): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validClaudeSse(answer = "answer"): ReadableStream<Uint8Array> {
|
||||
const events = [
|
||||
{ type: "message_start", message: { model: "claude-sonnet-5" } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: answer } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
return textStream(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""));
|
||||
}
|
||||
|
||||
function transportRequest(): ClaudeWebTransportRequest {
|
||||
const payload = transformToClaude(
|
||||
{ messages: [{ role: "user", content: "hello" }] },
|
||||
"claude-sonnet-5"
|
||||
);
|
||||
const organizationId = "organization-test";
|
||||
const conversationId = "00000000-0000-4000-8000-000000000010";
|
||||
return {
|
||||
scopeKey: "scope-digest",
|
||||
organizationId,
|
||||
conversationId,
|
||||
endpointSuffix: "completion",
|
||||
pageUrl: `https://claude.ai/chat/${conversationId}`,
|
||||
url: `https://claude.ai/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`,
|
||||
cookieString: "sessionKey=session-secret; cf_clearance=existing-browser-bound-value",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload,
|
||||
locale: payload.locale,
|
||||
timezone: payload.timezone,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
function result(
|
||||
status: number,
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
headers: Headers = new Headers()
|
||||
): ClaudeWebTransportResult {
|
||||
return { status, headers, body };
|
||||
}
|
||||
|
||||
function credentials(connectionId = "connection-a") {
|
||||
return {
|
||||
cookie: "sessionKey=session-secret; cf_clearance=existing-browser-bound-value",
|
||||
orgId: "organization-test",
|
||||
connectionId,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.WEB_COOKIE_USE_BROWSER;
|
||||
delete process.env.OMNIROUTE_BROWSER_POOL;
|
||||
__resetClaudeWebSessionForTesting();
|
||||
__resetClaudeWebBrowserTemplatesForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("WEB_COOKIE_USE_BROWSER", originalBrowserFlag);
|
||||
restoreEnv("OMNIROUTE_BROWSER_POOL", originalPoolFlag);
|
||||
__resetClaudeWebSessionForTesting();
|
||||
__resetClaudeWebBrowserTemplatesForTesting();
|
||||
});
|
||||
|
||||
describe("Claude Web direct transport", () => {
|
||||
it("sends only the prepared endpoint, headers, cookie, and payload", async () => {
|
||||
const request = transportRequest();
|
||||
let capturedUrl = "";
|
||||
let capturedOptions: Record<string, unknown> = {};
|
||||
|
||||
const response = await sendClaudeWebDirect(request, {
|
||||
async tlsFetch(url, options) {
|
||||
capturedUrl = url;
|
||||
capturedOptions = options as unknown as Record<string, unknown>;
|
||||
return {
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
text: null,
|
||||
body: validClaudeSse(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(capturedUrl, request.url);
|
||||
assert.equal(capturedOptions.method, "POST");
|
||||
assert.equal(capturedOptions.stream, true);
|
||||
assert.deepEqual(JSON.parse(String(capturedOptions.body)), request.payload);
|
||||
assert.equal((capturedOptions.headers as Record<string, string>).Cookie, request.cookieString);
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(response.body);
|
||||
});
|
||||
|
||||
it("classifies only known 403 challenge evidence", () => {
|
||||
assert.equal(
|
||||
isClaudeWebChallenge({
|
||||
...result(403, null, new Headers({ "cf-mitigated": "challenge" })),
|
||||
bodyText: "opaque",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeWebChallenge({
|
||||
...result(403, null),
|
||||
bodyText: "<title>Just a moment...</title>",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeWebChallenge({ ...result(403, null), bodyText: '{"error":"forbidden"}' }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeWebChallenge({
|
||||
...result(429, null, new Headers({ "cf-mitigated": "challenge" })),
|
||||
bodyText: "<title>Just a moment...</title>",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Web executor transport orchestration", () => {
|
||||
it("returns buffered output and generated state from an exact direct request", async () => {
|
||||
const requests: ClaudeWebTransportRequest[] = [];
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect(request) {
|
||||
requests.push(request);
|
||||
return result(
|
||||
200,
|
||||
validClaudeSse("direct answer"),
|
||||
new Headers({ "Content-Type": "text/event-stream" })
|
||||
);
|
||||
},
|
||||
async sendBrowser() {
|
||||
throw new Error("browser should not be called");
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.match(
|
||||
requests[0].url,
|
||||
/^https:\/\/claude\.ai\/api\/organizations\/organization-test\/chat_conversations\/[a-f0-9-]+\/completion$/
|
||||
);
|
||||
assert.ok(requests[0].payload.create_conversation_params);
|
||||
const responseBody = (await execution.response.json()) as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
claude_web: { conversation_id: string; assistant_message_uuid: string };
|
||||
};
|
||||
assert.equal(responseBody.choices[0].message.content, "direct answer");
|
||||
assert.equal(responseBody.claude_web.conversation_id, requests[0].conversationId);
|
||||
assert.match(
|
||||
execution.response.headers.get("X-OmniRoute-Claude-Web-Assistant-Message-Uuid") ?? "",
|
||||
/^[a-f0-9-]+$/
|
||||
);
|
||||
});
|
||||
|
||||
it("returns only a redacted request projection to the shared request logger", async () => {
|
||||
let sentRequest: ClaudeWebTransportRequest | undefined;
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect(request) {
|
||||
sentRequest = request;
|
||||
return result(200, validClaudeSse());
|
||||
},
|
||||
async sendBrowser() {
|
||||
throw new Error("browser should not be called");
|
||||
},
|
||||
});
|
||||
const execution = await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "PRIVATE_PROMPT" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "private_tool", parameters: { type: "object" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
...credentials(),
|
||||
deviceId: "private-device-id",
|
||||
},
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(sentRequest?.payload.prompt, "PRIVATE_PROMPT");
|
||||
const logged = JSON.stringify({
|
||||
url: execution.url,
|
||||
headers: execution.headers,
|
||||
body: execution.transformedBody,
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
logged,
|
||||
/PRIVATE_PROMPT|private_tool|private-device-id|organization-test|[0-9a-f]{8}-[0-9a-f-]{27}/i
|
||||
);
|
||||
assert.match(String(execution.url), /<organization>.*<conversation>/);
|
||||
});
|
||||
|
||||
it("does not expose transport exception details in logs or error responses", async () => {
|
||||
const messages: string[] = [];
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect() {
|
||||
throw new Error(
|
||||
"request failed for organization-test and sessionKey=session-secret at https://claude.ai/private"
|
||||
);
|
||||
},
|
||||
async sendBrowser() {
|
||||
throw new Error("browser should not be called");
|
||||
},
|
||||
});
|
||||
const execution = await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "PRIVATE_PROMPT" }] },
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
log: {
|
||||
error(_tag, message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const exposed = `${await execution.response.text()} ${messages.join(" ")}`;
|
||||
assert.equal(execution.response.status, 502);
|
||||
assert.doesNotMatch(
|
||||
exposed,
|
||||
/organization-test|session-secret|PRIVATE_PROMPT|claude\.ai\/private/
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from a direct challenge to the same scoped browser request when enabled", async () => {
|
||||
process.env.OMNIROUTE_BROWSER_POOL = "on";
|
||||
let directRequest: ClaudeWebTransportRequest | undefined;
|
||||
let browserRequest: ClaudeWebTransportRequest | undefined;
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect(request) {
|
||||
directRequest = request;
|
||||
return {
|
||||
...result(403, null, new Headers({ "cf-mitigated": "challenge" })),
|
||||
bodyText: "<title>Just a moment...</title>",
|
||||
};
|
||||
},
|
||||
async sendBrowser(request) {
|
||||
browserRequest = request;
|
||||
return result(200, validClaudeSse("browser answer"));
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await executor.execute({
|
||||
model: "claude-opus-4-8",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.ok(directRequest);
|
||||
assert.strictEqual(browserRequest, directRequest);
|
||||
assert.equal(execution.response.status, 200);
|
||||
assert.equal(
|
||||
((await execution.response.json()) as { choices: Array<{ message: { content: string } }> })
|
||||
.choices[0].message.content,
|
||||
"browser answer"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a sanitized challenge without invoking browser fallback when disabled", async () => {
|
||||
let browserCalls = 0;
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect() {
|
||||
return {
|
||||
...result(403, null, new Headers({ "cf-mitigated": "challenge" })),
|
||||
bodyText: "<title>Just a moment...</title> at C:\\Users\\private\\claude-cookie.txt",
|
||||
};
|
||||
},
|
||||
async sendBrowser() {
|
||||
browserCalls += 1;
|
||||
return result(200, validClaudeSse());
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: true,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
const body = await execution.response.text();
|
||||
|
||||
assert.equal(execution.response.status, 403);
|
||||
assert.equal(browserCalls, 0);
|
||||
assert.match(body, /cloudflare_challenge/);
|
||||
assert.doesNotMatch(body, /C:\\\\Users|private|cookie\.txt/);
|
||||
});
|
||||
|
||||
it("invalidates reusable continuation state after a 401", async () => {
|
||||
const conversationIds: string[] = [];
|
||||
let call = 0;
|
||||
const executor = new ClaudeWebExecutor({
|
||||
async sendDirect(request) {
|
||||
call += 1;
|
||||
conversationIds.push(request.conversationId);
|
||||
if (call === 2) return { ...result(401, null), bodyText: "expired" };
|
||||
return result(200, validClaudeSse(call === 1 ? "first answer" : "replacement answer"));
|
||||
},
|
||||
async sendBrowser() {
|
||||
throw new Error("browser should not be called");
|
||||
},
|
||||
});
|
||||
|
||||
const firstBody = { messages: [{ role: "user", content: "first question" }] };
|
||||
const followUpBody = {
|
||||
messages: [
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "assistant", content: "first answer" },
|
||||
{ role: "user", content: "second question" },
|
||||
],
|
||||
};
|
||||
await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: firstBody,
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
const unauthorized = await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: followUpBody,
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
await executor.execute({
|
||||
model: "claude-sonnet-5",
|
||||
body: followUpBody,
|
||||
stream: false,
|
||||
credentials: credentials(),
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.equal(unauthorized.response.status, 401);
|
||||
assert.equal(conversationIds[1], conversationIds[0]);
|
||||
assert.notEqual(conversationIds[2], conversationIds[0]);
|
||||
});
|
||||
|
||||
it("has no execution-time dependency on the standalone Turnstile solver", () => {
|
||||
const executorSource = readFileSync(
|
||||
new URL("../../open-sse/executors/claude-web.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const indexSource = readFileSync(
|
||||
new URL("../../open-sse/executors/index.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.doesNotMatch(executorSource, /claudeTurnstileSolver|getCfClearanceToken|tryBackedChat/);
|
||||
assert.doesNotMatch(indexSource, /ClaudeWebWithAutoRefresh/);
|
||||
assert.match(indexSource, /"claude-web": new ClaudeWebExecutor\(\)/);
|
||||
assert.match(indexSource, /"cw-web": new ClaudeWebExecutor\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,8 @@ import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../open-sse/executors/v0-vercel-web.ts");
|
||||
const { ClaudeWebExecutor } = await import("../../open-sse/executors/claude-web.ts");
|
||||
const { __setTlsFetchOverrideForTesting } = await import(
|
||||
"../../open-sse/services/claudeTlsClient.ts"
|
||||
);
|
||||
const { __setTlsFetchOverrideForTesting } =
|
||||
await import("../../open-sse/services/claudeTlsClient.ts");
|
||||
|
||||
function sseUpstream(events: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -128,8 +127,8 @@ describe("#6662 repro — claude-web drops thinking_delta reasoning_content", ()
|
||||
body: { messages: [{ role: "user", content: "Solve 17*23" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
// cf_clearance present up front so normalizeClaudeSessionCookieWithAutoRefresh
|
||||
// takes the fast path and never attempts a real Turnstile solve in-test.
|
||||
// The direct transport forwards the supplied cookie as-is. This fixture
|
||||
// stays entirely local through the injected TLS response.
|
||||
apiKey: "sessionKey=fake-session; cf_clearance=fake-clearance",
|
||||
orgId: "org-test",
|
||||
conversationId: "conv-test",
|
||||
|
||||
Reference in New Issue
Block a user