@velum-labs/routekit-gateway 0.9.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/LICENSE +201 -0
- package/README.md +28 -0
- package/dist/acp-agent.d.ts +38 -0
- package/dist/acp-agent.js +142 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +131 -0
- package/dist/adapters/anthropic.js +1195 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/cursor.d.ts +34 -0
- package/dist/adapters/cursor.js +305 -0
- package/dist/adapters/dropped.d.ts +10 -0
- package/dist/adapters/dropped.js +24 -0
- package/dist/adapters/openai-chat-wire.d.ts +93 -0
- package/dist/adapters/openai-chat-wire.js +143 -0
- package/dist/adapters/responses-stream.d.ts +7 -0
- package/dist/adapters/responses-stream.js +597 -0
- package/dist/adapters/responses.d.ts +174 -0
- package/dist/adapters/responses.js +778 -0
- package/dist/adapters/server-tool-loop.d.ts +94 -0
- package/dist/adapters/server-tool-loop.js +477 -0
- package/dist/adapters/upstream-error.d.ts +14 -0
- package/dist/adapters/upstream-error.js +25 -0
- package/dist/adapters/validate.d.ts +27 -0
- package/dist/adapters/validate.js +180 -0
- package/dist/adapters/web-search.d.ts +46 -0
- package/dist/adapters/web-search.js +151 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +28 -0
- package/dist/backend.d.ts +151 -0
- package/dist/backend.js +143 -0
- package/dist/capacity-pool.d.ts +31 -0
- package/dist/capacity-pool.js +99 -0
- package/dist/cost.d.ts +49 -0
- package/dist/cost.js +112 -0
- package/dist/endpoint-health.d.ts +54 -0
- package/dist/endpoint-health.js +123 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +23 -0
- package/dist/provenance.d.ts +31 -0
- package/dist/provenance.js +191 -0
- package/dist/provider-backends.d.ts +40 -0
- package/dist/provider-backends.js +1050 -0
- package/dist/provider-source.d.ts +40 -0
- package/dist/provider-source.js +293 -0
- package/dist/router.d.ts +168 -0
- package/dist/router.js +474 -0
- package/dist/server.d.ts +67 -0
- package/dist/server.js +930 -0
- package/dist/sse/chat-assembler.d.ts +45 -0
- package/dist/sse/chat-assembler.js +190 -0
- package/dist/sse/parse.d.ts +50 -0
- package/dist/sse/parse.js +149 -0
- package/dist/sse-wire.d.ts +10 -0
- package/dist/sse-wire.js +31 -0
- package/dist/switching-proxy.d.ts +15 -0
- package/dist/switching-proxy.js +232 -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 +793 -0
- package/dist/test/auth.test.d.ts +1 -0
- package/dist/test/auth.test.js +25 -0
- package/dist/test/boundary.test.d.ts +1 -0
- package/dist/test/boundary.test.js +32 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +418 -0
- package/dist/test/cost.test.d.ts +1 -0
- package/dist/test/cost.test.js +60 -0
- package/dist/test/cursor.test.d.ts +1 -0
- package/dist/test/cursor.test.js +100 -0
- package/dist/test/drain.test.d.ts +1 -0
- package/dist/test/drain.test.js +116 -0
- package/dist/test/dropped.test.d.ts +1 -0
- package/dist/test/dropped.test.js +80 -0
- package/dist/test/endpoint-health.test.d.ts +1 -0
- package/dist/test/endpoint-health.test.js +73 -0
- package/dist/test/provenance.test.d.ts +1 -0
- package/dist/test/provenance.test.js +176 -0
- package/dist/test/provider-backends.test.d.ts +1 -0
- package/dist/test/provider-backends.test.js +699 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +813 -0
- package/dist/test/routed-backend.test.d.ts +1 -0
- package/dist/test/routed-backend.test.js +39 -0
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +297 -0
- package/dist/test/server-resilience.test.d.ts +1 -0
- package/dist/test/server-resilience.test.js +169 -0
- package/dist/test/sse-codec.test.d.ts +1 -0
- package/dist/test/sse-codec.test.js +186 -0
- package/dist/test/web-search-loop.test.d.ts +1 -0
- package/dist/test/web-search-loop.test.js +469 -0
- package/dist/test/wire-validation.test.d.ts +1 -0
- package/dist/test/wire-validation.test.js +140 -0
- package/package.json +48 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { anthropicModelsResponse, anthropicToChat, chatToAnthropicMessage, claudeModelAlias, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias } from "../adapters/anthropic.js";
|
|
5
|
+
import { OpenAiBackend } from "../backend.js";
|
|
6
|
+
import { CatalogBackend } from "../router.js";
|
|
7
|
+
import { MODEL_CALL_ID_HEADER } from "../provenance.js";
|
|
8
|
+
import { startGateway } from "../server.js";
|
|
9
|
+
import { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA } from "../adapters/openai-chat-wire.js";
|
|
10
|
+
/**
|
|
11
|
+
* M2 coverage: the Anthropic Messages adapter against a mock OpenAI backend.
|
|
12
|
+
* Verifies request translation (system, tools, tool results), non-streaming
|
|
13
|
+
* and streaming response shapes, count_tokens, and discovery.
|
|
14
|
+
*/
|
|
15
|
+
test("anthropicModelsResponse aliases every model past Claude Code's claude/anthropic filter", async () => {
|
|
16
|
+
const res = anthropicModelsResponse("route-primary", [
|
|
17
|
+
"route-primary",
|
|
18
|
+
"claude-opus-4-8",
|
|
19
|
+
"gpt-5.5",
|
|
20
|
+
"mlx-community/Qwen3-1.7B-4bit"
|
|
21
|
+
]);
|
|
22
|
+
const body = (await res.json());
|
|
23
|
+
// Every id begins with claude/anthropic so Claude Code lists them all...
|
|
24
|
+
assert.ok(body.data.every((model) => model.id.startsWith("claude") || model.id.startsWith("anthropic")));
|
|
25
|
+
// ...non-Anthropic ids are aliased, Anthropic-family ids pass through as-is.
|
|
26
|
+
assert.deepEqual(body.data.map((model) => model.id), [
|
|
27
|
+
"claude-route-primary",
|
|
28
|
+
"claude-opus-4-8",
|
|
29
|
+
"claude-gpt-5.5",
|
|
30
|
+
"claude-mlx-community/Qwen3-1.7B-4bit"
|
|
31
|
+
]);
|
|
32
|
+
// The picker shows the real id via display_name.
|
|
33
|
+
const gpt = body.data.find((model) => model.id === "claude-gpt-5.5");
|
|
34
|
+
assert.equal(gpt?.display_name, "gpt-5.5");
|
|
35
|
+
assert.equal(claudeModelAlias("claude-opus-4-8"), "claude-opus-4-8");
|
|
36
|
+
assert.equal(resolveClaudeModelAlias("claude-gpt-5.5", [
|
|
37
|
+
"route-primary",
|
|
38
|
+
"gpt-5.5"
|
|
39
|
+
]), "gpt-5.5");
|
|
40
|
+
assert.equal(resolveClaudeModelAlias("claude-opus-4-8", ["gpt-5.5"]), "claude-opus-4-8");
|
|
41
|
+
});
|
|
42
|
+
test("anthropicModelsResponse exposes Claude subscription models as bare native ids", async () => {
|
|
43
|
+
const response = anthropicModelsResponse("claude-code/claude-sonnet-4-6", [
|
|
44
|
+
"claude-code/claude-sonnet-4-6",
|
|
45
|
+
"codex/gpt-5.5",
|
|
46
|
+
"anthropic/claude-opus-4-8"
|
|
47
|
+
], [
|
|
48
|
+
{
|
|
49
|
+
publicId: "claude-code/claude-sonnet-4-6",
|
|
50
|
+
nativeId: "claude-sonnet-4-6",
|
|
51
|
+
provider: "claude-code"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
publicId: "codex/gpt-5.5",
|
|
55
|
+
nativeId: "gpt-5.5",
|
|
56
|
+
provider: "codex"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
publicId: "anthropic/claude-opus-4-8",
|
|
60
|
+
nativeId: "claude-opus-4-8",
|
|
61
|
+
provider: "anthropic"
|
|
62
|
+
}
|
|
63
|
+
]);
|
|
64
|
+
const body = (await response.json());
|
|
65
|
+
assert.deepEqual(body.data, [
|
|
66
|
+
{
|
|
67
|
+
id: "claude-sonnet-4-6",
|
|
68
|
+
display_name: "claude-sonnet-4-6",
|
|
69
|
+
created_at: new Date(0).toISOString(),
|
|
70
|
+
type: "model"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "claude-codex/gpt-5.5",
|
|
74
|
+
display_name: "codex/gpt-5.5",
|
|
75
|
+
created_at: new Date(0).toISOString(),
|
|
76
|
+
type: "model"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "anthropic/claude-opus-4-8",
|
|
80
|
+
display_name: "anthropic/claude-opus-4-8",
|
|
81
|
+
created_at: new Date(0).toISOString(),
|
|
82
|
+
type: "model"
|
|
83
|
+
}
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
test("anthropicToChat tolerates thinking: null (same failure class as Responses reasoning: null)", () => {
|
|
87
|
+
const chat = anthropicToChat({ model: "claude-x", messages: [{ role: "user", content: "hi" }], thinking: null }, "claude-x");
|
|
88
|
+
assert.equal(chat.reasoning_effort, undefined);
|
|
89
|
+
});
|
|
90
|
+
function sendJson(res, status, value) {
|
|
91
|
+
res.statusCode = status;
|
|
92
|
+
res.setHeader("content-type", "application/json");
|
|
93
|
+
res.end(Buffer.from(JSON.stringify(value), "utf8"));
|
|
94
|
+
}
|
|
95
|
+
async function readAll(req) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
for await (const chunk of req)
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
return Buffer.concat(chunks);
|
|
100
|
+
}
|
|
101
|
+
async function startMock() {
|
|
102
|
+
let lastChatBody;
|
|
103
|
+
let lastModelCallId;
|
|
104
|
+
const server = createServer((req, res) => {
|
|
105
|
+
void (async () => {
|
|
106
|
+
const body = JSON.parse((await readAll(req)).toString("utf8"));
|
|
107
|
+
lastChatBody = body;
|
|
108
|
+
lastModelCallId =
|
|
109
|
+
typeof req.headers[MODEL_CALL_ID_HEADER] === "string"
|
|
110
|
+
? req.headers[MODEL_CALL_ID_HEADER]
|
|
111
|
+
: undefined;
|
|
112
|
+
if (body.stream === true) {
|
|
113
|
+
res.statusCode = 200;
|
|
114
|
+
res.setHeader("content-type", "text/event-stream");
|
|
115
|
+
res.write('data: {"choices":[{"delta":{"content":"Hel"},"finish_reason":null}]}\n\n');
|
|
116
|
+
res.write('data: {"choices":[{"delta":{"content":"lo"},"finish_reason":null}]}\n\n');
|
|
117
|
+
res.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"completion_tokens":2}}\n\n');
|
|
118
|
+
res.write("data: [DONE]\n\n");
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
sendJson(res, 200, {
|
|
123
|
+
id: "cmpl-1",
|
|
124
|
+
object: "chat.completion",
|
|
125
|
+
model: body.model,
|
|
126
|
+
choices: [
|
|
127
|
+
{ index: 0, message: { role: "assistant", content: "Hello there" }, finish_reason: "stop" }
|
|
128
|
+
],
|
|
129
|
+
usage: { prompt_tokens: 7, completion_tokens: 3 }
|
|
130
|
+
});
|
|
131
|
+
})();
|
|
132
|
+
});
|
|
133
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
134
|
+
const address = server.address();
|
|
135
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
136
|
+
return {
|
|
137
|
+
url: `http://127.0.0.1:${port}`,
|
|
138
|
+
lastChatBody: () => lastChatBody,
|
|
139
|
+
lastModelCallId: () => lastModelCallId,
|
|
140
|
+
close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
test("anthropicToChat maps system, tools, and tool results", () => {
|
|
144
|
+
const chat = anthropicToChat({
|
|
145
|
+
model: "claude-x",
|
|
146
|
+
system: "be terse",
|
|
147
|
+
max_tokens: 100,
|
|
148
|
+
messages: [
|
|
149
|
+
{ role: "user", content: "hi" },
|
|
150
|
+
{
|
|
151
|
+
role: "assistant",
|
|
152
|
+
content: [{ type: "tool_use", id: "tu_1", name: "search", input: { q: "x" } }]
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
role: "user",
|
|
156
|
+
content: [{ type: "tool_result", tool_use_id: "tu_1", content: "result text" }]
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
tools: [{ name: "search", description: "find", input_schema: { type: "object" } }]
|
|
160
|
+
}, "local-model");
|
|
161
|
+
const messages = chat.messages;
|
|
162
|
+
assert.equal(chat.model, "local-model");
|
|
163
|
+
// Modern spelling: OpenAI reasoning models reject legacy `max_tokens`.
|
|
164
|
+
assert.equal(chat.max_completion_tokens, 100);
|
|
165
|
+
assert.equal(chat.max_tokens, undefined);
|
|
166
|
+
assert.equal(messages[0]?.role, "system");
|
|
167
|
+
assert.equal(messages[1]?.role, "user");
|
|
168
|
+
assert.equal(messages[2]?.role, "assistant");
|
|
169
|
+
assert.ok(Array.isArray(messages[2].tool_calls));
|
|
170
|
+
assert.equal(messages[3]?.role, "tool");
|
|
171
|
+
assert.equal(messages[3].tool_call_id, "tu_1");
|
|
172
|
+
const tools = chat.tools;
|
|
173
|
+
assert.equal(tools[0]?.function.name, "search");
|
|
174
|
+
});
|
|
175
|
+
test("anthropicToChat treats explicit null optional fields as absent", () => {
|
|
176
|
+
// Some clients encode "unset" as an explicit JSON null (Codex does on the
|
|
177
|
+
// Responses wire); a null `thinking`/`tool_choice`/`system` must translate
|
|
178
|
+
// like an absent field instead of crashing the turn.
|
|
179
|
+
const chat = anthropicToChat({
|
|
180
|
+
model: "claude-x",
|
|
181
|
+
system: null,
|
|
182
|
+
messages: [{ role: "user", content: "hi" }],
|
|
183
|
+
thinking: null,
|
|
184
|
+
metadata: null,
|
|
185
|
+
tool_choice: null
|
|
186
|
+
}, "local-model");
|
|
187
|
+
assert.deepEqual(chat.messages, [{ role: "user", content: "hi" }]);
|
|
188
|
+
assert.equal(chat.reasoning_effort, undefined);
|
|
189
|
+
assert.equal(chat.tool_choice, undefined);
|
|
190
|
+
});
|
|
191
|
+
test("anthropicToChat keeps disabled thinking disabled despite output effort", () => {
|
|
192
|
+
const chat = anthropicToChat({
|
|
193
|
+
max_tokens: 4096,
|
|
194
|
+
thinking: { type: "disabled" },
|
|
195
|
+
output_config: { effort: "max" },
|
|
196
|
+
messages: [{ role: "user", content: "answer directly" }]
|
|
197
|
+
}, "gpt-route");
|
|
198
|
+
assert.equal(chat.reasoning_effort, undefined);
|
|
199
|
+
});
|
|
200
|
+
test("anthropicToChat preserves exact controls and signed/redacted history in-process", () => {
|
|
201
|
+
const chat = anthropicToChat({
|
|
202
|
+
model: "claude-x",
|
|
203
|
+
max_tokens: 4096,
|
|
204
|
+
thinking: { type: "adaptive", display: "omitted" },
|
|
205
|
+
output_config: { effort: "xhigh" },
|
|
206
|
+
messages: [
|
|
207
|
+
{ role: "user", content: "continue" },
|
|
208
|
+
{
|
|
209
|
+
role: "assistant",
|
|
210
|
+
content: [
|
|
211
|
+
{
|
|
212
|
+
type: "thinking",
|
|
213
|
+
thinking: "private",
|
|
214
|
+
signature: "sig-valid"
|
|
215
|
+
},
|
|
216
|
+
{ type: "redacted_thinking", data: "opaque" },
|
|
217
|
+
{
|
|
218
|
+
type: "tool_use",
|
|
219
|
+
id: "tool_1",
|
|
220
|
+
name: "read",
|
|
221
|
+
input: { path: "a.ts" }
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
}, "claude-x");
|
|
227
|
+
const metadata = chat[ANTHROPIC_REQUEST_METADATA];
|
|
228
|
+
assert.deepEqual(metadata, {
|
|
229
|
+
thinking: { type: "adaptive", display: "omitted" },
|
|
230
|
+
output_config: { effort: "xhigh" }
|
|
231
|
+
});
|
|
232
|
+
assert.equal(chat.reasoning_effort, "xhigh");
|
|
233
|
+
const assistant = chat.messages[1];
|
|
234
|
+
const native = assistant?.[ANTHROPIC_MESSAGE_CONTENT];
|
|
235
|
+
assert.deepEqual(native?.map((block) => block.type), [
|
|
236
|
+
"thinking",
|
|
237
|
+
"redacted_thinking",
|
|
238
|
+
"tool_use"
|
|
239
|
+
]);
|
|
240
|
+
assert.equal(native?.[0]?.signature, "sig-valid");
|
|
241
|
+
});
|
|
242
|
+
test("anthropicToChat never replays synthetic thinking with an empty signature", () => {
|
|
243
|
+
const chat = anthropicToChat({
|
|
244
|
+
messages: [
|
|
245
|
+
{
|
|
246
|
+
role: "assistant",
|
|
247
|
+
content: [
|
|
248
|
+
{ type: "thinking", thinking: "display only", signature: "" },
|
|
249
|
+
{ type: "text", text: "answer" }
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}, "claude-x");
|
|
254
|
+
assert.equal(chat.messages[0]?.[ANTHROPIC_MESSAGE_CONTENT], undefined);
|
|
255
|
+
assert.equal(chat.messages[0]?.content, "answer");
|
|
256
|
+
});
|
|
257
|
+
test("anthropicToChat projects typed client tools but excludes server-executed tools", () => {
|
|
258
|
+
const chat = anthropicToChat({
|
|
259
|
+
model: "claude-x",
|
|
260
|
+
messages: [{ role: "user", content: "hi" }],
|
|
261
|
+
tools: [
|
|
262
|
+
// Plain client tool (Claude Code's sub-agent door) — always projected.
|
|
263
|
+
{ name: "Task", description: "spawn a sub-agent", input_schema: { type: "object" } },
|
|
264
|
+
// Anthropic-defined *client* tool: caller executes it via tool_use.
|
|
265
|
+
{ type: "bash_20250124", name: "bash" },
|
|
266
|
+
// Server-executed tools: nothing behind the gateway can run them.
|
|
267
|
+
{ type: "web_search_20250305", name: "web_search", max_uses: 5 },
|
|
268
|
+
{ type: "code_execution_20250522", name: "code_execution" }
|
|
269
|
+
]
|
|
270
|
+
}, "local-model");
|
|
271
|
+
const tools = chat.tools;
|
|
272
|
+
assert.deepEqual(tools.map((tool) => tool.function.name), ["Task", "bash"]);
|
|
273
|
+
});
|
|
274
|
+
test("anthropicToChat groups parallel tool_use into one assistant message", () => {
|
|
275
|
+
// Anthropic batches parallel tool calls as multiple tool_use blocks in a
|
|
276
|
+
// single assistant message; they must stay one assistant message followed by
|
|
277
|
+
// the tool results so the chat API's tool_calls pairing stays valid.
|
|
278
|
+
const chat = anthropicToChat({
|
|
279
|
+
messages: [
|
|
280
|
+
{ role: "user", content: "do both" },
|
|
281
|
+
{
|
|
282
|
+
role: "assistant",
|
|
283
|
+
content: [
|
|
284
|
+
{ type: "tool_use", id: "tu_a", name: "read_file", input: { path: "a" } },
|
|
285
|
+
{ type: "tool_use", id: "tu_b", name: "read_file", input: { path: "b" } }
|
|
286
|
+
]
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
role: "user",
|
|
290
|
+
content: [
|
|
291
|
+
{ type: "tool_result", tool_use_id: "tu_a", content: "A" },
|
|
292
|
+
{ type: "tool_result", tool_use_id: "tu_b", content: "B" }
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
]
|
|
296
|
+
}, "local-model");
|
|
297
|
+
const messages = chat.messages;
|
|
298
|
+
const assistant = messages.find((m) => m.role === "assistant");
|
|
299
|
+
assert.equal(assistant.tool_calls?.length, 2);
|
|
300
|
+
assert.deepEqual(assistant.tool_calls?.map((call) => call.id), ["tu_a", "tu_b"]);
|
|
301
|
+
const toolMessages = messages.filter((m) => m.role === "tool");
|
|
302
|
+
assert.deepEqual(toolMessages.map((m) => m.tool_call_id), ["tu_a", "tu_b"]);
|
|
303
|
+
});
|
|
304
|
+
test("anthropic streaming starts eagerly and pings while the upstream is silent", async () => {
|
|
305
|
+
// A never-ending upstream simulates a slow provider before its first token.
|
|
306
|
+
let upstreamController;
|
|
307
|
+
const upstream = new ReadableStream({
|
|
308
|
+
start(controller) {
|
|
309
|
+
upstreamController = controller;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const decoder = new TextDecoder();
|
|
313
|
+
const reader = openAiSseToAnthropic(upstream, "claude-x").getReader();
|
|
314
|
+
try {
|
|
315
|
+
// message_start must arrive before any upstream data is produced.
|
|
316
|
+
const first = await reader.read();
|
|
317
|
+
assert.ok(first.value !== undefined);
|
|
318
|
+
assert.ok(decoder.decode(first.value).includes("event: message_start"));
|
|
319
|
+
// A ping keepalive must arrive while the upstream is still silent.
|
|
320
|
+
let sawPing = false;
|
|
321
|
+
const deadline = Date.now() + 4000;
|
|
322
|
+
while (Date.now() < deadline) {
|
|
323
|
+
const { value, done } = await reader.read();
|
|
324
|
+
if (done)
|
|
325
|
+
break;
|
|
326
|
+
if (value !== undefined && decoder.decode(value).includes("event: ping")) {
|
|
327
|
+
sawPing = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
assert.ok(sawPing, "a ping keepalive must be emitted while the upstream is silent");
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
await reader.cancel();
|
|
335
|
+
// cancel() already propagates to the upstream; closing again is a no-op.
|
|
336
|
+
try {
|
|
337
|
+
upstreamController.close();
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// already closed via cancel
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
test("streams a routed tool call end to end as Anthropic tool_use blocks", async () => {
|
|
345
|
+
// OpenAI-chat SSE with a tool call whose arguments arrive fragmented across
|
|
346
|
+
// chunks, then a tool_calls finish. The adapter must reconstruct one tool_use
|
|
347
|
+
// block with the fully-merged JSON input.
|
|
348
|
+
const chunks = [
|
|
349
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":"{\\"q\\":"}}]}}]}\n\n',
|
|
350
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"cats\\"}"}}]}}]}\n\n',
|
|
351
|
+
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
|
352
|
+
"data: [DONE]\n\n"
|
|
353
|
+
];
|
|
354
|
+
const encoder = new TextEncoder();
|
|
355
|
+
const upstream = new ReadableStream({
|
|
356
|
+
start(controller) {
|
|
357
|
+
for (const chunk of chunks)
|
|
358
|
+
controller.enqueue(encoder.encode(chunk));
|
|
359
|
+
controller.close();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
const decoder = new TextDecoder();
|
|
363
|
+
const reader = openAiSseToAnthropic(upstream, "claude-x").getReader();
|
|
364
|
+
let out = "";
|
|
365
|
+
for (;;) {
|
|
366
|
+
const { value, done } = await reader.read();
|
|
367
|
+
if (done)
|
|
368
|
+
break;
|
|
369
|
+
if (value !== undefined)
|
|
370
|
+
out += decoder.decode(value);
|
|
371
|
+
}
|
|
372
|
+
assert.ok(out.includes('"type":"tool_use"'), "a tool_use content block must be emitted");
|
|
373
|
+
assert.ok(out.includes('"name":"search"'), "the tool name must be carried through");
|
|
374
|
+
assert.ok(out.includes("input_json_delta"), "the arguments must stream as input_json_delta");
|
|
375
|
+
// Both fragments ("{\"q\": and \"cats\"}) must reach the client.
|
|
376
|
+
assert.ok(out.includes("cats"), "both argument fragments must be forwarded");
|
|
377
|
+
assert.ok(out.includes('"stop_reason":"tool_use"'), "tool_calls maps to a tool_use stop reason");
|
|
378
|
+
});
|
|
379
|
+
test("truncated stream (no finish_reason) surfaces an Anthropic error, not end_turn", async () => {
|
|
380
|
+
// Upstream ends after some content but never sends a finish_reason / [DONE].
|
|
381
|
+
const encoder = new TextEncoder();
|
|
382
|
+
const upstream = new ReadableStream({
|
|
383
|
+
start(controller) {
|
|
384
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'));
|
|
385
|
+
controller.close();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
const decoder = new TextDecoder();
|
|
389
|
+
const reader = openAiSseToAnthropic(upstream, "claude-x").getReader();
|
|
390
|
+
let out = "";
|
|
391
|
+
for (;;) {
|
|
392
|
+
const { value, done } = await reader.read();
|
|
393
|
+
if (done)
|
|
394
|
+
break;
|
|
395
|
+
if (value !== undefined)
|
|
396
|
+
out += decoder.decode(value);
|
|
397
|
+
}
|
|
398
|
+
assert.ok(out.includes("event: error"), "a truncated stream must emit an error event");
|
|
399
|
+
assert.ok(!out.includes('"stop_reason":"end_turn"'), "truncation must not fabricate a clean end_turn");
|
|
400
|
+
});
|
|
401
|
+
test("an OpenAI mid-stream error becomes a native Anthropic error event", async () => {
|
|
402
|
+
const encoder = new TextEncoder();
|
|
403
|
+
const upstream = new ReadableStream({
|
|
404
|
+
start(controller) {
|
|
405
|
+
controller.enqueue(encoder.encode('data: {"error":{"message":"provider overloaded","type":"provider_error"}}\n\n'));
|
|
406
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
407
|
+
controller.close();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
const output = await new Response(openAiSseToAnthropic(upstream, "claude-x")).text();
|
|
411
|
+
assert.match(output, /event: error/);
|
|
412
|
+
assert.match(output, /provider overloaded/);
|
|
413
|
+
assert.match(output, /provider_error/);
|
|
414
|
+
assert.doesNotMatch(output, /incomplete_stream/);
|
|
415
|
+
assert.doesNotMatch(output, /"stop_reason":"end_turn"/);
|
|
416
|
+
});
|
|
417
|
+
test("chatToAnthropicMessage produces a text content block", () => {
|
|
418
|
+
const message = chatToAnthropicMessage({
|
|
419
|
+
id: "cmpl-9",
|
|
420
|
+
choices: [{ message: { content: "hi" }, finish_reason: "stop" }],
|
|
421
|
+
usage: { prompt_tokens: 4, completion_tokens: 1 }
|
|
422
|
+
}, "claude-x");
|
|
423
|
+
assert.equal(message.type, "message");
|
|
424
|
+
const content = message.content;
|
|
425
|
+
assert.equal(content[0]?.type, "text");
|
|
426
|
+
assert.equal(content[0]?.text, "hi");
|
|
427
|
+
assert.equal(message.stop_reason, "end_turn");
|
|
428
|
+
});
|
|
429
|
+
test("chatToAnthropicMessage restores signed and redacted native reasoning blocks", () => {
|
|
430
|
+
const message = chatToAnthropicMessage({
|
|
431
|
+
id: "cmpl-native",
|
|
432
|
+
choices: [
|
|
433
|
+
{
|
|
434
|
+
message: {
|
|
435
|
+
content: "answer",
|
|
436
|
+
reasoning: "visible thought",
|
|
437
|
+
reasoning_content: "**gateway narration**",
|
|
438
|
+
reasoning_details: [
|
|
439
|
+
{
|
|
440
|
+
type: "thinking",
|
|
441
|
+
index: 0,
|
|
442
|
+
thinking: "visible thought",
|
|
443
|
+
signature: "sig-native"
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
type: "redacted_thinking",
|
|
447
|
+
index: 1,
|
|
448
|
+
data: "opaque-native"
|
|
449
|
+
}
|
|
450
|
+
]
|
|
451
|
+
},
|
|
452
|
+
finish_reason: "stop"
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
}, "claude-x");
|
|
456
|
+
const content = message.content;
|
|
457
|
+
assert.deepEqual(content.map((block) => block.type), [
|
|
458
|
+
"thinking",
|
|
459
|
+
"redacted_thinking",
|
|
460
|
+
"thinking",
|
|
461
|
+
"text"
|
|
462
|
+
]);
|
|
463
|
+
assert.equal(content[0]?.signature, "sig-native");
|
|
464
|
+
assert.equal(content[1]?.data, "opaque-native");
|
|
465
|
+
assert.equal(content[2]?.thinking, "gateway narration");
|
|
466
|
+
});
|
|
467
|
+
test("Anthropic response conversion ignores malformed untrusted reasoning details", () => {
|
|
468
|
+
const message = chatToAnthropicMessage({
|
|
469
|
+
choices: [
|
|
470
|
+
{
|
|
471
|
+
message: {
|
|
472
|
+
content: "safe",
|
|
473
|
+
reasoning_details: [
|
|
474
|
+
{ type: "attacker_block", index: 0, phase: "start", data: "leak" },
|
|
475
|
+
{ type: "redacted_thinking", index: 1, data: 42 }
|
|
476
|
+
]
|
|
477
|
+
},
|
|
478
|
+
finish_reason: "stop"
|
|
479
|
+
}
|
|
480
|
+
]
|
|
481
|
+
}, "claude-x");
|
|
482
|
+
assert.deepEqual(message.content.map((block) => block.type), ["text"]);
|
|
483
|
+
});
|
|
484
|
+
test("chatToAnthropicMessage restores provider-native stop metadata", () => {
|
|
485
|
+
const message = chatToAnthropicMessage({
|
|
486
|
+
choices: [
|
|
487
|
+
{
|
|
488
|
+
message: { content: "bounded" },
|
|
489
|
+
finish_reason: "stop",
|
|
490
|
+
anthropic_stop_reason: "stop_sequence",
|
|
491
|
+
anthropic_stop_sequence: "<END>"
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
}, "claude-x");
|
|
495
|
+
assert.equal(message.stop_reason, "stop_sequence");
|
|
496
|
+
assert.equal(message.stop_sequence, "<END>");
|
|
497
|
+
});
|
|
498
|
+
test("openAiSseToAnthropic restores native thinking lifecycle and signature deltas", async () => {
|
|
499
|
+
const encoder = new TextEncoder();
|
|
500
|
+
const chunks = [
|
|
501
|
+
{
|
|
502
|
+
reasoning_details: [
|
|
503
|
+
{ type: "thinking", index: 0, phase: "start", signature: "" }
|
|
504
|
+
]
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
reasoning: "native thought",
|
|
508
|
+
reasoning_details: [
|
|
509
|
+
{
|
|
510
|
+
type: "thinking",
|
|
511
|
+
index: 0,
|
|
512
|
+
phase: "delta",
|
|
513
|
+
thinking: "native thought"
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
},
|
|
517
|
+
{ reasoning_content: "**gateway narration**\n\n" },
|
|
518
|
+
{
|
|
519
|
+
reasoning_details: [
|
|
520
|
+
{
|
|
521
|
+
type: "thinking",
|
|
522
|
+
index: 0,
|
|
523
|
+
phase: "signature",
|
|
524
|
+
signature: "sig-stream"
|
|
525
|
+
}
|
|
526
|
+
]
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
reasoning_details: [
|
|
530
|
+
{ type: "thinking", index: 0, phase: "stop" }
|
|
531
|
+
]
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
reasoning_details: [
|
|
535
|
+
{
|
|
536
|
+
type: "redacted_thinking",
|
|
537
|
+
index: 1,
|
|
538
|
+
phase: "block",
|
|
539
|
+
data: "opaque-stream"
|
|
540
|
+
}
|
|
541
|
+
]
|
|
542
|
+
},
|
|
543
|
+
{ content: "answer" }
|
|
544
|
+
];
|
|
545
|
+
const upstream = new ReadableStream({
|
|
546
|
+
start(controller) {
|
|
547
|
+
for (const delta of chunks) {
|
|
548
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
549
|
+
choices: [{ delta, finish_reason: null }]
|
|
550
|
+
})}\n\n`));
|
|
551
|
+
}
|
|
552
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
|
|
553
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
554
|
+
controller.close();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
const text = await new Response(openAiSseToAnthropic(upstream, "claude-x")).text();
|
|
558
|
+
assert.match(text, /"type":"thinking_delta","thinking":"native thought"/);
|
|
559
|
+
assert.match(text, /"type":"signature_delta","signature":"sig-stream"/);
|
|
560
|
+
assert.match(text, /"type":"thinking_delta","thinking":"gateway narration\\n\\n"/);
|
|
561
|
+
assert.match(text, /"type":"redacted_thinking","data":"opaque-stream"/);
|
|
562
|
+
assert.equal(text.match(/"type":"thinking_delta","thinking":"native thought"/g)?.length, 1, "portable reasoning must not duplicate the native detail delta");
|
|
563
|
+
assert.ok(text.indexOf('"type":"signature_delta"') <
|
|
564
|
+
text.indexOf('"thinking":"gateway narration'));
|
|
565
|
+
assert.ok(text.indexOf('"thinking":"gateway narration') <
|
|
566
|
+
text.indexOf('"type":"redacted_thinking"'));
|
|
567
|
+
assert.ok(text.indexOf('"type":"redacted_thinking"') <
|
|
568
|
+
text.indexOf('"type":"text_delta"'));
|
|
569
|
+
});
|
|
570
|
+
test("mapStopReason maps tool_calls to tool_use", () => {
|
|
571
|
+
assert.equal(mapStopReason("tool_calls"), "tool_use");
|
|
572
|
+
assert.equal(mapStopReason("length"), "max_tokens");
|
|
573
|
+
assert.equal(mapStopReason("stop"), "end_turn");
|
|
574
|
+
});
|
|
575
|
+
test("serves a non-streaming Anthropic message end to end", async () => {
|
|
576
|
+
const mock = await startMock();
|
|
577
|
+
const gateway = await startGateway({
|
|
578
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
|
|
579
|
+
});
|
|
580
|
+
try {
|
|
581
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
582
|
+
method: "POST",
|
|
583
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
584
|
+
body: JSON.stringify({ model: "claude-x", max_tokens: 50, messages: [{ role: "user", content: "hi" }] })
|
|
585
|
+
});
|
|
586
|
+
assert.equal(response.status, 200);
|
|
587
|
+
assert.equal(mock.lastModelCallId(), response.headers.get(MODEL_CALL_ID_HEADER));
|
|
588
|
+
const json = (await response.json());
|
|
589
|
+
assert.equal(json.type, "message");
|
|
590
|
+
assert.equal(json.model, "claude-x");
|
|
591
|
+
assert.equal(json.content[0]?.text, "Hello there");
|
|
592
|
+
// Upstream got the backend model, not the claude id.
|
|
593
|
+
assert.equal(mock.lastChatBody()?.model, "local-model");
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
await gateway.close();
|
|
597
|
+
await mock.close();
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
test("rejects impossible Anthropic thinking budgets before provider routing", async () => {
|
|
601
|
+
const mock = await startMock();
|
|
602
|
+
const gateway = await startGateway({
|
|
603
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "gpt-route" })
|
|
604
|
+
});
|
|
605
|
+
try {
|
|
606
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
607
|
+
method: "POST",
|
|
608
|
+
headers: {
|
|
609
|
+
"content-type": "application/json",
|
|
610
|
+
"anthropic-version": "2023-06-01"
|
|
611
|
+
},
|
|
612
|
+
body: JSON.stringify({
|
|
613
|
+
model: "claude-gpt-route",
|
|
614
|
+
max_tokens: 2048,
|
|
615
|
+
thinking: { type: "enabled", budget_tokens: 2048 },
|
|
616
|
+
messages: [{ role: "user", content: "think" }]
|
|
617
|
+
})
|
|
618
|
+
});
|
|
619
|
+
assert.equal(response.status, 400);
|
|
620
|
+
assert.equal(mock.lastChatBody(), undefined);
|
|
621
|
+
assert.match(await response.text(), /budget_tokens must be less than max_tokens/);
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
await gateway.close();
|
|
625
|
+
await mock.close();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
test("translates a streamed Anthropic message", async () => {
|
|
629
|
+
const mock = await startMock();
|
|
630
|
+
const gateway = await startGateway({
|
|
631
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
|
|
632
|
+
});
|
|
633
|
+
try {
|
|
634
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
635
|
+
method: "POST",
|
|
636
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
637
|
+
body: JSON.stringify({ model: "claude-x", max_tokens: 50, stream: true, messages: [{ role: "user", content: "hi" }] })
|
|
638
|
+
});
|
|
639
|
+
assert.equal(response.status, 200);
|
|
640
|
+
assert.equal(response.headers.get("content-type"), "text/event-stream");
|
|
641
|
+
const text = await response.text();
|
|
642
|
+
assert.ok(text.includes("event: message_start"));
|
|
643
|
+
assert.ok(text.includes("event: content_block_start"));
|
|
644
|
+
assert.ok(text.includes('"type":"text_delta","text":"Hel"'));
|
|
645
|
+
assert.ok(text.includes("event: message_stop"));
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
await gateway.close();
|
|
649
|
+
await mock.close();
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
test("estimates tokens and serves Anthropic discovery", async () => {
|
|
653
|
+
const mock = await startMock();
|
|
654
|
+
const gateway = await startGateway({
|
|
655
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
|
|
656
|
+
});
|
|
657
|
+
try {
|
|
658
|
+
const count = await fetch(`${gateway.url()}/v1/messages/count_tokens`, {
|
|
659
|
+
method: "POST",
|
|
660
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
661
|
+
body: JSON.stringify({ model: "claude-x", messages: [{ role: "user", content: "hello world" }] })
|
|
662
|
+
});
|
|
663
|
+
assert.equal(count.status, 200);
|
|
664
|
+
const counted = (await count.json());
|
|
665
|
+
assert.ok(counted.input_tokens > 0);
|
|
666
|
+
const models = await fetch(`${gateway.url()}/v1/models`, {
|
|
667
|
+
headers: { "anthropic-version": "2023-06-01" }
|
|
668
|
+
});
|
|
669
|
+
const list = (await models.json());
|
|
670
|
+
assert.ok(list.data[0]?.id.startsWith("claude"));
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
await gateway.close();
|
|
674
|
+
await mock.close();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
test("Claude picker aliases use the canonical catalog and pooled native relay", async () => {
|
|
678
|
+
const sourceCalls = [];
|
|
679
|
+
const source = (sourceId) => ({
|
|
680
|
+
sourceId,
|
|
681
|
+
discoverModels: async () => [
|
|
682
|
+
{
|
|
683
|
+
id: sourceId === "claude-code"
|
|
684
|
+
? "claude-sonnet-4-6"
|
|
685
|
+
: "gpt-5.5"
|
|
686
|
+
}
|
|
687
|
+
],
|
|
688
|
+
chat: async (body) => {
|
|
689
|
+
sourceCalls.push(body.model);
|
|
690
|
+
return Response.json({
|
|
691
|
+
id: "chatcmpl_cross_provider",
|
|
692
|
+
choices: [
|
|
693
|
+
{
|
|
694
|
+
index: 0,
|
|
695
|
+
message: { role: "assistant", content: "CROSS_PROVIDER_OK" },
|
|
696
|
+
finish_reason: "stop"
|
|
697
|
+
}
|
|
698
|
+
],
|
|
699
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
700
|
+
});
|
|
701
|
+
},
|
|
702
|
+
embeddings: async () => Response.json({})
|
|
703
|
+
});
|
|
704
|
+
const backend = await CatalogBackend.create({
|
|
705
|
+
config: {
|
|
706
|
+
providers: { "claude-code": {}, codex: {} },
|
|
707
|
+
defaultModel: "claude-code/claude-sonnet-4-6"
|
|
708
|
+
},
|
|
709
|
+
sources: {
|
|
710
|
+
"claude-code": source("claude-code"),
|
|
711
|
+
codex: source("codex")
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
const relayedBodies = [];
|
|
715
|
+
const relay = {
|
|
716
|
+
dialect: "anthropic",
|
|
717
|
+
shouldRelay: () => false,
|
|
718
|
+
relay: async (_headers, body) => {
|
|
719
|
+
relayedBodies.push(body);
|
|
720
|
+
return Response.json({
|
|
721
|
+
id: "msg_native",
|
|
722
|
+
type: "message",
|
|
723
|
+
role: "assistant",
|
|
724
|
+
model: body.model,
|
|
725
|
+
content: [{ type: "text", text: "NATIVE_OK" }],
|
|
726
|
+
stop_reason: "end_turn",
|
|
727
|
+
stop_sequence: null,
|
|
728
|
+
usage: { input_tokens: 1, output_tokens: 1 }
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
const gateway = await startGateway({
|
|
733
|
+
backend,
|
|
734
|
+
providerRelays: { anthropic: relay }
|
|
735
|
+
});
|
|
736
|
+
try {
|
|
737
|
+
const catalog = (await (await fetch(`${gateway.url()}/v1/models`, {
|
|
738
|
+
headers: { "anthropic-version": "2023-06-01" }
|
|
739
|
+
})).json());
|
|
740
|
+
assert.deepEqual(catalog.data.map(({ id, display_name }) => [id, display_name]), [
|
|
741
|
+
["claude-codex/gpt-5.5", "codex/gpt-5.5"],
|
|
742
|
+
["claude-sonnet-4-6", "claude-sonnet-4-6"]
|
|
743
|
+
]);
|
|
744
|
+
for (const model of [
|
|
745
|
+
"claude-sonnet-4-6",
|
|
746
|
+
"claude-code/claude-sonnet-4-6"
|
|
747
|
+
]) {
|
|
748
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
749
|
+
method: "POST",
|
|
750
|
+
headers: {
|
|
751
|
+
"content-type": "application/json",
|
|
752
|
+
"anthropic-version": "2023-06-01"
|
|
753
|
+
},
|
|
754
|
+
body: JSON.stringify({
|
|
755
|
+
model,
|
|
756
|
+
max_tokens: 32,
|
|
757
|
+
system: "Preserve native Anthropic fields.",
|
|
758
|
+
messages: [{ role: "user", content: "hi" }]
|
|
759
|
+
})
|
|
760
|
+
});
|
|
761
|
+
assert.equal(response.status, 200);
|
|
762
|
+
assert.equal((await response.json())
|
|
763
|
+
.content[0]?.text, "NATIVE_OK");
|
|
764
|
+
}
|
|
765
|
+
assert.deepEqual(relayedBodies.map((body) => body.model), [
|
|
766
|
+
"claude-sonnet-4-6",
|
|
767
|
+
"claude-sonnet-4-6"
|
|
768
|
+
]);
|
|
769
|
+
assert.ok(relayedBodies.every((body) => body.system === "Preserve native Anthropic fields."));
|
|
770
|
+
assert.deepEqual(sourceCalls, []);
|
|
771
|
+
const unknown = await fetch(`${gateway.url()}/v1/messages`, {
|
|
772
|
+
method: "POST",
|
|
773
|
+
headers: {
|
|
774
|
+
"content-type": "application/json",
|
|
775
|
+
"anthropic-version": "2023-06-01"
|
|
776
|
+
},
|
|
777
|
+
body: JSON.stringify({
|
|
778
|
+
model: "claude-not-real",
|
|
779
|
+
max_tokens: 32,
|
|
780
|
+
messages: [{ role: "user", content: "hi" }]
|
|
781
|
+
})
|
|
782
|
+
});
|
|
783
|
+
assert.equal(unknown.status, 400);
|
|
784
|
+
assert.match(await unknown.text(), /unknown model/);
|
|
785
|
+
assert.deepEqual(relayedBodies.map((body) => body.model), [
|
|
786
|
+
"claude-sonnet-4-6",
|
|
787
|
+
"claude-sonnet-4-6"
|
|
788
|
+
]);
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
await gateway.close();
|
|
792
|
+
}
|
|
793
|
+
});
|