@velum-labs/routekit-gateway 1.3.2 → 1.4.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.
@@ -34,6 +34,9 @@ function mapTextFormat(text) {
34
34
  type: "json_schema",
35
35
  json_schema: {
36
36
  ...(typeof format.name === "string" ? { name: format.name } : {}),
37
+ ...(typeof format.description === "string"
38
+ ? { description: format.description }
39
+ : {}),
37
40
  ...(format.schema !== undefined ? { schema: format.schema } : {}),
38
41
  ...(typeof format.strict === "boolean" ? { strict: format.strict } : {})
39
42
  }
@@ -601,8 +604,11 @@ export function responsesToChat(body, backendModel, options = {}) {
601
604
  if (choice !== undefined)
602
605
  chat.tool_choice = choice;
603
606
  }
604
- if (body.stream === true)
607
+ // Do not synthesize a Chat-only field when the next adapter returns to
608
+ // Responses; Codex reports usage in its native terminal event.
609
+ if (body.stream === true && options.destinationWireShape !== "openai-responses") {
605
610
  chat.stream_options = { include_usage: true };
611
+ }
606
612
  return chat;
607
613
  }
608
614
  // ---- non-streaming response translation ----
@@ -1,4 +1,4 @@
1
- import { PRICING_ALIASES, DEFAULT_MODEL_PRICING as REGISTRY_MODEL_PRICING } from "@velum-labs/routekit-registry";
1
+ import { DEFAULT_MODEL_PRICING as REGISTRY_MODEL_PRICING, attributeModelCosts, lookupModelPricing } from "@velum-labs/routekit-registry";
2
2
  import { decodeBufferedSse } from "../sse/parse.js";
3
3
  const DEFAULT_CURRENCY = "USD";
4
4
  export const DEFAULT_MODEL_PRICING = REGISTRY_MODEL_PRICING;
@@ -44,21 +44,8 @@ export function parseUsageFromSse(text) {
44
44
  }
45
45
  return usage;
46
46
  }
47
- function canonicalPricingKey(model) {
48
- const direct = Object.keys(DEFAULT_MODEL_PRICING).find((candidate) => candidate.toLowerCase() === model.toLowerCase());
49
- if (direct !== undefined)
50
- return direct;
51
- const alias = Object.entries(PRICING_ALIASES).find(([candidate]) => candidate.toLowerCase() === model.toLowerCase());
52
- return alias?.[1] ?? model;
53
- }
54
47
  export function lookupPricing(model, overrides = {}) {
55
- const combined = {
56
- ...DEFAULT_MODEL_PRICING,
57
- ...overrides
58
- };
59
- const key = canonicalPricingKey(model);
60
- const entry = Object.entries(combined).find(([candidate]) => candidate.toLowerCase() === key.toLowerCase());
61
- return entry?.[1];
48
+ return lookupModelPricing(model, overrides);
62
49
  }
