icebear0828 Claude Opus 4.6 commited on
Commit
53d3b3b
·
1 Parent(s): 55594d3

feat: add Structured Outputs support (response_format → text.format)

Browse files

OpenAI response_format (json_object/json_schema) and Gemini
responseMimeType/responseSchema now translate to Codex text.format.
/v1/responses endpoint passes through text field as-is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

CHANGELOG.md CHANGED
@@ -10,6 +10,7 @@
10
 
11
  - 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
12
  - Model-aware 多计划账号路由:不同 plan(free/plus/business)的账号自动路由到各自支持的模型,business 账号可继续使用 gpt-5.4 等高端模型 (#57)
 
13
 
14
  ### Changed
15
 
 
10
 
11
  - 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
12
  - Model-aware 多计划账号路由:不同 plan(free/plus/business)的账号自动路由到各自支持的模型,business 账号可继续使用 gpt-5.4 等高端模型 (#57)
13
+ - Structured Outputs 支持:`/v1/chat/completions` 支持 `response_format`(`json_object` / `json_schema`),Gemini 端点支持 `responseMimeType` + `responseSchema`,自动翻译为 Codex Responses API 的 `text.format`;`/v1/responses` 直通 `text` 字段
14
 
15
  ### Changed
16
 
README.md CHANGED
@@ -107,6 +107,7 @@ curl http://localhost:8080/v1/chat/completions \
107
  - 完全兼容 `/v1/chat/completions`(OpenAI)、`/v1/messages`(Anthropic)和 Gemini 格式
108
  - 支持 SSE 流式输出,可直接对接所有 OpenAI SDK 和客户端
109
  - 自动完成 Chat Completions ↔ Codex Responses API 双向协议转换
 
110
 
111
  ### 2. 🔐 账号管理与智能轮换 (Auth & Multi-Account)
112
  - **OAuth PKCE 登录** — 浏览器一键授权,无需手动复制 Token
 
107
  - 完全兼容 `/v1/chat/completions`(OpenAI)、`/v1/messages`(Anthropic)和 Gemini 格式
108
  - 支持 SSE 流式输出,可直接对接所有 OpenAI SDK 和客户端
109
  - 自动完成 Chat Completions ↔ Codex Responses API 双向协议转换
110
+ - **Structured Outputs** — 支持 `response_format`(OpenAI `json_object` / `json_schema`)和 Gemini `responseMimeType`,强制 JSON 结构化输出无需提示词
111
 
112
  ### 2. 🔐 账号管理与智能轮换 (Auth & Multi-Account)
113
  - **OAuth PKCE 登录** — 浏览器一键授权,无需手动复制 Token
README_EN.md CHANGED
@@ -103,6 +103,7 @@ curl http://localhost:8080/v1/chat/completions \
103
  - Compatible with `/v1/chat/completions` (OpenAI), `/v1/messages` (Anthropic), and Gemini formats
104
  - SSE streaming output, works with all OpenAI SDKs and clients
105
  - Automatic bidirectional translation between Chat Completions and Codex Responses API
 
106
 
107
  ### 2. 🔐 Account Management & Smart Rotation
108
  - **OAuth PKCE login** — one-click browser auth, no manual token copying
 
103
  - Compatible with `/v1/chat/completions` (OpenAI), `/v1/messages` (Anthropic), and Gemini formats
104
  - SSE streaming output, works with all OpenAI SDKs and clients
105
  - Automatic bidirectional translation between Chat Completions and Codex Responses API
106
+ - **Structured Outputs** — supports `response_format` (OpenAI `json_object` / `json_schema`) and Gemini `responseMimeType` for enforcing JSON output without prompt engineering
107
 
108
  ### 2. 🔐 Account Management & Smart Rotation
109
  - **OAuth PKCE login** — one-click browser auth, no manual token copying
src/proxy/codex-api.ts CHANGED
@@ -34,6 +34,15 @@ export interface CodexResponsesRequest {
34
  tools?: unknown[];
35
  /** Optional: tool choice strategy */
36
  tool_choice?: string | { type: string; name: string };
 
 
 
 
 
 
 
 
 
37
  }
38
 
39
  /** Structured content part for multimodal Codex input. */
 
34
  tools?: unknown[];
35
  /** Optional: tool choice strategy */
36
  tool_choice?: string | { type: string; name: string };
37
+ /** Optional: text output format (JSON mode / structured outputs) */
38
+ text?: {
39
+ format: {
40
+ type: "text" | "json_object" | "json_schema";
41
+ name?: string;
42
+ schema?: Record<string, unknown>;
43
+ strict?: boolean;
44
+ };
45
+ };
46
  }
47
 
48
  /** Structured content part for multimodal Codex input. */
src/routes/responses.ts CHANGED
@@ -261,6 +261,28 @@ export function createResponsesRoutes(
261
  codexRequest.tool_choice = body.tool_choice as CodexResponsesRequest["tool_choice"];
262
  }
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  // Client can request non-streaming (collect mode), but upstream is always stream
265
  const clientWantsStream = body.stream !== false;
266
 
 
261
  codexRequest.tool_choice = body.tool_choice as CodexResponsesRequest["tool_choice"];
262
  }
