@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.
- package/dist/adapters/openai-responses-wire.d.ts +2 -0
- package/dist/adapters/openai-responses-wire.js +55 -0
- package/dist/backend.js +3 -1
- package/dist/provider-backends.js +3 -2
- package/dist/test/openai-responses-wire.test.d.ts +1 -0
- package/dist/test/openai-responses-wire.test.js +85 -0
- package/dist/test/provider-backends.test.js +55 -0
- package/package.json +5 -5
|
@@ -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(
|
|
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.
|
|
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.
|
|
33
|
-
"@velum-labs/routekit-registry": "0.16.
|
|
34
|
-
"@velum-labs/routekit-runtime": "0.16.
|
|
35
|
-
"@velum-labs/routekit-tracing": "0.16.
|
|
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",
|