@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,180 @@
1
+ /**
2
+ * Structural request validation at the gateway's wire doors.
3
+ *
4
+ * Hostile-input fuzzing showed that malformed bodies (a `model` array, a
5
+ * `messages` string, an empty body) sailed past the doors, hit deep code, and
6
+ * surfaced as 502 `upstream_error`s carrying raw TypeError text
7
+ * ("requested.startsWith is not a function", "body.messages is not iterable")
8
+ * or internal implementation details. A caller error must be a 400 in the
9
+ * door's native error envelope, and it must never reach a provider.
10
+ *
11
+ * Validation here is *structural only* — field presence and JSON types
12
+ * matching what the real providers enforce. Semantic rules (role enums,
13
+ * schema shapes) stay with the providers, so the doors never reject a shape a
14
+ * real provider would accept.
15
+ */
16
+ function isObject(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function openAiError(message) {
20
+ return {
21
+ status: 400,
22
+ body: { error: { message, type: "invalid_request_error", code: "invalid_request" } }
23
+ };
24
+ }
25
+ function anthropicError(message) {
26
+ return {
27
+ status: 400,
28
+ body: { type: "error", error: { type: "invalid_request_error", message } }
29
+ };
30
+ }
31
+ /** Validate the common message-array shape without imposing a dialect's role enum. */
32
+ function checkMessages(body, shape, options = {}) {
33
+ const messages = body.messages;
34
+ if (!Array.isArray(messages)) {
35
+ return shape("`messages` is required and must be an array of message objects");
36
+ }
37
+ if (messages.length === 0 && options.allowEmpty !== true) {
38
+ return shape("`messages` must contain at least one message");
39
+ }
40
+ for (const message of messages) {
41
+ if (!isObject(message) || typeof message.role !== "string") {
42
+ return shape("every message must be an object with a string `role`");
43
+ }
44
+ if (message.role === "tool" &&
45
+ (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0)) {
46
+ return shape("tool messages require a non-empty `tool_call_id`");
47
+ }
48
+ const content = message.content;
49
+ if (content === null && options.allowNullContent === false) {
50
+ return shape("message `content` must not be null");
51
+ }
52
+ if (content !== undefined && content !== null && typeof content !== "string" && !Array.isArray(content)) {
53
+ return shape("message `content` must be a string, an array of content parts, or null");
54
+ }
55
+ if (Array.isArray(content) && content.some((part) => !isObject(part))) {
56
+ return shape("every message content part must be an object");
57
+ }
58
+ const toolCalls = message.tool_calls;
59
+ if (toolCalls !== undefined && toolCalls !== null) {
60
+ if (!Array.isArray(toolCalls)) {
61
+ return shape("message `tool_calls` must be an array");
62
+ }
63
+ if (message.role !== "assistant") {
64
+ return shape("message `tool_calls` are only valid on assistant messages");
65
+ }
66
+ for (const call of toolCalls) {
67
+ if (!isObject(call))
68
+ return shape("every tool call must be an object");
69
+ const functionCall = isObject(call.function) ? call.function : call;
70
+ if (typeof functionCall.name !== "string" || functionCall.name.length === 0) {
71
+ return shape("every tool call requires a function name");
72
+ }
73
+ if (typeof functionCall.arguments !== "string") {
74
+ return shape("tool-call `arguments` must be a JSON string");
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(functionCall.arguments);
78
+ if (!isObject(parsed)) {
79
+ return shape("tool-call `arguments` must encode a JSON object");
80
+ }
81
+ }
82
+ catch {
83
+ return shape("tool-call `arguments` must be valid JSON");
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ function checkModel(body, shape) {
91
+ if (body.model !== undefined && typeof body.model !== "string") {
92
+ return shape("`model` must be a string");
93
+ }
94
+ return undefined;
95
+ }
96
+ function checkStream(body, shape) {
97
+ if (body.stream !== undefined && body.stream !== null && typeof body.stream !== "boolean") {
98
+ return shape("`stream` must be a boolean");
99
+ }
100
+ return undefined;
101
+ }
102
+ function checkPositiveInteger(body, field, shape) {
103
+ const value = body[field];
104
+ if (value !== undefined &&
105
+ value !== null &&
106
+ (typeof value !== "number" || !Number.isInteger(value) || value < 1)) {
107
+ return shape(`\`${field}\` must be a positive integer`);
108
+ }
109
+ return undefined;
110
+ }
111
+ function checkTools(body, shape) {
112
+ const tools = body.tools;
113
+ if (tools === undefined || tools === null)
114
+ return undefined;
115
+ if (!Array.isArray(tools)) {
116
+ return shape("`tools` must be an array of tool definitions");
117
+ }
118
+ if (tools.some((tool) => !isObject(tool))) {
119
+ return shape("every tool definition must be an object");
120
+ }
121
+ return undefined;
122
+ }
123
+ /** OpenAI Chat Completions door (`/v1/chat/completions`). */
124
+ export function validateChatRequest(body) {
125
+ if (!isObject(body))
126
+ return openAiError("request body must be a JSON object");
127
+ return (checkModel(body, openAiError) ??
128
+ checkStream(body, openAiError) ??
129
+ checkPositiveInteger(body, "max_tokens", openAiError) ??
130
+ checkPositiveInteger(body, "max_completion_tokens", openAiError) ??
131
+ checkMessages(body, openAiError) ??
132
+ checkTools(body, openAiError));
133
+ }
134
+ /** Anthropic Messages door (`/v1/messages`). */
135
+ export function validateAnthropicRequest(body) {
136
+ if (!isObject(body))
137
+ return anthropicError("request body must be a JSON object");
138
+ const system = body.system;
139
+ return (checkModel(body, anthropicError) ??
140
+ checkStream(body, anthropicError) ??
141
+ checkPositiveInteger(body, "max_tokens", anthropicError) ??
142
+ checkMessages(body, anthropicError, { allowNullContent: false }) ??
143
+ checkTools(body, anthropicError) ??
144
+ (system !== undefined && system !== null && typeof system !== "string" && !Array.isArray(system)
145
+ ? anthropicError("`system` must be a string or an array of text blocks")
146
+ : undefined));
147
+ }
148
+ /** Anthropic `count_tokens` door: same message-shape contract, no minimum length. */
149
+ export function validateCountTokensRequest(body) {
150
+ if (!isObject(body))
151
+ return anthropicError("request body must be a JSON object");
152
+ return checkMessages(body, anthropicError, {
153
+ allowEmpty: true,
154
+ allowNullContent: false
155
+ });
156
+ }
157
+ /** OpenAI Responses door (`/v1/responses`). */
158
+ export function validateResponsesRequest(body) {
159
+ if (!isObject(body))
160
+ return openAiError("request body must be a JSON object");
161
+ const model = checkModel(body, openAiError) ??
162
+ checkStream(body, openAiError) ??
163
+ checkPositiveInteger(body, "max_output_tokens", openAiError) ??
164
+ checkTools(body, openAiError);
165
+ if (model !== undefined)
166
+ return model;
167
+ const input = body.input;
168
+ if (typeof input === "string")
169
+ return undefined;
170
+ if (Array.isArray(input)) {
171
+ if (input.length === 0)
172
+ return openAiError("`input` must not be an empty array");
173
+ for (const item of input) {
174
+ if (!isObject(item))
175
+ return openAiError("every `input` item must be an object");
176
+ }
177
+ return undefined;
178
+ }
179
+ return openAiError("`input` is required and must be a string or an array of input items");
180
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Gateway-side web search execution (server-tool parity).
3
+ *
4
+ * `web_search` is a *server-executed* tool: callers (Codex, Claude Code, API
5
+ * clients) declare it but never run it — on the real provider APIs the
6
+ * backend searches mid-turn. Nothing behind this gateway can do that, so the
7
+ * gateway becomes the server: the dialect adapters project the tool to the
8
+ * upstream model, and when it calls the tool the server-tool loop executes the
9
+ * search here by delegating to a real provider's native web search in a
10
+ * one-shot, buffered side call.
11
+ *
12
+ * Each dialect prefers its own provider (result and citation shapes match
13
+ * what the caller's provider would have produced), falling back to the other
14
+ * provider when only one key is available. With no key at all the feature is
15
+ * off and the adapters keep their honest-drop behavior.
16
+ */
17
+ export type WebSearchCitation = {
18
+ url: string;
19
+ title?: string;
20
+ };
21
+ export type WebSearchOutcome = {
22
+ /** The answer/result text the upstream model reads. */
23
+ text: string;
24
+ citations: WebSearchCitation[];
25
+ /**
26
+ * Anthropic-native `web_search_result` blocks, verbatim, when the Anthropic
27
+ * executor served the search — the Anthropic egress passes them through for
28
+ * exact result-block parity. Absent for other executors.
29
+ */
30
+ anthropicResultBlocks?: unknown[];
31
+ };
32
+ export type WebSearchExecutor = {
33
+ readonly provider: "openai" | "anthropic";
34
+ readonly model: string;
35
+ search(query: string, signal?: AbortSignal): Promise<WebSearchOutcome>;
36
+ };
37
+ export type WebSearchDialect = "responses" | "anthropic";
38
+ /** Hard ceiling on gateway-executed searches within one caller turn. */
39
+ export declare const MAX_WEB_SEARCHES_PER_TURN = 8;
40
+ /**
41
+ * The executor for a dialect: the matching provider when its key is present,
42
+ * the other provider as fallback (working search beats provider purity), or
43
+ * `undefined` when the feature is off (`ROUTEKIT_WEB_SEARCH=0` or no keys),
44
+ * in which case the adapters keep dropping the tool with a warning.
45
+ */
46
+ export declare function resolveWebSearchExecutor(dialect: WebSearchDialect, env?: Record<string, string | undefined>): WebSearchExecutor | undefined;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Gateway-side web search execution (server-tool parity).
3
+ *
4
+ * `web_search` is a *server-executed* tool: callers (Codex, Claude Code, API
5
+ * clients) declare it but never run it — on the real provider APIs the
6
+ * backend searches mid-turn. Nothing behind this gateway can do that, so the
7
+ * gateway becomes the server: the dialect adapters project the tool to the
8
+ * upstream model, and when it calls the tool the server-tool loop executes the
9
+ * search here by delegating to a real provider's native web search in a
10
+ * one-shot, buffered side call.
11
+ *
12
+ * Each dialect prefers its own provider (result and citation shapes match
13
+ * what the caller's provider would have produced), falling back to the other
14
+ * provider when only one key is available. With no key at all the feature is
15
+ * off and the adapters keep their honest-drop behavior.
16
+ */
17
+ import { withDeadline } from "@velum-labs/routekit-runtime";
18
+ /** Hard ceiling on gateway-executed searches within one caller turn. */
19
+ export const MAX_WEB_SEARCHES_PER_TURN = 8;
20
+ /** Per-search wall clock before the delegated call is aborted. */
21
+ const SEARCH_TIMEOUT_MS = 90_000;
22
+ const OPENAI_DEFAULT_MODEL = "gpt-5.5";
23
+ const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5";
24
+ const SEARCH_PROMPT_PREFIX = "Search the web and report what you find, including source URLs. " +
25
+ "Be factual and concise; do not editorialize. Query:\n\n";
26
+ function searchError(provider, status, detail) {
27
+ return new Error(`web search via ${provider} failed (${status}): ${detail.slice(0, 500)}`);
28
+ }
29
+ function openAiExecutor(apiKey, env) {
30
+ const model = env.ROUTEKIT_WEB_SEARCH_OPENAI_MODEL ?? OPENAI_DEFAULT_MODEL;
31
+ const baseUrl = env.ROUTEKIT_WEB_SEARCH_OPENAI_URL ?? "https://api.openai.com/v1";
32
+ return {
33
+ provider: "openai",
34
+ model,
35
+ async search(query, signal) {
36
+ const response = await fetch(`${baseUrl}/responses`, {
37
+ method: "POST",
38
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
39
+ body: JSON.stringify({
40
+ model,
41
+ reasoning: { effort: "low" },
42
+ tools: [{ type: "web_search" }],
43
+ tool_choice: "auto",
44
+ input: `${SEARCH_PROMPT_PREFIX}${query}`
45
+ }),
46
+ signal: withDeadline(signal, SEARCH_TIMEOUT_MS)
47
+ });
48
+ if (!response.ok)
49
+ throw searchError("openai", response.status, await response.text());
50
+ const payload = (await response.json());
51
+ const texts = [];
52
+ const citations = [];
53
+ for (const item of payload.output ?? []) {
54
+ if (item.type !== "message")
55
+ continue;
56
+ for (const part of item.content ?? []) {
57
+ if (typeof part.text === "string" && part.text.length > 0)
58
+ texts.push(part.text);
59
+ // Citation annotations are best-effort: validated live runs sometimes
60
+ // return an answer with an empty annotations array.
61
+ for (const annotation of part.annotations ?? []) {
62
+ if (annotation.type === "url_citation" && typeof annotation.url === "string") {
63
+ citations.push({
64
+ url: annotation.url,
65
+ ...(typeof annotation.title === "string" ? { title: annotation.title } : {})
66
+ });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return { text: texts.join("\n"), citations };
72
+ }
73
+ };
74
+ }
75
+ function anthropicExecutor(apiKey, env) {
76
+ const model = env.ROUTEKIT_WEB_SEARCH_ANTHROPIC_MODEL ?? ANTHROPIC_DEFAULT_MODEL;
77
+ const baseUrl = env.ROUTEKIT_WEB_SEARCH_ANTHROPIC_URL ?? "https://api.anthropic.com/v1";
78
+ return {
79
+ provider: "anthropic",
80
+ model,
81
+ async search(query, signal) {
82
+ const response = await fetch(`${baseUrl}/messages`, {
83
+ method: "POST",
84
+ headers: {
85
+ "x-api-key": apiKey,
86
+ "anthropic-version": "2023-06-01",
87
+ "content-type": "application/json"
88
+ },
89
+ body: JSON.stringify({
90
+ model,
91
+ max_tokens: 2048,
92
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 3 }],
93
+ messages: [{ role: "user", content: `${SEARCH_PROMPT_PREFIX}${query}` }]
94
+ }),
95
+ signal: withDeadline(signal, SEARCH_TIMEOUT_MS)
96
+ });
97
+ if (!response.ok)
98
+ throw searchError("anthropic", response.status, await response.text());
99
+ const payload = (await response.json());
100
+ const texts = [];
101
+ const citations = [];
102
+ const resultBlocks = [];
103
+ for (const block of payload.content ?? []) {
104
+ if (block.type === "text" && typeof block.text === "string" && block.text.length > 0) {
105
+ texts.push(block.text);
106
+ }
107
+ if (block.type === "web_search_tool_result" && Array.isArray(block.content)) {
108
+ resultBlocks.push(...block.content);
109
+ for (const result of block.content) {
110
+ if (result.type === "web_search_result" && typeof result.url === "string") {
111
+ citations.push({
112
+ url: result.url,
113
+ ...(typeof result.title === "string" ? { title: result.title } : {})
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ text: texts.join("\n"),
121
+ citations,
122
+ ...(resultBlocks.length > 0 ? { anthropicResultBlocks: resultBlocks } : {})
123
+ };
124
+ }
125
+ };
126
+ }
127
+ // ---- selection ----
128
+ /**
129
+ * The executor for a dialect: the matching provider when its key is present,
130
+ * the other provider as fallback (working search beats provider purity), or
131
+ * `undefined` when the feature is off (`ROUTEKIT_WEB_SEARCH=0` or no keys),
132
+ * in which case the adapters keep dropping the tool with a warning.
133
+ */
134
+ export function resolveWebSearchExecutor(dialect, env = process.env) {
135
+ if (env.ROUTEKIT_WEB_SEARCH === "0")
136
+ return undefined;
137
+ const openAiKey = env.OPENAI_API_KEY;
138
+ const anthropicKey = env.ANTHROPIC_API_KEY;
139
+ const openAi = openAiKey !== undefined && openAiKey.length > 0 ? openAiExecutor(openAiKey, env) : undefined;
140
+ const anthropic = anthropicKey !== undefined && anthropicKey.length > 0 ? anthropicExecutor(anthropicKey, env) : undefined;
141
+ switch (dialect) {
142
+ case "responses":
143
+ return openAi ?? anthropic;
144
+ case "anthropic":
145
+ return anthropic ?? openAi;
146
+ default: {
147
+ const exhaustive = dialect;
148
+ throw new Error(`unknown web search dialect: ${String(exhaustive)}`);
149
+ }
150
+ }
151
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ /** Constant-time string equality (length-independent; no timing leaks). */
3
+ export declare function timingSafeStringEqual(a: string, b: string): boolean;
4
+ /** Verify an `Authorization: Bearer <token>` header value. */
5
+ export declare function verifyBearerToken(header: string | undefined, expected: string): boolean;
6
+ /**
7
+ * The gateway's request-authorization rule: a matching bearer token or a
8
+ * matching `x-api-key` header.
9
+ */
10
+ export declare function authorizedRequest(req: IncomingMessage, token: string): boolean;
package/dist/auth.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared bearer-token verification for the local gateway servers. One
3
+ * implementation, hardened once: comparisons run over fixed-length digests so
4
+ * neither content nor length differences are observable through timing.
5
+ */
6
+ import { createHash, timingSafeEqual } from "node:crypto";
7
+ /** Constant-time string equality (length-independent; no timing leaks). */
8
+ export function timingSafeStringEqual(a, b) {
9
+ const aDigest = createHash("sha256").update(a, "utf8").digest();
10
+ const bDigest = createHash("sha256").update(b, "utf8").digest();
11
+ // The digest comparison decides; the direct check only guards the
12
+ // astronomically unlikely hash collision and runs on match alone.
13
+ return timingSafeEqual(aDigest, bDigest) && a === b;
14
+ }
15
+ /** Verify an `Authorization: Bearer <token>` header value. */
16
+ export function verifyBearerToken(header, expected) {
17
+ return typeof header === "string" && timingSafeStringEqual(header, `Bearer ${expected}`);
18
+ }
19
+ /**
20
+ * The gateway's request-authorization rule: a matching bearer token or a
21
+ * matching `x-api-key` header.
22
+ */
23
+ export function authorizedRequest(req, token) {
24
+ if (verifyBearerToken(req.headers.authorization, token))
25
+ return true;
26
+ const apiKey = req.headers["x-api-key"];
27
+ return typeof apiKey === "string" && timingSafeStringEqual(apiKey, token);
28
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The gateway's model backend. The default HTTP implementation speaks
3
+ * OpenAI-compatible Chat Completions, but provider-native implementations can
4
+ * adapt another wire protocol behind the same interface. The backend is a thin
5
+ * `fetch` wrapper that returns the upstream `Response` unchanged, so the chat
6
+ * surface can stream straight through and the dialect adapters can consume the
7
+ * same core without a second abstraction.
8
+ */
9
+ import type { ModelReasoningCapabilities, RequestAttribution } from "@velum-labs/routekit-contracts";
10
+ export type BackendModelRoute = {
11
+ /** Stable RouteKit catalog id (`provider/model`). */
12
+ publicId: string;
13
+ /** Model id understood by the provider's native API. */
14
+ nativeId: string;
15
+ /** Configured provider that owns the model. */
16
+ provider: string;
17
+ reasoning?: ModelReasoningCapabilities;
18
+ };
19
+ export type Backend = {
20
+ /** Model id sent to the backend when a request omits one. */
21
+ readonly defaultModel: string | undefined;
22
+ /**
23
+ * All model ids the backend advertises for discovery: the default model
24
+ * first, then any native passthrough models. When present, the gateway lists
25
+ * these in `/v1/models` (both OpenAI and Anthropic shapes) so they appear in
26
+ * the tool's picker. Absent means single-model (just `defaultModel`).
27
+ */
28
+ listModelIds?(): readonly string[];
29
+ /**
30
+ * Resolve a client-requested model id to the upstream id the backend should
31
+ * actually run. When absent the gateway falls back to `defaultModel` (the
32
+ * historical single-model behaviour). A multi-model backend returns the
33
+ * requested id when it recognises a native model so the gateway can route it
34
+ * to its provider.
35
+ */
36
+ resolveModel?(requested: string | undefined): string | undefined;
37
+ /**
38
+ * Resolve a model together with its provider/native identity. An exact
39
+ * public id always wins. When `nativeProvider` is supplied, a bare native id
40
+ * may resolve only inside that provider; this powers provider-native client
41
+ * aliases without making bare ids valid on RouteKit's global API surface.
42
+ */
43
+ resolveModelRoute?(requested: string | undefined, nativeProvider?: string): BackendModelRoute | undefined;
44
+ /**
45
+ * Whether the backend serves this exact model id itself. Unlike
46
+ * {@link resolveModel} — which folds unknown
47
+ * ids into the default — this distinguishes "mine" from "unknown", so the
48
+ * gateway can hand unknown ids to a relay (e.g. the Codex backend relay)
49
+ * instead of silently routing them to the default.
50
+ */
51
+ servesModel?(model: string): boolean;
52
+ /** Capabilities advertised for a model id. */
53
+ capabilities?(model: string): Readonly<Record<string, string>>;
54
+ /** Structured reasoning controls advertised for a model id. */
55
+ reasoningCapabilities?(model: string): ModelReasoningCapabilities | undefined;
56
+ /** POST <base>/chat/completions — supports streaming (SSE) upstream. */
57
+ chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
58
+ /** GET <base>/models. */
59
+ models(signal?: AbortSignal): Promise<Response>;
60
+ /** POST <base>/embeddings. */
61
+ embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
62
+ /** Release any owned resources (e.g. a managed model process). Optional. */
63
+ close?(): Promise<void> | void;
64
+ };
65
+ export type BackendRequestOptions = {
66
+ modelCallId?: string;
67
+ reasoningCapabilities?: ModelReasoningCapabilities;
68
+ /** Request-local, sanitized attribution updates from routing/backends. */
69
+ onAttribution?: (update: RequestAttributionUpdate) => void;
70
+ /** Distinguishes compound provider operations within one public request. */
71
+ attributionOperationId?: string;
72
+ /**
73
+ * Neutral request context captured at the HTTP boundary. Backends may
74
+ * interpret their own namespaced headers; the gateway does not.
75
+ */
76
+ requestContext?: {
77
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>;
78
+ };
79
+ /**
80
+ * The caller will wrap the returned stream in a dialect translator
81
+ * (Anthropic / Responses) that emits its own keepalive.
82
+ */
83
+ translated?: boolean;
84
+ };
85
+ export type RequestAttributionUpdate = Partial<RequestAttribution> & {
86
+ accountAttempt?: {
87
+ operationId: string;
88
+ seat: string;
89
+ };
90
+ };
91
+ export type OpenAiBackendOptions = {
92
+ /**
93
+ * Base URL including the OpenAI API prefix, e.g.
94
+ * `http://127.0.0.1:8080/v1`. Route paths (`/chat/completions`, `/models`,
95
+ * `/embeddings`) are appended to this value.
96
+ */
97
+ baseUrl: string;
98
+ /**
99
+ * Bearer credential forwarded to the backend. Local servers ignore it; the
100
+ * default mirrors the `not-needed` placeholder the AI SDK uses for local
101
+ * OpenAI-compatible servers.
102
+ */
103
+ apiKey?: string;
104
+ /** Model id used when a request omits `model`. */
105
+ defaultModel?: string;
106
+ /**
107
+ * When set, every request's `model` is overwritten with this id before it is
108
+ * forwarded upstream, regardless of what the client sent. Used by per-candidate
109
+ * capture gateways that are dedicated to one routed endpoint: the driving CLI
110
+ * (e.g. Claude Code) picks its own model label, but the router must always
111
+ * receive the routed model id. Absent means the client's model passes through.
112
+ */
113
+ forceModel?: string;
114
+ /** Extra headers sent on every request. */
115
+ headers?: Record<string, string>;
116
+ };
117
+ /** Join a base URL (which may end in `/`) with a route path. */
118
+ export declare function joinPath(baseUrl: string, path: string): string;
119
+ /** An OpenAI Chat Completions backend reached over HTTP. */
120
+ export declare class OpenAiBackend implements Backend {
121
+ #private;
122
+ readonly defaultModel: string | undefined;
123
+ constructor(options: OpenAiBackendOptions);
124
+ chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
125
+ models(signal?: AbortSignal): Promise<Response>;
126
+ embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
127
+ }
128
+ export type ModelRoutedBackendOptions = {
129
+ /** Requested model ids served by `routed` instead of the primary backend. */
130
+ routedModelIds: readonly string[];
131
+ /** Backend for the routed ids. */
132
+ routed: Backend;
133
+ /** Backend for everything else (e.g. the member's router endpoint). */
134
+ primary: Backend;
135
+ };
136
+ /**
137
+ * A backend that dispatches by requested model id: ids in `routedModelIds` go
138
+ * to the `routed` backend, everything else to `primary`. This lets selected
139
+ * model ids use a secondary destination.
140
+ */
141
+ export declare class ModelRoutedBackend implements Backend {
142
+ #private;
143
+ readonly defaultModel: string | undefined;
144
+ constructor(options: ModelRoutedBackendOptions);
145
+ listModelIds(): readonly string[];
146
+ resolveModel(requested: string | undefined): string | undefined;
147
+ chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
148
+ models(signal?: AbortSignal): Promise<Response>;
149
+ embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
150
+ close(): Promise<void>;
151
+ }