@velum-labs/routekit-gateway 0.15.0 → 0.16.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.
@@ -825,13 +825,17 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
825
825
  }
826
826
  });
827
827
  }
828
- if (body.previous_response_id != null) {
828
+ const destinationWireShape = backend.reasoningWireShape?.(upstreamModel);
829
+ const nativeResponses = backend.responses?.bind(backend);
830
+ const supportsNativeResponses = nativeResponses !== undefined &&
831
+ (backend.supportsResponses?.(upstreamModel) ?? true);
832
+ if (body.previous_response_id != null && !supportsNativeResponses) {
829
833
  return jsonResponse(400, {
830
834
  error: {
831
835
  type: "invalid_request_error",
832
836
  code: "unsupported_previous_response_id",
833
837
  param: "previous_response_id",
834
- message: "previous_response_id is not supported by this stateless gateway; replay prior output items in input"
838
+ message: "previous_response_id cannot be translated to this destination; replay prior output items in input"
835
839
  }
836
840
  });
837
841
  }
@@ -840,8 +844,9 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
840
844
  typeof item.encrypted_content === "string" &&
841
845
  item.encrypted_content.length > 0);
842
846
  const requestsEncryptedReasoning = Array.isArray(body.include) && body.include.includes("reasoning.encrypted_content");
843
- const destinationWireShape = backend.reasoningWireShape?.(upstreamModel);
844
- const preservesOpaqueReasoning = destinationWireShape === "openai-responses" || destinationWireShape === "routekit-envelope";
847
+ const preservesOpaqueReasoning = supportsNativeResponses ||
848
+ destinationWireShape === "openai-responses" ||
849
+ destinationWireShape === "routekit-envelope";
845
850
  if (hasEncryptedReasoning && !preservesOpaqueReasoning) {
846
851
  return jsonResponse(400, {
847
852
  error: {
@@ -852,6 +857,12 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
852
857
  }
853
858
  });
854
859
  }
860
+ if (supportsNativeResponses && nativeResponses !== undefined) {
861
+ return nativeResponses(body, signal, {
862
+ ...backendOptions,
863
+ modelCallId
864
+ });
865
+ }
855
866
  // Server-executed web search is honored when the caller declared the tool,
856
867
  // an executor is available (a provider key exists), and no *client* tool
857
868
  // already owns the projected name; otherwise the ingress keeps its
