@velum-labs/routekit-gateway 1.0.4 → 1.0.6

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/index.d.ts CHANGED
@@ -19,8 +19,8 @@ export type { EvalSessionAdmission, GatewayPrincipal, WorkloadJwtPrincipalPolicy
19
19
  export { authorizedRequest, createWorkloadJwtVerifier, parsePrincipalHeader, presentedCredential, ROUTEKIT_PRINCIPAL_HEADER, resolvePrincipal } from "./http/auth.js";
20
20
  export type { Backend, BackendLifecyclePort, BackendModelPort, BackendModelRoute, BackendPorts, BackendRequest, BackendRequestOptions, BackendResponseMode, BackendResponsesPort, ModelRoutedBackendOptions, RequestAttributionUpdate } from "./providers/backend.js";
21
21
  export { borrowedBackendPorts, joinPath, ModelRoutedBackend, staticBackendModelPort } from "./providers/backend.js";
22
- export type { BedrockControlClient, BedrockProviderSourceOptions, BedrockRuntime } from "./providers/bedrock-source.js";
23
- export { BedrockProviderSource, fromBedrockConverseOutput, toBedrockConverseInput } from "./providers/bedrock-source.js";
22
+ export type { BedrockControlClient, BedrockMantleBackend, BedrockProviderSourceOptions, BedrockRuntime } from "./providers/bedrock-source.js";
23
+ export { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, fromBedrockConverseOutput, isBedrockOpenAiModel, toBedrockConverseInput } from "./providers/bedrock-source.js";
24
24
  export type { CapacityLease, CapacityPoolMember, CapacityPoolOptions, CapacityPoolStrategy } from "./capacity-pool.js";
25
25
  export { CapacityPool } from "./capacity-pool.js";
26
26
  export type { OpenRouterModelMetadata, OpenRouterModelMetadataClientOptions, ResolvedCodexStartupSelection } from "./providers/codex-model-selection.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { chatToResponses, handleResponses, openAiSseToResponses, responsesToChat
10
10
  export { MAX_WEB_SEARCHES_PER_TURN, resolveWebSearchExecutor } from "./adapters/web-search.js";
11
11
  export { authorizedRequest, createWorkloadJwtVerifier, parsePrincipalHeader, presentedCredential, ROUTEKIT_PRINCIPAL_HEADER, resolvePrincipal } from "./http/auth.js";
12
12
  export { borrowedBackendPorts, joinPath, ModelRoutedBackend, staticBackendModelPort } from "./providers/backend.js";
13
- export { BedrockProviderSource, fromBedrockConverseOutput, toBedrockConverseInput } from "./providers/bedrock-source.js";
13
+ export { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, fromBedrockConverseOutput, isBedrockOpenAiModel, toBedrockConverseInput } from "./providers/bedrock-source.js";
14
14
  export { CapacityPool } from "./capacity-pool.js";
15
15
  export { OpenRouterModelMetadataClient, resolveCodexStartupModel } from "./providers/codex-model-selection.js";
16
16
  export { CompositionalRoutingError, routeCompositionalRequest } from "./routing/compositional.js";
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Amazon Bedrock provider source. Discovers Anthropic foundation models and
3
- * inference profiles, then sends chat through Bedrock Converse. OpenAI Chat
4
- * Completions JSON ↔ Converse translation lives in `bedrock-codec.ts`.
2
+ * Amazon Bedrock provider source. Anthropic models use Bedrock Converse;
3
+ * allowlisted OpenAI frontier models use the regional bedrock-mantle
4
+ * OpenAI-compatible API.
5
5
  */
6
6
  import { BedrockClient } from "@aws-sdk/client-bedrock";
7
7
  import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
8
8
  import { fromBedrockConverseOutput, toBedrockConverseInput } from "./bedrock-codec.js";
9
+ import { OpenAiBackend } from "./openai-backend.js";
9
10
  import type { ProviderSource } from "./source.js";
10
11
  export type BedrockControlClient = Pick<BedrockClient, "send">;
11
12
  export type BedrockRuntime = Pick<BedrockRuntimeClient, "send">;
13
+ export type BedrockMantleBackend = Pick<OpenAiBackend, "chat" | "responses">;
12
14
  export type BedrockProviderSourceOptions = {
13
15
  controlClient?: BedrockControlClient;
14
16
  runtimeClient?: BedrockRuntime;
17
+ env?: Readonly<Record<string, string | undefined>>;
18
+ mantleBackend?: BedrockMantleBackend;
15
19
  };
16
20
  export { fromBedrockConverseOutput, toBedrockConverseInput };
21
+ 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"];
22
+ export declare function isBedrockOpenAiModel(modelId: string): boolean;
23
+ /**
24
+ * Remove OpenAI/Codex protocol fields that bedrock-mantle rejects while
25
+ * leaving native OpenAI egress unchanged.
26
+ */
27
+ export declare function sanitizeBedrockMantleRequestBody(body: unknown): unknown;
17
28
  export declare class BedrockProviderSource implements ProviderSource {
18
29
  #private;
19
30
  readonly sourceId: "bedrock";
@@ -1,20 +1,173 @@
1
1
  /**
2
- * Amazon Bedrock provider source. Discovers Anthropic foundation models and
3
- * inference profiles, then sends chat through Bedrock Converse. OpenAI Chat
4
- * Completions JSON ↔ Converse translation lives in `bedrock-codec.ts`.
2
+ * Amazon Bedrock provider source. Anthropic models use Bedrock Converse;
3
+ * allowlisted OpenAI frontier models use the regional bedrock-mantle
4
+ * OpenAI-compatible API.
5
5
  */
6
6
  import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from "@aws-sdk/client-bedrock";
7
7
  import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime";
8
8
  import { RouteKitFailure } from "@velum-labs/routekit-runtime/effect";
9
9
  import { Effect } from "effect";
10
10
  import { errorResponse, fromBedrockConverseOutput, isOpusFiveModel, streamResponse, toBedrockConverseInput } from "./bedrock-codec.js";
11
+ import { OpenAiBackend } from "./openai-backend.js";
11
12
  import { gatewayTry, gatewayTryPromise } from "../effect/gateway.js";
12
13
  export { fromBedrockConverseOutput, toBedrockConverseInput };
14
+ export const BEDROCK_OPENAI_ALLOWLIST = [
15
+ "openai.gpt-5.4",
16
+ "openai.gpt-5.5",
17
+ "openai.gpt-5.6-sol",
18
+ "openai.gpt-5.6-terra",
19
+ "openai.gpt-5.6-luna"
20
+ ];
21
+ const BEDROCK_OPENAI_MODEL = /^(?:(?:us|eu|global)\.)?openai\.gpt-/;
22
+ export function isBedrockOpenAiModel(modelId) {
23
+ return BEDROCK_OPENAI_MODEL.test(modelId);
24
+ }
13
25
  function record(value) {
14
26
  return typeof value === "object" && value !== null && !Array.isArray(value)
15
27
  ? value
16
28
  : undefined;
17
29
  }
30
+ function recognizedEncryptedPrefix(value) {
31
+ return typeof value === "string" && (value.startsWith("rsn_") || value.startsWith("smry_"));
32
+ }
33
+ function sanitizeBedrockMantleContent(content) {
34
+ if (!Array.isArray(content))
35
+ return { content, changed: false };
36
+ const parts = content.filter((part) => record(part)?.type !== "encrypted_content");
37
+ return { content: parts, changed: parts.length !== content.length };
38
+ }
39
+ function sanitizeBedrockMantleInputItem(item) {
40
+ const entry = record(item);
41
+ if (entry === undefined)
42
+ return item;
43
+ const type = typeof entry.type === "string" ? entry.type : "";
44
+ if (type === "compaction" &&
45
+ "encrypted_content" in entry &&
46
+ !recognizedEncryptedPrefix(entry.encrypted_content)) {
47
+ return undefined;
48
+ }
49
+ if (type === "agent_message") {
50
+ const { content } = sanitizeBedrockMantleContent(entry.content);
51
+ return { type: "message", role: "user", content };
52
+ }
53
+ let next = entry;
54
+ let changed = false;
55
+ if (Array.isArray(entry.content)) {
56
+ const sanitized = sanitizeBedrockMantleContent(entry.content);
57
+ if (sanitized.changed) {
58
+ next = { ...next, content: sanitized.content };
59
+ changed = true;
60
+ }
61
+ }
62
+ if (type === "reasoning" &&
63
+ "encrypted_content" in next &&
64
+ !recognizedEncryptedPrefix(next.encrypted_content)) {
65
+ const { encrypted_content: _ignored, ...rest } = next;
66
+ next = rest;
67
+ changed = true;
68
+ }
69
+ return changed ? next : item;
70
+ }
71
+ /**
72
+ * Remove OpenAI/Codex protocol fields that bedrock-mantle rejects while
73
+ * leaving native OpenAI egress unchanged.
74
+ */
75
+ export function sanitizeBedrockMantleRequestBody(body) {
76
+ const payload = record(body);
77
+ if (payload === undefined)
78
+ return body;
79
+ let next = payload;
80
+ let changed = false;
81
+ if (Array.isArray(payload.tools)) {
82
+ const tools = payload.tools.map((tool) => {
83
+ const entry = record(tool);
84
+ if (entry === undefined)
85
+ return tool;
86
+ const type = typeof entry.type === "string" ? entry.type : "";
87
+ if (!type.startsWith("web_search") || !("search_content_types" in entry))
88
+ return tool;
89
+ changed = true;
90
+ const { search_content_types: _ignored, ...rest } = entry;
91
+ return rest;
92
+ });
93
+ if (changed)
94
+ next = { ...next, tools };
95
+ }
96
+ if (Array.isArray(payload.input)) {
97
+ const input = [];
98
+ let inputChanged = false;
99
+ for (const item of payload.input) {
100
+ const sanitized = sanitizeBedrockMantleInputItem(item);
101
+ if (sanitized === undefined) {
102
+ inputChanged = true;
103
+ continue;
104
+ }
105
+ if (sanitized !== item)
106
+ inputChanged = true;
107
+ input.push(sanitized);
108
+ }
109
+ if (inputChanged) {
110
+ changed = true;
111
+ next = { ...next, input };
112
+ }
113
+ }
114
+ return changed ? next : body;
115
+ }
116
+ function mantleApiKey(env) {
117
+ const key = env.AWS_BEARER_TOKEN_BEDROCK ?? env.BEDROCK_API_KEY;
118
+ return typeof key === "string" && key.length > 0 ? key : undefined;
119
+ }
120
+ function mantleRegion(env) {
121
+ const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION;
122
+ return typeof region === "string" && region.length > 0 ? region : undefined;
123
+ }
124
+ function mantleBaseUrl(region) {
125
+ return `https://bedrock-mantle.${region}.api.aws/openai/v1`;
126
+ }
127
+ function bedrockOpenAiNativeId(modelId) {
128
+ return modelId.replace(/^(?:us|eu|global)\./, "");
129
+ }
130
+ function bedrockOpenAiReasoning(modelId) {
131
+ const native = bedrockOpenAiNativeId(modelId).replace(/^openai\./, "");
132
+ if (/^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(native)) {
133
+ return {
134
+ status: "supported",
135
+ efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
136
+ defaultEffort: "medium",
137
+ wireShape: "openai-responses",
138
+ provenance: "builtin"
139
+ };
140
+ }
141
+ if (/^gpt-5\.(?:4|5)(?:-\d{4}-\d{2}-\d{2})?$/.test(native)) {
142
+ return {
143
+ status: "supported",
144
+ efforts: ["none", "low", "medium", "high", "xhigh"].map((id) => ({ id })),
145
+ wireShape: "openai-responses",
146
+ provenance: "builtin"
147
+ };
148
+ }
149
+ return {
150
+ status: "supported",
151
+ wireShape: "openai-responses",
152
+ provenance: "builtin"
153
+ };
154
+ }
155
+ function bedrockOpenAiDiscoveredModel(id) {
156
+ return {
157
+ id,
158
+ metadata: {
159
+ architecture: {
160
+ modality: "text+image->text",
161
+ inputModalities: ["text", "image"],
162
+ outputModalities: ["text"]
163
+ },
164
+ supportedParameters: ["tools", "tool_choice"],
165
+ provenance: "route"
166
+ },
167
+ reasoning: bedrockOpenAiReasoning(id),
168
+ capabilities: { streaming: "supported" }
169
+ };
170
+ }
18
171
  function anthropicFoundationModel(model) {
19
172
  return (model.providerName?.toLowerCase() === "anthropic" &&
20
173
  model.modelLifecycle?.status === "ACTIVE" &&
@@ -96,20 +249,30 @@ export class BedrockProviderSource {
96
249
  sourceId = "bedrock";
97
250
  discovery;
98
251
  requests;
99
- responses = { kind: "unsupported" };
252
+ responses;
100
253
  capabilities;
101
254
  resource;
102
255
  #control;
103
256
  #runtime;
257
+ #env;
258
+ #injectedMantle;
259
+ #mantle;
104
260
  #inferenceProfilesByFoundation = new Map();
105
261
  constructor(options = {}) {
106
262
  this.#control = options.controlClient ?? new BedrockClient({});
107
263
  this.#runtime = options.runtimeClient ?? new BedrockRuntimeClient({});
264
+ this.#env = options.env ?? process.env;
265
+ this.#injectedMantle = options.mantleBackend;
108
266
  this.discovery = { discoverModels: (signal) => this.#discoverModels(signal) };
109
267
  this.requests = {
110
268
  chat: (body, signal, requestOptions) => this.#chat(body, signal, requestOptions),
111
269
  embeddings: () => Effect.succeed(Response.json({ error: { type: "not_implemented", message: "Bedrock embeddings are not supported" } }, { status: 501 }))
112
270
  };
271
+ this.responses = {
272
+ kind: "responses",
273
+ supports: (model) => isBedrockOpenAiModel(model),
274
+ execute: (body, signal, requestOptions) => this.#responses(body, signal, requestOptions)
275
+ };
113
276
  this.capabilities = {
114
277
  forModel: () => ({}),
115
278
  reasoningForModel: (model) => this.#reasoningCapabilities(model)
@@ -122,32 +285,73 @@ export class BedrockProviderSource {
122
285
  })
123
286
  };
124
287
  }
288
+ #mantleBackend() {
289
+ if (this.#injectedMantle !== undefined)
290
+ return this.#injectedMantle;
291
+ if (this.#mantle !== undefined)
292
+ return this.#mantle;
293
+ const apiKey = mantleApiKey(this.#env);
294
+ const region = mantleRegion(this.#env);
295
+ if (apiKey === undefined || region === undefined)
296
+ return undefined;
297
+ this.#mantle = new OpenAiBackend({
298
+ baseUrl: mantleBaseUrl(region),
299
+ apiKey
300
+ });
301
+ return this.#mantle;
302
+ }
303
+ #missingMantleResponse() {
304
+ return Response.json({
305
+ error: {
306
+ type: "invalid_request_error",
307
+ message: "Bedrock OpenAI models require AWS_BEARER_TOKEN_BEDROCK and AWS_REGION"
308
+ }
309
+ }, { status: 400 });
310
+ }
125
311
  #discoverModels(signal) {
126
312
  const self = this;
127
313
  return Effect.gen(function* () {
128
314
  self.#inferenceProfilesByFoundation.clear();
129
315
  const abort = signal === undefined ? undefined : { abortSignal: signal };
130
- const foundation = yield* gatewayTryPromise(() => self.#control.send(new ListFoundationModelsCommand({ byProvider: "Anthropic" }), abort));
131
- const foundations = (foundation.modelSummaries ?? []).filter(anthropicFoundationModel);
132
- const byId = new Map(foundations.map((model) => [model.modelId, model]));
133
- const ids = new Set(byId.keys());
134
- const discovered = new Map(foundations.map((model) => [model.modelId, bedrockDiscoveredModel(model.modelId, model)]));
135
- let nextToken;
136
- do {
137
- const profiles = yield* gatewayTryPromise(() => self.#control.send(new ListInferenceProfilesCommand({ ...(nextToken !== undefined ? { nextToken } : {}) }), abort));
138
- for (const profile of profiles.inferenceProfileSummaries ?? []) {
139
- if (!activeAnthropicProfile(profile, ids))
140
- continue;
141
- const backingId = (profile.models ?? [])
142
- .map((model) => foundationIdFromArn(model.modelArn))
143
- .find((id) => id !== undefined && ids.has(id));
144
- if (backingId !== undefined) {
145
- self.#inferenceProfilesByFoundation.set(backingId, preferredInferenceProfile(self.#inferenceProfilesByFoundation.get(backingId), profile.inferenceProfileId));
146
- discovered.set(profile.inferenceProfileId, bedrockDiscoveredModel(profile.inferenceProfileId, byId.get(backingId)));
316
+ const discovered = yield* Effect.gen(function* () {
317
+ const foundation = yield* gatewayTryPromise(() => self.#control.send(new ListFoundationModelsCommand({ byProvider: "Anthropic" }), abort));
318
+ const foundations = (foundation.modelSummaries ?? []).filter(anthropicFoundationModel);
319
+ const byId = new Map(foundations.map((model) => [model.modelId, model]));
320
+ const ids = new Set(byId.keys());
321
+ const anthropic = new Map(foundations.map((model) => [
322
+ model.modelId,
323
+ bedrockDiscoveredModel(model.modelId, model)
324
+ ]));
325
+ let nextToken;
326
+ do {
327
+ const profiles = yield* gatewayTryPromise(() => self.#control.send(new ListInferenceProfilesCommand({
328
+ ...(nextToken !== undefined ? { nextToken } : {})
329
+ }), abort));
330
+ for (const profile of profiles.inferenceProfileSummaries ?? []) {
331
+ if (!activeAnthropicProfile(profile, ids))
332
+ continue;
333
+ const backingId = (profile.models ?? [])
334
+ .map((model) => foundationIdFromArn(model.modelArn))
335
+ .find((id) => id !== undefined && ids.has(id));
336
+ if (backingId !== undefined) {
337
+ self.#inferenceProfilesByFoundation.set(backingId, preferredInferenceProfile(self.#inferenceProfilesByFoundation.get(backingId), profile.inferenceProfileId));
338
+ anthropic.set(profile.inferenceProfileId, bedrockDiscoveredModel(profile.inferenceProfileId, byId.get(backingId)));
339
+ }
147
340
  }
341
+ nextToken = profiles.nextToken;
342
+ } while (nextToken !== undefined && nextToken.length > 0);
343
+ return anthropic;
344
+ }).pipe(Effect.catch((error) => {
345
+ if (mantleApiKey(self.#env) === undefined)
346
+ return Effect.fail(error);
347
+ self.#inferenceProfilesByFoundation.clear();
348
+ return Effect.succeed(new Map());
349
+ }));
350
+ if (mantleApiKey(self.#env) !== undefined) {
351
+ for (const id of BEDROCK_OPENAI_ALLOWLIST) {
352
+ discovered.set(id, bedrockOpenAiDiscoveredModel(id));
148
353
  }
149
- nextToken = profiles.nextToken;
150
- } while (nextToken !== undefined && nextToken.length > 0);
354
+ }
151
355
  if (discovered.size === 0) {
152
356
  return yield* new RouteKitFailure({
153
357
  message: "model discovery returned no active Anthropic Bedrock models"
@@ -156,9 +360,27 @@ export class BedrockProviderSource {
156
360
  return [...discovered.values()];
157
361
  });
158
362
  }
159
- #chat(body, signal, _options) {
363
+ #responses(body, signal, options) {
364
+ const requestedModel = record(body)?.model;
365
+ const model = typeof requestedModel === "string" ? requestedModel : "";
366
+ if (!isBedrockOpenAiModel(model)) {
367
+ return Effect.succeed(Response.json({ error: { type: "not_supported", message: "native Responses egress is not supported" } }, { status: 501 }));
368
+ }
369
+ const backend = this.#mantleBackend();
370
+ if (backend === undefined)
371
+ return Effect.succeed(this.#missingMantleResponse());
372
+ return backend.responses(sanitizeBedrockMantleRequestBody(body), signal, options);
373
+ }
374
+ #chat(body, signal, options) {
160
375
  const self = this;
161
376
  return Effect.gen(function* () {
377
+ const requestedModel = record(body)?.model;
378
+ if (typeof requestedModel === "string" && isBedrockOpenAiModel(requestedModel)) {
379
+ const backend = self.#mantleBackend();
380
+ if (backend === undefined)
381
+ return self.#missingMantleResponse();
382
+ return yield* backend.chat(sanitizeBedrockMantleRequestBody(body), signal, options);
383
+ }
162
384
  const input = yield* gatewayTry(() => toBedrockConverseInput(body)).pipe(Effect.catch((error) => Effect.succeed(Response.json({
163
385
  error: {
164
386
  type: "invalid_request_error",
@@ -170,7 +392,7 @@ export class BedrockProviderSource {
170
392
  const stream = record(body)?.stream === true;
171
393
  const modelId = input.modelId;
172
394
  if (modelId === undefined)
173
- return errorResponse(new Error("Bedrock chat requires a model"));
395
+ return errorResponse("Bedrock chat requires a model");
174
396
  const runtimeModelId = modelId === "anthropic.claude-opus-5"
175
397
  ? (self.#inferenceProfilesByFoundation.get(modelId) ?? modelId)
176
398
  : modelId;
@@ -181,7 +403,7 @@ export class BedrockProviderSource {
181
403
  if (output instanceof Response)
182
404
  return output;
183
405
  if (output.stream === undefined)
184
- return errorResponse(new Error("Bedrock returned no response stream"));
406
+ return errorResponse("Bedrock returned no response stream");
185
407
  return streamResponse(output.stream, modelId, signal);
186
408
  }
187
409
  const output = yield* gatewayTryPromise(() => self.#runtime.send(new ConverseCommand(runtimeInput), abort)).pipe(Effect.catch((error) => Effect.succeed(errorResponse(error))));
@@ -191,6 +413,9 @@ export class BedrockProviderSource {
191
413
  });
192
414
  }
193
415
  #reasoningCapabilities(model) {
416
+ if (model !== undefined && isBedrockOpenAiModel(model)) {
417
+ return bedrockOpenAiReasoning(model);
418
+ }
194
419
  const known = bedrockReasoningCapabilities(model);
195
420
  if (known !== undefined)
196
421
  return known;
@@ -128,7 +128,7 @@ export class RoutingBackend {
128
128
  if (injected !== undefined)
129
129
  return injected;
130
130
  if (provider === "bedrock")
131
- return new BedrockProviderSource();
131
+ return new BedrockProviderSource({ env });
132
132
  if (isApiProvider(provider)) {
133
133
  return (options.createApiSource?.(provider, env) ??
134
134
  new ApiProviderSource({ provider, env }));
@@ -3,7 +3,8 @@ import { test } from "node:test";
3
3
  import { ListFoundationModelsCommand } from "@aws-sdk/client-bedrock";
4
4
  import { ConverseCommand, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime";
5
5
  import { runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
6
- import { BedrockProviderSource, toBedrockConverseInput } from "../providers/bedrock-source.js";
6
+ import { Effect } from "effect";
7
+ import { BEDROCK_OPENAI_ALLOWLIST, BedrockProviderSource, isBedrockOpenAiModel, sanitizeBedrockMantleRequestBody, toBedrockConverseInput } from "../providers/bedrock-source.js";
7
8
  test("Bedrock discovery includes active Anthropic foundations and paginated backed profiles", async () => {
8
9
  const commands = [];
9
10
  const source = new BedrockProviderSource({
@@ -124,6 +125,192 @@ test("Bedrock discovery includes active Anthropic foundations and paginated back
124
125
  assert.deepEqual(discovered[3]?.reasoning, discovered[1]?.reasoning);
125
126
  assert.equal(commands.length, 3);
126
127
  });
128
+ test("Bedrock OpenAI model ids stay on mantle and never match Anthropic Converse", () => {
129
+ assert.equal(isBedrockOpenAiModel("openai.gpt-5.4"), true);
130
+ assert.equal(isBedrockOpenAiModel("openai.gpt-5.6-sol"), true);
131
+ assert.equal(isBedrockOpenAiModel("us.openai.gpt-5.6-terra"), true);
132
+ assert.equal(isBedrockOpenAiModel("anthropic.claude-3"), false);
133
+ assert.equal(isBedrockOpenAiModel("us.anthropic.claude-3"), false);
134
+ });
135
+ test("Bedrock discovery unions OpenAI mantle models when a bearer token is present", async () => {
136
+ const source = new BedrockProviderSource({
137
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
138
+ controlClient: {
139
+ send: async (command) => {
140
+ if (command instanceof ListFoundationModelsCommand) {
141
+ return {
142
+ modelSummaries: [
143
+ {
144
+ modelId: "anthropic.claude-3",
145
+ providerName: "Anthropic",
146
+ modelLifecycle: { status: "ACTIVE" }
147
+ }
148
+ ]
149
+ };
150
+ }
151
+ return { inferenceProfileSummaries: [] };
152
+ }
153
+ },
154
+ runtimeClient: { send: async () => ({}) }
155
+ });
156
+ const discovered = await runRouteKitEffect(source.discovery.discoverModels());
157
+ assert.deepEqual(discovered.map((model) => model.id), ["anthropic.claude-3", ...BEDROCK_OPENAI_ALLOWLIST]);
158
+ const sol = discovered.find((model) => model.id === "openai.gpt-5.6-sol");
159
+ assert.equal(sol?.reasoning?.wireShape, "openai-responses");
160
+ assert.equal(sol?.reasoning?.defaultEffort, "medium");
161
+ });
162
+ test("Bedrock discovery keeps OpenAI models when Anthropic listing fails", async () => {
163
+ const source = new BedrockProviderSource({
164
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
165
+ controlClient: {
166
+ send: async () => {
167
+ throw new Error("not authorized");
168
+ }
169
+ },
170
+ runtimeClient: { send: async () => ({}) }
171
+ });
172
+ const discovered = await runRouteKitEffect(source.discovery.discoverModels());
173
+ assert.deepEqual(discovered.map((model) => model.id), [...BEDROCK_OPENAI_ALLOWLIST]);
174
+ });
175
+ test("Bedrock routes OpenAI models through mantle and leaves Anthropic on Converse", async () => {
176
+ const runtimeCalls = [];
177
+ const mantleCalls = [];
178
+ const source = new BedrockProviderSource({
179
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
180
+ controlClient: { send: async () => ({}) },
181
+ runtimeClient: {
182
+ send: async (value) => {
183
+ runtimeCalls.push(value);
184
+ return {
185
+ $metadata: { requestId: "req-1" },
186
+ output: { message: { role: "assistant", content: [{ text: "claude" }] } },
187
+ stopReason: "end_turn"
188
+ };
189
+ }
190
+ },
191
+ mantleBackend: {
192
+ chat: (body) => {
193
+ const model = typeof body.model === "string"
194
+ ? body.model
195
+ : undefined;
196
+ mantleCalls.push({ kind: "chat", ...(model !== undefined ? { model } : {}) });
197
+ return Effect.succeed(Response.json({ id: "chat", model }));
198
+ },
199
+ responses: (body) => {
200
+ const model = typeof body.model === "string"
201
+ ? body.model
202
+ : undefined;
203
+ mantleCalls.push({ kind: "responses", ...(model !== undefined ? { model } : {}) });
204
+ return Effect.succeed(Response.json({ id: "resp", model }));
205
+ }
206
+ }
207
+ });
208
+ assert.equal(source.responses.kind === "responses" && source.responses.supports("openai.gpt-5.4"), true);
209
+ assert.equal(source.responses.kind === "responses" && source.responses.supports("anthropic.claude-3"), false);
210
+ assert.equal(source.capabilities.reasoningForModel("openai.gpt-5.6-sol")?.wireShape, "openai-responses");
211
+ const openaiChat = await runRouteKitEffect(source.requests.chat({
212
+ model: "openai.gpt-5.4",
213
+ messages: [{ role: "user", content: "hi" }]
214
+ }));
215
+ assert.deepEqual(await openaiChat.json(), { id: "chat", model: "openai.gpt-5.4" });
216
+ assert.equal(source.responses.kind, "responses");
217
+ if (source.responses.kind !== "responses")
218
+ throw new Error("expected Responses support");
219
+ const openaiResponses = await runRouteKitEffect(source.responses.execute({
220
+ model: "openai.gpt-5.6-terra",
221
+ input: "hi"
222
+ }));
223
+ assert.deepEqual(await openaiResponses.json(), {
224
+ id: "resp",
225
+ model: "openai.gpt-5.6-terra"
226
+ });
227
+ const anthropic = await runRouteKitEffect(source.requests.chat({
228
+ model: "anthropic.claude-3",
229
+ messages: [{ role: "user", content: "hi" }]
230
+ }));
231
+ assert.equal(anthropic.status, 200);
232
+ assert.equal(runtimeCalls.length, 1);
233
+ assert.equal(runtimeCalls[0] instanceof ConverseCommand, true);
234
+ assert.deepEqual(mantleCalls, [
235
+ { kind: "chat", model: "openai.gpt-5.4" },
236
+ { kind: "responses", model: "openai.gpt-5.6-terra" }
237
+ ]);
238
+ const rejected = await runRouteKitEffect(source.responses.execute({
239
+ model: "anthropic.claude-3",
240
+ input: "hi"
241
+ }));
242
+ assert.equal(rejected.status, 501);
243
+ });
244
+ test("Bedrock mantle sanitizes Codex-only request fields before egress", async () => {
245
+ const forwarded = [];
246
+ const source = new BedrockProviderSource({
247
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
248
+ controlClient: { send: async () => ({}) },
249
+ runtimeClient: { send: async () => ({}) },
250
+ mantleBackend: {
251
+ chat: () => Effect.succeed(Response.json({})),
252
+ responses: (body) => {
253
+ forwarded.push(body);
254
+ return Effect.succeed(Response.json({ id: "resp" }));
255
+ }
256
+ }
257
+ });
258
+ const body = {
259
+ model: "openai.gpt-5.6-sol",
260
+ tools: [{ type: "web_search", search_content_types: ["text", "image"] }],
261
+ input: [
262
+ {
263
+ type: "agent_message",
264
+ author: "/root",
265
+ recipient: "/root/worker",
266
+ content: [
267
+ { type: "input_text", text: "hi" },
268
+ { type: "encrypted_content", encrypted_content: " " }
269
+ ]
270
+ },
271
+ { type: "compaction", encrypted_content: "not-a-prefix" },
272
+ { type: "reasoning", encrypted_content: "rsn_ok", summary: [] }
273
+ ]
274
+ };
275
+ assert.deepEqual(sanitizeBedrockMantleRequestBody(body), {
276
+ model: "openai.gpt-5.6-sol",
277
+ tools: [{ type: "web_search" }],
278
+ input: [
279
+ {
280
+ type: "message",
281
+ role: "user",
282
+ content: [{ type: "input_text", text: "hi" }]
283
+ },
284
+ { type: "reasoning", encrypted_content: "rsn_ok", summary: [] }
285
+ ]
286
+ });
287
+ assert.equal(source.responses.kind, "responses");
288
+ if (source.responses.kind !== "responses")
289
+ throw new Error("expected Responses support");
290
+ const response = await runRouteKitEffect(source.responses.execute(body));
291
+ assert.equal(response.status, 200);
292
+ assert.deepEqual(forwarded, [sanitizeBedrockMantleRequestBody(body)]);
293
+ });
294
+ test("Bedrock OpenAI chat without a mantle key returns 400 and skips Converse", async () => {
295
+ const runtimeCalls = [];
296
+ const source = new BedrockProviderSource({
297
+ env: { AWS_REGION: "us-east-1" },
298
+ controlClient: { send: async () => ({}) },
299
+ runtimeClient: {
300
+ send: async (value) => {
301
+ runtimeCalls.push(value);
302
+ return {};
303
+ }
304
+ }
305
+ });
306
+ const response = await runRouteKitEffect(source.requests.chat({
307
+ model: "openai.gpt-5.5",
308
+ messages: [{ role: "user", content: "hi" }]
309
+ }));
310
+ assert.equal(response.status, 400);
311
+ assert.match(await response.text(), /AWS_BEARER_TOKEN_BEDROCK/);
312
+ assert.equal(runtimeCalls.length, 0);
313
+ });
127
314
  test("Bedrock request translation covers system, image, tools, results, and inference config", () => {
128
315
  const input = toBedrockConverseInput({
129
316
  model: "us.anthropic.claude-3",
@@ -936,3 +936,53 @@ test("Bedrock Opus 5 exposes reasoning controls and accepts routed effort select
936
936
  const error = (await rejected.json());
937
937
  assert.equal(error.error.code, "unsupported_reasoning_control");
938
938
  });
939
+ test("Bedrock OpenAI models keep native ids and route through Responses", async () => {
940
+ const calls = [];
941
+ const bedrock = fakeSource("bedrock", [
942
+ {
943
+ id: "openai.gpt-5.6-sol",
944
+ reasoning: {
945
+ status: "supported",
946
+ efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
947
+ defaultEffort: "medium",
948
+ wireShape: "openai-responses",
949
+ provenance: "builtin"
950
+ }
951
+ }
952
+ ], calls);
953
+ const backend = await runRouteKitEffect(RoutingBackend.create({
954
+ config: { providers: { bedrock: {} }, defaultModel: "bedrock/openai.gpt-5.6-sol" },
955
+ sources: {
956
+ bedrock: {
957
+ ...bedrock,
958
+ responses: {
959
+ kind: "responses",
960
+ supports: (model) => model.startsWith("openai."),
961
+ execute: (body) => {
962
+ const model = typeof body === "object" &&
963
+ body !== null &&
964
+ "model" in body &&
965
+ typeof body.model === "string"
966
+ ? body.model
967
+ : undefined;
968
+ calls.push({
969
+ source: "bedrock-responses",
970
+ ...(model !== undefined ? { model } : {})
971
+ });
972
+ return Effect.succeed(Response.json({ ok: true, model }));
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }));
978
+ assert.deepEqual(backend.listModelIds(), ["bedrock/openai.gpt-5.6-sol"]);
979
+ assert.equal(backend.supportsResponses("bedrock/openai.gpt-5.6-sol"), true);
980
+ assert.equal(backend.modelInfo("bedrock/openai.gpt-5.6-sol")?.reasoning?.wireShape, "openai-responses");
981
+ const response = await runRouteKitEffect(backend.responses({
982
+ model: "bedrock/openai.gpt-5.6-sol",
983
+ input: "hi"
984
+ }));
985
+ assert.equal(response.status, 200);
986
+ assert.deepEqual(await response.json(), { ok: true, model: "openai.gpt-5.6-sol" });
987
+ assert.deepEqual(calls, [{ source: "bedrock-responses", model: "openai.gpt-5.6-sol" }]);
988
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "1.0.4",
4
+ "version": "1.0.6",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -45,12 +45,12 @@
45
45
  "@aws-sdk/client-bedrock": "3.1095.0",
46
46
  "@aws-sdk/client-bedrock-runtime": "3.1095.0",
47
47
  "effect": "4.0.0-rc.108",
48
- "@velum-labs/routekit-config-core": "1.0.4",
49
- "@velum-labs/routekit-contracts": "1.0.4",
50
- "@velum-labs/routekit-eval-contracts": "1.0.4",
51
- "@velum-labs/routekit-eval-core": "1.0.4",
52
- "@velum-labs/routekit-registry": "1.0.4",
53
- "@velum-labs/routekit-runtime": "1.0.4"
48
+ "@velum-labs/routekit-config-core": "1.0.6",
49
+ "@velum-labs/routekit-contracts": "1.0.6",
50
+ "@velum-labs/routekit-eval-contracts": "1.0.6",
51
+ "@velum-labs/routekit-eval-core": "1.0.6",
52
+ "@velum-labs/routekit-registry": "1.0.6",
53
+ "@velum-labs/routekit-runtime": "1.0.6"
54
54
  },
55
55
  "keywords": [
56
56
  "routekit",