@velum-labs/routekit-gateway 0.16.2 → 0.16.4

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.
@@ -0,0 +1,2 @@
1
+ /** Normalize OpenAI Responses call ids without mutating the caller's request. */
2
+ export declare function normalizeOpenAiResponsesCallIds(body: unknown): unknown;
@@ -0,0 +1,55 @@
1
+ import { createHash } from "node:crypto";
2
+ const OPENAI_RESPONSES_CALL_ID_MAX_LENGTH = 64;
3
+ const NORMALIZED_CALL_ID_PREFIX = "rk_";
4
+ function normalizedCallId(callId, attempt = 0) {
5
+ const hash = createHash("sha256").update(callId, "utf8");
6
+ if (attempt > 0)
7
+ hash.update(`\0${attempt}`, "utf8");
8
+ return `${NORMALIZED_CALL_ID_PREFIX}${hash.digest("base64url")}`;
9
+ }
10
+ /** Normalize OpenAI Responses call ids without mutating the caller's request. */
11
+ export function normalizeOpenAiResponsesCallIds(body) {
12
+ if (typeof body !== "object" || body === null || Array.isArray(body))
13
+ return body;
14
+ const record = body;
15
+ if (!Array.isArray(record.input))
16
+ return body;
17
+ const reserved = new Set();
18
+ const longCallIds = new Set();
19
+ for (const item of record.input) {
20
+ if (typeof item !== "object" || item === null || Array.isArray(item))
21
+ continue;
22
+ const callId = item.call_id;
23
+ if (typeof callId !== "string")
24
+ continue;
25
+ if (callId.length <= OPENAI_RESPONSES_CALL_ID_MAX_LENGTH)
26
+ reserved.add(callId);
27
+ else
28
+ longCallIds.add(callId);
29
+ }
30
+ if (longCallIds.size === 0)
31
+ return body;
32
+ const replacements = new Map();
33
+ for (const callId of [...longCallIds].sort()) {
34
+ let attempt = 0;
35
+ let replacement = normalizedCallId(callId);
36
+ while (reserved.has(replacement)) {
37
+ attempt += 1;
38
+ replacement = normalizedCallId(callId, attempt);
39
+ }
40
+ replacements.set(callId, replacement);
41
+ reserved.add(replacement);
42
+ }
43
+ const input = record.input.map((item) => {
44
+ if (typeof item !== "object" || item === null || Array.isArray(item))
45
+ return item;
46
+ const callId = item.call_id;
47
+ if (typeof callId !== "string")
48
+ return item;
49
+ const replacement = replacements.get(callId);
50
+ return replacement === undefined
51
+ ? item
52
+ : { ...item, call_id: replacement };
53
+ });
54
+ return { ...record, input };
55
+ }
package/dist/backend.js CHANGED
@@ -1,4 +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
3
  /** Join a base URL (which may end in `/`) with a route path. */
3
4
  export function joinPath(baseUrl, path) {
4
5
  const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
@@ -86,10 +87,11 @@ export class OpenAiBackend {
86
87
  !Array.isArray(body)
87
88
  ? { ...body, model: this.#forceModel }
88
89
  : body;
90
+ const providerPayload = withoutRouteKitExtensions(routed);
89
91
  return fetch(joinPath(this.#baseUrl, "/responses"), {
90
92
  method: "POST",
91
93
  headers: this.#headers(options),
92
- body: JSON.stringify(withoutRouteKitExtensions(routed)),
94
+ body: JSON.stringify(normalizeOpenAiResponsesCallIds(providerPayload)),
93
95
  ...(signal ? { signal } : {})
94
96
  });
95
97
  }
@@ -1,6 +1,7 @@
1
1
  import { randomId } from "@velum-labs/routekit-runtime";
2
2
  import { joinPath } from "./backend.js";
3
3
  import { droppedField } from "./adapters/dropped.js";
4
+ import { normalizeOpenAiResponsesCallIds } from "./adapters/openai-responses-wire.js";
4
5
  import { SseDecoder, SseParseError } from "./sse/parse.js";
5
6
  import { anthropicMessageContentOf, anthropicReasoningDetailsOf, anthropicRequestMetadataOf, attachGoogleToolCallIndexes, googleThoughtDetailsOf, googleToolCallIndexesOf, reasoningSelectionOf, routeKitRequestValidationErrorOf, attachResponsesReasoningMetadata, responsesReasoningMetadataOf } from "./adapters/openai-chat-wire.js";
6
7
  function invalidReasoningControlResponse(message, metadata = false, path) {
@@ -1027,7 +1028,7 @@ function responsesRequest(body, model, options) {
1027
1028
  });
1028
1029
  const includeEncryptedContent = responsesReasoningMetadataOf(body)?.includeEncryptedContent === true ||
1029
1030
  (body.messages ?? []).some((message) => responsesReasoningMetadataOf(message)?.includeEncryptedContent === true);
1030
- return {
1031
+ return normalizeOpenAiResponsesCallIds({
1031
1032
  model,
1032
1033
  input,
1033
1034
  stream: options.forceStream || body.stream === true,
@@ -1057,7 +1058,7 @@ function responsesRequest(body, model, options) {
1057
1058
  ])
1058
1059
  }
1059
1060
  : {})
1060
- };
1061
+ });
1061
1062
  }
