@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.
- 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/server.js +10 -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/dist/test/responses.test.js +26 -4
- 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;
|
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
|
|
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
|
|
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: {
|
|
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:
|
|
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.
|
|
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",
|