icebear icebear0828 commited on
Commit
e25a730
ยท
unverified ยท
1 Parent(s): c7c93b9

feat: tuple schema (prefixItems) support for structured outputs (#77)

Browse files

* feat: tuple schema support โ€” prefixItems auto-conversion for structured outputs

Upstream Codex API doesn't support JSON Schema prefixItems (tuple),
so we convert tuple schemas to equivalent object schemas on the request
side and reconvert the response back to arrays. All three endpoints
(OpenAI, Gemini, Responses passthrough) are covered.

- New module: src/translation/tuple-schema.ts (convert/reconvert/detect)
- prepareSchema() composes tuple conversion + additionalProperties injection
- Streaming: buffer text deltas when tupleSchema present, flush reconverted on completion
- 23 unit tests for core conversion logic

* fix: responses endpoint tuple reconversion + cleanup

- Add tuple schema reconversion to /v1/responses (stream + collect),
making all three endpoints consistent
- Remove unrelated web/src/hooks/use-status.ts from this branch
- Add console.warn to silent catch blocks for easier debugging
- Add streaming buffer tradeoff comment

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -15,6 +15,7 @@
15
  - Structured Outputs ๆ”ฏๆŒ๏ผš`/v1/chat/completions` ๆ”ฏๆŒ `response_format`๏ผˆ`json_object` / `json_schema`๏ผ‰๏ผŒGemini ็ซฏ็‚นๆ”ฏๆŒ `responseMimeType` + `responseSchema`๏ผŒ่‡ชๅŠจ็ฟป่ฏ‘ไธบ Codex Responses API ็š„ `text.format`๏ผ›`/v1/responses` ็›ด้€š `text` ๅญ—ๆฎต
16
 
17
  - ๆจกๅž‹ๅˆ—่กจ่‡ชๅŠจๅŒๆญฅ๏ผšๅŽ็ซฏๅŠจๆ€ fetch ๆˆๅŠŸๅŽ่‡ชๅŠจๅ›žๅ†™ `config/models.yaml`๏ผŒ้™ๆ€้…็ฝฎไธๅ†ๆปžๅŽ๏ผ›ๅ‰็ซฏๆฏ 60s ่ฝฎ่ฏขๆจกๅž‹ๅˆ—่กจ๏ผŒๆ–ฐๆจกๅž‹ๆ— ้œ€ๅˆทๆ–ฐ้กต้ขๅณๅฏ้€‰ๆ‹ฉ
 
18
 
19
  ### Changed
20
 
 
15
  - Structured Outputs ๆ”ฏๆŒ๏ผš`/v1/chat/completions` ๆ”ฏๆŒ `response_format`๏ผˆ`json_object` / `json_schema`๏ผ‰๏ผŒGemini ็ซฏ็‚นๆ”ฏๆŒ `responseMimeType` + `responseSchema`๏ผŒ่‡ชๅŠจ็ฟป่ฏ‘ไธบ Codex Responses API ็š„ `text.format`๏ผ›`/v1/responses` ็›ด้€š `text` ๅญ—ๆฎต
16
 
17
  - ๆจกๅž‹ๅˆ—่กจ่‡ชๅŠจๅŒๆญฅ๏ผšๅŽ็ซฏๅŠจๆ€ fetch ๆˆๅŠŸๅŽ่‡ชๅŠจๅ›žๅ†™ `config/models.yaml`๏ผŒ้™ๆ€้…็ฝฎไธๅ†ๆปžๅŽ๏ผ›ๅ‰็ซฏๆฏ 60s ่ฝฎ่ฏขๆจกๅž‹ๅˆ—่กจ๏ผŒๆ–ฐๆจกๅž‹ๆ— ้œ€ๅˆทๆ–ฐ้กต้ขๅณๅฏ้€‰ๆ‹ฉ
18
+ - Tuple Schema ๆ”ฏๆŒ๏ผš`prefixItems`๏ผˆJSON Schema 2020-12 tuple๏ผ‰่‡ชๅŠจ่ฝฌๆขไธบ็ญ‰ไปท object schema ๅ‘็ป™ไธŠๆธธ๏ผŒๅ“ๅบ”ไพง่ฟ˜ๅŽŸไธบๆ•ฐ็ป„๏ผ›OpenAI / Gemini / Responses ไธ‰็ซฏ็‚น็ปŸไธ€ๆ”ฏๆŒ
19
 
20
  ### Changed
21
 
src/routes/chat.ts CHANGED
@@ -44,10 +44,10 @@ function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter {
44
  code: "codex_api_error",
45
  },
46
  }),
47
- streamTranslator: (api, response, model, onUsage, onResponseId) =>
48
- streamCodexToOpenAI(api, response, model, onUsage, onResponseId, wantReasoning),
49
- collectTranslator: (api, response, model) =>
50
- collectCodexResponse(api, response, model, wantReasoning),
51
  };
52
  }
53
 
@@ -122,7 +122,7 @@ export function createChatRoutes(
122
  }
123
  const req = parsed.data;
124
 
125
- const codexRequest = translateToCodexRequest(req);
126
  const displayModel = buildDisplayModelName(parseModelName(req.model));
127
  const wantReasoning = !!req.reasoning_effort;
128
 
@@ -134,6 +134,7 @@ export function createChatRoutes(
134
  codexRequest,
135
  model: displayModel,
136
  isStreaming: req.stream,
 
137
  },
138
  makeOpenAIFormat(wantReasoning),
139
  proxyPool,
 
44
  code: "codex_api_error",
45
  },
46
  }),
47
+ streamTranslator: (api, response, model, onUsage, onResponseId, tupleSchema) =>
48
+ streamCodexToOpenAI(api, response, model, onUsage, onResponseId, wantReasoning, tupleSchema),
49
+ collectTranslator: (api, response, model, tupleSchema) =>
50
+ collectCodexResponse(api, response, model, wantReasoning, tupleSchema),
51
  };
52
  }
53
 
 
122
  }
123
  const req = parsed.data;
124
 
125
+ const { codexRequest, tupleSchema } = translateToCodexRequest(req);
126
  const displayModel = buildDisplayModelName(parseModelName(req.model));
127
  const wantReasoning = !!req.reasoning_effort;
128
 
 
134
  codexRequest,
135
  model: displayModel,
136
  isStreaming: req.stream,
137
+ tupleSchema,
138
  },
139
  makeOpenAIFormat(wantReasoning),
140
  proxyPool,
src/routes/gemini.ts CHANGED
@@ -67,8 +67,10 @@ const GEMINI_FORMAT: FormatAdapter = {
67
  ),
68
  format429: (msg) => makeError(429, msg, "RESOURCE_EXHAUSTED"),
69
  formatError: (status, msg) => makeError(status, msg),
70
- streamTranslator: streamCodexToGemini,
71
- collectTranslator: collectCodexToGeminiResponse,
 
 
72
  };
73
 
74
  export function createGeminiRoutes(
@@ -142,7 +144,7 @@ export function createGeminiRoutes(
142
  }
143
  const req = validationResult.data;
144
 
145
- const codexRequest = translateGeminiToCodexRequest(
146
  req,
147
  geminiModel,
148
  );
@@ -159,6 +161,7 @@ export function createGeminiRoutes(
159
  codexRequest,
160
  model: geminiModel,
161
  isStreaming,
 
162
  },
163
  GEMINI_FORMAT,
164
  proxyPool,
 
67
  ),
68
  format429: (msg) => makeError(429, msg, "RESOURCE_EXHAUSTED"),
69
  formatError: (status, msg) => makeError(status, msg),
70
+ streamTranslator: (api, response, model, onUsage, onResponseId, tupleSchema) =>
71
+ streamCodexToGemini(api, response, model, onUsage, onResponseId, tupleSchema),
72
+ collectTranslator: (api, response, model, tupleSchema) =>
73
+ collectCodexToGeminiResponse(api, response, model, tupleSchema),
74
  };
75
 
76
  export function createGeminiRoutes(
 
144
  }
