@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.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/acp-agent.d.ts +38 -0
  4. package/dist/acp-agent.js +142 -0
  5. package/dist/acp-registry.d.ts +36 -0
  6. package/dist/acp-registry.js +85 -0
  7. package/dist/adapters/anthropic.d.ts +131 -0
  8. package/dist/adapters/anthropic.js +1195 -0
  9. package/dist/adapters/chat.d.ts +14 -0
  10. package/dist/adapters/chat.js +34 -0
  11. package/dist/adapters/cursor.d.ts +34 -0
  12. package/dist/adapters/cursor.js +305 -0
  13. package/dist/adapters/dropped.d.ts +10 -0
  14. package/dist/adapters/dropped.js +24 -0
  15. package/dist/adapters/openai-chat-wire.d.ts +93 -0
  16. package/dist/adapters/openai-chat-wire.js +143 -0
  17. package/dist/adapters/responses-stream.d.ts +7 -0
  18. package/dist/adapters/responses-stream.js +597 -0
  19. package/dist/adapters/responses.d.ts +174 -0
  20. package/dist/adapters/responses.js +778 -0
  21. package/dist/adapters/server-tool-loop.d.ts +94 -0
  22. package/dist/adapters/server-tool-loop.js +477 -0
  23. package/dist/adapters/upstream-error.d.ts +14 -0
  24. package/dist/adapters/upstream-error.js +25 -0
  25. package/dist/adapters/validate.d.ts +27 -0
  26. package/dist/adapters/validate.js +180 -0
  27. package/dist/adapters/web-search.d.ts +46 -0
  28. package/dist/adapters/web-search.js +151 -0
  29. package/dist/auth.d.ts +10 -0
  30. package/dist/auth.js +28 -0
  31. package/dist/backend.d.ts +151 -0
  32. package/dist/backend.js +143 -0
  33. package/dist/capacity-pool.d.ts +31 -0
  34. package/dist/capacity-pool.js +99 -0
  35. package/dist/cost.d.ts +49 -0
  36. package/dist/cost.js +112 -0
  37. package/dist/endpoint-health.d.ts +54 -0
  38. package/dist/endpoint-health.js +123 -0
  39. package/dist/index.d.ts +40 -0
  40. package/dist/index.js +23 -0
  41. package/dist/provenance.d.ts +31 -0
  42. package/dist/provenance.js +191 -0
  43. package/dist/provider-backends.d.ts +40 -0
  44. package/dist/provider-backends.js +1050 -0
  45. package/dist/provider-source.d.ts +40 -0
  46. package/dist/provider-source.js +293 -0
  47. package/dist/router.d.ts +168 -0
  48. package/dist/router.js +474 -0
  49. package/dist/server.d.ts +67 -0
  50. package/dist/server.js +930 -0
  51. package/dist/sse/chat-assembler.d.ts +45 -0
  52. package/dist/sse/chat-assembler.js +190 -0
  53. package/dist/sse/parse.d.ts +50 -0
  54. package/dist/sse/parse.js +149 -0
  55. package/dist/sse-wire.d.ts +10 -0
  56. package/dist/sse-wire.js +31 -0
  57. package/dist/switching-proxy.d.ts +15 -0
  58. package/dist/switching-proxy.js +232 -0
  59. package/dist/test/acp-agent.test.d.ts +1 -0
  60. package/dist/test/acp-agent.test.js +66 -0
  61. package/dist/test/acp-registry.test.d.ts +1 -0
  62. package/dist/test/acp-registry.test.js +70 -0
  63. package/dist/test/anthropic.test.d.ts +1 -0
  64. package/dist/test/anthropic.test.js +793 -0
  65. package/dist/test/auth.test.d.ts +1 -0
  66. package/dist/test/auth.test.js +25 -0
  67. package/dist/test/boundary.test.d.ts +1 -0
  68. package/dist/test/boundary.test.js +32 -0
  69. package/dist/test/chat.test.d.ts +1 -0
  70. package/dist/test/chat.test.js +418 -0
  71. package/dist/test/cost.test.d.ts +1 -0
  72. package/dist/test/cost.test.js +60 -0
  73. package/dist/test/cursor.test.d.ts +1 -0
  74. package/dist/test/cursor.test.js +100 -0
  75. package/dist/test/drain.test.d.ts +1 -0
  76. package/dist/test/drain.test.js +116 -0
  77. package/dist/test/dropped.test.d.ts +1 -0
  78. package/dist/test/dropped.test.js +80 -0
  79. package/dist/test/endpoint-health.test.d.ts +1 -0
  80. package/dist/test/endpoint-health.test.js +73 -0
  81. package/dist/test/provenance.test.d.ts +1 -0
  82. package/dist/test/provenance.test.js +176 -0
  83. package/dist/test/provider-backends.test.d.ts +1 -0
  84. package/dist/test/provider-backends.test.js +699 -0
  85. package/dist/test/responses.test.d.ts +1 -0
  86. package/dist/test/responses.test.js +813 -0
  87. package/dist/test/routed-backend.test.d.ts +1 -0
  88. package/dist/test/routed-backend.test.js +39 -0
  89. package/dist/test/router.test.d.ts +1 -0
  90. package/dist/test/router.test.js +297 -0
  91. package/dist/test/server-resilience.test.d.ts +1 -0
  92. package/dist/test/server-resilience.test.js +169 -0
  93. package/dist/test/sse-codec.test.d.ts +1 -0
  94. package/dist/test/sse-codec.test.js +186 -0
  95. package/dist/test/web-search-loop.test.d.ts +1 -0
  96. package/dist/test/web-search-loop.test.js +469 -0
  97. package/dist/test/wire-validation.test.d.ts +1 -0
  98. package/dist/test/wire-validation.test.js +140 -0
  99. package/package.json +48 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Acceptance tests for the single SSE codec (WS5.1).
