@fusionkit/model-gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/acp-agent.d.ts +39 -0
  2. package/dist/acp-agent.js +143 -0
  3. package/dist/acp-registry.d.ts +36 -0
  4. package/dist/acp-registry.js +85 -0
  5. package/dist/adapters/anthropic.d.ts +111 -0
  6. package/dist/adapters/anthropic.js +446 -0
  7. package/dist/adapters/chat.d.ts +14 -0
  8. package/dist/adapters/chat.js +34 -0
  9. package/dist/adapters/responses.d.ts +94 -0
  10. package/dist/adapters/responses.js +438 -0
  11. package/dist/backend.d.ts +52 -0
  12. package/dist/backend.js +57 -0
  13. package/dist/config.d.ts +22 -0
  14. package/dist/config.js +47 -0
  15. package/dist/front-door-acceptance.d.ts +41 -0
  16. package/dist/front-door-acceptance.js +219 -0
  17. package/dist/fusion-backend.d.ts +96 -0
  18. package/dist/fusion-backend.js +521 -0
  19. package/dist/fusion-gateway.d.ts +69 -0
  20. package/dist/fusion-gateway.js +355 -0
  21. package/dist/index.d.ts +40 -0
  22. package/dist/index.js +28 -0
  23. package/dist/mlx-backend.d.ts +42 -0
  24. package/dist/mlx-backend.js +71 -0
  25. package/dist/provenance.d.ts +29 -0
  26. package/dist/provenance.js +182 -0
  27. package/dist/server.d.ts +27 -0
  28. package/dist/server.js +234 -0
  29. package/dist/test/acp-agent.test.d.ts +1 -0
  30. package/dist/test/acp-agent.test.js +66 -0
  31. package/dist/test/acp-registry.test.d.ts +1 -0
  32. package/dist/test/acp-registry.test.js +70 -0
  33. package/dist/test/anthropic.test.d.ts +1 -0
  34. package/dist/test/anthropic.test.js +251 -0
  35. package/dist/test/chat.test.d.ts +1 -0
  36. package/dist/test/chat.test.js +270 -0
  37. package/dist/test/front-door-acceptance.test.d.ts +1 -0
  38. package/dist/test/front-door-acceptance.test.js +94 -0
  39. package/dist/test/fusion-backend-trace.test.d.ts +1 -0
  40. package/dist/test/fusion-backend-trace.test.js +107 -0
  41. package/dist/test/fusion-backend.test.d.ts +1 -0
  42. package/dist/test/fusion-backend.test.js +193 -0
  43. package/dist/test/fusion-gateway.test.d.ts +1 -0
  44. package/dist/test/fusion-gateway.test.js +107 -0
  45. package/dist/test/responses.test.d.ts +1 -0
  46. package/dist/test/responses.test.js +157 -0
  47. package/package.json +31 -0
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Anthropic Messages adapter. Claude Code speaks the Anthropic Messages API to
3
+ * whatever `ANTHROPIC_BASE_URL` points at, so to back it with a local model we
4
+ * translate `/v1/messages` (and `/v1/messages/count_tokens`, and the
5
+ * `/v1/models` discovery probe) to and from the gateway's OpenAI Chat
6
+ * Completions core. The pure translation functions are exported for testing;
7
+ * the request handler wires them to a `Backend` and returns a `Response` the
8
+ * server pipes straight to the client (JSON or SSE).
9
+ */
10
+ const ENCODER = new TextEncoder();
11
+ // ---- request translation ----
12
+ function randomId() {
13
+ return Math.random().toString(36).slice(2, 12);
14
+ }
15
+ function systemText(system) {
16
+ if (system === undefined)
17
+ return "";
18
+ if (typeof system === "string")
19
+ return system;
20
+ return system.map((block) => block.text).join("\n");
21
+ }
22
+ function blockText(content) {
23
+ if (content === undefined)
24
+ return "";
25
+ if (typeof content === "string")
26
+ return content;
27
+ return content
28
+ .map((block) => (block.type === "text" ? block.text : ""))
29
+ .join("");
30
+ }
31
+ function mapToolChoice(choice) {
32
+ switch (choice.type) {
33
+ case "auto":
34
+ return "auto";
35
+ case "any":
36
+ return "required";
37
+ case "tool":
38
+ return { type: "function", function: { name: choice.name ?? "" } };
39
+ default: {
40
+ const unreachable = choice.type;
41
+ return unreachable;
42
+ }
43
+ }
44
+ }
45
+ /**
46
+ * Translate an Anthropic Messages request to an OpenAI Chat Completions body.
47
+ * The upstream model is always the backend's own model (Claude Code sends a
48
+ * `claude-*` id the local server would not recognise); the requested id is
49
+ * only echoed back in the response.
50
+ */
51
+ export function anthropicToChat(body, backendModel) {
52
+ const messages = [];
53
+ const system = systemText(body.system);
54
+ if (system.length > 0)
55
+ messages.push({ role: "system", content: system });
56
+ for (const message of body.messages) {
57
+ if (typeof message.content === "string") {
58
+ messages.push({ role: message.role, content: message.content });
59
+ continue;
60
+ }
61
+ const textParts = [];
62
+ const imageParts = [];
63
+ const toolCalls = [];
64
+ const toolResults = [];
65
+ for (const block of message.content) {
66
+ switch (block.type) {
67
+ case "text":
68
+ textParts.push(block.text);
69
+ break;
70
+ case "image": {
71
+ const source = block.source;
72
+ imageParts.push({
73
+ type: "image_url",
74
+ image_url: { url: `data:${source.media_type};base64,${source.data}` }
75
+ });
76
+ break;
77
+ }
78
+ case "tool_use": {
79
+ const tool = block;
80
+ toolCalls.push({
81
+ id: tool.id,
82
+ type: "function",
83
+ function: { name: tool.name, arguments: JSON.stringify(tool.input ?? {}) }
84
+ });
85
+ break;
86
+ }
87
+ case "tool_result": {
88
+ const result = block;
89
+ toolResults.push({ id: result.tool_use_id, content: blockText(result.content) });
90
+ break;
91
+ }
92
+ default:
93
+ break;
94
+ }
95
+ }
96
+ if (message.role === "assistant") {
97
+ const text = textParts.join("");
98
+ const assistant = { role: "assistant", content: text.length > 0 ? text : null };
99
+ if (toolCalls.length > 0)
100
+ assistant.tool_calls = toolCalls;
101
+ messages.push(assistant);
102
+ continue;
103
+ }
104
+ // user turn: tool results become standalone tool messages; remaining
105
+ // text/images become a user message.
106
+ for (const result of toolResults) {
107
+ messages.push({ role: "tool", tool_call_id: result.id, content: result.content });
108
+ }
109
+ const text = textParts.join("");
110
+ if (imageParts.length > 0) {
111
+ const parts = [];
112
+ if (text.length > 0)
113
+ parts.push({ type: "text", text });
114
+ parts.push(...imageParts);
115
+ messages.push({ role: "user", content: parts });
116
+ }
117
+ else if (text.length > 0 || toolResults.length === 0) {
118
+ messages.push({ role: "user", content: text });
119
+ }
120
+ }
121
+ const chat = {
122
+ model: backendModel ?? body.model ?? "",
123
+ messages,
124
+ stream: body.stream === true
125
+ };
126
+ if (typeof body.max_tokens === "number")
127
+ chat.max_tokens = body.max_tokens;
128
+ if (typeof body.temperature === "number")
129
+ chat.temperature = body.temperature;
130
+ if (typeof body.top_p === "number")
131
+ chat.top_p = body.top_p;
132
+ if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) {
133
+ chat.stop = body.stop_sequences;
134
+ }
135
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
136
+ chat.tools = body.tools.map((tool) => ({
137
+ type: "function",
138
+ function: {
139
+ name: tool.name,
140
+ ...(tool.description !== undefined ? { description: tool.description } : {}),
141
+ parameters: tool.input_schema ?? { type: "object", properties: {} }
142
+ }
143
+ }));
144
+ }
145
+ if (body.tool_choice !== undefined)
146
+ chat.tool_choice = mapToolChoice(body.tool_choice);
147
+ if (body.stream === true)
148
+ chat.stream_options = { include_usage: true };
149
+ return chat;
150
+ }
151
+ // ---- response translation ----
152
+ export function mapStopReason(finishReason) {
153
+ switch (finishReason) {
154
+ case "length":
155
+ return "max_tokens";
156
+ case "tool_calls":
157
+ return "tool_use";
158
+ case "stop":
159
+ case "content_filter":
160
+ case null:
161
+ case undefined:
162
+ return "end_turn";
163
+ default:
164
+ return "end_turn";
165
+ }
166
+ }
167
+ export function chatToAnthropicMessage(openai, model) {
168
+ const choice = openai.choices?.[0];
169
+ const message = choice?.message;
170
+ const content = [];
171
+ const text = typeof message?.content === "string" ? message.content : "";
172
+ if (text.length > 0)
173
+ content.push({ type: "text", text });
174
+ if (Array.isArray(message?.tool_calls)) {
175
+ for (const call of message.tool_calls) {
176
+ let input = {};
177
+ const args = call.function?.arguments;
178
+ if (typeof args === "string" && args.length > 0) {
179
+ try {
180
+ input = JSON.parse(args);
181
+ }
182
+ catch {
183
+ input = {};
184
+ }
185
+ }
186
+ content.push({
187
+ type: "tool_use",
188
+ id: call.id ?? `toolu_${randomId()}`,
189
+ name: call.function?.name ?? "",
190
+ input
191
+ });
192
+ }
193
+ }
194
+ if (content.length === 0)
195
+ content.push({ type: "text", text: "" });
196
+ return {
197
+ id: openai.id !== undefined ? `msg_${openai.id}` : `msg_${randomId()}`,
198
+ type: "message",
199
+ role: "assistant",
200
+ model,
201
+ content,
202
+ stop_reason: mapStopReason(choice?.finish_reason),
203
+ stop_sequence: null,
204
+ usage: {
205
+ input_tokens: openai.usage?.prompt_tokens ?? 0,
206
+ output_tokens: openai.usage?.completion_tokens ?? 0
207
+ }
208
+ };
209
+ }
210
+ // ---- streaming translation (OpenAI chat SSE -> Anthropic Messages SSE) ----
211
+ function sse(type, data) {
212
+ return ENCODER.encode(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
213
+ }
214
+ export function openAiSseToAnthropic(upstream, model) {
215
+ const reader = upstream.getReader();
216
+ const decoder = new TextDecoder();
217
+ const tools = new Map();
218
+ const messageId = `msg_${randomId()}`;
219
+ const state = {
220
+ started: false,
221
+ textOpen: false,
222
+ textIndex: -1,
223
+ nextIndex: 0,
224
+ finished: false,
225
+ outputTokens: 0,
226
+ keepaliveTimer: undefined
227
+ };
228
+ let buffer = "";
229
+ const ensureStarted = (controller) => {
230
+ if (state.started)
231
+ return;
232
+ state.started = true;
233
+ controller.enqueue(sse("message_start", {
234
+ type: "message_start",
235
+ message: {
236
+ id: messageId,
237
+ type: "message",
238
+ role: "assistant",
239
+ model,
240
+ content: [],
241
+ stop_reason: null,
242
+ stop_sequence: null,
243
+ usage: { input_tokens: 0, output_tokens: 0 }
244
+ }
245
+ }));
246
+ };
247
+ const ensureText = (controller) => {
248
+ ensureStarted(controller);
249
+ if (state.textOpen)
250
+ return;
251
+ state.textOpen = true;
252
+ state.textIndex = state.nextIndex++;
253
+ controller.enqueue(sse("content_block_start", {
254
+ type: "content_block_start",
255
+ index: state.textIndex,
256
+ content_block: { type: "text", text: "" }
257
+ }));
258
+ };
259
+ const finalize = (controller, stopReason) => {
260
+ if (state.finished)
261
+ return;
262
+ state.finished = true;
263
+ if (state.keepaliveTimer !== undefined)
264
+ clearInterval(state.keepaliveTimer);
265
+ if (state.textOpen) {
266
+ controller.enqueue(sse("content_block_stop", { type: "content_block_stop", index: state.textIndex }));
267
+ }
268
+ for (const index of tools.values()) {
269
+ controller.enqueue(sse("content_block_stop", { type: "content_block_stop", index }));
270
+ }
271
+ controller.enqueue(sse("message_delta", {
272
+ type: "message_delta",
273
+ delta: { stop_reason: stopReason, stop_sequence: null },
274
+ usage: { output_tokens: state.outputTokens }
275
+ }));
276
+ controller.enqueue(sse("message_stop", { type: "message_stop" }));
277
+ };
278
+ const process = (controller, chunk) => {
279
+ const choice = chunk.choices?.[0];
280
+ if (choice === undefined) {
281
+ if (chunk.usage?.completion_tokens !== undefined)
282
+ state.outputTokens = chunk.usage.completion_tokens;
283
+ return;
284
+ }
285
+ const delta = choice.delta ?? {};
286
+ if (typeof delta.content === "string" && delta.content.length > 0) {
287
+ ensureText(controller);
288
+ controller.enqueue(sse("content_block_delta", {
289
+ type: "content_block_delta",
290
+ index: state.textIndex,
291
+ delta: { type: "text_delta", text: delta.content }
292
+ }));
293
+ }
294
+ if (Array.isArray(delta.tool_calls)) {
295
+ for (const call of delta.tool_calls) {
296
+ const openAiIndex = typeof call.index === "number" ? call.index : 0;
297
+ let index = tools.get(openAiIndex);
298
+ if (index === undefined) {
299
+ ensureStarted(controller);
300
+ index = state.nextIndex++;
301
+ tools.set(openAiIndex, index);
302
+ controller.enqueue(sse("content_block_start", {
303
+ type: "content_block_start",
304
+ index,
305
+ content_block: {
306
+ type: "tool_use",
307
+ id: call.id ?? `toolu_${randomId()}`,
308
+ name: call.function?.name ?? "",
309
+ input: {}
310
+ }
311
+ }));
312
+ }
313
+ const args = call.function?.arguments;
314
+ if (typeof args === "string" && args.length > 0) {
315
+ controller.enqueue(sse("content_block_delta", {
316
+ type: "content_block_delta",
317
+ index,
318
+ delta: { type: "input_json_delta", partial_json: args }
319
+ }));
320
+ }
321
+ }
322
+ }
323
+ if (chunk.usage?.completion_tokens !== undefined)
324
+ state.outputTokens = chunk.usage.completion_tokens;
325
+ if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
326
+ finalize(controller, mapStopReason(choice.finish_reason));
327
+ }
328
+ };
329
+ return new ReadableStream({
330
+ start(controller) {
331
+ // Start the message immediately and keep the connection alive with `ping`
332
+ // events while the upstream is still producing its first token. Claude
333
+ // Code times out if it sees nothing during the fusion panel phase (the
334
+ // chat-layer keepalive comments are dropped by this translator).
335
+ ensureStarted(controller);
336
+ state.keepaliveTimer = setInterval(() => {
337
+ if (state.finished)
338
+ return;
339
+ try {
340
+ controller.enqueue(sse("ping", { type: "ping" }));
341
+ }
342
+ catch {
343
+ // controller closed
344
+ }
345
+ }, 3000);
346
+ },
347
+ async pull(controller) {
348
+ const { done, value } = await reader.read();
349
+ if (done) {
350
+ if (!state.finished)
351
+ finalize(controller, "end_turn");
352
+ controller.close();
353
+ return;
354
+ }
355
+ buffer += decoder.decode(value, { stream: true });
356
+ let newline = buffer.indexOf("\n");
357
+ while (newline >= 0) {
358
+ const line = buffer.slice(0, newline).trim();
359
+ buffer = buffer.slice(newline + 1);
360
+ newline = buffer.indexOf("\n");
361
+ if (!line.startsWith("data:"))
362
+ continue;
363
+ const payload = line.slice(5).trim();
364
+ if (payload === "[DONE]") {
365
+ if (!state.finished)
366
+ finalize(controller, "end_turn");
367
+ continue;
368
+ }
369
+ try {
370
+ process(controller, JSON.parse(payload));
371
+ }
372
+ catch {
373
+ // ignore malformed lines; the upstream stream is authoritative
374
+ }
375
+ }
376
+ },
377
+ cancel(reason) {
378
+ if (state.keepaliveTimer !== undefined)
379
+ clearInterval(state.keepaliveTimer);
380
+ return reader.cancel(reason);
381
+ }
382
+ });
383
+ }
384
+ // ---- token counting + discovery ----
385
+ export function countTokensEstimate(body) {
386
+ let chars = systemText(body.system).length;
387
+ for (const message of body.messages)
388
+ chars += blockText(message.content).length;
389
+ // A rough chars/4 heuristic; Claude Code uses this only for budgeting.
390
+ return Math.max(1, Math.ceil(chars / 4));
391
+ }
392
+ // ---- handlers (return a Response the server pipes) ----
393
+ function jsonResponse(status, value) {
394
+ return new Response(JSON.stringify(value), {
395
+ status,
396
+ headers: { "content-type": "application/json" }
397
+ });
398
+ }
399
+ export async function handleAnthropicMessages(backend, body, modelCallId, signal) {
400
+ const requestedModel = body.model ?? backend.defaultModel ?? "";
401
+ const chat = anthropicToChat(body, backend.defaultModel);
402
+ const upstream = await backend.chat(chat, signal, { modelCallId });
403
+ if (!upstream.ok) {
404
+ const detail = await upstream.text();
405
+ return jsonResponse(upstream.status, {
406
+ type: "error",
407
+ error: { type: "api_error", message: detail.slice(0, 2000) }
408
+ });
409
+ }
410
+ if (body.stream === true) {
411
+ const source = upstream.body;
412
+ if (source === null)
413
+ return jsonResponse(502, { type: "error", error: { type: "api_error", message: "no upstream stream" } });
414
+ return new Response(openAiSseToAnthropic(source, requestedModel), {
415
+ status: 200,
416
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }
417
+ });
418
+ }
419
+ const openai = (await upstream.json());
420
+ return jsonResponse(200, chatToAnthropicMessage(openai, requestedModel));
421
+ }
422
+ export function handleCountTokens(body) {
423
+ return jsonResponse(200, { input_tokens: countTokensEstimate(body) });
424
+ }
425
+ /**
426
+ * Anthropic-shaped `/v1/models` discovery response. Claude Code only adds
427
+ * models whose id begins with `claude` or `anthropic`, so the local model is
428
+ * surfaced under a `claude`-prefixed id with the real model id as its
429
+ * display name.
430
+ */
431
+ export function anthropicModelsResponse(backendModel) {
432
+ const id = "claude-warrant-local";
433
+ return new Response(JSON.stringify({
434
+ data: [
435
+ {
436
+ type: "model",
437
+ id,
438
+ display_name: backendModel ?? "warrant local model",
439
+ created_at: new Date(0).toISOString()
440
+ }
441
+ ],
442
+ has_more: false,
443
+ first_id: id,
444
+ last_id: id
445
+ }), { status: 200, headers: { "content-type": "application/json" } });
446
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OpenAI Chat Completions surface. This is the gateway's "core" dialect: it is
3
+ * what the owned mlx fork speaks, what opencode and the Cursor IDE plan panel
4
+ * consume directly, and what the Anthropic and Responses adapters translate
5
+ * down to. The handlers here are deliberately thin — the request is forwarded
6
+ * to the backend and the upstream response (including SSE streams) is piped
7
+ * straight back — so the only logic is filling in a default model.
8
+ */
9
+ /** Fill in `model` from the backend default when the caller omitted it. */
10
+ export declare function withDefaultModel(body: unknown, defaultModel: string | undefined): unknown;
11
+ /** Whether a chat/completions request asked for a streamed response. */
12
+ export declare function isStream(body: unknown): boolean;
13
+ /** The model id a request will run as, after default injection. */
14
+ export declare function effectiveModel(body: unknown, defaultModel: string | undefined): string | undefined;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * OpenAI Chat Completions surface. This is the gateway's "core" dialect: it is
3
+ * what the owned mlx fork speaks, what opencode and the Cursor IDE plan panel
4
+ * consume directly, and what the Anthropic and Responses adapters translate
5
+ * down to. The handlers here are deliberately thin — the request is forwarded
6
+ * to the backend and the upstream response (including SSE streams) is piped
7
+ * straight back — so the only logic is filling in a default model.
8
+ */
9
+ function asObject(body) {
10
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
11
+ return body;
12
+ }
13
+ return undefined;
14
+ }
15
+ /** Fill in `model` from the backend default when the caller omitted it. */
16
+ export function withDefaultModel(body, defaultModel) {
17
+ if (defaultModel === undefined)
18
+ return body;
19
+ const obj = asObject(body);
20
+ if (obj === undefined || obj.model !== undefined)
21
+ return body;
22
+ return { ...obj, model: defaultModel };
23
+ }
24
+ /** Whether a chat/completions request asked for a streamed response. */
25
+ export function isStream(body) {
26
+ return asObject(body)?.stream === true;
27
+ }
28
+ /** The model id a request will run as, after default injection. */
29
+ export function effectiveModel(body, defaultModel) {
30
+ const model = asObject(body)?.model;
31
+ if (typeof model === "string")
32
+ return model;
33
+ return defaultModel;
34
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * OpenAI Responses adapter. Codex speaks the Responses API exclusively
3
+ * (`wire_api="responses"`; Chat Completions support was removed), so to back it
4
+ * with a local model we translate `/v1/responses` to and from the gateway's
5
+ * OpenAI Chat Completions core. The pure translation functions are exported for
6
+ * testing; the handler returns a `Response` the server pipes (JSON or SSE).
7
+ *
8
+ * This is the highest-fidelity adapter: it maps Responses `input` items
9
+ * (messages, function calls, function-call outputs) into chat messages, and
10
+ * emits the Responses streaming event sequence (`response.created`,
11
+ * `response.output_item.added`, `response.output_text.delta`,
12
+ * `response.function_call_arguments.delta`, `response.completed`, …) from chat
13
+ * completion chunks.
14
+ */
15
+ import type { Backend } from "../backend.js";
16
+ type ResponsesContentPart = {
17
+ type: string;
18
+ text?: string;
19
+ image_url?: string;
20
+ [key: string]: unknown;
21
+ };
22
+ type ResponsesInputItem = {
23
+ type?: "message";
24
+ role: "user" | "assistant" | "system" | "developer";
25
+ content: string | ResponsesContentPart[];
26
+ } | {
27
+ type: "function_call";
28
+ call_id?: string;
29
+ id?: string;
30
+ name: string;
31
+ arguments: string;
32
+ } | {
33
+ type: "function_call_output";
34
+ call_id: string;
35
+ output: unknown;
36
+ } | {
37
+ type: string;
38
+ [key: string]: unknown;
39
+ };
40
+ export type ResponsesRequest = {
41
+ model?: string;
42
+ instructions?: string;
43
+ input?: string | ResponsesInputItem[];
44
+ tools?: Array<{
45
+ type?: string;
46
+ name: string;
47
+ description?: string;
48
+ parameters?: unknown;
49
+ strict?: boolean;
50
+ }>;
51
+ tool_choice?: "auto" | "none" | "required" | {
52
+ type: "function";
53
+ name: string;
54
+ };
55
+ max_output_tokens?: number;
56
+ temperature?: number;
57
+ top_p?: number;
58
+ stream?: boolean;
59
+ };
60
+ type OpenAiToolCall = {
61
+ id?: string;
62
+ index?: number;
63
+ function?: {
64
+ name?: string;
65
+ arguments?: string;
66
+ };
67
+ };
68
+ type OpenAiDelta = {
69
+ content?: string | null;
70
+ tool_calls?: OpenAiToolCall[];
71
+ };
72
+ type OpenAiChoice = {
73
+ delta?: OpenAiDelta;
74
+ message?: {
75
+ content?: string | null;
76
+ tool_calls?: OpenAiToolCall[];
77
+ };
78
+ finish_reason?: string | null;
79
+ };
80
+ type OpenAiUsage = {
81
+ prompt_tokens?: number;
82
+ completion_tokens?: number;
83
+ };
84
+ type OpenAiResponse = {
85
+ id?: string;
86
+ choices?: OpenAiChoice[];
87
+ usage?: OpenAiUsage;
88
+ };
89
+ /** Translate a Responses request to an OpenAI Chat Completions body. */
90
+ export declare function responsesToChat(body: ResponsesRequest, backendModel: string | undefined): Record<string, unknown>;
91
+ export declare function chatToResponses(openai: OpenAiResponse, model: string): Record<string, unknown>;
92
+ export declare function openAiSseToResponses(upstream: ReadableStream<Uint8Array>, model: string): ReadableStream<Uint8Array>;
93
+ export declare function handleResponses(backend: Backend, body: ResponsesRequest, modelCallId?: string, signal?: AbortSignal): Promise<Response>;
94
+ export {};