Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
4779e44
1
Parent(s): 0b4f97d
fix: recursive additionalProperties injection + upstream error status propagation
Browse files1. Add injectAdditionalProperties() to recursively inject `additionalProperties: false`
on every object-type node in JSON schemas. Codex API requires this explicitly but
OpenAI's native API auto-injects it — our proxy was passing schemas verbatim causing 400s.
2. Fix CONNECT tunnel header parsing in curl-cli-transport: loop to skip intermediate
header blocks (CONNECT 200, 100 Continue) before parsing the actual response headers.
3. Extract upstream HTTP status from error messages in non-streaming collect path
instead of hardcoding 502.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/routes/responses.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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 { parseModelName, resolveModelId, getModelInfo, buildDisplayModelName } from "../models/model-store.js";
|
| 16 |
import { EmptyResponseError } from "../translation/codex-event-extractor.js";
|
| 17 |
import {
|
|
@@ -274,7 +275,7 @@ export function createResponsesRoutes(
|
|
| 274 |
? { name: body.text.format.name }
|
| 275 |
: {}),
|
| 276 |
...(isRecord(body.text.format.schema)
|
| 277 |
-
? { schema: body.text.format.schema as Record<string, unknown> }
|
| 278 |
: {}),
|
| 279 |
...(typeof body.text.format.strict === "boolean"
|
| 280 |
? { strict: body.text.format.strict }
|
|
|
|
| 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 {
|
|
|
|
| 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 }
|
src/routes/shared/proxy-handler.ts
CHANGED
|
@@ -207,8 +207,12 @@ export async function handleProxyRequest(
|
|
| 207 |
return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
|
| 208 |
}
|
| 209 |
const msg = collectErr instanceof Error ? collectErr.message : "Unknown error";
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}
|
| 213 |
}
|
| 214 |
}
|
|
|
|
| 207 |
return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
|
| 208 |
}
|
| 209 |
const msg = collectErr instanceof Error ? collectErr.message : "Unknown error";
|
| 210 |
+
// Extract upstream status from error message (e.g. "HTTP/1.1 400 Bad Request")
|
| 211 |
+
const statusMatch = msg.match(/HTTP\/[\d.]+ (\d{3})/);
|
| 212 |
+
const upstreamStatus = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
| 213 |
+
const code = (upstreamStatus >= 400 && upstreamStatus < 600 ? upstreamStatus : 502) as StatusCode;
|
| 214 |
+
c.status(code);
|
| 215 |
+
return c.json(fmt.formatError(code, msg));
|
| 216 |
}
|
| 217 |
}
|
| 218 |
}
|
src/tls/curl-cli-transport.ts
CHANGED
|
@@ -101,30 +101,42 @@ export class CurlCliTransport implements TlsTransport {
|
|
| 101 |
|
| 102 |
// Accumulate until we find \r\n\r\n header separator
|
| 103 |
headerBuf = Buffer.concat([headerBuf, chunk]);
|
| 104 |
-
const separatorIdx = headerBuf.indexOf("\r\n\r\n");
|
| 105 |
-
if (separatorIdx === -1) return;
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
|
| 112 |
-
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
bodyController?.enqueue(new Uint8Array(remaining));
|
| 116 |
-
}
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
});
|
| 129 |
|
| 130 |
let stderrBuf = "";
|
|
@@ -284,3 +296,9 @@ function parseHeaderDump(headerBlock: string): {
|
|
| 284 |
|
| 285 |
return { status, headers, setCookieHeaders };
|
| 286 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
// Accumulate until we find \r\n\r\n header separator
|
| 103 |
headerBuf = Buffer.concat([headerBuf, chunk]);
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
// Loop to skip intermediate header blocks (CONNECT tunnel 200, 100 Continue, etc.)
|
| 106 |
+
while (!headersParsed) {
|
| 107 |
+
const separatorIdx = headerBuf.indexOf("\r\n\r\n");
|
| 108 |
+
if (separatorIdx === -1) return; // wait for more data
|
| 109 |
|
| 110 |
+
const headerBlock = headerBuf.subarray(0, separatorIdx).toString("utf-8");
|
| 111 |
+
const remainder = headerBuf.subarray(separatorIdx + 4);
|
| 112 |
|
| 113 |
+
const parsed = parseHeaderDump(headerBlock);
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
// Skip intermediate responses: CONNECT tunnel, 1xx informational
|
| 116 |
+
if (parsed.status < 200 || isConnectResponse(headerBlock)) {
|
| 117 |
+
headerBuf = remainder;
|
| 118 |
+
continue;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Real response found
|
| 122 |
+
headersParsed = true;
|
| 123 |
+
clearTimeout(headerTimer);
|
| 124 |
|
| 125 |
+
if (remainder.length > 0) {
|
| 126 |
+
bodyController?.enqueue(new Uint8Array(remainder));
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (signal) {
|
| 130 |
+
signal.removeEventListener("abort", onAbort);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
resolve({
|
| 134 |
+
status: parsed.status,
|
| 135 |
+
headers: parsed.headers,
|
| 136 |
+
body: bodyStream,
|
| 137 |
+
setCookieHeaders: parsed.setCookieHeaders,
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
});
|
| 141 |
|
| 142 |
let stderrBuf = "";
|
|
|
|
| 296 |
|
| 297 |
return { status, headers, setCookieHeaders };
|
| 298 |
}
|
| 299 |
+
|
| 300 |
+
/** Detect CONNECT tunnel responses (e.g. "HTTP/1.1 200 Connection established"). */
|
| 301 |
+
function isConnectResponse(headerBlock: string): boolean {
|
| 302 |
+
const firstLine = headerBlock.split("\r\n")[0] ?? "";
|
| 303 |
+
return /connection\s+established/i.test(firstLine);
|
| 304 |
+
}
|
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 } from "./shared-utils.js";
|
| 18 |
import { geminiToolsToCodex, geminiToolConfigToCodex } from "./tool-format.js";
|
| 19 |
|
| 20 |
/**
|
|
@@ -237,8 +237,8 @@ export function translateGeminiToCodexRequest(
|
|
| 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
|
| 241 |
-
const strictSchema =
|
| 242 |
request.text = {
|
| 243 |
format: {
|
| 244 |
type: "json_schema",
|
|
|
|
| 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 |
/**
|
|
|
|
| 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",
|
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 } from "./shared-utils.js";
|
| 14 |
import {
|
| 15 |
openAIToolsToCodex,
|
| 16 |
openAIToolChoiceToCodex,
|
|
@@ -204,7 +204,9 @@ export function translateToCodexRequest(
|
|
| 204 |
format: {
|
| 205 |
type: "json_schema",
|
| 206 |
name: req.response_format.json_schema.name,
|
| 207 |
-
schema:
|
|
|
|
|
|
|
| 208 |
...(req.response_format.json_schema.strict !== undefined
|
| 209 |
? { strict: req.response_format.json_schema.strict }
|
| 210 |
: {}),
|
|
|
|
| 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,
|
|
|
|
| 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 |
: {}),
|
src/translation/shared-utils.ts
CHANGED
|
@@ -61,3 +61,77 @@ export function budgetToEffort(budget: number | undefined): string | undefined {
|
|
| 61 |
if (budget < 20000) return "high";
|
| 62 |
return "xhigh";
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if (budget < 20000) return "high";
|
| 62 |
return "xhigh";
|
| 63 |
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Recursively inject `additionalProperties: false` into every object-type node
|
| 67 |
+
* of a JSON Schema. Deep-clones input to avoid mutation.
|
| 68 |
+
*
|
| 69 |
+
* Codex API requires explicit `additionalProperties: false` on every object in
|
| 70 |
+
* strict mode; OpenAI's native API auto-injects this but our proxy must do it.
|
| 71 |
+
*/
|
| 72 |
+
export function injectAdditionalProperties(
|
| 73 |
+
schema: Record<string, unknown>,
|
| 74 |
+
): Record<string, unknown> {
|
| 75 |
+
return walkSchema(structuredClone(schema));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
| 79 |
+
// Inject on object types that don't already specify additionalProperties
|
| 80 |
+
if (node.type === "object" && node.additionalProperties === undefined) {
|
| 81 |
+
node.additionalProperties = false;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Traverse properties
|
| 85 |
+
if (isRecord(node.properties)) {
|
| 86 |
+
for (const key of Object.keys(node.properties)) {
|
| 87 |
+
const prop = node.properties[key];
|
| 88 |
+
if (isRecord(prop)) {
|
| 89 |
+
node.properties[key] = walkSchema(prop);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Traverse $defs / definitions
|
| 95 |
+
for (const defsKey of ["$defs", "definitions"] as const) {
|
| 96 |
+
if (isRecord(node[defsKey])) {
|
| 97 |
+
const defs = node[defsKey] as Record<string, unknown>;
|
| 98 |
+
for (const key of Object.keys(defs)) {
|
| 99 |
+
if (isRecord(defs[key])) {
|
| 100 |
+
defs[key] = walkSchema(defs[key] as Record<string, unknown>);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// Traverse items (array items)
|
| 107 |
+
if (isRecord(node.items)) {
|
| 108 |
+
node.items = walkSchema(node.items as Record<string, unknown>);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Traverse prefixItems
|
| 112 |
+
if (Array.isArray(node.prefixItems)) {
|
| 113 |
+
node.prefixItems = node.prefixItems.map((item: unknown) =>
|
| 114 |
+
isRecord(item) ? walkSchema(item) : item,
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Traverse combinators: oneOf, anyOf, allOf
|
| 119 |
+
for (const combiner of ["oneOf", "anyOf", "allOf"] as const) {
|
| 120 |
+
if (Array.isArray(node[combiner])) {
|
| 121 |
+
node[combiner] = (node[combiner] as unknown[]).map((entry: unknown) =>
|
| 122 |
+
isRecord(entry) ? walkSchema(entry) : entry,
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Traverse not
|
| 128 |
+
if (isRecord(node.not)) {
|
| 129 |
+
node.not = walkSchema(node.not as Record<string, unknown>);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return node;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
| 136 |
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
| 137 |
+
}
|