3
+ *
4
+ * These tests define the contract for `SseDecoder` (incremental, spec-compliant
5
+ * server-sent-event parsing) and `ChatStreamAssembler` (OpenAI-chat delta
6
+ * assembly done once, correctly). Every hand-rolled `data:`-line parser in the
7
+ * gateway migrates onto these two classes.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { test } from "node:test";
11
+ import { ChatStreamAssembler } from "../sse/chat-assembler.js";
12
+ import { SseDecoder, SseParseError } from "../sse/parse.js";
13
+ function events(decoder, ...chunks) {
14
+ const out = [];
15
+ for (const chunk of chunks)
16
+ out.push(...decoder.feed(chunk));
17
+ return out;
18
+ }
19
+ // ---- SseDecoder ----
20
+ test("decodes a simple data event", () => {
21
+ const decoder = new SseDecoder();
22
+ assert.deepEqual(events(decoder, 'data: {"a":1}\n\n'), [{ data: '{"a":1}' }]);
23
+ });
24
+ test("joins multi-line data: fields with newlines per the SSE spec", () => {
25
+ // One event, two data lines -> payload is the lines joined by "\n". The old
26
+ // per-line parsers would misread this as two separate JSON documents.
27
+ const decoder = new SseDecoder();
28
+ const got = events(decoder, 'data: {"content":\ndata: "hi"}\n\n');
29
+ assert.deepEqual(got, [{ data: '{"content":\n"hi"}' }]);
30
+ });
31
+ test("carries event: and id: fields", () => {
32
+ const decoder = new SseDecoder();
33
+ const got = events(decoder, "event: message_start\nid: 7\ndata: {}\n\n");
34
+ assert.deepEqual(got, [{ event: "message_start", id: "7", data: "{}" }]);
35
+ });
36
+ test("ignores comment lines and unknown fields", () => {
37
+ const decoder = new SseDecoder();
38
+ const got = events(decoder, ": keepalive\nretry: 100\ndata: x\n\n: another\n\n");
39
+ assert.deepEqual(got, [{ data: "x" }]);
40
+ });
41
+ test("handles events split at arbitrary byte boundaries, including inside a UTF-8 rune", () => {
42
+ const decoder = new SseDecoder();
43
+ const bytes = new TextEncoder().encode('data: {"t":"héllo"}\n\ndata: [DONE]\n\n');
44
+ const collected = [];
45
+ // Feed one byte at a time: no chunking may ever split an event or corrupt a rune.
46
+ for (const byte of bytes)
47
+ collected.push(...decoder.feed(new Uint8Array([byte])));
48
+ assert.deepEqual(collected, [{ data: '{"t":"héllo"}' }, { data: "[DONE]" }]);
49
+ });
50
+ test("accepts CRLF line endings", () => {
51
+ const decoder = new SseDecoder();
52
+ assert.deepEqual(events(decoder, "data: a\r\n\r\ndata: b\r\n\r\n"), [{ data: "a" }, { data: "b" }]);
53
+ });
54
+ test("data: without a space after the colon is accepted", () => {
55
+ const decoder = new SseDecoder();
56
+ assert.deepEqual(events(decoder, "data:tight\n\n"), [{ data: "tight" }]);
57
+ });
58
+ test("flush on a clean boundary returns nothing", () => {
59
+ const decoder = new SseDecoder();
60
+ decoder.feed("data: done\n\n");
61
+ assert.deepEqual(decoder.flush(), []);
62
+ });
63
+ test("flush surfaces a trailing partial event as SseParseError, not silence", () => {
64
+ const decoder = new SseDecoder();
65
+ decoder.feed('data: {"complete":true}\n\ndata: {"cut-off-mid');
66
+ assert.throws(() => decoder.flush(), SseParseError);
67
+ });
68
+ test("large events arrive intact across many feeds", () => {
69
+ const decoder = new SseDecoder();
70
+ const payload = "x".repeat(100_000);
71
+ const wire = `data: ${payload}\n\n`;
72
+ const collected = [];
73
+ for (let i = 0; i < wire.length; i += 1_000)
74
+ collected.push(...decoder.feed(wire.slice(i, i + 1_000)));
75
+ assert.equal(collected.length, 1);
76
+ assert.equal(collected[0]?.data, payload);
77
+ });
78
+ // ---- ChatStreamAssembler ----
79
+ function feedAssembler(assembler, ...payloads) {
80
+ for (const data of payloads)
81
+ assembler.push({ data });
82
+ }
83
+ function chunk(delta, finish) {
84
+ return JSON.stringify({ choices: [{ index: 0, delta, finish_reason: finish ?? null }] });
85
+ }
86
+ test("assembles content and reasoning deltas in order", () => {
87
+ const assembler = new ChatStreamAssembler();
88
+ feedAssembler(assembler, chunk({ content: "Hel" }), chunk({ reasoning: "thinking…" }), chunk({ content: "lo" }), chunk({}, "stop"), "[DONE]");
89
+ const turn = assembler.result();
90
+ assert.equal(turn.content, "Hello");
91
+ assert.equal(turn.reasoning, "thinking…");
92
+ assert.equal(turn.finishReason, "stop");
93
+ assert.equal(assembler.truncated, false);
94
+ });
95
+ test("merges fragmented tool-call arguments across chunks by index", () => {
96
+ const assembler = new ChatStreamAssembler();
97
+ feedAssembler(assembler, chunk({ tool_calls: [{ index: 0, id: "call_a", function: { name: "read", arguments: '{"pa' } }] }), chunk({ tool_calls: [{ index: 0, function: { arguments: 'th":"a.txt"}' } }] }), chunk({}, "tool_calls"), "[DONE]");
98
+ const turn = assembler.result();
99
+ assert.equal(turn.toolCalls.length, 1);
100
+ assert.deepEqual(turn.toolCalls[0], { id: "call_a", name: "read", arguments: '{"path":"a.txt"}' });
101
+ assert.equal(turn.finishReason, "tool_calls");
102
+ });
103
+ test("keeps parallel tool calls separate when interleaved by index", () => {
104
+ const assembler = new ChatStreamAssembler();
105
+ feedAssembler(assembler, chunk({ tool_calls: [{ index: 0, id: "call_a", function: { name: "read", arguments: '{"a"' } }] }), chunk({ tool_calls: [{ index: 1, id: "call_b", function: { name: "write", arguments: '{"b"' } }] }), chunk({ tool_calls: [{ index: 0, function: { arguments: ":1}" } }] }), chunk({ tool_calls: [{ index: 1, function: { arguments: ":2}" } }] }), chunk({}, "tool_calls"), "[DONE]");
106
+ const turn = assembler.result();
107
+ assert.deepEqual(turn.toolCalls, [
108
+ { id: "call_a", name: "read", arguments: '{"a":1}' },
109
+ { id: "call_b", name: "write", arguments: '{"b":2}' }
110
+ ]);
111
+ });
112
+ test("parallel calls without index stay separate via id fallback", () => {
113
+ // Some upstreams (Anthropic/Responses translations) omit `index`. Two calls
114
+ // with distinct ids must not merge into one concatenated-arguments call.
115
+ const assembler = new ChatStreamAssembler();
116
+ feedAssembler(assembler, chunk({ tool_calls: [{ id: "call_a", function: { name: "read", arguments: '{"a":1}' } }] }), chunk({ tool_calls: [{ id: "call_b", function: { name: "write", arguments: '{"b":2}' } }] }), chunk({}, "tool_calls"), "[DONE]");
117
+ const turn = assembler.result();
118
+ assert.deepEqual(turn.toolCalls, [
119
+ { id: "call_a", name: "read", arguments: '{"a":1}' },
120
+ { id: "call_b", name: "write", arguments: '{"b":2}' }
121
+ ]);
122
+ });
123
+ test("id-and-index-less argument fragments append to the last open call", () => {
124
+ const assembler = new ChatStreamAssembler();
125
+ feedAssembler(assembler, chunk({ tool_calls: [{ id: "call_a", function: { name: "run", arguments: '{"cmd":' } }] }), chunk({ tool_calls: [{ function: { arguments: '"ls"}' } }] }), chunk({}, "tool_calls"), "[DONE]");
126
+ const turn = assembler.result();
127
+ assert.deepEqual(turn.toolCalls, [{ id: "call_a", name: "run", arguments: '{"cmd":"ls"}' }]);
128
+ });
129
+ test("captures usage and extension metadata from any chunk", () => {
130
+ const assembler = new ChatStreamAssembler();
131
+ feedAssembler(assembler, chunk({ content: "ok" }), JSON.stringify({
132
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
133
+ usage: { prompt_tokens: 3, completion_tokens: 5 },
134
+ route: { request_id: "request_1" }
135
+ }), "[DONE]");
136
+ const turn = assembler.result();
137
+ assert.deepEqual(turn.usage, { prompt_tokens: 3, completion_tokens: 5 });
138
+ assert.deepEqual(turn.extensions.route, { request_id: "request_1" });
139
+ });
140
+ test("merges split stream usage and rejects malformed reasoning metadata", () => {
141
+ const assembler = new ChatStreamAssembler();
142
+ feedAssembler(assembler, JSON.stringify({ choices: [], usage: { prompt_tokens: 7 } }), chunk({
143
+ reasoning_details: [
144
+ { type: "attacker_block", index: 0, phase: "start", data: "leak" },
145
+ { type: "redacted_thinking", index: 1, phase: "block", data: 42 }
146
+ ]
147
+ }), JSON.stringify({
148
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
149
+ usage: { completion_tokens: 3 }
150
+ }));
151
+ const turn = assembler.result();
152
+ assert.deepEqual(turn.reasoningDetails, []);
153
+ assert.deepEqual(turn.usage, {
154
+ prompt_tokens: 7,
155
+ completion_tokens: 3,
156
+ total_tokens: 10
157
+ });
158
+ });
159
+ test("a stream that ends without finish_reason is truncated, not a clean stop", () => {
160
+ const assembler = new ChatStreamAssembler();
161
+ feedAssembler(assembler, chunk({ content: "partial answ" }));
162
+ // No finish_reason chunk, no [DONE]: the caller sees truncation.
163
+ assert.equal(assembler.truncated, true);
164
+ const turn = assembler.result();
165
+ assert.equal(turn.content, "partial answ");
166
+ assert.equal(turn.finishReason, undefined);
167
+ });
168
+ test("[DONE] without a prior finish_reason still counts as truncated", () => {
169
+ const assembler = new ChatStreamAssembler();
170
+ feedAssembler(assembler, chunk({ content: "answer" }), "[DONE]");
171
+ assert.equal(assembler.truncated, true);
172
+ });
173
+ test("finish_reason followed by [DONE] is a clean stop", () => {
174
+ const assembler = new ChatStreamAssembler();
175
+ feedAssembler(assembler, chunk({ content: "answer" }), chunk({}, "stop"), "[DONE]");
176
+ assert.equal(assembler.truncated, false);
177
+ });
178
+ test("malformed JSON surfaces as SseParseError instead of being swallowed", () => {
179
+ const assembler = new ChatStreamAssembler();
180
+ assert.throws(() => assembler.push({ data: '{"choices": [ oops' }), SseParseError);
181
+ });
182
+ test("empty-data keepalive events are ignored", () => {
183
+ const assembler = new ChatStreamAssembler();
184
+ feedAssembler(assembler, "", chunk({ content: "hi" }), chunk({}, "stop"), "[DONE]");
185
+ assert.equal(assembler.result().content, "hi");
186
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,469 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { chatToResponses, responsesToChat, responsesToolRegistry } from "../adapters/responses.js";
4
+ import { anthropicToChat, chatToAnthropicMessage, openAiSseToAnthropic } from "../adapters/anthropic.js";
5
+ import { openAiSseToResponses } from "../adapters/responses-stream.js";
6
+ import { composeServerToolStream, runBufferedServerToolLoop } from "../adapters/server-tool-loop.js";
7
+ import { ANTHROPIC_MESSAGE_CONTENT } from "../adapters/openai-chat-wire.js";
8
+ import { resolveWebSearchExecutor } from "../adapters/web-search.js";
9
+ /**
10
+ * Server-tool loop coverage (gateway-executed web search): executor selection,
11
+ * ingress projection/replay in both dialects, the buffered and streaming inner
12
+ * loops, and the native item rendering in both egress translators.
13
+ */
14
+ // Verbatim shape from Codex 0.142: a nameless server-executed typed tool.
15
+ const WEB_SEARCH_DECL = { type: "web_search" };
16
+ function fakeExecutor(results) {
17
+ const queries = [];
18
+ return {
19
+ provider: "openai",
20
+ model: "fake-search",
21
+ queries,
22
+ async search(query) {
23
+ queries.push(query);
24
+ const outcome = results[query];
25
+ if (outcome === undefined)
26
+ throw new Error(`no fake result for query: ${query}`);
27
+ return outcome;
28
+ }
29
+ };
30
+ }
31
+ function jsonResponse(value) {
32
+ return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
33
+ }
34
+ function chatCompletion(message, finishReason = "stop") {
35
+ return {
36
+ id: "cmpl-x",
37
+ choices: [{ index: 0, message: { role: "assistant", ...message }, finish_reason: finishReason }],
38
+ usage: { prompt_tokens: 10, completion_tokens: 5 }
39
+ };
40
+ }
41
+ function sseStream(...events) {
42
+ const body = new ReadableStream({
43
+ start(controller) {
44
+ for (const event of events)
45
+ controller.enqueue(new TextEncoder().encode(event));
46
+ controller.close();
47
+ }
48
+ });
49
+ return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
50
+ }
51
+ function chunk(delta, finishReason = null, extra = {}) {
52
+ return `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: finishReason }], ...extra })}\n\n`;
53
+ }
54
+ // ---- executor selection ----
55
+ test("resolveWebSearchExecutor prefers the dialect's own provider and falls back", () => {
56
+ const both = { OPENAI_API_KEY: "sk-a", ANTHROPIC_API_KEY: "sk-b" };
57
+ assert.equal(resolveWebSearchExecutor("responses", both)?.provider, "openai");
58
+ assert.equal(resolveWebSearchExecutor("anthropic", both)?.provider, "anthropic");
59
+ assert.equal(resolveWebSearchExecutor("anthropic", { OPENAI_API_KEY: "sk-a" })?.provider, "openai");
60
+ assert.equal(resolveWebSearchExecutor("responses", { ANTHROPIC_API_KEY: "sk-b" })?.provider, "anthropic");
61
+ assert.equal(resolveWebSearchExecutor("responses", {}), undefined);
62
+ assert.equal(resolveWebSearchExecutor("responses", { ...both, ROUTEKIT_WEB_SEARCH: "0" }), undefined);
63
+ });
64
+ // ---- Responses ingress ----
65
+ test("responsesToolRegistry registers web_search as a server tool only when enabled", () => {
66
+ const body = { tools: [WEB_SEARCH_DECL] };
67
+ assert.equal(responsesToolRegistry(body).has("web_search"), false);
68
+ assert.equal(responsesToolRegistry(body, { serverTools: true }).get("web_search")?.kind, "server");
69
+ });
70
+ test("responsesToChat projects web_search as a function tool when enabled", () => {
71
+ const chat = responsesToChat({ input: "x", tools: [WEB_SEARCH_DECL] }, "local-model", { serverTools: true });
72
+ const tools = chat.tools;
73
+ assert.deepEqual(tools.map((tool) => tool.function.name), ["web_search"]);
74
+ assert.deepEqual(tools[0]?.function.parameters.required, ["query"]);
75
+ // Disabled: dropped as before (no tools at all).
76
+ const dropped = responsesToChat({ input: "x", tools: [WEB_SEARCH_DECL] }, "local-model");
77
+ assert.equal(dropped.tools, undefined);
78
+ });
79
+ test("responsesToChat folds an echoed id-less web_search_call into assistant context", () => {
80
+ // Verbatim echo shape from Codex 0.142: no id, no call_id, no results.
81
+ const chat = responsesToChat({
82
+ input: [
83
+ { type: "message", role: "user", content: "what is new?" },
84
+ { type: "web_search_call", status: "completed", action: { type: "search", query: "latest node lts" } },
85
+ { type: "message", role: "assistant", content: "Node 24 is the LTS." },
86
+ { type: "message", role: "user", content: "since when?" }
87
+ ]
88
+ }, "local-model");
89
+ const messages = chat.messages;
90
+ assert.equal(messages.length, 4);
91
+ assert.equal(messages[1]?.role, "assistant");
92
+ assert.match(String(messages[1]?.content), /searched the web for: "latest node lts"/);
93
+ });
94
+ // ---- buffered loop ----
95
+ test("runBufferedServerToolLoop executes searches and loops to the final answer", async () => {
96
+ const steps = [
97
+ chatCompletion({ content: null, tool_calls: [{ id: "call_1", function: { name: "web_search", arguments: '{"query":"node lts"}' } }] }, "tool_calls"),
98
+ chatCompletion({ content: "Node 24 is the LTS." })
99
+ ];
100
+ let stepIndex = 0;
101
+ const chat = { model: "m", messages: [{ role: "user", content: "what is the LTS?" }] };
102
+ const executor = fakeExecutor({ "node lts": { text: "Node.js 24 is the active LTS.", citations: [{ url: "https://nodejs.org", title: "Node.js" }] } });
103
+ const firstStep = jsonResponse(steps[stepIndex++]);
104
+ const outcome = await runBufferedServerToolLoop({
105
+ chat,
106
+ firstStep,
107
+ runStep: async () => jsonResponse(steps[stepIndex++]),
108
+ serverToolNames: new Set(["web_search"]),
109
+ executor
110
+ });
111
+ assert.equal(outcome.kind, "openai");
112
+ if (outcome.kind !== "openai")
113
+ return;
114
+ assert.deepEqual(executor.queries, ["node lts"]);
115
+ assert.equal(outcome.searches.length, 1);
116
+ assert.equal(outcome.searches[0]?.status, "completed");
117
+ assert.deepEqual(outcome.openai.usage, {
118
+ prompt_tokens: 20,
119
+ completion_tokens: 10,
120
+ total_tokens: 30
121
+ });
122
+ // The transcript got the assistant tool call + tool result appended.
123
+ const messages = chat.messages;
124
+ assert.deepEqual(messages.map((message) => message.role), ["user", "assistant", "tool"]);
125
+ assert.match(String(messages[2]?.content), /Node\.js 24 is the active LTS/);
126
+ assert.match(String(messages[2]?.content), /https:\/\/nodejs\.org/);
127
+ // The final Responses payload renders the native item before the message.
128
+ const rendered = chatToResponses(outcome.openai, "route-primary", responsesToolRegistry({ tools: [WEB_SEARCH_DECL] }, { serverTools: true }), outcome.searches);
129
+ const output = rendered.output;
130
+ assert.equal(output[0]?.type, "web_search_call");
131
+ assert.equal((output[0]?.action).query, "node lts");
132
+ assert.equal(output[1]?.type, "message");
133
+ });
134
+ test("runBufferedServerToolLoop replays signed Anthropic thinking before a server tool continuation", async () => {
135
+ const first = chatCompletion({
136
+ content: null,
137
+ reasoning: "I should search.",
138
+ reasoning_details: [
139
+ {
140
+ type: "thinking",
141
+ index: 0,
142
+ thinking: "I should search.",
143
+ signature: "sig-native"
144
+ }
145
+ ],
146
+ tool_calls: [
147
+ {
148
+ id: "call_1",
149
+ function: { name: "web_search", arguments: '{"query":"routekit"}' }
150
+ }
151
+ ]
152
+ }, "tool_calls");
153
+ const chat = {
154
+ model: "m",
155
+ messages: [{ role: "user", content: "look it up" }]
156
+ };
157
+ let replayed;
158
+ const outcome = await runBufferedServerToolLoop({
159
+ chat,
160
+ firstStep: jsonResponse(first),
161
+ runStep: async (next) => {
162
+ const assistant = next.messages[1];
163
+ replayed = assistant?.[ANTHROPIC_MESSAGE_CONTENT];
164
+ return jsonResponse(chatCompletion({ content: "done" }));
165
+ },
166
+ serverToolNames: new Set(["web_search"]),
167
+ executor: fakeExecutor({
168
+ routekit: { text: "RouteKit result", citations: [] }
169
+ })
170
+ });
171
+ assert.equal(outcome.kind, "openai");
172
+ assert.deepEqual(replayed?.map((block) => block.type), ["thinking", "tool_use"]);
173
+ assert.equal(replayed?.[0]?.signature, "sig-native");
174
+ if (outcome.kind !== "openai")
175
+ return;
176
+ const rendered = chatToAnthropicMessage(outcome.openai, "route-primary", outcome.searches, outcome.events);
177
+ assert.deepEqual(rendered.content.map((block) => block.type), ["thinking", "server_tool_use", "web_search_tool_result", "text"]);
178
+ assert.equal(rendered.content[0]?.signature, "sig-native");
179
+ });
180
+ test("runBufferedServerToolLoop surfaces mixed batches and drops the server calls", async () => {
181
+ const step = chatCompletion({
182
+ content: null,
183
+ tool_calls: [
184
+ { id: "call_ws", function: { name: "web_search", arguments: '{"query":"x"}' } },
185
+ { id: "call_sh", function: { name: "shell", arguments: '{"cmd":"ls"}' } }
186
+ ]
187
+ }, "tool_calls");
188
+ const executor = fakeExecutor({});
189
+ const outcome = await runBufferedServerToolLoop({
190
+ chat: { model: "m", messages: [] },
191
+ firstStep: jsonResponse(step),
192
+ runStep: async () => {
193
+ throw new Error("must not run a second step");
194
+ },
195
+ serverToolNames: new Set(["web_search"]),
196
+ executor
197
+ });
198
+ assert.equal(outcome.kind, "openai");
199
+ if (outcome.kind !== "openai")
200
+ return;
201
+ assert.equal(executor.queries.length, 0);
202
+ const message = outcome.openai.choices[0]?.message;
203
+ assert.deepEqual(message?.tool_calls.map((call) => call.id), ["call_sh"]);
204
+ });
205
+ test("a failed search becomes an error tool result, not a failed turn", async () => {
206
+ const steps = [
207
+ chatCompletion({ content: null, tool_calls: [{ id: "call_1", function: { name: "web_search", arguments: '{"query":"broken"}' } }] }, "tool_calls"),
208
+ chatCompletion({ content: "Could not verify; answering from training data." })
209
+ ];
210
+ let stepIndex = 0;
211
+ const chat = { model: "m", messages: [] };
212
+ const outcome = await runBufferedServerToolLoop({
213
+ chat,
214
+ firstStep: jsonResponse(steps[stepIndex++]),
215
+ runStep: async () => jsonResponse(steps[stepIndex++]),
216
+ serverToolNames: new Set(["web_search"]),
217
+ executor: fakeExecutor({})
218
+ });
219
+ assert.equal(outcome.kind, "openai");
220
+ if (outcome.kind !== "openai")
221
+ return;
222
+ assert.equal(outcome.searches[0]?.status, "failed");
223
+ const messages = chat.messages;
224
+ assert.match(String(messages[messages.length - 1]?.content), /web_search_error/);
225
+ });
226
+ test("the per-turn search cap yields limit tool results instead of executions", async () => {
227
+ const searchStep = () => chatCompletion({ content: null, tool_calls: [{ id: "c", function: { name: "web_search", arguments: '{"query":"q"}' } }] }, "tool_calls");
228
+ const finalStep = chatCompletion({ content: "done" });
229
+ let calls = 0;
230
+ const chat = { model: "m", messages: [] };
231
+ const executor = fakeExecutor({ q: { text: "r", citations: [] } });
232
+ const outcome = await runBufferedServerToolLoop({
233
+ chat,
234
+ firstStep: jsonResponse(searchStep()),
235
+ runStep: async () => jsonResponse(calls++ === 0 ? searchStep() : finalStep),
236
+ serverToolNames: new Set(["web_search"]),
237
+ executor,
238
+ maxSearches: 1
239
+ });
240
+ assert.equal(outcome.kind, "openai");
241
+ if (outcome.kind !== "openai")
242
+ return;
243
+ assert.equal(executor.queries.length, 1);
244
+ const messages = chat.messages;
245
+ assert.ok(messages.some((message) => String(message.content).includes("web_search_limit")));
246
+ });
247
+ // ---- streaming loop + Responses egress ----
248
+ test("composeServerToolStream renders native web_search_call items and one completed response", async () => {
249
+ const firstStep = sseStream(chunk({ content: "Let me check. " }), chunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "web_search", arguments: '{"query":"node lts"}' } }] }), chunk({}, "tool_calls", { usage: { prompt_tokens: 10, completion_tokens: 4 } }), "data: [DONE]\n\n");
250
+ const secondStep = sseStream(chunk({ content: "Node 24 is the LTS." }), chunk({}, "stop", { usage: { prompt_tokens: 20, completion_tokens: 6 } }), "data: [DONE]\n\n");
251
+ const stepQueue = [secondStep];
252
+ const chat = { model: "m", messages: [], stream: true };
253
+ const executor = fakeExecutor({ "node lts": { text: "Node.js 24 is LTS.", citations: [{ url: "https://nodejs.org" }] } });
254
+ const composed = composeServerToolStream({
255
+ chat,
256
+ firstStep,
257
+ runStep: async () => {
258
+ const next = stepQueue.shift();
259
+ if (next === undefined)
260
+ throw new Error("no more steps");
261
+ return next;
262
+ },
263
+ serverToolNames: new Set(["web_search"]),
264
+ executor
265
+ });
266
+ const registry = responsesToolRegistry({ tools: [WEB_SEARCH_DECL] }, { serverTools: true });
267
+ const text = await new Response(openAiSseToResponses(composed, "route-primary", registry)).text();
268
+ assert.ok(text.includes('"type":"web_search_call"'), "native search item emitted");
269
+ assert.ok(text.includes("response.web_search_call.searching"), "search lifecycle events emitted");
270
+ assert.ok(!text.includes('"type":"function_call"'), "the server tool never surfaces as a function_call");
271
+ assert.equal(text.split("event: response.completed").length, 2, "exactly one terminal response.completed");
272
+ const completedEvent = text.split("\n\n").find((event) => event.startsWith("event: response.completed"));
273
+ assert.ok(completedEvent !== undefined);
274
+ const payload = JSON.parse(completedEvent.slice(completedEvent.indexOf("data:") + 5));
275
+ assert.deepEqual(payload.response.output.map((item) => item.type).sort(), ["message", "web_search_call"]);
276
+ // Usage sums both model steps.
277
+ assert.equal(payload.response.usage.input_tokens, 30);
278
+ assert.equal(payload.response.usage.output_tokens, 10);
279
+ // The full text of both steps reached the message item.
280
+ assert.ok(text.includes("Let me check."));
281
+ assert.ok(text.includes("Node 24 is the LTS."));
282
+ });
283
+ test("composeServerToolStream carries streamed signed thinking into the continuation request", async () => {
284
+ const firstStep = sseStream(chunk({
285
+ reasoning_details: [
286
+ { type: "thinking", index: 0, phase: "start", signature: "" }
287
+ ]
288
+ }), chunk({
289
+ reasoning: "search first",
290
+ reasoning_details: [
291
+ {
292
+ type: "thinking",
293
+ index: 0,
294
+ phase: "delta",
295
+ thinking: "search first"
296
+ }
297
+ ]
298
+ }), chunk({
299
+ reasoning_details: [
300
+ {
301
+ type: "thinking",
302
+ index: 0,
303
+ phase: "signature",
304
+ signature: "sig-stream-loop"
305
+ }
306
+ ]
307
+ }), chunk({
308
+ reasoning_details: [
309
+ { type: "thinking", index: 0, phase: "stop" }
310
+ ]
311
+ }), chunk({
312
+ tool_calls: [
313
+ {
314
+ index: 0,
315
+ id: "call_1",
316
+ function: {
317
+ name: "web_search",
318
+ arguments: '{"query":"routekit"}'
319
+ }
320
+ }
321
+ ]
322
+ }), chunk({}, "tool_calls"), "data: [DONE]\n\n");
323
+ const secondStep = sseStream(chunk({
324
+ reasoning_details: [
325
+ { type: "thinking", index: 0, phase: "start", signature: "" }
326
+ ]
327
+ }), chunk({
328
+ reasoning: "answer now",
329
+ reasoning_details: [
330
+ {
331
+ type: "thinking",
332
+ index: 0,
333
+ phase: "delta",
334
+ thinking: "answer now"
335
+ }
336
+ ]
337
+ }), chunk({
338
+ reasoning_details: [
339
+ {
340
+ type: "thinking",
341
+ index: 0,
342
+ phase: "signature",
343
+ signature: "sig-second-step"
344
+ },
345
+ { type: "thinking", index: 0, phase: "stop" }
346
+ ]
347
+ }), chunk({ content: "done" }), chunk({}, "stop"), "data: [DONE]\n\n");
348
+ const chat = {
349
+ model: "m",
350
+ messages: [],
351
+ stream: true
352
+ };
353
+ let replayed;
354
+ const composed = composeServerToolStream({
355
+ chat,
356
+ firstStep,
357
+ runStep: async (next) => {
358
+ const assistant = next.messages[0];
359
+ replayed = assistant?.[ANTHROPIC_MESSAGE_CONTENT];
360
+ return secondStep;
361
+ },
362
+ serverToolNames: new Set(["web_search"]),
363
+ executor: fakeExecutor({
364
+ routekit: { text: "RouteKit result", citations: [] }
365
+ })
366
+ });
367
+ const translated = await new Response(openAiSseToAnthropic(composed, "route-primary")).text();
368
+ assert.deepEqual(replayed?.map((block) => block.type), [
369
+ "thinking",
370
+ "tool_use"
371
+ ]);
372
+ assert.equal(replayed?.[0]?.signature, "sig-stream-loop");
373
+ assert.match(translated, /"thinking":"answer now"/);
374
+ assert.match(translated, /"signature":"sig-second-step"/);
375
+ });
376
+ // ---- Anthropic dialect ----
377
+ const ANTHROPIC_TOOLS = [
378
+ { type: "web_search_20250305", name: "web_search" },
379
+ { name: "Bash", input_schema: { type: "object", properties: {} } },
380
+ { type: "code_execution_20250522", name: "code_execution" }
381
+ ];
382
+ test("anthropicToChat projects web_search when enabled and keeps code_execution dropped", () => {
383
+ const body = { messages: [{ role: "user", content: "hi" }], tools: ANTHROPIC_TOOLS };
384
+ const chat = anthropicToChat(body, "local-model", { serverTools: true });
385
+ const names = chat.tools.map((tool) => tool.function.name);
386
+ assert.deepEqual(names.sort(), ["Bash", "web_search"]);
387
+ const disabled = anthropicToChat(body, "local-model");
388
+ const disabledNames = disabled.tools.map((tool) => tool.function.name);
389
+ assert.deepEqual(disabledNames, ["Bash"]);
390
+ });
391
+ test("anthropicToChat replays echoed server_tool_use + web_search_tool_result as a tool exchange", () => {
392
+ const body = {
393
+ messages: [
394
+ { role: "user", content: "what is new?" },
395
+ {
396
+ role: "assistant",
397
+ content: [
398
+ { type: "server_tool_use", id: "srv_1", name: "web_search", input: { query: "node lts" } },
399
+ {
400
+ type: "web_search_tool_result",
401
+ tool_use_id: "srv_1",
402
+ content: [{ type: "web_search_result", url: "https://nodejs.org", title: "Node.js", encrypted_content: "opaque" }]
403
+ },
404
+ { type: "text", text: "Node 24 is the LTS." }
405
+ ]
406
+ },
407
+ { role: "user", content: "since when?" }
408
+ ]
409
+ };
410
+ const chat = anthropicToChat(body, "local-model");
411
+ const messages = chat.messages;
412
+ assert.deepEqual(messages.map((message) => message.role), ["user", "assistant", "tool", "assistant", "user"]);
413
+ assert.equal(messages[2]?.tool_call_id, "srv_1");
414
+ assert.match(String(messages[2]?.content), /nodejs\.org/);
415
+ assert.ok(!String(messages[2]?.content).includes("opaque"), "encrypted_content is stripped");
416
+ assert.equal(messages[3]?.content, "Node 24 is the LTS.");
417
+ });
418
+ test("chatToAnthropicMessage renders executed searches as native blocks", () => {
419
+ const openai = {
420
+ choices: [{ index: 0, message: { role: "assistant", content: "Node 24." }, finish_reason: "stop" }]
421
+ };
422
+ const rendered = chatToAnthropicMessage(openai, "route-primary", [
423
+ {
424
+ itemId: "srv_1",
425
+ query: "node lts",
426
+ status: "completed",
427
+ outcome: {
428
+ text: "Node 24 is LTS.",
429
+ citations: [{ url: "https://nodejs.org", title: "Node.js" }],
430
+ anthropicResultBlocks: [{ type: "web_search_result", url: "https://nodejs.org", title: "Node.js", encrypted_content: "e" }]
431
+ }
432
+ }
433
+ ]);
434
+ const content = rendered.content;
435
+ assert.deepEqual(content.map((block) => block.type), ["server_tool_use", "web_search_tool_result", "text"]);
436
+ assert.deepEqual(content[0]?.input, { query: "node lts" });
437
+ // Anthropic-native result blocks pass through verbatim.
438
+ assert.deepEqual((content[1]?.content)[0]?.encrypted_content, "e");
439
+ });
440
+ test("openAiSseToAnthropic renders loop markers as native search blocks", async () => {
441
+ const firstStep = sseStream(chunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "web_search", arguments: '{"query":"node lts"}' } }] }), chunk({}, "tool_calls"), "data: [DONE]\n\n");
442
+ const secondStep = sseStream(chunk({ content: "Node 24." }), chunk({}, "stop"), "data: [DONE]\n\n");
443
+ const chat = { model: "m", messages: [], stream: true };
444
+ const executor = {
445
+ provider: "anthropic",
446
+ model: "fake",
447
+ async search() {
448
+ return {
449
+ text: "Node 24 is LTS.",
450
+ citations: [{ url: "https://nodejs.org" }],
451
+ anthropicResultBlocks: [{ type: "web_search_result", url: "https://nodejs.org", title: "Node.js" }]
452
+ };
453
+ }
454
+ };
455
+ const composed = composeServerToolStream({
456
+ chat,
457
+ firstStep,
458
+ runStep: async () => secondStep,
459
+ serverToolNames: new Set(["web_search"]),
460
+ executor
461
+ });
462
+ const text = await new Response(openAiSseToAnthropic(composed, "route-primary")).text();
463
+ assert.ok(text.includes('"type":"server_tool_use"'));
464
+ assert.ok(text.includes('"type":"web_search_tool_result"'));
465
+ assert.ok(text.includes('"url":"https://nodejs.org"'));
466
+ assert.ok(!text.includes('"type":"tool_use","id":"call_1"'), "the server call never surfaces as a client tool_use");
467
+ assert.ok(text.includes('"stop_reason":"end_turn"'));
468
+ assert.equal(text.split("event: message_stop").length, 2, "exactly one message_stop");
469
+ });
@@ -0,0 +1 @@
1
+ export {};