@velum-labs/routekit-gateway 0.17.4 → 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.
@@ -20,6 +20,12 @@ export type PreparedResponsesReasoningInput = {
20
20
  body: unknown;
21
21
  dropped: number;
22
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;
23
29
  /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
24
30
  export declare function wrapResponsesEncryptedContent(ciphertext: string, owner: ResponsesReasoningOwner): string;
25
31
  /** Parse a RouteKit-owned encrypted-reasoning envelope. Raw provider ciphertext is ownerless. */
@@ -2,9 +2,43 @@ import { createHash } from "node:crypto";
2
2
  const OPENAI_RESPONSES_CALL_ID_MAX_LENGTH = 64;
3
3
  const NORMALIZED_CALL_ID_PREFIX = "rk_";
4
4
  const ENCRYPTED_REASONING_PREFIX = "rk1.";
5
+ const LEGACY_TOOL_SEARCH_ITEM_ID_PREFIX = "ttc_";
6
+ const TOOL_SEARCH_ITEM_ID_PREFIX = "tsc_";
5
7
  function sameOwner(left, right) {
6
8
  return left.provider === right.provider && left.nativeModel === right.nativeModel;
7
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
+ }
8
42
  /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
9
43
  export function wrapResponsesEncryptedContent(ciphertext, owner) {
10
44
  if (parseResponsesEncryptedContent(ciphertext) !== undefined)
@@ -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,7 +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, wrapResponsesReasoningResponse } from "./openai-responses-wire.js";
18
+ import { prepareResponsesReasoningInput, repairLegacyToolSearchItemIds, wrapResponsesReasoningResponse } from "./openai-responses-wire.js";
19
19
  import { unwrapUpstreamError } from "./upstream-error.js";
20
20
  import { openAiSseToResponses } from "./responses-stream.js";
21
21
  import { chatUsageToResponses } from "./responses-usage.js";
@@ -723,7 +723,7 @@ function buildOutput(message, toolRegistry) {
723
723
  if (entry.kind === "typed") {
724
724
  output.push(typedToolCallItem({
725
725
  name,
726
- itemId: `ttc_${randomId()}`,
726
+ itemId: `tsc_${randomId()}`,
727
727
  callId: call.id ?? `call_${randomId()}`,
728
728
  args
729
729
  }));
@@ -864,7 +864,7 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
864
864
  destinationWireShape === "openai-responses" ||
865
865
  destinationWireShape === "routekit-envelope";
866
866
  if (supportsNativeResponses && nativeResponses !== undefined) {
867
- const prepared = prepareResponsesReasoningInput(body, {
867
+ const prepared = prepareResponsesReasoningInput(repairLegacyToolSearchItemIds(body), {
868
868
  mode: "forward",
869
869
  owner: reasoningOwner
870
870
  });
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
  }
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
5
5
  import { cursorModelVariants, isCursorChatBody, resolveCursorModelSelection, translateCursorRequest } from "./adapters/cursor.js";
6
6
  import { droppedField } from "./adapters/dropped.js";
7
7
  import { routeKitRequestValidationErrorOf, withReasoningSelection } from "./adapters/openai-chat-wire.js";
8
- import { prepareResponsesReasoningInput, wrapResponsesReasoningResponse } from "./adapters/openai-responses-wire.js";
8
+ import { prepareResponsesReasoningInput, repairLegacyToolSearchItemIds, wrapResponsesReasoningResponse } from "./adapters/openai-responses-wire.js";
9
9
  import { handleResponses } from "./adapters/responses.js";
10
10
  import { validateAnthropicRequest, validateChatRequest, validateCountTokensRequest, validateResponsesRequest } from "./adapters/validate.js";
11
11
  import { authorizedRequest, parsePrincipalHeader, ROUTEKIT_PRINCIPAL_HEADER } from "./auth.js";
@@ -656,7 +656,7 @@ export async function startGateway(options) {
656
656
  : withModel(routedBody, route.publicId);
657
657
  if (codexProviderRelay !== undefined && route?.provider === "codex") {
658
658
  const owner = { provider: "codex", nativeModel: route.nativeId };
659
- const prepared = prepareResponsesReasoningInput(withModel(body, route.nativeId), {
659
+ const prepared = prepareResponsesReasoningInput(repairLegacyToolSearchItemIds(withModel(body, route.nativeId)), {
660
660
  mode: "forward",
661
661
  owner
662
662
  });
@@ -692,7 +692,7 @@ export async function startGateway(options) {
692
692
  provider: "codex",
693
693
  nativeModel: requestedModel ?? "codex/default"
694
694
  };
695
- const prepared = prepareResponsesReasoningInput(body, {
695
+ const prepared = prepareResponsesReasoningInput(repairLegacyToolSearchItemIds(body), {
696
696
  mode: "forward",
697
697
  owner
698
698
  });
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { createHash } from "node:crypto";
3
3
  import { test } from "node:test";
4
- import { normalizeOpenAiResponsesCallIds, parseResponsesEncryptedContent, prepareResponsesReasoningInput, wrapResponsesEncryptedContent, wrapResponsesReasoningResponse } from "../adapters/openai-responses-wire.js";
4
+ import { normalizeOpenAiResponsesCallIds, parseResponsesEncryptedContent, prepareResponsesReasoningInput, repairLegacyToolSearchItemIds, wrapResponsesEncryptedContent, wrapResponsesReasoningResponse } from "../adapters/openai-responses-wire.js";
5
5
  const OWNER_A = {
6
6
  provider: "codex",
7
7
  nativeModel: "gpt-5.6"
@@ -91,6 +91,49 @@ test("Responses call id replacement assignment is independent of occurrence orde
91
91
  assert.equal(forward.get(firstLongId), reversed.get(firstLongId));
92
92
  assert.equal(forward.get(secondLongId), reversed.get(secondLongId));
93
93
  });
94
+ test("legacy tool-search item ids are repaired narrowly without mutating input", () => {
95
+ const legacy = {
96
+ type: "tool_search_call",
97
+ id: "ttc_legacy",
98
+ call_id: "call_search",
99
+ status: "completed"
100
+ };
101
+ const valid = {
102
+ type: "tool_search_call",
103
+ id: "tsc_valid",
104
+ call_id: "call_valid"
105
+ };
106
+ const unrelated = {
107
+ type: "function_call",
108
+ id: "ttc_function",
109
+ call_id: "call_function"
110
+ };
111
+ const output = {
112
+ type: "tool_search_output",
113
+ call_id: "call_search",
114
+ tools: []
115
+ };
116
+ const body = {
117
+ model: "gpt-5.6-sol",
118
+ input: [legacy, valid, unrelated, output]
119
+ };
120
+ const repaired = repairLegacyToolSearchItemIds(body);
121
+ assert.notEqual(repaired, body);
122
+ assert.notEqual(repaired.input, body.input);
123
+ assert.deepEqual(repaired.input[0], {
124
+ ...legacy,
125
+ id: "tsc_legacy"
126
+ });
127
+ assert.equal(repaired.input[0]?.call_id, "call_search");
128
+ assert.equal(repaired.input[1], valid);
129
+ assert.equal(repaired.input[2], unrelated);
130
+ assert.equal(repaired.input[3], output);
131
+ assert.equal(legacy.id, "ttc_legacy");
132
+ const alreadyCompatible = { input: [valid, output] };
133
+ assert.equal(repairLegacyToolSearchItemIds(alreadyCompatible), alreadyCompatible);
134
+ const stringInput = { input: "hello" };
135
+ assert.equal(repairLegacyToolSearchItemIds(stringInput), stringInput);
136
+ });
94
137
  test("encrypted reasoning ownership round-trips exact opaque content", () => {
95
138
  const ciphertext = "opaque.with.separators/and unicode \u{1f9e0}\nbytes";
96
139
  const wrapped = wrapResponsesEncryptedContent(ciphertext, OWNER_A);
@@ -1586,6 +1586,8 @@ test("chatToResponses emits a native typed item for a call resolved as typed", (
1586
1586
  const output = response.output;
1587
1587
  assert.equal(output.length, 1);
1588
1588
  assert.equal(output[0]?.type, "tool_search_call");
1589
+ assert.match(String(output[0]?.id), /^tsc_/);
1590
+ assert.ok(!String(output[0]?.id).startsWith("ttc_"));
1589
1591
  assert.equal(output[0]?.call_id, "call_ts");
1590
1592
  assert.equal(output[0]?.execution, "client");
1591
1593
  assert.equal(output[0]?.status, "completed");
@@ -1598,16 +1600,143 @@ test("openAiSseToResponses streams a typed tool call as its native item", async
1598
1600
  const upstream = sseStream(chatChunk({ tool_calls: [{ index: 0, id: "call_ts", function: { name: "tool_search", arguments: args.slice(0, 10) } }] }), chatChunk({ tool_calls: [{ index: 0, function: { arguments: args.slice(10) } }] }), chatChunk({}, "tool_calls"), "data: [DONE]\n\n");
1599
1601
  const text = await new Response(openAiSseToResponses(upstream, "route-primary", registry)).text();
1600
1602
  assert.ok(text.includes('"type":"tool_search_call"'));
1603
+ assert.ok(!text.includes('"id":"ttc_'));
1601
1604
  assert.ok(!text.includes('"type":"function_call"'), "typed calls never surface as function_call items");
1602
1605
  assert.ok(!text.includes("response.function_call_arguments"), "typed calls emit no argument delta events");
1603
- const completed = text.split("\n\n").find((event) => event.startsWith("event: response.completed"));
1604
- assert.ok(completed !== undefined);
1605
- const payload = JSON.parse(completed.slice(completed.indexOf("data:") + 5));
1606
+ const events = text
1607
+ .split("\n\n")
1608
+ .filter((event) => event.includes("\ndata: "))
1609
+ .map((event) => JSON.parse(event.slice(event.indexOf("data:") + 5)));
1610
+ const added = events.find((event) => event.type === "response.output_item.added");
1611
+ const done = events.find((event) => event.type === "response.output_item.done");
1612
+ const completed = events.find((event) => event.type === "response.completed");
1613
+ assert.equal(added?.item?.type, "tool_search_call");
1614
+ assert.equal(done?.item?.type, "tool_search_call");
1615
+ assert.match(added?.item?.id ?? "", /^tsc_/);
1616
+ assert.equal(done?.item?.id, added?.item?.id);
1617
+ const payload = completed;
1606
1618
  const item = payload.response.output.find((entry) => entry.type === "tool_search_call");
1619
+ assert.equal(item?.id, added?.item?.id);
1607
1620
  assert.equal(item?.call_id, "call_ts");
1608
1621
  assert.equal(item?.execution, "client");
1609
1622
  assert.deepEqual(item?.arguments, { query: "spawn sub-agent", limit: 8 });
1610
1623
  });
1624
+ test("translated tool_search history keeps a valid item id when switching to native Codex", async () => {
1625
+ const source = (sourceId) => ({
1626
+ sourceId,
1627
+ discoverModels: async () => [{
1628
+ id: sourceId === "codex" ? "gpt-5.6-sol" : "claude-sonnet-4-6",
1629
+ metadata: {
1630
+ architecture: {
1631
+ inputModalities: ["text"],
1632
+ outputModalities: ["text"]
1633
+ },
1634
+ supportedParameters: ["tools", "tool_choice"],
1635
+ provenance: "route"
1636
+ }
1637
+ }],
1638
+ chat: async () => Response.json({
1639
+ id: "chatcmpl_tool_search",
1640
+ choices: [
1641
+ {
1642
+ index: 0,
1643
+ message: {
1644
+ role: "assistant",
1645
+ content: null,
1646
+ tool_calls: [
1647
+ {
1648
+ id: "call_ts",
1649
+ function: {
1650
+ name: "tool_search",
1651
+ arguments: '{"query":"spawn sub-agent","limit":8}'
1652
+ }
1653
+ }
1654
+ ]
1655
+ },
1656
+ finish_reason: "tool_calls"
1657
+ }
1658
+ ],
1659
+ usage: { prompt_tokens: 1, completion_tokens: 1 }
1660
+ }),
1661
+ embeddings: async () => Response.json({})
1662
+ });
1663
+ const backend = await CatalogBackend.create({
1664
+ config: {
1665
+ providers: { codex: {}, "claude-code": {} },
1666
+ defaultModel: "claude-code/claude-sonnet-4-6"
1667
+ },
1668
+ sources: {
1669
+ codex: source("codex"),
1670
+ "claude-code": source("claude-code")
1671
+ }
1672
+ });
1673
+ let relayedBody;
1674
+ const relay = {
1675
+ dialect: "codex",
1676
+ shouldRelay: () => false,
1677
+ relay: async (_headers, body) => {
1678
+ relayedBody = body;
1679
+ return Response.json({
1680
+ id: "resp_native_after_switch",
1681
+ object: "response",
1682
+ status: "completed",
1683
+ model: body.model,
1684
+ output: [],
1685
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
1686
+ });
1687
+ }
1688
+ };
1689
+ const gateway = await startGateway({
1690
+ backend,
1691
+ providerRelays: { codex: relay }
1692
+ });
1693
+ try {
1694
+ const translated = await fetch(`${gateway.url()}/v1/responses`, {
1695
+ method: "POST",
1696
+ headers: { "content-type": "application/json" },
1697
+ body: JSON.stringify({
1698
+ model: "claude-code/claude-sonnet-4-6",
1699
+ input: "find a sub-agent tool",
1700
+ tools: [TOOL_SEARCH_DECL]
1701
+ })
1702
+ });
1703
+ assert.equal(translated.status, 200);
1704
+ const translatedPayload = await translated.json();
1705
+ const toolSearchCall = translatedPayload.output.find((item) => item.type === "tool_search_call");
1706
+ assert.ok(toolSearchCall !== undefined);
1707
+ assert.match(String(toolSearchCall.id), /^tsc_/);
1708
+ assert.equal(toolSearchCall.call_id, "call_ts");
1709
+ const legacyToolSearchCall = {
1710
+ ...toolSearchCall,
1711
+ id: String(toolSearchCall.id).replace(/^tsc_/, "ttc_")
1712
+ };
1713
+ assert.match(String(legacyToolSearchCall.id), /^ttc_/);
1714
+ const switched = await fetch(`${gateway.url()}/v1/responses`, {
1715
+ method: "POST",
1716
+ headers: { "content-type": "application/json" },
1717
+ body: JSON.stringify({
1718
+ model: "gpt-5.6-sol",
1719
+ input: [
1720
+ legacyToolSearchCall,
1721
+ {
1722
+ type: "tool_search_output",
1723
+ call_id: "call_ts",
1724
+ status: "completed",
1725
+ execution: "client",
1726
+ tools: []
1727
+ }
1728
+ ]
1729
+ })
1730
+ });
1731
+ assert.equal(switched.status, 200);
1732
+ const relayedInput = relayedBody?.input;
1733
+ assert.equal(relayedInput?.[0]?.id, toolSearchCall.id);
1734
+ assert.equal(relayedInput?.[0]?.call_id, "call_ts");
1735
+ }
1736
+ finally {
1737
+ await gateway.close();
1738
+ }
1739
+ });
1611
1740
  test("openAiSseToResponses keeps function tools on the incremental function_call path", async () => {
1612
1741
  const upstream = sseStream(chatChunk({ tool_calls: [{ index: 0, id: "call_s", function: { name: "shell", arguments: '{"cmd":"ls"}' } }] }), chatChunk({}, "tool_calls"), "data: [DONE]\n\n");
1613
1742
  const text = await new Response(openAiSseToResponses(upstream, "route-primary", new Map([["apply_patch", { kind: "custom" }]]))).text();
@@ -2012,6 +2141,21 @@ test("Codex client relay still receives unknown native models after alias resolu
2012
2141
  { type: "reasoning", encrypted_content: matching },
2013
2142
  { type: "reasoning", encrypted_content: foreign },
2014
2143
  { type: "reasoning", encrypted_content: "legacy-raw-request" },
2144
+ {
2145
+ type: "tool_search_call",
2146
+ id: "ttc_persisted",
2147
+ call_id: "call_search",
2148
+ status: "completed",
2149
+ execution: "client",
2150
+ arguments: { query: "verification" }
2151
+ },
2152
+ {
2153
+ type: "tool_search_output",
2154
+ call_id: "call_search",
2155
+ status: "completed",
2156
+ execution: "client",
2157
+ tools: []
2158
+ },
2015
2159
  { type: "message", role: "user", content: "hi" }
2016
2160
  ]
2017
2161
  })
@@ -2026,6 +2170,21 @@ test("Codex client relay still receives unknown native models after alias resolu
2026
2170
  assert.equal(relayCalls[0]?.model, "upstream-only");
2027
2171
  assert.deepEqual(relayCalls[0]?.input, [
2028
2172
  { type: "reasoning", encrypted_content: "raw-client-relay-request" },
2173
+ {
2174
+ type: "tool_search_call",
2175
+ id: "tsc_persisted",
2176
+ call_id: "call_search",
2177
+ status: "completed",
2178
+ execution: "client",
2179
+ arguments: { query: "verification" }
2180
+ },
2181
+ {
2182
+ type: "tool_search_output",
2183
+ call_id: "call_search",
2184
+ status: "completed",
2185
+ execution: "client",
2186
+ tools: []
2187
+ },
2029
2188
  { type: "message", role: "user", content: "hi" }
2030
2189
  ]);
2031
2190
  assert.ok(!JSON.stringify(relayCalls[0]).includes("rk1."));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.17.4",
4
+ "version": "0.18.0",
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.17.4",
33
- "@velum-labs/routekit-registry": "0.17.4",
34
- "@velum-labs/routekit-runtime": "0.17.4",
35
- "@velum-labs/routekit-tracing": "0.17.4"
32
+ "@velum-labs/routekit-contracts": "0.18.0",
33
+ "@velum-labs/routekit-registry": "0.18.0",
34
+ "@velum-labs/routekit-runtime": "0.18.0",
35
+ "@velum-labs/routekit-tracing": "0.18.0"
36
36
  },
37
37
  "keywords": [
38
38
  "routekit",