@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,14 @@
1
+ /**
2
+ * OpenAI Chat Completions surface. This is the gateway's "core" dialect: it is
3
+ * what OpenAI-compatible providers speak, what opencode and the Cursor IDE
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 OpenAI-compatible providers speak, what opencode and the Cursor IDE
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,34 @@
1
+ /**
2
+ * Translate Cursor's BYOK request hybrid into OpenAI Chat Completions.
3
+ *
4
+ * When Cursor's "Override OpenAI Base URL" feature is active, Cursor POSTs to
5
+ * `{base_url}/chat/completions` but — for agent mode and GPT-family routing —
6
+ * the JSON body is shaped like the OpenAI *Responses* API (`input` item list,
7
+ * flat tool definitions, `reasoning`/`text` objects), while the response it
8
+ * renders is standard Chat Completions SSE. This module is the pure
9
+ * translation layer behind the `/v1/cursor/*` routes. It maps the
10
+ * Responses-hybrid body onto
11
+ * the Chat Completions shape the rest of the gateway already handles.
12
+ *
13
+ * No I/O happens here. The translation is total: weird-but-parseable input is
14
+ * never a reason to throw — unknown item and tool types are dropped so the
15
+ * boundary stays defensive without 4xx-ing on new shapes.
16
+ */
17
+ type JsonObject = Record<string, unknown>;
18
+ /**
19
+ * Whether a parsed request body is routable by the cursor route: a JSON
20
+ * object carrying either `messages` (plain Chat Completions, e.g. Ask mode)
21
+ * or `input` (the Responses hybrid). Rejecting other shapes is the route's
22
+ * job; the translation itself stays total.
23
+ */
24
+ export declare function isCursorChatBody(body: unknown): body is JsonObject;
25
+ /**
26
+ * Map a Cursor BYOK request body onto a Chat Completions body.
27
+ *
28
+ * Dual-shape tolerance: Cursor only sends the Responses hybrid for some
29
+ * models/modes; Ask mode may send plain Chat Completions. A body that
30
+ * already carries `messages` is returned unchanged; a body with `input`
31
+ * is translated. A body with neither yields an empty `messages` list.
32
+ */
33
+ export declare function translateCursorRequest(body: JsonObject): JsonObject;
34
+ export {};
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Translate Cursor's BYOK request hybrid into OpenAI Chat Completions.
3
+ *
4
+ * When Cursor's "Override OpenAI Base URL" feature is active, Cursor POSTs to
5
+ * `{base_url}/chat/completions` but — for agent mode and GPT-family routing —
6
+ * the JSON body is shaped like the OpenAI *Responses* API (`input` item list,
7
+ * flat tool definitions, `reasoning`/`text` objects), while the response it
8
+ * renders is standard Chat Completions SSE. This module is the pure
9
+ * translation layer behind the `/v1/cursor/*` routes. It maps the
10
+ * Responses-hybrid body onto
11
+ * the Chat Completions shape the rest of the gateway already handles.
12
+ *
13
+ * No I/O happens here. The translation is total: weird-but-parseable input is
14
+ * never a reason to throw — unknown item and tool types are dropped so the
15
+ * boundary stays defensive without 4xx-ing on new shapes.
16
+ */
17
+ import { droppedField } from "./dropped.js";
18
+ import { attachReasoningSelection, attachReasoningSelectionError } from "./openai-chat-wire.js";
19
+ /** Fields copied through unchanged when present and non-null. */
20
+ const PASSTHROUGH_FIELDS = ["model", "temperature", "top_p", "top_k", "tool_choice", "stream", "parallel_tool_calls"];
21
+ /**
22
+ * Permissive schema built for grammar-based ("custom") tools that
23
+ * declare no JSON schema of their own; the model's call output flows back as
24
+ * a normal function tool call with the raw text under `input`.
25
+ */
26
+ const CUSTOM_TOOL_PARAMETERS = {
27
+ type: "object",
28
+ properties: { input: { type: "string" } },
29
+ required: ["input"]
30
+ };
31
+ function isObject(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ /**
35
+ * Whether a parsed request body is routable by the cursor route: a JSON
36
+ * object carrying either `messages` (plain Chat Completions, e.g. Ask mode)
37
+ * or `input` (the Responses hybrid). Rejecting other shapes is the route's
38
+ * job; the translation itself stays total.
39
+ */
40
+ export function isCursorChatBody(body) {
41
+ return isObject(body) && ("messages" in body || "input" in body);
42
+ }
43
+ /**
44
+ * Map a Cursor BYOK request body onto a Chat Completions body.
45
+ *
46
+ * Dual-shape tolerance: Cursor only sends the Responses hybrid for some
47
+ * models/modes; Ask mode may send plain Chat Completions. A body that
48
+ * already carries `messages` is returned unchanged; a body with `input`
49
+ * is translated. A body with neither yields an empty `messages` list.
50
+ */
51
+ export function translateCursorRequest(body) {
52
+ if ("messages" in body) {
53
+ const passthrough = { ...body };
54
+ if (passthrough.stream === true) {
55
+ passthrough.stream_options = { include_usage: true };
56
+ }
57
+ return passthrough;
58
+ }
59
+ const translated = {};
60
+ for (const key of PASSTHROUGH_FIELDS) {
61
+ if (key in body && body[key] !== null && body[key] !== undefined) {
62
+ translated[key] = body[key];
63
+ }
64
+ }
65
+ translated.messages = inputItemsToMessages(body.input);
66
+ const tools = translateTools(body.tools);
67
+ if (tools !== undefined)
68
+ translated.tools = tools;
69
+ translateSampling(body, translated);
70
+ translateReasoning(body, translated);
71
+ translateTextFormat(body, translated);
72
+ if (translated.stream === true) {
73
+ translated.stream_options = { include_usage: true };
74
+ }
75
+ return translated;
76
+ }
77
+ /**
78
+ * Flatten a Responses-API `input` item list into chat messages.
79
+ *
80
+ * Handles message items (typed or bare role/content objects),
81
+ * `function_call` items (folded into assistant `tool_calls`, merging
82
+ * consecutive calls of the same assistant turn), `function_call_output`
83
+ * items (`tool` messages), and drops `reasoning` and unknown items.
84
+ */
85
+ function inputItemsToMessages(items) {
86
+ if (typeof items === "string") {
87
+ // The Responses API also accepts a plain string as the whole input.
88
+ return [{ role: "user", content: items }];
89
+ }
90
+ if (!Array.isArray(items))
91
+ return [];
92
+ const messages = [];
93
+ for (const item of items) {
94
+ if (!isObject(item))
95
+ continue;
96
+ const kind = item.type;
97
+ if (kind === undefined || kind === null || kind === "message") {
98
+ messages.push(messageFromItem(item));
99
+ }
100
+ else if (kind === "function_call") {
101
+ appendFunctionCall(messages, item);
102
+ }
103
+ else if (kind === "function_call_output") {
104
+ messages.push({
105
+ role: "tool",
106
+ tool_call_id: asStr(item.call_id),
107
+ content: stringify(item.output)
108
+ });
109
+ }
110
+ else if (kind === "reasoning") {
111
+ droppedField("cursor", "reasoning", "input");
112
+ }
113
+ else if (kind !== undefined && kind !== null) {
114
+ droppedField("cursor", asStr(kind), "input");
115
+ }
116
+ }
117
+ return messages;
118
+ }
119
+ function messageFromItem(item) {
120
+ let role = item.role;
121
+ if (role === "developer")
122
+ role = "system";
123
+ if (role !== "system" && role !== "user" && role !== "assistant" && role !== "tool") {
124
+ role = "user";
125
+ }
126
+ return { role, content: contentText(item.content) };
127
+ }
128
+ /**
129
+ * Concatenate the text parts of a Responses content value.
130
+ *
131
+ * Accepts a plain string or a parts list (`input_text` / `output_text` /
132
+ * anything else carrying a string `text`); non-text parts such as
133
+ * `input_image` are ignored rather than rejected.
134
+ */
135
+ function contentText(content) {
136
+ if (typeof content === "string")
137
+ return content;
138
+ if (Array.isArray(content)) {
139
+ const parts = [];
140
+ for (const part of content) {
141
+ if (typeof part === "string")
142
+ parts.push(part);
143
+ else if (isObject(part)) {
144
+ if (part.type === "input_file") {
145
+ droppedField("cursor", "input_file");
146
+ continue;
147
+ }
148
+ if (part.type === "refusal" && typeof part.text === "string") {
149
+ parts.push(part.text);
150
+ continue;
151
+ }
152
+ if (typeof part.text === "string")
153
+ parts.push(part.text);
154
+ }
155
+ }
156
+ return parts.join("");
157
+ }
158
+ return "";
159
+ }
160
+ /**
161
+ * Fold a `function_call` item into the current assistant turn.
162
+ *
163
+ * Consecutive function calls after the same assistant message extend that
164
+ * message's `tool_calls` list; otherwise a new assistant message opens.
165
+ */
166
+ function appendFunctionCall(messages, item) {
167
+ const call = {
168
+ id: asStr(item.call_id) || asStr(item.id),
169
+ type: "function",
170
+ function: {
171
+ name: asStr(item.name),
172
+ arguments: stringify(item.arguments) || "{}"
173
+ }
174
+ };
175
+ const last = messages.length > 0 ? messages[messages.length - 1] : undefined;
176
+ if (last !== undefined && last.role === "assistant") {
177
+ if (!Array.isArray(last.tool_calls))
178
+ last.tool_calls = [];
179
+ last.tool_calls.push(call);
180
+ return;
181
+ }
182
+ messages.push({ role: "assistant", content: "", tool_calls: [call] });
183
+ }
184
+ /**
185
+ * Map Responses tool definitions onto Chat Completions nested tools.
186
+ *
187
+ * Cursor sends flat function tools (`{type: "function", name, ...}`) and
188
+ * grammar-based custom tools (`{type: "custom", name, format}`). Flat tools
189
+ * are nested under `function`; custom tools become plain function tools with
190
+ * their declared schema or a permissive generated one. Already-nested
191
+ * tools pass through unchanged; other typed entries are forwarded as-is for
192
+ * the gateway's downstream tool normalization.
193
+ */
194
+ function translateTools(tools) {
195
+ if (!Array.isArray(tools) || tools.length === 0)
196
+ return undefined;
197
+ const translated = [];
198
+ for (const entry of tools) {
199
+ if (!isObject(entry))
200
+ continue;
201
+ if (isObject(entry.function)) {
202
+ translated.push(entry);
203
+ continue;
204
+ }
205
+ const kind = entry.type;
206
+ if (kind === "function") {
207
+ translated.push(nestedFunctionTool(entry, defaultParameters(entry)));
208
+ }
209
+ else if (kind === "custom") {
210
+ translated.push(nestedFunctionTool(entry, customParameters(entry)));
211
+ }
212
+ else {
213
+ // Typed nameless tools (e.g. web_search) and future shapes flow
214
+ // through; the downstream tool normalization decides their fate.
215
+ translated.push(entry);
216
+ }
217
+ }
218
+ return translated.length > 0 ? translated : undefined;
219
+ }
220
+ function nestedFunctionTool(entry, parameters) {
221
+ return {
222
+ type: "function",
223
+ function: {
224
+ name: asStr(entry.name),
225
+ description: asStr(entry.description),
226
+ parameters
227
+ }
228
+ };
229
+ }
230
+ function defaultParameters(entry) {
231
+ if (isObject(entry.parameters))
232
+ return entry.parameters;
233
+ return { type: "object", properties: {} };
234
+ }
235
+ function customParameters(entry) {
236
+ if (isObject(entry.parameters))
237
+ return entry.parameters;
238
+ return { ...CUSTOM_TOOL_PARAMETERS };
239
+ }
240
+ /** Fold Responses sampling names into Chat Completions ones in place. */
241
+ function translateSampling(body, translated) {
242
+ const maxTokens = body.max_output_tokens ?? body.max_tokens;
243
+ if (typeof maxTokens === "number" && Number.isInteger(maxTokens)) {
244
+ translated.max_completion_tokens = maxTokens;
245
+ }
246
+ }
247
+ function translateReasoning(body, translated) {
248
+ const reasoning = body.reasoning;
249
+ if (reasoning === undefined || reasoning === null)
250
+ return;
251
+ if (!isObject(reasoning)) {
252
+ attachReasoningSelectionError(translated, "reasoning must contain a non-empty effort string");
253
+ return;
254
+ }
255
+ const effort = reasoning.effort;
256
+ if (typeof effort === "string" && effort.length > 0) {
257
+ translated.reasoning_effort = effort;
258
+ attachReasoningSelection(translated, { mode: "effort", effort });
259
+ return;
260
+ }
261
+ attachReasoningSelectionError(translated, "reasoning.effort must be a non-empty string");
262
+ }
263
+ function translateTextFormat(body, translated) {
264
+ const text = body.text;
265
+ if (!isObject(text))
266
+ return;
267
+ const format = text.format;
268
+ if (!isObject(format) || typeof format.type !== "string")
269
+ return;
270
+ switch (format.type) {
271
+ case "json_schema":
272
+ translated.response_format = {
273
+ type: "json_schema",
274
+ json_schema: {
275
+ ...(typeof format.name === "string" ? { name: format.name } : {}),
276
+ ...(format.schema !== undefined ? { schema: format.schema } : {}),
277
+ ...(typeof format.strict === "boolean" ? { strict: format.strict } : {})
278
+ }
279
+ };
280
+ break;
281
+ case "json_object":
282
+ translated.response_format = { type: "json_object" };
283
+ break;
284
+ default:
285
+ droppedField("cursor", "text");
286
+ break;
287
+ }
288
+ }
289
+ function asStr(value) {
290
+ return typeof value === "string" ? value : "";
291
+ }
292
+ /** Stringify a non-string tool output/arguments value losslessly. */
293
+ function stringify(value) {
294
+ if (typeof value === "string")
295
+ return value;
296
+ if (value === null || value === undefined)
297
+ return "";
298
+ try {
299
+ const encoded = JSON.stringify(value);
300
+ return encoded === undefined ? String(value) : encoded;
301
+ }
302
+ catch {
303
+ return String(value);
304
+ }
305
+ }
@@ -0,0 +1,10 @@
1
+ export type DialectName = "anthropic" | "responses" | "cursor";
2
+ export declare const DIALECT_DROPPED_ATTRIBUTE = "routekit.dialect.dropped";
3
+ export type DroppedFieldSpan = {
4
+ span: {
5
+ setAttribute(name: string, value: string[]): unknown;
6
+ };
7
+ };
8
+ export declare function withDroppedFieldSpan<T>(span: DroppedFieldSpan, fn: () => T): T;
9
+ export declare function droppedField(dialect: DialectName, field: string, context?: string): void;
10
+ export declare function resetDroppedFieldWarnings(): void;
@@ -0,0 +1,24 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const DIALECT_DROPPED_ATTRIBUTE = "routekit.dialect.dropped";
3
+ const ambientSpan = new AsyncLocalStorage();
4
+ const spanEntries = new WeakMap();
5
+ const warned = new Set();
6
+ export function withDroppedFieldSpan(span, fn) {
7
+ return ambientSpan.run(span, fn);
8
+ }
9
+ export function droppedField(dialect, field, context) {
10
+ const entry = context !== undefined ? `${dialect}.${context}.${field}` : `${dialect}.${field}`;
11
+ const target = ambientSpan.getStore()?.span;
12
+ if (target !== undefined) {
13
+ const entries = [...(spanEntries.get(target) ?? []), entry];
14
+ spanEntries.set(target, entries);
15
+ target.setAttribute(DIALECT_DROPPED_ATTRIBUTE, entries);
16
+ }
17
+ if (warned.has(entry))
18
+ return;
19
+ warned.add(entry);
20
+ process.stderr.write(`routekit gateway: ${dialect} field "${context !== undefined ? `${context}.` : ""}${field}" is not translated and was dropped\n`);
21
+ }
22
+ export function resetDroppedFieldWarnings() {
23
+ warned.clear();
24
+ }
@@ -0,0 +1,93 @@
1
+ import type { ReasoningSelection } from "@velum-labs/routekit-contracts";
2
+ export type OpenAiToolCall = {
3
+ id?: string;
4
+ index?: number;
5
+ function?: {
6
+ name?: string;
7
+ arguments?: string;
8
+ };
9
+ };
10
+ /**
11
+ * Lossless Anthropic reasoning metadata carried beside the portable
12
+ * `reasoning` string. Native Anthropic streams need block lifecycle and opaque
13
+ * signatures to survive a round trip; other dialects can ignore this field.
14
+ */
15
+ export type AnthropicReasoningDetail = {
16
+ type: "thinking";
17
+ index: number;
18
+ phase?: "start" | "delta" | "signature" | "stop";
19
+ thinking?: string;
20
+ signature?: string;
21
+ } | {
22
+ type: "redacted_thinking";
23
+ index: number;
24
+ phase?: "block";
25
+ data: string;
26
+ };
27
+ export declare function anthropicReasoningDetailsOf(value: unknown, mode: "message" | "stream"): AnthropicReasoningDetail[];
28
+ export type AnthropicThinkingConfig = {
29
+ type: "enabled";
30
+ budget_tokens: number;
31
+ display?: "summarized" | "omitted" | null;
32
+ } | {
33
+ type: "adaptive";
34
+ display?: "summarized" | "omitted" | null;
35
+ } | {
36
+ type: "disabled";
37
+ };
38
+ export type AnthropicRequestMetadata = {
39
+ thinking?: AnthropicThinkingConfig;
40
+ output_config?: {
41
+ effort?: string | null;
42
+ [key: string]: unknown;
43
+ } | null;
44
+ };
45
+ /**
46
+ * Symbol-keyed metadata stays in-process through object spreads while
47
+ * JSON.stringify omits it. This lets an Anthropic backend receive exact native
48
+ * controls/history without leaking Anthropic-only fields to OpenAI providers.
49
+ */
50
+ export declare const ANTHROPIC_REQUEST_METADATA: unique symbol;
51
+ export declare const ANTHROPIC_MESSAGE_CONTENT: unique symbol;
52
+ export declare const REASONING_SELECTION: unique symbol;
53
+ export declare const REASONING_SELECTION_ERROR: unique symbol;
54
+ export declare function attachReasoningSelection(target: Record<PropertyKey, unknown>, selection: ReasoningSelection): void;
55
+ export declare function attachReasoningSelectionError(target: Record<PropertyKey, unknown>, message: string): void;
56
+ export declare function reasoningSelectionErrorOf(value: unknown): string | undefined;
57
+ export declare function reasoningSelectionOf(value: unknown): ReasoningSelection;
58
+ export type AnthropicNativeContentBlock = {
59
+ type: "text";
60
+ text: string;
61
+ } | {
62
+ type: "thinking";
63
+ thinking: string;
64
+ signature: string;
65
+ } | {
66
+ type: "redacted_thinking";
67
+ data: string;
68
+ } | {
69
+ type: "tool_use";
70
+ id: string;
71
+ name: string;
72
+ input: unknown;
73
+ };
74
+ export type OpenAiDelta = {
75
+ content?: string | null;
76
+ reasoning?: string | null;
77
+ reasoning_content?: string | null;
78
+ reasoning_details?: AnthropicReasoningDetail[];
79
+ tool_calls?: OpenAiToolCall[];
80
+ };
81
+ export type OpenAiChoice = {
82
+ delta?: OpenAiDelta;
83
+ message?: {
84
+ content?: string | null;
85
+ reasoning?: string | null;
86
+ reasoning_content?: string | null;
87
+ reasoning_details?: AnthropicReasoningDetail[];
88
+ tool_calls?: OpenAiToolCall[];
89
+ };
90
+ finish_reason?: string | null;
91
+ anthropic_stop_reason?: string | null;
92
+ anthropic_stop_sequence?: string | null;
93
+ };
@@ -0,0 +1,143 @@
1
+ export function anthropicReasoningDetailsOf(value, mode) {
2
+ if (!Array.isArray(value))
3
+ return [];
4
+ return value.flatMap((candidate) => {
5
+ if (candidate === null ||
6
+ typeof candidate !== "object" ||
7
+ Array.isArray(candidate)) {
8
+ return [];
9
+ }
10
+ const detail = candidate;
11
+ if (!Number.isInteger(detail.index) ||
12
+ detail.index < 0) {
13
+ return [];
14
+ }
15
+ const index = detail.index;
16
+ if (detail.type === "redacted_thinking") {
17
+ if (typeof detail.data !== "string" ||
18
+ (mode === "stream" && detail.phase !== "block") ||
19
+ (mode === "message" && detail.phase !== undefined)) {
20
+ return [];
21
+ }
22
+ return [
23
+ {
24
+ type: "redacted_thinking",
25
+ index,
26
+ ...(mode === "stream" ? { phase: "block" } : {}),
27
+ data: detail.data
28
+ }
29
+ ];
30
+ }
31
+ if (detail.type !== "thinking")
32
+ return [];
33
+ if (mode === "message") {
34
+ if (detail.phase !== undefined ||
35
+ typeof detail.thinking !== "string" ||
36
+ typeof detail.signature !== "string") {
37
+ return [];
38
+ }
39
+ return [
40
+ {
41
+ type: "thinking",
42
+ index,
43
+ thinking: detail.thinking,
44
+ signature: detail.signature
45
+ }
46
+ ];
47
+ }
48
+ if (detail.phase === "start") {
49
+ if (detail.signature !== undefined &&
50
+ typeof detail.signature !== "string") {
51
+ return [];
52
+ }
53
+ return [
54
+ {
55
+ type: "thinking",
56
+ index,
57
+ phase: "start",
58
+ ...(typeof detail.signature === "string"
59
+ ? { signature: detail.signature }
60
+ : {})
61
+ }
62
+ ];
63
+ }
64
+ if (detail.phase === "delta" && typeof detail.thinking === "string") {
65
+ return [
66
+ {
67
+ type: "thinking",
68
+ index,
69
+ phase: "delta",
70
+ thinking: detail.thinking
71
+ }
72
+ ];
73
+ }
74
+ if (detail.phase === "signature" &&
75
+ typeof detail.signature === "string") {
76
+ return [
77
+ {
78
+ type: "thinking",
79
+ index,
80
+ phase: "signature",
81
+ signature: detail.signature
82
+ }
83
+ ];
84
+ }
85
+ if (detail.phase === "stop") {
86
+ return [{ type: "thinking", index, phase: "stop" }];
87
+ }
88
+ return [];
89
+ });
90
+ }
91
+ /**
92
+ * Symbol-keyed metadata stays in-process through object spreads while
93
+ * JSON.stringify omits it. This lets an Anthropic backend receive exact native
94
+ * controls/history without leaking Anthropic-only fields to OpenAI providers.
95
+ */
96
+ export const ANTHROPIC_REQUEST_METADATA = Symbol.for("@velum-labs/routekit-gateway/anthropic-request-metadata");
97
+ export const ANTHROPIC_MESSAGE_CONTENT = Symbol.for("@velum-labs/routekit-gateway/anthropic-message-content");
98
+ export const REASONING_SELECTION = Symbol.for("@velum-labs/routekit-gateway/reasoning-selection");
99
+ export const REASONING_SELECTION_ERROR = Symbol.for("@velum-labs/routekit-gateway/reasoning-selection-error");
100
+ export function attachReasoningSelection(target, selection) {
101
+ Object.defineProperty(target, REASONING_SELECTION, {
102
+ value: selection,
103
+ enumerable: true
104
+ });
105
+ }
106
+ export function attachReasoningSelectionError(target, message) {
107
+ Object.defineProperty(target, REASONING_SELECTION_ERROR, {
108
+ value: message,
109
+ enumerable: true
110
+ });
111
+ }
112
+ export function reasoningSelectionErrorOf(value) {
113
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
114
+ return undefined;
115
+ }
116
+ const record = value;
117
+ const attachedError = record[REASONING_SELECTION_ERROR];
118
+ if (typeof attachedError === "string")
119
+ return attachedError;
120
+ if (Object.hasOwn(record, "reasoning_effort") &&
121
+ (typeof record.reasoning_effort !== "string" ||
122
+ record.reasoning_effort.length === 0)) {
123
+ return "reasoning_effort must be a non-empty string";
124
+ }
125
+ return undefined;
126
+ }
127
+ export function reasoningSelectionOf(value) {
128
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
129
+ const record = value;
130
+ const attached = record[REASONING_SELECTION];
131
+ if (attached !== null &&
132
+ typeof attached === "object" &&
133
+ !Array.isArray(attached) &&
134
+ typeof attached.mode === "string") {
135
+ return attached;
136
+ }
137
+ if (typeof record.reasoning_effort === "string" &&
138
+ record.reasoning_effort.length > 0) {
139
+ return { mode: "effort", effort: record.reasoning_effort };
140
+ }
141
+ }
142
+ return { mode: "auto" };
143
+ }
@@ -0,0 +1,7 @@
1
+ type ResponsesToolKind = "function" | "custom" | "typed" | "server";
2
+ type ResponsesToolRegistry = ReadonlyMap<string, {
3
+ kind: ResponsesToolKind;
4
+ namespace?: string;
5
+ }>;
6
+ export declare function openAiSseToResponses(upstream: ReadableStream<Uint8Array>, model: string, toolRegistry?: ResponsesToolRegistry): ReadableStream<Uint8Array>;
7
+ export {};