@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 +2 -2
- package/dist/index.js +1 -1
- package/dist/providers/bedrock-source.d.ts +14 -3
- package/dist/providers/bedrock-source.js +251 -26
- package/dist/routing/router.js +1 -1
- package/dist/test/bedrock-source.test.js +188 -1
- package/dist/test/router.test.js +50 -0
- package/package.json +7 -7
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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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(
|
|
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;
|
package/dist/routing/router.js
CHANGED
|
@@ -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 {
|
|
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",
|
package/dist/test/router.test.js
CHANGED
|
@@ -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
|
+
"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.
|
|
49
|
-
"@velum-labs/routekit-contracts": "1.0.
|
|
50
|
-
"@velum-labs/routekit-eval-contracts": "1.0.
|
|
51
|
-
"@velum-labs/routekit-eval-core": "1.0.
|
|
52
|
-
"@velum-labs/routekit-registry": "1.0.
|
|
53
|
-
"@velum-labs/routekit-runtime": "1.0.
|
|
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",
|