From bec963b2a481d30865fc2b0ea4714fc6997805ec Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 08:46:43 +0000 Subject: [PATCH 1/6] Serve Claude Code from Gemini API keys through a translator Port CPA's gemini/claude translator: Anthropic Messages become Gemini generateContent (system instruction, turns, function calls and responses, images, tools, tool choice, thinking), and Gemini answers, streamed or whole, become Anthropic events and messages. Google's default safety settings stay in place instead of being switched off. --- free/README.md | 5 +- free/src/app.ts | 33 ++-- free/src/claude-gemini.ts | 277 ++++++++++++++++++++++++++++++++ free/test/app.test.ts | 25 +++ free/test/claude-gemini.test.ts | 117 ++++++++++++++ 5 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 free/src/claude-gemini.ts create mode 100644 free/test/claude-gemini.test.ts diff --git a/free/README.md b/free/README.md index ee7ffd2..085b7f7 100644 --- a/free/README.md +++ b/free/README.md @@ -13,12 +13,13 @@ It covers **Claude and ChatGPT (Codex) subscriptions** and **API keys** for Clau - Codex CLI through a ChatGPT login, on `/backend-api/codex/responses` or `/v1/responses`. Other OpenAI Responses clients work too: their requests are adapted to what the ChatGPT endpoint takes, and a non-streaming request gets the final response as JSON. - Claude Code on an OpenAI-compatible key (OpenRouter and the like): ask for one of the provider's model aliases and the request goes out as Chat Completions, with the answer streamed back as Anthropic events, following CPA's Claude-to-OpenAI translator. - 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. - 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. - 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 to ChatGPT can run past the free plan's 10 ms, which the Container install doesn't have. +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: other translations (Claude Code to Gemini, OpenAI clients to Claude), Gemini and other OAuth logins, model lists for ChatGPT logins, usage statistics and request 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: translations for OpenAI and Gemini clients (OpenAI clients to Claude, for example), Gemini and other OAuth logins, model lists for ChatGPT logins, usage statistics and request 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 07284b7..85b47b3 100644 --- a/free/src/app.ts +++ b/free/src/app.ts @@ -1,5 +1,6 @@ import { hashKey, REFRESH_MARGIN_SECONDS, type Backend } from "./account.ts"; 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 { MANAGEMENT_PREFIX } from "./management.ts"; @@ -187,10 +188,11 @@ async function viaCodexLogin(request: Request, url: URL, config: AppConfig, text return collected(upstream); } -// Formats a provider takes with translation: Claude clients can reach Responses and Chat upstreams. +// Formats a provider takes with translation: Claude clients can reach Responses, Chat and +// Gemini upstreams. function servable(format: Format, kind: Candidate["credential"]["kind"]): boolean { const native = NATIVE_FORMAT[kind]; - return native === format || (format === "claude" && (native === "openai-response" || native === "openai")); + return native === format || (format === "claude" && (native === "openai-response" || native === "openai" || native === "gemini")); } function parsedObject(text: string): Record | null { @@ -211,17 +213,16 @@ async function upstreamMessage(upstream: Response): Promise { return `The upstream answered ${upstream.status}.`; } -// A Claude request served by a Responses upstream (a ChatGPT login or a Codex key) or a Chat -// Completions one (an OpenAI-compatible key): the body is translated, and the answer translated -// back, streamed or whole. +// A Claude request served by a Responses upstream (a ChatGPT login or a Codex key), a Chat +// Completions one (an OpenAI-compatible key) or Gemini (a Gemini key): the body is translated, +// and the answer translated back, streamed or whole. async function viaTranslation(request: Request, url: URL, route: Route, config: AppConfig, candidate: Candidate, text: string): Promise { 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" } }); const requested = typeof claude.model === "string" ? claude.model : candidate.model; - const chat = NATIVE_FORMAT[candidate.credential.kind] === "openai"; - const responses = chat ? null : await claudeToResponses(claude, candidate.model); - const translated = chat ? claudeToChat(claude, candidate.model) : (responses as NonNullable); + 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); const body = JSON.stringify(translated.body); // The client's own headers describe a Claude request, so none of them go upstream. const bare = new Request(request.url, { method: "POST" }); @@ -232,8 +233,13 @@ async function viaTranslation(request: Request, url: URL, route: Route, config: if (access instanceof Response) return formatError("claude", access.status, "api_error", await upstreamMessage(access)); accessToken = access.token; upstream = await config.upstream(codexRequest(bare, access.token, access.accountId, body)).catch(() => null); + } else if (native === "gemini") { + const geminiRoute: Route = { ...route, format: "gemini", geminiAction: translated.stream ? "streamGenerateContent" : "generateContent" }; + const target = new URL(request.url); + target.search = translated.stream ? "?alt=sse" : ""; + upstream = await config.upstream(providerRequest(bare, target, geminiRoute, candidate.credential, candidate.model, body)).catch(() => null); } else { - const upstreamRoute: Route = { ...route, format: NATIVE_FORMAT[candidate.credential.kind] }; + const upstreamRoute: Route = { ...route, format: native }; upstream = await config.upstream(providerRequest(bare, url, upstreamRoute, candidate.credential, candidate.model, body)).catch(() => null); } if (upstream === null) return formatError("claude", 502, "api_error", "The upstream couldn't be reached."); @@ -245,17 +251,18 @@ async function viaTranslation(request: Request, url: URL, route: Route, config: return formatError("claude", upstream.status, upstream.status === 429 ? "rate_limit_error" : "api_error", await upstreamMessage(upstream)); } if (translated.stream && upstream.body !== null) { - const translator = responses === null ? new ChatClaudeStream(requested) : new ClaudeStream(requested, responses.toolNames); + const translator = native === "openai" ? new ChatClaudeStream(requested) : native === "gemini" ? new GeminiClaudeStream(requested, translated.toolNames) : new ClaudeStream(requested, translated.toolNames); return new Response(claudeEventStream(upstream.body, translator), { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" } }); } - if (responses === null) { + if (native === "openai" || native === "gemini") { const final = parsedObject(await upstream.text()); if (final === null) return formatError("claude", 502, "api_error", "The upstream sent a response that isn't JSON."); - return Response.json(chatClaudeMessage(final, requested), { headers: { "cache-control": "no-store" } }); + const message = native === "openai" ? chatClaudeMessage(final, requested) : geminiClaudeMessage(final, requested, translated.toolNames); + return Response.json(message, { headers: { "cache-control": "no-store" } }); } const final = await collected(upstream); if (!final.ok) return formatError("claude", final.status, "api_error", await upstreamMessage(final)); - return Response.json(claudeMessage((await final.json()) as Record, requested, responses.toolNames), { headers: { "cache-control": "no-store" } }); + return Response.json(claudeMessage((await final.json()) as Record, requested, translated.toolNames), { headers: { "cache-control": "no-store" } }); } // Statuses after which another credential may well succeed. diff --git a/free/src/claude-gemini.ts b/free/src/claude-gemini.ts new file mode 100644 index 0000000..6736351 --- /dev/null +++ b/free/src/claude-gemini.ts @@ -0,0 +1,277 @@ +import { isAttribution, isObject, sse, text, type EventTranslator, type Json } from "./claude-responses.ts"; + +// Translation between Anthropic Messages and Gemini generateContent, for Gemini API keys, +// ported from CPA's translator/gemini/claude. Unlike CPA, Google's default safety settings are +// left in place rather than switched off. + +// Gemini wants a thought signature on replayed function calls; CPA sends this documented bypass +// value since Claude history carries none. +const SKIP_SIGNATURE = "skip_thought_signature_validator"; + +// Gemini function names: letters, digits, _ . : -, starting with a letter or underscore, 64 long. +export function geminiName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_.:-]/g, "_"); + if (sanitized === "") return "_"; + if (!/^[a-zA-Z_]/.test(sanitized)) sanitized = `_${sanitized.slice(0, 63)}`; + return sanitized.slice(0, 64); +} + +function cleanSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cleanSchema); + if (!isObject(value)) return value; + return Object.fromEntries(Object.entries(value).flatMap(([key, child]) => (key === "$schema" || key === "$id" ? [] : [[key, cleanSchema(child)]]))); +} + +function inlineData(source: unknown): Json | null { + if (!isObject(source) || source.type !== "base64" || text(source.media_type) === "" || text(source.data) === "") return null; + return { inline_data: { mime_type: text(source.media_type), data: text(source.data) } }; +} + +// A tool result's text, plus its images as their own parts. +function toolResult(content: unknown): { result: string; images: Json[] } { + if (typeof content === "string") return { result: content, images: [] }; + if (!Array.isArray(content)) return { result: "", images: [] }; + const texts: string[] = []; + const images: Json[] = []; + for (const item of content) { + if (!isObject(item)) continue; + if (item.type === "text") texts.push(text(item.text)); + else if (item.type === "image") { + const image = inlineData(item.source); + if (image !== null) images.push(image); + } + } + return { result: texts.join("\n"), images }; +} + +export interface GeminiRequest { + readonly body: Json; + readonly stream: boolean; + // Gemini function name to the client's, for the response. + readonly toolNames: Map; +} + +// Gemini 3 takes a thinking level (low or high, and on Flash also minimal or medium); Claude's +// higher efforts have no level above high. +function thinkingLevel(effort: string): string { + if (effort === "minimal" || effort === "low" || effort === "medium") return effort; + return "high"; +} + +export function claudeToGemini(claude: Json, model: string): GeminiRequest { + const toolNames = new Map(); + const nameOf = (name: string) => { + const short = geminiName(name); + toolNames.set(short, name); + return short; + }; + const body: Json = {}; + const system = typeof claude.system === "string" ? [{ type: "text", text: claude.system }] : Array.isArray(claude.system) ? claude.system : []; + const systemParts = system.flatMap((part) => (isObject(part) && part.type === "text" && text(part.text) !== "" && !isAttribution(text(part.text)) ? [{ text: text(part.text) }] : [])); + if (systemParts.length > 0) body.systemInstruction = { role: "user", parts: systemParts }; + + const contents: { role: string; parts: Json[] }[] = []; + const toolNameById = new Map(); + for (const message of Array.isArray(claude.messages) ? claude.messages : []) { + if (!isObject(message)) continue; + const role = message.role === "assistant" ? "model" : "user"; + const parts: Json[] = []; + if (typeof message.content === "string") { + if (message.content !== "") parts.push({ text: message.content }); + } else { + for (const part of Array.isArray(message.content) ? message.content : []) { + if (!isObject(part)) continue; + switch (part.type) { + case "text": + if (text(part.text) !== "") parts.push({ text: text(part.text) }); + break; + case "tool_use": { + const id = text(part.id); + const name = text(part.name); + if (id !== "" && name !== "") toolNameById.set(id, name); + const call: Json = { name: nameOf(name), args: isObject(part.input) ? part.input : {} }; + if (id !== "") call.id = id; + parts.push({ thoughtSignature: SKIP_SIGNATURE, functionCall: call }); + break; + } + case "tool_result": { + const id = text(part.tool_use_id); + if (id === "") break; + const result = toolResult(part.content); + parts.push({ functionResponse: { id, name: nameOf(toolNameById.get(id) ?? id), response: { result: result.result } } }); + parts.push(...result.images); + break; + } + case "image": + case "document": { + const data = inlineData(part.source); + if (data !== null) parts.push(data); + break; + } + default: + break; + } + } + } + if (parts.length === 0) continue; + // Gemini wants a user turn's prompt text ahead of its function responses. + if (role === "user" && parts.some((part) => isObject(part.functionResponse))) parts.sort((a, b) => Number("text" in b) - Number("text" in a)); + const previous = contents.at(-1); + if (previous !== undefined && previous.role === role) previous.parts.push(...parts); + else contents.push({ role, parts }); + } + body.contents = contents; + + const tools = Array.isArray(claude.tools) ? claude.tools.filter((tool): tool is Json => isObject(tool) && isObject(tool.input_schema) && text(tool.name) !== "") : []; + if (tools.length > 0) { + body.tools = [{ functionDeclarations: tools.map((tool) => ({ name: nameOf(text(tool.name)), description: text(tool.description), parametersJsonSchema: cleanSchema(tool.input_schema) })) }]; + } + const choice = claude.tool_choice; + const choiceType = isObject(choice) ? text(choice.type) : text(choice); + if (choiceType === "none") body.toolConfig = { functionCallingConfig: { mode: "NONE" } }; + else if (choiceType === "any") body.toolConfig = { functionCallingConfig: { mode: "ANY" } }; + else if (choiceType === "tool" && isObject(choice)) body.toolConfig = { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [nameOf(text(choice.name))] } }; + + const generation: Json = {}; + const thinking = isObject(claude.thinking) ? claude.thinking : null; + if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") generation.thinkingConfig = { thinkingBudget: thinking.budget_tokens, includeThoughts: true }; + else if (thinking?.type === "adaptive" || thinking?.type === "auto") { + const config = isObject(claude.output_config) ? claude.output_config : {}; + // Gemini 2.x has no levels; a budget of -1 lets it decide, as adaptive thinking does. + generation.thinkingConfig = /^gemini-2\./.test(model) ? { thinkingBudget: -1, includeThoughts: true } : { thinkingLevel: thinkingLevel(text(config.effort).trim().toLowerCase()), includeThoughts: true }; + } + if (typeof claude.max_tokens === "number") generation.maxOutputTokens = claude.max_tokens; + if (typeof claude.temperature === "number") generation.temperature = claude.temperature; + if (typeof claude.top_p === "number") generation.topP = claude.top_p; + if (typeof claude.top_k === "number") generation.topK = claude.top_k; + if (Array.isArray(claude.stop_sequences) && claude.stop_sequences.length > 0) generation.stopSequences = claude.stop_sequences; + if (Object.keys(generation).length > 0) body.generationConfig = generation; + return { body, stream: claude.stream === true, toolNames }; +} + +function stopReason(finish: string, toolUse: boolean): string { + if (toolUse) return "tool_use"; + if (finish === "MAX_TOKENS") return "max_tokens"; + if (finish === "SAFETY" || finish === "RECITATION" || finish === "PROHIBITED_CONTENT" || finish === "BLOCKLIST" || finish === "SPII") return "refusal"; + return "end_turn"; +} + +function geminiUsage(raw: unknown): Json { + const usage = isObject(raw) ? raw : {}; + const number = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0); + const cached = number(usage.cachedContentTokenCount); + const result: Json = { input_tokens: Math.max(0, number(usage.promptTokenCount) - cached), output_tokens: number(usage.candidatesTokenCount) + number(usage.thoughtsTokenCount) }; + if (cached > 0) result.cache_read_input_tokens = cached; + return result; +} + +function toolUseId(call: Json, index: number): string { + return text(call.id) || `toolu_${crypto.randomUUID().replaceAll("-", "").slice(0, 20)}${index}`; +} + +// Turns Gemini stream chunks into Anthropic events. Each chunk carries parts of the one +// candidate: thoughts and text stream as they come, function calls arrive whole. +export class GeminiClaudeStream implements EventTranslator { + private index = -1; + private open: "text" | "thinking" | null = null; + private started = false; + private finished = false; + private toolUse = false; + private finish = ""; + private usage: Json = { input_tokens: 0, output_tokens: 0 }; + + constructor( + private readonly model: string, + private readonly toolNames: Map, + ) {} + + private block(kind: "text" | "thinking"): string { + if (this.open === kind) return ""; + const out = this.stop(); + this.index += 1; + this.open = kind; + return out + sse("content_block_start", { type: "content_block_start", index: this.index, content_block: kind === "text" ? { type: "text", text: "" } : { type: "thinking", thinking: "" } }); + } + + private stop(): string { + if (this.open === null) return ""; + this.open = null; + return sse("content_block_stop", { type: "content_block_stop", index: this.index }); + } + + private messageStart(id: string): string { + if (this.started) return ""; + this.started = true; + const message = { id: `msg_${id || crypto.randomUUID().replaceAll("-", "")}`, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } }; + return sse("message_start", { type: "message_start", message }); + } + + event(event: Json): string { + if (this.finished) return ""; + if (isObject(event.error)) { + this.finished = true; + return sse("error", { type: "error", error: { type: "api_error", message: text(event.error.message) || "The upstream failed." } }); + } + let out = this.messageStart(text(event.responseId)); + if (isObject(event.usageMetadata)) this.usage = geminiUsage(event.usageMetadata); + const candidate = Array.isArray(event.candidates) && isObject(event.candidates[0]) ? event.candidates[0] : null; + if (candidate === null) return out; + const content = isObject(candidate.content) ? candidate.content : {}; + for (const part of Array.isArray(content.parts) ? content.parts : []) { + if (!isObject(part)) continue; + if (isObject(part.functionCall)) { + this.toolUse = true; + const call = part.functionCall; + const name = text(call.name); + out += this.stop(); + this.index += 1; + out += sse("content_block_start", { type: "content_block_start", index: this.index, content_block: { type: "tool_use", id: toolUseId(call, this.index), name: this.toolNames.get(name) ?? name, input: {} } }); + out += sse("content_block_delta", { type: "content_block_delta", index: this.index, delta: { type: "input_json_delta", partial_json: JSON.stringify(isObject(call.args) ? call.args : {}) } }); + out += sse("content_block_stop", { type: "content_block_stop", index: this.index }); + } else if (text(part.text) !== "") { + const kind = part.thought === true ? "thinking" : "text"; + out += this.block(kind); + out += sse("content_block_delta", { type: "content_block_delta", index: this.index, delta: kind === "text" ? { type: "text_delta", text: text(part.text) } : { type: "thinking_delta", thinking: text(part.text) } }); + } + } + if (text(candidate.finishReason) !== "") this.finish = text(candidate.finishReason); + return out; + } + + // The stream has no closing event of its own: the message ends when the body does. + end(): string { + if (this.finished) return ""; + this.finished = true; + const close = sse("message_delta", { type: "message_delta", delta: { stop_reason: stopReason(this.finish, this.toolUse), stop_sequence: null }, usage: this.usage }); + return this.messageStart("") + this.stop() + close + sse("message_stop", { type: "message_stop" }); + } +} + +export function geminiClaudeMessage(response: Json, model: string, toolNames: Map): Json { + const candidate = Array.isArray(response.candidates) && isObject(response.candidates[0]) ? response.candidates[0] : {}; + const parts = isObject(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts.filter(isObject) : []; + const content: Json[] = []; + let toolUse = false; + for (const [index, part] of parts.entries()) { + if (isObject(part.functionCall)) { + toolUse = true; + const name = text(part.functionCall.name); + content.push({ type: "tool_use", id: toolUseId(part.functionCall, index), name: toolNames.get(name) ?? name, input: isObject(part.functionCall.args) ? part.functionCall.args : {} }); + } else if (text(part.text) !== "") { + const last = content.at(-1); + const kind = part.thought === true ? "thinking" : "text"; + if (last !== undefined && last.type === kind) last[kind] = text(last[kind]) + text(part.text); + else content.push(kind === "text" ? { type: "text", text: text(part.text) } : { type: "thinking", thinking: text(part.text), signature: "" }); + } + } + return { + id: `msg_${text(response.responseId) || crypto.randomUUID().replaceAll("-", "")}`, + type: "message", + role: "assistant", + model, + content, + stop_reason: stopReason(text(candidate.finishReason), toolUse), + stop_sequence: null, + usage: geminiUsage(response.usageMetadata), + }; +} diff --git a/free/test/app.test.ts b/free/test/app.test.ts index 27714dc..1d3ff3b 100644 --- a/free/test/app.test.ts +++ b/free/test/app.test.ts @@ -637,6 +637,31 @@ describe("Claude Code on ChatGPT logins and Codex keys", () => { expect(await sent?.json()).toMatchObject({ model: "vendor/kimi-k2:free", stream: true, messages: [{ role: "user", content: "hi" }] }); }); + it("serves Claude Code from a Gemini key through the Gemini translator, streamed and whole", async () => { + const parts = [{ text: "Hi" }]; + const { config, seen } = await providersSetup(async (request) => + new URL(request.url).searchParams.get("alt") === "sse" + ? new Response(`data: ${JSON.stringify({ responseId: "r1", candidates: [{ content: { parts }, finishReason: "STOP" }] })}\r\n\r\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json({ responseId: "r2", candidates: [{ content: { parts }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 4, candidatesTokenCount: 1 } }), + ); + const streamed = await handle(messages({ "x-api-key": KEY, "anthropic-beta": "b" }, '{"model":"gemini-2.5-flash","stream":true,"max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'), config); + const text = await streamed.text(); + expect(text).toContain('"text_delta","text":"Hi"'); + expect(text).toContain("message_stop"); + const [sent] = seen; + expect(sent?.url).toBe("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"); + expect(sent?.headers.get("x-goog-api-key")).toBe("AIza-1"); + expect(sent?.headers.get("x-api-key")).toBeNull(); + expect(sent?.headers.get("anthropic-beta")).toBeNull(); + expect(await sent?.json()).toEqual({ contents: [{ role: "user", parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 64 } }); + const whole = await handle(messages({ "x-api-key": KEY }, '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}'), config); + expect(seen[1]?.url).toBe("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"); + expect(await whole.json()).toMatchObject({ id: "msg_r2", content: [{ type: "text", text: "Hi" }], stop_reason: "end_turn", usage: { input_tokens: 4, output_tokens: 1 } }); + // Excluded models stay excluded through the translator too. + await handle(messages({ "x-api-key": KEY }, '{"model":"gemini-2.5-pro","messages":[]}'), config); + expect(seen[2]?.url).not.toContain("generativelanguage"); + }); + 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" } } } })); diff --git a/free/test/claude-gemini.test.ts b/free/test/claude-gemini.test.ts new file mode 100644 index 0000000..1ac21a6 --- /dev/null +++ b/free/test/claude-gemini.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { claudeToGemini, geminiClaudeMessage, GeminiClaudeStream, geminiName } from "../src/claude-gemini.ts"; +import { claudeEventStream } from "../src/claude-responses.ts"; + +describe("Claude to Gemini", () => { + it("translates system, turns, tool calls and results, images and settings as CPA does", () => { + const { body, stream, toolNames } = claudeToGemini( + { + model: "gemini-2.5-pro", + max_tokens: 512, + temperature: 0.3, + top_k: 40, + stream: true, + stop_sequences: ["END"], + thinking: { type: "enabled", budget_tokens: 2000 }, + system: [{ type: "text", text: "x-anthropic-billing-header: v" }, { type: "text", text: "Be brief." }], + tools: [{ name: "mcp__srv/run tool", description: "Run", input_schema: { $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { a: { type: "string" } } } }, { type: "web_search_20250305", name: "web_search" }], + tool_choice: { type: "tool", name: "mcp__srv/run tool" }, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "thinking", thinking: "hmm", signature: "s" }, { type: "text", text: "Running." }, { type: "tool_use", id: "t1", name: "mcp__srv/run tool", input: { a: "ls" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "text", text: "a.txt" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "iV" } }] }, { type: "text", text: "next" }] }, + { role: "user", content: "and more" }, + ], + }, + "gemini-2.5-pro", + ); + expect(stream).toBe(true); + expect(toolNames.get("mcp__srv_run_tool")).toBe("mcp__srv/run tool"); + expect(body).toEqual({ + systemInstruction: { role: "user", parts: [{ text: "Be brief." }] }, + contents: [ + { role: "user", parts: [{ text: "hi" }] }, + { role: "model", parts: [{ text: "Running." }, { thoughtSignature: "skip_thought_signature_validator", functionCall: { name: "mcp__srv_run_tool", args: { a: "ls" }, id: "t1" } }] }, + { role: "user", parts: [{ text: "next" }, { functionResponse: { id: "t1", name: "mcp__srv_run_tool", response: { result: "a.txt" } } }, { inline_data: { mime_type: "image/png", data: "iV" } }, { text: "and more" }] }, + ], + tools: [{ functionDeclarations: [{ name: "mcp__srv_run_tool", description: "Run", parametersJsonSchema: { type: "object", properties: { a: { type: "string" } } } }] }], + toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["mcp__srv_run_tool"] } }, + generationConfig: { thinkingConfig: { thinkingBudget: 2000, includeThoughts: true }, maxOutputTokens: 512, temperature: 0.3, topK: 40, stopSequences: ["END"] }, + }); + }); + + it("maps adaptive thinking to a level on Gemini 3 and a dynamic budget on Gemini 2", () => { + const adaptive = (effort: string) => ({ thinking: { type: "adaptive" }, output_config: { effort }, messages: [] }); + expect(claudeToGemini(adaptive("max"), "gemini-3-pro-preview").body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "high", includeThoughts: true } }); + expect(claudeToGemini(adaptive("low"), "gemini-3-flash").body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "low", includeThoughts: true } }); + expect(claudeToGemini(adaptive("low"), "gemini-2.5-flash").body.generationConfig).toEqual({ thinkingConfig: { thinkingBudget: -1, includeThoughts: true } }); + }); + + it("makes names Gemini accepts", () => { + expect(geminiName("9lives")).toBe("_9lives"); + expect(geminiName("a b")).toBe("a_b"); + expect(geminiName("x".repeat(80))).toHaveLength(64); + }); +}); + +function chunks(events: unknown[]): ReadableStream { + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\r\n\r\n`).join("")).body as ReadableStream; +} + +describe("Gemini stream to Claude", () => { + it("streams thoughts and text, sends function calls whole and closes with usage at the end", async () => { + const names = new Map([["mcp__srv_run_tool", "mcp__srv/run tool"]]); + const out = await new Response( + claudeEventStream( + chunks([ + { responseId: "r1", candidates: [{ content: { role: "model", parts: [{ text: "think", thought: true }] } }] }, + { responseId: "r1", candidates: [{ content: { role: "model", parts: [{ text: "Hel" }] } }] }, + { responseId: "r1", candidates: [{ content: { role: "model", parts: [{ text: "lo" }, { functionCall: { name: "mcp__srv_run_tool", args: { a: "ls" } }, thoughtSignature: "sig" }] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 50, candidatesTokenCount: 9, thoughtsTokenCount: 3, cachedContentTokenCount: 20 } }, + ]), + new GeminiClaudeStream("claude-sonnet-4-5", names), + ), + ).text(); + const events = out.split("\n").filter((line) => line.startsWith("data: ")).map((line) => JSON.parse(line.slice(6)) as Record); + expect(events.map((event) => event.type)).toEqual([ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "content_block_start", + "content_block_delta", + "content_block_delta", + "content_block_stop", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + ]); + expect(events[0]).toMatchObject({ message: { id: "msg_r1", model: "claude-sonnet-4-5" } }); + expect(events[2]).toMatchObject({ delta: { type: "thinking_delta", thinking: "think" } }); + expect(events[8]).toMatchObject({ type: "content_block_start", index: 2, content_block: { type: "tool_use", name: "mcp__srv/run tool", input: {} } }); + expect(events[9]).toMatchObject({ delta: { type: "input_json_delta", partial_json: '{"a":"ls"}' } }); + expect(events[11]).toEqual({ type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { input_tokens: 30, output_tokens: 12, cache_read_input_tokens: 20 } }); + }); + + it("passes an in-stream error on and turns a whole response into a message", async () => { + const out = await new Response(claudeEventStream(chunks([{ error: { code: 429, message: "quota" } }]), new GeminiClaudeStream("m", new Map()))).text(); + expect(out).toContain('"type":"error","error":{"type":"api_error","message":"quota"}'); + expect(out).not.toContain("message_stop"); + const message = geminiClaudeMessage( + { responseId: "r9", candidates: [{ content: { parts: [{ text: "a", thought: true }, { text: "Hi" }, { text: "!" }] }, finishReason: "MAX_TOKENS" }], usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1 } }, + "m", + new Map(), + ); + expect(message).toEqual({ + id: "msg_r9", + type: "message", + role: "assistant", + model: "m", + content: [{ type: "thinking", thinking: "a", signature: "" }, { type: "text", text: "Hi!" }], + stop_reason: "max_tokens", + stop_sequence: null, + usage: { input_tokens: 3, output_tokens: 1 }, + }); + }); +}); From dd733cc8daf87001d84ce1a219dc1676db29d77e Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 08:51:28 +0000 Subject: [PATCH 2/6] Serve OpenAI Chat clients from Claude API keys through a translator Port CPA's claude/openai/chat-completions translator: Chat Completions requests become Anthropic Messages (system and developer prompts, turns, tool calls and results, images, files, cache_control, reasoning effort, tool choice, structured output as a system instruction), and Claude's answers, streamed or whole, become Chat Completions chunks and completions. Claude logins stay Claude Code only. --- free/README.md | 3 +- free/src/app.ts | 38 ++- free/src/openai-claude.ts | 426 ++++++++++++++++++++++++++++++++ free/test/app.test.ts | 33 +++ free/test/openai-claude.test.ts | 130 ++++++++++ 5 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 free/src/openai-claude.ts create mode 100644 free/test/openai-claude.test.ts diff --git a/free/README.md b/free/README.md index 085b7f7..bde4200 100644 --- a/free/README.md +++ b/free/README.md @@ -14,12 +14,13 @@ It covers **Claude and ChatGPT (Codex) subscriptions** and **API keys** for Clau - Claude Code on an OpenAI-compatible key (OpenRouter and the like): ask for one of the provider's model aliases and the request goes out as Chat Completions, with the answer streamed back as Anthropic events, following CPA's Claude-to-OpenAI translator. - 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. - 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: translations for OpenAI and Gemini clients (OpenAI clients to Claude, for example), Gemini and other OAuth logins, model lists for ChatGPT logins, usage statistics and request 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: the other client translations (OpenAI Responses and Gemini clients to other providers), Gemini and other OAuth logins, model lists for ChatGPT logins, usage statistics and request 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 85b47b3..99ff2b1 100644 --- a/free/src/app.ts +++ b/free/src/app.ts @@ -3,6 +3,7 @@ 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 { chatCompletion, ChatStream, chatToClaude } from "./openai-claude.ts"; import { MANAGEMENT_PREFIX } from "./management.ts"; import { loginPage, messagePage } from "./pages.ts"; import { candidates, inDefaultFamily, listedModels, NATIVE_FORMAT, ordered, type Candidate, type Format, type RoutingTable } from "./providers.ts"; @@ -189,10 +190,12 @@ async function viaCodexLogin(request: Request, url: URL, config: AppConfig, text } // Formats a provider takes with translation: Claude clients can reach Responses, Chat and -// Gemini upstreams. +// Gemini upstreams, and OpenAI Chat clients can reach Claude API keys. function servable(format: Format, kind: Candidate["credential"]["kind"]): boolean { const native = NATIVE_FORMAT[kind]; - return native === format || (format === "claude" && (native === "openai-response" || native === "openai" || native === "gemini")); + if (native === format) return true; + if (format === "claude") return native === "openai-response" || native === "openai" || native === "gemini"; + return format === "openai" && native === "claude"; } function parsedObject(text: string): Record | null { @@ -265,6 +268,27 @@ 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)); + 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 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" } }); +} + // Statuses after which another credential may well succeed. const RETRY_STATUSES = new Set([401, 403, 408, 429, 500, 502, 503, 504, 529]); const MAX_ATTEMPTS = 3; @@ -328,8 +352,11 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form if (route.format === "claude" && table.codexOAuth && inDefaultFamily("codex", model)) usable.push(loginCandidate("codex", model)); if (usable.length === 0) { if (route.format === "claude" && table.claudeOAuth) return viaClaudeLogin(request, url, config, text); + // Without CPA's cloaking a Claude login serves Claude Code only, so it isn't offered here. const reason = - found.length > 0 + 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.` + : 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.`; return formatError(route.format, 400, "invalid_request_error", reason); @@ -343,7 +370,8 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form const translated = route.format !== NATIVE_FORMAT[candidate.credential.kind]; if (translated || candidate.credential.id === CLAUDE_LOGIN_ID || candidate.credential.id === CODEX_LOGIN_ID) { let response: Response; - if (translated) response = await viaTranslation(request, url, route, config, candidate, text); + if (translated && route.format === "openai") response = await viaClaudeKey(request, 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); if (isLast || !RETRY_STATUSES.has(response.status)) return response; @@ -372,7 +400,7 @@ async function modelList(request: Request, url: URL, config: AppConfig): Promise // Anthropic SDKs and Claude Code get Anthropic's shape, as in CPA; everyone else OpenAI's. const anthropic = request.headers.has("anthropic-version") || (request.headers.get("user-agent") ?? "").startsWith("claude-cli"); if (!anthropic) { - const data = listed.filter((model) => model.kind !== "claude" && model.kind !== "gemini").map((model) => ({ id: model.id, object: "model", created: 0, owned_by: model.kind })); + const data = listed.filter((model) => model.kind !== "gemini").map((model) => ({ id: model.id, object: "model", created: 0, owned_by: model.kind })); return Response.json({ object: "list", data }, { headers }); } const extra = listed.filter((model) => model.kind === "claude").map((model) => ({ type: "model", id: model.id, display_name: model.id, created_at: "1970-01-01T00:00:00Z" })); diff --git a/free/src/openai-claude.ts b/free/src/openai-claude.ts new file mode 100644 index 0000000..bda58a0 --- /dev/null +++ b/free/src/openai-claude.ts @@ -0,0 +1,426 @@ +import { isObject, text, type EventTranslator, type Json } from "./claude-responses.ts"; + +// 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; +// 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 }; + +// Claude's tool names and IDs: letters, digits, _ and -, names up to 64 long. +export function claudeName(name: string): string { + if (name === "") return ""; + return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_"; +} + +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 { + return isObject(from.cache_control) ? { ...block, cache_control: from.cache_control } : block; +} + +function dataUrl(url: string): { mediaType: string; data: string } | null { + if (!url.startsWith("data:")) return null; + const comma = url.indexOf(","); + if (comma === -1) return null; + return { mediaType: url.slice(5, comma).split(";")[0] || "application/octet-stream", data: url.slice(comma + 1) }; +} + +function contentPart(part: Json): Json | null { + switch (part.type) { + case "text": + return text(part.text).trim() === "" ? null : { type: "text", text: text(part.text) }; + case "image_url": { + 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 "file": { + const inline = dataUrl(isObject(part.file) ? text(part.file.file_data) : ""); + return inline === null ? null : { type: "document", source: { type: "base64", media_type: inline.mediaType, data: inline.data } }; + } + default: + return null; + } +} + +function toolResultContent(content: unknown): string | Json[] { + if (typeof content === "string") return content; + if (isObject(content)) { + const part = contentPart(content); + return part === null ? JSON.stringify(content) : [part]; + } + if (!Array.isArray(content)) return ""; + return content.flatMap((item) => { + if (typeof item === "string") return item.trim() === "" ? [] : [{ type: "text", text: item }]; + const part = isObject(item) ? contentPart(item) : null; + return part === null ? [] : [part]; + }); +} + +// Structured output has no Claude field, so CPA asks for it in the system prompt. +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(); + if (type === "json_object") return plain; + if (type !== "json_schema") return ""; + const spec = isObject(format.json_schema) ? format.json_schema : {}; + const schema = spec.schema ?? format.schema; + if (schema === undefined) return plain; + const lines = ["You must format your entire response as valid JSON that conforms strictly to the following JSON schema:"]; + const name = text(spec.name).trim() || text(format.name).trim(); + if (name !== "") lines.push(`Schema Name: ${name}`); + const description = text(spec.description).trim() || text(format.description).trim(); + if (description !== "") lines.push(`Schema Description: ${description}`); + lines.push("JSON Schema:", JSON.stringify(schema), "Do not include any explanations, markdown code blocks (such as ```json), or any text outside of the JSON object."); + return lines.join("\n"); +} + +// Claude wants an object schema without a root-level union: object branches are merged in. +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 } : {}; + for (const [union, branches] of [["anyOf", anyOf], ["oneOf", oneOf], ["allOf", allOf]] as const) { + if (branches === undefined) continue; + for (const branch of Array.isArray(branches) ? branches.filter(isObject) : []) { + const type = branch.type; + if (type !== undefined && type !== "object" && !(Array.isArray(type) && type.includes("object"))) continue; + for (const [name, property] of Object.entries(isObject(branch.properties) ? branch.properties : {})) if (!Object.hasOwn(properties, name)) properties[name] = property; + if (union === "allOf" && Array.isArray(branch.required)) { + const required = Array.isArray(schema.required) ? schema.required : []; + schema.required = [...new Set([...required, ...branch.required])]; + } + } + } + return { ...schema, type: "object", properties }; +} + +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)) { + if (effort === "auto") return { thinking: { type: "adaptive" } }; + const level = effort === "minimal" ? "low" : effort === "xhigh" ? "max" : effort; + return ["low", "medium", "high", "max"].includes(level) ? { thinking: { type: "adaptive" }, output_config: { effort: level } } : null; + } + const budget = effort === "auto" ? 10000 : EFFORT_BUDGET[effort]; + if (budget === undefined) return null; + // A budget must stay under max_tokens and at 1024 or above. + const capped = Math.min(budget, maxTokens - 1); + return capped < 1024 ? null : { thinking: { type: "enabled", budget_tokens: capped } }; +} + +export interface ClaudeRequest { + readonly body: Json; + readonly stream: boolean; + // Claude tool name to the client's, for the response. + readonly toolNames: Map; +} + +export function chatToClaude(chat: Json, model: string): ClaudeRequest { + const toolNames = new Map(); + 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) { + last.content.push(...content); + last.toolUses.push(...toolUses); + } else turns.push({ role, content, toolUses }); + }; + const messages = Array.isArray(chat.messages) ? chat.messages.filter(isObject) : []; + // A repeated tool result keeps its first place and its last content, as in CPA. + const lastResult = new Map(); + for (const message of messages) if (message.role === "tool" && text(message.tool_call_id) !== "") lastResult.set(text(message.tool_call_id), message); + const emitted = new Set(); + + for (const message of messages) { + const content = message.content; + switch (message.role) { + case "system": + case "developer": { + const start = system.length; + if (typeof content === "string") { + if (content.trim() !== "") system.push(withCache({ type: "text", text: content }, message)); + } else if (Array.isArray(content)) { + for (const part of content.filter(isObject)) { + if (part.type === "text" && text(part.text).trim() !== "") system.push(withCache({ type: "text", text: text(part.text) }, part)); + } + const last = system.at(-1); + if (system.length > start && last !== undefined && !isObject(last.cache_control)) system[system.length - 1] = withCache(last, message); + } + break; + } + case "user": + case "assistant": { + const role = message.role; + const blocks: Json[] = []; + if (typeof content === "string") { + if (content.trim() !== "") blocks.push({ type: "text", text: content }); + } else if (Array.isArray(content)) { + for (const part of content.filter(isObject)) { + const block = contentPart(part); + if (block !== null) blocks.push(withCache(block, part)); + } + } + if (blocks.length > 0 && isObject(message.cache_control)) blocks[blocks.length - 1] = withCache(blocks.at(-1) as Json, message); + const toolUses: Json[] = []; + if (role === "assistant" && Array.isArray(message.tool_calls)) { + for (const call of message.tool_calls.filter(isObject)) { + if (call.type !== "function") continue; + const fn = isObject(call.function) ? call.function : {}; + let input: unknown; + try { + input = JSON.parse(text(fn.arguments) || "{}"); + } catch { + input = {}; + } + toolUses.push({ type: "tool_use", id: toolId(text(call.id)), name: nameOf(text(fn.name)), input: isObject(input) ? input : {} }); + } + } + append(role, blocks, toolUses); + break; + } + case "tool": { + const rawId = text(message.tool_call_id); + if (rawId !== "") { + if (emitted.has(rawId)) break; + emitted.add(rawId); + } + const target = lastResult.get(rawId) ?? message; + // Anthropic refuses cache_control inside a tool result, so it goes on the result itself. + const parts = Array.isArray(target.content) ? target.content.filter(isObject) : []; + const partCache = parts.find((part) => isObject(part.cache_control)); + const result = withCache({ type: "tool_result", tool_use_id: toolId(rawId), content: toolResultContent(target.content) }, isObject(target.cache_control) ? target : (partCache ?? {})); + append("user", [result]); + break; + } + default: + break; + } + } + + const instruction = formatInstruction(chat.response_format); + if (instruction !== "") system.push({ type: "text", text: instruction }); + // Claude's tool_use blocks close an assistant turn. + const claudeMessages = turns.map((turn) => ({ role: turn.role, content: [...turn.content, ...turn.toolUses] })); + if (claudeMessages.length === 0 && system.length > 0) claudeMessages.push({ role: "user", content: [{ type: "text", text: "" }] }); + + const maxTokens = typeof chat.max_tokens === "number" ? chat.max_tokens : typeof chat.max_completion_tokens === "number" ? chat.max_completion_tokens : /fable/i.test(model) ? FABLE_MAX_TOKENS : DEFAULT_MAX_TOKENS; + const stream = chat.stream === true; + const body: Json = { model, max_tokens: maxTokens, messages: claudeMessages, stream }; + if (system.length > 0) body.system = system; + if (typeof chat.top_p === "number") body.top_p = chat.top_p; + if (Array.isArray(chat.stop)) { + const stops = chat.stop.map(String); + if (stops.length > 0) body.stop_sequences = stops; + } else if (typeof chat.stop === "string") body.stop_sequences = [chat.stop]; + Object.assign(body, thinking(text(chat.reasoning_effort).trim().toLowerCase(), model, maxTokens)); + + // An allowed_tools choice narrows the tools sent, and then decides the choice. + const choice = chat.tool_choice; + let allowed: Set | null = null; + let allowedMode = "auto"; + if (isObject(choice) && choice.type === "allowed_tools") { + const spec = isObject(choice.allowed_tools) ? choice.allowed_tools : choice; + allowed = new Set(); + for (const tool of Array.isArray(spec.tools) ? spec.tools.filter(isObject) : []) { + const name = (isObject(tool.function) ? text(tool.function.name) : "").trim() || text(tool.name).trim(); + if (name !== "") allowed.add(name).add(claudeName(name)); + } + allowedMode = text(spec.mode).trim().toLowerCase() || "auto"; + } + const tools: Json[] = []; + for (const tool of Array.isArray(chat.tools) ? chat.tools.filter(isObject) : []) { + if (tool.type !== "function") continue; + const fn = isObject(tool.function) ? tool.function : {}; + const name = text(fn.name); + if (allowed !== null && !allowed.has(name) && !allowed.has(claudeName(name))) continue; + const declared: Json = { name: nameOf(name), description: text(fn.description), input_schema: inputSchema(fn.parameters ?? fn.parametersJsonSchema) }; + const strict = fn.strict ?? tool.strict; + if (typeof strict === "boolean") declared.strict = strict; + tools.push(withCache(declared, isObject(tool.cache_control) ? tool : fn)); + } + 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" || type === "any") body.tool_choice = { type: "any" }; + else if (type === "function" && isObject(choice)) { + const name = (isObject(choice.function) ? text(choice.function.name) : "") || text(choice.name); + body.tool_choice = name === "" ? { type: "none" } : { type: "tool", name: nameOf(name) }; + } + } + if (chat.parallel_tool_calls === false) { + if (isObject(body.tool_choice)) { + if (body.tool_choice.type !== "none") body.tool_choice = { ...body.tool_choice, disable_parallel_tool_use: true }; + } else if (tools.length > 0) body.tool_choice = { type: "auto", disable_parallel_tool_use: true }; + } + return { body, stream, toolNames }; +} + +function finishReason(stop: string): string { + if (stop === "tool_use") return "tool_calls"; + if (stop === "max_tokens" || stop === "model_context_window_exceeded") return "length"; + if (stop === "refusal" || stop === "sensitive") return "content_filter"; + return "stop"; +} + +interface Usage { + input: number; + output: number; + cacheWrite: number; + cacheRead: number; + thinking: number | null; +} + +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; + usage.output = number(raw.output_tokens) ?? usage.output; + usage.cacheWrite = number(raw.cache_creation_input_tokens) ?? usage.cacheWrite; + usage.cacheRead = number(raw.cache_read_input_tokens) ?? usage.cacheRead; + const details = isObject(raw.output_tokens_details) ? raw.output_tokens_details : {}; + usage.thinking = number(details.thinking_tokens) ?? usage.thinking; + return true; +} + +// Claude counts cached input apart; OpenAI counts it inside the prompt. +function openaiUsage(usage: Usage): Json { + const prompt = usage.input + usage.cacheWrite + usage.cacheRead; + const result: Json = { + prompt_tokens: prompt, + completion_tokens: usage.output, + total_tokens: prompt + usage.output, + prompt_tokens_details: { cached_tokens: usage.cacheRead, cached_creation_tokens: usage.cacheWrite, cache_write_tokens: usage.cacheWrite }, + }; + if (usage.thinking !== null) result.completion_tokens_details = { reasoning_tokens: usage.thinking }; + return result; +} + +function chunk(data: Json): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +// Turns Anthropic events into Chat Completions chunks. Text and thinking stream as they come; +// a tool call is sent whole once its block closes. +export class ChatStream implements EventTranslator { + private id = ""; + private created = Math.floor(Date.now() / 1000); + private readonly usage: Usage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, thinking: null }; + private hasUsage = false; + private finished = false; + private nextTool = 0; + private readonly tools = new Map(); + + constructor( + private readonly model: string, + private readonly toolNames: Map, + ) {} + + private delta(delta: Json, finish: string | null = null, usage?: Json): string { + const data: Json = { id: this.id, object: "chat.completion.chunk", created: this.created, model: this.model, choices: [{ index: 0, delta, finish_reason: finish }] }; + if (usage !== undefined) data.usage = usage; + return chunk(data); + } + + 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 : {}; + this.id = text(message.id); + this.hasUsage = mergeUsage(this.usage, message.usage) || this.hasUsage; + return this.delta({ role: "assistant" }); + } + case "content_block_start": { + const block = isObject(event.content_block) ? event.content_block : {}; + if (block.type === "tool_use") this.tools.set(index, { id: text(block.id), name: text(block.name), index: this.nextTool++, args: "" }); + return ""; + } + case "content_block_delta": + if (delta.type === "text_delta") return this.delta({ content: text(delta.text) }); + if (delta.type === "thinking_delta") return this.delta({ reasoning_content: text(delta.thinking) }); + if (delta.type === "input_json_delta") { + const tool = this.tools.get(index); + if (tool !== undefined) tool.args += text(delta.partial_json); + } + return ""; + case "content_block_stop": { + const tool = this.tools.get(index); + if (tool === undefined) return ""; + this.tools.delete(index); + return this.delta({ tool_calls: [{ index: tool.index, id: tool.id, type: "function", function: { name: this.toolNames.get(tool.name) ?? tool.name, arguments: tool.args || "{}" } }] }); + } + case "message_delta": { + const finish = typeof delta.stop_reason === "string" ? finishReason(delta.stop_reason) : null; + if (mergeUsage(this.usage, event.usage)) { + this.hasUsage = true; + return this.delta({}, finish, openaiUsage(this.usage)); + } + return this.delta({}, finish); + } + case "message_stop": + this.finished = true; + return (this.hasUsage ? chunk({ id: this.id, object: "chat.completion.chunk", created: this.created, model: this.model, choices: [], usage: openaiUsage(this.usage) }) : "") + "data: [DONE]\n\n"; + case "error": { + this.finished = true; + const error = isObject(event.error) ? event.error : {}; + return chunk({ error: { message: text(error.message), type: text(error.type) } }) + "data: [DONE]\n\n"; + } + default: + return ""; + } + } + + end(): string { + if (this.finished) return ""; + this.finished = true; + return "data: [DONE]\n\n"; + } +} + +// A whole Anthropic message as one chat completion, for a client that didn't stream. +export function chatCompletion(message: Json, model: string, toolNames: Map): Json { + const texts: string[] = []; + const reasoning: string[] = []; + const toolCalls: Json[] = []; + for (const block of Array.isArray(message.content) ? message.content.filter(isObject) : []) { + if (block.type === "text") texts.push(text(block.text)); + else if (block.type === "thinking") reasoning.push(text(block.thinking)); + else if (block.type === "tool_use") toolCalls.push({ id: text(block.id), type: "function", function: { name: toolNames.get(text(block.name)) ?? text(block.name), arguments: JSON.stringify(isObject(block.input) ? block.input : {}) } }); + } + const answer: Json = { role: "assistant", content: texts.join("") }; + if (reasoning.length > 0) answer.reasoning_content = reasoning.join(""); + if (toolCalls.length > 0) answer.tool_calls = toolCalls; + let finish = finishReason(text(message.stop_reason)); + if (toolCalls.length > 0 && finish !== "length") finish = "tool_calls"; + const usage: Usage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, thinking: null }; + const hasUsage = mergeUsage(usage, message.usage); + return { + id: text(message.id), + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: answer, finish_reason: finish }], + usage: hasUsage ? openaiUsage(usage) : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; +} diff --git a/free/test/app.test.ts b/free/test/app.test.ts index 1d3ff3b..3cc2f06 100644 --- a/free/test/app.test.ts +++ b/free/test/app.test.ts @@ -662,6 +662,39 @@ describe("Claude Code on ChatGPT logins and Codex keys", () => { expect(seen[2]?.url).not.toContain("generativelanguage"); }); + it("serves an OpenAI Chat client from a Claude API key, and explains that logins can't", async () => { + const claude = () => + new Response( + [ + { type: "message_start", message: { id: "msg_1", usage: { input_tokens: 4 } } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ] + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + const { config, seen, backend } = await providersSetup(async () => claude()); + const response = await handle(post("/v1/chat/completions", { model: "work/claude-sonnet-5-5", stream: true, messages: [{ role: "user", content: "hi" }] }, { "x-session": "s-1" }), config); + const text = await response.text(); + expect(text).toContain('"delta":{"content":"Hi"}'); + expect(text).toContain('"finish_reason":"stop"'); + expect(text.endsWith("data: [DONE]\n\n")).toBe(true); + 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(sent?.headers.get("anthropic-version")).toBe("2023-06-01"); + expect(await sent?.json()).toEqual({ model: "claude-sonnet-5-5", max_tokens: 32000, stream: true, messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }); + backend.table = { credentials: [], claudeOAuth: true, codexOAuth: false }; + config.cache.forgetConfig(); + const login = await handle(post("/v1/chat/completions", { model: "claude-sonnet-5-5", messages: [] }), config); + expect(login.status).toBe(400); + expect(await login.json()).toMatchObject({ error: { message: expect.stringMatching(/Claude logins serve Claude Code only/) } }); + expect(seen).toHaveLength(1); + }); + 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" } } } })); diff --git a/free/test/openai-claude.test.ts b/free/test/openai-claude.test.ts new file mode 100644 index 0000000..3b3df70 --- /dev/null +++ b/free/test/openai-claude.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { claudeEventStream } from "../src/claude-responses.ts"; +import { chatCompletion, ChatStream, chatToClaude, claudeName } from "../src/openai-claude.ts"; + +describe("Chat Completions to Claude", () => { + it("translates system, turns, tool calls and results, images and settings as CPA does", () => { + const { body, stream, toolNames } = chatToClaude( + { + model: "claude-sonnet-4-5", + stream: true, + max_completion_tokens: 4000, + top_p: 0.9, + stop: "END", + reasoning_effort: "high", + response_format: { type: "json_object" }, + parallel_tool_calls: false, + messages: [ + { role: "system", content: "Be brief." }, + { role: "developer", content: [{ type: "text", text: "Use tools.", cache_control: { type: "ephemeral" } }] }, + { role: "user", content: [{ type: "text", text: "hi" }, { type: "image_url", image_url: { url: "data:image/png;base64,iV" } }] }, + { role: "assistant", content: "Running.", tool_calls: [{ id: "call.1", type: "function", function: { name: "fs.read", arguments: '{"path":"a"}' } }, { id: "call_2", type: "function", function: { name: "bad", arguments: "{oops" } }] }, + { role: "tool", tool_call_id: "call.1", content: "old" }, + { role: "tool", tool_call_id: "call_2", content: [{ type: "text", text: "two" }] }, + { role: "tool", tool_call_id: "call.1", content: "new" }, + { role: "user", content: "next" }, + ], + tools: [{ type: "function", function: { name: "fs.read", description: "Read", parameters: { anyOf: [{ type: "object", properties: { path: { type: "string" } } }, { type: "string" }] } } }], + }, + "claude-sonnet-4-5", + ); + expect(stream).toBe(true); + expect(toolNames.get("fs_read")).toBe("fs.read"); + expect(body).toEqual({ + model: "claude-sonnet-4-5", + max_tokens: 4000, + stream: true, + top_p: 0.9, + stop_sequences: ["END"], + thinking: { type: "enabled", budget_tokens: 3999 }, + system: [ + { type: "text", text: "Be brief." }, + { type: "text", text: "Use tools.", cache_control: { type: "ephemeral" } }, + { type: "text", text: expect.stringContaining("valid JSON object") }, + ], + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "iV" } }] }, + { role: "assistant", content: [{ type: "text", text: "Running." }, { type: "tool_use", id: "call_1", name: "fs_read", input: { path: "a" } }, { type: "tool_use", id: "call_2", name: "bad", 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: "two" }] }, + { type: "text", text: "next" }, + ], + }, + ], + tools: [{ name: "fs_read", description: "Read", input_schema: { type: "object", properties: { path: { type: "string" } } } }], + tool_choice: { type: "auto", disable_parallel_tool_use: true }, + }); + }); + + it("thinks adaptively on newer models and maps tool choices", () => { + const base = { messages: [{ role: "user", content: "hi" }], tools: [{ type: "function", function: { name: "a" } }, { type: "function", function: { name: "b" } }] }; + const adaptive = chatToClaude({ ...base, reasoning_effort: "xhigh", tool_choice: "required" }, "claude-opus-4-7").body; + expect(adaptive).toMatchObject({ max_tokens: 32000, thinking: { type: "adaptive" }, output_config: { effort: "max" }, tool_choice: { type: "any" } }); + expect(chatToClaude({ ...base, reasoning_effort: "none" }, "claude-fable-5-1").body).toMatchObject({ max_tokens: 64000, thinking: { type: "disabled" } }); + const named = chatToClaude({ ...base, tool_choice: { type: "function", function: { name: "b" } } }, "claude-sonnet-5-5").body; + expect(named.tool_choice).toEqual({ type: "tool", name: "b" }); + const allowed = chatToClaude({ ...base, tool_choice: { type: "allowed_tools", allowed_tools: { mode: "required", tools: [{ type: "function", function: { name: "b" } }] } } }, "claude-sonnet-5-5").body; + expect(allowed).toMatchObject({ tools: [{ name: "b" }], tool_choice: { type: "any" } }); + expect(claudeName("mcp:server/tool")).toBe("mcp_server_tool"); + }); +}); + +function events(list: unknown[]): ReadableStream { + return new Response(list.map((event) => `event: x\ndata: ${JSON.stringify(event)}\n\n`).join("")).body as ReadableStream; +} + +describe("Claude stream to Chat Completions", () => { + it("streams text and reasoning, sends tool calls whole, then usage and [DONE]", async () => { + const out = await new Response( + claudeEventStream( + events([ + { type: "message_start", message: { id: "msg_1", usage: { input_tokens: 10, cache_read_input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hm" } }, + { type: "content_block_start", index: 1, content_block: { type: "text" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hi" } }, + { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "t1", name: "fs_read" } }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '{"pa' } }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: 'th":"a"}' } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 7 } }, + { type: "message_stop" }, + ]), + new ChatStream("gpt-ish", new Map([["fs_read", "fs.read"]])), + ), + ).text(); + const lines = out.split("\n\n").filter((line) => line !== ""); + expect(lines.at(-1)).toBe("data: [DONE]"); + const chunks = lines.slice(0, -1).map((line) => JSON.parse(line.slice(6)) as { choices: { delta: unknown; finish_reason: unknown }[]; usage?: unknown }); + expect(chunks.map((chunk) => chunk.choices[0]?.delta)).toEqual([ + { role: "assistant" }, + { reasoning_content: "hm" }, + { content: "Hi" }, + { tool_calls: [{ index: 0, id: "t1", type: "function", function: { name: "fs.read", arguments: '{"path":"a"}' } }] }, + {}, + undefined, + ]); + expect(chunks[4]?.choices[0]?.finish_reason).toBe("tool_calls"); + expect(chunks[5]?.usage).toEqual({ prompt_tokens: 15, completion_tokens: 7, total_tokens: 22, prompt_tokens_details: { cached_tokens: 5, cached_creation_tokens: 0, cache_write_tokens: 0 } }); + }); + + it("passes an error on and turns a whole message into a completion", async () => { + const out = await new Response(claudeEventStream(events([{ type: "error", error: { type: "overloaded_error", message: "busy" } }]), new ChatStream("m", new Map()))).text(); + expect(out).toBe('data: {"error":{"message":"busy","type":"overloaded_error"}}\n\ndata: [DONE]\n\n'); + const completion = chatCompletion( + { id: "msg_9", content: [{ type: "thinking", thinking: "a" }, { type: "text", text: "Hi" }, { type: "tool_use", id: "t", name: "fs_read", input: { p: 1 } }], stop_reason: "tool_use", usage: { input_tokens: 3, output_tokens: 2 } }, + "m", + new Map([["fs_read", "fs.read"]]), + ); + expect(completion).toMatchObject({ + id: "msg_9", + object: "chat.completion", + model: "m", + choices: [{ index: 0, message: { role: "assistant", content: "Hi", reasoning_content: "a", tool_calls: [{ id: "t", type: "function", function: { name: "fs.read", arguments: '{"p":1}' } }] }, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }); + }); +}); From 88abfb19f01ee9ec132a8f00661e2d652159bdca Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 12:17:38 +0000 Subject: [PATCH 3/6] Make the Deploy to Cloudflare buttons bigger Move each install's button out of the narrow table column into its own section, shown at 320 px wide, and bring the Workers-only description up to date. --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0db7430..4fc67fb 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,17 @@ Two ways to install. Both are free and open source, with the same dashboard; sub2api never charges you anything. Pick by the Cloudflare plan you already have: -| Install | Your Cloudflare plan | What runs | Deploy | -|---|---|---|---| -| **Container** | Workers Paid (Cloudflare bills $5/month; Containers need it) | The latest CPA release in a container, with its official dashboard | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PlaygrndLabs/sub2api) | -| **Workers-only** | Workers Free | Our container-free TypeScript rewrite of CPA with CPA's own dashboard (Claude subscriptions today, more CPA features coming) | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PlaygrndLabs/sub2api/tree/main/free) | +### Container + +For **Workers Paid** (Cloudflare bills $5/month; Containers need it). Runs the latest CPA release in a container, with its official dashboard. + +Deploy the Container install to Cloudflare + +### Workers-only + +For **Workers Free**. Runs our container-free TypeScript rewrite of CPA with CPA's own dashboard: Claude and ChatGPT logins, API keys for Claude, Gemini, Codex and OpenAI-compatible providers, and translation between their formats. See [free/README.md](free/README.md). + +Deploy the Workers-only install to Cloudflare ## Why From feb0ecac3423caa3ddbf3a857d46a18dd18ddc49 Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 12:18:25 +0000 Subject: [PATCH 4/6] Keep the install table; only enlarge its Deploy buttons --- README.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4fc67fb..80c8c95 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,10 @@ Two ways to install. Both are free and open source, with the same dashboard; sub2api never charges you anything. Pick by the Cloudflare plan you already have: -### Container - -For **Workers Paid** (Cloudflare bills $5/month; Containers need it). Runs the latest CPA release in a container, with its official dashboard. - -Deploy the Container install to Cloudflare - -### Workers-only - -For **Workers Free**. Runs our container-free TypeScript rewrite of CPA with CPA's own dashboard: Claude and ChatGPT logins, API keys for Claude, Gemini, Codex and OpenAI-compatible providers, and translation between their formats. See [free/README.md](free/README.md). - -Deploy the Workers-only install to Cloudflare +| Install | Your Cloudflare plan | What runs | Deploy | +|---|---|---|---| +| **Container** | Workers Paid (Cloudflare bills $5/month; Containers need it) | The latest CPA release in a container, with its official dashboard | Deploy to Cloudflare | +| **Workers-only** | Workers Free | Our container-free TypeScript rewrite of CPA with CPA's own dashboard (Claude and ChatGPT logins, API keys, format translation) | Deploy to Cloudflare | ## Why From 1c2efd2b90f29a6d9dfa95b780d7d99e3f726390 Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 12:46:49 +0000 Subject: [PATCH 5/6] Show usage counts and request logs on the Workers-only dashboard Record each API request in the Durable Object, batched a few seconds apart: success and failure counts per login file and API key (totals and CPA's twenty 10-minute buckets) and, while logging to file is on, a CPA style request line (time, status, latency, client IP, path, provider, model; never bodies, keys or the query). The dashboard's credential list, API-key usage and Logs pages read them back. API keys are stored only as hashes. --- free/README.md | 3 +- free/src/account.ts | 29 +++++-- free/src/app.ts | 98 +++++++++++++++++++-- free/src/index.ts | 3 +- free/src/management.ts | 47 ++++++++-- free/src/store.ts | 4 + free/src/usage.ts | 175 ++++++++++++++++++++++++++++++++++++++ free/src/vault.ts | 4 + free/test/account.test.ts | 2 +- free/test/app.test.ts | 48 ++++++++++- free/test/codex.test.ts | 2 +- free/test/usage.test.ts | 93 ++++++++++++++++++++ tsconfig.json | 3 +- tsconfig.node.json | 3 +- 14 files changed, 484 insertions(+), 30 deletions(-) create mode 100644 free/src/usage.ts create mode 100644 free/test/usage.test.ts diff --git a/free/README.md b/free/README.md index bde4200..e6729d7 100644 --- a/free/README.md +++ b/free/README.md @@ -16,11 +16,12 @@ It covers **Claude and ChatGPT (Codex) subscriptions** and **API keys** for Clau - 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. +- 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, model lists for ChatGPT logins, usage statistics and request 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: the other client translations (OpenAI Responses and Gemini clients to other providers), Gemini and other OAuth logins, model lists for ChatGPT logins, 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/account.ts b/free/src/account.ts index a2a3ad0..f133345 100644 --- a/free/src/account.ts +++ b/free/src/account.ts @@ -2,9 +2,11 @@ import { OAuthError, authorizeUrl, exchangeCode, parseCallback, randomToken, ref import * as codex from "./codex-oauth.ts"; import { Config } from "./config.ts"; import { upstreamCredentials, type RoutingTable, type UpstreamCredential } from "./providers.ts"; +import { storedKey, type UsageRecord } from "./usage.ts"; import { claudeCredential, claudeFileData, type CredentialFile, type LoginSession, type Vault } from "./vault.ts"; -export type TokenResult = { ok: true; accessToken: string; expiresAt: number; accountId?: string } | { ok: false; status: number; message: string }; +// `name` is the login file the token belongs to, for its usage counts. +export type TokenResult = { ok: true; accessToken: string; expiresAt: number; accountId?: string; name?: string } | { ok: false; status: number; message: string }; // The subscription logins the gateway signs in to and keeps fresh. export type LoginProvider = "claude" | "codex"; @@ -21,6 +23,7 @@ export interface Backend { attemptAllowed(client: string): Promise; manage(request: Request): Promise; routing(): Promise; + record(records: UsageRecord[]): Promise; } export const REFRESH_MARGIN_SECONDS = 5 * 60; @@ -113,7 +116,7 @@ export class Account { async tokenFor(file: CredentialFile, force = false): Promise { const current = loginOf(file); if (current === null) return { ok: false, status: 503, message: `The login ${file.name} has no tokens: sign in again.` }; - if (!force && current.expiresAt - this.nowSeconds() > REFRESH_MARGIN_SECONDS) return granted(current); + if (!force && current.expiresAt - this.nowSeconds() > REFRESH_MARGIN_SECONDS) return granted(current, file.name); let pending = this.refreshing.get(file.name); if (pending === undefined) { pending = this.refresh(file, current).finally(() => this.refreshing.delete(file.name)); @@ -135,6 +138,20 @@ export class Account { } } + // Counts every attempt; keeps log lines only while the dashboard's "logging to file" is on. + async record(records: UsageRecord[]): Promise { + const stored = await Promise.all(records.map(async (record) => ({ ...record, attempts: await Promise.all(record.attempts.map(async (attempt) => ({ ...attempt, key: await storedKey(attempt.key) }))) }))); + this.vault.usage.record(stored, this.loggingEnabled(), this.nowSeconds()); + } + + loggingEnabled(): boolean { + try { + return this.currentConfig().read(["observability", "logs", "logging-to-file"]) === true; + } catch { + return false; + } + } + async attemptAllowed(client: string): Promise { return this.vault.attemptAllowed(client, this.nowSeconds()); } @@ -201,21 +218,21 @@ export class Account { next = { ...refreshed, accountId: "" }; } this.vault.setCredentialStatus(file.name, false, "", this.nowSeconds()); - return granted(next); + return granted(next, file.name); } catch (error) { if (error instanceof OAuthError && (error.status === 400 || error.status === 401)) { this.vault.setCredentialStatus(file.name, false, "token expired", this.nowSeconds()); return { ok: false, status: 503, message: `The ${label} login has expired: sign in again under OAuth login in the dashboard.` }; } // A token that hasn't actually expired yet is still worth using. - if (current.expiresAt > this.nowSeconds()) return granted(current); + if (current.expiresAt > this.nowSeconds()) return granted(current, file.name); const reason = error instanceof OAuthError ? error.message : "the provider couldn't be reached"; return { ok: false, status: 503, message: `Couldn't refresh the ${label} login. ${reason}` }; } } } -function granted(login: Login): TokenResult { - const result = { ok: true as const, accessToken: login.accessToken, expiresAt: login.expiresAt }; +function granted(login: Login, name: string): TokenResult { + const result = { ok: true as const, accessToken: login.accessToken, expiresAt: login.expiresAt, name }; return login.accountId === "" ? result : { ...result, accountId: login.accountId }; } diff --git a/free/src/app.ts b/free/src/app.ts index 99ff2b1..9890113 100644 --- a/free/src/app.ts +++ b/free/src/app.ts @@ -8,6 +8,7 @@ import { MANAGEMENT_PREFIX } from "./management.ts"; import { loginPage, messagePage } from "./pages.ts"; import { candidates, inDefaultFamily, listedModels, NATIVE_FORMAT, ordered, type Candidate, type Format, type RoutingTable } from "./providers.ts"; import { CODEX_UPSTREAM, codexRequest, downstreamResponse, formatError, presentedKey, providerRequest, routeOf, upstreamRequest, UPSTREAM, type Route } from "./proxy.ts"; +import { apiKeyKey, loginKey, requestLine, type Attempt, type UsageRecord } from "./usage.ts"; import { SESSION_COOKIE, SESSION_SECONDS, issueSession, passwordMatches, readCookie, readSmallForm, sessionIsValid } from "./session.ts"; export const MIN_PASSWORD_LENGTH = 16; @@ -20,15 +21,20 @@ const MAX_FORM_BODY = 4096; // Workers accept request bodies up to 100 MB; a larger declared body is refused before reading. const MAX_BODY = 32 * 1024 * 1024; const PASSWORD_NOT_SET = `Sign-in is off: set the DASHBOARD_PASSWORD secret to at least ${MIN_PASSWORD_LENGTH} characters.`; +// Usage records wait this long in the isolate, so a burst of requests costs one Durable Object call. +const USAGE_FLUSH_MS = 5000; +const USAGE_FLUSH_COUNT = 50; const OAUTH_CALLBACK_PATHS = new Set(["/callback", "/auth/callback", "/oauth-callback", "/anthropic/callback", "/codex/callback"]); // Per-isolate memory, so most API calls skip the Durable Object round trip entirely. export class EdgeCache { readonly keys = new Map(); - token: { value: string; expiresAt: number; cachedUntil: number } | null = null; - codexToken: { value: string; accountId: string; expiresAt: number; cachedUntil: number } | null = null; + token: { value: string; name: string; expiresAt: number; cachedUntil: number } | null = null; + codexToken: { value: string; name: string; accountId: string; expiresAt: number; cachedUntil: number } | null = null; routing: { table: RoutingTable; until: number } | null = null; turn = 0; + usage: UsageRecord[] = []; + flushing = false; // A config or login change reaches this isolate at once and others within a minute. forgetConfig(): void { @@ -47,6 +53,9 @@ export interface AppConfig { readonly upstream: Fetcher; readonly panel: (request: Request) => Promise; readonly cache: EdgeCache; + // Keeps the isolate alive for work after the response, such as sending usage records. + readonly waitUntil?: (promise: Promise) => void; + readonly usageFlushMs?: number; } function apiError(status: number, type: string, message: string): Response { @@ -99,7 +108,7 @@ async function accessToken(config: AppConfig): Promise<{ token: string } | Respo if (cached !== null && cached.cachedUntil > now && cached.expiresAt - now > REFRESH_MARGIN_SECONDS) return { token: cached.value }; const result = await config.backend.token(); if (!result.ok) return apiError(result.status, "api_error", result.message); - config.cache.token = { value: result.accessToken, expiresAt: result.expiresAt, cachedUntil: now + TOKEN_CACHE_SECONDS }; + config.cache.token = { value: result.accessToken, name: result.name ?? "", expiresAt: result.expiresAt, cachedUntil: now + TOKEN_CACHE_SECONDS }; return { token: result.accessToken }; } @@ -123,7 +132,7 @@ async function codexAccess(config: AppConfig): Promise<{ token: string; accountI const result = await config.backend.codexToken(); if (!result.ok) return formatError("openai-response", result.status, "api_error", result.message); const accountId = result.accountId ?? ""; - config.cache.codexToken = { value: result.accessToken, accountId, expiresAt: result.expiresAt, cachedUntil: now + TOKEN_CACHE_SECONDS }; + config.cache.codexToken = { value: result.accessToken, name: result.name ?? "", accountId, expiresAt: result.expiresAt, cachedUntil: now + TOKEN_CACHE_SECONDS }; return { token: result.accessToken, accountId }; } @@ -331,11 +340,28 @@ function requestBody(text: string): RequestBody { return { text, model, withModel: (next) => JSON.stringify({ ...parsed, model: next }) }; } -async function proxied(request: Request, url: URL, route: Route & { format: Format }, config: AppConfig): Promise { +// What a request was served with, for its log line and the credentials' usage counts. +interface Trace { + readonly attempts: Attempt[]; + provider: string; + model: string; +} + +function noteAttempt(trace: Trace, config: AppConfig, candidate: Candidate, status: number): void { + trace.provider = candidate.credential.kind; + let key: string | null = apiKeyKey(candidate.credential); + if (candidate.credential.id === CLAUDE_LOGIN_ID) key = config.cache.token?.name ? loginKey(config.cache.token.name) : null; + else if (candidate.credential.id === CODEX_LOGIN_ID) key = config.cache.codexToken?.name ? loginKey(config.cache.codexToken.name) : null; + if (key !== null) trace.attempts.push({ key, ok: status < 400 }); +} + +async function proxied(request: Request, url: URL, route: Route & { format: Format }, config: AppConfig, trace: Trace): Promise { const table = await routingTable(config); // Claude clients with only Claude logins configured: stream the body through unread. if (route.format === "claude" && !table.codexOAuth && !table.credentials.some((credential) => servable("claude", credential.kind))) { - return viaClaudeLogin(request, url, config); + const response = await viaClaudeLogin(request, url, config); + noteAttempt(trace, config, loginCandidate("claude", ""), response.status); + return response; } const length = Number(request.headers.get("content-length") ?? "0"); if (Number.isFinite(length) && length > MAX_BODY) return formatError(route.format, 413, "invalid_request_error", "Request body too large."); @@ -343,6 +369,7 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form const parsed = route.format === "gemini" ? null : requestBody(text); const model = route.geminiModel ?? parsed?.model ?? null; if (model === null) return formatError(route.format, 400, "invalid_request_error", "The request needs a model."); + trace.model = model; const found = candidates(table, model); const usable = found.filter((candidate) => servable(route.format, candidate.credential.kind)); if (route.format === "claude" && table.claudeOAuth && /^claude-/i.test(model)) usable.push(loginCandidate("claude", model)); @@ -351,7 +378,11 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form // 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)); if (usable.length === 0) { - if (route.format === "claude" && table.claudeOAuth) return viaClaudeLogin(request, url, config, text); + if (route.format === "claude" && table.claudeOAuth) { + const response = await viaClaudeLogin(request, url, config, text); + noteAttempt(trace, config, loginCandidate("claude", model), response.status); + return response; + } // 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) @@ -374,12 +405,14 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form 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); + noteAttempt(trace, config, candidate, response.status); if (isLast || !RETRY_STATUSES.has(response.status)) return response; await response.body?.cancel(); last = new Response(null, { status: response.status }); continue; } const upstream = await config.upstream(providerRequest(request, url, route, candidate.credential, candidate.model, body)).catch(() => null); + noteAttempt(trace, config, candidate, upstream?.status ?? 502); if (upstream !== null && (isLast || !RETRY_STATUSES.has(upstream.status))) return downstreamResponse(upstream); if (upstream !== null) { await upstream.body?.cancel(); @@ -411,14 +444,63 @@ async function modelList(request: Request, url: URL, config: AppConfig): Promise return Response.json({ ...body, data: [...(body.data ?? []), ...extra] }, { headers }); } +// Sends the isolate's usage records to the Durable Object, a few seconds after the first one so +// that requests close together share one call. +function remember(config: AppConfig, record: UsageRecord): void { + const cache = config.cache; + if (config.waitUntil === undefined) return; + cache.usage.push(record); + const flush = async () => { + const batch = cache.usage.splice(0); + if (batch.length > 0) await config.backend.record(batch).catch(() => undefined); + }; + if (cache.usage.length >= USAGE_FLUSH_COUNT) { + config.waitUntil(flush()); + return; + } + if (cache.flushing) return; + cache.flushing = true; + config.waitUntil( + new Promise((resolve) => setTimeout(resolve, config.usageFlushMs ?? USAGE_FLUSH_MS)) + .then(flush) + .finally(() => { + cache.flushing = false; + }), + ); +} + +function requestId(): string { + return crypto.randomUUID().replaceAll("-", "").slice(0, 8); +} + async function api(request: Request, url: URL, route: Route, config: AppConfig): Promise { + const started = Date.now(); + const trace: Trace = { attempts: [], provider: "", model: route.geminiModel ?? "" }; + const response = await served(request, url, route, config, trace); + const line = requestLine({ + at: started, + requestId: requestId(), + status: response.status, + ms: Date.now() - started, + ip: request.headers.get("cf-connecting-ip") ?? "", + method: request.method, + // The query is left out: Gemini clients may put their key in it. + path: url.pathname, + provider: trace.provider, + model: trace.model, + }); + remember(config, { at: started, line, attempts: trace.attempts }); + return response; +} + +async function served(request: Request, url: URL, route: Route, config: AppConfig, trace: Trace): Promise { if (request.method !== route.method) return formatError(route.format, 405, "invalid_request_error", "Method not allowed."); const key = presentedKey(request, url); if (key === null || !(await keyValid(key, config))) { return formatError(route.format, 401, "authentication_error", "Invalid API key. Add one under API Keys in the gateway dashboard."); } if (route.format === "models") return modelList(request, url, config); - return proxied(request, url, route as Route & { format: Format }, config); + return proxied(request, url, route as Route & { format: Format }, config, trace); } async function signedIn(request: Request, config: AppConfig): Promise { diff --git a/free/src/index.ts b/free/src/index.ts index 768cfaa..c1125eb 100644 --- a/free/src/index.ts +++ b/free/src/index.ts @@ -7,7 +7,7 @@ const cache = new EdgeCache(); const nowSeconds = () => Math.floor(Date.now() / 1000); export default { - fetch(request, env) { + fetch(request, env, ctx) { return handle(request, { backend: env.STORE.getByName("main"), dashboardPassword: env.DASHBOARD_PASSWORD ?? "", @@ -16,6 +16,7 @@ export default { upstream: (upstream) => fetch(upstream), panel: (panelRequest) => env.ASSETS.fetch(new Request(new URL("/management.html", panelRequest.url))), cache, + waitUntil: (promise) => ctx.waitUntil(promise), }); }, } satisfies ExportedHandler; diff --git a/free/src/management.ts b/free/src/management.ts index 3bc4167..5d9cc1d 100644 --- a/free/src/management.ts +++ b/free/src/management.ts @@ -1,5 +1,6 @@ import { LOGIN_PROVIDERS, type Account, type LoginProvider } from "./account.ts"; import { Config, ConfigError, configTag, ifMatchSatisfied } from "./config.ts"; +import { apiKeyKey, apiKeyProvider, loginKey, storedKey, type UsageStats } from "./usage.ts"; import type { CredentialFile } from "./vault.ts"; // CPA's v8 management API (the subset the official dashboard uses), answered from the Durable @@ -67,7 +68,7 @@ function iso(seconds: number): string { return new Date(seconds * 1000).toISOString(); } -function credentialEntry(file: CredentialFile, nowSeconds: number): Record { +function credentialEntry(file: CredentialFile, nowSeconds: number, usage: UsageStats): Record { const expires = typeof file.data.expired === "string" ? Date.parse(file.data.expired) / 1000 : Number.NaN; const expired = Number.isFinite(expires) && expires <= nowSeconds && file.statusMessage === "token expired"; const entry: Record = { @@ -85,9 +86,9 @@ function credentialEntry(file: CredentialFile, nowSeconds: number): Record name === "" || file.name === name); const now = account.nowSeconds(); - const entries = files.map((file) => credentialEntry(file, now)); + const entries = files.map((file) => credentialEntry(file, now, account.vault.usage.stats(loginKey(file.name), now))); const observedAt = new Date().toISOString(); if (!url.searchParams.has("page") && !url.searchParams.has("page_size")) return json({ observed_at: observedAt, files: entries }); const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1); @@ -120,6 +121,35 @@ function listCredentials(url: URL, account: Account): Response { }); } +// CPA's API-key usage: per provider, keyed "base url|api key", for the keys configured now. +async function apiKeyUsage(account: Account): Promise { + const now = account.nowSeconds(); + const out: Record> = {}; + for (const credential of (await account.routing()).credentials) { + const provider = apiKeyProvider(credential); + const key = `${credential.baseUrl}|${credential.apiKey}`; + out[provider] ??= {}; + const group = out[provider]; + if (Object.hasOwn(group, key)) continue; + group[key] = account.vault.usage.stats(await storedKey(apiKeyKey(credential)), now); + } + return json(out); +} + +// The request log, read like CPA's log file: a tail, lines after a time, or after a cursor. +function logs(url: URL, account: Account): Response { + if (!account.loggingEnabled()) return failure(400, "logging to file disabled"); + const limitText = url.searchParams.get("limit")?.trim() ?? ""; + const limit = limitText === "" ? 0 : Number(limitText); + if (!Number.isSafeInteger(limit) || limit < 0 || (limitText !== "" && limit === 0)) return failure(400, "invalid limit: must be a positive integer"); + const afterText = url.searchParams.get("after")?.trim() ?? ""; + const after = /^\d+$/.test(afterText) ? Number(afterText) : 0; + const result = account.vault.usage.lines({ cursor: url.searchParams.get("cursor")?.trim() ?? "", after, limit }); + const body: Record = { lines: result.lines, "line-count": result.lines.length, "latest-timestamp": result.latest, "next-cursor": result.nextCursor }; + if (result.reset) body["cursor-reset"] = true; + return json(body); +} + function providerOf(name: string, data: Record): string | null { if (typeof data.type === "string" && data.type.trim() !== "") return data.type.trim().toLowerCase(); const prefix = name.split("-")[0]?.toLowerCase(); @@ -337,12 +367,15 @@ export async function handleManagement(request: Request, account: Account): Prom return json({ status: "ok", refreshed: refreshed.length, files: refreshed }); } case "GET observability/logs": + return logs(url, account); case "DELETE observability/logs": - return failure(400, "logging to file disabled"); + if (!account.loggingEnabled()) return failure(400, "logging to file disabled"); + account.vault.usage.clearLines(); + return json({ success: true, message: "Logs cleared successfully", removed: 0 }); case "GET observability/logs/errors": return json({ files: [] }); case "GET observability/usage/api-keys": - return json({}); + return apiKeyUsage(account); case "GET observability/usage/queue": return json([]); case "GET plugins": diff --git a/free/src/store.ts b/free/src/store.ts index a6d7142..5280ffa 100644 --- a/free/src/store.ts +++ b/free/src/store.ts @@ -3,6 +3,7 @@ import { Account, type Backend, type TokenResult } from "./account.ts"; import type { Env } from "./env.ts"; import { handleManagement } from "./management.ts"; import type { RoutingTable } from "./providers.ts"; +import type { UsageRecord } from "./usage.ts"; import { Vault } from "./vault.ts"; const nowSeconds = () => Math.floor(Date.now() / 1000); @@ -38,4 +39,7 @@ export class Store extends DurableObject implements Backend { routing(): Promise { return this.account.routing(); } + record(records: UsageRecord[]): Promise { + return this.account.record(records); + } } diff --git a/free/src/usage.ts b/free/src/usage.ts new file mode 100644 index 0000000..793af74 --- /dev/null +++ b/free/src/usage.ts @@ -0,0 +1,175 @@ +import type { UpstreamCredential } from "./providers.ts"; +import type { Sql } from "./vault.ts"; + +// Request counts per credential and request log lines, which CPA keeps in memory and in its log +// file and shows on the dashboard's Usage and Logs pages. Here they live in the Durable Object. + +// CPA's recent-request window: twenty 10-minute buckets. +export const BUCKET_SECONDS = 10 * 60; +export const BUCKET_COUNT = 20; +const KEPT_LINES = 2000; + +export interface Attempt { + // A login file ("file:") or an API key ("\n|"). + readonly key: string; + readonly ok: boolean; +} + +export interface UsageRecord { + readonly at: number; + readonly line: string; + readonly attempts: readonly Attempt[]; +} + +export interface RecentBucket { + readonly time: string; + readonly success: number; + readonly failed: number; +} + +export interface UsageStats { + readonly success: number; + readonly failed: number; + readonly recent_requests: RecentBucket[]; +} + +export function loginKey(name: string): string { + return `file:${name}`; +} + +// CPA groups API keys by provider, naming OpenAI-compatible ones by their entry's name. +export function apiKeyProvider(credential: UpstreamCredential): string { + return credential.kind === "openai-compatibility" ? credential.group.toLowerCase() || "openai-compatibility" : credential.kind; +} + +export function apiKeyKey(credential: UpstreamCredential): string { + return `${apiKeyProvider(credential)}\n${credential.baseUrl}|${credential.apiKey}`; +} + +// API keys are stored only as a hash, so a key removed from the config doesn't linger here. +export async function storedKey(key: string): Promise { + if (key.startsWith("file:")) return key; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)); + return `key:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +function pad(value: number, width = 2): string { + return String(value).padStart(width, "0"); +} + +function clock(date: Date): string { + return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; +} + +// Go's duration format, as CPA's request lines print latency. +function latency(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${Number(seconds.toFixed(3))}s`; + return `${Math.floor(seconds / 60)}m${Math.floor(seconds % 60)}s`; +} + +export interface RequestLine { + readonly at: number; + readonly requestId: string; + readonly status: number; + readonly ms: number; + readonly ip: string; + readonly method: string; + readonly path: string; + readonly provider: string; + readonly model: string; +} + +// CPA's line for a served request: "[time] [request id] [level] status | latency | ip | method path". +export function requestLine(request: RequestLine): string { + const date = new Date(request.at); + const time = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + const level = request.status >= 500 ? "error" : request.status >= 400 ? "warn " : "info "; + const fields = [request.provider === "" ? "" : ` provider=${request.provider}`, request.model === "" ? "" : ` model=${JSON.stringify(request.model)}`].join(""); + return `[${time}] [${request.requestId}] [${level}] ${request.status} | ${latency(request.ms).padStart(13)} | ${request.ip.padStart(15)} | ${request.method.padEnd(7)} ${JSON.stringify(request.path)}${fields}`; +} + +export class UsageStore { + constructor(private readonly sql: Sql) { + sql.exec("CREATE TABLE IF NOT EXISTS usage_totals (key TEXT PRIMARY KEY, success INTEGER NOT NULL, failed INTEGER NOT NULL)"); + sql.exec(`CREATE TABLE IF NOT EXISTS usage_buckets (key TEXT NOT NULL, bucket INTEGER NOT NULL, success INTEGER NOT NULL, failed INTEGER NOT NULL, + PRIMARY KEY (key, bucket))`); + sql.exec("CREATE TABLE IF NOT EXISTS request_log (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, line TEXT NOT NULL)"); + } + + record(records: readonly UsageRecord[], keepLines: boolean, nowSeconds: number): void { + for (const record of records) { + for (const attempt of record.attempts) { + const success = attempt.ok ? 1 : 0; + const failed = attempt.ok ? 0 : 1; + this.sql.exec( + "INSERT INTO usage_totals (key, success, failed) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET success = success + excluded.success, failed = failed + excluded.failed", + attempt.key, + success, + failed, + ); + this.sql.exec( + "INSERT INTO usage_buckets (key, bucket, success, failed) VALUES (?, ?, ?, ?) ON CONFLICT (key, bucket) DO UPDATE SET success = success + excluded.success, failed = failed + excluded.failed", + attempt.key, + Math.floor(record.at / 1000 / BUCKET_SECONDS), + success, + failed, + ); + } + if (keepLines) this.sql.exec("INSERT INTO request_log (at, line) VALUES (?, ?)", Math.floor(record.at / 1000), record.line); + } + this.sql.exec("DELETE FROM usage_buckets WHERE bucket <= ?", Math.floor(nowSeconds / BUCKET_SECONDS) - BUCKET_COUNT); + if (keepLines) this.sql.exec("DELETE FROM request_log WHERE id <= (SELECT MAX(id) FROM request_log) - ?", KEPT_LINES); + } + + stats(key: string, nowSeconds: number): UsageStats { + const total = this.sql.exec("SELECT success, failed FROM usage_totals WHERE key = ?", key).toArray()[0]; + const current = Math.floor(nowSeconds / BUCKET_SECONDS); + const counts = new Map(this.sql.exec("SELECT bucket, success, failed FROM usage_buckets WHERE key = ? AND bucket > ?", key, current - BUCKET_COUNT).toArray().map((row) => [Number(row.bucket), row])); + const recent: RecentBucket[] = []; + for (let bucket = current - BUCKET_COUNT + 1; bucket <= current; bucket += 1) { + const row = counts.get(bucket); + recent.push({ + time: `${clock(new Date(bucket * BUCKET_SECONDS * 1000))}-${clock(new Date((bucket + 1) * BUCKET_SECONDS * 1000))}`, + success: row === undefined ? 0 : Number(row.success), + failed: row === undefined ? 0 : Number(row.failed), + }); + } + return { success: total === undefined ? 0 : Number(total.success), failed: total === undefined ? 0 : Number(total.failed), recent_requests: recent }; + } + + // Log lines after a cursor (a line id) or a time, oldest first, keeping the newest `limit`. + lines(options: { cursor: string; after: number; limit: number }): { lines: string[]; latest: number; nextCursor: string; reset: boolean } { + const oldest = Number(this.sql.exec("SELECT MIN(id) AS id FROM request_log").toArray()[0]?.id ?? 0); + let reset = false; + let after = 0; + if (options.cursor !== "") { + after = /^\d+$/.test(options.cursor) ? Number(options.cursor) : -1; + // An unreadable cursor, or one for lines already dropped, starts again from the tail. + if (after < 0 || (oldest > 0 && after < oldest - 1)) { + reset = true; + after = 0; + } + } + const limit = options.limit > 0 ? options.limit : KEPT_LINES; + const rows = + after > 0 || options.cursor !== "" + ? this.sql.exec("SELECT id, at, line FROM (SELECT id, at, line FROM request_log WHERE id > ? ORDER BY id DESC LIMIT ?) ORDER BY id", after, limit).toArray() + : this.sql.exec("SELECT id, at, line FROM (SELECT id, at, line FROM request_log WHERE at > ? ORDER BY id DESC LIMIT ?) ORDER BY id", options.after, limit).toArray(); + const last = rows.at(-1); + const newest = this.sql.exec("SELECT MAX(id) AS id, MAX(at) AS at FROM request_log").toArray()[0]; + return { + lines: rows.map((row) => String(row.line)), + latest: Math.max(options.after, Number(last?.at ?? newest?.at ?? 0)), + nextCursor: String(last?.id ?? newest?.id ?? after), + reset, + }; + } + + clearLines(): number { + const count = Number(this.sql.exec("SELECT COUNT(*) AS count FROM request_log").toArray()[0]?.count ?? 0); + this.sql.exec("DELETE FROM request_log"); + return count; + } +} diff --git a/free/src/vault.ts b/free/src/vault.ts index 81577c6..e62e086 100644 --- a/free/src/vault.ts +++ b/free/src/vault.ts @@ -1,5 +1,6 @@ import type { Credential } from "./claude-oauth.ts"; import { DEFAULT_CONFIG } from "./config.ts"; +import { UsageStore } from "./usage.ts"; // The slice of Durable Object SQLite storage this file uses, so tests can back it with node:sqlite. export interface Sql { @@ -40,6 +41,8 @@ const ATTEMPT_WINDOW = 60; const ATTEMPT_LIMIT = 5; export class Vault { + readonly usage: UsageStore; + constructor(private readonly sql: Sql) { sql.exec(`CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, hash TEXT NOT NULL UNIQUE, hint TEXT NOT NULL, created_at INTEGER NOT NULL)`); @@ -55,6 +58,7 @@ export class Vault { sql.exec(`CREATE TABLE IF NOT EXISTS oauth_sessions ( state TEXT PRIMARY KEY, provider TEXT NOT NULL, verifier TEXT NOT NULL, created_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'wait', error TEXT NOT NULL DEFAULT '')`); + this.usage = new UsageStore(sql); this.migrateSingleLogin(); } diff --git a/free/test/account.test.ts b/free/test/account.test.ts index 2cc29ec..6ca53e1 100644 --- a/free/test/account.test.ts +++ b/free/test/account.test.ts @@ -105,7 +105,7 @@ describe("access token", () => { saveLogin(vault, "claude-a.json", "at-a", NOW + 3600); saveLogin(vault, "claude-b.json", "at-b", NOW + 3600); vault.setCredentialStatus("claude-b.json", true, "", NOW); - expect(await account.token()).toEqual({ ok: true, accessToken: "at-a", expiresAt: NOW + 3600 }); + expect(await account.token()).toEqual({ ok: true, accessToken: "at-a", expiresAt: NOW + 3600, name: "claude-a.json" }); expect(await account.token()).toMatchObject({ accessToken: "at-a" }); expect(bodies).toHaveLength(0); }); diff --git a/free/test/app.test.ts b/free/test/app.test.ts index 3cc2f06..1bad918 100644 --- a/free/test/app.test.ts +++ b/free/test/app.test.ts @@ -4,6 +4,7 @@ import { EdgeCache, handle, type AppConfig } from "../src/app.ts"; import { upstreamCredentials, type RoutingTable } from "../src/providers.ts"; import { OAUTH_BETA } from "../src/proxy.ts"; import { SESSION_COOKIE, issueSession } from "../src/session.ts"; +import type { UsageRecord } from "../src/usage.ts"; const PASSWORD = "correct-horse-battery-staple-0123456789"; const NOW = 1_800_000_000; @@ -47,12 +48,17 @@ class FakeBackend implements Backend { this.managed.push(request); return this.answer.clone(); } + recorded: UsageRecord[] = []; + async record(records: UsageRecord[]) { + this.recorded.push(...records); + } } async function setup(upstream: (request: Request) => Promise = async () => new Response("{}")) { const backend = new FakeBackend(); backend.hashes.add(await hashKey(KEY)); const seen: Request[] = []; + const later: Promise[] = []; const config: AppConfig = { backend, dashboardPassword: PASSWORD, @@ -64,8 +70,14 @@ async function setup(upstream: (request: Request) => Promise = async ( }, panel: async () => new Response("CPA panel", { headers: { "content-type": "text/html" } }), cache: new EdgeCache(), + waitUntil: (promise) => later.push(promise), + usageFlushMs: 0, + }; + // Waits for the work the gateway left running after its responses. + const settle = async () => { + while (later.length > 0) await later.shift(); }; - return { backend, seen, config }; + return { backend, seen, config, settle }; } function at(path: string, init: RequestInit = {}): Request { @@ -405,17 +417,47 @@ describe("management API", () => { it("forgets the cached token after a credential change", async () => { const { config } = await setup(); - config.cache.token = { value: "old", expiresAt: NOW + 3600, cachedUntil: NOW + 60 }; + config.cache.token = { value: "old", name: "claude-a.json", expiresAt: NOW + 3600, cachedUntil: NOW + 60 }; await handle(await managementCall("/v8/management/credentials?name=claude-a.json", { authorization: `Bearer ${MANAGEMENT_KEY}` }, { method: "DELETE" }), config); expect(config.cache.token).toBeNull(); }); }); +describe("usage records", () => { + it("logs each API request and counts the login or key that served it, without the query", async () => { + const { config, backend, settle } = await setup(async () => new Response("{}", { status: 200 })); + backend.tokenResult = { ok: true, accessToken: "access-1", expiresAt: NOW + 3600, name: "claude-me.json" }; + await handle(messages({ "x-api-key": KEY, "cf-connecting-ip": "1.2.3.4" }), config); + await handle(at(`/v1beta/models/gemini-2.5-flash:generateContent?key=${KEY}`, { method: "POST", body: "{}" }), config); + await handle(messages({ "x-api-key": "wrong" }), config); + await settle(); + expect(backend.recorded.map((record) => record.attempts)).toEqual([[{ key: "file:claude-me.json", ok: true }], [], []]); + const [served, gemini, refused] = backend.recorded.map((record) => record.line); + expect(served).toMatch(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] \[[0-9a-f]{8}\] \[info \] 200 \| +\d+ms \| +1\.2\.3\.4 \| POST +"\/v1\/messages" provider=claude$/); + expect(gemini).toContain('"/v1beta/models/gemini-2.5-flash:generateContent"'); + expect(gemini).not.toContain(KEY); + expect(refused).toContain("[warn ] 401"); + }); + + it("counts every attempt on API keys, including ones retried elsewhere", async () => { + let calls = 0; + const { config, backend, settle } = await providersSetup(async () => (calls++ === 0 ? new Response("busy", { status: 429 }) : Response.json({}))); + backend.table = { ...backend.table, claudeOAuth: false }; + await handle(post("/v1/chat/completions", { model: "kimi", messages: [] }), config); + await settle(); + expect(backend.recorded[0]?.attempts).toEqual([ + { key: expect.stringMatching(/^router\nhttps:\/\/router\.example\/api\/v1\|sk-router-[12]$/), ok: false }, + { key: expect.stringMatching(/^router\nhttps:\/\/router\.example\/api\/v1\|sk-router-[12]$/), ok: true }, + ]); + expect(backend.recorded[0]?.line).toContain('provider=openai-compatibility model="kimi"'); + }); +}); + describe("OAuth callback", () => { it("needs sign-in, then hands the code to the store", async () => { const { config, backend } = await setup(); expect((await handle(at("/callback?code=c&state=s"), config)).status).toBe(401); - config.cache.token = { value: "old", expiresAt: NOW + 3600, cachedUntil: NOW + 60 }; + config.cache.token = { value: "old", name: "claude-a.json", expiresAt: NOW + 3600, cachedUntil: NOW + 60 }; const done = await handle(at("/callback?code=c&state=s", { headers: { cookie: await cookie() } }), config); expect(await done.text()).toContain("Login received"); expect(backend.managed[0]?.url).toBe(`${ORIGIN}/v8/management/oauth/callback?code=c&state=s`); diff --git a/free/test/codex.test.ts b/free/test/codex.test.ts index c2d7b8f..56f7114 100644 --- a/free/test/codex.test.ts +++ b/free/test/codex.test.ts @@ -62,7 +62,7 @@ describe("ChatGPT (Codex) login", () => { const { account, bodies, vault } = setup([tokens("at-2")]); const near = codexFileData({ accessToken: "at-1", refreshToken: "rt-1", idToken: ID_TOKEN, accountId: "acct-123", email: "me@example.com", planType: "pro", expiresAt: NOW + 60 }); vault.saveCredentialFile("codex-me.json", "codex", near, NOW); - expect(await account.codexToken()).toEqual({ ok: true, accessToken: "at-2", expiresAt: NOW + 3600, accountId: "acct-123" }); + expect(await account.codexToken()).toEqual({ ok: true, accessToken: "at-2", expiresAt: NOW + 3600, accountId: "acct-123", name: "codex-me.json" }); expect(Object.fromEntries(bodies[0] ?? [])).toEqual({ client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token: "rt-1", scope: "openid profile email" }); expect(vault.credentialFile("codex-me.json")?.data).toMatchObject({ access_token: "at-2", refresh_token: "rt-1" }); // A Claude-only gateway still says to connect a ChatGPT account. diff --git a/free/test/usage.test.ts b/free/test/usage.test.ts new file mode 100644 index 0000000..b76e23e --- /dev/null +++ b/free/test/usage.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { Account } from "../src/account.ts"; +import { handleManagement, MANAGEMENT_PREFIX } from "../src/management.ts"; +import { BUCKET_SECONDS, requestLine, UsageStore } from "../src/usage.ts"; +import { Vault, claudeFileData } from "../src/vault.ts"; +import { memorySql } from "./sqlite.ts"; + +const NOW = 1_800_000_000; +const ORIGIN = "https://gateway.example"; +const LINE = { requestId: "a1b2c3d4", ms: 1234, ip: "1.2.3.4", method: "POST", path: "/v1/messages", provider: "claude", model: "claude-sonnet-5-5" }; + +describe("request lines", () => { + it("print as CPA's request log lines", () => { + expect(requestLine({ ...LINE, at: NOW * 1000, status: 200 })).toBe('[2027-01-15 08:00:00] [a1b2c3d4] [info ] 200 | 1.234s | 1.2.3.4 | POST "/v1/messages" provider=claude model="claude-sonnet-5-5"'); + expect(requestLine({ ...LINE, at: NOW * 1000, status: 429, ms: 87, provider: "", model: "" })).toBe('[2027-01-15 08:00:00] [a1b2c3d4] [warn ] 429 | 87ms | 1.2.3.4 | POST "/v1/messages"'); + expect(requestLine({ ...LINE, at: NOW * 1000, status: 502 })).toContain("[error] 502"); + }); +}); + +describe("usage store", () => { + it("counts attempts per credential in totals and 10-minute buckets", () => { + const store = new UsageStore(memorySql()); + const at = NOW * 1000; + store.record( + [ + { at, line: "a", attempts: [{ key: "file:x.json", ok: false }, { key: "file:y.json", ok: true }] }, + { at: at - BUCKET_SECONDS * 1000, line: "b", attempts: [{ key: "file:x.json", ok: true }] }, + { at: at - 30 * BUCKET_SECONDS * 1000, line: "c", attempts: [{ key: "file:x.json", ok: true }] }, + ], + false, + NOW, + ); + const stats = store.stats("file:x.json", NOW); + expect(stats.success).toBe(2); + expect(stats.failed).toBe(1); + expect(stats.recent_requests).toHaveLength(20); + expect(stats.recent_requests.at(-1)).toEqual({ time: "08:00-08:10", success: 0, failed: 1 }); + expect(stats.recent_requests.at(-2)).toEqual({ time: "07:50-08:00", success: 1, failed: 0 }); + expect(store.stats("file:none.json", NOW)).toMatchObject({ success: 0, failed: 0 }); + // Lines are kept only while logging is on. + expect(store.lines({ cursor: "", after: 0, limit: 0 }).lines).toEqual([]); + }); + + it("reads lines as a tail, after a time, or after a cursor, and resets a stale cursor", () => { + const store = new UsageStore(memorySql()); + const lines = Array.from({ length: 5 }, (_, index) => ({ at: (NOW + index) * 1000, line: `line ${index}`, attempts: [] })); + store.record(lines, true, NOW); + const tail = store.lines({ cursor: "", after: 0, limit: 2 }); + expect(tail).toEqual({ lines: ["line 3", "line 4"], latest: NOW + 4, nextCursor: "5", reset: false }); + expect(store.lines({ cursor: "", after: NOW + 2, limit: 0 }).lines).toEqual(["line 3", "line 4"]); + store.record([{ at: (NOW + 9) * 1000, line: "line 9", attempts: [] }], true, NOW); + expect(store.lines({ cursor: tail.nextCursor, after: 0, limit: 0 })).toEqual({ lines: ["line 9"], latest: NOW + 9, nextCursor: "6", reset: false }); + expect(store.lines({ cursor: "6", after: 0, limit: 0 })).toMatchObject({ lines: [], nextCursor: "6" }); + expect(store.clearLines()).toBe(6); + expect(store.lines({ cursor: "bogus", after: 0, limit: 0 })).toMatchObject({ lines: [], reset: true }); + }); +}); + +describe("usage on the dashboard", () => { + function setup() { + const sql = memorySql(); + const vault = new Vault(sql); + const account = new Account(vault, async () => new Response("unexpected", { status: 500 }), () => NOW); + const call = (method: string, path: string, body?: string) => handleManagement(new Request(`${ORIGIN}${MANAGEMENT_PREFIX}/${path}`, { method, ...(body === undefined ? {} : { body }) }), account); + return { sql, vault, account, call }; + } + + it("shows each login's counts and each configured key's usage", async () => { + const { sql, vault, account, call } = setup(); + vault.saveCredentialFile("claude-a.json", "claude", claudeFileData({ accessToken: "at", refreshToken: "rt", expiresAt: NOW + 3600, email: "a@example.com" }), NOW); + await call("PATCH", "config", JSON.stringify({ "api-keys": { gemini: [{ name: "g", keys: [{ "api-key": "AIza-1" }] }] } })); + await account.record([{ at: NOW * 1000, line: "x", attempts: [{ key: "file:claude-a.json", ok: true }, { key: "gemini\nhttps://generativelanguage.googleapis.com|AIza-1", ok: false }] }]); + const files = (await (await call("GET", "credentials")).json()) as { files: { success: number; failed: number; recent_requests: unknown[] }[] }; + expect(files.files[0]).toMatchObject({ success: 1, failed: 0 }); + expect(files.files[0]?.recent_requests).toHaveLength(20); + const usage = (await (await call("GET", "observability/usage/api-keys")).json()) as Record>; + expect(usage.gemini?.["https://generativelanguage.googleapis.com|AIza-1"]).toMatchObject({ success: 0, failed: 1 }); + // The key itself is stored only as a hash. + expect(JSON.stringify(sql.exec("SELECT key FROM usage_totals").toArray())).not.toContain("AIza-1"); + }); + + it("serves the request log only while logging to file is on", async () => { + const { account, call } = setup(); + await account.record([{ at: NOW * 1000, line: "dropped", attempts: [] }]); + expect((await call("GET", "observability/logs")).status).toBe(400); + await call("PATCH", "config", JSON.stringify({ observability: { logs: { "logging-to-file": true } } })); + await account.record([{ at: NOW * 1000, line: "kept", attempts: [] }]); + expect(await (await call("GET", "observability/logs?limit=10")).json()).toEqual({ lines: ["kept"], "line-count": 1, "latest-timestamp": NOW, "next-cursor": "1" }); + expect((await call("GET", "observability/logs?limit=0")).status).toBe(400); + expect(await (await call("DELETE", "observability/logs")).json()).toMatchObject({ success: true }); + expect(await (await call("GET", "observability/logs")).json()).toMatchObject({ lines: [] }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3d05fcc..0c660d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "free/test/sqlite.ts", "free/test/account.test.ts", "free/test/management.test.ts", - "free/test/codex.test.ts" + "free/test/codex.test.ts", + "free/test/usage.test.ts" ] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 4ee678a..2bc272d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -10,7 +10,8 @@ "free/test/sqlite.ts", "free/test/account.test.ts", "free/test/management.test.ts", - "free/test/codex.test.ts" + "free/test/codex.test.ts", + "free/test/usage.test.ts" ], "exclude": [] } From 564c3c39b882b0ad2383fd22705c78e999bb64a7 Mon Sep 17 00:00:00 2001 From: PlaygrndLabs Date: Wed, 28 Oct 2026 13:49:06 +0000 Subject: [PATCH 6/6] Let OpenAI Responses and Chat clients use any provider Responses requests are translated to Anthropic Messages (ported from CPA's Responses-to-Claude translator) and sent to a Claude key, or on through the existing Claude translators to Gemini, OpenAI-compatible and Codex keys and the ChatGPT login. Answers come back as Responses events or a whole response. Chat clients take the same route to the providers they couldn't reach before. --- free/README.md | 6 +- free/src/app.ts | 67 +++-- free/src/openai-claude.ts | 20 +- free/src/responses-claude.ts | 464 +++++++++++++++++++++++++++++ free/test/app.test.ts | 71 ++++- free/test/responses-claude.test.ts | 252 ++++++++++++++++ 6 files changed, 838 insertions(+), 42 deletions(-) create mode 100644 free/src/responses-claude.ts create mode 100644 free/test/responses-claude.test.ts diff --git a/free/README.md b/free/README.md index e6729d7..1a2bbe6 100644 --- a/free/README.md +++ b/free/README.md @@ -15,13 +15,15 @@ It covers **Claude and ChatGPT (Codex) subscriptions** and **API keys** for Clau - 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. - 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, model lists for ChatGPT logins, 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, model lists for ChatGPT logins, 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 9890113..78d3b18 100644 --- a/free/src/app.ts +++ b/free/src/app.ts @@ -2,8 +2,9 @@ import { hashKey, REFRESH_MARGIN_SECONDS, type Backend } from "./account.ts"; 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 { responsesResponse, ResponsesStream, responsesToClaude } from "./responses-claude.ts"; import { MANAGEMENT_PREFIX } from "./management.ts"; import { loginPage, messagePage } from "./pages.ts"; import { candidates, inDefaultFamily, listedModels, NATIVE_FORMAT, ordered, type Candidate, type Format, type RoutingTable } from "./providers.ts"; @@ -198,13 +199,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 (native === format) return true; - if (format === "claude") return native === "openai-response" || native === "openai" || native === "gemini"; - return format === "openai" && native === "claude"; + return NATIVE_FORMAT[kind] === format || format !== "gemini"; } function parsedObject(text: string): Record | null { @@ -232,6 +230,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); @@ -277,25 +280,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 (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. @@ -375,8 +386,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)); if (usable.length === 0) { if (route.format === "claude" && table.claudeOAuth) { const response = await viaClaudeLogin(request, url, config, text); @@ -385,8 +396,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.`; @@ -401,7 +412,7 @@ async function proxied(request: Request, url: URL, route: Route & { format: Form const translated = route.format !== NATIVE_FORMAT[candidate.credential.kind]; 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 1bad918..d9ba39e 100644 --- a/free/test/app.test.ts +++ b/free/test/app.test.ts @@ -309,8 +309,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); @@ -737,6 +737,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" } } } })); @@ -753,3 +762,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 }, + }); + }); +});