icebear0828 Claude Opus 4.6 commited on
Commit
4779e44
·
1 Parent(s): 0b4f97d

fix: recursive additionalProperties injection + upstream error status propagation

Browse files

1. 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
- c.status(502);
211
- return c.json(fmt.formatError(502, msg));
 
 
 
 
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
- headersParsed = true;
108
- clearTimeout(headerTimer);
109
- const headerBlock = headerBuf.subarray(0, separatorIdx).toString("utf-8");
110
- const remaining = headerBuf.subarray(separatorIdx + 4);
111
 
112
- const { status, headers: parsedHeaders, setCookieHeaders } = parseHeaderDump(headerBlock);
 
113
 
114
- if (remaining.length > 0) {
115
- bodyController?.enqueue(new Uint8Array(remaining));
116
- }
117
 
118
- if (signal) {
119
- signal.removeEventListener("abort", onAbort);
120
- }
 
 
 
 
 
 
121
 
122
- resolve({
123
- status,
124
- headers: parsedHeaders,
125
- body: bodyStream,
126
- setCookieHeaders,
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 at root level
241
- const strictSchema = { additionalProperties: false, ...schema };
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: req.response_format.json_schema.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
+ }