diff --git a/free/README.md b/free/README.md index 2cc5236..9e323db 100644 --- a/free/README.md +++ b/free/README.md @@ -18,14 +18,16 @@ It covers **Claude, ChatGPT (Codex) and Kimi subscriptions** and **API keys** fo - Claude Code on a ChatGPT login or a Codex API key: ask for a ChatGPT model (for example `ANTHROPIC_MODEL=gpt-5.6-sol`) and the request is translated to OpenAI Responses and the answer streamed back as Anthropic events, with thinking, tool calls and images, following CPA's Claude-to-Codex translator. - Claude Code on a Gemini API key: ask for a Gemini model (for example `ANTHROPIC_MODEL=gemini-2.5-flash`) and the request goes out as Gemini `generateContent`, with thinking, tool calls and images, following CPA's Claude-to-Gemini translator. Unlike CPA, Google's default safety filters stay on; a blocked answer ends with a `refusal` stop reason. - OpenAI Chat clients (Cursor, Cline, the OpenAI SDKs and the like) on a Claude API key: ask `/v1/chat/completions` for a Claude model and the request goes out as Anthropic Messages, with reasoning effort, tool calls, images, files and prompt caching, and the answer comes back as Chat Completions, following CPA's OpenAI-to-Claude translator. Claude logins aren't offered to these clients, since without CPA's cloaking Anthropic accepts a subscription token from Claude Code only. -- Each client talks in its own format: Anthropic (`/v1/messages`), OpenAI Chat (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`) and Gemini (`/v1beta/models/{model}:generateContent`). A request goes to a provider that takes the same format, apart from the Claude translations above. +- OpenAI Responses clients on a Claude API key, the same way, following CPA's Responses-to-Claude translator: reasoning, function and custom tool calls, images and files, streamed as Responses events or returned whole. +- OpenAI Chat and Responses clients on any other provider too (a Gemini key, an OpenAI-compatible key, a Codex key or a ChatGPT login): the request is translated to Anthropic Messages and on to the provider's format, and the answer back the same way. +- Each client talks in its own format: Anthropic (`/v1/messages`), OpenAI Chat (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`) and Gemini (`/v1beta/models/{model}:generateContent`). A request goes to a provider that takes the same format, apart from the translations above. - Model lists as in CPA: `GET /v1/models` (OpenAI's shape, or Anthropic's for Claude Code and the Anthropic SDKs), `GET /v1beta/models` and `GET /v1beta/models/{model}` list every model across the logins and API keys, and so does the dashboard's **Available Models**. A login or key with no model list of its own lists CPA's catalog for its provider, and a ChatGPT login the catalog for its plan. The catalog is refreshed from CPA's model catalog every three hours, with a built-in copy as fallback; it only decides what is listed, never where a request goes. - Usage and logs as in CPA: each login and API key shows its success and failure counts and the last 200 minutes in 10-minute buckets, and with **Logging to file** on, the **Logs** page shows a line per API request (the last 2,000). Lines hold the time, status, latency, client IP, path, provider and model, never request bodies or keys. Records are sent to the Durable Object in batches a few seconds apart. - Everything is stored in one SQLite-backed Durable Object, which the free plan includes. Claude Code and Codex CLI request bodies are never parsed, so a Worker spends well under 1 ms of its 10 ms CPU budget even on a 600 KB Claude Code request (see the CPU test in `test/app.test.ts`). Requests that need a model rewritten or a body adapted are parsed once. Translated requests cost the most, about 4 to 6 ms for a 600 KB conversation; very long conversations translated can run past the free plan's 10 ms, which the Container install doesn't have. -Not included yet: the other client translations (OpenAI Responses and Gemini clients to other providers), Gemini and other OAuth logins, the Codex CLI's own model list (`/v1/models?client_version=`), and CPA's per-request detail logs. Deliberately left out: CPA's request cloaking and TLS fingerprint spoofing. Clients other than Claude Code may be refused by Anthropic when they use a subscription token. +Not included yet: Gemini clients on other providers, Gemini and other OAuth logins, the Codex CLI's own model list (`/v1/models?client_version=`), and CPA's per-request detail logs. Deliberately left out: CPA's request cloaking and TLS fingerprint spoofing. Clients other than Claude Code may be refused by Anthropic when they use a subscription token. ## Try it diff --git a/free/src/app.ts b/free/src/app.ts index 8d94150..d9586be 100644 --- a/free/src/app.ts +++ b/free/src/app.ts @@ -2,9 +2,10 @@ import { hashKey, REFRESH_MARGIN_SECONDS, type Backend, type ServedLogin } from import type { Fetcher } from "./claude-oauth.ts"; import { claudeToGemini, geminiClaudeMessage, GeminiClaudeStream } from "./claude-gemini.ts"; import { chatClaudeMessage, ChatClaudeStream, claudeToChat } from "./claude-openai.ts"; -import { claudeEventStream, claudeMessage, ClaudeStream, claudeToResponses } from "./claude-responses.ts"; +import { claudeEventStream, claudeMessage, ClaudeStream, claudeToResponses, type EventTranslator, type Json } from "./claude-responses.ts"; import { chatCompletion, ChatStream, chatToClaude } from "./openai-claude.ts"; import { kimiUpstreamModel } from "./kimi-oauth.ts"; +import { responsesResponse, ResponsesStream, responsesToClaude } from "./responses-claude.ts"; import { MANAGEMENT_PREFIX } from "./management.ts"; import { availableModels, CatalogCache, claudeModelList, geminiModel, geminiModelList, openaiModelList } from "./models.ts"; import { loginPage, messagePage } from "./pages.ts"; @@ -222,13 +223,10 @@ async function viaCodexLogin(request: Request, url: URL, config: AppConfig, text return collected(upstream); } -// Formats a provider takes with translation: Claude clients can reach Responses, Chat and -// Gemini upstreams, and OpenAI Chat clients can reach Claude API keys. +// Formats a provider takes with translation: Claude, OpenAI Chat and Responses clients reach +// every provider through Anthropic Messages. function servable(format: Format, kind: Candidate["credential"]["kind"]): boolean { - const native = NATIVE_FORMAT[kind]; - if (takesNatively(kind, format)) return true; - if (format === "claude") return native === "openai-response" || native === "openai" || native === "gemini"; - return format === "openai" && native === "claude"; + return takesNatively(kind, format) || format !== "gemini"; } function parsedObject(text: string): Record | null { @@ -256,6 +254,11 @@ async function viaTranslation(request: Request, url: URL, route: Route, config: const claude = parsedObject(text); if (claude === null) return formatError("claude", 400, "invalid_request_error", "The request body must be a JSON object."); if (url.pathname.endsWith("/count_tokens")) return Response.json({ input_tokens: Math.ceil(text.length / 4) }, { headers: { "cache-control": "no-store" } }); + return translatedClaude(request, route, config, candidate, claude); +} + +async function translatedClaude(request: Request, route: Route, config: AppConfig, candidate: Candidate, claude: Json): Promise { + const url = new URL(request.url); const requested = typeof claude.model === "string" ? claude.model : candidate.model; const native = NATIVE_FORMAT[candidate.credential.kind]; const translated = native === "openai" ? { ...claudeToChat(claude, candidate.model), toolNames: new Map() } : native === "gemini" ? claudeToGemini(claude, candidate.model) : await claudeToResponses(claude, candidate.model); @@ -301,25 +304,33 @@ async function viaTranslation(request: Request, url: URL, route: Route, config: return Response.json(claudeMessage((await final.json()) as Record, requested, translated.toolNames), { headers: { "cache-control": "no-store" } }); } -// An OpenAI Chat request served by a Claude API key: the body is translated to Anthropic -// Messages, and the answer translated back, streamed or whole. -async function viaClaudeKey(request: Request, config: AppConfig, candidate: Candidate, text: string): Promise { - const chat = parsedObject(text); - if (chat === null) return formatError("openai", 400, "invalid_request_error", "The request body must be a JSON object."); - const requested = typeof chat.model === "string" ? chat.model : candidate.model; - const translated = chatToClaude(chat, candidate.model); - // The client's own headers describe an OpenAI request, so none of them go upstream. - const bare = new Request(request.url, { method: "POST" }); - const target = new URL("/v1/messages", request.url); - const upstream = await config.upstream(providerRequest(bare, target, { method: "POST", format: "claude" }, candidate.credential, candidate.model, JSON.stringify(translated.body))).catch(() => null); - if (upstream === null) return formatError("openai", 502, "api_error", "The upstream couldn't be reached."); - if (!upstream.ok) return formatError("openai", upstream.status, upstream.status === 429 ? "rate_limit_error" : "api_error", await upstreamMessage(upstream)); +// An OpenAI Chat or Responses request: the body is translated to Anthropic Messages and sent +// to a Claude API key, or translated on to the candidate's own format; the answer comes back +// the same way, streamed or whole. +async function viaClaudeMessages(request: Request, url: URL, route: Route & { format: Format }, config: AppConfig, candidate: Candidate, text: string): Promise { + const client = parsedObject(text); + if (client === null) return formatError(route.format, 400, "invalid_request_error", "The request body must be a JSON object."); + const requested = typeof client.model === "string" ? client.model : candidate.model; + const translated = route.format === "openai-response" ? responsesToClaude(client, candidate.model) : { ...chatToClaude(client, candidate.model), customTools: new Set() }; + let upstream: Response | null; + if (NATIVE_FORMAT[candidate.credential.kind] === "claude") { + // The client's own headers describe an OpenAI request, so none of them go upstream. + const bare = new Request(request.url, { method: "POST" }); + const target = new URL("/v1/messages", request.url); + upstream = await config.upstream(providerRequest(bare, target, { method: "POST", format: "claude" }, candidate.credential, candidate.model, JSON.stringify(translated.body))).catch(() => null); + } else { + upstream = await translatedClaude(new Request(new URL("/v1/messages", url), { method: "POST" }), { method: "POST", format: "claude" }, config, candidate, translated.body); + } + if (upstream === null) return formatError(route.format, 502, "api_error", "The upstream couldn't be reached."); + if (!upstream.ok) return formatError(route.format, upstream.status, upstream.status === 429 ? "rate_limit_error" : "api_error", await upstreamMessage(upstream)); if (translated.stream && upstream.body !== null) { - return new Response(claudeEventStream(upstream.body, new ChatStream(requested, translated.toolNames)), { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" } }); + const translator: EventTranslator = route.format === "openai-response" ? new ResponsesStream(requested, translated.toolNames, translated.customTools) : new ChatStream(requested, translated.toolNames); + return new Response(claudeEventStream(upstream.body, translator), { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" } }); } const final = parsedObject(await upstream.text()); - if (final === null) return formatError("openai", 502, "api_error", "The upstream sent a response that isn't JSON."); - return Response.json(chatCompletion(final, requested, translated.toolNames), { headers: { "cache-control": "no-store" } }); + if (final === null) return formatError(route.format, 502, "api_error", "The upstream sent a response that isn't JSON."); + const answer = route.format === "openai-response" ? responsesResponse(final, requested, translated.toolNames, translated.customTools) : chatCompletion(final, requested, translated.toolNames); + return Response.json(answer, { headers: { "cache-control": "no-store" } }); } // Statuses after which another credential may well succeed. @@ -415,8 +426,8 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form if (route.format === "claude" && table.claudeOAuth && /^claude-/i.test(model)) usable.push(loginCandidate("claude", model)); // ChatGPT logins take Codex's own models, and any Responses model nothing else serves. if (route.format === "openai-response" && table.codexOAuth && (inDefaultFamily("codex", model) || usable.length === 0)) usable.push(loginCandidate("codex", model)); - // Claude Code asking for a ChatGPT model by name, e.g. ANTHROPIC_MODEL=gpt-5.6-sol. - if (route.format === "claude" && table.codexOAuth && inDefaultFamily("codex", model)) usable.push(loginCandidate("codex", model)); + // Claude Code or a Chat client asking for a ChatGPT model by name, e.g. ANTHROPIC_MODEL=gpt-5.6-sol. + if ((route.format === "claude" || route.format === "openai") && table.codexOAuth && inDefaultFamily("codex", model)) usable.push(loginCandidate("codex", model)); // Vertex service accounts take Gemini models, from Gemini clients and translated Claude ones. if ((route.format === "gemini" || route.format === "claude") && table.vertexOAuth === true && inDefaultFamily("vertex", model)) usable.push(loginCandidate("vertex", model)); // Kimi logins take Kimi's models in Anthropic and both OpenAI formats, untranslated. @@ -429,8 +440,8 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form } // Without CPA's cloaking a Claude login serves Claude Code only, so it isn't offered here. const reason = - route.format === "openai" && table.claudeOAuth && /^claude-/i.test(model) - ? `Claude logins serve Claude Code only. To use ${model} from an OpenAI client, add a Claude API key under AI Providers in the dashboard.` + route.format !== "claude" && table.claudeOAuth && /^claude-/i.test(model) + ? `Claude logins serve Claude Code only. To use ${model} from another client, add a Claude API key under AI Providers in the dashboard.` : found.length > 0 ? `Model ${model} is served by a ${found[0]?.credential.kind} provider, which doesn't take this request format yet.` : `No provider is configured for model ${model}. Add one under AI Providers in the dashboard.`; @@ -464,7 +475,7 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form const translated = !takesNatively(candidate.credential.kind, route.format); if (translated || candidate.credential.id === CLAUDE_LOGIN_ID || candidate.credential.id === CODEX_LOGIN_ID) { let response: Response; - if (translated && route.format === "openai") response = await viaClaudeKey(request, config, candidate, text); + if (translated && route.format !== "claude") response = await viaClaudeMessages(request, url, route, config, candidate, text); else if (translated) response = await viaTranslation(request, url, route, config, candidate, text); else if (candidate.credential.id === CLAUDE_LOGIN_ID) response = await viaClaudeLogin(request, url, config, body); else response = await viaCodexLogin(request, url, config, body); diff --git a/free/src/openai-claude.ts b/free/src/openai-claude.ts index bda58a0..e5e2c22 100644 --- a/free/src/openai-claude.ts +++ b/free/src/openai-claude.ts @@ -3,8 +3,8 @@ import { isObject, text, type EventTranslator, type Json } from "./claude-respon // Translation between OpenAI Chat Completions clients and Anthropic Messages, for Claude API // keys, ported from CPA's translator/claude/openai/chat-completions. -const DEFAULT_MAX_TOKENS = 32000; -const FABLE_MAX_TOKENS = 64000; +export const DEFAULT_MAX_TOKENS = 32000; +export const FABLE_MAX_TOKENS = 64000; // Claude 4.5 and older take a thinking budget; newer models think adaptively at an effort. const BUDGET_THINKING = /^claude-(?:3|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$|(?:opus|sonnet|haiku)-4-[0-5]-)/i; const EFFORT_BUDGET: Record = { minimal: 512, low: 1024, medium: 8192, high: 24576, xhigh: 32768, max: 128000 }; @@ -15,15 +15,15 @@ export function claudeName(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_"; } -function toolId(id: string): string { +export function toolId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, "_") || `toolu_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`; } -function withCache(block: Json, from: Json): Json { +export function withCache(block: Json, from: Json): Json { return isObject(from.cache_control) ? { ...block, cache_control: from.cache_control } : block; } -function dataUrl(url: string): { mediaType: string; data: string } | null { +export function dataUrl(url: string): { mediaType: string; data: string } | null { if (!url.startsWith("data:")) return null; const comma = url.indexOf(","); if (comma === -1) return null; @@ -64,7 +64,7 @@ function toolResultContent(content: unknown): string | Json[] { } // Structured output has no Claude field, so CPA asks for it in the system prompt. -function formatInstruction(format: unknown): string { +export function formatInstruction(format: unknown): string { if (!isObject(format)) return ""; const plain = "You must format your entire response as a valid JSON object. Do not include any explanations, markdown code blocks (such as ```json), or any text outside of the JSON object."; const type = text(format.type).trim().toLowerCase(); @@ -83,7 +83,7 @@ function formatInstruction(format: unknown): string { } // Claude wants an object schema without a root-level union: object branches are merged in. -function inputSchema(raw: unknown): Json { +export function inputSchema(raw: unknown): Json { if (!isObject(raw)) return { type: "object", properties: {} }; const { anyOf, oneOf, allOf, ...schema } = raw; const properties: Json = isObject(schema.properties) ? { ...schema.properties } : {}; @@ -102,7 +102,7 @@ function inputSchema(raw: unknown): Json { return { ...schema, type: "object", properties }; } -function thinking(effort: string, model: string, maxTokens: number): Json | null { +export function thinking(effort: string, model: string, maxTokens: number): Json | null { if (effort === "") return null; if (effort === "none") return { thinking: { type: "disabled" } }; if (!BUDGET_THINKING.test(model)) { @@ -281,7 +281,7 @@ function finishReason(stop: string): string { return "stop"; } -interface Usage { +export interface Usage { input: number; output: number; cacheWrite: number; @@ -289,7 +289,7 @@ interface Usage { thinking: number | null; } -function mergeUsage(usage: Usage, raw: unknown): boolean { +export function mergeUsage(usage: Usage, raw: unknown): boolean { if (!isObject(raw)) return false; const number = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined); usage.input = number(raw.input_tokens) ?? usage.input; diff --git a/free/src/responses-claude.ts b/free/src/responses-claude.ts new file mode 100644 index 0000000..da21354 --- /dev/null +++ b/free/src/responses-claude.ts @@ -0,0 +1,464 @@ +import { isObject, sse, text, type EventTranslator, type Json } from "./claude-responses.ts"; +import { claudeName, dataUrl, DEFAULT_MAX_TOKENS, FABLE_MAX_TOKENS, formatInstruction, inputSchema, mergeUsage, thinking, toolId, type ClaudeRequest, type Usage } from "./openai-claude.ts"; + +// Translation between OpenAI Responses clients and Anthropic Messages, ported from CPA's +// translator/claude/openai/responses. Other providers are reached by translating the Claude +// request on again. + +// OpenAI's encrypted reasoning (a Fernet token) can't be replayed to Claude. +const OPENAI_REASONING = /^gAAAA/; +const INTERRUPTED = "Tool call was interrupted before any output was recorded."; +// A custom (free-form) tool takes one string, which Claude sees as a one-field object. +const CUSTOM_SCHEMA: Json = { type: "object", properties: { input: { type: "string" } }, required: ["input"] }; + +export interface ResponsesRequest extends ClaudeRequest { + // Claude names of the client's custom tools, whose calls go back as custom_tool_call. + readonly customTools: Set; +} + +function inputPart(part: Json): Json | null { + switch (part.type) { + case "input_text": + case "output_text": + case "text": + return text(part.text) === "" ? null : { type: "text", text: text(part.text) }; + case "refusal": + return text(part.refusal) === "" ? null : { type: "text", text: text(part.refusal) }; + case "input_image": { + const url = isObject(part.image_url) ? text(part.image_url.url) : text(part.image_url); + if (url === "") return null; + const inline = dataUrl(url); + return inline === null ? { type: "image", source: { type: "url", url } } : { type: "image", source: { type: "base64", media_type: inline.mediaType, data: inline.data } }; + } + case "input_file": { + const data = text(part.file_data); + const inline = dataUrl(data); + if (inline !== null) return { type: "document", source: { type: "base64", media_type: inline.mediaType, data: inline.data } }; + if (data !== "" && /\.pdf$/i.test(text(part.filename))) return { type: "document", source: { type: "base64", media_type: "application/pdf", data } }; + const url = text(part.file_url); + return url === "" ? null : { type: "document", source: { type: "url", url } }; + } + default: + return null; + } +} + +function parts(content: unknown): Json[] { + if (typeof content === "string") return content === "" ? [] : [{ type: "text", text: content }]; + if (!Array.isArray(content)) return []; + return content.flatMap((part) => { + if (typeof part === "string") return part === "" ? [] : [{ type: "text", text: part }]; + const block = isObject(part) ? inputPart(part) : null; + return block === null ? [] : [block]; + }); +} + +function parsedArguments(raw: unknown): Json { + if (isObject(raw)) return raw; + try { + const value: unknown = JSON.parse(text(raw) || "{}"); + return isObject(value) ? value : {}; + } catch { + return {}; + } +} + +// Every tool_use is answered at the start of the next user turn, as Claude requires: missing +// results are filled in as errors, and results without a call become plain text. +function orphaned(block: Json, calls: readonly string[]): Json { + if (block.type !== "tool_result" || calls.includes(text(block.tool_use_id))) return block; + return { type: "text", text: `Tool result ${text(block.tool_use_id)}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}` }; +} + +function pairToolResults(messages: Json[]): Json[] { + const paired: Json[] = []; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index] as Json & { content: Json[] }; + if (message.role === "user") { + paired.push({ ...message, content: message.content.map((block) => orphaned(block, [])) }); + continue; + } + paired.push(message); + const ids = message.content.filter((block) => block.type === "tool_use").map((block) => text(block.id)); + if (ids.length === 0) continue; + const next = messages[index + 1] as (Json & { content: Json[] }) | undefined; + const answers = next?.role === "user" ? next.content : []; + const results = ids.map((id) => answers.find((block) => block.type === "tool_result" && block.tool_use_id === id) ?? { type: "tool_result", tool_use_id: id, content: INTERRUPTED, is_error: true }); + const rest = answers.filter((block) => !(block.type === "tool_result" && ids.includes(text(block.tool_use_id)))).map((block) => orphaned(block, ids)); + paired.push({ role: "user", content: [...results, ...rest] }); + if (next?.role === "user") index += 1; + } + return paired; +} + +export function responsesToClaude(request: Json, model: string): ResponsesRequest { + const toolNames = new Map(); + const customTools = new Set(); + const nameOf = (name: string) => { + const short = claudeName(name); + toolNames.set(short, name); + return short; + }; + const system: Json[] = []; + const turns: { role: string; content: Json[]; toolUses: Json[] }[] = []; + const append = (role: string, content: Json[], toolUses: Json[] = []) => { + if (content.length === 0 && toolUses.length === 0) return; + const last = turns.at(-1); + if (last !== undefined && last.role === role && (role === "user" || last.toolUses.length === 0 || content.length === 0)) { + last.content.push(...content); + last.toolUses.push(...toolUses); + } else turns.push({ role, content, toolUses }); + }; + if (text(request.instructions).trim() !== "") system.push({ type: "text", text: text(request.instructions) }); + + const items = typeof request.input === "string" ? [{ role: "user", content: request.input }] : Array.isArray(request.input) ? request.input.filter(isObject) : []; + // A repeated tool output keeps its first place and its last content, as in CPA. + const lastOutput = new Map(); + for (const item of items) if ((item.type === "function_call_output" || item.type === "custom_tool_call_output") && text(item.call_id) !== "") lastOutput.set(text(item.call_id), item); + const emitted = new Set(); + + for (const item of items) { + const type = text(item.type) || "message"; + switch (type) { + case "message": { + const role = text(item.role); + if (role === "system" || role === "developer") { + for (const part of parts(item.content)) if (text(part.text).trim() !== "") system.push(part); + } else if (role === "user" || role === "assistant") append(role, parts(item.content)); + break; + } + case "reasoning": { + // Only a Claude signature from an earlier turn can be replayed; without it the thought is dropped. + const signature = text(item.encrypted_content); + if (signature === "" || OPENAI_REASONING.test(signature)) break; + const summary = (Array.isArray(item.summary) ? item.summary.filter(isObject) : []).map((part) => text(part.text)).join("\n\n"); + append("assistant", [summary === "" ? { type: "redacted_thinking", data: signature } : { type: "thinking", thinking: summary, signature }]); + break; + } + case "function_call": + append("assistant", [], [{ type: "tool_use", id: toolId(text(item.call_id) || text(item.id)), name: nameOf(text(item.name)), input: parsedArguments(item.arguments) }]); + break; + case "custom_tool_call": + append("assistant", [], [{ type: "tool_use", id: toolId(text(item.call_id) || text(item.id)), name: nameOf(text(item.name)), input: { input: text(item.input) } }]); + break; + case "function_call_output": + case "custom_tool_call_output": { + const rawId = text(item.call_id); + if (rawId !== "") { + if (emitted.has(rawId)) break; + emitted.add(rawId); + } + const target = lastOutput.get(rawId) ?? item; + const output = typeof target.output === "string" ? target.output : parts(target.output); + const result: Json = { type: "tool_result", tool_use_id: toolId(rawId), content: output }; + if (target.status === "incomplete") result.is_error = true; + append("user", [result]); + break; + } + default: + // Built-in tool items (web search, file search, computer use) have no Claude equivalent. + break; + } + } + + const format = isObject(request.text) ? request.text.format : undefined; + const instruction = formatInstruction(format); + if (instruction !== "") system.push({ type: "text", text: instruction }); + let messages = pairToolResults(turns.map((turn) => ({ role: turn.role, content: [...turn.content, ...turn.toolUses] }))); + // A prefilled assistant turn can't end in a thought. + const last = messages.at(-1); + if (last?.role === "assistant") { + const content = [...(last.content as Json[])]; + while (content.length > 0 && (content.at(-1)?.type === "thinking" || content.at(-1)?.type === "redacted_thinking")) content.pop(); + messages = content.length === 0 ? messages.slice(0, -1) : [...messages.slice(0, -1), { ...last, content }]; + } + if (messages.length === 0) messages.push({ role: "user", content: [{ type: "text", text: "" }] }); + + const maxTokens = typeof request.max_output_tokens === "number" ? request.max_output_tokens : /fable/i.test(model) ? FABLE_MAX_TOKENS : DEFAULT_MAX_TOKENS; + const stream = request.stream === true; + const body: Json = { model, max_tokens: maxTokens, messages, stream }; + if (system.length > 0) body.system = system; + if (typeof request.top_p === "number") body.top_p = request.top_p; + const reasoning = isObject(request.reasoning) ? request.reasoning : {}; + Object.assign(body, thinking(text(reasoning.effort).trim().toLowerCase(), model, maxTokens)); + + const choice = request.tool_choice; + let allowed: Set | null = null; + let allowedMode = "auto"; + if (isObject(choice) && choice.type === "allowed_tools") { + allowed = new Set(); + for (const tool of Array.isArray(choice.tools) ? choice.tools.filter(isObject) : []) if (text(tool.name) !== "") allowed.add(text(tool.name)); + allowedMode = text(choice.mode).trim().toLowerCase() || "auto"; + } + const tools: Json[] = []; + for (const tool of Array.isArray(request.tools) ? request.tools.filter(isObject) : []) { + const name = text(tool.name); + if ((tool.type !== "function" && tool.type !== "custom") || name === "" || (allowed !== null && !allowed.has(name))) continue; + const declared: Json = { name: nameOf(name), description: text(tool.description) }; + if (tool.type === "custom") { + customTools.add(declared.name as string); + declared.input_schema = CUSTOM_SCHEMA; + } else { + declared.input_schema = inputSchema(tool.parameters); + if (typeof tool.strict === "boolean") declared.strict = tool.strict; + } + tools.push(declared); + } + if (tools.length > 0) body.tools = tools; + + if (allowed !== null) body.tool_choice = tools.length === 0 ? { type: "none" } : { type: allowedMode === "required" ? "any" : "auto" }; + else { + const type = isObject(choice) ? text(choice.type) : text(choice); + if (type === "none" || type === "auto") body.tool_choice = { type }; + else if (type === "required") body.tool_choice = { type: "any" }; + else if ((type === "function" || type === "custom") && isObject(choice) && text(choice.name) !== "") body.tool_choice = { type: "tool", name: nameOf(text(choice.name)) }; + } + if (request.parallel_tool_calls === false && tools.length > 0) { + const current = isObject(body.tool_choice) ? body.tool_choice : { type: "auto" }; + if (current.type !== "none") body.tool_choice = { ...current, disable_parallel_tool_use: true }; + } + return { body, stream, toolNames, customTools }; +} + +// Claude counts cached input apart; OpenAI counts it inside the input. +function responsesUsage(usage: Usage): Json { + const input = usage.input + usage.cacheWrite + usage.cacheRead; + return { + input_tokens: input, + input_tokens_details: { cached_tokens: usage.cacheRead }, + output_tokens: usage.output, + output_tokens_details: { reasoning_tokens: usage.thinking ?? 0 }, + total_tokens: input + usage.output, + }; +} + +function responseId(messageId: string): string { + return `resp_${messageId.replace(/^msg_/, "") || crypto.randomUUID().replaceAll("-", "")}`; +} + +interface Shared { + readonly model: string; + readonly toolNames: Map; + readonly customTools: Set; +} + +function toolItem(shared: Shared, id: string, callId: string, name: string, args: string, status: string): Json { + const original = shared.toolNames.get(name) ?? name; + if (shared.customTools.has(name)) { + const input = parsedArguments(args).input; + return { id, type: "custom_tool_call", status, call_id: callId, name: original, input: typeof input === "string" ? input : "" }; + } + return { id, type: "function_call", status, call_id: callId, name: original, arguments: args || "{}" }; +} + +function messageItem(id: string, value: string, status: string): Json { + return { id, type: "message", status, role: "assistant", content: [{ type: "output_text", text: value, annotations: [], logprobs: [] }] }; +} + +function reasoningItem(id: string, summary: string, signature: string): Json { + const item: Json = { id, type: "reasoning", summary: summary === "" ? [] : [{ type: "summary_text", text: summary }] }; + if (signature !== "") item.encrypted_content = signature; + return item; +} + +function responseObject(id: string, created: number, model: string, status: string, output: Json[], usage: Json | null, stop: string): Json { + const response: Json = { id, object: "response", created_at: created, status, error: null, incomplete_details: null, model, output, usage }; + if (status === "incomplete") response.incomplete_details = { reason: stop === "max_tokens" ? "max_output_tokens" : "content_filter" }; + return response; +} + +function finalStatus(stop: string): string { + return stop === "max_tokens" || stop === "model_context_window_exceeded" || stop === "refusal" ? "incomplete" : "completed"; +} + +interface OpenBlock { + readonly kind: "text" | "thinking" | "redacted" | "tool"; + readonly id: string; + readonly output: number; + value: string; + signature: string; + readonly callId: string; + readonly name: string; +} + +// Turns Anthropic events into a Responses event stream: each Claude content block becomes one +// output item, text and reasoning streamed as they come. +export class ResponsesStream implements EventTranslator { + private id = ""; + private readonly created = Math.floor(Date.now() / 1000); + private sequence = 0; + private readonly output: Json[] = []; + private readonly blocks = new Map(); + private readonly usage: Usage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, thinking: null }; + private stop = ""; + private started = false; + private finished = false; + private readonly shared: Shared; + + constructor(model: string, toolNames: Map, customTools: Set = new Set()) { + this.shared = { model, toolNames, customTools }; + } + + private send(type: string, data: Json): string { + return sse(type, { type, sequence_number: this.sequence++, ...data }); + } + + private response(status: string): Json { + return responseObject(this.id, this.created, this.shared.model, status, this.output, status === "in_progress" ? null : responsesUsage(this.usage), this.stop); + } + + private begin(messageId: string): string { + if (this.started) return ""; + this.started = true; + this.id = responseId(messageId); + return this.send("response.created", { response: this.response("in_progress") }) + this.send("response.in_progress", { response: this.response("in_progress") }); + } + + event(event: Json): string { + if (this.finished) return ""; + const index = typeof event.index === "number" ? event.index : 0; + const delta = isObject(event.delta) ? event.delta : {}; + switch (event.type) { + case "message_start": { + const message = isObject(event.message) ? event.message : {}; + mergeUsage(this.usage, message.usage); + return this.begin(text(message.id)); + } + case "content_block_start": + return this.begin("") + this.open(index, isObject(event.content_block) ? event.content_block : {}); + case "content_block_delta": { + const block = this.blocks.get(index); + if (block === undefined) return ""; + const at = { item_id: block.id, output_index: block.output }; + if (delta.type === "text_delta" && block.kind === "text") { + block.value += text(delta.text); + return this.send("response.output_text.delta", { ...at, content_index: 0, delta: text(delta.text), logprobs: [] }); + } + if (delta.type === "thinking_delta" && block.kind === "thinking") { + block.value += text(delta.thinking); + return this.send("response.reasoning_summary_text.delta", { ...at, summary_index: 0, delta: text(delta.thinking) }); + } + if (delta.type === "signature_delta") block.signature += text(delta.signature); + if (delta.type === "input_json_delta" && block.kind === "tool") { + block.value += text(delta.partial_json); + // A custom tool's input is the string inside the JSON, known only once it is complete. + if (!this.shared.customTools.has(block.name)) return this.send("response.function_call_arguments.delta", { ...at, delta: text(delta.partial_json) }); + } + return ""; + } + case "content_block_stop": + return this.close(index); + case "message_delta": + mergeUsage(this.usage, event.usage); + if (typeof delta.stop_reason === "string") this.stop = delta.stop_reason; + return ""; + case "message_stop": { + this.finished = true; + const closing = [...this.blocks.keys()].map((open) => this.close(open)).join(""); + const status = finalStatus(this.stop); + return this.begin("") + closing + this.send(status === "completed" ? "response.completed" : "response.incomplete", { response: this.response(status) }); + } + case "error": { + const error = isObject(event.error) ? event.error : {}; + return this.fail(text(error.message) || "The response failed upstream."); + } + default: + return ""; + } + } + + private open(index: number, block: Json): string { + const output = this.output.length + this.blocks.size; + const suffix = `${this.id.slice(5)}_${output}`; + switch (block.type) { + case "text": { + const id = `msg_${suffix}`; + this.blocks.set(index, { kind: "text", id, output, value: "", signature: "", callId: "", name: "" }); + const item = { id, type: "message", status: "in_progress", role: "assistant", content: [] }; + return this.send("response.output_item.added", { output_index: output, item }) + this.send("response.content_part.added", { item_id: id, output_index: output, content_index: 0, part: { type: "output_text", text: "", annotations: [], logprobs: [] } }); + } + case "thinking": + case "redacted_thinking": { + const id = `rs_${suffix}`; + const redacted = block.type === "redacted_thinking"; + this.blocks.set(index, { kind: redacted ? "redacted" : "thinking", id, output, value: "", signature: redacted ? text(block.data) : "", callId: "", name: "" }); + const added = this.send("response.output_item.added", { output_index: output, item: { id, type: "reasoning", summary: [] } }); + return redacted ? added : added + this.send("response.reasoning_summary_part.added", { item_id: id, output_index: output, summary_index: 0, part: { type: "summary_text", text: "" } }); + } + case "tool_use": { + const id = `fc_${suffix}`; + const name = text(block.name); + const input = isObject(block.input) && Object.keys(block.input).length > 0 ? JSON.stringify(block.input) : ""; + this.blocks.set(index, { kind: "tool", id, output, value: input, signature: "", callId: text(block.id), name }); + const item = toolItem(this.shared, id, text(block.id), name, "", "in_progress"); + if (item.type === "function_call") item.arguments = ""; + return this.send("response.output_item.added", { output_index: output, item }); + } + default: + return ""; + } + } + + private close(index: number): string { + const block = this.blocks.get(index); + if (block === undefined) return ""; + this.blocks.delete(index); + const at = { item_id: block.id, output_index: block.output }; + let events = ""; + let item: Json; + switch (block.kind) { + case "text": + item = messageItem(block.id, block.value, "completed"); + events = + this.send("response.output_text.done", { ...at, content_index: 0, text: block.value, logprobs: [] }) + + this.send("response.content_part.done", { ...at, content_index: 0, part: { type: "output_text", text: block.value, annotations: [], logprobs: [] } }); + break; + case "thinking": + item = reasoningItem(block.id, block.value, block.signature); + events = this.send("response.reasoning_summary_text.done", { ...at, summary_index: 0, text: block.value }) + this.send("response.reasoning_summary_part.done", { ...at, summary_index: 0, part: { type: "summary_text", text: block.value } }); + break; + case "redacted": + item = reasoningItem(block.id, "", block.signature); + break; + case "tool": + item = toolItem(this.shared, block.id, block.callId, block.name, block.value, "completed"); + events = + item.type === "custom_tool_call" + ? this.send("response.custom_tool_call_input.delta", { ...at, delta: item.input }) + this.send("response.custom_tool_call_input.done", { ...at, input: item.input }) + : this.send("response.function_call_arguments.done", { ...at, name: item.name, arguments: item.arguments }); + break; + } + this.output[block.output] = item; + return events + this.send("response.output_item.done", { output_index: block.output, item }); + } + + private fail(message: string): string { + this.finished = true; + const response = { ...this.response("failed"), error: { code: "server_error", message } }; + return this.begin("") + this.send("response.failed", { response }); + } + + end(): string { + if (this.finished) return ""; + return this.fail("The upstream stream ended early."); + } +} + +// A whole Anthropic message as one Responses object, for a client that didn't stream. +export function responsesResponse(message: Json, model: string, toolNames: Map, customTools: Set): Json { + const shared: Shared = { model, toolNames, customTools }; + const id = responseId(text(message.id)); + const suffix = id.slice(5); + const output: Json[] = []; + for (const block of Array.isArray(message.content) ? message.content.filter(isObject) : []) { + const itemId = `${suffix}_${output.length}`; + if (block.type === "text") output.push(messageItem(`msg_${itemId}`, text(block.text), "completed")); + else if (block.type === "thinking") output.push(reasoningItem(`rs_${itemId}`, text(block.thinking), text(block.signature))); + else if (block.type === "redacted_thinking") output.push(reasoningItem(`rs_${itemId}`, "", text(block.data))); + else if (block.type === "tool_use") output.push(toolItem(shared, `fc_${itemId}`, text(block.id), text(block.name), JSON.stringify(isObject(block.input) ? block.input : {}), "completed")); + } + const usage: Usage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, thinking: null }; + mergeUsage(usage, message.usage); + const stop = text(message.stop_reason); + return responseObject(id, Math.floor(Date.now() / 1000), model, finalStatus(stop), output, responsesUsage(usage), stop); +} diff --git a/free/test/app.test.ts b/free/test/app.test.ts index 8960680..8a6c46d 100644 --- a/free/test/app.test.ts +++ b/free/test/app.test.ts @@ -316,8 +316,8 @@ describe("API-key providers", () => { const unknown = await handle(post("/v1/chat/completions", { model: "nothing" }), config); expect(unknown.status).toBe(400); expect(await unknown.json()).toMatchObject({ error: { type: "invalid_request_error", message: expect.stringMatching(/No provider is configured for model nothing/) } }); - const wrongShape = await handle(post("/v1/chat/completions", { model: "gemini-2.5-flash" }), config); - expect(await wrongShape.json()).toMatchObject({ error: { message: expect.stringMatching(/gemini provider, which doesn't take this request format yet/) } }); + const wrongShape = await handle(at(`/v1beta/models/kimi:generateContent?key=${KEY}`, { method: "POST", body: "{}" }), config); + expect(await wrongShape.json()).toMatchObject({ error: { message: expect.stringMatching(/openai-compatibility provider, which doesn.t take this request format yet/) } }); backend.table = { ...backend.table, claudeOAuth: false }; config.cache.forgetConfig(); expect((await handle(post("/v1/messages", { model: "x" }), config)).status).toBe(400); @@ -916,6 +916,15 @@ describe("Claude Code on ChatGPT logins and Codex keys", () => { expect(seen).toHaveLength(1); }); + it("serves an OpenAI Chat client from a ChatGPT login, through Claude Messages", async () => { + const { config, seen } = await loginSetup(); + const response = await handle(post("/v1/chat/completions", { model: "gpt-5.6-sol", messages: [{ role: "user", content: "hi" }] }), config); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ object: "chat.completion", model: "gpt-5.6-sol", choices: [{ message: { content: "Hi" }, finish_reason: "stop" }] }); + expect(seen[0]?.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(await seen[0]?.json()).toMatchObject({ model: "gpt-5.6-sol", stream: true, input: [{ type: "message", role: "user" }] }); + }); + it("translates a 600 KB Claude Code request within the CPU budget", async () => { const history = Array.from({ length: 400 }, (_, index) => ({ role: index % 2 ? "assistant" : "user", content: [{ type: "text", text: "x".repeat(1500) }] })); const tools = Array.from({ length: 30 }, (_, index) => ({ name: `tool_${index}`, description: "d".repeat(300), input_schema: { type: "object", properties: { a: { type: "string" } } } })); @@ -932,3 +941,61 @@ describe("Claude Code on ChatGPT logins and Codex keys", () => { expect(gateway).toBeLessThan(9); }); }); + +describe("OpenAI Responses clients on other providers", () => { + const claudeEvents = (events: unknown[]) => + new Response(events.map((event) => `event: x\ndata: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); + const claudeAnswer = () => + claudeEvents([ + { type: "message_start", message: { id: "msg_1", usage: { input_tokens: 4, cache_read_input_tokens: 2 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_1", name: "fs_read", input: {} } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"path":"a"}' } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 3 } }, + { type: "message_stop" }, + ]); + + it("serves a Responses client from a Claude API key, streamed and whole", async () => { + const { config, seen } = await providersSetup(async (request) => + (await request.clone().json() as { stream?: boolean }).stream + ? claudeAnswer() + : Response.json({ id: "msg_2", content: [{ type: "text", text: "Hello" }], stop_reason: "max_tokens", usage: { input_tokens: 4, output_tokens: 9 } }), + ); + const body = { model: "work/claude-sonnet-5-5", stream: true, instructions: "Be brief.", input: "hi", tools: [{ type: "function", name: "fs.read", parameters: { type: "object", properties: { path: { type: "string" } } } }] }; + const streamed = await handle(post("/v1/responses", body), config); + expect(streamed.headers.get("content-type")).toMatch(/^text\/event-stream/); + const text = await streamed.text(); + expect(text).toContain('"type":"response.output_text.delta"'); + expect(text).toContain('"name":"fs.read","arguments":"{\\"path\\":\\"a\\"}"'); + expect(text).toContain('event: response.completed'); + expect(text).toContain('"input_tokens":6,"input_tokens_details":{"cached_tokens":2}'); + const [sent] = seen; + expect(sent?.url).toBe("https://api.anthropic.com/v1/messages"); + expect(sent?.headers.get("x-api-key")).toBe("sk-ant-1"); + expect(sent?.headers.get("authorization")).toBeNull(); + expect(await sent?.json()).toMatchObject({ model: "claude-sonnet-5-5", system: [{ type: "text", text: "Be brief." }], messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], tools: [{ name: "fs_read" }] }); + const whole = await handle(post("/v1/responses", { model: "work/claude-sonnet-5-5", input: "hi" }), config); + expect(await whole.json()).toMatchObject({ object: "response", status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output: [{ type: "message", content: [{ type: "output_text", text: "Hello" }] }] }); + }); + + it("serves a Responses client from an OpenAI-compatible key, through Claude Messages", async () => { + const chat = () => new Response(`data: ${JSON.stringify({ id: "chatcmpl-1", choices: [{ delta: { content: "Hi" }, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); + const { config, seen } = await providersSetup(async () => chat()); + const response = await handle(post("/v1/responses", { model: "kimi", stream: true, input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }] }), config); + const text = await response.text(); + expect(text).toContain('"delta":"Hi"'); + expect(text).toContain("event: response.completed"); + expect(seen[0]?.url).toBe("https://router.example/api/v1/chat/completions"); + expect(await seen[0]?.json()).toMatchObject({ model: "vendor/kimi-k2:free", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }); + }); + + it("returns upstream errors in the Responses shape", async () => { + const { config } = await providersSetup(async () => Response.json({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } }, { status: 400 })); + const response = await handle(post("/v1/responses", { model: "work/claude-sonnet-5-5", input: "hi" }), config); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: { message: "Overloaded", type: "api_error", code: null } }); + }); +}); diff --git a/free/test/responses-claude.test.ts b/free/test/responses-claude.test.ts new file mode 100644 index 0000000..4b405f4 --- /dev/null +++ b/free/test/responses-claude.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { claudeEventStream } from "../src/claude-responses.ts"; +import { responsesResponse, ResponsesStream, responsesToClaude } from "../src/responses-claude.ts"; + +function events(stream: string): { event: string; data: Record }[] { + return stream + .trim() + .split("\n\n") + .map((block) => { + const [event = "", data = ""] = block.split("\n"); + return { event: event.slice(7), data: JSON.parse(data.slice(6)) as Record }; + }); +} + +function run(input: Record[], customTools = new Set()): string { + const stream = new ResponsesStream("claude-sonnet-5-5", new Map([["fs_read", "fs.read"]]), customTools); + return input.map((event) => stream.event(event)).join("") + stream.end(); +} + +describe("Responses to Claude", () => { + it("translates instructions, items, reasoning, tool calls and settings as CPA does", () => { + const { body, stream, toolNames, customTools } = responsesToClaude( + { + model: "claude-sonnet-4-5", + stream: true, + instructions: "Be brief.", + max_output_tokens: 4000, + reasoning: { effort: "high" }, + text: { format: { type: "json_schema", name: "answer", schema: { type: "object" } } }, + parallel_tool_calls: false, + input: [ + { role: "developer", content: [{ type: "input_text", text: "Use tools." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }, { type: "input_image", image_url: "data:image/png;base64,iV" }, { type: "input_file", filename: "a.pdf", file_data: "JVBER" }] }, + { type: "reasoning", summary: [{ type: "summary_text", text: "thought" }], encrypted_content: "sig-claude" }, + { type: "reasoning", summary: [{ type: "summary_text", text: "openai" }], encrypted_content: "gAAAAopenai" }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Running." }] }, + { type: "function_call", call_id: "call.1", name: "fs.read", arguments: '{"path":"a"}' }, + { type: "custom_tool_call", call_id: "call_2", name: "apply_patch", input: "*** Begin Patch" }, + { type: "function_call", call_id: "call_3", name: "fs.read", arguments: "{oops" }, + { type: "function_call_output", call_id: "call.1", output: "old" }, + { type: "custom_tool_call_output", call_id: "call_2", output: [{ type: "input_text", text: "done" }] }, + { type: "function_call_output", call_id: "call.1", output: "new" }, + { type: "function_call_output", call_id: "orphan", output: "lost" }, + { type: "web_search_call", id: "ws_1" }, + { role: "user", content: "next" }, + ], + tools: [ + { type: "function", name: "fs.read", description: "Read", parameters: { type: "object", properties: { path: { type: "string" } } }, strict: true }, + { type: "custom", name: "apply_patch", description: "Patch" }, + { type: "web_search" }, + ], + }, + "claude-sonnet-4-5", + ); + expect(stream).toBe(true); + expect(toolNames.get("fs_read")).toBe("fs.read"); + expect([...customTools]).toEqual(["apply_patch"]); + expect(body).toEqual({ + model: "claude-sonnet-4-5", + max_tokens: 4000, + stream: true, + thinking: { type: "enabled", budget_tokens: 3999 }, + system: [ + { type: "text", text: "Be brief." }, + { type: "text", text: "Use tools." }, + { type: "text", text: expect.stringContaining("Schema Name: answer") }, + ], + messages: [ + { + role: "user", + content: [ + { type: "text", text: "hi" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "iV" } }, + { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBER" } }, + ], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thought", signature: "sig-claude" }, + { type: "text", text: "Running." }, + { type: "tool_use", id: "call_1", name: "fs_read", input: { path: "a" } }, + { type: "tool_use", id: "call_2", name: "apply_patch", input: { input: "*** Begin Patch" } }, + { type: "tool_use", id: "call_3", name: "fs_read", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_1", content: "new" }, + { type: "tool_result", tool_use_id: "call_2", content: [{ type: "text", text: "done" }] }, + { type: "tool_result", tool_use_id: "call_3", content: "Tool call was interrupted before any output was recorded.", is_error: true }, + { type: "text", text: "Tool result orphan: lost" }, + { type: "text", text: "next" }, + ], + }, + ], + tools: [ + { name: "fs_read", description: "Read", input_schema: { type: "object", properties: { path: { type: "string" } } }, strict: true }, + { name: "apply_patch", description: "Patch", input_schema: { type: "object", properties: { input: { type: "string" } }, required: ["input"] } }, + ], + tool_choice: { type: "auto", disable_parallel_tool_use: true }, + }); + }); + + it("takes a plain string input, adaptive effort, tool choices, and drops a trailing thought", () => { + expect(responsesToClaude({ input: "hi", reasoning: { effort: "xhigh" }, tool_choice: "required", tools: [{ type: "function", name: "f" }] }, "claude-opus-5-5").body).toMatchObject({ + max_tokens: 32000, + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "adaptive" }, + output_config: { effort: "max" }, + tool_choice: { type: "any" }, + }); + const prefilled = responsesToClaude( + { input: [{ role: "user", content: "q" }, { role: "assistant", content: "Sure" }, { type: "reasoning", summary: [], encrypted_content: "sig" }], tool_choice: { type: "custom", name: "apply_patch" } }, + "claude-fable-5-1", + ).body; + expect(prefilled.max_tokens).toBe(64000); + expect(prefilled.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "q" }] }, + { role: "assistant", content: [{ type: "text", text: "Sure" }] }, + ]); + expect(prefilled.tool_choice).toEqual({ type: "tool", name: "apply_patch" }); + // A trailing tool call gets an interrupted result, so the request stays valid. + const pending = responsesToClaude({ input: [{ type: "function_call", call_id: "c", name: "f", arguments: "{}" }] }, "claude-opus-5-5").body; + expect(pending.messages).toEqual([ + { role: "assistant", content: [{ type: "tool_use", id: "c", name: "f", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "c", content: "Tool call was interrupted before any output was recorded.", is_error: true }] }, + ]); + expect(responsesToClaude({ input: [] }, "claude-opus-5-5").body.messages).toEqual([{ role: "user", content: [{ type: "text", text: "" }] }]); + }); +}); + +describe("Claude events to Responses", () => { + it("streams reasoning, text and tool calls as Responses items, in order", () => { + const out = events( + run([ + { type: "message_start", message: { id: "msg_abc", usage: { input_tokens: 10, cache_read_input_tokens: 5, cache_creation_input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Hmm" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hi" } }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "toolu_1", name: "fs_read", input: {} } }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '{"path":' } }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '"a"}' } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 7 } }, + { type: "message_stop" }, + ]), + ); + expect(out.map((event) => event.event)).toEqual([ + "response.created", + "response.in_progress", + "response.output_item.added", + "response.reasoning_summary_part.added", + "response.reasoning_summary_text.delta", + "response.reasoning_summary_text.done", + "response.reasoning_summary_part.done", + "response.output_item.done", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + "response.output_item.done", + "response.completed", + ]); + expect(out.map((event) => event.data.sequence_number)).toEqual(out.map((_, index) => index)); + expect(out[0]?.data.response).toMatchObject({ id: "resp_abc", status: "in_progress", model: "claude-sonnet-5-5" }); + expect(out.at(-1)?.data.response).toMatchObject({ + id: "resp_abc", + status: "completed", + output: [ + { id: "rs_abc_0", type: "reasoning", summary: [{ type: "summary_text", text: "Hmm" }], encrypted_content: "sig" }, + { id: "msg_abc_1", type: "message", role: "assistant", content: [{ type: "output_text", text: "Hi" }] }, + { id: "fc_abc_2", type: "function_call", call_id: "toolu_1", name: "fs.read", arguments: '{"path":"a"}', status: "completed" }, + ], + usage: { input_tokens: 16, input_tokens_details: { cached_tokens: 5 }, output_tokens: 7, total_tokens: 23 }, + }); + }); + + it("sends custom tool calls whole, marks max_tokens incomplete, and fails a cut-off stream", () => { + const custom = events( + run( + [ + { type: "message_start", message: { id: "msg_c" } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_2", name: "apply_patch", input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"input":"*** Begin"}' } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "max_tokens" } }, + { type: "message_stop" }, + ], + new Set(["apply_patch"]), + ), + ); + expect(custom.map((event) => event.event)).toContain("response.custom_tool_call_input.done"); + expect(custom.at(-1)).toMatchObject({ + event: "response.incomplete", + data: { response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output: [{ type: "custom_tool_call", call_id: "toolu_2", name: "apply_patch", input: "*** Begin" }] } }, + }); + const cut = events(run([{ type: "message_start", message: { id: "msg_d" } }, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }])); + expect(cut.at(-1)).toMatchObject({ event: "response.failed", data: { response: { status: "failed", error: { message: "The upstream stream ended early." } } } }); + const failed = events(run([{ type: "error", error: { type: "overloaded_error", message: "Overloaded" } }])); + expect(failed.map((event) => event.event)).toEqual(["response.created", "response.in_progress", "response.failed"]); + }); + + it("re-encodes an Anthropic SSE body as it arrives", async () => { + const body = new Response('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_s"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n').body; + const text = await new Response(claudeEventStream(body as ReadableStream, new ResponsesStream("m", new Map()))).text(); + expect(events(text).map((event) => event.event)).toEqual(["response.created", "response.in_progress", "response.completed"]); + }); + + it("converts a whole message for a client that didn't stream", () => { + const response = responsesResponse( + { + id: "msg_w", + content: [ + { type: "thinking", thinking: "Hmm", signature: "sig" }, + { type: "text", text: "Hi" }, + { type: "tool_use", id: "toolu_1", name: "fs_read", input: { path: "a" } }, + { type: "tool_use", id: "toolu_2", name: "apply_patch", input: { input: "p" } }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 3, output_tokens: 2 }, + }, + "claude-sonnet-5-5", + new Map([["fs_read", "fs.read"]]), + new Set(["apply_patch"]), + ); + expect(response).toMatchObject({ + id: "resp_w", + object: "response", + status: "completed", + model: "claude-sonnet-5-5", + output: [ + { type: "reasoning", summary: [{ text: "Hmm" }], encrypted_content: "sig" }, + { type: "message", content: [{ type: "output_text", text: "Hi" }] }, + { type: "function_call", call_id: "toolu_1", name: "fs.read", arguments: '{"path":"a"}' }, + { type: "custom_tool_call", call_id: "toolu_2", name: "apply_patch", input: "p" }, + ], + usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 }, + }); + }); +});