@velum-labs/routekit-gateway 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/openai-responses-wire.d.ts +3 -1
- package/dist/adapters/openai-responses-wire.js +73 -4
- package/dist/adapters/responses-codec.js +11 -2
- package/dist/adapters/responses-stream.js +27 -9
- package/dist/observability/provenance.js +53 -13
- package/dist/switching-proxy.d.ts +2 -0
- package/dist/switching-proxy.js +5 -2
- package/dist/test/openai-responses-wire.test.js +54 -0
- package/dist/test/provenance.test.js +62 -0
- package/dist/test/responses-streaming-catalog.test.js +32 -1
- package/dist/test/switching-proxy-retire.test.js +29 -0
- package/package.json +7 -7
|
@@ -35,6 +35,8 @@ export declare function prepareResponsesReasoningInput<Body>(body: Body, policy:
|
|
|
35
35
|
* Add ownership to encrypted reasoning emitted by a successful native
|
|
36
36
|
* Responses call. Provider failures and unrelated response formats pass through.
|
|
37
37
|
*/
|
|
38
|
-
export declare function wrapResponsesReasoningResponse(response: Response, owner: ResponsesReasoningOwner
|
|
38
|
+
export declare function wrapResponsesReasoningResponse(response: Response, owner: ResponsesReasoningOwner, options?: {
|
|
39
|
+
keepaliveMs?: number;
|
|
40
|
+
}): Effect.Effect<Response, Error>;
|
|
39
41
|
/** Normalize OpenAI Responses call ids without mutating the caller's request. */
|
|
40
42
|
export declare function normalizeOpenAiResponsesCallIds(body: unknown): unknown;
|
|
@@ -5,6 +5,13 @@ import { gatewayTryPromise } from "../effect/gateway.js";
|
|
|
5
5
|
const OPENAI_RESPONSES_CALL_ID_MAX_LENGTH = 64;
|
|
6
6
|
const NORMALIZED_CALL_ID_PREFIX = "rk_";
|
|
7
7
|
const ENCRYPTED_REASONING_PREFIX = "rk1.";
|
|
8
|
+
const RESPONSES_KEEPALIVE_MS = 15_000;
|
|
9
|
+
const TERMINAL_RESPONSES_EVENTS = new Set([
|
|
10
|
+
"response.completed",
|
|
11
|
+
"response.failed",
|
|
12
|
+
"response.incomplete",
|
|
13
|
+
"error"
|
|
14
|
+
]);
|
|
8
15
|
function sameOwner(left, right) {
|
|
9
16
|
return left.provider === right.provider && left.nativeModel === right.nativeModel;
|
|
10
17
|
}
|
|
@@ -186,20 +193,82 @@ function wrapSseFrame(frame, delimiter, owner) {
|
|
|
186
193
|
});
|
|
187
194
|
return `${rewritten.join(lineEnding)}${delimiter}`;
|
|
188
195
|
}
|
|
189
|
-
function
|
|
196
|
+
function responsesSseEventType(frame) {
|
|
197
|
+
let eventType;
|
|
198
|
+
const data = [];
|
|
199
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
200
|
+
if (line.startsWith("event:")) {
|
|
201
|
+
eventType = line.slice("event:".length).trim();
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const value = sseDataValue(line);
|
|
205
|
+
if (value !== undefined)
|
|
206
|
+
data.push(value);
|
|
207
|
+
}
|
|
208
|
+
if (eventType !== undefined && eventType.length > 0)
|
|
209
|
+
return eventType;
|
|
210
|
+
if (data.length === 0 || data.join("\n") === "[DONE]")
|
|
211
|
+
return undefined;
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(data.join("\n"));
|
|
214
|
+
return typeof parsed.type === "string" ? parsed.type : undefined;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function responsesStreamFailure(status, lastEvent, started, cause) {
|
|
221
|
+
const durationMs = Date.now() - started;
|
|
222
|
+
const detail = cause instanceof Error
|
|
223
|
+
? `; cause=${cause.name}: ${cause.message.slice(0, 300)}`
|
|
224
|
+
: cause === undefined
|
|
225
|
+
? ""
|
|
226
|
+
: `; cause=${String(cause).slice(0, 300)}`;
|
|
227
|
+
const message = `RouteKit upstream Responses stream ended before a terminal event ` +
|
|
228
|
+
`(status=${status}, last_event=${lastEvent ?? "none"}, duration_ms=${durationMs})${detail}`;
|
|
229
|
+
return new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({
|
|
230
|
+
type: "error",
|
|
231
|
+
error: {
|
|
232
|
+
type: "upstream_stream_closed",
|
|
233
|
+
code: "upstream_stream_closed",
|
|
234
|
+
message
|
|
235
|
+
}
|
|
236
|
+
})}\n\n`);
|
|
237
|
+
}
|
|
238
|
+
function wrapResponsesReasoningSse(source, owner, status, keepaliveMs) {
|
|
190
239
|
const encoder = new TextEncoder();
|
|
240
|
+
const started = Date.now();
|
|
241
|
+
let lastEvent;
|
|
242
|
+
let terminal = false;
|
|
191
243
|
return StreamPump.frames(source, {
|
|
244
|
+
keepaliveMs,
|
|
192
245
|
onFrame(frame, delimiter, controller) {
|
|
246
|
+
const eventType = responsesSseEventType(frame);
|
|
247
|
+
if (eventType !== undefined) {
|
|
248
|
+
lastEvent = eventType;
|
|
249
|
+
terminal ||= TERMINAL_RESPONSES_EVENTS.has(eventType);
|
|
250
|
+
}
|
|
193
251
|
controller.enqueue(encoder.encode(wrapSseFrame(frame, delimiter, owner)));
|
|
194
252
|
},
|
|
195
|
-
onEnd() {
|
|
253
|
+
onEnd(controller) {
|
|
254
|
+
if (!terminal)
|
|
255
|
+
controller.enqueue(responsesStreamFailure(status, lastEvent, started));
|
|
256
|
+
},
|
|
257
|
+
keepalive(controller) {
|
|
258
|
+
if (!terminal)
|
|
259
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
260
|
+
},
|
|
261
|
+
onError(error, controller) {
|
|
262
|
+
if (!terminal)
|
|
263
|
+
controller.enqueue(responsesStreamFailure(status, lastEvent, started, error));
|
|
264
|
+
}
|
|
196
265
|
});
|
|
197
266
|
}
|
|
198
267
|
/**
|
|
199
268
|
* Add ownership to encrypted reasoning emitted by a successful native
|
|
200
269
|
* Responses call. Provider failures and unrelated response formats pass through.
|
|
201
270
|
*/
|
|
202
|
-
export function wrapResponsesReasoningResponse(response, owner) {
|
|
271
|
+
export function wrapResponsesReasoningResponse(response, owner, options = {}) {
|
|
203
272
|
if (!response.ok || response.body === null)
|
|
204
273
|
return Effect.succeed(response);
|
|
205
274
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
@@ -207,7 +276,7 @@ export function wrapResponsesReasoningResponse(response, owner) {
|
|
|
207
276
|
const headers = new Headers(response.headers);
|
|
208
277
|
headers.delete("content-length");
|
|
209
278
|
headers.delete("content-encoding");
|
|
210
|
-
return Effect.succeed(new Response(wrapResponsesReasoningSse(response.body, owner), {
|
|
279
|
+
return Effect.succeed(new Response(wrapResponsesReasoningSse(response.body, owner, response.status, options.keepaliveMs ?? RESPONSES_KEEPALIVE_MS), {
|
|
211
280
|
status: response.status,
|
|
212
281
|
statusText: response.statusText,
|
|
213
282
|
headers
|
|
@@ -740,17 +740,26 @@ function buildOutput(message, toolRegistry) {
|
|
|
740
740
|
return output;
|
|
741
741
|
}
|
|
742
742
|
export function chatToResponses(openai, model, toolRegistry = EMPTY_TOOL_REGISTRY, searches = []) {
|
|
743
|
-
const
|
|
743
|
+
const choice = openai.choices?.[0];
|
|
744
|
+
const message = choice?.message;
|
|
745
|
+
const incompleteReason = choice?.finish_reason === "length" ||
|
|
746
|
+
choice?.finish_reason === "max_tokens" ||
|
|
747
|
+
choice?.finish_reason === "max_output_tokens"
|
|
748
|
+
? "max_output_tokens"
|
|
749
|
+
: choice?.finish_reason === "content_filter"
|
|
750
|
+
? "content_filter"
|
|
751
|
+
: undefined;
|
|
744
752
|
// Gateway-executed searches happened before the terminal step's output.
|
|
745
753
|
const output = [...searches.map(executedSearchItem), ...buildOutput(message, toolRegistry)];
|
|
746
754
|
return {
|
|
747
755
|
id: `resp_${openai.id ?? randomId()}`,
|
|
748
756
|
object: "response",
|
|
749
757
|
created_at: Math.floor(Date.now() / 1000),
|
|
750
|
-
status: "completed",
|
|
758
|
+
status: incompleteReason === undefined ? "completed" : "incomplete",
|
|
751
759
|
model,
|
|
752
760
|
output,
|
|
753
761
|
usage: chatUsageToResponses(openai.usage),
|
|
762
|
+
...(incompleteReason === undefined ? {} : { incomplete_details: { reason: incompleteReason } }),
|
|
754
763
|
...(openai.provider_cost !== undefined ? { provider_cost: openai.provider_cost } : {})
|
|
755
764
|
};
|
|
756
765
|
}
|
|
@@ -123,7 +123,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
123
123
|
let nextOutputIndex = 0;
|
|
124
124
|
let messageOutputIndex = -1;
|
|
125
125
|
let finished = false;
|
|
126
|
-
let
|
|
126
|
+
let finishReason;
|
|
127
127
|
let usage;
|
|
128
128
|
let providerCost;
|
|
129
129
|
let sequenceNumber = 0;
|
|
@@ -136,7 +136,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
136
136
|
// search, completed items collected for the terminal response payload.
|
|
137
137
|
const openSearches = new Map();
|
|
138
138
|
const completedSearchItems = [];
|
|
139
|
-
const baseResponse = (status, output) => ({
|
|
139
|
+
const baseResponse = (status, output, incompleteReason) => ({
|
|
140
140
|
id: responseId,
|
|
141
141
|
object: "response",
|
|
142
142
|
created_at: Math.floor(Date.now() / 1000),
|
|
@@ -144,6 +144,9 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
144
144
|
model,
|
|
145
145
|
output,
|
|
146
146
|
usage: status === "completed" ? chatUsageToResponses(usage) : null,
|
|
147
|
+
...(status === "incomplete" && incompleteReason !== undefined
|
|
148
|
+
? { incomplete_details: { reason: incompleteReason } }
|
|
149
|
+
: {}),
|
|
147
150
|
...(status === "completed" && providerCost !== undefined ? { provider_cost: providerCost } : {})
|
|
148
151
|
});
|
|
149
152
|
const ensureCreated = (controller) => {
|
|
@@ -341,7 +344,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
341
344
|
}
|
|
342
345
|
return indexed.sort((a, b) => a.outputIndex - b.outputIndex).map(({ item }) => item);
|
|
343
346
|
};
|
|
344
|
-
const finalize = (controller, terminal = "completed") => {
|
|
347
|
+
const finalize = (controller, terminal = "completed", incompleteReason) => {
|
|
345
348
|
if (finished)
|
|
346
349
|
return;
|
|
347
350
|
finished = true;
|
|
@@ -410,7 +413,22 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
410
413
|
// as incomplete.
|
|
411
414
|
controller.enqueue(terminal === "completed"
|
|
412
415
|
? emit("response.completed", { response: baseResponse("completed", assembleOutput()) })
|
|
413
|
-
: emit("response.incomplete", {
|
|
416
|
+
: emit("response.incomplete", {
|
|
417
|
+
response: baseResponse("incomplete", assembleOutput(), incompleteReason)
|
|
418
|
+
}));
|
|
419
|
+
};
|
|
420
|
+
const finalizeFromFinishReason = (controller) => {
|
|
421
|
+
if (finishReason === "length" ||
|
|
422
|
+
finishReason === "max_tokens" ||
|
|
423
|
+
finishReason === "max_output_tokens") {
|
|
424
|
+
finalize(controller, "incomplete", "max_output_tokens");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (finishReason === "content_filter") {
|
|
428
|
+
finalize(controller, "incomplete", "content_filter");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
finalize(controller, finishReason === undefined ? "incomplete" : "completed");
|
|
414
432
|
};
|
|
415
433
|
// A mid-stream provider failure (`data: {"error": {...}}` — e.g. the
|
|
416
434
|
// router's classified provider_error) becomes a `response.failed` event
|
|
@@ -590,9 +608,9 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
590
608
|
}
|
|
591
609
|
if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
|
|
592
610
|
// OpenAI can emit token usage/provider cost in a later choices:[] chunk.
|
|
593
|
-
// Record
|
|
594
|
-
//
|
|
595
|
-
|
|
611
|
+
// Record how the turn ended, but wait for [DONE] or EOF before emitting
|
|
612
|
+
// the one terminal Responses event.
|
|
613
|
+
finishReason = choice.finish_reason;
|
|
596
614
|
}
|
|
597
615
|
};
|
|
598
616
|
const handleEvent = (controller, data) => {
|
|
@@ -601,7 +619,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
601
619
|
if (data === "[DONE]") {
|
|
602
620
|
// A `[DONE]` with no prior finish_reason is truncation, not a clean stop.
|
|
603
621
|
if (!finished)
|
|
604
|
-
|
|
622
|
+
finalizeFromFinishReason(controller);
|
|
605
623
|
return;
|
|
606
624
|
}
|
|
607
625
|
let chunk;
|
|
@@ -639,7 +657,7 @@ export function openAiSseToResponses(upstream, model, toolRegistry = new Map())
|
|
|
639
657
|
},
|
|
640
658
|
onEnd(controller) {
|
|
641
659
|
if (!finished)
|
|
642
|
-
|
|
660
|
+
finalizeFromFinishReason(controller);
|
|
643
661
|
}
|
|
644
662
|
});
|
|
645
663
|
}
|
|
@@ -165,19 +165,55 @@ function streamedProviderError(body) {
|
|
|
165
165
|
const response = asRecord(payload?.response);
|
|
166
166
|
return (asRecord(payload?.error) ??
|
|
167
167
|
asRecord(response?.error) ??
|
|
168
|
-
asRecord(response?.incomplete_details)
|
|
168
|
+
asRecord(response?.incomplete_details) ?? {
|
|
169
|
+
type: eventType === "response.incomplete" ? "response_incomplete" : "response_failed"
|
|
170
|
+
});
|
|
169
171
|
}
|
|
170
172
|
return undefined;
|
|
171
173
|
}
|
|
172
|
-
function
|
|
173
|
-
const
|
|
174
|
+
function bufferedProviderError(body) {
|
|
175
|
+
const response = asRecord(parseJson(body));
|
|
176
|
+
if (response?.status === "incomplete") {
|
|
177
|
+
return asRecord(response.incomplete_details) ?? { type: "response_incomplete" };
|
|
178
|
+
}
|
|
179
|
+
if (response?.status === "failed") {
|
|
180
|
+
return asRecord(response.error) ?? { type: "response_failed" };
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function requestedMaximumOutputTokens(body) {
|
|
185
|
+
const request = asRecord(body);
|
|
186
|
+
for (const field of ["max_output_tokens", "max_completion_tokens", "max_tokens"]) {
|
|
187
|
+
const value = request?.[field];
|
|
188
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
function terminalStopReason(context, result, usage) {
|
|
194
|
+
const terminalError = bufferedProviderError(result.responseBody) ?? streamedProviderError(result.responseBody);
|
|
195
|
+
const reason = terminalError?.reason ?? terminalError?.code;
|
|
196
|
+
if (typeof reason === "string" && reason.length > 0)
|
|
197
|
+
return reason;
|
|
198
|
+
const maximumOutputTokens = requestedMaximumOutputTokens(context.requestBody);
|
|
199
|
+
return maximumOutputTokens !== undefined &&
|
|
200
|
+
usage?.completion_tokens !== undefined &&
|
|
201
|
+
usage.completion_tokens >= maximumOutputTokens
|
|
202
|
+
? "max_output_tokens"
|
|
203
|
+
: undefined;
|
|
204
|
+
}
|
|
205
|
+
function providerError(result, stopReason) {
|
|
206
|
+
const terminalError = bufferedProviderError(result.responseBody) ?? streamedProviderError(result.responseBody);
|
|
174
207
|
if (result.error === undefined &&
|
|
175
208
|
result.statusCode >= 200 &&
|
|
176
209
|
result.statusCode < 400 &&
|
|
177
|
-
|
|
210
|
+
terminalError === undefined &&
|
|
211
|
+
stopReason === undefined) {
|
|
178
212
|
return undefined;
|
|
179
213
|
}
|
|
180
|
-
const responseError = asRecord(asRecord(parseJson(result.responseBody))?.error) ??
|
|
214
|
+
const responseError = asRecord(asRecord(parseJson(result.responseBody))?.error) ??
|
|
215
|
+
terminalError ??
|
|
216
|
+
(stopReason === undefined ? undefined : { reason: stopReason });
|
|
181
217
|
const noModelAvailable = result.statusCode === 503 &&
|
|
182
218
|
responseError?.type === "unavailable" &&
|
|
183
219
|
responseError.message === "no model is available; configure a provider";
|
|
@@ -196,13 +232,15 @@ function providerError(result) {
|
|
|
196
232
|
: "provider_error";
|
|
197
233
|
const message = kind === "capability_missing"
|
|
198
234
|
? "no model route is configured"
|
|
199
|
-
:
|
|
200
|
-
? "provider
|
|
201
|
-
: kind === "
|
|
202
|
-
? "provider
|
|
203
|
-
: kind === "
|
|
204
|
-
? "provider
|
|
205
|
-
:
|
|
235
|
+
: responseError?.reason === "max_output_tokens"
|
|
236
|
+
? "provider response reached the maximum output token limit"
|
|
237
|
+
: kind === "timeout"
|
|
238
|
+
? "provider request timed out"
|
|
239
|
+
: kind === "rate_limited"
|
|
240
|
+
? "provider rate limited the request"
|
|
241
|
+
: kind === "validation_error"
|
|
242
|
+
? "provider rejected the request"
|
|
243
|
+
: "provider request failed";
|
|
206
244
|
return {
|
|
207
245
|
kind,
|
|
208
246
|
message,
|
|
@@ -225,13 +263,15 @@ export function buildModelCallRecord(context, result) {
|
|
|
225
263
|
totalTokens: usage.total_tokens
|
|
226
264
|
}
|
|
227
265
|
});
|
|
228
|
-
const
|
|
266
|
+
const stopReason = terminalStopReason(context, result, usage);
|
|
267
|
+
const error = providerError(result, stopReason);
|
|
229
268
|
const metadata = {
|
|
230
269
|
dialect: context.dialect,
|
|
231
270
|
stream: context.stream,
|
|
232
271
|
http_status: result.statusCode,
|
|
233
272
|
duration_ms: result.durationMs,
|
|
234
273
|
requested_model: context.requestedModel ?? null,
|
|
274
|
+
...(stopReason === undefined ? {} : { stop_reason: stopReason }),
|
|
235
275
|
unknown_usage: callCost.unknownUsage,
|
|
236
276
|
unknown_cost: callCost.unknownCost,
|
|
237
277
|
...(context.attribution !== undefined
|
|
@@ -25,6 +25,8 @@ export type SwitchingGatewayProxyOptions = {
|
|
|
25
25
|
host?: string;
|
|
26
26
|
port?: number;
|
|
27
27
|
authToken?: string;
|
|
28
|
+
/** Maximum wait for the active generation to return response headers. */
|
|
29
|
+
upstreamHeadersTimeoutMs?: number;
|
|
28
30
|
/** Resolve a named data-plane principal; preferred over `authToken` alone. */
|
|
29
31
|
resolveDataPrincipal?: (presented: string) => GatewayPrincipal | undefined;
|
|
30
32
|
};
|
package/dist/switching-proxy.js
CHANGED
|
@@ -17,6 +17,7 @@ import * as HttpEffect from "effect/unstable/http/HttpEffect";
|
|
|
17
17
|
import { authorizedRequest, ROUTEKIT_PRINCIPAL_HEADER, resolvePrincipal } from "./http/auth.js";
|
|
18
18
|
import { gatewayTryPromise } from "./effect/gateway.js";
|
|
19
19
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS = 10 * 60 * 1000;
|
|
20
21
|
const HOP_BY_HOP = new Set([
|
|
21
22
|
"connection",
|
|
22
23
|
"keep-alive",
|
|
@@ -221,12 +222,14 @@ const startSwitchingGatewayProxyOperation = Effect.fn("SwitchingGatewayProxy.sta
|
|
|
221
222
|
error: { message: rejection.message, type: "invalid_request_error" }
|
|
222
223
|
});
|
|
223
224
|
}
|
|
225
|
+
const headersTimeout = new AbortController();
|
|
226
|
+
const timeout = setTimeout(() => headersTimeout.abort(new Error(`gateway generation did not return headers within ${input.upstreamHeadersTimeoutMs ?? DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS}ms`)), input.upstreamHeadersTimeoutMs ?? DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS);
|
|
224
227
|
const upstream = yield* executeWebRequest(`${selected.url}${path}`, {
|
|
225
228
|
method: nodeReq.method ?? "GET",
|
|
226
229
|
headers: requestHeaders(nodeReq.headers, principal),
|
|
227
230
|
...(body !== undefined ? { body } : {}),
|
|
228
|
-
signal: AbortSignal.any([aborter.signal,
|
|
229
|
-
});
|
|
231
|
+
signal: AbortSignal.any([aborter.signal, headersTimeout.signal])
|
|
232
|
+
}).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timeout))));
|
|
230
233
|
return HttpEffect.scopeTransferToStream(proxyResponse(upstream, retiring));
|
|
231
234
|
}).pipe(Effect.orElseSucceed(() => jsonResponse(502, {
|
|
232
235
|
error: { message: "router generation unavailable", type: "upstream_error" }
|
|
@@ -229,3 +229,57 @@ test("streaming Responses output wraps reasoning and preserves SSE framing", asy
|
|
|
229
229
|
{ owner: OWNER_A, ciphertext: "terminal" }
|
|
230
230
|
]);
|
|
231
231
|
});
|
|
232
|
+
test("native Responses streams emit keepalives while the provider is silent", async () => {
|
|
233
|
+
let upstream;
|
|
234
|
+
const response = new Response(new ReadableStream({
|
|
235
|
+
start(controller) {
|
|
236
|
+
upstream = controller;
|
|
237
|
+
}
|
|
238
|
+
}), { headers: { "content-type": "text/event-stream" } });
|
|
239
|
+
const wrapped = await Effect.runPromise(wrapResponsesReasoningResponse(response, OWNER_A, { keepaliveMs: 5 }));
|
|
240
|
+
const reader = wrapped.body?.getReader();
|
|
241
|
+
assert.ok(reader !== undefined);
|
|
242
|
+
const first = await reader.read();
|
|
243
|
+
assert.equal(new TextDecoder().decode(first.value), ": keepalive\n\n");
|
|
244
|
+
upstream?.enqueue(new TextEncoder().encode('event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n'));
|
|
245
|
+
upstream?.close();
|
|
246
|
+
const rest = [];
|
|
247
|
+
for (;;) {
|
|
248
|
+
const next = await reader.read();
|
|
249
|
+
if (next.done)
|
|
250
|
+
break;
|
|
251
|
+
rest.push(next.value);
|
|
252
|
+
}
|
|
253
|
+
assert.match(new TextDecoder().decode(Buffer.concat(rest)), /event: response\.completed/);
|
|
254
|
+
});
|
|
255
|
+
test("native Responses EOF surfaces terminal diagnostics instead of a silent close", async () => {
|
|
256
|
+
const response = new Response(new ReadableStream({
|
|
257
|
+
start(controller) {
|
|
258
|
+
controller.enqueue(new TextEncoder().encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hi"}\n\n'));
|
|
259
|
+
controller.close();
|
|
260
|
+
}
|
|
261
|
+
}), { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
262
|
+
const wrapped = await Effect.runPromise(wrapResponsesReasoningResponse(response, OWNER_A));
|
|
263
|
+
const text = await wrapped.text();
|
|
264
|
+
assert.match(text, /event: error/);
|
|
265
|
+
assert.match(text, /upstream_stream_closed/);
|
|
266
|
+
assert.match(text, /status=200/);
|
|
267
|
+
assert.match(text, /last_event=response\.output_text\.delta/);
|
|
268
|
+
assert.match(text, /duration_ms=\d+/);
|
|
269
|
+
});
|
|
270
|
+
test("native Responses read errors become a terminal SSE error event", async () => {
|
|
271
|
+
let upstream;
|
|
272
|
+
const response = new Response(new ReadableStream({
|
|
273
|
+
start(controller) {
|
|
274
|
+
upstream = controller;
|
|
275
|
+
controller.enqueue(new TextEncoder().encode('event: response.created\ndata: {"type":"response.created"}\n\n'));
|
|
276
|
+
}
|
|
277
|
+
}), { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
278
|
+
const wrapped = await Effect.runPromise(wrapResponsesReasoningResponse(response, OWNER_A));
|
|
279
|
+
const reading = wrapped.text();
|
|
280
|
+
upstream?.error(new Error("simulated socket reset"));
|
|
281
|
+
const text = await reading;
|
|
282
|
+
assert.match(text, /event: error/);
|
|
283
|
+
assert.match(text, /last_event=response\.created/);
|
|
284
|
+
assert.match(text, /simulated socket reset/);
|
|
285
|
+
});
|
|
@@ -261,3 +261,65 @@ test("HTTP 200 Codex terminal SSE quota failure is rate-limited provenance", ()
|
|
|
261
261
|
assert.equal(record.error?.kind, "rate_limited");
|
|
262
262
|
assert.equal(record.error?.retryable, true);
|
|
263
263
|
});
|
|
264
|
+
test("HTTP 200 incomplete Responses output is failed provenance", () => {
|
|
265
|
+
const record = buildModelCallRecord({
|
|
266
|
+
callId: "call_response_incomplete",
|
|
267
|
+
dialect: "openai-responses",
|
|
268
|
+
requestedModel: "claude-code/claude-opus-5",
|
|
269
|
+
model: "claude-code/claude-opus-5",
|
|
270
|
+
stream: false,
|
|
271
|
+
requestBody: { input: "author twenty evaluation cases" },
|
|
272
|
+
startedAt: "2026-08-20T13:00:00.000Z"
|
|
273
|
+
}, {
|
|
274
|
+
statusCode: 200,
|
|
275
|
+
durationMs: 5,
|
|
276
|
+
responseBody: Buffer.from(JSON.stringify({
|
|
277
|
+
status: "incomplete",
|
|
278
|
+
incomplete_details: { reason: "max_output_tokens" },
|
|
279
|
+
usage: { input_tokens: 31_533, output_tokens: 16_384 }
|
|
280
|
+
}))
|
|
281
|
+
});
|
|
282
|
+
assert.equal(record.status, "failed");
|
|
283
|
+
assert.equal(record.error?.message, "provider response reached the maximum output token limit");
|
|
284
|
+
assert.equal(record.usage?.completion_tokens, 16_384);
|
|
285
|
+
assert.equal(record.metadata?.stop_reason, "max_output_tokens");
|
|
286
|
+
});
|
|
287
|
+
test("an exact requested output-token cap is failed provenance", () => {
|
|
288
|
+
const record = buildModelCallRecord({
|
|
289
|
+
callId: "call_response_exact_cap",
|
|
290
|
+
dialect: "openai-responses",
|
|
291
|
+
requestedModel: "claude-code/claude-opus-5",
|
|
292
|
+
model: "claude-code/claude-opus-5",
|
|
293
|
+
stream: false,
|
|
294
|
+
requestBody: {
|
|
295
|
+
input: "author twenty evaluation cases",
|
|
296
|
+
max_output_tokens: 32_768
|
|
297
|
+
},
|
|
298
|
+
startedAt: "2026-08-20T13:00:00.000Z"
|
|
299
|
+
}, {
|
|
300
|
+
statusCode: 200,
|
|
301
|
+
durationMs: 5,
|
|
302
|
+
responseBody: Buffer.from(JSON.stringify({
|
|
303
|
+
status: "completed",
|
|
304
|
+
usage: { input_tokens: 31_533, output_tokens: 32_768 }
|
|
305
|
+
}))
|
|
306
|
+
});
|
|
307
|
+
assert.equal(record.status, "failed");
|
|
308
|
+
assert.equal(record.metadata?.stop_reason, "max_output_tokens");
|
|
309
|
+
});
|
|
310
|
+
test("HTTP 200 Responses stream without a finish reason is failed provenance", () => {
|
|
311
|
+
const record = buildModelCallRecord({
|
|
312
|
+
callId: "call_response_stream_truncated",
|
|
313
|
+
dialect: "openai-responses",
|
|
314
|
+
requestedModel: "openai/model",
|
|
315
|
+
model: "openai/model",
|
|
316
|
+
stream: true,
|
|
317
|
+
requestBody: { input: "answer", stream: true },
|
|
318
|
+
startedAt: "2026-08-20T13:00:00.000Z"
|
|
319
|
+
}, {
|
|
320
|
+
statusCode: 200,
|
|
321
|
+
durationMs: 5,
|
|
322
|
+
responseBody: Buffer.from('event: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete"}}\n\n')
|
|
323
|
+
});
|
|
324
|
+
assert.equal(record.status, "failed");
|
|
325
|
+
});
|
|
@@ -4,7 +4,7 @@ import { runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
|
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { reasoningSelectionOf } from "../adapters/openai-chat-wire.js";
|
|
6
6
|
import { parseResponsesEncryptedContent, wrapResponsesEncryptedContent } from "../adapters/openai-responses-wire.js";
|
|
7
|
-
import { openAiSseToResponses } from "../adapters/responses.js";
|
|
7
|
+
import { chatToResponses, openAiSseToResponses } from "../adapters/responses.js";
|
|
8
8
|
import { RoutingBackend } from "../routing/router.js";
|
|
9
9
|
import { startGateway } from "../gateway-service.js";
|
|
10
10
|
import { testProviderSource } from "./provider-source-fixture.js";
|
|
@@ -26,6 +26,37 @@ test("a mid-stream provider error event becomes response.failed with the upstrea
|
|
|
26
26
|
assert.ok(text.includes("openrouter call failed (unknown)"));
|
|
27
27
|
assert.ok(!text.includes("event: response.completed"));
|
|
28
28
|
});
|
|
29
|
+
test("max-token Chat completion becomes an incomplete Responses result", () => {
|
|
30
|
+
for (const finishReason of ["length", "max_tokens"]) {
|
|
31
|
+
const response = chatToResponses({
|
|
32
|
+
choices: [
|
|
33
|
+
{
|
|
34
|
+
message: { role: "assistant", content: '{"cases":[{"id":"truncated' },
|
|
35
|
+
finish_reason: finishReason
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}, "claude-code/claude-opus-5");
|
|
39
|
+
assert.equal(response.status, "incomplete");
|
|
40
|
+
assert.deepEqual(response.incomplete_details, { reason: "max_output_tokens" });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test("max-token Chat stream becomes response.incomplete with its reason", async () => {
|
|
44
|
+
const stream = openAiSseToResponses(sseStream(`data: ${JSON.stringify({
|
|
45
|
+
choices: [
|
|
46
|
+
{
|
|
47
|
+
index: 0,
|
|
48
|
+
delta: { content: '{"cases":[{"id":"truncated' },
|
|
49
|
+
finish_reason: null
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
})}\n\n`, `data: ${JSON.stringify({
|
|
53
|
+
choices: [{ index: 0, delta: {}, finish_reason: "length" }]
|
|
54
|
+
})}\n\n`, "data: [DONE]\n\n"), "claude-code/claude-opus-5");
|
|
55
|
+
const text = await new Response(stream).text();
|
|
56
|
+
assert.match(text, /event: response\.incomplete/u);
|
|
57
|
+
assert.match(text, /"incomplete_details":\{"reason":"max_output_tokens"\}/u);
|
|
58
|
+
assert.doesNotMatch(text, /event: response\.completed/u);
|
|
59
|
+
});
|
|
29
60
|
test("translates a streamed Responses event sequence", async () => {
|
|
30
61
|
const mock = await startMock();
|
|
31
62
|
const gateway = await startGateway({
|
|
@@ -61,6 +61,35 @@ test("switching proxy retirement preserves an admitted streaming response", asyn
|
|
|
61
61
|
await target.close();
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
|
+
test("switching proxy header timeout does not abort an admitted response body", async () => {
|
|
65
|
+
const target = await heldTarget();
|
|
66
|
+
const proxy = await startSwitchingGatewayProxy({
|
|
67
|
+
target: target.url,
|
|
68
|
+
upstreamHeadersTimeoutMs: 25
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(`${proxy.url()}/stream`);
|
|
72
|
+
const reader = response.body?.getReader();
|
|
73
|
+
assert.ok(reader !== undefined);
|
|
74
|
+
const first = await reader.read();
|
|
75
|
+
assert.match(Buffer.from(first.value ?? []).toString("utf8"), /first/);
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
77
|
+
target.release();
|
|
78
|
+
let remainder = "";
|
|
79
|
+
for (;;) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done)
|
|
82
|
+
break;
|
|
83
|
+
remainder += Buffer.from(value ?? []).toString("utf8");
|
|
84
|
+
}
|
|
85
|
+
assert.match(remainder, /last/);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
target.release();
|
|
89
|
+
await Effect.runPromise(proxy.close);
|
|
90
|
+
await target.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
64
93
|
test("switching proxy retirement deadline terminates a stream that never finishes", async () => {
|
|
65
94
|
const target = await heldTarget();
|
|
66
95
|
const proxy = await startSwitchingGatewayProxy({ target: target.url });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-gateway",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.10",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"@aws-sdk/client-bedrock": "3.1095.0",
|
|
46
46
|
"@aws-sdk/client-bedrock-runtime": "3.1095.0",
|
|
47
47
|
"effect": "4.0.0-rc.108",
|
|
48
|
-
"@velum-labs/routekit-config-core": "1.0.
|
|
49
|
-
"@velum-labs/routekit-contracts": "1.0.
|
|
50
|
-
"@velum-labs/routekit-eval-contracts": "1.0.
|
|
51
|
-
"@velum-labs/routekit-eval-core": "1.0.
|
|
52
|
-
"@velum-labs/routekit-registry": "1.0.
|
|
53
|
-
"@velum-labs/routekit-runtime": "1.0.
|
|
48
|
+
"@velum-labs/routekit-config-core": "1.0.10",
|
|
49
|
+
"@velum-labs/routekit-contracts": "1.0.10",
|
|
50
|
+
"@velum-labs/routekit-eval-contracts": "1.0.10",
|
|
51
|
+
"@velum-labs/routekit-eval-core": "1.0.10",
|
|
52
|
+
"@velum-labs/routekit-registry": "1.0.10",
|
|
53
|
+
"@velum-labs/routekit-runtime": "1.0.10"
|
|
54
54
|
},
|
|
55
55
|
"keywords": [
|
|
56
56
|
"routekit",
|