@velum-labs/routekit-gateway 0.16.3 → 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;
@@ -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;
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.3",
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.3",
33
- "@velum-labs/routekit-registry": "0.16.3",
34
- "@velum-labs/routekit-runtime": "0.16.3",
35
- "@velum-labs/routekit-tracing": "0.16.3"
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",