145
  const req = validationResult.data;
146
 
147
+ const { codexRequest, tupleSchema } = translateGeminiToCodexRequest(
148
  req,
149
  geminiModel,
150
  );
 
161
  codexRequest,
162
  model: geminiModel,
163
  isStreaming,
164
+ tupleSchema,
165
  },
166
  GEMINI_FORMAT,
167
  proxyPool,
src/routes/messages.ts CHANGED
@@ -40,9 +40,9 @@ function makeAnthropicFormat(wantThinking: boolean): FormatAdapter {
40
  ),
41
  format429: (msg) => makeError("rate_limit_error", msg),
42
  formatError: (_status, msg) => makeError("api_error", msg),
43
- streamTranslator: (api, response, model, onUsage, onResponseId) =>
44
  streamCodexToAnthropic(api, response, model, onUsage, onResponseId, wantThinking),
45
- collectTranslator: (api, response, model) =>
46
  collectCodexToAnthropicResponse(api, response, model, wantThinking),
47
  };
48
  }
 
40
  ),
41
  format429: (msg) => makeError("rate_limit_error", msg),
42
  formatError: (_status, msg) => makeError("api_error", msg),
43
+ streamTranslator: (api, response, model, onUsage, onResponseId, _tupleSchema) =>
44
  streamCodexToAnthropic(api, response, model, onUsage, onResponseId, wantThinking),
45
+ collectTranslator: (api, response, model, _tupleSchema) =>
46
  collectCodexToAnthropicResponse(api, response, model, wantThinking),
47
  };
48
  }
src/routes/responses.ts CHANGED
@@ -12,7 +12,8 @@ import type { CookieJar } from "../proxy/cookie-jar.js";
12
  import type { ProxyPool } from "../proxy/proxy-pool.js";
13
  import type { CodexResponsesRequest, CodexInputItem, CodexApi } from "../proxy/codex-api.js";
14
  import { getConfig } from "../config.js";
15
- import { injectAdditionalProperties } from "../translation/shared-utils.js";
 
16
  import { parseModelName, resolveModelId, getModelInfo, buildDisplayModelName } from "../models/model-store.js";
17
  import { EmptyResponseError } from "../translation/codex-event-extractor.js";
18
  import {
@@ -34,8 +35,57 @@ async function* streamPassthrough(
34
  _model: string,
35
  onUsage: (u: { input_tokens: number; output_tokens: number }) => void,
36
  onResponseId: (id: string) => void,
 
37
  ): AsyncGenerator<string> {
 
 
 
 
 
38
  for await (const raw of api.parseStream(response)) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  // Re-emit raw SSE event
40
  yield `event: ${raw.event}\ndata: ${JSON.stringify(raw.data)}\n\n`;
41
 
@@ -66,6 +116,7 @@ async function collectPassthrough(
66
  api: CodexApi,
67
  response: Response,
68
  _model: string,
 
69
  ): Promise<{
70
  response: unknown;
71
  usage: { input_tokens: number; output_tokens: number };
@@ -107,6 +158,27 @@ async function collectPassthrough(
107
  throw new EmptyResponseError(responseId, usage);
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return { response: finalResponse, usage, responseId };
111
  }
112
 
@@ -139,8 +211,10 @@ const PASSTHROUGH_FORMAT: FormatAdapter = {
139
  message: msg,
140
  },
141
  }),
142
- streamTranslator: streamPassthrough,
143
- collectTranslator: collectPassthrough,
 
 
144
  };
145
 
146
  // โ”€โ”€ Route โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -263,20 +337,25 @@ export function createResponsesRoutes(
263
  }
264
 
265
  // Pass through text format (JSON mode / structured outputs) as-is
 
