@velum-labs/routekit-gateway 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,45 @@
1
+ export type ResponsesReasoningOwner = {
2
+ provider: string;
3
+ nativeModel: string;
4
+ };
5
+ export type ResponsesReasoningInputPolicy = {
6
+ mode: "drop";
7
+ } | {
8
+ mode: "relay";
9
+ } | {
10
+ mode: "forward";
11
+ owner: ResponsesReasoningOwner;
12
+ /** Keep the RouteKit wrapper for a later Chat-to-Responses provider bridge. */
13
+ unwrap?: boolean;
14
+ };
15
+ export type ParsedResponsesEncryptedContent = {
16
+ owner: ResponsesReasoningOwner;
17
+ ciphertext: string;
18
+ };
19
+ export type PreparedResponsesReasoningInput = {
20
+ body: unknown;
21
+ dropped: number;
22
+ };
23
+ /**
24
+ * Repair RouteKit's legacy tool-search item prefix before replaying history to
25
+ * a native OpenAI Responses destination. Tool outputs correlate through
26
+ * `call_id`, so changing only the item `id` preserves the execution pair.
27
+ */
28
+ export declare function repairLegacyToolSearchItemIds(body: unknown): unknown;
29
+ /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
30
+ export declare function wrapResponsesEncryptedContent(ciphertext: string, owner: ResponsesReasoningOwner): string;
31
+ /** Parse a RouteKit-owned encrypted-reasoning envelope. Raw provider ciphertext is ownerless. */
32
+ export declare function parseResponsesEncryptedContent(value: unknown): ParsedResponsesEncryptedContent | undefined;
33
+ /**
34
+ * Apply the destination policy to Responses input without mutating the request.
35
+ * Incompatible reasoning items are removed while their assistant/tool carriers
36
+ * remain in the portable transcript.
37
+ */
38
+ export declare function prepareResponsesReasoningInput(body: unknown, policy: ResponsesReasoningInputPolicy): PreparedResponsesReasoningInput;
39
+ /**
40
+ * Add ownership to encrypted reasoning emitted by a successful native
41
+ * Responses call. Provider failures and unrelated response formats pass through.
42
+ */
43
+ export declare function wrapResponsesReasoningResponse(response: Response, owner: ResponsesReasoningOwner): Promise<Response>;
1
44
  /** Normalize OpenAI Responses call ids without mutating the caller's request. */
2
45
  export declare function normalizeOpenAiResponsesCallIds(body: unknown): unknown;
@@ -1,6 +1,282 @@
1
1
  import { createHash } from "node:crypto";
2
2
  const OPENAI_RESPONSES_CALL_ID_MAX_LENGTH = 64;
3
3
  const NORMALIZED_CALL_ID_PREFIX = "rk_";