63
50
  export function estimateCost(usage, pricing) {
64
51
  if (pricing === undefined)
@@ -67,10 +54,19 @@ export function estimateCost(usage, pricing) {
67
54
  const hasCompletion = usage.completionTokens !== undefined;
68
55
  if (!hasPrompt && !hasCompletion)
69
56
  return undefined;
57
+ const actuals = attributeModelCosts([
58
+ {
59
+ model: "configured",
60
+ usage: {
61
+ ...(usage.promptTokens === undefined ? {} : { inputTokens: usage.promptTokens }),
62
+ ...(usage.completionTokens === undefined ? {} : { outputTokens: usage.completionTokens })
63
+ }
64
+ }
65
+ ], { configured: pricing });
66
+ if (actuals.knownCostUsd === undefined)
67
+ return undefined;
70
68
  return {
71
- costUsd: ((usage.promptTokens ?? 0) * pricing.inputPer1mTokens +
72
- (usage.completionTokens ?? 0) * pricing.outputPer1mTokens) /
73
- 1_000_000,
69
+ costUsd: actuals.knownCostUsd,
74
70
  partialUsage: !hasPrompt || !hasCompletion
75
71
  };
76
72
  }
@@ -21,6 +21,7 @@ export type ChatMessage = {
21
21
  tool_call_id?: string;
22
22
  };
23
23
  export type ChatBody = {
24
+ [key: string]: unknown;
24
25
  model?: string;
25
26
  messages?: ChatMessage[];
26
27
  tools?: Array<{
@@ -40,6 +41,7 @@ export type ChatBody = {
40
41
  type?: string;
41
42
  json_schema?: {
42
43
  name?: string;
44
+ description?: string;
43
45
  schema?: unknown;
44
46
  strict?: boolean;
45
47
  };
@@ -7,7 +7,7 @@ import { StreamPump } from "@velum-labs/routekit-runtime/sse";
7
7
  import { Effect } from "effect";
8
8
  import { routeKitRequestValidationErrorOf } from "../adapters/openai-chat-wire.js";
9
9
  import { joinPath } from "./backend.js";
10
- import { applyCodexForceStreamEvent, codexCompletionResponse, codexForceStreamResponse, codexReasoningModeError, codexSseToChatChunks, createCodexForceStreamState, createCodexStreamState, responsesRequest } from "./codex-responses-codec.js";
10
+ import { applyCodexForceStreamEvent, CodexResponsesTranslationError, codexCompletionResponse, codexForceStreamResponse, codexReasoningModeError, codexSseToChatChunks, createCodexForceStreamState, createCodexStreamState, responsesRequest } from "./codex-responses-codec.js";
11
11
  import { gatewayTry, gatewayTryPromise } from "../effect/gateway.js";
12
12
  import { copyFailure, jsonResponse } from "../http/response.js";
13
13
  import { bodyRecord, HttpProviderBackend, invalidReasoningControlResponse, mapSse, providerTransport } from "./backend-core.js";
@@ -46,6 +46,22 @@ export class CodexResponsesBackend extends HttpProviderBackend {
46
46
  }
47
47
  }, 400);
48
48
  }
49
+ const encoded = yield* gatewayTry(() => responsesRequest(body, model, {
50
+ forceStream: self.#forceStream,
51
+ omitSampling: self.#omitSampling
52
+ })).pipe(Effect.map((body) => ({ ok: true, body })), Effect.catch((failure) => failure.cause instanceof CodexResponsesTranslationError
53
+ ? Effect.succeed({ ok: false, cause: failure.cause })
54
+ : Effect.fail(failure)));
55
+ if (!encoded.ok) {
56
+ return jsonResponse({
57
+ error: {
58
+ type: "invalid_request_error",
59
+ code: encoded.cause.code,
60
+ param: encoded.cause.field,
61
+ message: encoded.cause.message
62
+ }
63
+ }, 400);
64
+ }
49
65
  const response = yield* providerTransport(self.transport, joinPath(self.baseUrl, "/responses"), {
50
66
  method: "POST",
51
67
  headers: {
@@ -54,10 +70,7 @@ export class CodexResponsesBackend extends HttpProviderBackend {
54
70
  ...(self.#accountId !== undefined ? { "chatgpt-account-id": self.#accountId } : {}),
55
71
  ...self.extraHeaders
56
72
  },
57
- body: JSON.stringify(responsesRequest(body, model, {
58
- forceStream: self.#forceStream,
59
- omitSampling: self.#omitSampling
60
- })),
73
+ body: JSON.stringify(encoded.body),
61
74
  ...(signal !== undefined ? { signal } : {})
62
75
  }, options);
63
76
  if (!response.ok)
@@ -9,6 +9,11 @@
9
9
  import { type ResponsesReasoningOwner } from "../adapters/openai-responses-wire.js";
10
10
  import { type ChatBody } from "./backend-core.js";
11
11
  import { type ProviderRecord } from "./protocol.js";
12
+ export declare class CodexResponsesTranslationError extends Error {
13
+ readonly field: string;
14
+ readonly code = "codex_responses_unmappable_chat_field";
15
+ constructor(field: string, message?: string);
16
+ }
12
17
  export declare function responsesRequest(body: ChatBody, model: string, options: {
13
18
  forceStream: boolean;
14
19
  omitSampling: boolean;
@@ -13,8 +13,97 @@ import { normalizeOpenAiResponsesCallIds, prepareResponsesReasoningInput, wrapRe
13
13
  import { jsonResponse } from "../http/response.js";
14
14
  import { chatCompletion, normalizedOpenAiUsage, textContent } from "./backend-core.js";
15
15
  import { isProviderRecord, ProviderProtocolError } from "./protocol.js";
16
+ export class CodexResponsesTranslationError extends Error {
17
+ field;
18
+ code = "codex_responses_unmappable_chat_field";
19
+ constructor(field, message = `Codex Responses cannot represent Chat field "${field}"`) {
20
+ super(message);
21
+ this.field = field;
22
+ this.name = "CodexResponsesTranslationError";
23
+ }
24
+ }
25
+ const supportedChatFields = new Set([
26
+ "model",
27
+ "messages",
28
+ "tools",
29
+ "tool_choice",
30
+ "parallel_tool_calls",
31
+ "stream",
32
+ "max_tokens",
33
+ "max_completion_tokens",
34
+ "temperature",
35
+ "top_p",
36
+ "reasoning_effort",
37
+ "response_format",
38
+ // RouteKit's private reasoning envelope is consumed by reasoningSelectionOf.
39
+ "x_routekit"
40
+ ]);
41
+ function assertTotalChatRequest(body, options) {
42
+ for (const field of Object.keys(body)) {
43
+ if (!supportedChatFields.has(field)) {
44
+ throw new CodexResponsesTranslationError(field);
45
+ }
46
+ }
47
+ if (body.max_tokens !== undefined && body.max_completion_tokens !== undefined) {
48
+ throw new CodexResponsesTranslationError("max_completion_tokens", 'Codex Responses cannot represent both Chat fields "max_tokens" and "max_completion_tokens"');
49
+ }
50
+ if (options.omitSampling) {
51
+ for (const field of ["temperature", "top_p"]) {
52
+ if (body[field] !== undefined) {
53
+ throw new CodexResponsesTranslationError(field, `Codex subscription Responses does not support Chat field "${field}"`);
54
+ }
55
+ }
56
+ }
57
+ }
58
+ function responsesTextFormat(responseFormat) {
59
+ if (responseFormat === undefined)
60
+ return undefined;
61
+ switch (responseFormat.type) {
62
+ case "text":
63
+ case "json_object":
64
+ return { type: responseFormat.type };
65
+ case "json_schema": {
66
+ const jsonSchema = responseFormat.json_schema;
67
+ if (jsonSchema === undefined ||
68
+ typeof jsonSchema.name !== "string" ||
69
+ jsonSchema.name.length === 0 ||
70
+ jsonSchema.schema === undefined) {
71
+ throw new CodexResponsesTranslationError("response_format.json_schema", 'Codex Responses requires Chat "response_format.json_schema" to include name and schema');
72
+ }
73
+ return {
74
+ type: "json_schema",
75
+ name: jsonSchema.name,
76
+ ...(jsonSchema.description !== undefined
77
+ ? { description: jsonSchema.description }
78
+ : {}),
79
+ schema: jsonSchema.schema,
80
+ ...(jsonSchema.strict !== undefined ? { strict: jsonSchema.strict } : {})
81
+ };
82
+ }
83
+ default:
84
+ throw new CodexResponsesTranslationError("response_format.type", `Codex Responses cannot represent Chat response format ${JSON.stringify(responseFormat.type)}`);
85
+ }
86
+ }
87
+ function responsesToolChoice(choice) {
88
+ if (choice === undefined)
89
+ return undefined;
90
+ if (choice === "auto" || choice === "none" || choice === "required")
91
+ return choice;
92
+ if (!isProviderRecord(choice) || choice.type !== "function") {
93
+ throw new CodexResponsesTranslationError("tool_choice");
94
+ }
95
+ const fn = isProviderRecord(choice.function) ? choice.function : undefined;
96
+ if (typeof fn?.name !== "string" || fn.name.length === 0) {
97
+ throw new CodexResponsesTranslationError("tool_choice.function.name", 'Codex Responses requires a named function in Chat "tool_choice"');
98
+ }
99
+ return { type: "function", name: fn.name };
100
+ }
16
101
  export function responsesRequest(body, model, options) {
102
+ assertTotalChatRequest(body, options);
17
103
  const reasoning = reasoningSelectionOf(body);
104
+ const textFormat = responsesTextFormat(body.response_format);
105
+ const toolChoice = responsesToolChoice(body.tool_choice);
106
+ const maxOutputTokens = body.max_completion_tokens ?? body.max_tokens;
18
107
  const input = (body.messages ?? []).flatMap((message) => {
19
108
  if (message.role === "tool") {
20
109
  return [
@@ -63,13 +152,16 @@ export function responsesRequest(body, model, options) {
63
152
  store: false,
64
153
  ...(includeEncryptedContent ? { include: ["reasoning.encrypted_content"] } : {}),
65
154
  ...(reasoning.mode === "effort" ? { reasoning: { effort: reasoning.effort } } : {}),
66
- ...(!options.omitSampling && body.max_tokens !== undefined
67
- ? { max_output_tokens: body.max_tokens }
68
- : {}),
155
+ ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}),
69
156
  ...(!options.omitSampling && body.temperature !== undefined
70
157
  ? { temperature: body.temperature }
71
158
  : {}),
72
- ...(body.tool_choice !== undefined ? { tool_choice: body.tool_choice } : {}),
159
+ ...(!options.omitSampling && body.top_p !== undefined ? { top_p: body.top_p } : {}),
160
+ ...(textFormat !== undefined ? { text: { format: textFormat } } : {}),
161
+ ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
162
+ ...(body.parallel_tool_calls !== undefined
163
+ ? { parallel_tool_calls: body.parallel_tool_calls }
164
+ : {}),
73
165
  ...(body.tools !== undefined
74
166
  ? {
75
167
  tools: body.tools.flatMap((tool) => tool.function === undefined
@@ -79,7 +171,10 @@ export function responsesRequest(body, model, options) {
79
171
  type: "function",
80
172
  name: tool.function.name,
81
173
  description: tool.function.description,
82
- parameters: tool.function.parameters ?? { type: "object" }
174
+ parameters: tool.function.parameters ?? { type: "object" },
175
+ ...(typeof tool.function.strict === "boolean"
176
+ ? { strict: tool.function.strict }
177
+ : {})
83
178
  }
84
179
  ])
85
180
  }
@@ -10,7 +10,7 @@
10
10
  import { createServer } from "node:http";
11
11
  import { EVAL_ATTRIBUTION_HEADER, EVAL_POLICY_BYPASS_HEADER, isForbiddenEvalModel } from "@velum-labs/routekit-eval-contracts";
12
12
  import { assertAuthenticatedBind, trimTrailingSlashes } from "@velum-labs/routekit-runtime/network";
13
- import { createNodeHttpHandlerEffect, executeWebRequest, RouteKitLive, toRouteKitFailure } from "@velum-labs/routekit-runtime/effect";
13
+ import { createNodeHttpHandlerEffect, executeWebRequest, RouteKitFailure, RouteKitLive, toRouteKitFailure } from "@velum-labs/routekit-runtime/effect";
14
14
  import { Context, Deferred, Effect, Layer, ManagedRuntime, Stream } from "effect";
15
15
  import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
16
16
  import * as HttpEffect from "effect/unstable/http/HttpEffect";
@@ -224,14 +224,18 @@ const startSwitchingGatewayProxyOperation = Effect.fn("SwitchingGatewayProxy.sta
224
224
  error: { message: rejection.message, type: "invalid_request_error" }
225
225
  });
226
226
  }
227
- const headersTimeout = new AbortController();
228
- 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);
227
+ const timeoutMs = input.upstreamHeadersTimeoutMs ?? DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS;
229
228
  const upstream = yield* executeWebRequest(`${selected.url}${path}`, {
230
229
  method: nodeReq.method ?? "GET",
231
230
  headers: requestHeaders(nodeReq.headers, principal),
232
231
  ...(body !== undefined ? { body } : {}),
233
- signal: AbortSignal.any([aborter.signal, headersTimeout.signal])
234
- }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timeout))));
232
+ signal: aborter.signal
233
+ }).pipe(Effect.timeoutOrElse({
234
+ duration: timeoutMs,
235
+ orElse: () => Effect.fail(new RouteKitFailure({
236
+ message: `gateway generation did not return headers within ${timeoutMs}ms`
237
+ }))
238
+ }));
235
239
  return HttpEffect.scopeTransferToStream(proxyResponse(upstream, retiring));
236
240
  }).pipe(Effect.orElseSucceed(() => jsonResponse(502, {
237
241
  error: { message: "router generation unavailable", type: "upstream_error" }
@@ -12,6 +12,7 @@ test("single-call metering normalizes provider usage and registry pricing", () =
12
12
  outputPer1mTokens: 10
13
13
  });
14
14
  assert.deepEqual(estimateCost({ promptTokens: 1_000_000, completionTokens: 1_000_000 }, lookupPricing("gpt-5.5")), { costUsd: 11.25, partialUsage: false });
15
+ assert.equal(estimateCost({ promptTokens: 1_000_000 }, lookupPricing("gpt-5.5")), undefined);
15
16
  });
16
17
  test("SSE usage extraction retains the last provider usage block", () => {
17
18
  const text = `data: ${JSON.stringify({ choices: [{ delta: { content: "first" } }] })}\n\n` +
@@ -1,11 +1,18 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
2
3
  import { test } from "node:test";
3
- import { runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
4
+ import { RouteKitLive, runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
5
+ import { Effect } from "effect";
4
6
  import { responsesReasoningMetadataOf } from "../adapters/openai-chat-wire.js";
5
7
  import { parseResponsesEncryptedContent, wrapResponsesEncryptedContent } from "../adapters/openai-responses-wire.js";
6
8
  import { responsesToChat } from "../adapters/responses.js";
7
9
  import { OpenAiBackend } from "../providers/openai-backend.js";
8
10
  import { AnthropicBackend, CodexResponsesBackend, GoogleGenAiBackend } from "../providers/backends.js";
11
+ import { languageModelDimensionRequestDecomposerLayer } from "../routing/dimension-request-decomposer.js";
12
+ import { classifierResponseSchemaV3 } from "../routing/classifier-v3-protocol.js";
13
+ import { lunaDirectRequestDecomposerLayer } from "../routing/luna-direct-classifier.js";
14
+ import { buildTaskContextV3 } from "../routing/task-context.js";
15
+ import { RequestDecomposer } from "../services/request-decomposer/service.js";
9
16
  import { ChatStreamAssembler } from "../sse/chat-assembler.js";
10
17
  import { SseDecoder } from "../sse/parse.js";
11
18
  import { asTransport, sse } from "./provider-backends-fixtures.js";
@@ -439,6 +446,220 @@ test("Codex Responses egress preserves subscription auth and tool output", async
439
446
  globalThis.fetch = original;
440
447
  }
441
448
  });
449
+ test("dimension classifier reaches Codex Responses with its exact schema and output budget", async () => {
450
+ const original = globalThis.fetch;
451
+ let request;
452
+ const dimensions = [
453
+ ["provider-adapters", "Provider-specific protocol adapters"],
454
+ ["eval-routing", "Evaluation-driven model routing"],
455
+ ["gateway-protocol", "OpenAI-compatible gateway protocol behavior"],
456
+ ["daemon-lifecycle", "Daemon startup and lifecycle"],
457
+ ["remote-enrollment", "Remote enrollment and control relays"]
458
+ ].map(([id, description]) => ({
459
+ id: id,
460
+ description: description,
461
+ includes: [`${description} work`],
462
+ excludes: [`work outside ${description.toLowerCase()}`]
463
+ }));
464
+ globalThis.fetch = async (input, init) => {
465
+ request = new Request(input, init);
466
+ return sse([
467
+ {
468
+ event: "response.completed",
469
+ data: {
470
+ response: {
471
+ output: [
472
+ {
473
+ type: "message",
474
+ content: [
475
+ {
476
+ type: "output_text",
477
+ text: '{"weights":{"provider-adapters":1,"eval-routing":0,"gateway-protocol":0,"daemon-lifecycle":0,"remote-enrollment":0},"unknownWeight":0}'
478
+ }
479
+ ]
480
+ }
481
+ ]
482
+ }
483
+ }
484
+ }
485
+ ]);
486
+ };
487
+ try {
488
+ const backend = new CodexResponsesBackend({
489
+ baseUrl: "https://chatgpt.test/backend-api/codex",
490
+ apiKey: "oauth",
491
+ defaultModel: "codex-test",
492
+ forceStream: true,
493
+ omitSampling: true
494
+ });
495
+ const classifier = await runRouteKitEffect(RequestDecomposer.pipe(Effect.provide(languageModelDimensionRequestDecomposerLayer({
496
+ model: "codex/gpt-5.6-luna",
497
+ complete: (body, signal) => backend.chat(body, signal).pipe(Effect.provide(RouteKitLive))
498
+ }))));
499
+ const result = await runRouteKitEffect(classifier.decompose({
500
+ request: "Repair the Codex Responses adapter",
501
+ dimensions
502
+ }));
503
+ assert.deepEqual(result, {
504
+ weights: dimensions.map((dimension) => ({
505
+ dimensionId: dimension.id,
506
+ weight: dimension.id === "provider-adapters" ? 1 : 0
507
+ })),
508
+ unknownWeight: 0
509
+ });
510
+ const outbound = (await request?.json());
511
+ assert.equal(outbound.max_output_tokens, 256);
512
+ assert.equal(outbound.text?.format?.type, "json_schema");
513
+ assert.equal(outbound.text?.format?.name, "routekit_request_decomposition");
514
+ assert.equal(outbound.text?.format?.strict, true);
515
+ assert.equal(outbound.text?.format?.schema?.additionalProperties, false);
516
+ assert.deepEqual(outbound.text?.format?.schema?.properties?.weights?.required, [
517
+ "provider-adapters",
518
+ "eval-routing",
519
+ "gateway-protocol",
520
+ "daemon-lifecycle",
521
+ "remote-enrollment"
522
+ ]);
523
+ }
524
+ finally {
525
+ globalThis.fetch = original;
526
+ }
527
+ });
528
+ test("V3 classifier reaches Codex Responses with its exact schema and output budget", async () => {
529
+ const original = globalThis.fetch;
530
+ let request;
531
+ const basis = JSON.parse(readFileSync(new URL("../../../../test/fixtures/routing-v3/examples/routing-basis-v3.example.json", import.meta.url), "utf8"));
532
+ const activation = JSON.parse(readFileSync(new URL("../../../../test/fixtures/routing-v3/examples/published-routing-activation-v3.example.json", import.meta.url), "utf8"));
533
+ const scores = Object.fromEntries(basis.dimensions.map((dimension) => [dimension.id, 0]));
534
+ globalThis.fetch = async (input, init) => {
535
+ request = new Request(input, init);
536
+ return sse([
537
+ {
538
+ event: "response.completed",
539
+ data: {
540
+ response: {
541
+ output: [
542
+ {
543
+ type: "message",
544
+ content: [
545
+ {
546
+ type: "output_text",
547
+ text: JSON.stringify({
548
+ dimension_scores: scores,
549
+ unknown_probability: 0
550
+ })
551
+ }
552
+ ]
553
+ }
554
+ ]
555
+ }
556
+ }
557
+ }
558
+ ]);
559
+ };
560
+ try {
561
+ const backend = new CodexResponsesBackend({
562
+ baseUrl: "https://chatgpt.test/backend-api/codex",
563
+ apiKey: "oauth",
564
+ defaultModel: "codex-test",
565
+ forceStream: true,
566
+ omitSampling: true
567
+ });
568
+ const classifier = await runRouteKitEffect(RequestDecomposer.pipe(Effect.provide(lunaDirectRequestDecomposerLayer({
569
+ model: "codex/gpt-5.6-luna",
570
+ modelAvailable: () => true,
571
+ complete: (body, signal) => backend.chat(body, signal).pipe(Effect.provide(RouteKitLive))
572
+ }))));
573
+ const context = buildTaskContextV3({
574
+ currentRequest: "Repair the Codex Responses adapter",
575
+ earlierUserContext: [],
576
+ relevantDiagnostics: []
577
+ }, basis.facetSnapshot, activation.classifier.context);
578
+ const result = await runRouteKitEffect(classifier.classify({
579
+ basis,
580
+ context,
581
+ config: activation.classifier
582
+ }));
583
+ assert.deepEqual(result.scores, basis.dimensions.map((dimension) => ({ dimensionId: dimension.id, score: 0 })));
584
+ const outbound = (await request?.json());
585
+ assert.equal(outbound.max_output_tokens, activation.classifier.maxCompletionTokens);
586
+ assert.deepEqual(outbound.text?.format, {
587
+ type: "json_schema",
588
+ name: "routekit_independent_dimension_scores_v3",
589
+ strict: true,
590
+ schema: classifierResponseSchemaV3(basis)
591
+ });
592
+ }
593
+ finally {
594
+ globalThis.fetch = original;
595
+ }
596
+ });
597
+ test("Codex subscription egress named-rejects an unmappable Chat field before I/O", async () => {
598
+ const original = globalThis.fetch;
599
+ let requests = 0;
600
+ globalThis.fetch = async () => {
601
+ requests += 1;
602
+ return Response.json({});
603
+ };
604
+ try {
605
+ const backend = new CodexResponsesBackend({
606
+ baseUrl: "https://chatgpt.test/backend-api/codex",
607
+ apiKey: "oauth",
608
+ defaultModel: "codex-test",
609
+ forceStream: true,
610
+ omitSampling: true
611
+ });
612
+ const response = await runRouteKitEffect(backend.chat({
613
+ messages: [{ role: "user", content: "reply" }],
614
+ top_k: 40
615
+ }));
616
+ const payload = (await response.json());
617
+ assert.equal(response.status, 400);
618
+ assert.equal(requests, 0);
619
+ assert.deepEqual(payload.error, {
620
+ type: "invalid_request_error",
621
+ code: "codex_responses_unmappable_chat_field",
622
+ param: "top_k",
623
+ message: 'Codex Responses cannot represent Chat field "top_k"'
624
+ });
625
+ }
626
+ finally {
627
+ globalThis.fetch = original;
628
+ }
629
+ });
630
+ test("Codex subscription egress named-rejects an unknown Chat field before I/O", async () => {
631
+ const original = globalThis.fetch;
632
+ let requests = 0;
633
+ globalThis.fetch = async () => {
634
+ requests += 1;
635
+ return Response.json({});
636
+ };
637
+ try {
638
+ const backend = new CodexResponsesBackend({
639
+ baseUrl: "https://chatgpt.test/backend-api/codex",
640
+ apiKey: "oauth",
641
+ defaultModel: "codex-test",
642
+ forceStream: true,
643
+ omitSampling: true
644
+ });
645
+ const response = await runRouteKitEffect(backend.chat({
646
+ messages: [{ role: "user", content: "reply" }],
647
+ best_of: 2
648
+ }));
649
+ const payload = (await response.json());
650
+ assert.equal(response.status, 400);
651
+ assert.equal(requests, 0);
652
+ assert.deepEqual(payload.error, {
653
+ type: "invalid_request_error",
654
+ code: "codex_responses_unmappable_chat_field",
655
+ param: "best_of",
656
+ message: 'Codex Responses cannot represent Chat field "best_of"'
657
+ });
658
+ }
659
+ finally {
660
+ globalThis.fetch = original;
661
+ }
662
+ });
442
663
  test("Codex subscription egress forces SSE and omits unsupported sampling", async () => {
443
664
  const original = globalThis.fetch;
444
665
  let request;
@@ -471,8 +692,6 @@ test("Codex subscription egress forces SSE and omits unsupported sampling", asyn
471
692
  });
472
693
  const response = await runRouteKitEffect(backend.chat({
473
694
  stream: false,
474
- max_tokens: 16,
475
- temperature: 0,
476
695
  messages: [{ role: "user", content: "reply" }]
477
696
  }));
478
697
  const outbound = (await request?.json());
@@ -132,6 +132,34 @@ test("responsesToChat tolerates reasoning: null and text: null (Codex custom-pro
132
132
  assert.equal(chat.model, "grok-4");
133
133
  assert.equal(chat.reasoning_effort, undefined);
134
134
  assert.equal(chat.response_format, undefined);
135
+ assert.deepEqual(chat.stream_options, { include_usage: true });
136
+ });
137
+ test("responsesToChat does not add Chat stream options before Codex Responses egress", () => {
138
+ const chat = responsesToChat({
139
+ model: "codex/matrix-codex",
140
+ input: [
141
+ {
142
+ type: "message",
143
+ role: "user",
144
+ content: [{ type: "input_text", text: "Use the declared tool." }]
145
+ }
146
+ ],
147
+ stream: true,
148
+ tools: [
149
+ {
150
+ type: "function",
151
+ name: "read_file",
152
+ description: "read a file",
153
+ parameters: {
154
+ type: "object",
155
+ properties: { path: { type: "string" } }
156
+ }
157
+ }
158
+ ]
159
+ }, "matrix-codex", { destinationWireShape: "openai-responses" });
160
+ assert.equal(chat.stream, true);
161
+ assert.equal(chat.stream_options, undefined);
162
+ assert.equal((chat.tools[0]?.function?.name), "read_file");
135
163
  });
136
164
  test("responsesToChat treats Codex reasoning effort null as absent", () => {
137
165
  const chat = responsesToChat({ model: "gpt-5.5", input: "say OK", reasoning: { effort: null } }, "gpt-5.5");
@@ -839,6 +839,69 @@ test("Responses follows ModelRoutedBackend reasoning wire capability", async ()
839
839
  await Effect.runPromise(gateway.close);
840
840
  }
841
841
  });
842
+ test("streaming Responses requests reach Codex without Chat-only stream options", async () => {
843
+ let codexBody;
844
+ const codex = new CodexResponsesBackend({
845
+ baseUrl: "https://codex.test",
846
+ apiKey: "x",
847
+ defaultModel: "codex-native",
848
+ transport: asTransport(async (_url, init) => {
849
+ codexBody = JSON.parse(String(init.body));
850
+ return new Response([
851
+ 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"ok"}\n\n',
852
+ 'event: response.completed\ndata: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}}\n\n'
853
+ ].join(""), { headers: { "content-type": "text/event-stream" } });
854
+ })
855
+ });
856
+ const primary = {
857
+ defaultModel: "primary-model",
858
+ ports: borrowedBackendPorts("primary-model"),
859
+ chat: () => Effect.succeed(Response.json({ choices: [] })),
860
+ models: () => Effect.succeed(Response.json({ data: [] })),
861
+ embeddings: () => Effect.succeed(Response.json({}))
862
+ };
863
+ const backend = new ModelRoutedBackend({
864
+ routedModelIds: ["codex-model"],
865
+ routed: codex,
866
+ primary
867
+ });
868
+ const gateway = await startGateway({ backend });
869
+ try {
870
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
871
+ method: "POST",
872
+ headers: { "content-type": "application/json" },
873
+ body: JSON.stringify({
874
+ model: "codex-model",
875
+ input: [
876
+ {
877
+ type: "message",
878
+ role: "user",
879
+ content: [{ type: "input_text", text: "Use the declared tool." }]
880
+ }
881
+ ],
882
+ stream: true,
883
+ tools: [
884
+ {
885
+ type: "function",
886
+ name: "read_file",
887
+ description: "read a file",
888
+ parameters: {
889
+ type: "object",
890
+ properties: { path: { type: "string" } }
891
+ }
892
+ }
893
+ ]
894
+ })
895
+ });
896
+ assert.equal(response.status, 200, await response.clone().text());
897
+ assert.equal(codexBody?.stream, true);
898
+ assert.equal("stream_options" in (codexBody ?? {}), false);
899
+ assert.equal(codexBody?.tools?.[0]?.name, "read_file");
900
+ }
901
+ finally {
902
+ await Effect.runPromise(gateway.close);
903
+ }
904
+ });
842
905
  test("native Responses swaps isolate provider reasoning and restore it on A to B to A", async () => {
843
906
  const requests = [];
844
907
  const routes = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
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.3.2",
49
- "@velum-labs/routekit-contracts": "1.3.2",
50
- "@velum-labs/routekit-eval-contracts": "1.3.2",
51
- "@velum-labs/routekit-eval-core": "1.3.2",
52
- "@velum-labs/routekit-registry": "1.3.2",
53
- "@velum-labs/routekit-runtime": "1.3.2"
48
+ "@velum-labs/routekit-config-core": "1.4.0",
49
+ "@velum-labs/routekit-contracts": "1.4.0",
50
+ "@velum-labs/routekit-eval-contracts": "1.4.0",
51
+ "@velum-labs/routekit-eval-core": "1.4.0",
52
+ "@velum-labs/routekit-registry": "1.4.0",
53
+ "@velum-labs/routekit-runtime": "1.4.0"
54
54
  },
55
55
  "keywords": [
56
56
  "routekit",