263
 
264
+ // Pass through text format (JSON mode / structured outputs) as-is
265
+ if (
266
+ isRecord(body.text) &&
267
+ isRecord(body.text.format) &&
268
+ typeof body.text.format.type === "string"
269
+ ) {
270
+ codexRequest.text = {
271
+ format: {
272
+ type: body.text.format.type as "text" | "json_object" | "json_schema",
273
+ ...(typeof body.text.format.name === "string"
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 }
281
+ : {}),
282
+ },
283
+ };
284
+ }
285
+
286
  // Client can request non-streaming (collect mode), but upstream is always stream
287
  const clientWantsStream = body.stream !== false;
288
 
src/translation/gemini-to-codex.ts CHANGED
@@ -232,5 +232,25 @@ export function translateGeminiToCodexRequest(
232
  request.service_tier = serviceTier;
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return request;
236
  }
 
232
  request.service_tier = serviceTier;
233
  }
234
 
235
+ // Response format: translate responseMimeType + responseSchema → text.format
236
+ const mimeType = req.generationConfig?.responseMimeType;
237
+ if (mimeType === "application/json") {
238
+ const schema = req.generationConfig?.responseSchema;
239
+ if (schema && Object.keys(schema).length > 0) {
240
+ // Codex strict mode requires additionalProperties: false at root level
241
+ const strictSchema = { additionalProperties: false, ...schema };
242
+ request.text = {
243
+ format: {
244
+ type: "json_schema",
245
+ name: "gemini_schema",
246
+ schema: strictSchema,
247
+ strict: true,
248
+ },
249
+ };
250
+ } else {
251
+ request.text = { format: { type: "json_object" } };
252
+ }
253
+ }
254
+
255
  return request;
256
  }
src/translation/openai-to-codex.ts CHANGED
@@ -192,5 +192,26 @@ export function translateToCodexRequest(
192
  request.service_tier = serviceTier;
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  return request;
196
  }
 
192
  request.service_tier = serviceTier;
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" } };
199
+ } else if (
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: req.response_format.json_schema.schema,
208
+ ...(req.response_format.json_schema.strict !== undefined
209
+ ? { strict: req.response_format.json_schema.strict }
210
+ : {}),
211
+ },
212
+ };
213
+ }
214
+ }
215
+
216
  return request;
217
  }
src/types/gemini.ts CHANGED
@@ -40,6 +40,8 @@ const GeminiGenerationConfigSchema = z.object({
40
  maxOutputTokens: z.number().optional(),
41
  stopSequences: z.array(z.string()).optional(),
42
  thinkingConfig: GeminiThinkingConfigSchema.optional(),
 
 
43
  });
44
 
45
  export const GeminiGenerateContentRequestSchema = z.object({
 
40
  maxOutputTokens: z.number().optional(),
41
  stopSequences: z.array(z.string()).optional(),
42
  thinkingConfig: GeminiThinkingConfigSchema.optional(),
43
+ responseMimeType: z.string().optional(),
44
+ responseSchema: z.record(z.unknown()).optional(),
45
  });
46
 
47
  export const GeminiGenerateContentRequestSchema = z.object({
src/types/openai.ts CHANGED
@@ -60,6 +60,15 @@ export const ChatCompletionRequestSchema = z.object({
60
  z.object({ type: z.literal("function"), function: z.object({ name: z.string() }) }),
61
  ]).optional(),
62
  parallel_tool_calls: z.boolean().optional(),
 
 
 
 
 
 
 
 
 
63
  // Legacy function format (accepted for compatibility, not forwarded to Codex)
64
  functions: z.array(z.object({
65
  name: z.string(),
 
60
  z.object({ type: z.literal("function"), function: z.object({ name: z.string() }) }),
61
  ]).optional(),
62
  parallel_tool_calls: z.boolean().optional(),
63
+ // Structured output format (JSON mode / JSON Schema)
64
+ response_format: z.object({
65
+ type: z.enum(["text", "json_object", "json_schema"]),
66
+ json_schema: z.object({
67
+ name: z.string(),
68
+ schema: z.record(z.unknown()),
69
+ strict: z.boolean().optional(),
70
+ }).optional(),
71
+ }).optional(),
72
  // Legacy function format (accepted for compatibility, not forwarded to Codex)
73
  functions: z.array(z.object({
74
  name: z.string(),