| 1 | /** |
| 2 | * Speaking OpenAI's Chat Completions API on behalf of a harness that speaks |
| 3 | * Anthropic's Messages API. |
| 4 | * |
| 5 | * g1t's agents run Claude Code, which only speaks Anthropic's API. A |
| 6 | * workspace whose provider speaks OpenAI's (OpenAI itself, Gemini's |
| 7 | * compatible endpoint, OpenRouter, Groq, vLLM, Ollama…) still gets agents: |
| 8 | * the proxy turns each request into a chat completion and turns the answer, |
| 9 | * streamed or not, back into what Anthropic would have sent, tool calls |
| 10 | * included. |
| 11 | */ |
| 12 | |
| 13 | type Json = Record<string, unknown>; |
| 14 | |
| 15 | type AnthropicBlock = |
| 16 | | { type: "text"; text: string } |
| 17 | | { type: "image"; source: { type: "base64"; media_type: string; data: string } | { type: "url"; url: string } } |
| 18 | | { type: "tool_use"; id: string; name: string; input: unknown } |
| 19 | | { type: "tool_result"; tool_use_id: string; content?: string | AnthropicBlock[]; is_error?: boolean } |
| 20 | | { type: "thinking" | "redacted_thinking"; [key: string]: unknown }; |
| 21 | |
| 22 | type AnthropicMessage = { role: "user" | "assistant"; content: string | AnthropicBlock[] }; |
| 23 | |
| 24 | export type AnthropicRequest = { |
| 25 | model?: string; |
| 26 | system?: string | { type: "text"; text: string }[]; |
| 27 | messages: AnthropicMessage[]; |
| 28 | tools?: { name: string; description?: string; input_schema?: unknown; type?: string }[]; |
| 29 | tool_choice?: { type: "auto" | "any" | "tool" | "none"; name?: string }; |
| 30 | max_tokens?: number; |
| 31 | temperature?: number; |
| 32 | top_p?: number; |
| 33 | stop_sequences?: string[]; |
| 34 | stream?: boolean; |
| 35 | }; |
| 36 | |
| 37 | type ChatMessage = |
| 38 | | { role: "system"; content: string } |
| 39 | | { role: "user"; content: string | Json[] } |
| 40 | | { role: "assistant"; content: string | null; tool_calls?: Json[] } |
| 41 | | { role: "tool"; tool_call_id: string; content: string }; |
| 42 | |
| 43 | /** How the provider wants the request shaped. */ |
| 44 | export type Dialect = { |
| 45 | /** OpenAI's own API: `max_completion_tokens`, and no temperature for its reasoning models. */ |
| 46 | official: boolean; |
| 47 | }; |
| 48 | |
| 49 | function text(content: string | AnthropicBlock[] | undefined): string { |
| 50 | if (content == null) return ""; |
| 51 | if (typeof content === "string") return content; |
| 52 | return content |
| 53 | .map((block) => (block.type === "text" ? block.text : "")) |
| 54 | .filter(Boolean) |
| 55 | .join("\n"); |
| 56 | } |
| 57 | |
| 58 | /** A chat completion request saying what the Anthropic request said. */ |
| 59 | export function toChat(request: AnthropicRequest, model: string, dialect: Dialect): Json { |
| 60 | const messages: ChatMessage[] = []; |
| 61 | const system = typeof request.system === "string" ? request.system : text(request.system as AnthropicBlock[] | undefined); |
| 62 | if (system) messages.push({ role: "system", content: system }); |
| 63 | |
| 64 | for (const message of request.messages) { |
| 65 | const blocks: AnthropicBlock[] = |
| 66 | typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; |
| 67 | if (message.role === "assistant") { |
| 68 | const said = blocks.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join(""); |
| 69 | const calls = blocks |
| 70 | .filter((b): b is Extract<AnthropicBlock, { type: "tool_use" }> => b.type === "tool_use") |
| 71 | .map((b) => ({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } })); |
| 72 | messages.push({ role: "assistant", content: said || null, ...(calls.length ? { tool_calls: calls } : {}) }); |
| 73 | continue; |
| 74 | } |
| 75 | // Tool results answer the assistant's calls, so they go first, each as |
| 76 | // its own message; whatever else the user said follows. |
| 77 | for (const block of blocks) { |
| 78 | if (block.type !== "tool_result") continue; |
| 79 | const result = text(block.content); |
| 80 | messages.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.is_error ? `Error: ${result}` : result }); |
| 81 | } |
| 82 | const parts: Json[] = []; |
| 83 | for (const block of blocks) { |
| 84 | if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text }); |
| 85 | if (block.type === "image") { |
| 86 | const url = block.source.type === "base64" ? `data:${block.source.media_type};base64,${block.source.data}` : block.source.url; |
| 87 | parts.push({ type: "image_url", image_url: { url } }); |
| 88 | } |
| 89 | } |
| 90 | if (parts.length === 1 && parts[0].type === "text") messages.push({ role: "user", content: parts[0].text as string }); |
| 91 | else if (parts.length > 0) messages.push({ role: "user", content: parts }); |
| 92 | } |
| 93 | |
| 94 | const body: Json = { model, messages, stream: request.stream === true }; |
| 95 | if (request.stream) body.stream_options = { include_usage: true }; |
| 96 | if (request.max_tokens) body[dialect.official ? "max_completion_tokens" : "max_tokens"] = request.max_tokens; |
| 97 | if (!dialect.official && request.temperature != null) body.temperature = request.temperature; |
| 98 | if (!dialect.official && request.top_p != null) body.top_p = request.top_p; |
| 99 | if (request.stop_sequences?.length) body.stop = request.stop_sequences.slice(0, 4); |
| 100 | // Anthropic's own server tools (web search and the like) have no |
| 101 | // counterpart; functions do. |
| 102 | const tools = (request.tools ?? []).filter((tool) => tool.input_schema != null); |
| 103 | if (tools.length) { |
| 104 | body.tools = tools.map((tool) => ({ |
| 105 | type: "function", |
| 106 | function: { name: tool.name, description: tool.description ?? "", parameters: tool.input_schema }, |
| 107 | })); |
| 108 | const choice = request.tool_choice; |
| 109 | if (choice?.type === "any") body.tool_choice = "required"; |
| 110 | else if (choice?.type === "tool" && choice.name) body.tool_choice = { type: "function", function: { name: choice.name } }; |
| 111 | else if (choice?.type === "none") body.tool_choice = "none"; |
| 112 | } |
| 113 | return body; |
| 114 | } |
| 115 | |
| 116 | const STOP: Record<string, string> = { |
| 117 | stop: "end_turn", |
| 118 | length: "max_tokens", |
| 119 | tool_calls: "tool_use", |
| 120 | function_call: "tool_use", |
| 121 | content_filter: "end_turn", |
| 122 | }; |
| 123 | |
| 124 | function parseArguments(raw: string): unknown { |
| 125 | try { |
| 126 | return raw ? JSON.parse(raw) : {}; |
| 127 | } catch { |
| 128 | return {}; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /** An Anthropic message saying what a (non-streamed) chat completion said. */ |
| 133 | export function fromChat(completion: Json, model: string): Json { |
| 134 | const choice = ((completion.choices as Json[] | undefined) ?? [])[0] ?? {}; |
| 135 | const message = (choice.message as Json | undefined) ?? {}; |
| 136 | const content: Json[] = []; |
| 137 | if (typeof message.content === "string" && message.content) content.push({ type: "text", text: message.content }); |
| 138 | for (const call of (message.tool_calls as Json[] | undefined) ?? []) { |
| 139 | const fn = call.function as Json; |
| 140 | content.push({ type: "tool_use", id: call.id, name: fn.name, input: parseArguments(String(fn.arguments ?? "")) }); |
| 141 | } |
| 142 | const usage = (completion.usage as Json | undefined) ?? {}; |
| 143 | return { |
| 144 | id: `msg_${String(completion.id ?? crypto.randomUUID()).replace(/[^A-Za-z0-9_-]/g, "")}`, |
| 145 | type: "message", |
| 146 | role: "assistant", |
| 147 | model, |
| 148 | content, |
| 149 | stop_reason: STOP[String(choice.finish_reason)] ?? "end_turn", |
| 150 | stop_sequence: null, |
| 151 | usage: { input_tokens: Number(usage.prompt_tokens ?? 0), output_tokens: Number(usage.completion_tokens ?? 0) }, |
| 152 | }; |
| 153 | } |
| 154 | |
| 155 | function event(name: string, data: Json): string { |
| 156 | return `event: ${name}\ndata: ${JSON.stringify({ type: name, ...data })}\n\n`; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Turns a chat completion's server-sent events into the events Anthropic's |
| 161 | * API streams: a message, its content blocks one at a time (text, or a tool |
| 162 | * call whose input arrives in pieces), then why it stopped. |
| 163 | */ |
| 164 | export class StreamTranslator { |
| 165 | private started = false; |
| 166 | private open: { kind: "text" } | { kind: "tool"; slot: number } | null = null; |
| 167 | private index = -1; |
| 168 | private stopReason = "end_turn"; |
| 169 | private inputTokens = 0; |
| 170 | private outputTokens = 0; |
| 171 | private buffer = ""; |
| 172 | private finished = false; |
| 173 | |
| 174 | private readonly model: string; |
| 175 | |
| 176 | constructor(model: string) { |
| 177 | this.model = model; |
| 178 | } |
| 179 | |
| 180 | private start(id: string): string { |
| 181 | if (this.started) return ""; |
| 182 | this.started = true; |
| 183 | return event("message_start", { |
| 184 | message: { |
| 185 | id: `msg_${id.replace(/[^A-Za-z0-9_-]/g, "") || crypto.randomUUID()}`, |
| 186 | type: "message", |
| 187 | role: "assistant", |
| 188 | model: this.model, |
| 189 | content: [], |
| 190 | stop_reason: null, |
| 191 | stop_sequence: null, |
| 192 | usage: { input_tokens: 0, output_tokens: 0 }, |
| 193 | }, |
| 194 | }); |
| 195 | } |
| 196 | |
| 197 | private close(): string { |
| 198 | if (!this.open) return ""; |
| 199 | this.open = null; |
| 200 | return event("content_block_stop", { index: this.index }); |
| 201 | } |
| 202 | |
| 203 | private chunk(data: Json): string { |
| 204 | let out = this.start(String(data.id ?? "")); |
| 205 | const usage = data.usage as Json | undefined; |
| 206 | if (usage) { |
| 207 | this.inputTokens = Number(usage.prompt_tokens ?? this.inputTokens); |
| 208 | this.outputTokens = Number(usage.completion_tokens ?? this.outputTokens); |
| 209 | } |
| 210 | for (const choice of (data.choices as Json[] | undefined) ?? []) { |
| 211 | const delta = (choice.delta as Json | undefined) ?? {}; |
| 212 | if (typeof delta.content === "string" && delta.content) { |
| 213 | if (this.open?.kind !== "text") { |
| 214 | out += this.close(); |
| 215 | this.index += 1; |
| 216 | this.open = { kind: "text" }; |
| 217 | out += event("content_block_start", { index: this.index, content_block: { type: "text", text: "" } }); |
| 218 | } |
| 219 | out += event("content_block_delta", { index: this.index, delta: { type: "text_delta", text: delta.content } }); |
| 220 | } |
| 221 | for (const call of (delta.tool_calls as Json[] | undefined) ?? []) { |
| 222 | const slot = Number(call.index ?? 0); |
| 223 | const fn = (call.function as Json | undefined) ?? {}; |
| 224 | if (!(this.open?.kind === "tool" && this.open.slot === slot)) { |
| 225 | out += this.close(); |
| 226 | this.index += 1; |
| 227 | this.open = { kind: "tool", slot }; |
| 228 | out += event("content_block_start", { |
| 229 | index: this.index, |
| 230 | content_block: { type: "tool_use", id: String(call.id ?? `call_${this.index}`), name: String(fn.name ?? ""), input: {} }, |
| 231 | }); |
| 232 | } |
| 233 | if (typeof fn.arguments === "string" && fn.arguments) { |
| 234 | out += event("content_block_delta", { |
| 235 | index: this.index, |
| 236 | delta: { type: "input_json_delta", partial_json: fn.arguments }, |
| 237 | }); |
| 238 | } |
| 239 | } |
| 240 | if (choice.finish_reason) this.stopReason = STOP[String(choice.finish_reason)] ?? "end_turn"; |
| 241 | } |
| 242 | return out; |
| 243 | } |
| 244 | |
| 245 | /** Takes raw bytes of the upstream stream; returns Anthropic events to send. */ |
| 246 | push(piece: string): string { |
| 247 | this.buffer += piece; |
| 248 | let out = ""; |
| 249 | let at: number; |
| 250 | while ((at = this.buffer.indexOf("\n")) >= 0) { |
| 251 | const line = this.buffer.slice(0, at).trim(); |
| 252 | this.buffer = this.buffer.slice(at + 1); |
| 253 | if (!line.startsWith("data:")) continue; |
| 254 | const payload = line.slice(5).trim(); |
| 255 | if (payload === "[DONE]") { |
| 256 | out += this.finish(); |
| 257 | continue; |
| 258 | } |
| 259 | try { |
| 260 | out += this.chunk(JSON.parse(payload) as Json); |
| 261 | } catch { |
| 262 | // A line that is not JSON carries nothing to translate. |
| 263 | } |
| 264 | } |
| 265 | return out; |
| 266 | } |
| 267 | |
| 268 | /** The closing events, once. */ |
| 269 | finish(): string { |
| 270 | if (this.finished) return ""; |
| 271 | this.finished = true; |
| 272 | return ( |
| 273 | this.start("") + |
| 274 | this.close() + |
| 275 | event("message_delta", { |
| 276 | delta: { stop_reason: this.stopReason, stop_sequence: null }, |
| 277 | usage: { input_tokens: this.inputTokens, output_tokens: this.outputTokens }, |
| 278 | }) + |
| 279 | event("message_stop", {}) |
| 280 | ); |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /** A provider's error, in the shape Anthropic's API uses. */ |
| 285 | export function errorFromChat(status: number, body: string): Json { |
| 286 | let message = body.slice(0, 500); |
| 287 | try { |
| 288 | const parsed = JSON.parse(body) as Json; |
| 289 | const error = (Array.isArray(parsed) ? parsed[0]?.error : parsed.error) as Json | string | undefined; |
| 290 | if (typeof error === "string") message = error; |
| 291 | else if (error && typeof error.message === "string") message = error.message; |
| 292 | } catch { |
| 293 | // Not JSON: keep the text. |
| 294 | } |
| 295 | const type = |
| 296 | status === 401 ? "authentication_error" : status === 429 ? "rate_limit_error" : status === 404 ? "not_found_error" : status >= 500 ? "api_error" : "invalid_request_error"; |
| 297 | return { type: "error", error: { type, message: `The provider said: ${message}` } }; |
| 298 | } |
| 299 | |
| 300 | /** A rough count, for the harness's token counting, which chat APIs lack. */ |
| 301 | export function estimateTokens(request: AnthropicRequest): number { |
| 302 | return Math.ceil(JSON.stringify({ system: request.system, messages: request.messages, tools: request.tools }).length / 4); |
| 303 | } |