Spaces:
Paused
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 +1 -0
- src/routes/chat.ts +6 -5
- src/routes/gemini.ts +6 -3
- src/routes/messages.ts +2 -2
- src/routes/responses.ts +86 -6
- src/routes/shared/proxy-handler.ts +6 -0
- src/translation/__tests__/tuple-schema.test.ts +378 -0
- src/translation/codex-to-gemini.ts +44 -7
- src/translation/codex-to-openai.ts +67 -13
- src/translation/gemini-to-codex.ts +12 -6
- src/translation/openai-to-codex.ts +15 -6
- src/translation/shared-utils.ts +20 -0
- src/translation/tuple-schema.ts +207 -0
|
@@ -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 |
|
|
@@ -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,
|
|
@@ -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:
|
| 71 |
-
|
|
|
|
|
|
|
| 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,
|
|
@@ -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 |
}
|
|
@@ -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 {
|
|
|
|
| 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:
|
| 143 |
-
|
|
|
|
|
|
|
| 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 |
-
...(
|
| 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,
|
|
@@ -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);
|
|
@@ -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 |
+
});
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
candidates: [
|
| 99 |
{
|
| 100 |
content: {
|
| 101 |
-
parts: [{ text
|
| 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(
|
| 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) {
|
|
@@ -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 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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",
|
|
@@ -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,
|
| 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 |
-
):
|
| 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 |
-
|
| 241 |
-
|
| 242 |
request.text = {
|
| 243 |
format: {
|
| 244 |
type: "json_schema",
|
| 245 |
name: "gemini_schema",
|
| 246 |
-
schema:
|
| 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 |
}
|
|
@@ -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,
|
| 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 |
-
):
|
| 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:
|
| 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 |
}
|
|
@@ -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;
|
|
@@ -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 |
+
}
|