pr_01m47d15m3e54sn21z27rpy5n9/services/models/src/openai.ts

303 lines12,357 bytesCodeBlame
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
13type Json = Record<string, unknown>;
14
15type 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
22type AnthropicMessage = { role: "user" | "assistant"; content: string | AnthropicBlock[] };
23
24export 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
37type 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. */
44export type Dialect = {
45 /** OpenAI's own API: `max_completion_tokens`, and no temperature for its reasoning models. */
46 official: boolean;
47};
48
49function 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. */
59export 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
116const 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
124function 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. */
133export 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
155function 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 */
164export 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. */
285export 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. */
301export function estimateTokens(request: AnthropicRequest): number {
302 return Math.ceil(JSON.stringify({ system: request.system, messages: request.messages, tools: request.tools }).length / 4);
303}