icebear0828 Claude Opus 4.6 commited on
Commit
8068f1c
·
1 Parent(s): b6eac9d

feat: /v1/responses passthrough endpoint + missing SSE event types + Docker docs fix (#37, #38)

Browse files

- Add /v1/responses endpoint for raw Codex Responses API passthrough with
multi-account load balancing, no format translation
- Recognize response.output_item.done, response.incomplete, response.queued
SSE events to eliminate "Unknown event" debug log noise
- Add cp .env.example .env step to Docker quick start in README

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

CHANGELOG.md CHANGED
@@ -8,10 +8,14 @@
8
 
9
  ### Fixed
10
 
 
 
11
  - 流式 SSE 请求不再设置 `--max-time` 墙钟超时,修复思考链(reasoning/thinking)在 60 秒处中断的问题;连接保护由 header 超时 + AbortSignal 提供,非流式请求(models、usage)超时不受影响
12
 
13
  ### Added
14
 
 
 
15
  - 模型名后缀系统:通过模型名嵌入推理等级和速度模式(如 `gpt-5.4-high-fast`),CLI 工具(Claude Code、opencode 等)无需额外参数即可控制推理强度和 Fast 模式
16
  - `service_tier` 支持:接受 API 请求体中的 `service_tier` 字段("fast" / "flex"),或通过 `-fast` 模型名后缀自动设置
17
  - Dashboard Speed 切换:模型选择器下方新增 Standard / Fast 速度切换按钮
 
8
 
9
  ### Fixed
10
 
11
+ - README Docker 快速开始补充 `cp .env.example .env` 步骤,修复新用户因缺少 `.env` 文件导致 `docker compose up -d` 启动失败的问题 (#38)
12
+ - 识别 `response.output_item.done`、`response.incomplete`、`response.queued` Codex SSE 事件,消除 "Unknown event" 日志噪音
13
  - 流式 SSE 请求不再设置 `--max-time` 墙钟超时,修复思考链(reasoning/thinking)在 60 秒处中断的问题;连接保护由 header 超时 + AbortSignal 提供,非流式请求(models、usage)超时不受影响
14
 
15
  ### Added
16
 
17
+ - `/v1/responses` 端点:Codex Responses API 直通,无格式转换,支持原始 SSE 事件流和多账号负载均衡
18
+
19
  - 模型名后缀系统:通过模型名嵌入推理等级和速度模式(如 `gpt-5.4-high-fast`),CLI 工具(Claude Code、opencode 等)无需额外参数即可控制推理强度和 Fast 模式
20
  - `service_tier` 支持:接受 API 请求体中的 `service_tier` 字段("fast" / "flex"),或通过 `-fast` 模型名后缀自动设置
21
  - Dashboard Speed 切换:模型选择器下方新增 Standard / Fast 速度切换按钮
README.md CHANGED
@@ -58,6 +58,7 @@ cd codex-proxy
58
  ### Docker(推荐,所有平台通用)
59
 
60
  ```bash
 
61
  docker compose up -d
62
  # 打开 http://localhost:8080 登录
63
  ```
 
58
  ### Docker(推荐,所有平台通用)
59
 
60
  ```bash
61
+ cp .env.example .env # 创建环境变量文件(可编辑配置)
62
  docker compose up -d
63
  # 打开 http://localhost:8080 登录
64
  ```
README_EN.md CHANGED
@@ -58,6 +58,7 @@ cd codex-proxy
58
  #### Docker (Recommended)
59
 
60
  ```bash
 
61
  docker compose up -d
62
  # Open http://localhost:8080 to log in
63
  ```
 
58
  #### Docker (Recommended)
59
 
60
  ```bash
61
+ cp .env.example .env # Create env file (edit to configure)
62
  docker compose up -d
63
  # Open http://localhost:8080 to log in
64
  ```
src/index.ts CHANGED
@@ -17,6 +17,7 @@ import { createWebRoutes } from "./routes/web.js";
17
  import { CookieJar } from "./proxy/cookie-jar.js";
18
  import { ProxyPool } from "./proxy/proxy-pool.js";
19
  import { createProxyRoutes } from "./routes/proxies.js";
 
20
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
21
  import { initProxy } from "./tls/curl-binary.js";
22
  import { initTransport } from "./tls/transport.js";
@@ -72,6 +73,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
72
  const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
73
  const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
74
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
 
75
  const proxyRoutes = createProxyRoutes(proxyPool, accountPool);
76
  const webRoutes = createWebRoutes(accountPool);
77
 
@@ -80,6 +82,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
80
  app.route("/", chatRoutes);
81
  app.route("/", messagesRoutes);
82
  app.route("/", geminiRoutes);
 
83
  app.route("/", proxyRoutes);
84
  app.route("/", createModelRoutes());
85
  app.route("/", webRoutes);
 
17
  import { CookieJar } from "./proxy/cookie-jar.js";
18
  import { ProxyPool } from "./proxy/proxy-pool.js";
19
  import { createProxyRoutes } from "./routes/proxies.js";
20
+ import { createResponsesRoutes } from "./routes/responses.js";
21
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
22
  import { initProxy } from "./tls/curl-binary.js";
23
  import { initTransport } from "./tls/transport.js";
 
73
  const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
74
  const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
75
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
76
+ const responsesRoutes = createResponsesRoutes(accountPool, cookieJar, proxyPool);
77
  const proxyRoutes = createProxyRoutes(proxyPool, accountPool);
78
  const webRoutes = createWebRoutes(accountPool);
79
 
 
82
  app.route("/", chatRoutes);
83
  app.route("/", messagesRoutes);
84
  app.route("/", geminiRoutes);
85
+ app.route("/", responsesRoutes);
86
  app.route("/", proxyRoutes);
87
  app.route("/", createModelRoutes());
88
  app.route("/", webRoutes);
src/routes/responses.ts ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * POST /v1/responses — Codex Responses API passthrough.
3
+ *
4
+ * Accepts the native Codex Responses API format and streams raw SSE events
5
+ * back to the client without translation. Provides multi-account load balancing,
6
+ * retry logic, and usage tracking via the shared proxy handler.
7
+ */
8
+
9
+ import { Hono } from "hono";
10
+ import type { AccountPool } from "../auth/account-pool.js";
11
+ 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 } from "../models/model-store.js";
16
+ import { EmptyResponseError } from "../translation/codex-event-extractor.js";
17
+ import {
18
+ handleProxyRequest,
19
+ type FormatAdapter,
20
+ } from "./shared/proxy-handler.js";
21
+
22
+ // ── Helpers ────────────────────────────────────────────────────────
23
+
24
+ function isRecord(v: unknown): v is Record<string, unknown> {
25
+ return typeof v === "object" && v !== null && !Array.isArray(v);
26
+ }
27
+
28
+ // ── Passthrough stream translator ──────────────────────────────────
29
+
30
+ async function* streamPassthrough(
31
+ api: CodexApi,
32
+ response: Response,
33
+ _model: string,
34
+ onUsage: (u: { input_tokens: number; output_tokens: number }) => void,
35
+ onResponseId: (id: string) => void,
36
+ ): AsyncGenerator<string> {
37
+ for await (const raw of api.parseStream(response)) {
38
+ // Re-emit raw SSE event
39
+ yield `event: ${raw.event}\ndata: ${JSON.stringify(raw.data)}\n\n`;
40
+
41
+ // Extract usage and responseId for account pool bookkeeping
42
+ if (
43
+ raw.event === "response.created" ||
44
+ raw.event === "response.in_progress" ||
45
+ raw.event === "response.completed"
46
+ ) {
47
+ const data = raw.data;
48
+ if (isRecord(data) && isRecord(data.response)) {
49
+ const resp = data.response;
50
+ if (typeof resp.id === "string") onResponseId(resp.id);
51
+ if (raw.event === "response.completed" && isRecord(resp.usage)) {
52
+ onUsage({
53
+ input_tokens: typeof resp.usage.input_tokens === "number" ? resp.usage.input_tokens : 0,
54
+ output_tokens: typeof resp.usage.output_tokens === "number" ? resp.usage.output_tokens : 0,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // ── Passthrough collect translator ─────────────────────────────────
63
+
64
+ async function collectPassthrough(
65
+ api: CodexApi,
66
+ response: Response,
67
+ _model: string,
68
+ ): Promise<{
69
+ response: unknown;
70
+ usage: { input_tokens: number; output_tokens: number };
71
+ responseId: string | null;
72
+ }> {
73
+ let finalResponse: unknown = null;
74
+ let usage = { input_tokens: 0, output_tokens: 0 };
75
+ let responseId: string | null = null;
76
+
77
+ for await (const raw of api.parseStream(response)) {
78
+ const data = raw.data;
79
+ if (!isRecord(data)) continue;
80
+ const resp = isRecord(data.response) ? data.response : null;
81
+
82
+ if (raw.event === "response.created" || raw.event === "response.in_progress") {
83
+ if (resp && typeof resp.id === "string") responseId = resp.id;
84
+ }
85
+
86
+ if (raw.event === "response.completed" && resp) {
87
+ finalResponse = resp;
88
+ if (typeof resp.id === "string") responseId = resp.id;
89
+ if (isRecord(resp.usage)) {
90
+ usage = {
91
+ input_tokens: typeof resp.usage.input_tokens === "number" ? resp.usage.input_tokens : 0,
92
+ output_tokens: typeof resp.usage.output_tokens === "number" ? resp.usage.output_tokens : 0,
93
+ };
94
+ }
95
+ }
96
+
97
+ if (raw.event === "error" || raw.event === "response.failed") {
98
+ const err = isRecord(data.error) ? data.error : data;
99
+ throw new Error(
100
+ `Codex API error: ${typeof err.code === "string" ? err.code : "unknown"}: ${typeof err.message === "string" ? err.message : JSON.stringify(data)}`,
101
+ );
102
+ }
103
+ }
104
+
105
+ if (!finalResponse) {
106
+ throw new EmptyResponseError(responseId, usage);
107
+ }
108
+
109
+ return { response: finalResponse, usage, responseId };
110
+ }
111
+
112
+ // ── Format adapter ─────────────────────────────────────────────────
113
+
114
+ const PASSTHROUGH_FORMAT: FormatAdapter = {
115
+ tag: "Responses",
116
+ noAccountStatus: 503,
117
+ formatNoAccount: () => ({
118
+ type: "error",
119
+ error: {
120
+ type: "server_error",
121
+ code: "no_available_accounts",
122
+ message: "No available accounts. All accounts are expired or rate-limited.",
123
+ },
124
+ }),
125
+ format429: (msg) => ({
126
+ type: "error",
127
+ error: {
128
+ type: "rate_limit_error",
129
+ code: "rate_limit_exceeded",
130
+ message: msg,
131
+ },
132
+ }),
133
+ formatError: (_status, msg) => ({
134
+ type: "error",
135
+ error: {
136
+ type: "server_error",
137
+ code: "codex_api_error",
138
+ message: msg,
139
+ },
140
+ }),
141
+ streamTranslator: streamPassthrough,
142
+ collectTranslator: collectPassthrough,
143
+ };
144
+
145
+ // ── Route ──────────────────────────────────────────────────────────
146
+
147
+ export function createResponsesRoutes(
148
+ accountPool: AccountPool,
149
+ cookieJar?: CookieJar,
150
+ proxyPool?: ProxyPool,
151
+ ): Hono {
152
+ const app = new Hono();
153
+
154
+ app.post("/v1/responses", async (c) => {
155
+ // Auth check
156
+ if (!accountPool.isAuthenticated()) {
157
+ c.status(401);
158
+ return c.json({
159
+ type: "error",
160
+ error: {
161
+ type: "invalid_request_error",
162
+ code: "invalid_api_key",
163
+ message: "Not authenticated. Please login first at /",
164
+ },
165
+ });
166
+ }
167
+
168
+ // Optional proxy API key check
169
+ const config = getConfig();
170
+ if (config.server.proxy_api_key) {
171
+ const authHeader = c.req.header("Authorization");
172
+ const providedKey = authHeader?.replace("Bearer ", "");
173
+ if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
174
+ c.status(401);
175
+ return c.json({
176
+ type: "error",
177
+ error: {
178
+ type: "invalid_request_error",
179
+ code: "invalid_api_key",
180
+ message: "Invalid proxy API key",
181
+ },
182
+ });
183
+ }
184
+ }
185
+
186
+ // Parse request body
187
+ let body: unknown;
188
+ try {
189
+ body = await c.req.json();
190
+ } catch {
191
+ c.status(400);
192
+ return c.json({
193
+ type: "error",
194
+ error: {
195
+ type: "invalid_request_error",
196
+ code: "invalid_json",
197
+ message: "Malformed JSON request body",
198
+ },
199
+ });
200
+ }
201
+
202
+ if (!isRecord(body) || typeof body.instructions !== "string") {
203
+ c.status(400);
204
+ return c.json({
205
+ type: "error",
206
+ error: {
207
+ type: "invalid_request_error",
208
+ code: "invalid_request",
209
+ message: "Missing required field: instructions (string)",
210
+ },
211
+ });
212
+ }
213
+
214
+ // Resolve model (suffix parsing extracts service_tier and reasoning_effort)
215
+ const rawModel = typeof body.model === "string" ? body.model : "codex";
216
+ const parsed = parseModelName(rawModel);
217
+ const modelId = resolveModelId(parsed.modelId);
218
+ const modelInfo = getModelInfo(modelId);
219
+
220
+ // Build CodexResponsesRequest
221
+ const codexRequest: CodexResponsesRequest = {
222
+ model: modelId,
223
+ instructions: body.instructions,
224
+ input: Array.isArray(body.input) ? (body.input as CodexInputItem[]) : [],
225
+ stream: true,
226
+ store: false,
227
+ };
228
+
229
+ // Reasoning effort: explicit body > suffix > model default > config default
230
+ const effort =
231
+ (isRecord(body.reasoning) && typeof body.reasoning.effort === "string"
232
+ ? body.reasoning.effort
233
+ : null) ??
234
+ parsed.reasoningEffort ??
235
+ modelInfo?.defaultReasoningEffort ??
236
+ config.model.default_reasoning_effort;
237
+ const summary =
238
+ isRecord(body.reasoning) && typeof body.reasoning.summary === "string"
239
+ ? body.reasoning.summary
240
+ : "auto";
241
+ codexRequest.reasoning = { summary, ...(effort ? { effort } : {}) };
242
+
243
+ // Service tier: explicit body > suffix > config default
244
+ const serviceTier =
245
+ (typeof body.service_tier === "string" ? body.service_tier : null) ??
246
+ parsed.serviceTier ??
247
+ config.model.default_service_tier ??
248
+ null;
249
+ if (serviceTier) {
250
+ codexRequest.service_tier = serviceTier;
251
+ }
252
+
253
+ // Pass through tools and tool_choice as-is
254
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
255
+ codexRequest.tools = body.tools;
256
+ }
257
+ if (body.tool_choice !== undefined) {
258
+ codexRequest.tool_choice = body.tool_choice as CodexResponsesRequest["tool_choice"];
259
+ }
260
+
261
+ // Client can request non-streaming (collect mode), but upstream is always stream
262
+ const clientWantsStream = body.stream !== false;
263
+
264
+ return handleProxyRequest(
265
+ c,
266
+ accountPool,
267
+ cookieJar,
268
+ {
269
+ codexRequest,
270
+ model: modelId,
271
+ isStreaming: clientWantsStream,
272
+ },
273
+ PASSTHROUGH_FORMAT,
274
+ proxyPool,
275
+ );
276
+ });
277
+
278
+ return app;
279
+ }
src/translation/codex-event-extractor.ts CHANGED
@@ -125,6 +125,21 @@ export async function* iterateCodexEvents(
125
  break;
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  case "response.completed":
129
  if (typed.response.id) extracted.responseId = typed.response.id;
130
  if (typed.response.usage) extracted.usage = typed.response.usage;
 
125
  break;
126
  }
127
 
128
+ case "response.output_item.done":
129
+ // Completion marker — tool call data already delivered via delta/done events
130
+ break;
131
+
132
+ case "response.incomplete":
133
+ // Response was truncated/incomplete
134
+ if (typed.response.id) extracted.responseId = typed.response.id;
135
+ if (typed.response.usage) extracted.usage = typed.response.usage;
136
+ break;
137
+
138
+ case "response.queued":
139
+ // Response is queued for processing
140
+ if (typed.response.id) extracted.responseId = typed.response.id;
141
+ break;
142
+
143
  case "response.completed":
144
  if (typed.response.id) extracted.responseId = typed.response.id;
145
  if (typed.response.usage) extracted.usage = typed.response.usage;
src/types/codex-events.ts CHANGED
@@ -82,6 +82,29 @@ export interface CodexFunctionCallArgsDoneEvent {
82
  name: string;
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  export interface CodexErrorEvent {
86
  type: "error";
87
  error: { type: string; code: string; message: string };
@@ -107,6 +130,9 @@ export type TypedCodexEvent =
107
  | CodexReasoningSummaryDoneEvent
108
  | CodexCompletedEvent
109
  | CodexOutputItemAddedEvent
 
 
 
110
  | CodexFunctionCallArgsDeltaEvent
111
  | CodexFunctionCallArgsDoneEvent
112
  | CodexErrorEvent
@@ -276,6 +302,34 @@ export function parseCodexEvent(evt: CodexSSEEvent): TypedCodexEvent {
276
  }
277
  return { type: "unknown", raw: data };
278
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  default:
280
  return { type: "unknown", raw: data };
281
  }
 
82
  name: string;
83
  }
84
 
85
+ export interface CodexOutputItemDoneEvent {
86
+ type: "response.output_item.done";
87
+ outputIndex: number;
88
+ item: {
89
+ type: string;
90
+ id?: string;
91
+ call_id?: string;
92
+ name?: string;
93
+ arguments?: string;
94
+ [key: string]: unknown;
95
+ };
96
+ }
97
+
98
+ export interface CodexIncompleteEvent {
99
+ type: "response.incomplete";
100
+ response: CodexResponseData;
101
+ }
102
+
103
+ export interface CodexQueuedEvent {
104
+ type: "response.queued";
105
+ response: CodexResponseData;
106
+ }
107
+
108
  export interface CodexErrorEvent {
109
  type: "error";
110
  error: { type: string; code: string; message: string };
 
130
  | CodexReasoningSummaryDoneEvent
131
  | CodexCompletedEvent
132
  | CodexOutputItemAddedEvent
133
+ | CodexOutputItemDoneEvent
134
+ | CodexIncompleteEvent
135
+ | CodexQueuedEvent
136
  | CodexFunctionCallArgsDeltaEvent
137
  | CodexFunctionCallArgsDoneEvent
138
  | CodexErrorEvent
 
302
  }
303
  return { type: "unknown", raw: data };
304
  }
305
+ case "response.output_item.done": {
306
+ if (isRecord(data) && isRecord(data.item)) {
307
+ return {
308
+ type: "response.output_item.done",
309
+ outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
310
+ item: {
311
+ type: typeof data.item.type === "string" ? data.item.type : "unknown",
312
+ ...(typeof data.item.id === "string" ? { id: data.item.id } : {}),
313
+ ...(typeof data.item.call_id === "string" ? { call_id: data.item.call_id } : {}),
314
+ ...(typeof data.item.name === "string" ? { name: data.item.name } : {}),
315
+ ...(typeof data.item.arguments === "string" ? { arguments: data.item.arguments } : {}),
316
+ },
317
+ };
318
+ }
319
+ return { type: "unknown", raw: data };
320
+ }
321
+ case "response.incomplete": {
322
+ const resp = parseResponseData(data);
323
+ return resp
324
+ ? { type: "response.incomplete", response: resp }
325
+ : { type: "unknown", raw: data };
326
+ }
327
+ case "response.queued": {
328
+ const resp = parseResponseData(data);
329
+ return resp
330
+ ? { type: "response.queued", response: resp }
331
+ : { type: "unknown", raw: data };
332
+ }
333
  default:
334
  return { type: "unknown", raw: data };
335
  }