@velum-labs/routekit-gateway 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/backend.d.ts CHANGED
@@ -44,8 +44,8 @@ export type Backend = {
44
44
  resolveModelRoute?(requested: string | undefined, nativeProvider?: string): BackendModelRoute | undefined;
45
45
  /**
46
46
  * Whether the backend serves this exact model id itself. Unlike
47
- * {@link resolveModel} — which folds unknown
48
- * ids into the default — this distinguishes "mine" from "unknown", so the
47
+ * The defaulting resolver folds unknown ids into the default; this
48
+ * distinguishes "mine" from "unknown", so the
49
49
  * gateway can hand unknown ids to a relay (e.g. the Codex backend relay)
50
50
  * instead of silently routing them to the default.
51
51
  */
@@ -1,13 +1,18 @@
1
1
  import { BedrockClient } from "@aws-sdk/client-bedrock";
2
2
  import { BedrockRuntimeClient, type ConverseCommandInput, type ConverseCommandOutput } from "@aws-sdk/client-bedrock-runtime";
3
- import type { BackendRequestOptions } from "./backend.js";
3
+ import { OpenAiBackend, type BackendRequestOptions } from "./backend.js";
4
4
  import type { DiscoveredModel, ProviderSource } from "./provider-source.js";
5
5
  export type BedrockControlClient = Pick<BedrockClient, "send">;
6
6
  export type BedrockRuntime = Pick<BedrockRuntimeClient, "send">;
7
+ export type BedrockMantleBackend = Pick<OpenAiBackend, "chat" | "responses">;
7
8
  export type BedrockProviderSourceOptions = {
8
9
  controlClient?: BedrockControlClient;
9
10
  runtimeClient?: BedrockRuntime;
11
+ env?: NodeJS.ProcessEnv;
12
+ mantleBackend?: BedrockMantleBackend;
10
13
  };
14
+ export declare const BEDROCK_OPENAI_ALLOWLIST: readonly ["openai.gpt-5.4", "openai.gpt-5.5", "openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna"];
15
+ export declare function isBedrockOpenAiModel(modelId: string): boolean;
11
16
  export declare function toBedrockConverseInput(body: unknown): ConverseCommandInput;
12
17
  export declare function fromBedrockConverseOutput(output: ConverseCommandOutput, model: string): Record<string, unknown>;
13
18
  export declare class BedrockProviderSource implements ProviderSource {
@@ -15,7 +20,9 @@ export declare class BedrockProviderSource implements ProviderSource {
15
20
  readonly sourceId: "bedrock";
16
21
  constructor(options?: BedrockProviderSourceOptions);
17
22
  discoverModels(signal?: AbortSignal): Promise<readonly DiscoveredModel[]>;
18
- chat(body: unknown, signal?: AbortSignal, _options?: BackendRequestOptions): Promise<Response>;
23
+ supportsResponses(model: string): boolean;
24
+ responses(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
25
+ chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
19
26
  embeddings(): Promise<Response>;
20
27
  reasoningCapabilities(model?: string): DiscoveredModel["reasoning"];
21
28
  close(): void;
@@ -1,7 +1,74 @@
1
1
  import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from "@aws-sdk/client-bedrock";
2
2
  import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime";
3
3
  import { randomId } from "@velum-labs/routekit-runtime";
4
+ import { OpenAiBackend } from "./backend.js";
4
5
  import { anthropicReasoningDetailsOf, reasoningSelectionOf, withoutRouteKitExtensions } from "./adapters/openai-chat-wire.js";
6
+ export const BEDROCK_OPENAI_ALLOWLIST = [
7
+ "openai.gpt-5.4",
8
+ "openai.gpt-5.5",
9
+ "openai.gpt-5.6-sol",
10
+ "openai.gpt-5.6-terra",
11
+ "openai.gpt-5.6-luna"
12
+ ];
13
+ const BEDROCK_OPENAI_MODEL = /^(?:(?:us|eu|global)\.)?openai\.gpt-/;
14
+ export function isBedrockOpenAiModel(modelId) {
15
+ return BEDROCK_OPENAI_MODEL.test(modelId);
16
+ }
17
+ function mantleApiKey(env) {
18
+ const key = env.AWS_BEARER_TOKEN_BEDROCK ?? env.BEDROCK_API_KEY;
19
+ return typeof key === "string" && key.length > 0 ? key : undefined;
20
+ }
21
+ function mantleRegion(env) {
22
+ const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION;
23
+ return typeof region === "string" && region.length > 0 ? region : undefined;
24
+ }
25
+ function mantleBaseUrl(region) {
26
+ return `https://bedrock-mantle.${region}.api.aws/openai/v1`;
27
+ }
28
+ function bedrockOpenAiNativeId(modelId) {
29
+ return modelId.replace(/^(?:us|eu|global)\./, "");
30
+ }
31
+ function bedrockOpenAiReasoning(modelId) {
32
+ const native = bedrockOpenAiNativeId(modelId).replace(/^openai\./, "");
33
+ if (/^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(native)) {
34
+ return {
35
+ status: "supported",
36
+ efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
37
+ defaultEffort: "medium",
38
+ wireShape: "openai-responses",
39
+ provenance: "builtin"
40
+ };
41
+ }
42
+ if (/^gpt-5\.(?:4|5)(?:-\d{4}-\d{2}-\d{2})?$/.test(native)) {
43
+ return {
44
+ status: "supported",
45
+ efforts: ["none", "low", "medium", "high", "xhigh"].map((id) => ({ id })),
46
+ wireShape: "openai-responses",
47
+ provenance: "builtin"
48
+ };
49
+ }
50
+ return {
51
+ status: "supported",
52
+ wireShape: "openai-responses",
53
+ provenance: "builtin"
54
+ };
55
+ }
56
+ function bedrockOpenAiDiscoveredModel(id) {
57
+ return {
58
+ id,
59
+ metadata: {
60
+ architecture: {
61
+ modality: "text+image->text",
62
+ inputModalities: ["text", "image"],
63
+ outputModalities: ["text"]
64
+ },
65
+ supportedParameters: ["tools", "tool_choice"],
66
+ provenance: "route"
67
+ },
68
+ reasoning: bedrockOpenAiReasoning(id),
69
+ capabilities: { streaming: "supported" }
70
+ };
71
+ }
5
72
  function record(value) {
6
73
  return typeof value === "object" && value !== null && !Array.isArray(value)
7
74
  ? value
@@ -431,42 +498,106 @@ export class BedrockProviderSource {
431
498
  sourceId = "bedrock";
432
499
  #control;
433
500
  #runtime;
501
+ #env;
502
+ #injectedMantle;
503
+ #mantle;
434
504
  #inferenceProfilesByFoundation = new Map();
435
505
  constructor(options = {}) {
436
506
  this.#control = options.controlClient ?? new BedrockClient({});
437
507
  this.#runtime = options.runtimeClient ?? new BedrockRuntimeClient({});
508
+ this.#env = options.env ?? process.env;
509
+ this.#injectedMantle = options.mantleBackend;
510
+ }
511
+ #mantleBackend() {
512
+ if (this.#injectedMantle !== undefined)
513
+ return this.#injectedMantle;
514
+ if (this.#mantle !== undefined)
515
+ return this.#mantle;
516
+ const apiKey = mantleApiKey(this.#env);
517
+ const region = mantleRegion(this.#env);
518
+ if (apiKey === undefined || region === undefined)
519
+ return undefined;
520
+ this.#mantle = new OpenAiBackend({
521
+ baseUrl: mantleBaseUrl(region),
522
+ apiKey
523
+ });
524
+ return this.#mantle;
525
+ }
526
+ #missingMantleResponse() {
527
+ return Response.json({
528
+ error: {
529
+ type: "invalid_request_error",
530
+ message: "Bedrock OpenAI models require AWS_BEARER_TOKEN_BEDROCK and AWS_REGION"
531
+ }
532
+ }, { status: 400 });
438
533
  }
439
534
  async discoverModels(signal) {
440
535
  this.#inferenceProfilesByFoundation.clear();
441
- const foundation = await this.#control.send(new ListFoundationModelsCommand({ byProvider: "Anthropic" }), signal === undefined ? undefined : { abortSignal: signal });
442
- const foundations = (foundation.modelSummaries ?? []).filter(anthropicFoundationModel);
443
- const byId = new Map(foundations.map((model) => [model.modelId, model]));
444
- const ids = new Set(byId.keys());
445
- const discovered = new Map(foundations.map((model) => [
446
- model.modelId,
447
- bedrockDiscoveredModel(model.modelId, model)
448
- ]));
449
- let nextToken;
450
- do {
451
- const profiles = await this.#control.send(new ListInferenceProfilesCommand({ ...(nextToken !== undefined ? { nextToken } : {}) }), signal === undefined ? undefined : { abortSignal: signal });
452
- for (const profile of profiles.inferenceProfileSummaries ?? []) {
453
- if (!activeAnthropicProfile(profile, ids))
454
- continue;
455
- const backingId = (profile.models ?? [])
456
- .map((model) => foundationIdFromArn(model.modelArn))
457
- .find((id) => id !== undefined && ids.has(id));
458
- if (backingId !== undefined) {
459
- this.#inferenceProfilesByFoundation.set(backingId, preferredInferenceProfile(this.#inferenceProfilesByFoundation.get(backingId), profile.inferenceProfileId));
460
- discovered.set(profile.inferenceProfileId, bedrockDiscoveredModel(profile.inferenceProfileId, byId.get(backingId)));
536
+ const abort = signal === undefined ? undefined : { abortSignal: signal };
537
+ let discovered = new Map();
538
+ try {
539
+ const foundation = await this.#control.send(new ListFoundationModelsCommand({ byProvider: "Anthropic" }), abort);
540
+ const foundations = (foundation.modelSummaries ?? []).filter(anthropicFoundationModel);
541
+ const byId = new Map(foundations.map((model) => [model.modelId, model]));
542
+ const ids = new Set(byId.keys());
543
+ discovered = new Map(foundations.map((model) => [
544
+ model.modelId,
545
+ bedrockDiscoveredModel(model.modelId, model)
546
+ ]));
547
+ let nextToken;
548
+ do {
549
+ const profiles = await this.#control.send(new ListInferenceProfilesCommand({ ...(nextToken !== undefined ? { nextToken } : {}) }), abort);
550
+ for (const profile of profiles.inferenceProfileSummaries ?? []) {
551
+ if (!activeAnthropicProfile(profile, ids))
552
+ continue;
553
+ const backingId = (profile.models ?? [])
554
+ .map((model) => foundationIdFromArn(model.modelArn))
555
+ .find((id) => id !== undefined && ids.has(id));
556
+ if (backingId !== undefined) {
557
+ this.#inferenceProfilesByFoundation.set(backingId, preferredInferenceProfile(this.#inferenceProfilesByFoundation.get(backingId), profile.inferenceProfileId));
558
+ discovered.set(profile.inferenceProfileId, bedrockDiscoveredModel(profile.inferenceProfileId, byId.get(backingId)));
559
+ }
461
560
  }
561
+ nextToken = profiles.nextToken;
562
+ } while (nextToken !== undefined && nextToken.length > 0);
563
+ }
564
+ catch (error) {
565
+ if (mantleApiKey(this.#env) === undefined)
566
+ throw error;
567
+ discovered = new Map();
568
+ this.#inferenceProfilesByFoundation.clear();
569
+ }
570
+ if (mantleApiKey(this.#env) !== undefined) {
571
+ for (const id of BEDROCK_OPENAI_ALLOWLIST) {
572
+ discovered.set(id, bedrockOpenAiDiscoveredModel(id));
462
573
  }
463
- nextToken = profiles.nextToken;
464
- } while (nextToken !== undefined && nextToken.length > 0);
574
+ }
465
575
  if (discovered.size === 0)
466
576
  throw new Error("model discovery returned no active Anthropic Bedrock models");
467
577
  return [...discovered.values()];
468
578
  }
469
- async chat(body, signal, _options) {
579
+ supportsResponses(model) {
580
+ return isBedrockOpenAiModel(model);
581
+ }
582
+ async responses(body, signal, options) {
583
+ const requestedModel = record(body)?.model;
584
+ const model = typeof requestedModel === "string" ? requestedModel : "";
585
+ if (!isBedrockOpenAiModel(model)) {
586
+ return Response.json({ error: { type: "not_supported", message: "native Responses egress is not supported" } }, { status: 501 });
587
+ }
588
+ const backend = this.#mantleBackend();
589
+ if (backend === undefined)
590
+ return this.#missingMantleResponse();
591
+ return backend.responses(body, signal, options);
592
+ }
593
+ async chat(body, signal, options) {
594
+ const requestedModel = record(body)?.model;
595
+ if (typeof requestedModel === "string" && isBedrockOpenAiModel(requestedModel)) {
596
+ const backend = this.#mantleBackend();
597
+ if (backend === undefined)
598
+ return this.#missingMantleResponse();
599
+ return backend.chat(body, signal, options);
600
+ }
470
601
  let input;
471
602
  try {
472
603
  input = toBedrockConverseInput(body);
@@ -500,6 +631,9 @@ export class BedrockProviderSource {
500
631
  return Promise.resolve(Response.json({ error: { type: "not_implemented", message: "Bedrock embeddings are not supported" } }, { status: 501 }));
501
632
  }
502
633
  reasoningCapabilities(model) {
634
+ if (model !== undefined && isBedrockOpenAiModel(model)) {
635
+ return bedrockOpenAiReasoning(model);
636
+ }
503
637
  const known = bedrockReasoningCapabilities(model);
504
638
  if (known !== undefined)
505
639
  return known;
package/dist/index.d.ts CHANGED
@@ -7,8 +7,8 @@ export { joinPath, ModelRoutedBackend, OpenAiBackend } from "./backend.js";
7
7
  export type { Backend, BackendModelRoute, BackendRequestOptions, BackendResponseMode, RequestAttributionUpdate, ModelRoutedBackendOptions, OpenAiBackendOptions } from "./backend.js";
8
8
  export { AnthropicBackend, CodexResponsesBackend, GoogleGenAiBackend } from "./provider-backends.js";
9
9
  export type { ProviderBackendOptions, ProviderTransport } from "./provider-backends.js";
10
- export { BedrockProviderSource, fromBedrockConverseOutput, toBedrockConverseInput } from "./bedrock-source.js";
11
- export type { BedrockControlClient, BedrockProviderSourceOptions, BedrockRuntime } from "./bedrock-source.js";
10
+ export { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, fromBedrockConverseOutput, isBedrockOpenAiModel, toBedrockConverseInput } from "./bedrock-source.js";
11
+ export type { BedrockControlClient, BedrockMantleBackend, BedrockProviderSourceOptions, BedrockRuntime } from "./bedrock-source.js";
12
12
  export { CatalogBackend, DEFAULT_LEADERBOARD_DURABLE_RETENTION_DAYS, DEFAULT_LEADERBOARD_LIVE_LIMIT, DEFAULT_LEADERBOARD_LIVE_TTL_HOURS, isSubscriptionProvider, leaderboardConfigSchema, NoModelAvailableError, modelPolicyAllowsModel, modelPolicyRuleMatches, normalizeRouterConfigAliases, parseRouterConfig, resolveLeaderboardConfig, routerConfigSchema, splitNamespacedModel, UnknownModelError } from "./router.js";
13
13
  export type { CatalogBackendOptions, CatalogModelInfo, LeaderboardConfig, ModelPolicy, ProviderPolicy, RouterConfig } from "./router.js";
14
14
  export { API_PROVIDER_IDS, ApiProviderSource, parseDiscoveredModels, parseReasoningCapabilities, PROVIDER_IDS, SUBSCRIPTION_PROVIDER_IDS } from "./provider-source.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { startGateway } from "./server.js";
3
3
  export { startSwitchingGatewayProxy } from "./switching-proxy.js";
4
4
  export { joinPath, ModelRoutedBackend, OpenAiBackend } from "./backend.js";
5
5
  export { AnthropicBackend, CodexResponsesBackend, GoogleGenAiBackend } from "./provider-backends.js";
6
- export { BedrockProviderSource, fromBedrockConverseOutput, toBedrockConverseInput } from "./bedrock-source.js";
6
+ export { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, fromBedrockConverseOutput, isBedrockOpenAiModel, toBedrockConverseInput } from "./bedrock-source.js";
7
7
  export { CatalogBackend, DEFAULT_LEADERBOARD_DURABLE_RETENTION_DAYS, DEFAULT_LEADERBOARD_LIVE_LIMIT, DEFAULT_LEADERBOARD_LIVE_TTL_HOURS, isSubscriptionProvider, leaderboardConfigSchema, NoModelAvailableError, modelPolicyAllowsModel, modelPolicyRuleMatches, normalizeRouterConfigAliases, parseRouterConfig, resolveLeaderboardConfig, routerConfigSchema, splitNamespacedModel, UnknownModelError } from "./router.js";
8
8
  export { API_PROVIDER_IDS, ApiProviderSource, parseDiscoveredModels, parseReasoningCapabilities, PROVIDER_IDS, SUBSCRIPTION_PROVIDER_IDS } from "./provider-source.js";
9
9
  export { OpenRouterModelMetadataClient, resolveCodexStartupModel } from "./codex-model-selection.js";
package/dist/router.js CHANGED
@@ -280,8 +280,12 @@ export function modelPolicyAllowsModel(policy, canonicalModel) {
280
280
  * verified; provider discovery and explicit config always take precedence.
281
281
  */
282
282
  export function inferKnownReasoningCapabilities(provider, model) {
283
- if (provider === "openai" &&
284
- /^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
283
+ const bedrockOpenAi = provider === "bedrock" && /^(?:(?:us|eu|global)\.)?openai\./.test(model)
284
+ ? model.replace(/^(?:(?:us|eu|global)\.)?openai\./, "")
285
+ : undefined;
286
+ const openaiModel = bedrockOpenAi ?? (provider === "openai" ? model : undefined);
287
+ if (openaiModel !== undefined &&
288
+ /^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(openaiModel)) {
285
289
  return {
286
290
  status: "supported",
287
291
  efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
@@ -290,6 +294,15 @@ export function inferKnownReasoningCapabilities(provider, model) {
290
294
  provenance: "builtin"
291
295
  };
292
296
  }
297
+ if (bedrockOpenAi !== undefined &&
298
+ /^gpt-5\.(?:4|5)(?:-\d{4}-\d{2}-\d{2})?$/.test(bedrockOpenAi)) {
299
+ return {
300
+ status: "supported",
301
+ efforts: ["none", "low", "medium", "high", "xhigh"].map((id) => ({ id })),
302
+ wireShape: "openai-responses",
303
+ provenance: "builtin"
304
+ };
305
+ }
293
306
  if (provider === "openai" && /^gpt-5\.5(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
294
307
  return {
295
308
  status: "supported",
@@ -5,6 +5,7 @@ export type SwitchingGatewayProxy = {
5
5
  target(): string;
6
6
  swapTarget(target: string): string;
7
7
  waitForTargetIdle(target: string, graceMs: number): Promise<boolean>;
8
+ retire(graceMs?: number): Promise<void>;
8
9
  drain(graceMs?: number): Promise<void>;
9
10
  close(): Promise<void>;
10
11
  };
@@ -68,13 +68,17 @@ function writeJson(res, status, value) {
68
68
  res.setHeader("content-length", String(payload.length));
69
69
  res.end(payload);
70
70
  }
71
- async function pipe(res, upstream) {
71
+ async function pipe(res, upstream, closeConnection) {
72
72
  res.statusCode = upstream.status;
73
73
  for (const [name, value] of upstream.headers) {
74
74
  if (!HOP_BY_HOP.has(name.toLowerCase()) && name.toLowerCase() !== "content-length") {
75
75
  res.setHeader(name, value);
76
76
  }
77
77
  }
78
+ if (closeConnection()) {
79
+ res.shouldKeepAlive = false;
80
+ res.setHeader("connection", "close");
81
+ }
78
82
  if (upstream.body === null) {
79
83
  res.end();
80
84
  return;
@@ -108,8 +112,13 @@ export async function startSwitchingGatewayProxy(input) {
108
112
  };
109
113
  const generations = new Map([[active.url, active]]);
110
114
  let draining = false;
115
+ let retiring = false;
111
116
  let inflight = 0;
112
117
  const server = createServer((req, res) => {
118
+ if (retiring) {
119
+ res.shouldKeepAlive = false;
120
+ res.setHeader("connection", "close");
121
+ }
113
122
  inflight += 1;
114
123
  res.once("close", () => {
115
124
  inflight -= 1;
@@ -170,7 +179,7 @@ export async function startSwitchingGatewayProxy(input) {
170
179
  AbortSignal.timeout(10 * 60 * 1000)
171
180
  ])
172
181
  });
173
- await pipe(res, upstream);
182
+ await pipe(res, upstream, () => retiring);
174
183
  }
175
184
  catch {
176
185
  if (!res.destroyed && !res.headersSent) {
@@ -205,14 +214,33 @@ export async function startSwitchingGatewayProxy(input) {
205
214
  const address = server.address();
206
215
  const port = typeof address === "object" && address !== null ? address.port : input.port ?? 0;
207
216
  let drainRun;
217
+ let retireRun;
218
+ const waitForInflight = async (graceMs) => {
219
+ const deadline = Date.now() + graceMs;
220
+ while (inflight > 0 && Date.now() < deadline) {
221
+ await new Promise((resolve) => setTimeout(resolve, 50));
222
+ }
223
+ };
224
+ const closeRetiringListener = async (graceMs) => {
225
+ server.closeIdleConnections();
226
+ const closed = new Promise((resolve) => server.close(() => resolve()));
227
+ await waitForInflight(graceMs);
228
+ if (inflight > 0)
229
+ server.closeAllConnections();
230
+ await closed;
231
+ };
232
+ const retire = (graceMs = 0) => {
233
+ retireRun ??= (async () => {
234
+ retiring = true;
235
+ await closeRetiringListener(graceMs);
236
+ })();
237
+ return retireRun;
238
+ };
208
239
  const drain = (graceMs = 0) => {
209
240
  drainRun ??= (async () => {
210
241
  draining = true;
211
242
  server.closeIdleConnections();
212
- const deadline = Date.now() + graceMs;
213
- while (inflight > 0 && Date.now() < deadline) {
214
- await new Promise((resolve) => setTimeout(resolve, 50));
215
- }
243
+ await waitForInflight(graceMs);
216
244
  const closed = new Promise((resolve) => server.close(() => resolve()));
217
245
  server.closeAllConnections();
218
246
  await closed;
@@ -252,6 +280,7 @@ export async function startSwitchingGatewayProxy(input) {
252
280
  generations.delete(generation.url);
253
281
  return result;
254
282
  },
283
+ retire,
255
284
  drain,
256
285
  close: async () => await drain(0)
257
286
  };
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { ConverseCommand, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime";
4
4
  import { ListFoundationModelsCommand } from "@aws-sdk/client-bedrock";
5
- import { BedrockProviderSource, toBedrockConverseInput } from "../bedrock-source.js";
5
+ import { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, isBedrockOpenAiModel, toBedrockConverseInput } from "../bedrock-source.js";
6
6
  test("Bedrock discovery includes active Anthropic foundations and paginated backed profiles", async () => {
7
7
  const commands = [];
8
8
  const source = new BedrockProviderSource({
@@ -392,6 +392,135 @@ test("Bedrock groups parallel tool results into one user message", () => {
392
392
  ]
393
393
  });
394
394
  });
395
+ test("Bedrock OpenAI model ids stay on mantle and never match Anthropic Converse", () => {
396
+ assert.equal(isBedrockOpenAiModel("openai.gpt-5.4"), true);
397
+ assert.equal(isBedrockOpenAiModel("openai.gpt-5.6-sol"), true);
398
+ assert.equal(isBedrockOpenAiModel("us.openai.gpt-5.6-terra"), true);
399
+ assert.equal(isBedrockOpenAiModel("anthropic.claude-3"), false);
400
+ assert.equal(isBedrockOpenAiModel("us.anthropic.claude-3"), false);
401
+ });
402
+ test("Bedrock discovery unions OpenAI mantle models when a Bedrock API key is present", async () => {
403
+ const source = new BedrockProviderSource({
404
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
405
+ controlClient: {
406
+ send: async (command) => {
407
+ if (command instanceof ListFoundationModelsCommand)
408
+ return {
409
+ modelSummaries: [{
410
+ modelId: "anthropic.claude-3",
411
+ providerName: "Anthropic",
412
+ modelLifecycle: { status: "ACTIVE" }
413
+ }]
414
+ };
415
+ return { inferenceProfileSummaries: [] };
416
+ }
417
+ },
418
+ runtimeClient: { send: async () => ({}) }
419
+ });
420
+ const discovered = await source.discoverModels();
421
+ assert.deepEqual(discovered.map((model) => model.id), ["anthropic.claude-3", ...BEDROCK_OPENAI_ALLOWLIST]);
422
+ const sol = discovered.find((model) => model.id === "openai.gpt-5.6-sol");
423
+ assert.equal(sol?.reasoning?.wireShape, "openai-responses");
424
+ assert.equal(sol?.reasoning?.defaultEffort, "medium");
425
+ });
426
+ test("Bedrock discovery keeps OpenAI models when Anthropic listing fails and a key is present", async () => {
427
+ const source = new BedrockProviderSource({
428
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
429
+ controlClient: {
430
+ send: async () => {
431
+ throw new Error("not authorized");
432
+ }
433
+ },
434
+ runtimeClient: { send: async () => ({}) }
435
+ });
436
+ const discovered = await source.discoverModels();
437
+ assert.deepEqual(discovered.map((model) => model.id), [...BEDROCK_OPENAI_ALLOWLIST]);
438
+ });
439
+ test("Bedrock routes OpenAI models through mantle and leaves Anthropic on Converse", async () => {
440
+ const runtimeCalls = [];
441
+ const mantleCalls = [];
442
+ const source = new BedrockProviderSource({
443
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
444
+ controlClient: { send: async () => ({}) },
445
+ runtimeClient: {
446
+ send: async (value) => {
447
+ runtimeCalls.push(value);
448
+ return {
449
+ $metadata: { requestId: "req-1" },
450
+ output: { message: { role: "assistant", content: [{ text: "claude" }] } },
451
+ stopReason: "end_turn"
452
+ };
453
+ }
454
+ },
455
+ mantleBackend: {
456
+ chat: async (body) => {
457
+ const model = typeof body.model === "string"
458
+ ? body.model
459
+ : undefined;
460
+ mantleCalls.push({ kind: "chat", ...(model !== undefined ? { model } : {}) });
461
+ return Response.json({ id: "chat", model });
462
+ },
463
+ responses: async (body) => {
464
+ const model = typeof body.model === "string"
465
+ ? body.model
466
+ : undefined;
467
+ mantleCalls.push({ kind: "responses", ...(model !== undefined ? { model } : {}) });
468
+ return Response.json({ id: "resp", model });
469
+ }
470
+ }
471
+ });
472
+ assert.equal(source.supportsResponses("openai.gpt-5.4"), true);
473
+ assert.equal(source.supportsResponses("anthropic.claude-3"), false);
474
+ assert.equal(source.reasoningCapabilities("openai.gpt-5.6-sol")?.wireShape, "openai-responses");
475
+ const openaiChat = await source.chat({
476
+ model: "openai.gpt-5.4",
477
+ messages: [{ role: "user", content: "hi" }]
478
+ });
479
+ assert.equal(openaiChat.status, 200);
480
+ assert.deepEqual(await openaiChat.json(), { id: "chat", model: "openai.gpt-5.4" });
481
+ const openaiResponses = await source.responses({
482
+ model: "openai.gpt-5.6-terra",
483
+ input: "hi"
484
+ });
485
+ assert.equal(openaiResponses.status, 200);
486
+ assert.deepEqual(await openaiResponses.json(), { id: "resp", model: "openai.gpt-5.6-terra" });
487
+ const anthropic = await source.chat({
488
+ model: "anthropic.claude-3",
489
+ messages: [{ role: "user", content: "hi" }]
490
+ });
491
+ assert.equal(anthropic.status, 200);
492
+ assert.equal(runtimeCalls.length, 1);
493
+ assert.equal(runtimeCalls[0] instanceof ConverseCommand, true);
494
+ assert.deepEqual(mantleCalls, [
495
+ { kind: "chat", model: "openai.gpt-5.4" },
496
+ { kind: "responses", model: "openai.gpt-5.6-terra" }
497
+ ]);
498
+ const rejected = await source.responses({
499
+ model: "anthropic.claude-3",
500
+ input: "hi"
501
+ });
502
+ assert.equal(rejected.status, 501);
503
+ });
504
+ test("Bedrock OpenAI chat without a mantle key returns 400 and does not call Converse", async () => {
505
+ const runtimeCalls = [];
506
+ const source = new BedrockProviderSource({
507
+ env: { AWS_REGION: "us-east-1" },
508
+ controlClient: { send: async () => ({}) },
509
+ runtimeClient: {
510
+ send: async (value) => {
511
+ runtimeCalls.push(value);
512
+ return {};
513
+ }
514
+ }
515
+ });
516
+ const response = await source.chat({
517
+ model: "openai.gpt-5.5",
518
+ messages: [{ role: "user", content: "hi" }]
519
+ });
520
+ assert.equal(response.status, 400);
521
+ assert.match(await response.text(), /AWS_BEARER_TOKEN_BEDROCK/);
522
+ assert.equal(runtimeCalls.length, 0);
523
+ });
395
524
  test("Bedrock defaults reasoning to unknown and ordinary requests omit thinking", () => {
396
525
  const source = new BedrockProviderSource({
397
526
  controlClient: { send: async () => ({}) },
@@ -768,3 +768,36 @@ test("Bedrock Opus 5 exposes reasoning controls and accepts routed effort select
768
768
  const error = (await rejected.json());
769
769
  assert.equal(error.error.code, "unsupported_reasoning_control");
770
770
  });
771
+ test("Bedrock OpenAI models keep native openai. ids and Responses reasoning", async () => {
772
+ const calls = [];
773
+ const backend = await CatalogBackend.create({
774
+ config: { providers: { bedrock: {} }, defaultModel: "bedrock/openai.gpt-5.6-sol" },
775
+ sources: {
776
+ bedrock: {
777
+ ...fakeSource("bedrock", [{ id: "openai.gpt-5.6-sol" }], calls),
778
+ supportsResponses(model) {
779
+ return model.startsWith("openai.");
780
+ },
781
+ async responses(body) {
782
+ const model = typeof body === "object" &&
783
+ body !== null &&
784
+ "model" in body &&
785
+ typeof body.model === "string"
786
+ ? body.model
787
+ : undefined;
788
+ calls.push({ source: "bedrock-responses", ...(model !== undefined ? { model } : {}) });
789
+ return Response.json({ ok: true, model });
790
+ }
791
+ }
792
+ }
793
+ });
794
+ assert.equal(backend.supportsResponses("bedrock/openai.gpt-5.6-sol"), true);
795
+ assert.equal(backend.modelInfo("bedrock/openai.gpt-5.6-sol")?.reasoning?.wireShape, "openai-responses");
796
+ const response = await backend.responses({
797
+ model: "bedrock/openai.gpt-5.6-sol",
798
+ input: "hi"
799
+ });
800
+ assert.equal(response.status, 200);
801
+ assert.deepEqual(await response.json(), { ok: true, model: "openai.gpt-5.6-sol" });
802
+ assert.deepEqual(calls, [{ source: "bedrock-responses", model: "openai.gpt-5.6-sol" }]);
803
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import test from "node:test";
4
+ import { startSwitchingGatewayProxy } from "../switching-proxy.js";
5
+ async function heldTarget() {
6
+ let markStarted;
7
+ const started = new Promise((resolve) => {
8
+ markStarted = resolve;
9
+ });
10
+ let release;
11
+ const released = new Promise((resolve) => {
12
+ release = resolve;
13
+ });
14
+ const server = createServer((request, response) => {
15
+ if (request.url === "/stream") {
16
+ response.setHeader("content-type", "text/plain");
17
+ response.write("first\n");
18
+ markStarted();
19
+ void released.then(() => response.end("last\n"));
20
+ return;
21
+ }
22
+ response.end("ok");
23
+ });
24
+ await new Promise((resolve, reject) => {
25
+ server.once("error", reject);
26
+ server.listen(0, "127.0.0.1", () => resolve());
27
+ });
28
+ const port = server.address().port;
29
+ return {
30
+ url: `http://127.0.0.1:${port}`,
31
+ started,
32
+ release,
33
+ close: async () => await new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))))
34
+ };
35
+ }
36
+ test("switching proxy retirement preserves an admitted streaming response", async () => {
37
+ const target = await heldTarget();
38
+ const proxy = await startSwitchingGatewayProxy({ target: target.url });
39
+ try {
40
+ const response = await fetch(`${proxy.url()}/stream`);
41
+ const reader = response.body?.getReader();
42
+ assert.ok(reader !== undefined);
43
+ const first = await reader.read();
44
+ assert.match(Buffer.from(first.value ?? []).toString("utf8"), /first/);
45
+ await target.started;
46
+ const retiring = proxy.retire(2_000);
47
+ target.release();
48
+ let remainder = "";
49
+ for (;;) {
50
+ const { done, value } = await reader.read();
51
+ if (done)
52
+ break;
53
+ remainder += Buffer.from(value ?? []).toString("utf8");
54
+ }
55
+ assert.match(remainder, /last/);
56
+ await retiring;
57
+ }
58
+ finally {
59
+ await proxy.close();
60
+ await target.close();
61
+ }
62
+ });
63
+ test("switching proxy retirement deadline terminates a stream that never finishes", async () => {
64
+ const target = await heldTarget();
65
+ const proxy = await startSwitchingGatewayProxy({ target: target.url });
66
+ try {
67
+ const response = await fetch(`${proxy.url()}/stream`);
68
+ const reader = response.body?.getReader();
69
+ assert.ok(reader !== undefined);
70
+ await reader.read();
71
+ await target.started;
72
+ const startedAt = Date.now();
73
+ await proxy.retire(250);
74
+ assert.ok(Date.now() - startedAt >= 200);
75
+ await assert.rejects(async () => {
76
+ for (;;) {
77
+ const { done } = await reader.read();
78
+ if (done)
79
+ break;
80
+ }
81
+ });
82
+ }
83
+ finally {
84
+ target.release();
85
+ await proxy.close();
86
+ await target.close();
87
+ }
88
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.18.1",
4
+ "version": "0.18.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -29,10 +29,10 @@
29
29
  "@aws-sdk/client-bedrock": "3.1095.0",
30
30
  "@aws-sdk/client-bedrock-runtime": "3.1095.0",
31
31
  "zod": "4.4.3",
32
- "@velum-labs/routekit-contracts": "0.18.1",
33
- "@velum-labs/routekit-registry": "0.18.1",
34
- "@velum-labs/routekit-runtime": "0.18.1",
35
- "@velum-labs/routekit-tracing": "0.18.1"
32
+ "@velum-labs/routekit-contracts": "0.18.3",
33
+ "@velum-labs/routekit-registry": "0.18.3",
34
+ "@velum-labs/routekit-runtime": "0.18.3",
35
+ "@velum-labs/routekit-tracing": "0.18.3"
36
36
  },
37
37
  "keywords": [
38
38
  "routekit",