@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.
- package/dist/acp-agent.d.ts +39 -0
- package/dist/acp-agent.js +143 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +111 -0
- package/dist/adapters/anthropic.js +446 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/responses.d.ts +94 -0
- package/dist/adapters/responses.js +438 -0
- package/dist/backend.d.ts +52 -0
- package/dist/backend.js +57 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +47 -0
- package/dist/front-door-acceptance.d.ts +41 -0
- package/dist/front-door-acceptance.js +219 -0
- package/dist/fusion-backend.d.ts +96 -0
- package/dist/fusion-backend.js +521 -0
- package/dist/fusion-gateway.d.ts +69 -0
- package/dist/fusion-gateway.js +355 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +28 -0
- package/dist/mlx-backend.d.ts +42 -0
- package/dist/mlx-backend.js +71 -0
- package/dist/provenance.d.ts +29 -0
- package/dist/provenance.js +182 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +234 -0
- package/dist/test/acp-agent.test.d.ts +1 -0
- package/dist/test/acp-agent.test.js +66 -0
- package/dist/test/acp-registry.test.d.ts +1 -0
- package/dist/test/acp-registry.test.js +70 -0
- package/dist/test/anthropic.test.d.ts +1 -0
- package/dist/test/anthropic.test.js +251 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +270 -0
- package/dist/test/front-door-acceptance.test.d.ts +1 -0
- package/dist/test/front-door-acceptance.test.js +94 -0
- package/dist/test/fusion-backend-trace.test.d.ts +1 -0
- package/dist/test/fusion-backend-trace.test.js +107 -0
- package/dist/test/fusion-backend.test.d.ts +1 -0
- package/dist/test/fusion-backend.test.js +193 -0
- package/dist/test/fusion-gateway.test.d.ts +1 -0
- package/dist/test/fusion-gateway.test.js +107 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +157 -0
- 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 {};
|