266
  if (
267
  isRecord(body.text) &&
268
  isRecord(body.text.format) &&
269
  typeof body.text.format.type === "string"
270
  ) {
 
 
 
 
 
 
271
  codexRequest.text = {
272
  format: {
273
  type: body.text.format.type as "text" | "json_object" | "json_schema",
274
  ...(typeof body.text.format.name === "string"
275
  ? { name: body.text.format.name }
276
  : {}),
277
- ...(isRecord(body.text.format.schema)
278
- ? { schema: injectAdditionalProperties(body.text.format.schema as Record<string, unknown>) }
279
- : {}),
280
  ...(typeof body.text.format.strict === "boolean"
281
  ? { strict: body.text.format.strict }
282
  : {}),
@@ -295,6 +374,7 @@ export function createResponsesRoutes(
295
  codexRequest,
296
  model: displayModel,
297
  isStreaming: clientWantsStream,
 
298
  },
299
  PASSTHROUGH_FORMAT,
300
  proxyPool,
 
12
  import type { ProxyPool } from "../proxy/proxy-pool.js";
13
  import type { CodexResponsesRequest, CodexInputItem, CodexApi } from "../proxy/codex-api.js";
14
  import { getConfig } from "../config.js";
15
+ import { prepareSchema } from "../translation/shared-utils.js";
16
+ import { reconvertTupleValues } from "../translation/tuple-schema.js";
17
  import { parseModelName, resolveModelId, getModelInfo, buildDisplayModelName } from "../models/model-store.js";
18
  import { EmptyResponseError } from "../translation/codex-event-extractor.js";
19
  import {
 
35
  _model: string,
36
  onUsage: (u: { input_tokens: number; output_tokens: number }) => void,
37
  onResponseId: (id: string) => void,
38
+ tupleSchema?: Record<string, unknown> | null,
39
  ): AsyncGenerator<string> {
40
+ // When tupleSchema is present, buffer text deltas and reconvert on completion.
41
+ // This means the client receives zero incremental text โ€” all text arrives at once
42
+ // after response.completed. This is a known tradeoff for tuple reconversion correctness.
43
+ let tupleTextBuffer = tupleSchema ? "" : null;
44
+
45
  for await (const raw of api.parseStream(response)) {
46
+ // Buffer text deltas when tuple reconversion is active
47
+ if (tupleTextBuffer !== null && raw.event === "response.output_text.delta") {
48
+ const data = raw.data;
49
+ if (isRecord(data) && typeof data.delta === "string") {
50
+ tupleTextBuffer += data.delta;
51
+ continue; // suppress this event โ€” will flush reconverted text on completion
52
+ }
53
+ }
54
+
55
+ // On completion, flush reconverted text before emitting the completed event
56
+ if (tupleTextBuffer !== null && tupleSchema && raw.event === "response.completed") {
57
+ if (tupleTextBuffer) {
58
+ let reconvertedText = tupleTextBuffer;
59
+ try {
60
+ const parsed = JSON.parse(tupleTextBuffer) as unknown;
61
+ reconvertedText = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
62
+ } catch (e) {
63
+ console.warn("[tuple-reconvert] streaming JSON parse failed, emitting raw text:", e);
64
+ }
65
+ // Emit a single text delta with reconverted content
66
+ yield `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: reconvertedText })}\n\n`;
67
+ }
68
+ // Patch the completed event's output text if present
69
+ const data = raw.data;
70
+ if (isRecord(data) && isRecord(data.response) && tupleTextBuffer) {
71
+ const resp = data.response;
72
+ if (Array.isArray(resp.output)) {
73
+ for (const item of resp.output as unknown[]) {
74
+ if (isRecord(item) && Array.isArray(item.content)) {
75
+ for (const part of item.content as unknown[]) {
76
+ if (isRecord(part) && part.type === "output_text" && typeof part.text === "string") {
77
+ try {
78
+ const parsed = JSON.parse(part.text) as unknown;
79
+ part.text = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
80
+ } catch { /* leave as-is */ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
  // Re-emit raw SSE event
90
  yield `event: ${raw.event}\ndata: ${JSON.stringify(raw.data)}\n\n`;
91
 
 
116
  api: CodexApi,
117
  response: Response,
118
  _model: string,
119
+ tupleSchema?: Record<string, unknown> | null,
120
  ): Promise<{
121
  response: unknown;
122
  usage: { input_tokens: number; output_tokens: number };
 
158
  throw new EmptyResponseError(responseId, usage);
159
  }
160
 
161
+ // Reconvert tuple objects back to arrays in output text
162
+ if (tupleSchema && isRecord(finalResponse)) {
163
+ const resp = finalResponse;
164
+ if (Array.isArray(resp.output)) {
165
+ for (const item of resp.output as unknown[]) {
166
+ if (isRecord(item) && Array.isArray(item.content)) {
167
+ for (const part of item.content as unknown[]) {
168
+ if (isRecord(part) && part.type === "output_text" && typeof part.text === "string") {
169
+ try {
170
+ const parsed = JSON.parse(part.text) as unknown;
171
+ part.text = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
172
+ } catch (e) {
173
+ console.warn("[tuple-reconvert] collect JSON parse failed, passing through:", e);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
  return { response: finalResponse, usage, responseId };
183
  }
184
 
 
211
  message: msg,
212
  },
213
  }),
214
+ streamTranslator: (api, response, model, onUsage, onResponseId, tupleSchema) =>
215
+ streamPassthrough(api, response, model, onUsage, onResponseId, tupleSchema),
216
+ collectTranslator: (api, response, model, tupleSchema) =>
217
+ collectPassthrough(api, response, model, tupleSchema),
218
  };
219
 
220
  // โ”€โ”€ Route โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
337
  }
338
 
339
  // Pass through text format (JSON mode / structured outputs) as-is
340
+ let tupleSchema: Record<string, unknown> | null = null;
341
  if (
342
  isRecord(body.text) &&
343
  isRecord(body.text.format) &&
344
  typeof body.text.format.type === "string"
345
  ) {
346
+ let formatSchema: Record<string, unknown> | undefined;
347
+ if (isRecord(body.text.format.schema)) {
348
+ const prepared = prepareSchema(body.text.format.schema as Record<string, unknown>);
349
+ formatSchema = prepared.schema;
350
+ tupleSchema = prepared.originalSchema;
351
+ }
352
  codexRequest.text = {
353
  format: {
354
  type: body.text.format.type as "text" | "json_object" | "json_schema",
355
  ...(typeof body.text.format.name === "string"
356
  ? { name: body.text.format.name }
357
  : {}),
358
+ ...(formatSchema ? { schema: formatSchema } : {}),
 
 
359
  ...(typeof body.text.format.strict === "boolean"
360
  ? { strict: body.text.format.strict }
361
  : {}),
 
374
  codexRequest,
375
  model: displayModel,
376
  isStreaming: clientWantsStream,
377
+ tupleSchema,
378
  },
379
  PASSTHROUGH_FORMAT,
380
  proxyPool,
src/routes/shared/proxy-handler.ts CHANGED
@@ -22,6 +22,8 @@ export interface ProxyRequest {
22
  codexRequest: CodexResponsesRequest;
23
  model: string;
24
  isStreaming: boolean;
 
 
25
  }
26
 
27
  /** Format-specific adapter provided by each route. */
@@ -37,11 +39,13 @@ export interface FormatAdapter {
37
  model: string,
38
  onUsage: (u: { input_tokens: number; output_tokens: number; cached_tokens?: number; reasoning_tokens?: number }) => void,
39
  onResponseId: (id: string) => void,
 
40
  ) => AsyncGenerator<string>;
41
  collectTranslator: (
42
  api: CodexApi,
43
  response: Response,
44
  model: string,
 
45
  ) => Promise<{
46
  response: unknown;
47
  usage: { input_tokens: number; output_tokens: number; cached_tokens?: number; reasoning_tokens?: number };
@@ -147,6 +151,7 @@ export async function handleProxyRequest(
147
  usageInfo = u;
148
  },
149
  () => {},
 
150
  )) {
151
  await s.write(chunk);
152
  }
@@ -176,6 +181,7 @@ export async function handleProxyRequest(
176
  currentCodexApi,
177
  currentRawResponse,
178
  req.model,
 
179
  );
180
  accountPool.release(currentEntryId, result.usage);
181
  return c.json(result.response);
 
22
  codexRequest: CodexResponsesRequest;
23
  model: string;
24
  isStreaming: boolean;
25
+ /** Original schema before tupleโ†’object conversion (for response reconversion). */
26
+ tupleSchema?: Record<string, unknown> | null;
27
  }
28
 
29
  /** Format-specific adapter provided by each route. */
 
39
  model: string,
40
  onUsage: (u: { input_tokens: number; output_tokens: number; cached_tokens?: number; reasoning_tokens?: number }) => void,
41
  onResponseId: (id: string) => void,
42
+ tupleSchema?: Record<string, unknown> | null,
43
  ) => AsyncGenerator<string>;
44
  collectTranslator: (
45
  api: CodexApi,
46
  response: Response,
47
  model: string,
48
+ tupleSchema?: Record<string, unknown> | null,
49
  ) => Promise<{
50
  response: unknown;
51
  usage: { input_tokens: number; output_tokens: number; cached_tokens?: number; reasoning_tokens?: number };
 
151
  usageInfo = u;
152
  },
153
  () => {},
154
+ req.tupleSchema,
155
  )) {
156
  await s.write(chunk);
157
  }
 
181
  currentCodexApi,
182
  currentRawResponse,
183
  req.model,
184
+ req.tupleSchema,
185
  );
186
  accountPool.release(currentEntryId, result.usage);
187
  return c.json(result.response);
src/translation/__tests__/tuple-schema.test.ts ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ convertTupleSchemas,
4
+ reconvertTupleValues,
5
+ hasTupleSchemas,
6
+ } from "../tuple-schema.js";
7
+
8
+ describe("hasTupleSchemas", () => {
9
+ it("returns false when no prefixItems", () => {
10
+ expect(hasTupleSchemas({ type: "object", properties: { a: { type: "string" } } })).toBe(false);
11
+ });
12
+
13
+ it("returns true when prefixItems at top level", () => {
14
+ expect(hasTupleSchemas({ type: "array", prefixItems: [{ type: "number" }] })).toBe(true);
15
+ });
16
+
17
+ it("returns true when prefixItems nested in properties", () => {
18
+ const schema = {
19
+ type: "object",
20
+ properties: {
21
+ point: {
22
+ type: "array",
23
+ prefixItems: [{ type: "number" }, { type: "string" }],
24
+ },
25
+ },
26
+ };
27
+ expect(hasTupleSchemas(schema)).toBe(true);
28
+ });
29
+
30
+ it("returns true when prefixItems inside items", () => {
31
+ const schema = {
32
+ type: "array",
33
+ items: {
34
+ type: "array",
35
+ prefixItems: [{ type: "number" }],
36
+ },
37
+ };
38
+ expect(hasTupleSchemas(schema)).toBe(true);
39
+ });
40
+
41
+ it("returns true when prefixItems inside oneOf", () => {
42
+ const schema = {
43
+ oneOf: [
44
+ { type: "string" },
45
+ { type: "array", prefixItems: [{ type: "number" }] },
46
+ ],
47
+ };
48
+ expect(hasTupleSchemas(schema)).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("convertTupleSchemas", () => {
53
+ it("does not mutate input", () => {
54
+ const original = {
55
+ type: "object",
56
+ properties: {
57
+ point: {
58
+ type: "array",
59
+ prefixItems: [{ type: "number" }, { type: "string" }],
60
+ items: false,
61
+ },
62
+ },
63
+ };
64
+ const frozen = JSON.parse(JSON.stringify(original));
65
+ convertTupleSchemas(structuredClone(original));
66
+ expect(original).toEqual(frozen);
67
+ });
68
+
69
+ it("converts simple tuple to object", () => {
70
+ const schema = {
71
+ type: "array",
72
+ prefixItems: [
73
+ { type: "number" },
74
+ { type: "number" },
75
+ { type: "string" },
76
+ ],
77
+ items: false,
78
+ };
79
+ const result = convertTupleSchemas(structuredClone(schema));
80
+ expect(result).toEqual({
81
+ type: "object",
82
+ properties: {
83
+ "0": { type: "number" },
84
+ "1": { type: "number" },
85
+ "2": { type: "string" },
86
+ },
87
+ required: ["0", "1", "2"],
88
+ additionalProperties: false,
89
+ });
90
+ });
91
+
92
+ it("converts tuple nested in object property", () => {
93
+ const schema = {
94
+ type: "object",
95
+ properties: {
96
+ point: {
97
+ type: "array",
98
+ prefixItems: [{ type: "number" }, { type: "string" }],
99
+ items: false,
100
+ },
101
+ name: { type: "string" },
102
+ },
103
+ };
104
+ const result = convertTupleSchemas(structuredClone(schema));
105
+ expect(result.type).toBe("object");
106
+ expect(result.properties).toEqual({
107
+ point: {
108
+ type: "object",
109
+ properties: {
110
+ "0": { type: "number" },
111
+ "1": { type: "string" },
112
+ },
113
+ required: ["0", "1"],
114
+ additionalProperties: false,
115
+ },
116
+ name: { type: "string" },
117
+ });
118
+ });
119
+
120
+ it("converts nested tuple (tuple inside tuple element)", () => {
121
+ const schema = {
122
+ type: "array",
123
+ prefixItems: [
124
+ {
125
+ type: "array",
126
+ prefixItems: [{ type: "number" }, { type: "number" }],
127
+ items: false,
128
+ },
129
+ { type: "string" },
130
+ ],
131
+ items: false,
132
+ };
133
+ const result = convertTupleSchemas(structuredClone(schema));
134
+ expect(result).toEqual({
135
+ type: "object",
136
+ properties: {
137
+ "0": {
138
+ type: "object",
139
+ properties: {
140
+ "0": { type: "number" },
141
+ "1": { type: "number" },
142
+ },
143
+ required: ["0", "1"],
144
+ additionalProperties: false,
145
+ },
146
+ "1": { type: "string" },
147
+ },
148
+ required: ["0", "1"],
149
+ additionalProperties: false,
150
+ });
151
+ });
152
+
153
+ it("converts tuple inside array items", () => {
154
+ const schema = {
155
+ type: "array",
156
+ items: {
157
+ type: "array",
158
+ prefixItems: [{ type: "number" }, { type: "string" }],
159
+ items: false,
160
+ },
161
+ };
162
+ const result = convertTupleSchemas(structuredClone(schema));
163
+ expect(result).toEqual({
164
+ type: "array",
165
+ items: {
166
+ type: "object",
167
+ properties: {
168
+ "0": { type: "number" },
169
+ "1": { type: "string" },
170
+ },
171
+ required: ["0", "1"],
172
+ additionalProperties: false,
173
+ },
174
+ });
175
+ });
176
+
177
+ it("converts tuple inside oneOf", () => {
178
+ const schema = {
179
+ oneOf: [
180
+ { type: "string" },
181
+ {
182
+ type: "array",
183
+ prefixItems: [{ type: "number" }],
184
+ items: false,
185
+ },
186
+ ],
187
+ };
188
+ const result = convertTupleSchemas(structuredClone(schema));
189
+ expect((result.oneOf as Record<string, unknown>[])[1]).toEqual({
190
+ type: "object",
191
+ properties: { "0": { type: "number" } },
192
+ required: ["0"],
193
+ additionalProperties: false,
194
+ });
195
+ });
196
+
197
+ it("converts tuple inside $defs", () => {
198
+ const schema = {
199
+ type: "object",
200
+ properties: {
201
+ coord: { $ref: "#/$defs/Coordinate" },
202
+ },
203
+ $defs: {
204
+ Coordinate: {
205
+ type: "array",
206
+ prefixItems: [{ type: "number" }, { type: "number" }],
207
+ items: false,
208
+ },
209
+ },
210
+ };
211
+ const result = convertTupleSchemas(structuredClone(schema));
212
+ expect((result.$defs as Record<string, unknown>).Coordinate).toEqual({
213
+ type: "object",
214
+ properties: {
215
+ "0": { type: "number" },
216
+ "1": { type: "number" },
217
+ },
218
+ required: ["0", "1"],
219
+ additionalProperties: false,
220
+ });
221
+ });
222
+
223
+ it("leaves non-tuple schemas unchanged", () => {
224
+ const schema = {
225
+ type: "object",
226
+ properties: {
227
+ names: { type: "array", items: { type: "string" } },
228
+ age: { type: "number" },
229
+ },
230
+ };
231
+ const result = convertTupleSchemas(structuredClone(schema));
232
+ expect(result).toEqual(schema);
233
+ });
234
+
235
+ it("handles empty prefixItems", () => {
236
+ const schema = { type: "array", prefixItems: [], items: false };
237
+ const result = convertTupleSchemas(structuredClone(schema));
238
+ expect(result).toEqual({
239
+ type: "object",
240
+ properties: {},
241
+ required: [],
242
+ additionalProperties: false,
243
+ });
244
+ });
245
+
246
+ it("converts tuple without items: false (open tuple)", () => {
247
+ // prefixItems without items: false means additional items are allowed
248
+ // We still convert to object but note this loses the "additional items" semantics
249
+ const schema = {
250
+ type: "array",
251
+ prefixItems: [{ type: "number" }, { type: "string" }],
252
+ };
253
+ const result = convertTupleSchemas(structuredClone(schema));
254
+ expect(result.type).toBe("object");
255
+ expect(result.properties).toEqual({
256
+ "0": { type: "number" },
257
+ "1": { type: "string" },
258
+ });
259
+ expect(result.required).toEqual(["0", "1"]);
260
+ expect(result.additionalProperties).toBe(false);
261
+ });
262
+ });
263
+
264
+ describe("reconvertTupleValues", () => {
265
+ it("converts object with numeric keys back to array", () => {
266
+ const originalSchema = {
267
+ type: "array",
268
+ prefixItems: [{ type: "number" }, { type: "number" }, { type: "string" }],
269
+ items: false,
270
+ };
271
+ const data = { "0": 40.7, "1": -74.0, "2": "NYC" };
272
+ const result = reconvertTupleValues(data, originalSchema);
273
+ expect(result).toEqual([40.7, -74.0, "NYC"]);
274
+ });
275
+
276
+ it("reconverts tuple nested in object", () => {
277
+ const originalSchema = {
278
+ type: "object",
279
+ properties: {
280
+ point: {
281
+ type: "array",
282
+ prefixItems: [{ type: "number" }, { type: "string" }],
283
+ items: false,
284
+ },
285
+ name: { type: "string" },
286
+ },
287
+ };
288
+ const data = { point: { "0": 42, "1": "hello" }, name: "test" };
289
+ const result = reconvertTupleValues(data, originalSchema) as Record<string, unknown>;
290
+ expect(result.point).toEqual([42, "hello"]);
291
+ expect(result.name).toBe("test");
292
+ });
293
+
294
+ it("reconverts nested tuples", () => {
295
+ const originalSchema = {
296
+ type: "array",
297
+ prefixItems: [
298
+ {
299
+ type: "array",
300
+ prefixItems: [{ type: "number" }, { type: "number" }],
301
+ items: false,
302
+ },
303
+ { type: "string" },
304
+ ],
305
+ items: false,
306
+ };
307
+ const data = { "0": { "0": 1, "1": 2 }, "1": "label" };
308
+ const result = reconvertTupleValues(data, originalSchema);
309
+ expect(result).toEqual([[1, 2], "label"]);
310
+ });
311
+
312
+ it("reconverts array of tuples", () => {
313
+ const originalSchema = {
314
+ type: "array",
315
+ items: {
316
+ type: "array",
317
+ prefixItems: [{ type: "number" }, { type: "string" }],
318
+ items: false,
319
+ },
320
+ };
321
+ const data = [
322
+ { "0": 1, "1": "a" },
323
+ { "0": 2, "1": "b" },
324
+ ];
325
+ const result = reconvertTupleValues(data, originalSchema);
326
+ expect(result).toEqual([
327
+ [1, "a"],
328
+ [2, "b"],
329
+ ]);
330
+ });
331
+
332
+ it("handles null values in tuple positions", () => {
333
+ const originalSchema = {
334
+ type: "array",
335
+ prefixItems: [{ type: "number" }, { type: "string" }],
336
+ items: false,
337
+ };
338
+ const data = { "0": null, "1": "hello" };
339
+ const result = reconvertTupleValues(data, originalSchema);
340
+ expect(result).toEqual([null, "hello"]);
341
+ });
342
+
343
+ it("returns data unchanged when schema has no tuples", () => {
344
+ const originalSchema = {
345
+ type: "object",
346
+ properties: { name: { type: "string" } },
347
+ };
348
+ const data = { name: "test" };
349
+ const result = reconvertTupleValues(data, originalSchema);
350
+ expect(result).toEqual({ name: "test" });
351
+ });
352
+
353
+ it("returns primitive data unchanged", () => {
354
+ const originalSchema = { type: "string" };
355
+ expect(reconvertTupleValues("hello", originalSchema)).toBe("hello");
356
+ expect(reconvertTupleValues(42, originalSchema)).toBe(42);
357
+ expect(reconvertTupleValues(null, originalSchema)).toBe(null);
358
+ });
359
+
360
+ it("reconverts tuple inside $defs via $ref", () => {
361
+ const originalSchema = {
362
+ type: "object",
363
+ properties: {
364
+ coord: { $ref: "#/$defs/Coordinate" },
365
+ },
366
+ $defs: {
367
+ Coordinate: {
368
+ type: "array",
369
+ prefixItems: [{ type: "number" }, { type: "number" }],
370
+ items: false,
371
+ },
372
+ },
373
+ };
374
+ const data = { coord: { "0": 40.7, "1": -74.0 } };
375
+ const result = reconvertTupleValues(data, originalSchema) as Record<string, unknown>;
376
+ expect(result.coord).toEqual([40.7, -74.0]);
377
+ });
378
+ });
src/translation/codex-to-gemini.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
  GeminiPart,
17
  } from "../types/gemini.js";
18
  import { iterateCodexEvents, EmptyResponseError } from "./codex-event-extractor.js";
 
19
 
20
  export interface GeminiUsageInfo {
21
  input_tokens: number;
@@ -34,11 +35,13 @@ export async function* streamCodexToGemini(
34
  model: string,
35
  onUsage?: (usage: GeminiUsageInfo) => void,
36
  onResponseId?: (id: string) => void,
 
37
  ): AsyncGenerator<string> {
38
  let inputTokens = 0;
39
  let outputTokens = 0;
40
  let cachedTokens: number | undefined;
41
  let hasContent = false;
 
42
 
43
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
44
  if (evt.responseId) onResponseId?.(evt.responseId);
@@ -94,11 +97,40 @@ export async function* streamCodexToGemini(
94
  case "response.output_text.delta": {
95
  if (evt.textDelta) {
96
  hasContent = true;
97
- const chunk: GeminiGenerateContentResponse = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  candidates: [
99
  {
100
  content: {
101
- parts: [{ text: evt.textDelta }],
102
  role: "model",
103
  },
104
  index: 0,
@@ -106,12 +138,8 @@ export async function* streamCodexToGemini(
106
  ],
107
  modelVersion: model,
108
  };
109
- yield `data: ${JSON.stringify(chunk)}\n\n`;
110
  }
111
- break;
112
- }
113
-
114
- case "response.completed": {
115
  if (evt.usage) {
116
  inputTokens = evt.usage.input_tokens;
117
  outputTokens = evt.usage.output_tokens;
@@ -171,6 +199,7 @@ export async function collectCodexToGeminiResponse(
171
  codexApi: CodexApi,
172
  rawResponse: Response,
173
  model: string,
 
174
  ): Promise<{
175
  response: GeminiGenerateContentResponse;
176
  usage: GeminiUsageInfo;
@@ -223,6 +252,14 @@ export async function collectCodexToGeminiResponse(
223
  throw new EmptyResponseError(responseId, { input_tokens: inputTokens, output_tokens: outputTokens });
224
  }
225
 
 
 
 
 
 
 
 
 
226
  // Build response parts: text + function calls
227
  const parts: GeminiPart[] = [];
228
  if (fullText) {
 
16
  GeminiPart,
17
  } from "../types/gemini.js";
18
  import { iterateCodexEvents, EmptyResponseError } from "./codex-event-extractor.js";
19
+ import { reconvertTupleValues } from "./tuple-schema.js";
20
 
21
  export interface GeminiUsageInfo {
22
  input_tokens: number;
 
35
  model: string,
36
  onUsage?: (usage: GeminiUsageInfo) => void,
37
  onResponseId?: (id: string) => void,
38
+ tupleSchema?: Record<string, unknown> | null,
39
  ): AsyncGenerator<string> {
40
  let inputTokens = 0;
41
  let outputTokens = 0;
42
  let cachedTokens: number | undefined;
43
  let hasContent = false;
44
+ let tupleTextBuffer = tupleSchema ? "" : null;
45
 
46
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
47
  if (evt.responseId) onResponseId?.(evt.responseId);
 
97
  case "response.output_text.delta": {
98
  if (evt.textDelta) {
99
  hasContent = true;
100
+ if (tupleTextBuffer !== null) {
101
+ tupleTextBuffer += evt.textDelta;
102
+ } else {
103
+ const chunk: GeminiGenerateContentResponse = {
104
+ candidates: [
105
+ {
106
+ content: {
107
+ parts: [{ text: evt.textDelta }],
108
+ role: "model",
109
+ },
110
+ index: 0,
111
+ },
112
+ ],
113
+ modelVersion: model,
114
+ };
115
+ yield `data: ${JSON.stringify(chunk)}\n\n`;
116
+ }
117
+ }
118
+ break;
119
+ }
120
+
121
+ case "response.completed": {
122
+ // Flush buffered tuple text as reconverted JSON
123
+ if (tupleTextBuffer !== null && tupleSchema && tupleTextBuffer) {
124
+ let text = tupleTextBuffer;
125
+ try {
126
+ const parsed = JSON.parse(tupleTextBuffer) as unknown;
127
+ text = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
128
+ } catch (e) { console.warn("[tuple-reconvert] streaming JSON parse failed, emitting raw text:", e); }
129
+ const tupleChunk: GeminiGenerateContentResponse = {
130
  candidates: [
131
  {
132
  content: {
133
+ parts: [{ text }],
134
  role: "model",
135
  },
136
  index: 0,
 
138
  ],
139
  modelVersion: model,
140
  };
141
+ yield `data: ${JSON.stringify(tupleChunk)}\n\n`;
142
  }
 
 
 
 
143
  if (evt.usage) {
144
  inputTokens = evt.usage.input_tokens;
145
  outputTokens = evt.usage.output_tokens;
 
199
  codexApi: CodexApi,
200
  rawResponse: Response,
201
  model: string,
202
+ tupleSchema?: Record<string, unknown> | null,
203
  ): Promise<{
204
  response: GeminiGenerateContentResponse;
205
  usage: GeminiUsageInfo;
 
252
  throw new EmptyResponseError(responseId, { input_tokens: inputTokens, output_tokens: outputTokens });
253
  }
254
 
255
+ // Reconvert tuple objects back to arrays
256
+ if (tupleSchema && fullText) {
257
+ try {
258
+ const parsed = JSON.parse(fullText) as unknown;
259
+ fullText = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
260
+ } catch (e) { console.warn("[tuple-reconvert] collect JSON parse failed, passing through:", e); }
261
+ }
262
+
263
  // Build response parts: text + function calls
264
  const parts: GeminiPart[] = [];
265
  if (fullText) {
src/translation/codex-to-openai.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
  ChatCompletionChunkToolCall,
20
  } from "../types/openai.js";
21
  import { iterateCodexEvents, EmptyResponseError, type UsageInfo } from "./codex-event-extractor.js";
 
22
 
23
  export type { UsageInfo };
24
 
@@ -39,11 +40,14 @@ export async function* streamCodexToOpenAI(
39
  onUsage?: (usage: UsageInfo) => void,
40
  onResponseId?: (id: string) => void,
41
  wantReasoning?: boolean,
 
42
  ): AsyncGenerator<string> {
43
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
44
  const created = Math.floor(Date.now() / 1000);
45
  let hasToolCalls = false;
46
  let hasContent = false;
 
 
47
  // Track tool call indices by call_id
48
  const toolCallIndexMap = new Map<string, number>();
49
  let nextToolCallIndex = 0;
@@ -205,24 +209,65 @@ export async function* streamCodexToOpenAI(
205
  case "response.output_text.delta": {
206
  if (evt.textDelta) {
207
  hasContent = true;
208
- yield formatSSE({
209
- id: chunkId,
210
- object: "chat.completion.chunk",
211
- created,
212
- model,
213
- choices: [
214
- {
215
- index: 0,
216
- delta: { content: evt.textDelta },
217
- finish_reason: null,
218
- },
219
- ],
220
- });
 
 
 
 
 
221
  }
222
  break;
223
  }
224
 
225
  case "response.completed": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  if (evt.usage) onUsage?.(evt.usage);
227
  // Inject error text if stream completed with no content
228
  if (!hasContent) {
@@ -286,6 +331,7 @@ export async function collectCodexResponse(
286
  rawResponse: Response,
287
  model: string,
288
  wantReasoning?: boolean,
 
289
  ): Promise<{ response: ChatCompletionResponse; usage: UsageInfo; responseId: string | null }> {
290
  const id = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
291
  const created = Math.floor(Date.now() / 1000);
@@ -330,6 +376,14 @@ export async function collectCodexResponse(
330
  throw new EmptyResponseError(responseId, { input_tokens: promptTokens, output_tokens: completionTokens });
331
  }
332
 
 
 
 
 
 
 
 
 
333
  const hasToolCalls = toolCalls.length > 0;
334
  const message: ChatCompletionResponse["choices"][0]["message"] = {
335
  role: "assistant",
 
19
  ChatCompletionChunkToolCall,
20
  } from "../types/openai.js";
21
  import { iterateCodexEvents, EmptyResponseError, type UsageInfo } from "./codex-event-extractor.js";
22
+ import { reconvertTupleValues } from "./tuple-schema.js";
23
 
24
  export type { UsageInfo };
25
 
 
40
  onUsage?: (usage: UsageInfo) => void,
41
  onResponseId?: (id: string) => void,
42
  wantReasoning?: boolean,
43
+ tupleSchema?: Record<string, unknown> | null,
44
  ): AsyncGenerator<string> {
45
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
46
  const created = Math.floor(Date.now() / 1000);
47
  let hasToolCalls = false;
48
  let hasContent = false;
49
+ // When tupleSchema is set, buffer text deltas to reconvert at response.completed
50
+ let tupleTextBuffer = tupleSchema ? "" : null;
51
  // Track tool call indices by call_id
52
  const toolCallIndexMap = new Map<string, number>();
53
  let nextToolCallIndex = 0;
 
209
  case "response.output_text.delta": {
210
  if (evt.textDelta) {
211
  hasContent = true;
212
+ if (tupleTextBuffer !== null) {
213
+ // Buffer text for reconversion
214
+ tupleTextBuffer += evt.textDelta;
215
+ } else {
216
+ yield formatSSE({
217
+ id: chunkId,
218
+ object: "chat.completion.chunk",
219
+ created,
220
+ model,
221
+ choices: [
222
+ {
223
+ index: 0,
224
+ delta: { content: evt.textDelta },
225
+ finish_reason: null,
226
+ },
227
+ ],
228
+ });
229
+ }
230
  }
231
  break;
232
  }
233
 
234
  case "response.completed": {
235
+ // Flush buffered tuple text as reconverted JSON
236
+ if (tupleTextBuffer !== null && tupleSchema && tupleTextBuffer) {
237
+ try {
238
+ const parsed = JSON.parse(tupleTextBuffer) as unknown;
239
+ const reconverted = reconvertTupleValues(parsed, tupleSchema);
240
+ yield formatSSE({
241
+ id: chunkId,
242
+ object: "chat.completion.chunk",
243
+ created,
244
+ model,
245
+ choices: [
246
+ {
247
+ index: 0,
248
+ delta: { content: JSON.stringify(reconverted) },
249
+ finish_reason: null,
250
+ },
251
+ ],
252
+ });
253
+ } catch (e) {
254
+ console.warn("[tuple-reconvert] streaming JSON parse failed, emitting raw text:", e);
255
+ yield formatSSE({
256
+ id: chunkId,
257
+ object: "chat.completion.chunk",
258
+ created,
259
+ model,
260
+ choices: [
261
+ {
262
+ index: 0,
263
+ delta: { content: tupleTextBuffer },
264
+ finish_reason: null,
265
+ },
266
+ ],
267
+ });
268
+ }
269
+ }
270
+
271
  if (evt.usage) onUsage?.(evt.usage);
272
  // Inject error text if stream completed with no content
273
  if (!hasContent) {
 
331
  rawResponse: Response,
332
  model: string,
333
  wantReasoning?: boolean,
334
+ tupleSchema?: Record<string, unknown> | null,
335
  ): Promise<{ response: ChatCompletionResponse; usage: UsageInfo; responseId: string | null }> {
336
  const id = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
337
  const created = Math.floor(Date.now() / 1000);
 
376
  throw new EmptyResponseError(responseId, { input_tokens: promptTokens, output_tokens: completionTokens });
377
  }
378
 
379
+ // Reconvert tuple objects back to arrays in structured output
380
+ if (tupleSchema && fullText) {
381
+ try {
382
+ const parsed = JSON.parse(fullText) as unknown;
383
+ fullText = JSON.stringify(reconvertTupleValues(parsed, tupleSchema));
384
+ } catch (e) { console.warn("[tuple-reconvert] collect JSON parse failed, passing through:", e); }
385
+ }
386
+
387
  const hasToolCalls = toolCalls.length > 0;
388
  const message: ChatCompletionResponse["choices"][0]["message"] = {
389
  role: "assistant",
src/translation/gemini-to-codex.ts CHANGED
@@ -14,7 +14,7 @@ import type {
14
  } from "../proxy/codex-api.js";
15
  import { parseModelName, getModelInfo } from "../models/model-store.js";
16
  import { getConfig } from "../config.js";
17
- import { buildInstructions, budgetToEffort, injectAdditionalProperties } from "./shared-utils.js";
18
  import { geminiToolsToCodex, geminiToolConfigToCodex } from "./tool-format.js";
19
 
20
  /**
@@ -158,10 +158,15 @@ export function geminiContentsToMessages(
158
  * - model (from URL) โ†’ resolved model ID
159
  * - thinkingConfig โ†’ reasoning.effort
160
  */
 
 
 
 
 
161
  export function translateGeminiToCodexRequest(
162
  req: GeminiGenerateContentRequest,
163
  geminiModel: string,
164
- ): CodexResponsesRequest {
165
  // Extract system instructions
166
  let userInstructions: string;
167
  if (req.systemInstruction) {
@@ -233,17 +238,18 @@ export function translateGeminiToCodexRequest(
233
  }
234
 
235
  // Response format: translate responseMimeType + responseSchema โ†’ text.format
 
236
  const mimeType = req.generationConfig?.responseMimeType;
237
  if (mimeType === "application/json") {
238
  const schema = req.generationConfig?.responseSchema;
239
  if (schema && Object.keys(schema).length > 0) {
240
- // Codex strict mode requires additionalProperties: false on every object
241
- const strictSchema = injectAdditionalProperties(schema as Record<string, unknown>);
242
  request.text = {
243
  format: {
244
  type: "json_schema",
245
  name: "gemini_schema",
246
- schema: strictSchema,
247
  strict: true,
248
  },
249
  };
@@ -252,5 +258,5 @@ export function translateGeminiToCodexRequest(
252
  }
253
  }
254
 
255
- return request;
256
  }
 
14
  } from "../proxy/codex-api.js";
15
  import { parseModelName, getModelInfo } from "../models/model-store.js";
16
  import { getConfig } from "../config.js";
17
+ import { buildInstructions, budgetToEffort, prepareSchema } from "./shared-utils.js";
18
  import { geminiToolsToCodex, geminiToolConfigToCodex } from "./tool-format.js";
19
 
20
  /**
 
158
  * - model (from URL) โ†’ resolved model ID
159
  * - thinkingConfig โ†’ reasoning.effort
160
  */
161
+ export interface GeminiTranslationResult {
162
+ codexRequest: CodexResponsesRequest;
163
+ tupleSchema: Record<string, unknown> | null;
164
+ }
165
+
166
  export function translateGeminiToCodexRequest(
167
  req: GeminiGenerateContentRequest,
168
  geminiModel: string,
169
+ ): GeminiTranslationResult {
170
  // Extract system instructions
171
  let userInstructions: string;
172
  if (req.systemInstruction) {
 
238
  }
239
 
240
  // Response format: translate responseMimeType + responseSchema โ†’ text.format
241
+ let tupleSchema: Record<string, unknown> | null = null;
242
  const mimeType = req.generationConfig?.responseMimeType;
243
  if (mimeType === "application/json") {
244
  const schema = req.generationConfig?.responseSchema;
245
  if (schema && Object.keys(schema).length > 0) {
246
+ const prepared = prepareSchema(schema as Record<string, unknown>);
247
+ tupleSchema = prepared.originalSchema;
248
  request.text = {
249
  format: {
250
  type: "json_schema",
251
  name: "gemini_schema",
252
+ schema: prepared.schema,
253
  strict: true,
254
  },
255
  };
 
258
  }
259
  }
260
 
261
+ return { codexRequest: request, tupleSchema };
262
  }
src/translation/openai-to-codex.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
  } from "../proxy/codex-api.js";
11
  import { parseModelName, getModelInfo } from "../models/model-store.js";
12
  import { getConfig } from "../config.js";
13
- import { buildInstructions, injectAdditionalProperties } from "./shared-utils.js";
14
  import {
15
  openAIToolsToCodex,
16
  openAIToolChoiceToCodex,
@@ -78,9 +78,15 @@ function extractContent(
78
  * - model โ†’ resolved model ID
79
  * - reasoning_effort โ†’ reasoning.effort
80
  */
 
 
 
 
 
 
81
  export function translateToCodexRequest(
82
  req: ChatCompletionRequest,
83
- ): CodexResponsesRequest {
84
  // Collect system/developer messages as instructions
85
  const systemMessages = req.messages.filter(
86
  (m) => m.role === "system" || m.role === "developer",
@@ -193,6 +199,7 @@ export function translateToCodexRequest(
193
  }
194
 
195
  // Response format: translate response_format โ†’ text.format
 
196
  if (req.response_format && req.response_format.type !== "text") {
197
  if (req.response_format.type === "json_object") {
198
  request.text = { format: { type: "json_object" } };
@@ -200,13 +207,15 @@ export function translateToCodexRequest(
200
  req.response_format.type === "json_schema" &&
201
  req.response_format.json_schema
202
  ) {
 
 
 
 
203
  request.text = {
204
  format: {
205
  type: "json_schema",
206
  name: req.response_format.json_schema.name,
207
- schema: injectAdditionalProperties(
208
- req.response_format.json_schema.schema as Record<string, unknown>,
209
- ),
210
  ...(req.response_format.json_schema.strict !== undefined
211
  ? { strict: req.response_format.json_schema.strict }
212
  : {}),
@@ -215,5 +224,5 @@ export function translateToCodexRequest(
215
  }
216
  }
217
 
218
- return request;
219
  }
 
10
  } from "../proxy/codex-api.js";
11
  import { parseModelName, getModelInfo } from "../models/model-store.js";
12
  import { getConfig } from "../config.js";
13
+ import { buildInstructions, prepareSchema } from "./shared-utils.js";
14
  import {
15
  openAIToolsToCodex,
16
  openAIToolChoiceToCodex,
 
78
  * - model โ†’ resolved model ID
79
  * - reasoning_effort โ†’ reasoning.effort
80
  */
81
+ export interface TranslationResult {
82
+ codexRequest: CodexResponsesRequest;
83
+ /** Original schema before tuple conversion โ€” null if no tuples were found. */
84
+ tupleSchema: Record<string, unknown> | null;
85
+ }
86
+
87
  export function translateToCodexRequest(
88
  req: ChatCompletionRequest,
89
+ ): TranslationResult {
90
  // Collect system/developer messages as instructions
91
  const systemMessages = req.messages.filter(
92
  (m) => m.role === "system" || m.role === "developer",
 
199
  }
200
 
201
  // Response format: translate response_format โ†’ text.format
202
+ let tupleSchema: Record<string, unknown> | null = null;
203
  if (req.response_format && req.response_format.type !== "text") {
204
  if (req.response_format.type === "json_object") {
205
  request.text = { format: { type: "json_object" } };
 
207
  req.response_format.type === "json_schema" &&
208
  req.response_format.json_schema
209
  ) {
210
+ const prepared = prepareSchema(
211
+ req.response_format.json_schema.schema as Record<string, unknown>,
212
+ );
213
+ tupleSchema = prepared.originalSchema;
214
  request.text = {
215
  format: {
216
  type: "json_schema",
217
  name: req.response_format.json_schema.name,
218
+ schema: prepared.schema,
 
 
219
  ...(req.response_format.json_schema.strict !== undefined
220
  ? { strict: req.response_format.json_schema.strict }
221
  : {}),
 
224
  }
225
  }
226
 
227
+ return { codexRequest: request, tupleSchema };
228
  }
src/translation/shared-utils.ts CHANGED
@@ -8,6 +8,7 @@ import { readFileSync } from "fs";
8
  import { resolve } from "path";
9
  import { getConfig } from "../config.js";
10
  import { getConfigDir } from "../paths.js";
 
11
 
12
  let cachedDesktopContext: string | null = null;
13
 
@@ -75,6 +76,25 @@ export function injectAdditionalProperties(
75
  return walkSchema(structuredClone(schema), new Set());
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  function walkSchema(node: Record<string, unknown>, seen: Set<object>): Record<string, unknown> {
79
  // Cycle detection โ€” stop if we've already visited this node
80
  if (seen.has(node)) return node;
 
8
  import { resolve } from "path";
9
  import { getConfig } from "../config.js";
10
  import { getConfigDir } from "../paths.js";
11
+ import { hasTupleSchemas, convertTupleSchemas } from "./tuple-schema.js";
12
 
13
  let cachedDesktopContext: string | null = null;
14
 
 
76
  return walkSchema(structuredClone(schema), new Set());
77
  }
78
 
79
+ /**
80
+ * Prepare a JSON Schema for Codex: convert tuple schemas (prefixItems) to
81
+ * equivalent object schemas, then inject additionalProperties: false.
82
+ *
83
+ * Returns the converted schema and the original (pre-conversion) schema if
84
+ * tuples were found (needed for response-side reconversion), or null otherwise.
85
+ */
86
+ export function prepareSchema(
87
+ schema: Record<string, unknown>,
88
+ ): { schema: Record<string, unknown>; originalSchema: Record<string, unknown> | null } {
89
+ const cloned = structuredClone(schema);
90
+ if (!hasTupleSchemas(cloned)) {
91
+ return { schema: walkSchema(cloned, new Set()), originalSchema: null };
92
+ }
93
+ const originalSchema = structuredClone(schema);
94
+ convertTupleSchemas(cloned);
95
+ return { schema: walkSchema(cloned, new Set()), originalSchema };
96
+ }
97
+
98
  function walkSchema(node: Record<string, unknown>, seen: Set<object>): Record<string, unknown> {
99
  // Cycle detection โ€” stop if we've already visited this node
100
  if (seen.has(node)) return node;
src/translation/tuple-schema.ts ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tuple schema conversion โ€” bridges JSON Schema `prefixItems` (tuple) to
3
+ * object-based representation that Codex upstream accepts.
4
+ *
5
+ * Request side: convertTupleSchemas() rewrites prefixItems โ†’ properties with numeric keys
6
+ * Response side: reconvertTupleValues() restores {"0":โ€ฆ,"1":โ€ฆ} back to [โ€ฆ,โ€ฆ]
7
+ */
8
+
9
+ type Schema = Record<string, unknown>;
10
+
11
+ function isRecord(v: unknown): v is Record<string, unknown> {
12
+ return typeof v === "object" && v !== null && !Array.isArray(v);
13
+ }
14
+
15
+ // โ”€โ”€ Detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
16
+
17
+ /** Returns true if the schema tree contains any `prefixItems` node. */
18
+ export function hasTupleSchemas(schema: Schema): boolean {
19
+ return walk(schema, new Set());
20
+ }
21
+
22
+ function walk(node: Schema, seen: Set<object>): boolean {
23
+ if (seen.has(node)) return false;
24
+ seen.add(node);
25
+
26
+ if (Array.isArray(node.prefixItems)) return true;
27
+
28
+ // properties
29
+ if (isRecord(node.properties)) {
30
+ for (const v of Object.values(node.properties)) {
31
+ if (isRecord(v) && walk(v, seen)) return true;
32
+ }
33
+ }
34
+
35
+ // items
36
+ if (isRecord(node.items) && walk(node.items as Schema, seen)) return true;
37
+
38
+ // combinators
39
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
40
+ if (Array.isArray(node[key])) {
41
+ for (const entry of node[key] as unknown[]) {
42
+ if (isRecord(entry) && walk(entry, seen)) return true;
43
+ }
44
+ }
45
+ }
46
+
47
+ // $defs / definitions
48
+ for (const key of ["$defs", "definitions"] as const) {
49
+ if (isRecord(node[key])) {
50
+ for (const v of Object.values(node[key] as Schema)) {
51
+ if (isRecord(v) && walk(v, seen)) return true;
52
+ }
53
+ }
54
+ }
55
+
56
+ // conditional
57
+ for (const key of ["if", "then", "else", "not"] as const) {
58
+ if (isRecord(node[key]) && walk(node[key] as Schema, seen)) return true;
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ // โ”€โ”€ Request-side conversion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
+
66
+ /**
67
+ * Recursively convert `prefixItems` tuple schemas to equivalent object schemas.
68
+ * Input must be a clone โ€” this function mutates in place and returns the same reference.
69
+ */
70
+ export function convertTupleSchemas(node: Schema): Schema {
71
+ return convertWalk(node, new Set());
72
+ }
73
+
74
+ function convertWalk(node: Schema, seen: Set<object>): Schema {
75
+ if (seen.has(node)) return node;
76
+ seen.add(node);
77
+
78
+ // Convert this node if it has prefixItems
79
+ if (Array.isArray(node.prefixItems)) {
80
+ const items = node.prefixItems as unknown[];
81
+ const properties: Record<string, unknown> = {};
82
+ const required: string[] = [];
83
+
84
+ for (let i = 0; i < items.length; i++) {
85
+ const key = String(i);
86
+ properties[key] = isRecord(items[i]) ? convertWalk(items[i] as Schema, seen) : items[i];
87
+ required.push(key);
88
+ }
89
+
90
+ node.type = "object";
91
+ node.properties = properties;
92
+ node.required = required;
93
+ node.additionalProperties = false;
94
+ delete node.prefixItems;
95
+ delete node.items;
96
+ return node;
97
+ }
98
+
99
+ // Recurse into properties
100
+ if (isRecord(node.properties)) {
101
+ for (const [k, v] of Object.entries(node.properties)) {
102
+ if (isRecord(v)) node.properties[k] = convertWalk(v, seen);
103
+ }
104
+ }
105
+
106
+ // Recurse into items
107
+ if (isRecord(node.items)) {
108
+ node.items = convertWalk(node.items as Schema, seen);
109
+ }
110
+
111
+ // Recurse into combinators
112
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
113
+ if (Array.isArray(node[key])) {
114
+ node[key] = (node[key] as unknown[]).map((entry) =>
115
+ isRecord(entry) ? convertWalk(entry, seen) : entry,
116
+ );
117
+ }
118
+ }
119
+
120
+ // Recurse into $defs / definitions
121
+ for (const key of ["$defs", "definitions"] as const) {
122
+ if (isRecord(node[key])) {
123
+ const defs = node[key] as Schema;
124
+ for (const [k, v] of Object.entries(defs)) {
125
+ if (isRecord(v)) defs[k] = convertWalk(v, seen);
126
+ }
127
+ }
128
+ }
129
+
130
+ // Recurse into conditional
131
+ for (const key of ["if", "then", "else", "not"] as const) {
132
+ if (isRecord(node[key])) {
133
+ node[key] = convertWalk(node[key] as Schema, seen);
134
+ }
135
+ }
136
+
137
+ return node;
138
+ }
139
+
140
+ // โ”€โ”€ Response-side reconversion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
141
+
142
+ /**
143
+ * Schema-guided recursive reconversion: turn {"0":โ€ฆ,"1":โ€ฆ} objects back to arrays
144
+ * wherever the *original* schema had `prefixItems`.
145
+ */
146
+ export function reconvertTupleValues(data: unknown, schema: Schema, rootSchema?: Schema): unknown {
147
+ const root = rootSchema ?? schema;
148
+
149
+ // Resolve $ref
150
+ if (typeof schema.$ref === "string") {
151
+ const resolved = resolveRef(schema.$ref, root);
152
+ if (resolved) return reconvertTupleValues(data, resolved, root);
153
+ return data;
154
+ }
155
+
156
+ // Tuple node: original schema has prefixItems โ†’ data should be {"0":โ€ฆ,"1":โ€ฆ} ๏ฟฝ๏ฟฝ convert to array
157
+ if (Array.isArray(schema.prefixItems) && isRecord(data)) {
158
+ const items = schema.prefixItems as unknown[];
159
+ const result: unknown[] = [];
160
+ for (let i = 0; i < items.length; i++) {
161
+ const key = String(i);
162
+ const val = data[key];
163
+ const itemSchema = items[i];
164
+ result.push(isRecord(itemSchema) ? reconvertTupleValues(val, itemSchema, root) : val);
165
+ }
166
+ return result;
167
+ }
168
+
169
+ // Object with properties โ†’ recurse into each property
170
+ if (isRecord(schema.properties) && isRecord(data)) {
171
+ const result: Record<string, unknown> = { ...data };
172
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
173
+ if (key in result && isRecord(propSchema)) {
174
+ result[key] = reconvertTupleValues(result[key], propSchema, root);
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+
180
+ // Array with items schema โ†’ recurse into each element
181
+ if (isRecord(schema.items) && Array.isArray(data)) {
182
+ return data.map((el) => reconvertTupleValues(el, schema.items as Schema, root));
183
+ }
184
+
185
+ // Combinators โ€” try to find matching branch (heuristic: first branch that has prefixItems)
186
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
187
+ if (Array.isArray(schema[key])) {
188
+ for (const branch of schema[key] as unknown[]) {
189
+ if (isRecord(branch) && hasTupleSchemas(branch)) {
190
+ return reconvertTupleValues(data, branch, root);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ return data;
197
+ }
198
+
199
+ function resolveRef(ref: string, root: Schema): Schema | undefined {
200
+ // Only handle internal refs: #/$defs/Name or #/definitions/Name
201
+ const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/);
202
+ if (!match) return undefined;
203
+ const defs = root[match[1]];
204
+ if (!isRecord(defs)) return undefined;
205
+ const resolved = defs[match[2]];
206
+ return isRecord(resolved) ? resolved : undefined;
207
+ }