package/dist/backend.d.ts CHANGED
@@ -61,6 +61,10 @@ export type Backend = {
61
61
  reasoningWireShape?(model: string): string | undefined;
62
62
  /** POST <base>/chat/completions — supports streaming (SSE) upstream. */
63
63
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
64
+ /** Whether this model can egress through the provider's native Responses API. */
65
+ supportsResponses?(model: string): boolean;
66
+ /** POST <base>/responses without translating through Chat Completions. */
67
+ responses?(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
64
68
  /** GET <base>/models. */
65
69
  models(signal?: AbortSignal): Promise<Response>;
66
70
  /** POST <base>/embeddings. */
@@ -122,12 +126,14 @@ export type OpenAiBackendOptions = {
122
126
  };
123
127
  /** Join a base URL (which may end in `/`) with a route path. */
124
128
  export declare function joinPath(baseUrl: string, path: string): string;
125
- /** An OpenAI Chat Completions backend reached over HTTP. */
129
+ /** An OpenAI HTTP backend supporting Chat Completions and native Responses. */
126
130
  export declare class OpenAiBackend implements Backend {
127
131
  #private;
128
132
  readonly defaultModel: string | undefined;
129
133
  constructor(options: OpenAiBackendOptions);
130
134
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
135
+ supportsResponses(): boolean;
136
+ responses(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
131
137
  models(signal?: AbortSignal): Promise<Response>;
132
138
  embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
133
139
  }
@@ -152,6 +158,8 @@ export declare class ModelRoutedBackend implements Backend {
152
158
  resolveModel(requested: string | undefined): string | undefined;
153
159
  reasoningWireShape(model: string): string | undefined;
154
160
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
161
+ supportsResponses(model: string): boolean;
162
+ responses(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
155
163
  models(signal?: AbortSignal): Promise<Response>;
156
164
  embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
157
165
  close(): Promise<void>;
package/dist/backend.js CHANGED
@@ -8,7 +8,7 @@ export function joinPath(baseUrl, path) {
8
8
  function invalidReasoningControlResponse(message, metadata = false, path) {
9
9
  return Response.json({ error: { type: "invalid_request_error", code: metadata ? "invalid_reasoning_metadata" : "invalid_reasoning_control", ...(path !== undefined ? { param: path } : {}), message } }, { status: 400 });
10
10
  }
11
- /** An OpenAI Chat Completions backend reached over HTTP. */
11
+ /** An OpenAI HTTP backend supporting Chat Completions and native Responses. */
12
12
  export class OpenAiBackend {
13
13
  #baseUrl;
14
14
  #apiKey;
@@ -76,6 +76,23 @@ export class OpenAiBackend {
76
76
  ...(signal ? { signal } : {})
77
77
  });
78
78
  }
79
+ supportsResponses() {
80
+ return true;
81
+ }
82
+ responses(body, signal, options = {}) {
83
+ const routed = this.#forceModel !== undefined &&
84
+ typeof body === "object" &&
85
+ body !== null &&
86
+ !Array.isArray(body)
87
+ ? { ...body, model: this.#forceModel }
88
+ : body;
89
+ return fetch(joinPath(this.#baseUrl, "/responses"), {
90
+ method: "POST",
91
+ headers: this.#headers(options),
92
+ body: JSON.stringify(withoutRouteKitExtensions(routed)),
93
+ ...(signal ? { signal } : {})
94
+ });
95
+ }
79
96
  #openRouterReasoning(body, selection) {
80
97
  const payload = { ...body };
81
98
  delete payload.reasoning_effort;
@@ -154,6 +171,24 @@ export class ModelRoutedBackend {
154
171
  : undefined;
155
172
  return this.#backendFor(model).chat(body, signal, options);
156
173
  }
174
+ supportsResponses(model) {
175
+ const backend = this.#backendFor(model);
176
+ const delegatedModel = backend.resolveModel?.(model) ?? backend.defaultModel ?? model;
177
+ return (backend.responses !== undefined &&
178
+ (backend.supportsResponses?.(delegatedModel) ?? true));
179
+ }
180
+ responses(body, signal, options = {}) {
181
+ const model = typeof body === "object" &&
182
+ body !== null &&
183
+ typeof body.model === "string"
184
+ ? body.model
185
+ : undefined;
186
+ const backend = this.#backendFor(model);
187
+ if (backend.responses === undefined) {
188
+ return Promise.resolve(Response.json({ error: { type: "not_supported", message: "native Responses egress is not supported" } }, { status: 501 }));
189
+ }
190
+ return backend.responses(body, signal, options);
191
+ }
157
192
  models(signal) {
158
193
  return this.#primary.models(signal);
159
194
  }
@@ -0,0 +1,6 @@
1
+ import type { ServerResponse } from "node:http";
2
+ /**
3
+ * Wait for backpressure to clear or the client to disconnect, removing the
4
+ * listener for whichever event loses the race.
5
+ */
6
+ export declare function waitForDrainOrClose(res: ServerResponse): Promise<"drain" | "close">;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Wait for backpressure to clear or the client to disconnect, removing the
3
+ * listener for whichever event loses the race.
4
+ */
5
+ export function waitForDrainOrClose(res) {
6
+ if (res.destroyed)
7
+ return Promise.resolve("close");
8
+ return new Promise((resolve) => {
9
+ const settle = (event) => {
10
+ res.off("drain", onDrain);
11
+ res.off("close", onClose);
12
+ resolve(event);
13
+ };
14
+ const onDrain = () => settle("drain");
15
+ const onClose = () => settle("close");
16
+ res.once("drain", onDrain);
17
+ res.once("close", onClose);
18
+ });
19
+ }
@@ -16,6 +16,8 @@ export type ProviderSource = {
16
16
  readonly sourceId: ProviderId;
17
17
  discoverModels(signal?: AbortSignal): Promise<readonly DiscoveredModel[]>;
18
18
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
19
+ supportsResponses?(model: string): boolean;
20
+ responses?(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
19
21
  embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
20
22
  capabilities?(model: string): Readonly<Record<string, string>>;
21
23
  reasoningCapabilities?(model: string): ModelReasoningCapabilities | undefined;
@@ -35,6 +37,8 @@ export declare class ApiProviderSource implements ProviderSource {
35
37
  constructor(options: ApiProviderSourceOptions);
36
38
  discoverModels(signal?: AbortSignal): Promise<readonly DiscoveredModel[]>;
37
39
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
40
+ supportsResponses(model: string): boolean;
41
+ responses(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
38
42
  embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
39
43
  close(): Promise<void> | void;
40
44
  }
@@ -342,6 +342,17 @@ export class ApiProviderSource {
342
342
  chat(body, signal, options) {
343
343
  return this.#backend.chat(body, signal, options);
344
344
  }
345
+ supportsResponses(model) {
346
+ return (this.sourceId === "openai" &&
347
+ this.#backend.responses !== undefined &&
348
+ (this.#backend.supportsResponses?.(model) ?? true));
349
+ }
350
+ responses(body, signal, options) {
351
+ if (this.#backend.responses === undefined) {
352
+ return Promise.resolve(Response.json({ error: { type: "not_supported", message: "native Responses egress is not supported" } }, { status: 501 }));
353
+ }
354
+ return this.#backend.responses(body, signal, options);
355
+ }
345
356
  embeddings(body, signal, options) {
346
357
  return this.#backend.embeddings(body, signal, options);
347
358
  }
package/dist/router.d.ts CHANGED
@@ -208,7 +208,9 @@ export declare class CatalogBackend implements Backend {
208
208
  capabilities(model: string): Readonly<Record<string, string>>;
209
209
  reasoningCapabilities(model: string): ModelReasoningCapabilities | undefined;
210
210
  reasoningWireShape(model: string): string | undefined;
211
+ supportsResponses(model: string): boolean;
211
212
  chat(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
213
+ responses(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
212
214
  models(): Promise<Response>;
213
215
  embeddings(body: unknown, signal?: AbortSignal, options?: BackendRequestOptions): Promise<Response>;
214
216
  close(): Promise<void>;
package/dist/router.js CHANGED
@@ -280,6 +280,16 @@ export function modelPolicyAllowsModel(policy, canonicalModel) {
280
280
  * verified; provider discovery and explicit config always take precedence.
281
281
  */
282
282
  export function inferKnownReasoningCapabilities(provider, model) {
283
+ if (provider === "openai" &&
284
+ /^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
285
+ return {
286
+ status: "supported",
287
+ efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
288
+ defaultEffort: "medium",
289
+ wireShape: "openai-responses",
290
+ provenance: "builtin"
291
+ };
292
+ }
283
293
  if (provider === "openai" && /^gpt-5\.5(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
284
294
  return {
285
295
  status: "supported",
@@ -486,6 +496,12 @@ export class CatalogBackend {
486
496
  // reasoning controls for a particular model.
487
497
  return entry.provider === "codex" ? "openai-responses" : entry.reasoning?.wireShape;
488
498
  }
499
+ supportsResponses(model) {
500
+ const entry = this.#entries.get(model);
501
+ if (entry === undefined || entry.source.responses === undefined)
502
+ return false;
503
+ return entry.source.supportsResponses?.(entry.nativeId) ?? true;
504
+ }
489
505
  chat(body, signal, options) {
490
506
  const entry = this.#entry(this.#requestedModel(body));
491
507
  const validationError = routeKitRequestValidationErrorOf(body);
@@ -538,6 +554,80 @@ export class CatalogBackend {
538
554
  ...(entry.reasoning !== undefined ? { reasoningCapabilities: entry.reasoning } : {})
539
555
  });
540
556
  }
557
+ responses(body, signal, options) {
558
+ const entry = this.#entry(this.#requestedModel(body));
559
+ if (entry.source.responses === undefined ||
560
+ entry.source.supportsResponses?.(entry.nativeId) === false) {
561
+ return Promise.resolve(Response.json({ error: { type: "not_supported", message: "native Responses egress is not supported" } }, { status: 501 }));
562
+ }
563
+ const validationError = routeKitRequestValidationErrorOf(body);
564
+ if (validationError !== undefined) {
565
+ return Promise.resolve(Response.json({
566
+ error: {
567
+ type: "invalid_request_error",
568
+ code: validationError.code,
569
+ param: validationError.path,
570
+ message: validationError.message
571
+ }
572
+ }, { status: 400 }));
573
+ }
574
+ const record = typeof body === "object" && body !== null && !Array.isArray(body)
575
+ ? body
576
+ : {};
577
+ const nativeReasoning = typeof record.reasoning === "object" &&
578
+ record.reasoning !== null &&
579
+ !Array.isArray(record.reasoning)
580
+ ? record.reasoning
581
+ : undefined;
582
+ const nativeEffort = typeof nativeReasoning?.effort === "string" && nativeReasoning.effort.length > 0
583
+ ? nativeReasoning.effort
584
+ : undefined;
585
+ const envelopeSelection = reasoningSelectionOf(body);
586
+ if (nativeEffort !== undefined &&
587
+ envelopeSelection.mode !== "auto" &&
588
+ (envelopeSelection.mode !== "effort" || envelopeSelection.effort !== nativeEffort)) {
589
+ return Promise.resolve(Response.json({
590
+ error: {
591
+ type: "invalid_request_error",
592
+ code: "invalid_reasoning_control",
593
+ param: "reasoning",
594
+ message: "reasoning.effort conflicts with x_routekit.selection"
595
+ }
596
+ }, { status: 400 }));
597
+ }
598
+ const requestedSelection = envelopeSelection.mode === "auto" && nativeEffort !== undefined
599
+ ? { mode: "effort", effort: nativeEffort }
600
+ : envelopeSelection;
601
+ const selection = this.#validatedReasoning(entry, requestedSelection);
602
+ if (typeof selection === "string") {
603
+ return Promise.resolve(Response.json({
604
+ error: {
605
+ type: "invalid_request_error",
606
+ code: "unsupported_reasoning_control",
607
+ message: selection
608
+ }
609
+ }, { status: 400 }));
610
+ }
611
+ const nativeBody = this.#withNativeModel(record, entry.nativeId);
612
+ if (selection.mode === "effort") {
613
+ nativeBody.reasoning = { ...nativeReasoning, effort: selection.effort };
614
+ }
615
+ else if (selection.mode === "disabled") {
616
+ nativeBody.reasoning = { ...nativeReasoning, effort: "none" };
617
+ }
618
+ options?.onAttribution?.({
619
+ effective_model: entry.publicId,
620
+ native_model: entry.nativeId,
621
+ provider: entry.provider,
622
+ billing_mode: isSubscriptionProvider(entry.provider) || entry.provider === "cliproxy"
623
+ ? "subscription"
624
+ : "api_key"
625
+ });
626
+ return entry.source.responses(nativeBody, signal, {
627
+ ...options,
628
+ ...(entry.reasoning !== undefined ? { reasoningCapabilities: entry.reasoning } : {})
629
+ });
630
+ }
541
631
  models() {
542
632
  const data = [...this.#entries.values()].map((entry) => ({
543
633
  id: entry.publicId,
package/dist/server.js CHANGED
@@ -1,4 +1,3 @@
1
- import { once } from "node:events";
2
1
  import { createServer } from "node:http";
3
2
  import { ProviderFailureError } from "@velum-labs/routekit-contracts";
4
3
  import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens, resolveClaudeModelAlias } from "./adapters/anthropic.js";
@@ -10,6 +9,7 @@ import { handleResponses } from "./adapters/responses.js";
10
9
  import { routeKitRequestValidationErrorOf } from "./adapters/openai-chat-wire.js";
11
10
  import { validateAnthropicRequest, validateChatRequest, validateCountTokensRequest, validateResponsesRequest } from "./adapters/validate.js";
12
11
  import { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId } from "./provenance.js";
12
+ import { waitForDrainOrClose } from "./http-response.js";
13
13
  import { NoModelAvailableError, UnknownModelError } from "./router.js";
14
14
  function codexModelInfo(id, priority, reasoning) {
15
15
  const levels = (reasoning?.efforts ?? []).map((effort) => ({
@@ -952,7 +952,7 @@ async function pipeUpstream(res, upstream, collectBody = false, signal) {
952
952
  collectedBytes += chunk.length;
953
953
  }
954
954
  if (!res.write(chunk)) {
955
- await Promise.race([once(res, "drain"), once(res, "close")]);
955
+ await waitForDrainOrClose(res);
956
956
  }
957
957
  }
958
958
  }
@@ -10,6 +10,7 @@
10
10
  import { createServer } from "node:http";
11
11
  import { assertAuthenticatedBind, trimTrailingSlashes } from "@velum-labs/routekit-runtime";
12
12
  import { ROUTEKIT_PRINCIPAL_HEADER, authorizedRequest, resolvePrincipal } from "./auth.js";
13
+ import { waitForDrainOrClose } from "./http-response.js";
13
14
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
14
15
  const HOP_BY_HOP = new Set([
15
16
  "connection",
@@ -85,10 +86,7 @@ async function pipe(res, upstream) {
85
86
  if (done)
86
87
  break;
87
88
  if (value !== undefined && !res.write(Buffer.from(value))) {
88
- await Promise.race([
89
- new Promise((resolve) => res.once("drain", resolve)),
90
- new Promise((resolve) => res.once("close", resolve))
91
- ]);
89
+ await waitForDrainOrClose(res);
92
90
  if (res.destroyed)
93
91
  break;
94
92
  }
@@ -59,6 +59,15 @@ async function startMock() {
59
59
  close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
60
60
  };
61
61
  }
62
+ function chatOnlyOpenAiBackend(baseUrl, defaultModel) {
63
+ const backend = new OpenAiBackend({ baseUrl, defaultModel });
64
+ return {
65
+ defaultModel,
66
+ chat: (body, signal, options) => backend.chat(body, signal, options),
67
+ models: (signal) => backend.models(signal),
68
+ embeddings: (body, signal) => backend.embeddings(body, signal)
69
+ };
70
+ }
62
71
  test("responsesToChat maps instructions, input items, and function output", () => {
63
72
  const chat = responsesToChat({
64
73
  model: "gpt-x",
@@ -291,12 +300,204 @@ test("Responses accepts identical native and envelope effort controls", () => {
291
300
  }, "model");
292
301
  assert.deepEqual(reasoningSelectionOf(chat), { mode: "effort", effort: "high" });
293
302
  });
303
+ test("GPT-5.6 API routes Responses tools and reasoning without Chat translation", async () => {
304
+ let upstreamPath;
305
+ let upstreamBody;
306
+ let upstreamModelCallId;
307
+ const upstream = createServer((req, res) => {
308
+ void (async () => {
309
+ upstreamPath = req.url;
310
+ upstreamModelCallId =
311
+ typeof req.headers[MODEL_CALL_ID_HEADER] === "string"
312
+ ? req.headers[MODEL_CALL_ID_HEADER]
313
+ : undefined;
314
+ upstreamBody = JSON.parse((await readAll(req)).toString("utf8"));
315
+ sendJson(res, 200, {
316
+ id: "resp_native",
317
+ object: "response",
318
+ status: "completed",
319
+ model: upstreamBody.model,
320
+ output: [
321
+ {
322
+ id: "msg_native",
323
+ type: "message",
324
+ role: "assistant",
325
+ status: "completed",
326
+ content: [{ type: "output_text", text: "NATIVE_OK", annotations: [] }]
327
+ }
328
+ ],
329
+ usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 }
330
+ });
331
+ })();
332
+ });
333
+ await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve));
334
+ const address = upstream.address();
335
+ const port = typeof address === "object" && address !== null ? address.port : 0;
336
+ const openai = new OpenAiBackend({
337
+ baseUrl: `http://127.0.0.1:${port}/v1`,
338
+ apiKey: "test-key"
339
+ });
340
+ const backend = await CatalogBackend.create({
341
+ config: {
342
+ providers: { openai: {} },
343
+ defaultModel: "openai/gpt-5.6-sol"
344
+ },
345
+ sources: {
346
+ openai: {
347
+ sourceId: "openai",
348
+ async discoverModels() {
349
+ return [{ id: "gpt-5.6-sol" }];
350
+ },
351
+ chat: () => {
352
+ throw new Error("Chat Completions must not be used for GPT-5.6 Responses");
353
+ },
354
+ supportsResponses: () => openai.supportsResponses(),
355
+ responses: (body, signal, options) => openai.responses(body, signal, options),
356
+ embeddings: async () => Response.json({})
357
+ }
358
+ }
359
+ });
360
+ const gateway = await startGateway({ backend });
361
+ try {
362
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
363
+ method: "POST",
364
+ headers: { "content-type": "application/json" },
365
+ body: JSON.stringify({
366
+ model: "openai/gpt-5.6-sol",
367
+ input: [
368
+ {
369
+ role: "user",
370
+ content: [{ type: "input_text", text: "Use the tool." }]
371
+ }
372
+ ],
373
+ reasoning: { effort: "high" },
374
+ tools: [
375
+ {
376
+ type: "function",
377
+ name: "lookup",
378
+ description: "Look up a value",
379
+ parameters: {
380
+ type: "object",
381
+ properties: { id: { type: "string" } },
382
+ required: ["id"],
383
+ additionalProperties: false
384
+ },
385
+ strict: true
386
+ }
387
+ ],
388
+ x_routekit: {
389
+ version: 1,
390
+ selection: { mode: "effort", effort: "high" }
391
+ }
392
+ })
393
+ });
394
+ assert.equal(response.status, 200);
395
+ assert.equal(upstreamPath, "/v1/responses");
396
+ assert.equal(upstreamBody?.model, "gpt-5.6-sol");
397
+ assert.deepEqual(upstreamBody?.reasoning, { effort: "high" });
398
+ assert.equal(upstreamBody?.tools?.length, 1);
399
+ assert.equal(Object.hasOwn(upstreamBody ?? {}, "x_routekit"), false);
400
+ assert.ok(upstreamModelCallId);
401
+ assert.equal((await response.json())
402
+ .output?.[0]?.content?.[0]?.text, "NATIVE_OK");
403
+ assert.deepEqual(backend.modelInfo("openai/gpt-5.6-sol")?.reasoning, {
404
+ status: "supported",
405
+ efforts: ["none", "low", "medium", "high", "xhigh", "max"].map((id) => ({ id })),
406
+ defaultEffort: "medium",
407
+ wireShape: "openai-responses",
408
+ provenance: "builtin"
409
+ });
410
+ }
411
+ finally {
412
+ await gateway.close();
413
+ await new Promise((resolve, reject) => upstream.close((error) => (error ? reject(error) : resolve())));
414
+ }
415
+ });
416
+ test("OpenAI API keeps non-reasoning models on native Responses", async () => {
417
+ let upstreamPath;
418
+ let upstreamBody;
419
+ const upstream = createServer((req, res) => {
420
+ void (async () => {
421
+ upstreamPath = req.url;
422
+ upstreamBody = JSON.parse((await readAll(req)).toString("utf8"));
423
+ sendJson(res, 200, {
424
+ id: "resp_general",
425
+ object: "response",
426
+ status: "completed",
427
+ model: upstreamBody.model,
428
+ output: []
429
+ });
430
+ })();
431
+ });
432
+ await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve));
433
+ const address = upstream.address();
434
+ const port = typeof address === "object" && address !== null ? address.port : 0;
435
+ const openai = new OpenAiBackend({
436
+ baseUrl: `http://127.0.0.1:${port}/v1`,
437
+ apiKey: "test-key"
438
+ });
439
+ const backend = await CatalogBackend.create({
440
+ config: {
441
+ providers: { openai: {} },
442
+ defaultModel: "openai/gpt-4.1"
443
+ },
444
+ sources: {
445
+ openai: {
446
+ sourceId: "openai",
447
+ async discoverModels() {
448
+ return [{ id: "gpt-4.1" }];
449
+ },
450
+ chat: () => {
451
+ throw new Error("Chat Completions must not be used for OpenAI Responses");
452
+ },
453
+ supportsResponses: () => openai.supportsResponses(),
454
+ responses: (body, signal, options) => openai.responses(body, signal, options),
455
+ embeddings: async () => Response.json({})
456
+ }
457
+ }
458
+ });
459
+ const gateway = await startGateway({ backend });
460
+ try {
461
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
462
+ method: "POST",
463
+ headers: { "content-type": "application/json" },
464
+ body: JSON.stringify({
465
+ model: "openai/gpt-4.1",
466
+ input: "Use the tool.",
467
+ previous_response_id: "resp_previous",
468
+ tools: [
469
+ {
470
+ type: "function",
471
+ name: "lookup",
472
+ description: "Look up a value",
473
+ parameters: {
474
+ type: "object",
475
+ properties: { id: { type: "string" } },
476
+ required: ["id"],
477
+ additionalProperties: false
478
+ },
479
+ strict: true
480
+ }
481
+ ]
482
+ })
483
+ });
484
+ assert.equal(response.status, 200);
485
+ assert.equal(upstreamPath, "/v1/responses");
486
+ assert.equal(upstreamBody?.model, "gpt-4.1");
487
+ assert.equal(upstreamBody?.previous_response_id, "resp_previous");
488
+ assert.equal(upstreamBody?.tools?.length, 1);
489
+ }
490
+ finally {
491
+ await gateway.close();
492
+ await new Promise((resolve, reject) => upstream.close((error) => (error ? reject(error) : resolve())));
493
+ }
494
+ });
294
495
  test("serves a Responses request carrying reasoning: null end to end", async () => {
295
496
  // The member capture gateway path: codex exec -> /v1/responses with
296
497
  // `reasoning: null` -> chat completion upstream. Must be a 200, never a 502.
297
498
  const mock = await startMock();
298
499
  const gateway = await startGateway({
299
- backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "grok-4" })
500
+ backend: chatOnlyOpenAiBackend(`${mock.url}/v1`, "grok-4")
300
501
  });
301
502
  try {
302
503
  const response = await fetch(`${gateway.url()}/v1/responses`, {
@@ -405,7 +606,7 @@ test("Responses routes discovered Claude efforts to adaptive Anthropic egress",
405
606
  test("serves a Responses request with null optional fields end to end", async () => {
406
607
  const mock = await startMock();
407
608
  const gateway = await startGateway({
408
- backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
609
+ backend: chatOnlyOpenAiBackend(`${mock.url}/v1`, "local-model")
409
610
  });
410
611
  try {
411
612
  const response = await fetch(`${gateway.url()}/v1/responses`, {
@@ -435,7 +636,7 @@ test("serves a Responses request with null optional fields end to end", async ()
435
636
  test("serves a non-streaming Responses object end to end", async () => {
436
637
  const mock = await startMock();
437
638
  const gateway = await startGateway({
438
- backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
639
+ backend: chatOnlyOpenAiBackend(`${mock.url}/v1`, "local-model")
439
640
  });
440
641
  try {
441
642
  const response = await fetch(`${gateway.url()}/v1/responses`, {
@@ -1226,7 +1427,7 @@ test("a mid-stream provider error event becomes response.failed with the upstrea
1226
1427
  test("translates a streamed Responses event sequence", async () => {
1227
1428
  const mock = await startMock();
1228
1429
  const gateway = await startGateway({
1229
- backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
1430
+ backend: chatOnlyOpenAiBackend(`${mock.url}/v1`, "local-model")
1230
1431
  });
1231
1432
  try {
1232
1433
  const response = await fetch(`${gateway.url()}/v1/responses`, {
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { startGateway } from "../server.js";
4
+ import { startSwitchingGatewayProxy } from "../switching-proxy.js";
4
5
  /**
5
6
  * Crash resilience: an upstream stream that dies mid-response (the shape of a
6
7
  * local model server being OOM-killed during a turn) must fail only that one
@@ -167,3 +168,53 @@ test("oversized request bodies are rejected before the backend is called", async
167
168
  await gateway.close();
168
169
  }
169
170
  });
171
+ test("stream backpressure does not retain close listeners", async () => {
172
+ const warnings = [];
173
+ const onWarning = (warning) => {
174
+ if (warning.name === "MaxListenersExceededWarning" &&
175
+ warning.message.includes("ServerResponse")) {
176
+ warnings.push(warning);
177
+ }
178
+ };
179
+ process.on("warning", onWarning);
180
+ const chunk = Buffer.alloc(128 * 1024, "x");
181
+ const chunkCount = 24;
182
+ let emitted = 0;
183
+ const backend = {
184
+ defaultModel: "mock-model",
185
+ chat: async () => new Response(new ReadableStream({
186
+ pull(controller) {
187
+ if (emitted === chunkCount) {
188
+ controller.close();
189
+ return;
190
+ }
191
+ emitted += 1;
192
+ controller.enqueue(chunk);
193
+ }
194
+ }), { status: 200, headers: { "content-type": "application/octet-stream" } }),
195
+ models: async () => Response.json({ data: [] }),
196
+ embeddings: async () => Response.json({})
197
+ };
198
+ const gateway = await startGateway({ backend });
199
+ const proxy = await startSwitchingGatewayProxy({ target: gateway.url() });
200
+ try {
201
+ const response = await fetch(`${proxy.url()}/v1/chat/completions`, {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({
205
+ model: "mock-model",
206
+ stream: true,
207
+ messages: [{ role: "user", content: "hi" }]
208
+ })
209
+ });
210
+ assert.equal(response.status, 200);
211
+ assert.equal((await response.arrayBuffer()).byteLength, chunk.length * chunkCount);
212
+ await new Promise((resolve) => setImmediate(resolve));
213
+ assert.deepEqual(warnings, []);
214
+ }
215
+ finally {
216
+ process.off("warning", onWarning);
217
+ await proxy.close();
218
+ await gateway.close();
219
+ }
220
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.15.0",
4
+ "version": "0.16.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.15.0",
33
- "@velum-labs/routekit-registry": "0.15.0",
34
- "@velum-labs/routekit-runtime": "0.15.0",
35
- "@velum-labs/routekit-tracing": "0.15.0"
32
+ "@velum-labs/routekit-contracts": "0.16.0",
33
+ "@velum-labs/routekit-registry": "0.16.0",
34
+ "@velum-labs/routekit-runtime": "0.16.0",
35
+ "@velum-labs/routekit-tracing": "0.16.0"
36
36
  },
37
37
  "keywords": [
38
38
  "routekit",