1062
1063
  function responsesOutput(payload) {
1063
1064
  const output = payload.output;
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { ProviderFailureError } from "@velum-labs/routekit-contracts";
2
+ import { isCodexPickerEligibleModel, ProviderFailureError } from "@velum-labs/routekit-contracts";
3
3
  import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens, resolveClaudeModelAlias } from "./adapters/anthropic.js";
4
4
  import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
5
5
  import { authorizedRequest, parsePrincipalHeader, ROUTEKIT_PRINCIPAL_HEADER } from "./auth.js";
@@ -123,7 +123,15 @@ function withoutStaleCodexIdentity(body, route) {
123
123
  function codexPickerModels(backend, configured, native, includeUnroutedNative) {
124
124
  const nativeBySlug = new Map(native.flatMap((entry) => typeof entry.slug === "string" ? [[entry.slug, entry]] : []));
125
125
  const seen = new Set();
126
- const models = configured.map((entry, priority) => {
126
+ const eligible = configured.filter((entry) => {
127
+ const route = backend.resolveModelRoute?.(entry.id);
128
+ return (entry.id === backend.defaultModel ||
129
+ isCodexPickerEligibleModel({
130
+ provider: route?.provider,
131
+ reasoning: route?.reasoning
132
+ }));
133
+ });
134
+ const models = eligible.map((entry, priority) => {
127
135
  const route = backend.resolveModelRoute?.(entry.id);
128
136
  const slug = route?.provider === "codex" ? route.nativeId : entry.id;
129
137
  seen.add(slug);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { normalizeOpenAiResponsesCallIds } from "../adapters/openai-responses-wire.js";
5
+ test("Responses call ids are normalized deterministically without mutating input", () => {
6
+ const longCallId = `call_${"a".repeat(80)}`;
7
+ const shortCallId = "s".repeat(64);
8
+ const overlongBoundaryId = "l".repeat(65);
9
+ const call = { type: "custom_call", call_id: longCallId, name: "shell" };
10
+ const output = { type: "typed_call_output", call_id: longCallId, output: "done" };
11
+ const body = {
12
+ input: [
13
+ call,
14
+ output,
15
+ { type: "function_call", call_id: shortCallId },
16
+ { type: "function_call", call_id: overlongBoundaryId }
17
+ ]
18
+ };
19
+ const normalized = normalizeOpenAiResponsesCallIds(body);
20
+ const normalizedCallId = normalized.input[0]?.call_id;
21
+ assert.match(normalizedCallId ?? "", /^rk_[A-Za-z0-9_-]+$/);
22
+ assert.ok((normalizedCallId?.length ?? Infinity) <= 64);
23
+ assert.equal(normalized.input[1]?.call_id, normalizedCallId);
24
+ assert.equal(normalized.input[2]?.call_id, shortCallId);
25
+ assert.equal(normalized.input[2]?.call_id.length, 64);
26
+ assert.notEqual(normalized.input[3]?.call_id, overlongBoundaryId);
27
+ assert.ok((normalized.input[3]?.call_id.length ?? Infinity) <= 64);
28
+ assert.equal(body.input[0], call);
29
+ assert.equal(body.input[1], output);
30
+ assert.equal(call.call_id, longCallId);
31
+ assert.equal(output.call_id, longCallId);
32
+ });
33
+ test("Responses call id normalization hashes the complete id and leaves malformed input untouched", () => {
34
+ const prefix = "x".repeat(64);
35
+ const malformed = [null, "text", 7, ["nested"], { call_id: 42 }];
36
+ const body = {
37
+ input: [
38
+ { type: "function_call", call_id: `${prefix}a` },
39
+ { type: "function_call", call_id: `${prefix}b` },
40
+ ...malformed
41
+ ]
42
+ };
43
+ const normalized = normalizeOpenAiResponsesCallIds(body);
44
+ const first = normalized.input[0];
45
+ const second = normalized.input[1];
46
+ assert.notEqual(first.call_id, second.call_id);
47
+ assert.deepEqual(normalized.input.slice(2), malformed);
48
+ });
49
+ test("Responses call id normalization disambiguates a reserved generated ID", () => {
50
+ const longCallId = `call_${"collision".repeat(12)}`;
51
+ const preexistingCandidate = `rk_${createHash("sha256")
52
+ .update(longCallId, "utf8")
53
+ .digest("base64url")}`;
54
+ const body = {
55
+ input: [
56
+ { type: "function_call_output", call_id: longCallId, output: "done" },
57
+ { type: "function_call", call_id: preexistingCandidate, name: "reserved" },
58
+ { type: "function_call", call_id: longCallId, name: "actual" }
59
+ ]
60
+ };
61
+ const normalized = normalizeOpenAiResponsesCallIds(body);
62
+ const longOutputId = normalized.input[0]?.call_id;
63
+ const reservedId = normalized.input[1]?.call_id;
64
+ const longCallIdReplacement = normalized.input[2]?.call_id;
65
+ assert.equal(reservedId, preexistingCandidate);
66
+ assert.notEqual(longOutputId, reservedId);
67
+ assert.equal(longOutputId, longCallIdReplacement);
68
+ assert.ok((longOutputId?.length ?? Infinity) <= 64);
69
+ assert.equal(body.input[0]?.call_id, longCallId);
70
+ assert.equal(body.input[2]?.call_id, longCallId);
71
+ });
72
+ test("Responses call id replacement assignment is independent of occurrence order", () => {
73
+ const firstLongId = `call_${"a".repeat(80)}`;
74
+ const secondLongId = `call_${"b".repeat(80)}`;
75
+ const normalize = (ids) => {
76
+ const normalized = normalizeOpenAiResponsesCallIds({
77
+ input: ids.map((call_id) => ({ call_id }))
78
+ });
79
+ return new Map(ids.map((id, index) => [id, normalized.input[index]?.call_id]));
80
+ };
81
+ const forward = normalize([firstLongId, secondLongId]);
82
+ const reversed = normalize([secondLongId, firstLongId]);
83
+ assert.equal(forward.get(firstLongId), reversed.get(firstLongId));
84
+ assert.equal(forward.get(secondLongId), reversed.get(secondLongId));
85
+ });
@@ -837,6 +837,61 @@ test("Google GenAI ignores malformed and unknown canonical thought metadata", as
837
837
  globalThis.fetch = original;
838
838
  }
839
839
  });
840
+ test("OpenAI native Responses egress normalizes long paired call ids immutably", async () => {
841
+ const originalFetch = globalThis.fetch;
842
+ let outbound;
843
+ globalThis.fetch = async (_input, init) => {
844
+ outbound = JSON.parse(String(init?.body));
845
+ return Response.json({ id: "resp_1", output: [] });
846
+ };
847
+ try {
848
+ const longCallId = `call_${"native".repeat(20)}`;
849
+ const call = { type: "function_call", call_id: longCallId, name: "read", arguments: "{}" };
850
+ const output = { type: "function_call_output", call_id: longCallId, output: "source" };
851
+ const body = { model: "m", input: [call, output], x_routekit: { version: 1 } };
852
+ const backend = new OpenAiBackend({ baseUrl: "https://openai.test/v1" });
853
+ await backend.responses(body);
854
+ const items = outbound?.input;
855
+ assert.equal(items[0]?.call_id, items[1]?.call_id);
856
+ assert.ok((items[0]?.call_id.length ?? Infinity) <= 64);
857
+ assert.match(items[0]?.call_id ?? "", /^rk_/);
858
+ assert.equal(outbound?.x_routekit, undefined);
859
+ assert.equal(call.call_id, longCallId);
860
+ assert.equal(output.call_id, longCallId);
861
+ }
862
+ finally {
863
+ globalThis.fetch = originalFetch;
864
+ }
865
+ });
866
+ test("Codex Responses egress normalizes long tool call and output ids together", async () => {
867
+ let outbound;
868
+ const longCallId = `call_${"codex".repeat(20)}`;
869
+ const backend = new CodexResponsesBackend({
870
+ baseUrl: "https://chatgpt.test/backend-api/codex",
871
+ apiKey: "oauth",
872
+ defaultModel: "codex-test",
873
+ transport: async (_url, init) => {
874
+ outbound = JSON.parse(String(init.body));
875
+ return Response.json({
876
+ output: [{ type: "message", content: [{ type: "output_text", text: "done" }] }]
877
+ });
878
+ }
879
+ });
880
+ await backend.chat({
881
+ messages: [
882
+ {
883
+ role: "assistant",
884
+ content: "",
885
+ tool_calls: [{ id: longCallId, function: { name: "read", arguments: "{}" } }]
886
+ },
887
+ { role: "tool", tool_call_id: longCallId, content: "source" }
888
+ ]
889
+ });
890
+ const items = outbound?.input;
891
+ assert.equal(items[0]?.call_id, items[1]?.call_id);
892
+ assert.ok((items[0]?.call_id.length ?? Infinity) <= 64);
893
+ assert.match(items[0]?.call_id ?? "", /^rk_/);
894
+ });
840
895
  test("Codex Responses egress replays encrypted reasoning and include around tool continuation", async () => {
841
896
  const original = globalThis.fetch;
842
897
  let request;
@@ -1449,7 +1449,7 @@ test("translates a streamed Responses event sequence", async () => {
1449
1449
  await mock.close();
1450
1450
  }
1451
1451
  });
1452
- test("Codex catalog advertises reasoning summaries only for text-capable wire shapes", async () => {
1452
+ test("Codex catalog filters chat-only OpenRouter models and preserves reasoning summaries", async () => {
1453
1453
  const source = (sourceId, wireShape) => ({
1454
1454
  sourceId,
1455
1455
  discoverModels: async () => [{
@@ -1460,15 +1460,30 @@ test("Codex catalog advertises reasoning summaries only for text-capable wire sh
1460
1460
  wireShape,
1461
1461
  provenance: "provider"
1462
1462
  }
1463
- }],
1463
+ }, ...(sourceId === "openai" ? [{ id: "unknown-model" }] : [])],
1464
1464
  chat: async () => Response.json({}),
1465
1465
  embeddings: async () => Response.json({})
1466
1466
  });
1467
+ const chatOnly = {
1468
+ sourceId: "openrouter",
1469
+ discoverModels: async () => [{ id: "chat-only" }],
1470
+ chat: async () => Response.json({}),
1471
+ embeddings: async () => Response.json({})
1472
+ };
1467
1473
  const backend = await CatalogBackend.create({
1468
- config: { providers: { openai: {}, openrouter: {} } },
1474
+ config: {
1475
+ providers: { openai: {}, openrouter: {} },
1476
+ defaultModel: "openai/gpt-5.5"
1477
+ },
1469
1478
  sources: {
1470
1479
  openai: source("openai", "openai-chat"),
1471
- openrouter: source("openrouter", "openrouter")
1480
+ openrouter: {
1481
+ ...source("openrouter", "openrouter"),
1482
+ discoverModels: async () => [
1483
+ ...(await chatOnly.discoverModels()),
1484
+ ...(await source("openrouter", "openrouter").discoverModels())
1485
+ ]
1486
+ }
1472
1487
  }
1473
1488
  });
1474
1489
  const gateway = await startGateway({ backend });
@@ -1476,8 +1491,15 @@ test("Codex catalog advertises reasoning summaries only for text-capable wire sh
1476
1491
  const response = await fetch(`${gateway.url()}/v1/models`);
1477
1492
  assert.equal(response.status, 200);
1478
1493
  const catalog = (await response.json());
1494
+ assert.deepEqual(catalog.data.map((model) => model.id), [
1495
+ "openai/gpt-5.5",
1496
+ "openai/unknown-model",
1497
+ "openrouter/chat-only",
1498
+ "openrouter/reasoning-model"
1499
+ ]);
1479
1500
  assert.deepEqual(catalog.models.map((model) => [model.slug, model.supports_reasoning_summaries]), [
1480
1501
  ["openai/gpt-5.5", false],
1502
+ ["openai/unknown-model", false],
1481
1503
  ["openrouter/reasoning-model", true]
1482
1504
  ]);
1483
1505
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.16.2",
4
+ "version": "0.16.4",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -29,10 +29,10 @@
29
29
  "@aws-sdk/client-bedrock": "3.1095.0",
30
30
  "@aws-sdk/client-bedrock-runtime": "3.1095.0",
31
31
  "zod": "4.4.3",
32
- "@velum-labs/routekit-contracts": "0.16.2",
33
- "@velum-labs/routekit-registry": "0.16.2",
34
- "@velum-labs/routekit-runtime": "0.16.2",
35
- "@velum-labs/routekit-tracing": "0.16.2"
32
+ "@velum-labs/routekit-contracts": "0.16.4",
33
+ "@velum-labs/routekit-registry": "0.16.4",
34
+ "@velum-labs/routekit-runtime": "0.16.4",
35
+ "@velum-labs/routekit-tracing": "0.16.4"
36
36
  },
37
37
  "keywords": [
38
38
  "routekit",