@velum-labs/routekit-gateway 0.18.4 → 0.18.6

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.
@@ -1,22 +1,22 @@
1
- export type ResponsesReasoningOwner = {
1
+ export type ResponsesEncryptedContentOwner = {
2
2
  provider: string;
3
3
  nativeModel: string;
4
4
  };
5
- export type ResponsesReasoningInputPolicy = {
5
+ export type ResponsesEncryptedInputPolicy = {
6
6
  mode: "drop";
7
7
  } | {
8
8
  mode: "relay";
9
9
  } | {
10
10
  mode: "forward";
11
- owner: ResponsesReasoningOwner;
11
+ owner: ResponsesEncryptedContentOwner;
12
12
  /** Keep the RouteKit wrapper for a later Chat-to-Responses provider bridge. */
13
13
  unwrap?: boolean;
14
14
  };
15
15
  export type ParsedResponsesEncryptedContent = {
16
- owner: ResponsesReasoningOwner;
16
+ owner: ResponsesEncryptedContentOwner;
17
17
  ciphertext: string;
18
18
  };
19
- export type PreparedResponsesReasoningInput = {
19
+ export type PreparedResponsesEncryptedInput = {
20
20
  body: unknown;
21
21
  dropped: number;
22
22
  };
@@ -26,20 +26,20 @@ export type PreparedResponsesReasoningInput = {
26
26
  * `call_id`, so changing only the item `id` preserves the execution pair.
27
27
  */
28
28
  export declare function repairLegacyToolSearchItemIds(body: unknown): unknown;
29
- /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
30
- export declare function wrapResponsesEncryptedContent(ciphertext: string, owner: ResponsesReasoningOwner): string;
31
- /** Parse a RouteKit-owned encrypted-reasoning envelope. Raw provider ciphertext is ownerless. */
29
+ /** Wrap provider-owned opaque Responses content without expanding the ciphertext itself. */
30
+ export declare function wrapResponsesEncryptedContent(ciphertext: string, owner: ResponsesEncryptedContentOwner): string;
31
+ /** Parse a RouteKit-owned encrypted-content envelope. Raw provider ciphertext is ownerless. */
32
32
  export declare function parseResponsesEncryptedContent(value: unknown): ParsedResponsesEncryptedContent | undefined;
33
33
  /**
34
- * Apply the destination policy to Responses input without mutating the request.
35
- * Incompatible reasoning items are removed while their assistant/tool carriers
36
- * remain in the portable transcript.
34
+ * Apply the destination policy to provider-owned Responses input without
35
+ * mutating the request. Incompatible opaque state is removed while visible
36
+ * messages and tool-call/output carriers remain in the portable transcript.
37
37
  */
38
- export declare function prepareResponsesReasoningInput(body: unknown, policy: ResponsesReasoningInputPolicy): PreparedResponsesReasoningInput;
38
+ export declare function prepareResponsesEncryptedInput(body: unknown, policy: ResponsesEncryptedInputPolicy): PreparedResponsesEncryptedInput;
39
39
  /**
40
- * Add ownership to encrypted reasoning emitted by a successful native
40
+ * Add ownership to encrypted Responses state emitted by a successful native
41
41
  * Responses call. Provider failures and unrelated response formats pass through.
42
42
  */
43
- export declare function wrapResponsesReasoningResponse(response: Response, owner: ResponsesReasoningOwner): Promise<Response>;
43
+ export declare function wrapResponsesEncryptedResponse(response: Response, owner: ResponsesEncryptedContentOwner): Promise<Response>;
44
44
  /** Normalize OpenAI Responses call ids without mutating the caller's request. */
45
45
  export declare function normalizeOpenAiResponsesCallIds(body: unknown): unknown;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  const OPENAI_RESPONSES_CALL_ID_MAX_LENGTH = 64;
3
3
  const NORMALIZED_CALL_ID_PREFIX = "rk_";
4
- const ENCRYPTED_REASONING_PREFIX = "rk1.";
4
+ const ENCRYPTED_CONTENT_PREFIX = "rk1.";
5
5
  const LEGACY_TOOL_SEARCH_ITEM_ID_PREFIX = "ttc_";
6
6
  const TOOL_SEARCH_ITEM_ID_PREFIX = "tsc_";
7
7
  function sameOwner(left, right) {
@@ -39,22 +39,22 @@ export function repairLegacyToolSearchItemIds(body) {
39
39
  });
40
40
  return changed ? { ...record, input } : body;
41
41
  }
42
- /** Wrap provider-owned opaque reasoning without expanding the ciphertext itself. */
42
+ /** Wrap provider-owned opaque Responses content without expanding the ciphertext itself. */
43
43
  export function wrapResponsesEncryptedContent(ciphertext, owner) {
44
44
  if (parseResponsesEncryptedContent(ciphertext) !== undefined)
45
45
  return ciphertext;
46
46
  const encodedOwner = Buffer.from(JSON.stringify({ p: owner.provider, m: owner.nativeModel }), "utf8").toString("base64url");
47
- return `${ENCRYPTED_REASONING_PREFIX}${encodedOwner}.${ciphertext}`;
47
+ return `${ENCRYPTED_CONTENT_PREFIX}${encodedOwner}.${ciphertext}`;
48
48
  }
49
- /** Parse a RouteKit-owned encrypted-reasoning envelope. Raw provider ciphertext is ownerless. */
49
+ /** Parse a RouteKit-owned encrypted-content envelope. Raw provider ciphertext is ownerless. */
50
50
  export function parseResponsesEncryptedContent(value) {
51
- if (typeof value !== "string" || !value.startsWith(ENCRYPTED_REASONING_PREFIX)) {
51
+ if (typeof value !== "string" || !value.startsWith(ENCRYPTED_CONTENT_PREFIX)) {
52
52
  return undefined;
53
53
  }
54
- const separator = value.indexOf(".", ENCRYPTED_REASONING_PREFIX.length);
54
+ const separator = value.indexOf(".", ENCRYPTED_CONTENT_PREFIX.length);
55
55
  if (separator === -1)
56
56
  return undefined;
57
- const encodedOwner = value.slice(ENCRYPTED_REASONING_PREFIX.length, separator);
57
+ const encodedOwner = value.slice(ENCRYPTED_CONTENT_PREFIX.length, separator);
58
58
  const ciphertext = value.slice(separator + 1);
59
59
  if (encodedOwner.length === 0 || ciphertext.length === 0)
60
60
  return undefined;
@@ -82,72 +82,193 @@ export function parseResponsesEncryptedContent(value) {
82
82
  return undefined;
83
83
  }
84
84
  }
85
- /**
86
- * Apply the destination policy to Responses input without mutating the request.
87
- * Incompatible reasoning items are removed while their assistant/tool carriers
88
- * remain in the portable transcript.
89
- */
90
- export function prepareResponsesReasoningInput(body, policy) {
91
- if (typeof body !== "object" || body === null || Array.isArray(body)) {
92
- return { body, dropped: 0 };
85
+ function applyEncryptedContentPolicy(value, policy) {
86
+ const parsed = parseResponsesEncryptedContent(value);
87
+ if (policy.mode === "drop")
88
+ return { action: "drop" };
89
+ if (policy.mode === "relay") {
90
+ return parsed === undefined
91
+ ? { action: "drop" }
92
+ : { action: "keep", value, changed: false };
93
93
  }
94
- const record = body;
95
- if (!Array.isArray(record.input))
96
- return { body, dropped: 0 };
97
- let dropped = 0;
94
+ if (parsed === undefined || !sameOwner(parsed.owner, policy.owner)) {
95
+ return { action: "drop" };
96
+ }
97
+ if (policy.unwrap === false) {
98
+ return { action: "keep", value, changed: false };
99
+ }
100
+ return { action: "keep", value: parsed.ciphertext, changed: true };
101
+ }
102
+ function prepareEncryptedContentParts(parts, policy) {
98
103
  let changed = false;
99
- const input = [];
100
- for (const candidate of record.input) {
104
+ let dropped = 0;
105
+ const value = [];
106
+ for (const candidate of parts) {
101
107
  if (typeof candidate !== "object" ||
102
108
  candidate === null ||
103
109
  Array.isArray(candidate)) {
104
- input.push(candidate);
110
+ value.push(candidate);
105
111
  continue;
106
112
  }
107
- const item = candidate;
108
- if (item.type !== "reasoning" ||
109
- typeof item.encrypted_content !== "string" ||
110
- item.encrypted_content.length === 0) {
111
- input.push(candidate);
113
+ const part = candidate;
114
+ if (part.type !== "encrypted_content" ||
115
+ typeof part.encrypted_content !== "string" ||
116
+ part.encrypted_content.length === 0) {
117
+ value.push(candidate);
112
118
  continue;
113
119
  }
114
- const parsed = parseResponsesEncryptedContent(item.encrypted_content);
115
- if (policy.mode === "drop") {
116
- dropped += 1;
120
+ const prepared = applyEncryptedContentPolicy(part.encrypted_content, policy);
121
+ if (prepared.action === "drop") {
117
122
  changed = true;
123
+ dropped += 1;
118
124
  continue;
119
125
  }
120
- if (policy.mode === "relay") {
121
- if (parsed === undefined) {
122
- dropped += 1;
123
- changed = true;
124
- continue;
125
- }
126
- input.push(candidate);
126
+ if (prepared.changed) {
127
+ value.push({ ...part, encrypted_content: prepared.value });
128
+ changed = true;
127
129
  continue;
128
130
  }
129
- if (parsed === undefined || !sameOwner(parsed.owner, policy.owner)) {
131
+ value.push(candidate);
132
+ }
133
+ return { value, changed, dropped };
134
+ }
135
+ function prepareEncryptedInputItem(candidate, policy) {
136
+ if (typeof candidate !== "object" ||
137
+ candidate === null ||
138
+ Array.isArray(candidate)) {
139
+ return { value: candidate, changed: false, dropped: 0, remove: false };
140
+ }
141
+ const item = candidate;
142
+ const type = typeof item.type === "string" ? item.type : "";
143
+ let next = item;
144
+ let changed = false;
145
+ let dropped = 0;
146
+ if (ENCRYPTED_CONTENT_RECORD_TYPES.has(type) &&
147
+ typeof item.encrypted_content === "string" &&
148
+ item.encrypted_content.length > 0) {
149
+ const prepared = applyEncryptedContentPolicy(item.encrypted_content, policy);
150
+ if (prepared.action === "drop") {
151
+ if (type === "reasoning" ||
152
+ type === "compaction" ||
153
+ type === "encrypted_content") {
154
+ return { value: candidate, changed: true, dropped: 1, remove: true };
155
+ }
156
+ const { encrypted_content: _encryptedContent, ...portable } = next;
157
+ next = portable;
158
+ changed = true;
130
159
  dropped += 1;
160
+ if ((type === "message" || type === "agent_message") &&
161
+ !Array.isArray(item.content)) {
162
+ return { value: candidate, changed: true, dropped, remove: true };
163
+ }
164
+ if ((type === "function_call_output" ||
165
+ type === "custom_tool_call_output") &&
166
+ !Object.hasOwn(item, "output")) {
167
+ next = { ...next, output: "" };
168
+ }
169
+ }
170
+ if (prepared.action === "keep" && prepared.changed) {
171
+ next = { ...next, encrypted_content: prepared.value };
131
172
  changed = true;
132
- continue;
133
173
  }
134
- if (policy.unwrap === false) {
135
- input.push(candidate);
136
- continue;
174
+ }
175
+ if ((type === "message" || type === "agent_message") &&
176
+ Array.isArray(item.content)) {
177
+ const prepared = prepareEncryptedContentParts(item.content, policy);
178
+ dropped += prepared.dropped;
179
+ if (prepared.changed) {
180
+ if (prepared.value.length === 0) {
181
+ return { value: candidate, changed: true, dropped, remove: true };
182
+ }
183
+ next = { ...next, content: prepared.value };
184
+ changed = true;
137
185
  }
138
- input.push({ ...item, encrypted_content: parsed.ciphertext });
139
- changed = true;
186
+ }
187
+ if ((type === "function_call_output" ||
188
+ type === "custom_tool_call_output") &&
189
+ Array.isArray(item.output)) {
190
+ const prepared = prepareEncryptedContentParts(item.output, policy);
191
+ dropped += prepared.dropped;
192
+ if (prepared.changed) {
193
+ next = {
194
+ ...next,
195
+ output: prepared.value.length === 0 ? "" : prepared.value
196
+ };
197
+ changed = true;
198
+ }
199
+ }
200
+ else if ((type === "function_call_output" ||
201
+ type === "custom_tool_call_output") &&
202
+ typeof item.output === "object" &&
203
+ item.output !== null &&
204
+ !Array.isArray(item.output)) {
205
+ const output = item.output;
206
+ if (output.type === "encrypted_content" &&
207
+ typeof output.encrypted_content === "string" &&
208
+ output.encrypted_content.length > 0) {
209
+ const prepared = applyEncryptedContentPolicy(output.encrypted_content, policy);
210
+ if (prepared.action === "drop") {
211
+ next = { ...next, output: "" };
212
+ changed = true;
213
+ dropped += 1;
214
+ }
215
+ else if (prepared.changed) {
216
+ next = {
217
+ ...next,
218
+ output: { ...output, encrypted_content: prepared.value }
219
+ };
220
+ changed = true;
221
+ }
222
+ }
223
+ }
224
+ return {
225
+ value: changed ? next : candidate,
226
+ changed,
227
+ dropped,
228
+ remove: false
229
+ };
230
+ }
231
+ /**
232
+ * Apply the destination policy to provider-owned Responses input without
233
+ * mutating the request. Incompatible opaque state is removed while visible
234
+ * messages and tool-call/output carriers remain in the portable transcript.
235
+ */
236
+ export function prepareResponsesEncryptedInput(body, policy) {
237
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
238
+ return { body, dropped: 0 };
239
+ }
240
+ const record = body;
241
+ if (!Array.isArray(record.input))
242
+ return { body, dropped: 0 };
243
+ let dropped = 0;
244
+ let changed = false;
245
+ const input = [];
246
+ for (const candidate of record.input) {
247
+ const prepared = prepareEncryptedInputItem(candidate, policy);
248
+ dropped += prepared.dropped;
249
+ changed ||= prepared.changed;
250
+ if (!prepared.remove)
251
+ input.push(prepared.value);
140
252
  }
141
253
  return {
142
254
  body: changed ? { ...record, input } : body,
143
255
  dropped
144
256
  };
145
257
  }
146
- function wrapEncryptedReasoningValue(value, owner) {
258
+ const ENCRYPTED_CONTENT_RECORD_TYPES = new Set([
259
+ "reasoning",
260
+ "compaction",
261
+ "encrypted_content",
262
+ "message",
263
+ "function_call_output",
264
+ "custom_tool_call_output",
265
+ "agent_message"
266
+ ]);
267
+ function wrapEncryptedResponsesValue(value, owner) {
147
268
  if (Array.isArray(value)) {
148
269
  let changed = false;
149
270
  const items = value.map((item) => {
150
- const transformed = wrapEncryptedReasoningValue(item, owner);
271
+ const transformed = wrapEncryptedResponsesValue(item, owner);
151
272
  changed ||= transformed.changed;
152
273
  return transformed.value;
153
274
  });
@@ -161,7 +282,8 @@ function wrapEncryptedReasoningValue(value, owner) {
161
282
  const output = {};
162
283
  for (const [key, field] of Object.entries(record)) {
163
284
  if (key === "encrypted_content" &&
164
- record.type === "reasoning" &&
285
+ typeof record.type === "string" &&
286
+ ENCRYPTED_CONTENT_RECORD_TYPES.has(record.type) &&
165
287
  typeof field === "string" &&
166
288
  field.length > 0) {
167
289
  const wrapped = wrapResponsesEncryptedContent(field, owner);
@@ -169,7 +291,7 @@ function wrapEncryptedReasoningValue(value, owner) {
169
291
  changed ||= wrapped !== field;
170
292
  continue;
171
293
  }
172
- const transformed = wrapEncryptedReasoningValue(field, owner);
294
+ const transformed = wrapEncryptedResponsesValue(field, owner);
173
295
  output[key] = transformed.value;
174
296
  changed ||= transformed.changed;
175
297
  }
@@ -200,7 +322,7 @@ function wrapSseFrame(frame, delimiter, owner) {
200
322
  catch {
201
323
  return `${frame}${delimiter}`;
202
324
  }
203
- const transformed = wrapEncryptedReasoningValue(parsed, owner);
325
+ const transformed = wrapEncryptedResponsesValue(parsed, owner);
204
326
  if (!transformed.changed)
205
327
  return `${frame}${delimiter}`;
206
328
  let emittedData = false;
@@ -214,7 +336,7 @@ function wrapSseFrame(frame, delimiter, owner) {
214
336
  });
215
337
  return `${rewritten.join(lineEnding)}${delimiter}`;
216
338
  }
217
- function wrapResponsesReasoningSse(source, owner) {
339
+ function wrapResponsesEncryptedSse(source, owner) {
218
340
  const decoder = new TextDecoder();
219
341
  const encoder = new TextEncoder();
220
342
  let buffer = "";
@@ -239,10 +361,10 @@ function wrapResponsesReasoningSse(source, owner) {
239
361
  }));
240
362
  }
241
363
  /**
242
- * Add ownership to encrypted reasoning emitted by a successful native
364
+ * Add ownership to encrypted Responses state emitted by a successful native
243
365
  * Responses call. Provider failures and unrelated response formats pass through.
244
366
  */
245
- export async function wrapResponsesReasoningResponse(response, owner) {
367
+ export async function wrapResponsesEncryptedResponse(response, owner) {
246
368
  if (!response.ok || response.body === null)
247
369
  return response;
248
370
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
@@ -250,7 +372,7 @@ export async function wrapResponsesReasoningResponse(response, owner) {
250
372
  const headers = new Headers(response.headers);
251
373
  headers.delete("content-length");
252
374
  headers.delete("content-encoding");
253
- return new Response(wrapResponsesReasoningSse(response.body, owner), {
375
+ return new Response(wrapResponsesEncryptedSse(response.body, owner), {
254
376
  status: response.status,
255
377
  statusText: response.statusText,
256
378
  headers
@@ -265,7 +387,7 @@ export async function wrapResponsesReasoningResponse(response, owner) {
265
387
  catch {
266
388
  return response;
267
389
  }
268
- const transformed = wrapEncryptedReasoningValue(parsed, owner);
390
+ const transformed = wrapEncryptedResponsesValue(parsed, owner);
269
391
  if (!transformed.changed)
270
392
  return response;
271
393
  const headers = new Headers(response.headers);
@@ -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, repairLegacyToolSearchItemIds, wrapResponsesReasoningResponse } from "./openai-responses-wire.js";
18
+ import { prepareResponsesEncryptedInput, repairLegacyToolSearchItemIds, wrapResponsesEncryptedResponse } 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";
@@ -776,7 +776,7 @@ export function chatToResponses(openai, model, toolRegistry = EMPTY_TOOL_REGISTR
776
776
  function jsonResponse(status, value) {
777
777
  return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
778
778
  }
779
- function responsesReasoningOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses) {
779
+ function responsesEncryptedContentOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses) {
780
780
  const route = backend.resolveModelRoute?.(upstreamModel);
781
781
  return route === undefined
782
782
  ? {
@@ -789,9 +789,9 @@ function responsesReasoningOwner(backend, upstreamModel, destinationWireShape, s
789
789
  }
790
790
  : { provider: route.provider, nativeModel: route.nativeId };
791
791
  }
792
- function recordDroppedEncryptedReasoning(count) {
792
+ function recordDroppedEncryptedContent(count) {
793
793
  if (count > 0) {
794
- droppedField("responses", "encrypted_content", "input.reasoning");
794
+ droppedField("responses", "encrypted_content", "input");
795
795
  }
796
796
  }
797
797
  export async function handleResponses(backend, body, modelCallId, signal, backendOptions = {}) {
@@ -848,7 +848,7 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
848
848
  const nativeResponses = backend.responses?.bind(backend);
849
849
  const supportsNativeResponses = nativeResponses !== undefined &&
850
850
  (backend.supportsResponses?.(upstreamModel) ?? true);
851
- const reasoningOwner = responsesReasoningOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses);
851
+ const encryptedContentOwner = responsesEncryptedContentOwner(backend, upstreamModel, destinationWireShape, supportsNativeResponses);
852
852
  if (body.previous_response_id != null && !supportsNativeResponses) {
853
853
  return jsonResponse(400, {
854
854
  error: {
@@ -864,16 +864,16 @@ 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(repairLegacyToolSearchItemIds(body), {
867
+ const prepared = prepareResponsesEncryptedInput(repairLegacyToolSearchItemIds(body), {
868
868
  mode: "forward",
869
- owner: reasoningOwner
869
+ owner: encryptedContentOwner
870
870
  });
871
- recordDroppedEncryptedReasoning(prepared.dropped);
871
+ recordDroppedEncryptedContent(prepared.dropped);
872
872
  const response = await nativeResponses(prepared.body, signal, {
873
873
  ...backendOptions,
874
874
  modelCallId
875
875
  });
876
- return wrapResponsesReasoningResponse(response, reasoningOwner);
876
+ return wrapResponsesEncryptedResponse(response, encryptedContentOwner);
877
877
  }
878
878
  // Server-executed web search is honored when the caller declared the tool,
879
879
  // an executor is available (a provider key exists), and no *client* tool
@@ -887,13 +887,13 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
887
887
  const includeCompatibleBody = requestsEncryptedReasoning && !preservesOpaqueReasoning
888
888
  ? { ...body, include: body.include?.filter((value) => value !== "reasoning.encrypted_content") }
889
889
  : body;
890
- const reasoningPolicy = destinationWireShape === "routekit-envelope"
890
+ const encryptedInputPolicy = destinationWireShape === "routekit-envelope"
891
891
  ? { mode: "relay" }
892
892
  : destinationWireShape === "openai-responses"
893
- ? { mode: "forward", owner: reasoningOwner, unwrap: false }
893
+ ? { mode: "forward", owner: encryptedContentOwner, unwrap: false }
894
894
  : { mode: "drop" };
895
- const prepared = prepareResponsesReasoningInput(includeCompatibleBody, reasoningPolicy);
896
- recordDroppedEncryptedReasoning(prepared.dropped);
895
+ const prepared = prepareResponsesEncryptedInput(includeCompatibleBody, encryptedInputPolicy);
896
+ recordDroppedEncryptedContent(prepared.dropped);
897
897
  const translatedBody = prepared.body;
898
898
  let chat;
899
899
  try {
@@ -14,9 +14,13 @@ export type BedrockProviderSourceOptions = {
14
14
  export declare const BEDROCK_OPENAI_ALLOWLIST: readonly ["openai.gpt-5.4", "openai.gpt-5.5", "openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna"];
15
15
  export declare function isBedrockOpenAiModel(modelId: string): boolean;
16
16
  /**
17
- * Codex advertises `web_search_tool_type: "text_and_image"` and sends
18
- * `search_content_types` on `{ type: "web_search" }`. Bedrock mantle accepts
19
- * the tool but rejects that field. Native OpenAI Responses is unchanged.
17
+ * Codex talks to RouteKit as OpenAI-compatible, so it sends OpenAI/Codex
18
+ * protocol items that Bedrock mantle rejects. Native OpenAI is unchanged.
19
+ *
20
+ * - `search_content_types` on `web_search*`
21
+ * - `agent_message` (Codex 0.147 subagent handoff)
22
+ * - `encrypted_content` content parts (same 400 as `agent_message`)
23
+ * - `compaction` / `reasoning` encrypted blobs without `rsn_` / `smry_`
20
24
  */
21
25
  export declare function sanitizeBedrockMantleRequestBody(body: unknown): unknown;
22
26
  export declare function toBedrockConverseInput(body: unknown): ConverseCommandInput;
@@ -15,27 +15,93 @@ export function isBedrockOpenAiModel(modelId) {
15
15
  return BEDROCK_OPENAI_MODEL.test(modelId);
16
16
  }
17
17
  /**
18
- * Codex advertises `web_search_tool_type: "text_and_image"` and sends
19
- * `search_content_types` on `{ type: "web_search" }`. Bedrock mantle accepts
20
- * the tool but rejects that field. Native OpenAI Responses is unchanged.
18
+ * Codex talks to RouteKit as OpenAI-compatible, so it sends OpenAI/Codex
19
+ * protocol items that Bedrock mantle rejects. Native OpenAI is unchanged.
20
+ *
21
+ * - `search_content_types` on `web_search*`
22
+ * - `agent_message` (Codex 0.147 subagent handoff)
23
+ * - `encrypted_content` content parts (same 400 as `agent_message`)
24
+ * - `compaction` / `reasoning` encrypted blobs without `rsn_` / `smry_`
21
25
  */
22
26
  export function sanitizeBedrockMantleRequestBody(body) {
23
27
  const payload = record(body);
24
- if (payload === undefined || !Array.isArray(payload.tools))
28
+ if (payload === undefined)
25
29
  return body;
30
+ let next = payload;
26
31
  let changed = false;
27
- const tools = payload.tools.map((tool) => {
28
- const entry = record(tool);
29
- if (entry === undefined)
30
- return tool;
31
- const type = typeof entry.type === "string" ? entry.type : "";
32
- if (!type.startsWith("web_search") || !("search_content_types" in entry))
33
- return tool;
32
+ if (Array.isArray(payload.tools)) {
33
+ const tools = payload.tools.map((tool) => {
34
+ const entry = record(tool);
35
+ if (entry === undefined)
36
+ return tool;
37
+ const type = typeof entry.type === "string" ? entry.type : "";
38
+ if (!type.startsWith("web_search") || !("search_content_types" in entry))
39
+ return tool;
40
+ changed = true;
41
+ const { search_content_types: _ignored, ...rest } = entry;
42
+ return rest;
43
+ });
44
+ if (changed)
45
+ next = { ...next, tools };
46
+ }
47
+ if (Array.isArray(payload.input)) {
48
+ const input = [];
49
+ let inputChanged = false;
50
+ for (const item of payload.input) {
51
+ const sanitized = sanitizeBedrockMantleInputItem(item);
52
+ if (sanitized === undefined) {
53
+ inputChanged = true;
54
+ continue;
55
+ }
56
+ if (sanitized !== item)
57
+ inputChanged = true;
58
+ input.push(sanitized);
59
+ }
60
+ if (inputChanged) {
61
+ changed = true;
62
+ next = { ...next, input };
63
+ }
64
+ }
65
+ return changed ? next : body;
66
+ }
67
+ function recognizedEncryptedPrefix(value) {
68
+ return typeof value === "string" && (value.startsWith("rsn_") || value.startsWith("smry_"));
69
+ }
70
+ function sanitizeBedrockMantleContent(content) {
71
+ if (!Array.isArray(content))
72
+ return { content, changed: false };
73
+ const parts = content.filter((part) => record(part)?.type !== "encrypted_content");
74
+ return { content: parts, changed: parts.length !== content.length };
75
+ }
76
+ function sanitizeBedrockMantleInputItem(item) {
77
+ const entry = record(item);
78
+ if (entry === undefined)
79
+ return item;
80
+ const type = typeof entry.type === "string" ? entry.type : "";
81
+ if (type === "compaction" && "encrypted_content" in entry && !recognizedEncryptedPrefix(entry.encrypted_content)) {
82
+ return undefined;
83
+ }
84
+ if (type === "agent_message") {
85
+ const { content } = sanitizeBedrockMantleContent(entry.content);
86
+ return { type: "message", role: "user", content };
87
+ }
88
+ let next = entry;
89
+ let changed = false;
90
+ if (Array.isArray(entry.content)) {
91
+ const sanitized = sanitizeBedrockMantleContent(entry.content);
92
+ if (sanitized.changed) {
93
+ next = { ...next, content: sanitized.content };
94
+ changed = true;
95
+ }
96
+ }
97
+ if (type === "reasoning" &&
98
+ "encrypted_content" in next &&
99
+ !recognizedEncryptedPrefix(next.encrypted_content)) {
100
+ const { encrypted_content: _ignored, ...rest } = next;
101
+ next = rest;
34
102
  changed = true;
35
- const { search_content_types: _ignored, ...rest } = entry;
36
- return rest;
37
- });
38
- return changed ? { ...payload, tools } : body;
103
+ }
104
+ return changed ? next : item;
39
105
  }
40
106
  function mantleApiKey(env) {
41
107
  const key = env.AWS_BEARER_TOKEN_BEDROCK ?? env.BEDROCK_API_KEY;
@@ -1,7 +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, prepareResponsesReasoningInput, wrapResponsesEncryptedContent } from "./adapters/openai-responses-wire.js";
4
+ import { normalizeOpenAiResponsesCallIds, prepareResponsesEncryptedInput, wrapResponsesEncryptedContent } from "./adapters/openai-responses-wire.js";
5
5
  import { SseDecoder, SseParseError } from "./sse/parse.js";
6
6
  import { anthropicMessageContentOf, anthropicReasoningDetailsOf, anthropicRequestMetadataOf, attachGoogleToolCallIndexes, googleThoughtDetailsOf, googleToolCallIndexesOf, reasoningSelectionOf, routeKitRequestValidationErrorOf, attachResponsesReasoningMetadata, responsesReasoningMetadataOf } from "./adapters/openai-chat-wire.js";
7
7
  function invalidReasoningControlResponse(message, metadata = false, path) {
@@ -1059,16 +1059,16 @@ function responsesRequest(body, model, options) {
1059
1059
  }
1060
1060
  : {})
1061
1061
  });
1062
- const prepared = prepareResponsesReasoningInput(request, {
1062
+ const prepared = prepareResponsesEncryptedInput(request, {
1063
1063
  mode: "forward",
1064
- owner: codexReasoningOwner(model)
1064
+ owner: codexEncryptedContentOwner(model)
1065
1065
  });
1066
1066
  if (prepared.dropped > 0) {
1067
- droppedField("responses", "encrypted_content", "input.reasoning");
1067
+ droppedField("responses", "encrypted_content", "input");
1068
1068
  }
1069
1069
  return prepared.body;
1070
1070
  }
1071
- function codexReasoningOwner(model) {
1071
+ function codexEncryptedContentOwner(model) {
1072
1072
  return { provider: "codex", nativeModel: model };
1073
1073
  }
1074
1074
  function responsesOutput(payload, model) {
@@ -1089,7 +1089,7 @@ function responsesOutput(payload, model) {
1089
1089
  item.encrypted_content.length > 0
1090
1090
  ? [{
1091
1091
  ...item,
1092
- encrypted_content: wrapResponsesEncryptedContent(item.encrypted_content, codexReasoningOwner(model))
1092
+ encrypted_content: wrapResponsesEncryptedContent(item.encrypted_content, codexEncryptedContentOwner(model))
1093
1093
  }]
1094
1094
  : []);
1095
1095
  const toolCalls = (output ?? []).flatMap((item, index) => item.type === "function_call"
@@ -1315,7 +1315,7 @@ export class CodexResponsesBackend extends HttpProviderBackend {
1315
1315
  const delta = {};
1316
1316
  const wrappedOutput = {
1317
1317
  ...output,
1318
- encrypted_content: wrapResponsesEncryptedContent(output.encrypted_content, codexReasoningOwner(model))
1318
+ encrypted_content: wrapResponsesEncryptedContent(output.encrypted_content, codexEncryptedContentOwner(model))
1319
1319
  };
1320
1320
  attachResponsesReasoningMetadata(delta, {
1321
1321
  items: [wrappedOutput],
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, repairLegacyToolSearchItemIds, wrapResponsesReasoningResponse } from "./adapters/openai-responses-wire.js";
8
+ import { prepareResponsesEncryptedInput, repairLegacyToolSearchItemIds, wrapResponsesEncryptedResponse } 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(repairLegacyToolSearchItemIds(withModel(body, route.nativeId)), {
659
+ const prepared = prepareResponsesEncryptedInput(repairLegacyToolSearchItemIds(withModel(body, route.nativeId)), {
660
660
  mode: "forward",
661
661
  owner
662
662
  });
@@ -673,13 +673,13 @@ export async function startGateway(options) {
673
673
  },
674
674
  invoke: async (_callId, signal, onAttribution) => {
675
675
  if (prepared.dropped > 0) {
676
- droppedField("responses", "encrypted_content", "input.reasoning");
676
+ droppedField("responses", "encrypted_content", "input");
677
677
  }
678
678
  const response = await codexProviderRelay.relay(req.headers, relayBody, signal, {
679
679
  responseMode: isStream(body) ? "streaming" : "buffered",
680
680
  onAttribution
681
681
  });
682
- return await wrapResponsesReasoningResponse(response, owner);
682
+ return await wrapResponsesEncryptedResponse(response, owner);
683
683
  }
684
684
  });
685
685
  return;
@@ -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(repairLegacyToolSearchItemIds(body), {
695
+ const prepared = prepareResponsesEncryptedInput(repairLegacyToolSearchItemIds(body), {
696
696
  mode: "forward",
697
697
  owner
698
698
  });
@@ -708,13 +708,13 @@ export async function startGateway(options) {
708
708
  },
709
709
  invoke: async (_callId, signal, onAttribution) => {
710
710
  if (prepared.dropped > 0) {
711
- droppedField("responses", "encrypted_content", "input.reasoning");
711
+ droppedField("responses", "encrypted_content", "input");
712
712
  }
713
713
  const response = await codexRequestRelay.relay(req.headers, prepared.body, signal, {
714
714
  responseMode: isStream(body) ? "streaming" : "buffered",
715
715
  onAttribution
716
716
  });
717
- return await wrapResponsesReasoningResponse(response, owner);
717
+ return await wrapResponsesEncryptedResponse(response, owner);
718
718
  }
719
719
  });
720
720
  return;
@@ -523,6 +523,48 @@ test("sanitizeBedrockMantleRequestBody strips search_content_types from web_sear
523
523
  const untouched = { model: "openai.gpt-5.6-sol", tools: [{ type: "web_search" }] };
524
524
  assert.equal(sanitizeBedrockMantleRequestBody(untouched), untouched);
525
525
  });
526
+ test("sanitizeBedrockMantleRequestBody rewrites Codex agent_message and drops encrypted_content", () => {
527
+ const body = {
528
+ model: "openai.gpt-5.6-sol",
529
+ input: [
530
+ {
531
+ type: "agent_message",
532
+ author: "/root",
533
+ recipient: "/root/package_architecture",
534
+ content: [
535
+ { type: "input_text", text: "Reply with exactly PONG" },
536
+ { type: "encrypted_content", encrypted_content: " " }
537
+ ]
538
+ },
539
+ {
540
+ type: "message",
541
+ role: "user",
542
+ content: [
543
+ { type: "input_text", text: "keep" },
544
+ { type: "encrypted_content", encrypted_content: " " }
545
+ ]
546
+ },
547
+ { type: "compaction", encrypted_content: "not-a-prefix" },
548
+ { type: "reasoning", encrypted_content: "rsn_ok", summary: [] }
549
+ ]
550
+ };
551
+ assert.deepEqual(sanitizeBedrockMantleRequestBody(body), {
552
+ model: "openai.gpt-5.6-sol",
553
+ input: [
554
+ {
555
+ type: "message",
556
+ role: "user",
557
+ content: [{ type: "input_text", text: "Reply with exactly PONG" }]
558
+ },
559
+ {
560
+ type: "message",
561
+ role: "user",
562
+ content: [{ type: "input_text", text: "keep" }]
563
+ },
564
+ { type: "reasoning", encrypted_content: "rsn_ok", summary: [] }
565
+ ]
566
+ });
567
+ });
526
568
  test("Bedrock mantle Responses drops Codex search_content_types before egress", async () => {
527
569
  const forwarded = [];
528
570
  const source = new BedrockProviderSource({
@@ -551,6 +593,40 @@ test("Bedrock mantle Responses drops Codex search_content_types before egress",
551
593
  }
552
594
  ]);
553
595
  });
596
+ test("Bedrock mantle Responses rewrites Codex agent_message before egress", async () => {
597
+ const forwarded = [];
598
+ const source = new BedrockProviderSource({
599
+ env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
600
+ controlClient: { send: async () => ({}) },
601
+ runtimeClient: { send: async () => ({}) },
602
+ mantleBackend: {
603
+ chat: async () => Response.json({}),
604
+ responses: async (body) => {
605
+ forwarded.push(body);
606
+ return Response.json({ id: "resp" });
607
+ }
608
+ }
609
+ });
610
+ const response = await source.responses({
611
+ model: "openai.gpt-5.6-sol",
612
+ input: [{
613
+ type: "agent_message",
614
+ author: "/root",
615
+ recipient: "/root/async_lifecycle",
616
+ content: [
617
+ { type: "input_text", text: "hi" },
618
+ { type: "encrypted_content", encrypted_content: " " }
619
+ ]
620
+ }]
621
+ });
622
+ assert.equal(response.status, 200);
623
+ assert.deepEqual(forwarded, [
624
+ {
625
+ model: "openai.gpt-5.6-sol",
626
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }]
627
+ }
628
+ ]);
629
+ });
554
630
  test("Bedrock OpenAI chat without a mantle key returns 400 and does not call Converse", async () => {
555
631
  const runtimeCalls = [];
556
632
  const source = new BedrockProviderSource({
@@ -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, repairLegacyToolSearchItemIds, wrapResponsesEncryptedContent, wrapResponsesReasoningResponse } from "../adapters/openai-responses-wire.js";
4
+ import { normalizeOpenAiResponsesCallIds, parseResponsesEncryptedContent, prepareResponsesEncryptedInput, repairLegacyToolSearchItemIds, wrapResponsesEncryptedContent, wrapResponsesEncryptedResponse } from "../adapters/openai-responses-wire.js";
5
5
  const OWNER_A = {
6
6
  provider: "codex",
7
7
  nativeModel: "gpt-5.6"
@@ -134,7 +134,7 @@ test("legacy tool-search item ids are repaired narrowly without mutating input",
134
134
  const stringInput = { input: "hello" };
135
135
  assert.equal(repairLegacyToolSearchItemIds(stringInput), stringInput);
136
136
  });
137
- test("encrypted reasoning ownership round-trips exact opaque content", () => {
137
+ test("encrypted Responses ownership round-trips exact opaque content", () => {
138
138
  const ciphertext = "opaque.with.separators/and unicode \u{1f9e0}\nbytes";
139
139
  const wrapped = wrapResponsesEncryptedContent(ciphertext, OWNER_A);
140
140
  assert.match(wrapped, /^rk1\.[A-Za-z0-9_-]+\./);
@@ -144,7 +144,7 @@ test("encrypted reasoning ownership round-trips exact opaque content", () => {
144
144
  });
145
145
  assert.equal(wrapResponsesEncryptedContent(wrapped, OWNER_B), wrapped);
146
146
  });
147
- test("reasoning input forward policy unwraps only the matching provider and native model", () => {
147
+ test("encrypted input forward policy unwraps only the matching provider and native model", () => {
148
148
  const matching = wrapResponsesEncryptedContent("cipher-a", OWNER_A);
149
149
  const otherProvider = wrapResponsesEncryptedContent("cipher-b", OWNER_B);
150
150
  const otherModel = wrapResponsesEncryptedContent("cipher-c", {
@@ -164,7 +164,7 @@ test("reasoning input forward policy unwraps only the matching provider and nati
164
164
  { type: "function_call", call_id: "call_1", name: "read", arguments: "{}" }
165
165
  ]
166
166
  };
167
- const prepared = prepareResponsesReasoningInput(body, {
167
+ const prepared = prepareResponsesEncryptedInput(body, {
168
168
  mode: "forward",
169
169
  owner: OWNER_A
170
170
  });
@@ -175,7 +175,7 @@ test("reasoning input forward policy unwraps only the matching provider and nati
175
175
  assert.equal(input[4]?.encrypted_content, undefined);
176
176
  assert.equal(body.input[1]?.encrypted_content, matching, "caller input is not mutated");
177
177
  });
178
- test("reasoning input relay and drop policies retain the portable transcript", () => {
178
+ test("encrypted input relay and drop policies retain the portable transcript", () => {
179
179
  const ownedA = wrapResponsesEncryptedContent("cipher-a", OWNER_A);
180
180
  const ownedB = wrapResponsesEncryptedContent("cipher-b", OWNER_B);
181
181
  const malformedHeader = `rk1.${ownedA.split(".")[1]}!.cipher-malformed`;
@@ -191,10 +191,10 @@ test("reasoning input relay and drop policies retain the portable transcript", (
191
191
  { type: "function_call", call_id: "call_1", name: "read", arguments: "{}" }
192
192
  ]
193
193
  };
194
- const relayed = prepareResponsesReasoningInput(body, { mode: "relay" });
194
+ const relayed = prepareResponsesEncryptedInput(body, { mode: "relay" });
195
195
  assert.equal(relayed.dropped, 3);
196
196
  assert.deepEqual(relayed.body.input.map((item) => item.encrypted_content), [ownedA, undefined, ownedB, undefined, undefined]);
197
- const dropped = prepareResponsesReasoningInput(body, { mode: "drop" });
197
+ const dropped = prepareResponsesEncryptedInput(body, { mode: "drop" });
198
198
  assert.equal(dropped.dropped, 5);
199
199
  assert.deepEqual(dropped.body.input.map((item) => item.type), ["message", "message", "function_call"]);
200
200
  });
@@ -203,7 +203,7 @@ test("forward policy can retain a matching wrapper for a later provider bridge",
203
203
  const body = {
204
204
  input: [{ type: "reasoning", encrypted_content: wrapped }]
205
205
  };
206
- const prepared = prepareResponsesReasoningInput(body, {
206
+ const prepared = prepareResponsesEncryptedInput(body, {
207
207
  mode: "forward",
208
208
  owner: OWNER_A,
209
209
  unwrap: false
@@ -211,24 +211,208 @@ test("forward policy can retain a matching wrapper for a later provider bridge",
211
211
  assert.equal(prepared.body, body);
212
212
  assert.equal(prepared.dropped, 0);
213
213
  });
214
- test("buffered Responses output wraps nested encrypted reasoning only", async () => {
214
+ test("encrypted tool and message parts preserve portable history across provider switches", () => {
215
+ const matching = wrapResponsesEncryptedContent("matching", OWNER_A);
216
+ const foreign = wrapResponsesEncryptedContent("foreign", OWNER_B);
217
+ const otherModel = wrapResponsesEncryptedContent("other-model", {
218
+ provider: OWNER_A.provider,
219
+ nativeModel: "gpt-5.7"
220
+ });
221
+ const message = {
222
+ type: "agent_message",
223
+ id: "amsg_1",
224
+ content: [
225
+ { type: "input_text", text: "visible handoff" },
226
+ { type: "encrypted_content", encrypted_content: foreign }
227
+ ]
228
+ };
229
+ const functionOutput = {
230
+ type: "function_call_output",
231
+ call_id: "call_1",
232
+ output: [
233
+ { type: "input_text", text: "visible result" },
234
+ { type: "encrypted_content", encrypted_content: matching },
235
+ { type: "encrypted_content", encrypted_content: foreign },
236
+ { type: "encrypted_content", encrypted_content: "legacy-raw" }
237
+ ]
238
+ };
239
+ const customOutput = {
240
+ type: "custom_tool_call_output",
241
+ call_id: "call_2",
242
+ output: [
243
+ { type: "encrypted_content", encrypted_content: otherModel }
244
+ ]
245
+ };
246
+ const objectOutput = {
247
+ type: "function_call_output",
248
+ call_id: "call_3",
249
+ output: {
250
+ type: "encrypted_content",
251
+ encrypted_content: matching
252
+ }
253
+ };
254
+ const compaction = {
255
+ type: "compaction",
256
+ encrypted_content: foreign
257
+ };
258
+ const body = {
259
+ input: [
260
+ message,
261
+ functionOutput,
262
+ customOutput,
263
+ objectOutput,
264
+ compaction
265
+ ]
266
+ };
267
+ const prepared = prepareResponsesEncryptedInput(body, {
268
+ mode: "forward",
269
+ owner: OWNER_A
270
+ });
271
+ const input = prepared.body.input;
272
+ assert.equal(prepared.dropped, 5);
273
+ assert.deepEqual(input, [
274
+ {
275
+ type: "agent_message",
276
+ id: "amsg_1",
277
+ content: [{ type: "input_text", text: "visible handoff" }]
278
+ },
279
+ {
280
+ type: "function_call_output",
281
+ call_id: "call_1",
282
+ output: [
283
+ { type: "input_text", text: "visible result" },
284
+ { type: "encrypted_content", encrypted_content: "matching" }
285
+ ]
286
+ },
287
+ {
288
+ type: "custom_tool_call_output",
289
+ call_id: "call_2",
290
+ output: ""
291
+ },
292
+ {
293
+ type: "function_call_output",
294
+ call_id: "call_3",
295
+ output: {
296
+ type: "encrypted_content",
297
+ encrypted_content: "matching"
298
+ }
299
+ }
300
+ ]);
301
+ assert.equal(message.content[1].encrypted_content, foreign, "caller message is not mutated");
302
+ assert.equal(functionOutput.output[1]
303
+ .encrypted_content, matching, "caller function output is not mutated");
304
+ });
305
+ test("encrypted-only message carriers are removed while tool outputs retain pairing", () => {
306
+ const foreign = wrapResponsesEncryptedContent("foreign", OWNER_B);
307
+ const body = {
308
+ input: [
309
+ {
310
+ type: "message",
311
+ role: "assistant",
312
+ content: [
313
+ { type: "encrypted_content", encrypted_content: foreign }
314
+ ]
315
+ },
316
+ {
317
+ type: "agent_message",
318
+ id: "amsg_1",
319
+ content: [
320
+ { type: "encrypted_content", encrypted_content: "legacy-raw" }
321
+ ]
322
+ },
323
+ {
324
+ type: "function_call_output",
325
+ call_id: "call_1",
326
+ output: [
327
+ { type: "encrypted_content", encrypted_content: foreign }
328
+ ]
329
+ },
330
+ {
331
+ type: "custom_tool_call_output",
332
+ call_id: "call_2",
333
+ output: {
334
+ type: "encrypted_content",
335
+ encrypted_content: "legacy-raw"
336
+ }
337
+ }
338
+ ]
339
+ };
340
+ const prepared = prepareResponsesEncryptedInput(body, {
341
+ mode: "forward",
342
+ owner: OWNER_A
343
+ });
344
+ assert.equal(prepared.dropped, 4);
345
+ assert.deepEqual(prepared.body.input, [
346
+ { type: "function_call_output", call_id: "call_1", output: "" },
347
+ { type: "custom_tool_call_output", call_id: "call_2", output: "" }
348
+ ]);
349
+ });
350
+ test("relay policy retains owned nested content without unwrapping", () => {
351
+ const owned = wrapResponsesEncryptedContent("owned", OWNER_A);
352
+ const body = {
353
+ input: [
354
+ {
355
+ type: "function_call_output",
356
+ call_id: "call_1",
357
+ output: [
358
+ { type: "encrypted_content", encrypted_content: owned },
359
+ { type: "encrypted_content", encrypted_content: "legacy-raw" }
360
+ ]
361
+ }
362
+ ]
363
+ };
364
+ const prepared = prepareResponsesEncryptedInput(body, { mode: "relay" });
365
+ assert.equal(prepared.dropped, 1);
366
+ assert.deepEqual(prepared.body.input, [
367
+ {
368
+ type: "function_call_output",
369
+ call_id: "call_1",
370
+ output: [{ type: "encrypted_content", encrypted_content: owned }]
371
+ }
372
+ ]);
373
+ });
374
+ test("buffered Responses output wraps all supported encrypted replay content", async () => {
215
375
  const response = Response.json({
216
376
  id: "resp_1",
217
377
  output: [
218
378
  { type: "reasoning", encrypted_content: "cipher-a", summary: [] },
379
+ { type: "compaction", encrypted_content: "compact-a" },
380
+ {
381
+ type: "agent_message",
382
+ content: [
383
+ { type: "input_text", text: "handoff" },
384
+ { type: "encrypted_content", encrypted_content: "agent-a" }
385
+ ]
386
+ },
387
+ {
388
+ type: "function_call_output",
389
+ call_id: "call_1",
390
+ output: [
391
+ { type: "input_text", text: "result" },
392
+ { type: "encrypted_content", encrypted_content: "function-a" }
393
+ ]
394
+ },
395
+ {
396
+ type: "custom_tool_call_output",
397
+ call_id: "call_2",
398
+ output: {
399
+ type: "encrypted_content",
400
+ encrypted_content: "custom-a"
401
+ }
402
+ },
219
403
  { type: "message", content: [{ type: "output_text", text: "done" }] }
220
404
  ]
221
405
  }, {
222
406
  headers: { "content-length": "1", "x-test": "kept" }
223
407
  });
224
- const wrapped = await wrapResponsesReasoningResponse(response, OWNER_A);
408
+ const wrapped = await wrapResponsesEncryptedResponse(response, OWNER_A);
225
409
  const payload = await wrapped.json();
226
- assert.deepEqual(parseResponsesEncryptedContent(payload.output[0]?.encrypted_content), { owner: OWNER_A, ciphertext: "cipher-a" });
227
- assert.equal(payload.output[1]?.encrypted_content, undefined);
410
+ const encrypted = [...JSON.stringify(payload).matchAll(/"encrypted_content":"([^"]+)"/g)].map((match) => parseResponsesEncryptedContent(match[1]));
411
+ assert.deepEqual(encrypted, ["cipher-a", "compact-a", "agent-a", "function-a", "custom-a"].map((ciphertext) => ({ owner: OWNER_A, ciphertext })));
228
412
  assert.equal(wrapped.headers.get("content-length"), null);
229
413
  assert.equal(wrapped.headers.get("x-test"), "kept");
230
414
  });
231
- test("streaming Responses output wraps reasoning and preserves SSE framing", async () => {
415
+ test("streaming Responses output wraps encrypted replay content and preserves SSE framing", async () => {
232
416
  const encoder = new TextEncoder();
233
417
  const raw = [
234
418
  ": keepalive\r\n\r\n",
@@ -237,8 +421,10 @@ test("streaming Responses output wraps reasoning and preserves SSE framing", asy
237
421
  "event: response.output_item.done\r\n",
238
422
  'data: {"output_index":0,\r\n',
239
423
  'data: "item":{"type":"reasoning","encrypted_content":"cipher-\u{1f9e0}"}}\r\n\r\n',
424
+ "event: response.output_item.done\r\n",
425
+ 'data: {"output_index":1,"item":{"type":"function_call_output","call_id":"call_1","output":[{"type":"encrypted_content","encrypted_content":"function-stream"}]}}\r\n\r\n',
240
426
  "event: response.completed\r\n",
241
- 'data: {"response":{"output":[{"type":"reasoning","encrypted_content":"terminal"}]}}\r\n\r\n',
427
+ 'data: {"response":{"output":[{"type":"agent_message","content":[{"type":"encrypted_content","encrypted_content":"terminal"}]}]}}\r\n\r\n',
242
428
  "event: custom\r\n",
243
429
  "data: not-json\r\n\r\n",
244
430
  "data: [DONE]\r\n\r\n"
@@ -255,7 +441,7 @@ test("streaming Responses output wraps reasoning and preserves SSE framing", asy
255
441
  const response = new Response(source, {
256
442
  headers: { "content-type": "text/event-stream", "content-length": "5" }
257
443
  });
258
- const wrapped = await wrapResponsesReasoningResponse(response, OWNER_A);
444
+ const wrapped = await wrapResponsesEncryptedResponse(response, OWNER_A);
259
445
  const text = await wrapped.text();
260
446
  assert.match(text, /^: keepalive\r\n\r\n/);
261
447
  assert.match(text, /id: event-1\r\nretry: 1000\r\nevent: response\.output_item\.done/);
@@ -266,6 +452,7 @@ test("streaming Responses output wraps reasoning and preserves SSE framing", asy
266
452
  .map((match) => parseResponsesEncryptedContent(match[1]));
267
453
  assert.deepEqual(envelopes, [
268
454
  { owner: OWNER_A, ciphertext: "cipher-\u{1f9e0}" },
455
+ { owner: OWNER_A, ciphertext: "function-stream" },
269
456
  { owner: OWNER_A, ciphertext: "terminal" }
270
457
  ]);
271
458
  });
@@ -1011,7 +1011,7 @@ test("Responses follows ModelRoutedBackend reasoning wire capability", async ()
1011
1011
  await gateway.close();
1012
1012
  }
1013
1013
  });
1014
- test("native Responses swaps isolate provider reasoning and restore it on A to B to A", async () => {
1014
+ test("native Responses swaps isolate encrypted replay state and restore it on A to B to A", async () => {
1015
1015
  const requests = [];
1016
1016
  const routes = {
1017
1017
  "provider-a/model-a": {
@@ -1054,6 +1054,17 @@ test("native Responses swaps isolate provider reasoning and restore it on A to B
1054
1054
  encrypted_content: `raw-${suffix}`,
1055
1055
  summary: []
1056
1056
  },
1057
+ {
1058
+ type: "function_call_output",
1059
+ call_id: `call_${suffix}`,
1060
+ output: [
1061
+ { type: "input_text", text: `tool-${suffix}` },
1062
+ {
1063
+ type: "encrypted_content",
1064
+ encrypted_content: `raw-tool-${suffix}`
1065
+ }
1066
+ ]
1067
+ },
1057
1068
  {
1058
1069
  type: "message",
1059
1070
  role: "assistant",
@@ -1084,9 +1095,25 @@ test("native Responses swaps isolate provider reasoning and restore it on A to B
1084
1095
  owner: { provider: "provider-a", nativeModel: "model-a" },
1085
1096
  ciphertext: "raw-a"
1086
1097
  });
1098
+ const encryptedToolA = String(firstPayload.output[1]?.output?.[1]?.encrypted_content);
1099
+ assert.deepEqual(parseResponsesEncryptedContent(encryptedToolA), {
1100
+ owner: { provider: "provider-a", nativeModel: "model-a" },
1101
+ ciphertext: "raw-tool-a"
1102
+ });
1087
1103
  const secondInput = [
1088
1104
  { role: "user", content: "start" },
1089
1105
  { type: "reasoning", id: "rs_a", encrypted_content: encryptedA },
1106
+ {
1107
+ type: "function_call_output",
1108
+ call_id: "call_a",
1109
+ output: [
1110
+ { type: "input_text", text: "tool-a" },
1111
+ {
1112
+ type: "encrypted_content",
1113
+ encrypted_content: encryptedToolA
1114
+ }
1115
+ ]
1116
+ },
1090
1117
  { type: "message", role: "assistant", content: "visible-a" }
1091
1118
  ];
1092
1119
  const second = await fetch(`${gateway.url()}/v1/responses`, {
@@ -1107,7 +1134,18 @@ test("native Responses swaps isolate provider reasoning and restore it on A to B
1107
1134
  });
1108
1135
  const sentToB = requests[1];
1109
1136
  assert.equal(sentToB.input.some((item) => item.type === "reasoning"), false);
1137
+ assert.deepEqual(sentToB.input.find((item) => item.type === "function_call_output"), {
1138
+ type: "function_call_output",
1139
+ call_id: "call_a",
1140
+ output: [{ type: "input_text", text: "tool-a" }]
1141
+ });
1142
+ assert.equal(JSON.stringify(sentToB).includes("raw-tool-a"), false);
1110
1143
  assert.deepEqual(sentToB.include, ["reasoning.encrypted_content"]);
1144
+ const encryptedToolB = String(secondPayload.output[1]?.output?.[1]?.encrypted_content);
1145
+ assert.deepEqual(parseResponsesEncryptedContent(encryptedToolB), {
1146
+ owner: { provider: "provider-b", nativeModel: "model-b" },
1147
+ ciphertext: "raw-tool-b"
1148
+ });
1111
1149
  const third = await fetch(`${gateway.url()}/v1/responses`, {
1112
1150
  method: "POST",
1113
1151
  headers: { "content-type": "application/json" },
@@ -1116,6 +1154,17 @@ test("native Responses swaps isolate provider reasoning and restore it on A to B
1116
1154
  input: [
1117
1155
  ...secondInput,
1118
1156
  { type: "reasoning", id: "rs_b", encrypted_content: encryptedB },
1157
+ {
1158
+ type: "function_call_output",
1159
+ call_id: "call_b",
1160
+ output: [
1161
+ { type: "input_text", text: "tool-b" },
1162
+ {
1163
+ type: "encrypted_content",
1164
+ encrypted_content: encryptedToolB
1165
+ }
1166
+ ]
1167
+ },
1119
1168
  { type: "message", role: "assistant", content: "visible-b" }
1120
1169
  ],
1121
1170
  include: ["reasoning.encrypted_content"]
@@ -1130,7 +1179,17 @@ test("native Responses swaps isolate provider reasoning and restore it on A to B
1130
1179
  encrypted_content: "raw-a"
1131
1180
  }]);
1132
1181
  assert.equal(JSON.stringify(sentBackToA).includes("raw-b"), false);
1182
+ assert.equal(JSON.stringify(sentBackToA).includes("raw-tool-b"), false);
1133
1183
  assert.equal(JSON.stringify(sentBackToA).includes("rk1."), false);
1184
+ assert.deepEqual(sentBackToA.input
1185
+ .filter((item) => item.type === "function_call_output")
1186
+ .map((item) => item.output), [
1187
+ [
1188
+ { type: "input_text", text: "tool-a" },
1189
+ { type: "encrypted_content", encrypted_content: "raw-tool-a" }
1190
+ ],
1191
+ [{ type: "input_text", text: "tool-b" }]
1192
+ ]);
1134
1193
  assert.deepEqual(sentBackToA.input
1135
1194
  .filter((item) => item.type === "message")
1136
1195
  .map((item) => item.content), ["visible-a", "visible-b"]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.18.4",
4
+ "version": "0.18.6",
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.18.4",
33
- "@velum-labs/routekit-registry": "0.18.4",
34
- "@velum-labs/routekit-runtime": "0.18.4",
35
- "@velum-labs/routekit-tracing": "0.18.4"
32
+ "@velum-labs/routekit-contracts": "0.18.6",
33
+ "@velum-labs/routekit-tracing": "0.18.6",
34
+ "@velum-labs/routekit-runtime": "0.18.6",
35
+ "@velum-labs/routekit-registry": "0.18.6"
36
36
  },
37
37
  "keywords": [
38
38
  "routekit",