@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.
- package/dist/adapters/responses.js +15 -4
- package/dist/backend.d.ts +9 -1
- package/dist/backend.js +36 -1
- package/dist/http-response.d.ts +6 -0
- package/dist/http-response.js +19 -0
- package/dist/provider-source.d.ts +4 -0
- package/dist/provider-source.js +11 -0
- package/dist/router.d.ts +2 -0
- package/dist/router.js +90 -0
- package/dist/server.js +2 -2
- package/dist/switching-proxy.js +2 -4
- package/dist/test/responses.test.js +205 -4
- package/dist/test/server-resilience.test.js +51 -0
- package/package.json +5 -5
|
@@ -825,13 +825,17 @@ export async function handleResponses(backend, body, modelCallId, signal, backen
|
|
|
825
825
|
}
|
|
826
826
|
});
|
|
827
827
|
}
|
|
828
|
-
|
|
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
|
|
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
|
|
844
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/provider-source.js
CHANGED
|
@@ -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
|
|
955
|
+
await waitForDrainOrClose(res);
|
|
956
956
|
}
|
|
957
957
|
}
|
|
958
958
|
}
|
package/dist/switching-proxy.js
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
33
|
-
"@velum-labs/routekit-registry": "0.
|
|
34
|
-
"@velum-labs/routekit-runtime": "0.
|
|
35
|
-
"@velum-labs/routekit-tracing": "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",
|