@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
package/dist/router.js ADDED
@@ -0,0 +1,474 @@
1
+ import { z } from "zod";
2
+ import { resolveReasoningEffort } from "@velum-labs/routekit-contracts";
3
+ import { API_PROVIDER_IDS, ApiProviderSource, PROVIDER_IDS, SUBSCRIPTION_PROVIDER_IDS } from "./provider-source.js";
4
+ import { attachReasoningSelection, reasoningSelectionErrorOf, reasoningSelectionOf } from "./adapters/openai-chat-wire.js";
5
+ export class UnknownModelError extends Error {
6
+ model;
7
+ constructor(model) {
8
+ super(`unknown model: ${model}`);
9
+ this.model = model;
10
+ this.name = "UnknownModelError";
11
+ }
12
+ }
13
+ export class NoModelAvailableError extends Error {
14
+ constructor() {
15
+ super("no model is available; configure a provider");
16
+ this.name = "NoModelAvailableError";
17
+ }
18
+ }
19
+ const providerPolicySchema = z
20
+ .object({
21
+ strategy: z
22
+ .enum(["sticky", "round_robin", "capacity_weighted"])
23
+ .default("capacity_weighted"),
24
+ switchThreshold: z.number().min(0.01).max(1).default(0.9),
25
+ probeIntervalMs: z.number().int().nonnegative().optional(),
26
+ fallbackCooldownSeconds: z.number().nonnegative().optional()
27
+ })
28
+ .strict();
29
+ const reasoningCapabilityOverrideSchema = z
30
+ .object({
31
+ status: z.enum(["supported", "unsupported", "unknown"]).default("supported"),
32
+ efforts: z
33
+ .array(z
34
+ .object({
35
+ id: z.string().min(1),
36
+ label: z.string().min(1).optional(),
37
+ description: z.string().min(1).optional(),
38
+ aliases: z.array(z.string().min(1)).optional()
39
+ })
40
+ .strict())
41
+ .optional(),
42
+ defaultEffort: z.string().min(1).optional(),
43
+ budget: z
44
+ .object({
45
+ minTokens: z.number().int().nonnegative().optional(),
46
+ maxTokens: z.number().int().positive().optional(),
47
+ defaultTokens: z.number().int().nonnegative().optional()
48
+ })
49
+ .strict()
50
+ .optional(),
51
+ adaptive: z.boolean().optional(),
52
+ wireShape: z.string().min(1).optional()
53
+ })
54
+ .strict()
55
+ .superRefine((capability, context) => {
56
+ const ids = new Set();
57
+ for (const [index, effort] of (capability.efforts ?? []).entries()) {
58
+ if (ids.has(effort.id)) {
59
+ context.addIssue({
60
+ code: "custom",
61
+ path: ["efforts", index, "id"],
62
+ message: `duplicate reasoning effort "${effort.id}"`
63
+ });
64
+ }
65
+ ids.add(effort.id);
66
+ }
67
+ if (capability.defaultEffort !== undefined &&
68
+ !ids.has(capability.defaultEffort)) {
69
+ context.addIssue({
70
+ code: "custom",
71
+ path: ["defaultEffort"],
72
+ message: "default reasoning effort must be listed in efforts"
73
+ });
74
+ }
75
+ if (capability.budget?.minTokens !== undefined &&
76
+ capability.budget.maxTokens !== undefined &&
77
+ capability.budget.minTokens > capability.budget.maxTokens) {
78
+ context.addIssue({
79
+ code: "custom",
80
+ path: ["budget"],
81
+ message: "minimum reasoning budget cannot exceed maximum"
82
+ });
83
+ }
84
+ });
85
+ export const routerConfigSchema = z
86
+ .object({
87
+ providers: z
88
+ .object({
89
+ openai: providerPolicySchema.optional(),
90
+ anthropic: providerPolicySchema.optional(),
91
+ google: providerPolicySchema.optional(),
92
+ openrouter: providerPolicySchema.optional(),
93
+ cliproxy: providerPolicySchema.optional(),
94
+ codex: providerPolicySchema.optional(),
95
+ "claude-code": providerPolicySchema.optional()
96
+ })
97
+ .strict(),
98
+ defaultModel: z.string().min(3).optional(),
99
+ reasoningCapabilities: z
100
+ .record(z.string().min(3), reasoningCapabilityOverrideSchema)
101
+ .optional()
102
+ })
103
+ .strict();
104
+ export function normalizeRouterConfigAliases(value) {
105
+ if (typeof value !== "object" || value === null || Array.isArray(value))
106
+ return value;
107
+ const config = value;
108
+ const rawProviders = config.providers;
109
+ if (typeof rawProviders !== "object" ||
110
+ rawProviders === null ||
111
+ Array.isArray(rawProviders)) {
112
+ return value;
113
+ }
114
+ const providers = rawProviders;
115
+ const claudeKeys = ["claude-code", "claudeCode", "claude"].filter((key) => Object.hasOwn(providers, key));
116
+ if (claudeKeys.length > 1) {
117
+ throw new Error(`router config contains conflicting Claude provider keys: ${claudeKeys.join(", ")}`);
118
+ }
119
+ const normalizedProviders = { ...providers };
120
+ const claudeKey = claudeKeys[0];
121
+ if (claudeKey !== undefined && claudeKey !== "claude-code") {
122
+ normalizedProviders["claude-code"] = normalizedProviders[claudeKey];
123
+ delete normalizedProviders[claudeKey];
124
+ }
125
+ return { ...config, providers: normalizedProviders };
126
+ }
127
+ export function splitNamespacedModel(model) {
128
+ const separator = model.indexOf("/");
129
+ const source = separator < 0 ? "" : model.slice(0, separator);
130
+ const nativeModel = separator < 0 ? "" : model.slice(separator + 1);
131
+ if (!PROVIDER_IDS.includes(source) ||
132
+ nativeModel.length === 0 ||
133
+ nativeModel.startsWith("/")) {
134
+ throw new Error(`model "${model}" must use a supported provider/model namespace`);
135
+ }
136
+ return { provider: source, model: nativeModel };
137
+ }
138
+ export function parseRouterConfig(value) {
139
+ const config = routerConfigSchema.parse(normalizeRouterConfigAliases(value));
140
+ if (config.defaultModel !== undefined) {
141
+ const selected = splitNamespacedModel(config.defaultModel);
142
+ if (config.providers[selected.provider] === undefined) {
143
+ throw new Error(`default model provider "${selected.provider}" is not configured`);
144
+ }
145
+ }
146
+ return config;
147
+ }
148
+ function routeBilling(provider) {
149
+ switch (provider) {
150
+ case "openai":
151
+ case "anthropic":
152
+ case "google":
153
+ case "openrouter":
154
+ return { accountClass: "api-key", billingMode: "metered-api" };
155
+ case "codex":
156
+ case "claude-code":
157
+ return { accountClass: "subscription", billingMode: "subscription" };
158
+ case "cliproxy":
159
+ return { accountClass: "proxy", billingMode: "upstream-managed" };
160
+ default: {
161
+ const unreachable = provider;
162
+ throw new Error(`unsupported route provider: ${String(unreachable)}`);
163
+ }
164
+ }
165
+ }
166
+ function configuredProviderIds(config) {
167
+ return PROVIDER_IDS.filter((provider) => config.providers[provider] !== undefined);
168
+ }
169
+ function isApiProvider(provider) {
170
+ return API_PROVIDER_IDS.includes(provider);
171
+ }
172
+ function namespaced(provider, model) {
173
+ return `${provider}/${model}`;
174
+ }
175
+ export class CatalogBackend {
176
+ defaultModel;
177
+ #entries;
178
+ #sources;
179
+ constructor(defaultModel, entries, sources) {
180
+ this.defaultModel = defaultModel;
181
+ this.#entries = entries;
182
+ this.#sources = sources;
183
+ }
184
+ static async create(options) {
185
+ const config = parseRouterConfig(options.config);
186
+ const env = options.env ?? process.env;
187
+ const sources = [];
188
+ const entries = new Map();
189
+ try {
190
+ for (const provider of configuredProviderIds(config)) {
191
+ const injected = options.sources?.[provider];
192
+ let source;
193
+ if (injected !== undefined) {
194
+ source = injected;
195
+ }
196
+ else if (isApiProvider(provider)) {
197
+ source =
198
+ options.createApiSource?.(provider, env) ??
199
+ new ApiProviderSource({ provider, env });
200
+ }
201
+ else {
202
+ throw new Error(`provider "${provider}" requires enrolled subscription accounts`);
203
+ }
204
+ if (source.sourceId !== provider) {
205
+ throw new Error(`provider source mismatch: configured "${provider}", received "${source.sourceId}"`);
206
+ }
207
+ sources.push(source);
208
+ let discovered;
209
+ try {
210
+ discovered = await source.discoverModels(options.signal);
211
+ }
212
+ catch (error) {
213
+ throw new Error(`provider "${provider}" discovery failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
214
+ }
215
+ if (discovered.length === 0) {
216
+ throw new Error(`provider "${provider}" discovery returned no models`);
217
+ }
218
+ for (const model of discovered) {
219
+ const publicId = namespaced(provider, model.id);
220
+ if (entries.has(publicId))
221
+ continue;
222
+ const override = config.reasoningCapabilities?.[publicId];
223
+ const reasoning = override !== undefined
224
+ ? {
225
+ ...override,
226
+ provenance: "config"
227
+ }
228
+ : model.reasoning ?? source.reasoningCapabilities?.(model.id);
229
+ entries.set(publicId, {
230
+ publicId,
231
+ nativeId: model.id,
232
+ provider,
233
+ source,
234
+ capabilities: model.capabilities ?? source.capabilities?.(model.id) ?? {},
235
+ ...(reasoning !== undefined ? { reasoning } : {})
236
+ });
237
+ }
238
+ }
239
+ const first = entries.keys().next().value;
240
+ const defaultModel = config.defaultModel ?? first;
241
+ if (defaultModel === undefined) {
242
+ if (configuredProviderIds(config).length > 0) {
243
+ throw new Error("configured providers discovered no models");
244
+ }
245
+ }
246
+ else if (!entries.has(defaultModel)) {
247
+ throw new UnknownModelError(defaultModel);
248
+ }
249
+ return new CatalogBackend(defaultModel, entries, sources);
250
+ }
251
+ catch (error) {
252
+ await Promise.allSettled(sources.map(async (source) => await source.close?.()));
253
+ throw error;
254
+ }
255
+ }
256
+ listModelIds() {
257
+ return [...this.#entries.keys()];
258
+ }
259
+ modelInfo(model) {
260
+ const entry = this.#entries.get(model);
261
+ if (entry === undefined)
262
+ return undefined;
263
+ return {
264
+ id: entry.publicId,
265
+ provider: entry.provider,
266
+ nativeModel: entry.nativeId,
267
+ ...routeBilling(entry.provider),
268
+ default: entry.publicId === this.defaultModel,
269
+ capabilities: entry.capabilities,
270
+ reasoning: entry.reasoning ?? null
271
+ };
272
+ }
273
+ async providerStatuses(signal) {
274
+ return await Promise.all(this.#sources.map(async (source) => {
275
+ try {
276
+ const models = await source.discoverModels(signal);
277
+ if (models.length === 0) {
278
+ return {
279
+ provider: source.sourceId,
280
+ ok: false,
281
+ models: [],
282
+ error: "live discovery returned no models"
283
+ };
284
+ }
285
+ return {
286
+ provider: source.sourceId,
287
+ ok: true,
288
+ models: models.map((model) => namespaced(source.sourceId, model.id))
289
+ };
290
+ }
291
+ catch (error) {
292
+ return {
293
+ provider: source.sourceId,
294
+ ok: false,
295
+ models: [],
296
+ error: error instanceof Error ? error.message : String(error)
297
+ };
298
+ }
299
+ }));
300
+ }
301
+ servesModel(model) {
302
+ return this.#entries.has(model);
303
+ }
304
+ resolveModel(requested) {
305
+ if (requested === undefined)
306
+ return this.defaultModel;
307
+ return this.#entries.has(requested) ? requested : undefined;
308
+ }
309
+ resolveModelRoute(requested, nativeProvider) {
310
+ const publicId = requested ?? this.defaultModel;
311
+ if (publicId === undefined)
312
+ return undefined;
313
+ const exact = this.#entries.get(publicId);
314
+ if (exact !== undefined)
315
+ return this.#modelRoute(exact);
316
+ if (nativeProvider === undefined || requested === undefined)
317
+ return undefined;
318
+ for (const entry of this.#entries.values()) {
319
+ if (entry.provider === nativeProvider && entry.nativeId === requested) {
320
+ return this.#modelRoute(entry);
321
+ }
322
+ }
323
+ return undefined;
324
+ }
325
+ capabilities(model) {
326
+ return this.#entries.get(model)?.capabilities ?? {};
327
+ }
328
+ reasoningCapabilities(model) {
329
+ return this.#entries.get(model)?.reasoning;
330
+ }
331
+ chat(body, signal, options) {
332
+ const entry = this.#entry(this.#requestedModel(body));
333
+ const selectionError = reasoningSelectionErrorOf(body);
334
+ if (selectionError !== undefined) {
335
+ return Promise.resolve(Response.json({
336
+ error: {
337
+ type: "invalid_request_error",
338
+ code: "invalid_reasoning_control",
339
+ message: selectionError
340
+ }
341
+ }, { status: 400 }));
342
+ }
343
+ const selection = this.#validatedReasoning(entry, reasoningSelectionOf(body));
344
+ if (typeof selection === "string") {
345
+ return Promise.resolve(Response.json({
346
+ error: {
347
+ type: "invalid_request_error",
348
+ code: "unsupported_reasoning_control",
349
+ message: selection
350
+ }
351
+ }, { status: 400 }));
352
+ }
353
+ const nativeBody = this.#withNativeModel(body, entry.nativeId);
354
+ options?.onAttribution?.({
355
+ effective_model: entry.publicId,
356
+ native_model: entry.nativeId,
357
+ provider: entry.provider,
358
+ billing_mode: isSubscriptionProvider(entry.provider) || entry.provider === "cliproxy"
359
+ ? "subscription"
360
+ : "api_key"
361
+ });
362
+ if (nativeBody !== null &&
363
+ typeof nativeBody === "object" &&
364
+ !Array.isArray(nativeBody)) {
365
+ attachReasoningSelection(nativeBody, selection);
366
+ if (selection.mode === "effort") {
367
+ nativeBody.reasoning_effort =
368
+ selection.effort;
369
+ }
370
+ }
371
+ return entry.source.chat(nativeBody, signal, {
372
+ ...options,
373
+ ...(entry.reasoning !== undefined
374
+ ? { reasoningCapabilities: entry.reasoning }
375
+ : {})
376
+ });
377
+ }
378
+ models() {
379
+ const data = [...this.#entries.values()].map((entry) => ({
380
+ id: entry.publicId,
381
+ object: "model",
382
+ owned_by: entry.provider,
383
+ capabilities: entry.capabilities,
384
+ ...(entry.reasoning !== undefined ? { reasoning: entry.reasoning } : {})
385
+ }));
386
+ return Promise.resolve(new Response(JSON.stringify({ object: "list", data }), {
387
+ headers: { "content-type": "application/json" }
388
+ }));
389
+ }
390
+ embeddings(body, signal, options) {
391
+ const entry = this.#entry(this.#requestedModel(body));
392
+ options?.onAttribution?.({
393
+ effective_model: entry.publicId,
394
+ native_model: entry.nativeId,
395
+ provider: entry.provider,
396
+ billing_mode: isSubscriptionProvider(entry.provider) || entry.provider === "cliproxy"
397
+ ? "subscription"
398
+ : "api_key"
399
+ });
400
+ return entry.source.embeddings(this.#withNativeModel(body, entry.nativeId), signal, options);
401
+ }
402
+ async close() {
403
+ await Promise.all(this.#sources.map(async (source) => await source.close?.()));
404
+ }
405
+ #requestedModel(body) {
406
+ return typeof body === "object" &&
407
+ body !== null &&
408
+ !Array.isArray(body) &&
409
+ typeof body.model === "string"
410
+ ? body.model
411
+ : undefined;
412
+ }
413
+ #entry(requested) {
414
+ const model = requested ?? this.defaultModel;
415
+ if (model === undefined)
416
+ throw new NoModelAvailableError();
417
+ const entry = this.#entries.get(model);
418
+ if (entry === undefined)
419
+ throw new UnknownModelError(model);
420
+ return entry;
421
+ }
422
+ #withNativeModel(body, nativeModel) {
423
+ return typeof body === "object" && body !== null && !Array.isArray(body)
424
+ ? { ...body, model: nativeModel }
425
+ : body;
426
+ }
427
+ #modelRoute(entry) {
428
+ return {
429
+ publicId: entry.publicId,
430
+ nativeId: entry.nativeId,
431
+ provider: entry.provider,
432
+ ...(entry.reasoning !== undefined ? { reasoning: entry.reasoning } : {})
433
+ };
434
+ }
435
+ #validatedReasoning(entry, selection) {
436
+ if (selection.mode === "auto" || selection.mode === "disabled") {
437
+ return selection;
438
+ }
439
+ const capability = entry.reasoning;
440
+ if (capability === undefined || capability.status === "unknown") {
441
+ return `model "${entry.publicId}" has no discovered reasoning controls`;
442
+ }
443
+ if (capability.status === "unsupported") {
444
+ return `model "${entry.publicId}" does not support reasoning controls`;
445
+ }
446
+ if (selection.mode === "effort") {
447
+ const effort = resolveReasoningEffort(capability, selection.effort);
448
+ return effort === undefined
449
+ ? `reasoning effort "${selection.effort}" is not supported by model "${entry.publicId}"`
450
+ : { mode: "effort", effort };
451
+ }
452
+ if (selection.mode === "adaptive") {
453
+ return capability.adaptive === true
454
+ ? selection
455
+ : `adaptive reasoning is not supported by model "${entry.publicId}"`;
456
+ }
457
+ const budget = capability.budget;
458
+ if (budget === undefined) {
459
+ return `reasoning token budgets are not supported by model "${entry.publicId}"`;
460
+ }
461
+ if (budget.minTokens !== undefined &&
462
+ selection.budgetTokens < budget.minTokens) {
463
+ return `reasoning budget must be at least ${budget.minTokens} tokens`;
464
+ }
465
+ if (budget.maxTokens !== undefined &&
466
+ selection.budgetTokens > budget.maxTokens) {
467
+ return `reasoning budget must be at most ${budget.maxTokens} tokens`;
468
+ }
469
+ return selection;
470
+ }
471
+ }
472
+ export function isSubscriptionProvider(provider) {
473
+ return SUBSCRIPTION_PROVIDER_IDS.includes(provider);
474
+ }
@@ -0,0 +1,67 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { RequestAttribution } from "@velum-labs/routekit-contracts";
3
+ import type { AnthropicRequest } from "./adapters/anthropic.js";
4
+ import type { ResponsesRequest } from "./adapters/responses.js";
5
+ import type { Backend, BackendRequestOptions } from "./backend.js";
6
+ import type { ProvenanceSink } from "./provenance.js";
7
+ /**
8
+ * The local-model gateway HTTP server. It fronts a single OpenAI Chat
9
+ * Completions backend (the owned mlx fork by default) and exposes the wire
10
+ * dialects each agent harness needs: OpenAI chat, Anthropic Messages, OpenAI
11
+ * Responses, and Cursor's Responses-hybrid BYOK shape.
12
+ */
13
+ export type GatewayOptions = {
14
+ backend: Backend;
15
+ /** Bind host; defaults to loopback. */
16
+ host?: string;
17
+ /** Bind port; defaults to an ephemeral free port. */
18
+ port?: number;
19
+ /** When set, require this bearer token (or matching `x-api-key`). */
20
+ authToken?: string;
21
+ /** Optional observation sink for model calls. */
22
+ provenance?: ProvenanceSink;
23
+ /** Optional client-authenticated Responses relay. */
24
+ codexRelay?: ProviderRelay;
25
+ /** Provider-native relays sharing this HTTP boundary. */
26
+ providerRelays?: Partial<Record<ProviderRelayDialect, ProviderRelay>>;
27
+ /** Optional provider usage payload for `GET /usage`. */
28
+ usage?: () => unknown | Promise<unknown>;
29
+ };
30
+ export type ProviderRelayDialect = "anthropic" | "codex";
31
+ export type ProviderRelay = {
32
+ readonly dialect: ProviderRelayDialect;
33
+ shouldRelay(headers: IncomingMessage["headers"], model: string | undefined, servesLocally: (model: string) => boolean): boolean;
34
+ relay(headers: IncomingMessage["headers"], body: AnthropicRequest | ResponsesRequest, signal?: AbortSignal, options?: Pick<BackendRequestOptions, "onAttribution">): Promise<Response>;
35
+ models?(headers: IncomingMessage["headers"], search: string, signal?: AbortSignal): Promise<Response>;
36
+ countTokens?(headers: IncomingMessage["headers"], body: AnthropicRequest, signal?: AbortSignal): Promise<Response>;
37
+ mergedCatalog?(headers: IncomingMessage["headers"], search: string): Promise<{
38
+ models: Array<Record<string, unknown>>;
39
+ etag?: string;
40
+ } | undefined>;
41
+ mergeDataIds?(data: Array<{
42
+ id: string;
43
+ } & Record<string, unknown>>, models: readonly Record<string, unknown>[]): Array<{
44
+ id: string;
45
+ } & Record<string, unknown>>;
46
+ close?(): Promise<void> | void;
47
+ };
48
+ export type Gateway = {
49
+ /** Base URL clients should target (without the `/v1` suffix). */
50
+ url(): string;
51
+ port(): number;
52
+ /**
53
+ * Graceful drain: flip `/health` to 503 and reject new model calls while
54
+ * letting in-flight requests (long-lived LLM streams) finish, bounded by
55
+ * `graceMs`; then close the listener and sever whatever remains. Does not
56
+ * release the backend — follow with {@link close}.
57
+ */
58
+ drain(graceMs?: number): Promise<void>;
59
+ /** Immediate close: equivalent to `drain(0)` plus backend/relay teardown. */
60
+ close(): Promise<void>;
61
+ };
62
+ export declare function initialAttribution(backend: Backend, requested: string | undefined, nativeProvider?: "claude-code" | "codex"): Partial<RequestAttribution>;
63
+ export declare function startGateway(options: GatewayOptions): Promise<Gateway>;
64
+ export declare function collectAttribution(seed: Partial<RequestAttribution> | undefined): {
65
+ report: NonNullable<BackendRequestOptions["onAttribution"]>;
66
+ snapshot(): RequestAttribution | undefined;
67
+ };