diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 6c384fcc7a..599814d072 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -27,6 +27,11 @@ import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { tryBackedChat } from "../services/browserBackedChat.ts"; +import { + type ClaudeWebRequestPayload, + transformToClaude, + transformFromClaude, +} from "./claude-web/payload.ts"; // ─── Constants ────────────────────────────────────────────────────────────── const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; @@ -87,74 +92,6 @@ function readClaudeWebDeviceId(credentials: unknown): string | undefined { return undefined; } -// Default model when not specified -const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; - -// ─── Types ────────────────────────────────────────────────────────────────── -/** - * Extended credentials to include organization and conversation context - */ -interface ClaudeWebRequestPayload { - prompt: string; - model: string; - timezone: string; - personalized_styles: Array<{ - type: string; - key: string; - name: string; - nameKey: string; - prompt: string; - summary: string; - summaryKey: string; - isDefault: boolean; - }>; - locale: string; - tools: Array<{ - name?: string; - description?: string; - input_schema?: Record; - integration_name?: string; - is_mcp_app?: boolean; - type?: string; - }>; - turn_message_uuids: { - human_message_uuid: string; - assistant_message_uuid: string; - }; - attachments: unknown[]; - effort: string; - files: unknown[]; - sync_sources: unknown[]; - rendering_mode: string; - thinking_mode: string; - create_conversation_params: { - name: string; - model: string; - include_conversation_preferences: boolean; - paprika_mode: unknown; - compass_mode: unknown; - is_temporary: boolean; - enabled_imagine: boolean; - tool_search_mode: string; - }; -} - -/** - * Stream chunk from Claude Web API - */ -interface ClaudeWebStreamChunk { - type?: string; - index?: number; - completion?: string; - stop_reason?: string | null; - model?: string; - delta?: { - type?: string; - text?: string; - }; - [key: string]: unknown; -} - // ─── Helper Functions ─────────────────────────────────────────────────────── /** @@ -230,166 +167,6 @@ async function normalizeClaudeSessionCookieWithAutoRefresh( return normalized; } -/** - * Generate UUIDs for turn message tracking - */ -function generateMessageUUIDs() { - return { - human_message_uuid: randomUUID(), - assistant_message_uuid: randomUUID(), - }; -} - -/** - * Get default tool definitions for Claude Web API - */ -function getDefaultTools(): ClaudeWebRequestPayload["tools"] { - return [ - { - name: "show_widget", - description: "Display interactive widgets and visualizations", - input_schema: { - type: "object", - properties: { - widget_type: { - type: "string", - description: "Type of widget to display", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: true, - }, - { - name: "read_me", - description: "Read and reference documents", - input_schema: { - type: "object", - properties: { - file_path: { - type: "string", - description: "Path to the file to read", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: false, - }, - { - type: "web_search_v0", - name: "web_search", - }, - { - type: "artifacts_v0", - name: "artifacts", - }, - { - type: "repl_v0", - name: "repl", - }, - { type: "widget", name: "weather_fetch" }, - { type: "widget", name: "recipe_display_v0" }, - { type: "widget", name: "places_map_display_v0" }, - { type: "widget", name: "message_compose_v1" }, - { type: "widget", name: "ask_user_input_v0" }, - { type: "widget", name: "recommend_claude_apps" }, - { type: "widget", name: "places_search" }, - { type: "widget", name: "fetch_sports_data" }, - ]; -} - -/** - * Get default personalized style - */ -function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personalized_styles"] { - return [ - { - type: "default", - key: "Default", - name: "Normal", - nameKey: "normal_style_name", - prompt: "Normal\n", - summary: "Default responses from Claude", - summaryKey: "normal_style_summary", - isDefault: true, - }, - ]; -} - -/** - * Transform OpenAI format to Claude Web format - */ -function transformToClaude(body: Record, model: string): ClaudeWebRequestPayload { - const messages = Array.isArray(body.messages) ? body.messages : []; - - // Extract the last user message as the prompt - let prompt = ""; - for (const msg of messages) { - if (typeof msg === "object" && msg !== null) { - const message = msg as Record; - if (message.role === "user") { - prompt = String(message.content || ""); - } - } - } - - if (!prompt.trim()) { - throw new Error("No user message found in request"); - } - - return { - prompt, - model: model || DEFAULT_CLAUDE_MODEL, - timezone: "Asia/Jakarta", - personalized_styles: getDefaultPersonalizedStyle(), - locale: "en-US", - tools: getDefaultTools(), - turn_message_uuids: generateMessageUUIDs(), - attachments: [], - effort: "low", - files: [], - sync_sources: [], - rendering_mode: "messages", - thinking_mode: "off", - create_conversation_params: { - name: "", - model: model || DEFAULT_CLAUDE_MODEL, - include_conversation_preferences: true, - paprika_mode: null, - compass_mode: null, - is_temporary: false, - enabled_imagine: true, - tool_search_mode: "auto", - }, - }; -} - -/** - * Transform Claude Web response to OpenAI format - */ -function transformFromClaude( - claudeContent: string, - model: string, - stopReason?: string -): Record { - return { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { - content: claudeContent, - }, - finish_reason: stopReason === "end_turn" ? "stop" : null, - logprobs: null, - }, - ], - }; -} - /** * Verify session is still valid by checking if the organizations endpoint * returns a successful response. Claude's API does not have a /api/auth/session diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts new file mode 100644 index 0000000000..cc0dcc3787 --- /dev/null +++ b/open-sse/executors/claude-web/payload.ts @@ -0,0 +1,230 @@ +// Pure Claude-web payload construction (types + transforms + default tools/style). +// Extracted verbatim from claude-web.ts. No host state, no fetch/auth. +import { randomUUID } from "crypto"; + +// Default model when not specified +export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; + +export interface ClaudeWebRequestPayload { + prompt: string; + model: string; + timezone: string; + personalized_styles: Array<{ + type: string; + key: string; + name: string; + nameKey: string; + prompt: string; + summary: string; + summaryKey: string; + isDefault: boolean; + }>; + locale: string; + tools: Array<{ + name?: string; + description?: string; + input_schema?: Record; + integration_name?: string; + is_mcp_app?: boolean; + type?: string; + }>; + turn_message_uuids: { + human_message_uuid: string; + assistant_message_uuid: string; + }; + attachments: unknown[]; + effort: string; + files: unknown[]; + sync_sources: unknown[]; + rendering_mode: string; + thinking_mode: string; + create_conversation_params: { + name: string; + model: string; + include_conversation_preferences: boolean; + paprika_mode: unknown; + compass_mode: unknown; + is_temporary: boolean; + enabled_imagine: boolean; + tool_search_mode: string; + }; +} + +/** + * Stream chunk from Claude Web API + */ +export interface ClaudeWebStreamChunk { + type?: string; + index?: number; + completion?: string; + stop_reason?: string | null; + model?: string; + delta?: { + type?: string; + text?: string; + }; + [key: string]: unknown; +} + +/** + * Generate UUIDs for turn message tracking + */ +export function generateMessageUUIDs() { + return { + human_message_uuid: randomUUID(), + assistant_message_uuid: randomUUID(), + }; +} + +/** + * Get default tool definitions for Claude Web API + */ +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" }, + ]; +} + +/** + * Get default personalized style + */ +export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personalized_styles"] { + return [ + { + type: "default", + key: "Default", + name: "Normal", + nameKey: "normal_style_name", + prompt: "Normal\n", + summary: "Default responses from Claude", + summaryKey: "normal_style_summary", + isDefault: true, + }, + ]; +} + +/** + * Transform OpenAI format to Claude Web format + */ +export function transformToClaude( + body: Record, + model: string +): ClaudeWebRequestPayload { + const messages = Array.isArray(body.messages) ? body.messages : []; + + // Extract the last user message as the prompt + let prompt = ""; + for (const msg of messages) { + if (typeof msg === "object" && msg !== null) { + const message = msg as Record; + if (message.role === "user") { + prompt = String(message.content || ""); + } + } + } + + if (!prompt.trim()) { + throw new Error("No user message found in request"); + } + + return { + prompt, + model: model || DEFAULT_CLAUDE_MODEL, + timezone: "Asia/Jakarta", + personalized_styles: getDefaultPersonalizedStyle(), + locale: "en-US", + tools: getDefaultTools(), + turn_message_uuids: generateMessageUUIDs(), + attachments: [], + effort: "low", + files: [], + sync_sources: [], + rendering_mode: "messages", + thinking_mode: "off", + create_conversation_params: { + name: "", + model: model || DEFAULT_CLAUDE_MODEL, + include_conversation_preferences: true, + paprika_mode: null, + compass_mode: null, + is_temporary: false, + enabled_imagine: true, + tool_search_mode: "auto", + }, + }; +} + +/** + * Transform Claude Web response to OpenAI format + */ +export function transformFromClaude( + claudeContent: string, + model: string, + stopReason?: string +): Record { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + content: claudeContent, + }, + finish_reason: stopReason === "end_turn" ? "stop" : null, + logprobs: null, + }, + ], + }; +} diff --git a/tests/unit/claude-web-executor-split.test.ts b/tests/unit/claude-web-executor-split.test.ts new file mode 100644 index 0000000000..be510e80d4 --- /dev/null +++ b/tests/unit/claude-web-executor-split.test.ts @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +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 +// 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)); +const EXE = join(HERE, "../../open-sse/executors"); +const HOST = join(EXE, "claude-web.ts"); +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"]) { + assert.match(src, new RegExp(`export function ${sym}\\b`)); + } + assert.match(src, /export interface ClaudeWebRequestPayload\b/); + assert.doesNotMatch(src, /from "\.\.\/claude-web\.ts"/); +}); + +test("host imports the transforms back from the leaf", () => { + const host = readFileSync(HOST, "utf8"); + assert.match(host, /from "\.\/claude-web\/payload\.ts"/); +}); + +test("transformToClaude builds a Claude-web payload with model + tools", async () => { + const { transformToClaude } = await import("../../open-sse/executors/claude-web/payload.ts"); + const payload = transformToClaude( + { messages: [{ role: "user", content: "hi" }] }, + "claude-sonnet-4-6" + ); + assert.equal(typeof payload, "object"); + assert.ok(Array.isArray(payload.tools)); +});