4
+ const ENCRYPTED_REASONING_PREFIX = "rk1.";
5
+ const LEGACY_TOOL_SEARCH_ITEM_ID_PREFIX = "ttc_";
6
+ const TOOL_SEARCH_ITEM_ID_PREFIX = "tsc_";
7
+ function sameOwner(left, right) {
8
+ return left.provider === right.provider && left.nativeModel === right.nativeModel;
9
+ }
10
+ /**
11
+ * Repair RouteKit's legacy tool-search item prefix before replaying history to
12
+ * a native OpenAI Responses destination. Tool outputs correlate through
13
+ * `call_id`, so changing only the item `id` preserves the execution pair.
14
+ */
15
+ export function repairLegacyToolSearchItemIds(body) {
16
+ if (typeof body !== "object" || body === null || Array.isArray(body))
17
+ return body;
18
+ const record = body;
19
+ if (!Array.isArray(record.input))
20
+ return body;
21
+ let changed = false;
22
+ const input = record.input.map((candidate) => {
23
+ if (typeof candidate !== "object" ||
24
+ candidate === null ||
25
+ Array.isArray(candidate)) {
26
+ return candidate;
27
+ }
28
+ const item = candidate;
29
+ if (item.type !== "tool_search_call" ||
30
+ typeof item.id !== "string" ||
31
+ !item.id.startsWith(LEGACY_TOOL_SEARCH_ITEM_ID_PREFIX)) {
32
+ return candidate;
33
+ }
34
+ changed = true;
35
+ return {
36
+ ...item,
37
+ id: `${TOOL_SEARCH_ITEM_ID_PREFIX}${item.id.slice(LEGACY_TOOL_SEARCH_ITEM_ID_PREFIX.length)}`
38
+ };
39
+ });
40
+ return changed ? { ...record, input } : body;
41
+ }
42
+ /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
43
+ export function wrapResponsesEncryptedContent(ciphertext, owner) {
44
+ if (parseResponsesEncryptedContent(ciphertext) !== undefined)
45
+ return ciphertext;
46
+ const encodedOwner = Buffer.from(JSON.stringify({ p: owner.provider, m: owner.nativeModel }), "utf8").toString("base64url");
47
+ return `${ENCRYPTED_REASONING_PREFIX}${encodedOwner}.${ciphertext}`;
48
+ }
49
+ /** Parse a RouteKit-owned encrypted-reasoning envelope. Raw provider ciphertext is ownerless. */
50
+ export function parseResponsesEncryptedContent(value) {
51
+ if (typeof value !== "string" || !value.startsWith(ENCRYPTED_REASONING_PREFIX)) {
52
+ return undefined;
53
+ }
54
+ const separator = value.indexOf(".", ENCRYPTED_REASONING_PREFIX.length);
55
+ if (separator === -1)
56
+ return undefined;
57
+ const encodedOwner = value.slice(ENCRYPTED_REASONING_PREFIX.length, separator);
58
+ const ciphertext = value.slice(separator + 1);
59
+ if (encodedOwner.length === 0 || ciphertext.length === 0)
60
+ return undefined;
61
+ if (!/^[A-Za-z0-9_-]+$/.test(encodedOwner))
62
+ return undefined;
63
+ try {
64
+ const ownerBytes = Buffer.from(encodedOwner, "base64url");
65
+ if (ownerBytes.toString("base64url") !== encodedOwner)
66
+ return undefined;
67
+ const parsed = JSON.parse(ownerBytes.toString("utf8"));
68
+ if (parsed === null ||
69
+ typeof parsed !== "object" ||
70
+ typeof parsed.p !== "string" ||
71
+ parsed.p.length === 0 ||
72
+ typeof parsed.m !== "string" ||
73
+ parsed.m.length === 0) {
74
+ return undefined;
75
+ }
76
+ return {
77
+ owner: { provider: parsed.p, nativeModel: parsed.m },
78
+ ciphertext
79
+ };
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Apply the destination policy to Responses input without mutating the request.
87
+ * Incompatible reasoning items are removed while their assistant/tool carriers
88
+ * remain in the portable transcript.
89
+ */
90
+ export function prepareResponsesReasoningInput(body, policy) {
91
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
92
+ return { body, dropped: 0 };
93
+ }
94
+ const record = body;
95
+ if (!Array.isArray(record.input))
96
+ return { body, dropped: 0 };
97
+ let dropped = 0;
98
+ let changed = false;
99
+ const input = [];
100
+ for (const candidate of record.input) {
101
+ if (typeof candidate !== "object" ||
102
+ candidate === null ||
103
+ Array.isArray(candidate)) {
104
+ input.push(candidate);
105
+ continue;
106
+ }
107
+ const item = candidate;
108
+ if (item.type !== "reasoning" ||
109
+ typeof item.encrypted_content !== "string" ||
110
+ item.encrypted_content.length === 0) {
111
+ input.push(candidate);
112
+ continue;
113
+ }
114
+ const parsed = parseResponsesEncryptedContent(item.encrypted_content);
115
+ if (policy.mode === "drop") {
116
+ dropped += 1;
117
+ changed = true;
118
+ continue;
119
+ }
120
+ if (policy.mode === "relay") {
121
+ if (parsed === undefined) {
122
+ dropped += 1;
123
+ changed = true;
124
+ continue;
125
+ }
126
+ input.push(candidate);
127
+ continue;
128
+ }
129
+ if (parsed === undefined || !sameOwner(parsed.owner, policy.owner)) {
130
+ dropped += 1;
131
+ changed = true;
132
+ continue;
133
+ }
134
+ if (policy.unwrap === false) {
135
+ input.push(candidate);
136
+ continue;
137
+ }
138
+ input.push({ ...item, encrypted_content: parsed.ciphertext });
139
+ changed = true;
140
+ }
141
+ return {
142
+ body: changed ? { ...record, input } : body,
143
+ dropped
144
+ };
145
+ }
146
+ function wrapEncryptedReasoningValue(value, owner) {
147
+ if (Array.isArray(value)) {
148
+ let changed = false;
149
+ const items = value.map((item) => {
150
+ const transformed = wrapEncryptedReasoningValue(item, owner);
151
+ changed ||= transformed.changed;
152
+ return transformed.value;
153
+ });
154
+ return { value: changed ? items : value, changed };
155
+ }
156
+ if (typeof value !== "object" || value === null) {
157
+ return { value, changed: false };
158
+ }
159
+ const record = value;
160
+ let changed = false;
161
+ const output = {};
162
+ for (const [key, field] of Object.entries(record)) {
163
+ if (key === "encrypted_content" &&
164
+ record.type === "reasoning" &&
165
+ typeof field === "string" &&
166
+ field.length > 0) {
167
+ const wrapped = wrapResponsesEncryptedContent(field, owner);
168
+ output[key] = wrapped;
169
+ changed ||= wrapped !== field;
170
+ continue;
171
+ }
172
+ const transformed = wrapEncryptedReasoningValue(field, owner);
173
+ output[key] = transformed.value;
174
+ changed ||= transformed.changed;
175
+ }
176
+ return { value: changed ? output : value, changed };
177
+ }
178
+ function sseDataValue(line) {
179
+ if (line === "data")
180
+ return "";
181
+ if (!line.startsWith("data:"))
182
+ return undefined;
183
+ const value = line.slice("data:".length);
184
+ return value.startsWith(" ") ? value.slice(1) : value;
185
+ }
186
+ function wrapSseFrame(frame, delimiter, owner) {
187
+ const lineEnding = frame.includes("\r\n") ? "\r\n" : "\n";
188
+ const lines = frame.split(/\r?\n/);
189
+ const data = lines.flatMap((line) => {
190
+ const value = sseDataValue(line);
191
+ return value === undefined ? [] : [value];
192
+ });
193
+ if (data.length === 0 || data.join("\n") === "[DONE]") {
194
+ return `${frame}${delimiter}`;
195
+ }
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(data.join("\n"));
199
+ }
200
+ catch {
201
+ return `${frame}${delimiter}`;
202
+ }
203
+ const transformed = wrapEncryptedReasoningValue(parsed, owner);
204
+ if (!transformed.changed)
205
+ return `${frame}${delimiter}`;
206
+ let emittedData = false;
207
+ const rewritten = lines.flatMap((line) => {
208
+ if (sseDataValue(line) === undefined)
209
+ return [line];
210
+ if (emittedData)
211
+ return [];
212
+ emittedData = true;
213
+ return [`data: ${JSON.stringify(transformed.value)}`];
214
+ });
215
+ return `${rewritten.join(lineEnding)}${delimiter}`;
216
+ }
217
+ function wrapResponsesReasoningSse(source, owner) {
218
+ const decoder = new TextDecoder();
219
+ const encoder = new TextEncoder();
220
+ let buffer = "";
221
+ return source.pipeThrough(new TransformStream({
222
+ transform(chunk, controller) {
223
+ buffer += decoder.decode(chunk, { stream: true });
224
+ for (;;) {
225
+ const match = /\r?\n\r?\n/.exec(buffer);
226
+ if (match === null || match.index === undefined)
227
+ break;
228
+ const frame = buffer.slice(0, match.index);
229
+ const delimiter = match[0];
230
+ buffer = buffer.slice(match.index + delimiter.length);
231
+ controller.enqueue(encoder.encode(wrapSseFrame(frame, delimiter, owner)));
232
+ }
233
+ },
234
+ flush(controller) {
235
+ buffer += decoder.decode();
236
+ if (buffer.length > 0)
237
+ controller.enqueue(encoder.encode(buffer));
238
+ }
239
+ }));
240
+ }
241
+ /**
242
+ * Add ownership to encrypted reasoning emitted by a successful native
243
+ * Responses call. Provider failures and unrelated response formats pass through.
244
+ */
245
+ export async function wrapResponsesReasoningResponse(response, owner) {
246
+ if (!response.ok || response.body === null)
247
+ return response;
248
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
249
+ if (contentType.includes("text/event-stream")) {
250
+ const headers = new Headers(response.headers);
251
+ headers.delete("content-length");
252
+ headers.delete("content-encoding");
253
+ return new Response(wrapResponsesReasoningSse(response.body, owner), {
254
+ status: response.status,
255
+ statusText: response.statusText,
256
+ headers
257
+ });
258
+ }
259
+ if (!contentType.includes("json"))
260
+ return response;
261
+ let parsed;
262
+ try {
263
+ parsed = await response.clone().json();
264
+ }
265
+ catch {
266
+ return response;
267
+ }
268
+ const transformed = wrapEncryptedReasoningValue(parsed, owner);
269
+ if (!transformed.changed)
270
+ return response;
271
+ const headers = new Headers(response.headers);
272
+ headers.delete("content-length");
273
+ headers.delete("content-encoding");
274
+ return new Response(JSON.stringify(transformed.value), {
275
+ status: response.status,
276
+ statusText: response.statusText,
277
+ headers
278
+ });
279
+ }
4
280
  function normalizedCallId(callId, attempt = 0) {
5
281
  const hash = createHash("sha256").update(callId, "utf8");
6
282
  if (attempt > 0)
@@ -475,7 +475,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
475
475
  itemId: kind === "custom"
476
476
  ? `ctc_${randomId()}`
477
477
  : kind === "typed"
478
- ? `ttc_${randomId()}`
478
+ ? `tsc_${randomId()}`
479
479
  : kind === "server"
480
480
  ? `ws_${randomId()}`
481
481
  : `fc_${randomId()}`,
@@ -15,6 +15,7 @@
15
15
  import { randomId } from "@velum-labs/routekit-runtime";
16
16
  import { attachReasoningSelection, attachReasoningSelectionError, attachResponsesReasoningMetadata, reasoningSelectionErrorOf, hasExplicitReasoningSelection, reasoningSelectionOf, routeKitRequestValidationErrorOf, responsesReasoningMetadataOf } from "./openai-chat-wire.js";
17
17
  import { droppedField } from "./dropped.js";
18
+ import { prepareResponsesReasoningInput, repairLegacyToolSearchItemIds, wrapResponsesReasoningResponse } from "./openai-responses-wire.js";
18
19
  import { unwrapUpstreamError } from "./upstream-error.js";
19
20
  import { openAiSseToResponses } from "./responses-stream.js";
20
21
  import { chatUsageToResponses } from "./responses-usage.js";
@@ -722,7 +723,7 @@ function buildOutput(message, toolRegistry) {
722
723
  if (entry.kind === "typed") {
723
724
  output.push(typedToolCallItem({
724
725
  name,
725
- itemId: `ttc_${randomId()}`,
726
+ itemId: `tsc_${randomId()}`,
726
727
  callId: call.id ?? `call_${randomId()}`,
727
728
  args
728
729
  }));
@@ -775,6 +776,24 @@ export function chatToResponses(openai, model, toolRegistry = EMPTY_TOOL_REGISTR
775
776
  function jsonResponse(status, value) {
776
777
  return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
777
778
  }
779
+ function responsesReasoningOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses) {
780
+ const route = backend.resolveModelRoute?.(upstreamModel);
781
+ return route === undefined
782
+ ? {
783
+ // The only Chat backend that reconstructs OpenAI Responses is the
784
+ // Codex bridge. Native Responses fall back to their protocol identity.
785
+ provider: !supportsNativeResponses && destinationWireShape === "openai-responses"
786
+ ? "codex"
787
+ : destinationWireShape ?? "openai-responses",
788
+ nativeModel: upstreamModel
789
+ }
790
+ : { provider: route.provider, nativeModel: route.nativeId };
791
+ }
792
+ function recordDroppedEncryptedReasoning(count) {
793
+ if (count > 0) {
794
+ droppedField("responses", "encrypted_content", "input.reasoning");
795
+ }
796
+ }
778
797
  export async function handleResponses(backend, body, modelCallId, signal, backendOptions = {}) {
779
798
  const requestedModel = body.model ?? backend.defaultModel ?? "";
780
799
  const validationError = routeKitRequestValidationErrorOf(body);
@@ -829,6 +848,7 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
829
848
  const nativeResponses = backend.responses?.bind(backend);
830
849
  const supportsNativeResponses = nativeResponses !== undefined &&
831
850
  (backend.supportsResponses?.(upstreamModel) ?? true);
851
+ const reasoningOwner = responsesReasoningOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses);
832
852
  if (body.previous_response_id != null && !supportsNativeResponses) {
833
853
  return jsonResponse(400, {
834
854
  error: {
@@ -839,29 +859,21 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
839
859
  }
840
860
  });
841
861
  }
842
- const hasEncryptedReasoning = Array.isArray(body.input) &&
843
- body.input.some((item) => item.type === "reasoning" &&
844
- typeof item.encrypted_content === "string" &&
845
- item.encrypted_content.length > 0);
846
862
  const requestsEncryptedReasoning = Array.isArray(body.include) && body.include.includes("reasoning.encrypted_content");
847
863
  const preservesOpaqueReasoning = supportsNativeResponses ||
848
864
  destinationWireShape === "openai-responses" ||
849
865
  destinationWireShape === "routekit-envelope";
850
- if (hasEncryptedReasoning && !preservesOpaqueReasoning) {
851
- return jsonResponse(400, {
852
- error: {
853
- type: "invalid_request_error",
854
- code: "unsupported_encrypted_reasoning",
855
- param: "input",
856
- message: `model "${requestedModel}" cannot consume OpenAI Responses encrypted reasoning`
857
- }
858
- });
859
- }
860
866
  if (supportsNativeResponses && nativeResponses !== undefined) {
861
- return nativeResponses(body, signal, {
867
+ const prepared = prepareResponsesReasoningInput(repairLegacyToolSearchItemIds(body), {
868
+ mode: "forward",
869
+ owner: reasoningOwner
870
+ });
871
+ recordDroppedEncryptedReasoning(prepared.dropped);
872
+ const response = await nativeResponses(prepared.body, signal, {
862
873
  ...backendOptions,
863
874
  modelCallId
864
875
  });
876
+ return wrapResponsesReasoningResponse(response, reasoningOwner);
865
877
  }
866
878
  // Server-executed web search is honored when the caller declared the tool,
867
879
  // an executor is available (a provider key exists), and no *client* tool
@@ -872,9 +884,17 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
872
884
  const executor = declaresWebSearch && !clientNameCollision ? resolveWebSearchExecutor("responses") : undefined;
873
885
  const serverTools = executor !== undefined;
874
886
  const toolRegistry = responsesToolRegistry(body, { serverTools });
875
- const translatedBody = requestsEncryptedReasoning && !preservesOpaqueReasoning
887
+ const includeCompatibleBody = requestsEncryptedReasoning && !preservesOpaqueReasoning
876
888
  ? { ...body, include: body.include?.filter((value) => value !== "reasoning.encrypted_content") }
877
889
  : body;
890
+ const reasoningPolicy = destinationWireShape === "routekit-envelope"
891
+ ? { mode: "relay" }
892
+ : destinationWireShape === "openai-responses"
893
+ ? { mode: "forward", owner: reasoningOwner, unwrap: false }
894
+ : { mode: "drop" };
895
+ const prepared = prepareResponsesReasoningInput(includeCompatibleBody, reasoningPolicy);
896
+ recordDroppedEncryptedReasoning(prepared.dropped);
897
+ const translatedBody = prepared.body;
878
898
  let chat;
879
899
  try {
880
900
  chat = responsesToChat(translatedBody, upstreamModel, { serverTools, destinationWireShape });
package/dist/backend.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * surface can stream straight through and the dialect adapters can consume the
7
7
  * same core without a second abstraction.
8
8
  */
9
- import type { ModelReasoningCapabilities, RequestAttribution } from "@velum-labs/routekit-contracts";
9
+ import type { ModelCapabilityMetadata, ModelReasoningCapabilities, RequestAttribution } from "@velum-labs/routekit-contracts";
10
10
  export type BackendModelRoute = {
11
11
  /** Stable RouteKit catalog id (`provider/model`). */
12
12
  publicId: string;
@@ -14,6 +14,7 @@ export type BackendModelRoute = {
14
14
  nativeId: string;
15
15
  /** Configured provider that owns the model. */
16
16
  provider: string;
17
+ metadata?: ModelCapabilityMetadata;
17
18
  reasoning?: ModelReasoningCapabilities;
18
19
  };
19
20
  export type Backend = {
@@ -51,6 +52,8 @@ export type Backend = {
51
52
  servesModel?(model: string): boolean;
52
53
  /** Capabilities advertised for a model id. */
53
54
  capabilities?(model: string): Readonly<Record<string, string>>;
55
+ /** OpenRouter-compatible architecture and parameter metadata. */
56
+ modelMetadata?(model: string): ModelCapabilityMetadata | undefined;
54
57
  /** Structured reasoning controls advertised for a model id. */
55
58
  reasoningCapabilities?(model: string): ModelReasoningCapabilities | undefined;
56
59
  /**
package/dist/backend.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { REASONING_SELECTION, reasoningSelectionOf, routeKitRequestValidationErrorOf, withoutRouteKitExtensions } from "./adapters/openai-chat-wire.js";
2
- import { normalizeOpenAiResponsesCallIds } from "./adapters/openai-responses-wire.js";
2
+ import { normalizeOpenAiResponsesCallIds, repairLegacyToolSearchItemIds } from "./adapters/openai-responses-wire.js";
3
3
  /** Join a base URL (which may end in `/`) with a route path. */
4
4
  export function joinPath(baseUrl, path) {
5
5
  const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
@@ -105,7 +105,7 @@ export class OpenAiBackend {
105
105
  return fetch(joinPath(this.#baseUrl, "/responses"), {
106
106
  method: "POST",
107
107
  headers: this.#headers(options),
108
- body: JSON.stringify(normalizeOpenAiResponsesCallIds(providerPayload)),
108
+ body: JSON.stringify(normalizeOpenAiResponsesCallIds(repairLegacyToolSearchItemIds(providerPayload))),
109
109
  ...(signal ? { signal } : {})
110
110
  });
111
111
  }
@@ -27,6 +27,34 @@ function activeAnthropicProfile(profile, anthropicModels) {
27
27
  return id !== undefined && anthropicModels.has(id);
28
28
  });
29
29
  }
30
+ function bedrockMetadata(model) {
31
+ const inputModalities = (model.inputModalities ?? []).map((value) => value.toLowerCase());
32
+ const outputModalities = (model.outputModalities ?? []).map((value) => value.toLowerCase());
33
+ const inputs = inputModalities.length > 0 ? inputModalities : ["text"];
34
+ const outputs = outputModalities.length > 0 ? outputModalities : ["text"];
35
+ return {
36
+ architecture: {
37
+ modality: `${inputs.join("+")}->${outputs.join("+")}`,
38
+ inputModalities: inputs,
39
+ outputModalities: outputs
40
+ },
41
+ supportedParameters: ["tools", "tool_choice"],
42
+ provenance: inputModalities.length > 0 || outputModalities.length > 0 ? "provider" : "route"
43
+ };
44
+ }
45
+ function bedrockDiscoveredModel(id, model) {
46
+ return {
47
+ id,
48
+ metadata: bedrockMetadata(model),
49
+ ...(model.responseStreamingSupported !== undefined
50
+ ? {
51
+ capabilities: {
52
+ streaming: model.responseStreamingSupported ? "supported" : "unsupported"
53
+ }
54
+ }
55
+ : {})
56
+ };
57
+ }
30
58
  function textParts(content) {
31
59
  if (typeof content === "string")
32
60
  return content;
@@ -372,19 +400,30 @@ export class BedrockProviderSource {
372
400
  async discoverModels(signal) {
373
401
  const foundation = await this.#control.send(new ListFoundationModelsCommand({ byProvider: "Anthropic" }), signal === undefined ? undefined : { abortSignal: signal });
374
402
  const foundations = (foundation.modelSummaries ?? []).filter(anthropicFoundationModel);
375
- const ids = new Set(foundations.map((model) => model.modelId));
376
- const discovered = new Set(ids);
403
+ const byId = new Map(foundations.map((model) => [model.modelId, model]));
404
+ const ids = new Set(byId.keys());
405
+ const discovered = new Map(foundations.map((model) => [
406
+ model.modelId,
407
+ bedrockDiscoveredModel(model.modelId, model)
408
+ ]));
377
409
  let nextToken;
378
410
  do {
379
411
  const profiles = await this.#control.send(new ListInferenceProfilesCommand({ ...(nextToken !== undefined ? { nextToken } : {}) }), signal === undefined ? undefined : { abortSignal: signal });
380
- for (const profile of profiles.inferenceProfileSummaries ?? [])
381
- if (activeAnthropicProfile(profile, ids))
382
- discovered.add(profile.inferenceProfileId);
412
+ for (const profile of profiles.inferenceProfileSummaries ?? []) {
413
+ if (!activeAnthropicProfile(profile, ids))
414
+ continue;
415
+ const backingId = (profile.models ?? [])
416
+ .map((model) => foundationIdFromArn(model.modelArn))
417
+ .find((id) => id !== undefined && ids.has(id));
418
+ if (backingId !== undefined) {
419
+ discovered.set(profile.inferenceProfileId, bedrockDiscoveredModel(profile.inferenceProfileId, byId.get(backingId)));
420
+ }
421
+ }
383
422
  nextToken = profiles.nextToken;
384
423
  } while (nextToken !== undefined && nextToken.length > 0);
385
424
  if (discovered.size === 0)
386
425
  throw new Error("model discovery returned no active Anthropic Bedrock models");
387
- return [...discovered].map((id) => ({ id }));
426
+ return [...discovered.values()];
388
427
  }
389
428
  async chat(body, signal, _options) {
390
429
  let input;
@@ -0,0 +1,26 @@
1
+ import { type CodexModelCandidate, type CodexStartupSelection, type ModelCapabilityMetadata, type ModelSelectionSignals } from "@velum-labs/routekit-contracts";
2
+ export type OpenRouterModelMetadata = ModelCapabilityMetadata & Pick<ModelSelectionSignals, "createdAt">;
3
+ export type OpenRouterModelMetadataClientOptions = {
4
+ fetch?: typeof fetch;
5
+ now?: () => number;
6
+ freshMs?: number;
7
+ staleMs?: number;
8
+ timeoutMs?: number;
9
+ baseUrl?: string;
10
+ };
11
+ export declare class OpenRouterModelMetadataClient {
12
+ #private;
13
+ constructor(options?: OpenRouterModelMetadataClientOptions);
14
+ models(signal?: AbortSignal): Promise<ReadonlyMap<string, OpenRouterModelMetadata>>;
15
+ }
16
+ export type ResolvedCodexStartupSelection = CodexStartupSelection & {
17
+ models: readonly CodexModelCandidate[];
18
+ };
19
+ export declare function resolveCodexStartupModel(input: {
20
+ models: readonly CodexModelCandidate[];
21
+ preferredModel?: string;
22
+ requestedModel?: string;
23
+ signal?: AbortSignal;
24
+ }, dependencies?: {
25
+ openRouter?: OpenRouterModelMetadataClient;
26
+ }): Promise<ResolvedCodexStartupSelection>;