ai-zero-token 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +34 -2
  3. package/README.zh-CN.md +35 -3
  4. package/admin-ui/dist/assets/{accounts-DymL4WIa.js → accounts-ABMyXo4H.js} +3 -1
  5. package/admin-ui/dist/assets/{docs-DtO-AOWU.js → docs-Dh0aFha_.js} +3 -3
  6. package/admin-ui/dist/assets/{image-bed-yIVQ4dKs.js → image-bed-C1M7-0q1.js} +1 -1
  7. package/admin-ui/dist/assets/index--rNjdmzf.js +10 -0
  8. package/admin-ui/dist/assets/{index-By4r-wy3.css → index-DjtN30PC.css} +1 -1
  9. package/admin-ui/dist/assets/{launch-CQXYrl-h.js → launch-pB7YlWFI.js} +1 -1
  10. package/admin-ui/dist/assets/logs-B7McijSi.js +1 -0
  11. package/admin-ui/dist/assets/{network-detect-sSrnwZqf.js → network-detect-Bx3XmXPk.js} +1 -1
  12. package/admin-ui/dist/assets/{overview-BbSON0Jl.js → overview-CV0H2Nsq.js} +1 -1
  13. package/admin-ui/dist/assets/settings-ynCIdUvZ.js +7 -0
  14. package/admin-ui/dist/assets/{tester-CftPgRE9.js → tester-BG-up8qP.js} +1 -1
  15. package/admin-ui/dist/index.html +2 -2
  16. package/build/tray-icon-template.png +0 -0
  17. package/dist/core/providers/http-client.js +228 -3
  18. package/dist/core/providers/openai-codex/chat.js +160 -23
  19. package/dist/core/services/auth-service.js +14 -5
  20. package/dist/core/services/chat-service.js +1 -0
  21. package/dist/core/services/config-service.js +15 -5
  22. package/dist/core/store/codex-auth-store.js +295 -4
  23. package/dist/core/store/settings-store.js +54 -24
  24. package/dist/desktop/main.js +616 -15
  25. package/dist/server/app.js +859 -91
  26. package/dist/server/index.js +2 -1
  27. package/docs/API_USAGE.md +82 -1
  28. package/docs/DESKTOP_RELEASE.md +24 -0
  29. package/package.json +3 -1
  30. package/admin-ui/dist/assets/index-DRe-tByu.js +0 -10
  31. package/admin-ui/dist/assets/logs-awABDg1C.js +0 -1
  32. package/admin-ui/dist/assets/settings-DvRiHS7i.js +0 -1
@@ -2,15 +2,18 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { Readable } from "node:stream";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import Fastify from "fastify";
7
8
  import cors from "@fastify/cors";
8
9
  import { z } from "zod";
9
10
  import { createGatewayContext } from "../core/context.js";
10
11
  import { requestText } from "../core/providers/http-client.js";
12
+ import { streamOpenAICodex } from "../core/providers/openai-codex/chat.js";
11
13
  const packageRoot = path.dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
12
14
  const adminUiDistDir = path.join(packageRoot, "admin-ui", "dist");
13
15
  const adminUiIndexPath = path.join(adminUiDistDir, "index.html");
16
+ const MAX_GATEWAY_REQUEST_LOGS = 100;
14
17
  const assetContentTypes = {
15
18
  ".css": "text/css; charset=utf-8",
16
19
  ".gif": "image/gif",
@@ -44,17 +47,9 @@ async function readAdminUiAsset(assetPath) {
44
47
  return null;
45
48
  }
46
49
  }
47
- const inputPartSchema = z.object({
48
- type: z.string().optional(),
49
- text: z.string().optional()
50
- }).passthrough();
51
- const inputMessageSchema = z.object({
52
- role: z.string().optional(),
53
- content: z.array(inputPartSchema).optional()
54
- }).passthrough();
55
50
  const responsesBodySchema = z.object({
56
51
  model: z.string().optional(),
57
- input: z.union([z.string(), z.array(inputMessageSchema)]).optional(),
52
+ input: z.unknown().optional(),
58
53
  instructions: z.string().optional(),
59
54
  stream: z.boolean().optional(),
60
55
  tools: z.array(z.unknown()).optional(),
@@ -68,7 +63,7 @@ const responsesBodySchema = z.object({
68
63
  allow_unknown_model: z.boolean().optional(),
69
64
  include_raw: z.boolean().optional()
70
65
  }).passthrough().optional()
71
- });
66
+ }).passthrough();
72
67
  const chatCompletionContentPartSchema = z.object({
73
68
  type: z.string().optional(),
74
69
  text: z.string().optional(),
@@ -113,7 +108,8 @@ const settingsUpdateSchema = z.object({
113
108
  noProxy: z.string().optional()
114
109
  }).optional(),
115
110
  autoSwitch: z.object({
116
- enabled: z.boolean()
111
+ enabled: z.boolean().optional(),
112
+ excludedProfileIds: z.array(z.string()).optional()
117
113
  }).optional(),
118
114
  runtime: z.object({
119
115
  quotaSyncConcurrency: z.number().int().min(1).max(32).optional()
@@ -149,6 +145,10 @@ const profileExportSchema = z.object({
149
145
  const codexApplySchema = z.object({
150
146
  profileId: z.string().min(1)
151
147
  });
148
+ const codexProviderConfigSchema = z.object({
149
+ baseUrl: z.string().min(1).optional(),
150
+ providerId: z.string().min(1).optional()
151
+ });
152
152
  const githubImageBedConfigSchema = z.object({
153
153
  token: z.string().min(1)
154
154
  });
@@ -198,13 +198,21 @@ const imageEditsBodySchema = z.object({
198
198
  response_format: z.enum(["b64_json", "url"]).optional(),
199
199
  user: z.string().optional()
200
200
  }).passthrough();
201
- const chatCompletionExcludedKeys = /* @__PURE__ */ new Set([
202
- "messages",
203
- "n",
204
- "stream",
205
- "max_tokens",
206
- "max_completion_tokens"
207
- ]);
201
+ function extractTextFromInputContent(content) {
202
+ if (typeof content === "string" && content.trim()) {
203
+ return [content.trim()];
204
+ }
205
+ if (!Array.isArray(content)) {
206
+ return [];
207
+ }
208
+ return content.flatMap((part) => {
209
+ if (!part || typeof part !== "object") {
210
+ return [];
211
+ }
212
+ const record = part;
213
+ return typeof record.text === "string" && record.text.trim() ? [record.text.trim()] : [];
214
+ });
215
+ }
208
216
  function extractTextInput(input) {
209
217
  if (typeof input === "undefined") {
210
218
  return "";
@@ -213,12 +221,14 @@ function extractTextInput(input) {
213
221
  return input;
214
222
  }
215
223
  const chunks = [];
224
+ if (!Array.isArray(input)) {
225
+ return "";
226
+ }
216
227
  for (const item of input) {
217
- for (const part of item.content ?? []) {
218
- if (typeof part.text === "string" && part.text.trim()) {
219
- chunks.push(part.text.trim());
220
- }
228
+ if (!item || typeof item !== "object") {
229
+ continue;
221
230
  }
231
+ chunks.push(...extractTextFromInputContent(item.content));
222
232
  }
223
233
  return chunks.join("\n").trim();
224
234
  }
@@ -242,7 +252,20 @@ function normalizeChatRole(role) {
242
252
  }
243
253
  return role ?? "user";
244
254
  }
245
- function normalizeChatContentPart(part) {
255
+ function safeJsonStringify(value) {
256
+ if (typeof value === "string") {
257
+ return value;
258
+ }
259
+ if (typeof value === "undefined" || value === null) {
260
+ return "";
261
+ }
262
+ try {
263
+ return JSON.stringify(value);
264
+ } catch {
265
+ return String(value);
266
+ }
267
+ }
268
+ function normalizeChatContentPart(part, textType) {
246
269
  if (part.type === "image_url") {
247
270
  const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
248
271
  if (!url) {
@@ -258,37 +281,270 @@ function normalizeChatContentPart(part) {
258
281
  }
259
282
  const text = typeof part.text === "string" ? part.text : "";
260
283
  return {
261
- type: "input_text",
284
+ type: textType,
262
285
  text
263
286
  };
264
287
  }
265
- function normalizeChatContent(content) {
288
+ function normalizeChatContent(content, role) {
289
+ const textType = role === "assistant" ? "output_text" : "input_text";
266
290
  if (typeof content === "string") {
267
- return [{ type: "input_text", text: content }];
291
+ return [{ type: textType, text: content }];
268
292
  }
269
293
  if (!Array.isArray(content) || content.length === 0) {
270
- return [{ type: "input_text", text: "" }];
294
+ return [{ type: textType, text: "" }];
271
295
  }
272
- return content.map((part) => normalizeChatContentPart(part));
296
+ return content.map((part) => normalizeChatContentPart(part, textType));
273
297
  }
274
298
  function normalizeChatMessages(messages) {
275
- return messages.map((message) => ({
276
- role: normalizeChatRole(message.role),
277
- content: normalizeChatContent(message.content),
278
- ...message.name ? { name: message.name } : {},
279
- ...message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}
299
+ const normalized = [];
300
+ for (const message of messages) {
301
+ const record = message;
302
+ if (message.role === "tool") {
303
+ normalized.push({
304
+ type: "function_call_output",
305
+ call_id: message.tool_call_id,
306
+ output: typeof message.content === "string" ? message.content : safeJsonStringify(message.content)
307
+ });
308
+ continue;
309
+ }
310
+ normalized.push({
311
+ role: normalizeChatRole(message.role),
312
+ content: normalizeChatContent(message.content, message.role),
313
+ ...message.name ? { name: message.name } : {},
314
+ ...message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}
315
+ });
316
+ const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
317
+ for (const toolCall of toolCalls) {
318
+ const call = toolCall && typeof toolCall === "object" ? toolCall : {};
319
+ const fn = call.function && typeof call.function === "object" ? call.function : {};
320
+ const name = typeof fn.name === "string" ? fn.name : void 0;
321
+ if (!name) {
322
+ continue;
323
+ }
324
+ normalized.push({
325
+ type: "function_call",
326
+ call_id: typeof call.id === "string" ? call.id : `call_${normalized.length}`,
327
+ name,
328
+ arguments: safeJsonStringify(fn.arguments)
329
+ });
330
+ }
331
+ }
332
+ return normalized;
333
+ }
334
+ function normalizeChatTools(tools) {
335
+ if (!tools) {
336
+ return void 0;
337
+ }
338
+ return tools.map((tool) => {
339
+ if (!tool || typeof tool !== "object") {
340
+ return tool;
341
+ }
342
+ const record = tool;
343
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
344
+ if (record.type !== "function" || !fn) {
345
+ return tool;
346
+ }
347
+ return {
348
+ type: "function",
349
+ name: fn.name,
350
+ description: fn.description,
351
+ parameters: fn.parameters
352
+ };
353
+ });
354
+ }
355
+ function normalizeChatToolChoice(toolChoice) {
356
+ if (!toolChoice || typeof toolChoice !== "object") {
357
+ return toolChoice;
358
+ }
359
+ const record = toolChoice;
360
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
361
+ if (record.type === "function" && fn && typeof fn.name === "string") {
362
+ return {
363
+ type: "function",
364
+ name: fn.name
365
+ };
366
+ }
367
+ return toolChoice;
368
+ }
369
+ function normalizeReasoningEffort(value) {
370
+ if (value === "low" || value === "medium" || value === "high") {
371
+ return value;
372
+ }
373
+ if (value === "minimal") {
374
+ return "low";
375
+ }
376
+ if (value === "xhigh") {
377
+ return "high";
378
+ }
379
+ return void 0;
380
+ }
381
+ function normalizeChatReasoning(data) {
382
+ const record = data;
383
+ const existing = record.reasoning;
384
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
385
+ return existing;
386
+ }
387
+ const effort = normalizeReasoningEffort(record.reasoning_effort);
388
+ return effort ? { effort } : void 0;
389
+ }
390
+ function truncateForLog(value, maxLength = 300) {
391
+ const normalized = value.replace(/\s+/g, " ").trim();
392
+ if (normalized.length <= maxLength) {
393
+ return normalized;
394
+ }
395
+ return `${normalized.slice(0, maxLength)}...`;
396
+ }
397
+ function extractChatMessageText(message) {
398
+ if (typeof message.content === "string") {
399
+ return message.content;
400
+ }
401
+ if (!Array.isArray(message.content)) {
402
+ return "";
403
+ }
404
+ return message.content.map((part) => typeof part.text === "string" ? part.text : part.image_url ? "[image]" : "").filter(Boolean).join("\n");
405
+ }
406
+ function countRoles(messages) {
407
+ const counts = {};
408
+ for (const message of messages) {
409
+ const role = message.role ?? "user";
410
+ counts[role] = (counts[role] ?? 0) + 1;
411
+ }
412
+ return counts;
413
+ }
414
+ function summarizeRecentMessages(messages) {
415
+ return messages.slice(-8).map((message) => ({
416
+ role: message.role ?? "user",
417
+ textPreview: truncateForLog(extractChatMessageText(message), 180),
418
+ toolCallId: message.tool_call_id
280
419
  }));
281
420
  }
421
+ function summarizeToolNames(tools) {
422
+ if (!tools) {
423
+ return [];
424
+ }
425
+ return tools.map((tool) => {
426
+ if (!tool || typeof tool !== "object") {
427
+ return "";
428
+ }
429
+ const record = tool;
430
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
431
+ return typeof fn?.name === "string" ? fn.name : typeof record.name === "string" ? record.name : "";
432
+ }).filter(Boolean);
433
+ }
434
+ function summarizeResponsesRequest(data) {
435
+ const input = data.input;
436
+ const toolNames = summarizeToolNames(Array.isArray(data.tools) ? data.tools : void 0);
437
+ return {
438
+ endpoint: "/v1/responses",
439
+ model: data.model ?? "default",
440
+ stream: data.stream ?? false,
441
+ inputKind: typeof input === "string" ? "string" : Array.isArray(input) ? "array" : "override",
442
+ inputItems: Array.isArray(input) ? input.length : void 0,
443
+ inputTextPreview: typeof input === "string" ? truncateForLog(input) : "",
444
+ instructionsLength: typeof data.instructions === "string" ? data.instructions.length : void 0,
445
+ toolCount: Array.isArray(data.tools) ? data.tools.length : 0,
446
+ toolNames: toolNames.slice(0, 50),
447
+ toolNamesTruncated: toolNames.length > 50,
448
+ toolChoice: typeof data.tool_choice === "undefined" ? "default" : typeof data.tool_choice,
449
+ parallelToolCalls: data.parallel_tool_calls,
450
+ hasReasoning: Boolean(data.reasoning)
451
+ };
452
+ }
453
+ function createResponsesCodexBody(data) {
454
+ const experimentalBody = data.experimental_codex?.body ?? {};
455
+ const body = {
456
+ ...experimentalBody,
457
+ ...data
458
+ };
459
+ delete body.experimental_codex;
460
+ const normalizedInput = normalizeResponseInput(data.input);
461
+ if (typeof normalizedInput !== "undefined") {
462
+ body.input = normalizedInput;
463
+ }
464
+ return body;
465
+ }
466
+ function createCodexPassthroughBody(data, model) {
467
+ const body = {
468
+ ...data,
469
+ model
470
+ };
471
+ delete body.experimental_codex;
472
+ return body;
473
+ }
474
+ function summarizeChatCompletionsRequest(data) {
475
+ const lastUserMessage = [...data.messages].reverse().find((message) => (message.role ?? "user") === "user");
476
+ const toolNames = summarizeToolNames(data.tools);
477
+ return {
478
+ endpoint: "/v1/chat/completions",
479
+ model: data.model ?? "default",
480
+ stream: data.stream ?? false,
481
+ messageCount: data.messages.length,
482
+ roleCounts: countRoles(data.messages),
483
+ recentMessages: summarizeRecentMessages(data.messages),
484
+ lastUserTextPreview: lastUserMessage ? truncateForLog(extractChatMessageText(lastUserMessage)) : "",
485
+ toolCount: data.tools?.length ?? 0,
486
+ toolNames: toolNames.slice(0, 50),
487
+ toolNamesTruncated: toolNames.length > 50,
488
+ toolChoice: typeof data.tool_choice === "undefined" ? "default" : typeof data.tool_choice,
489
+ parallelToolCalls: data.parallel_tool_calls,
490
+ hasReasoning: Boolean(data.reasoning || data.reasoning_effort),
491
+ maxTokens: data.max_completion_tokens ?? data.max_tokens
492
+ };
493
+ }
494
+ function summarizeCodexChatBody(body) {
495
+ const toolNames = summarizeToolNames(Array.isArray(body.tools) ? body.tools : void 0);
496
+ return {
497
+ keys: Object.keys(body).sort(),
498
+ model: body.model ?? "default",
499
+ stream: body.stream,
500
+ store: body.store,
501
+ inputItems: Array.isArray(body.input) ? body.input.length : void 0,
502
+ tools: Array.isArray(body.tools) ? body.tools.length : void 0,
503
+ toolNames: toolNames.slice(0, 50),
504
+ toolNamesTruncated: toolNames.length > 50,
505
+ toolChoice: typeof body.tool_choice === "undefined" ? "default" : typeof body.tool_choice,
506
+ parallelToolCalls: body.parallel_tool_calls,
507
+ hasReasoning: Boolean(body.reasoning)
508
+ };
509
+ }
510
+ function profileLogLabel(profile) {
511
+ return profile?.email || profile?.accountId || profile?.profileId || "-";
512
+ }
513
+ function requestSourceFromUserAgent(userAgent) {
514
+ if (typeof userAgent !== "string") {
515
+ return "API";
516
+ }
517
+ const normalized = userAgent.toLowerCase();
518
+ if (normalized.includes("codex")) {
519
+ return "Codex";
520
+ }
521
+ if (normalized.includes("openclaw")) {
522
+ return "OpenClaw";
523
+ }
524
+ return "API";
525
+ }
282
526
  function createChatCompletionsCodexBody(data) {
283
- const body = Object.fromEntries(
284
- Object.entries(data).filter(([key]) => !chatCompletionExcludedKeys.has(key))
285
- );
286
- if (typeof data.max_completion_tokens === "number") {
287
- body.max_output_tokens = data.max_completion_tokens;
288
- } else if (typeof data.max_tokens === "number") {
289
- body.max_output_tokens = data.max_tokens;
290
- }
291
- body.input = normalizeChatMessages(data.messages);
527
+ const body = {
528
+ store: false,
529
+ stream: true,
530
+ input: normalizeChatMessages(data.messages)
531
+ };
532
+ if (data.model) {
533
+ body.model = data.model;
534
+ }
535
+ if (typeof data.parallel_tool_calls === "boolean") {
536
+ body.parallel_tool_calls = data.parallel_tool_calls;
537
+ }
538
+ if (data.tools) {
539
+ body.tools = normalizeChatTools(data.tools);
540
+ }
541
+ if (typeof data.tool_choice !== "undefined") {
542
+ body.tool_choice = normalizeChatToolChoice(data.tool_choice);
543
+ }
544
+ const reasoning = normalizeChatReasoning(data);
545
+ if (reasoning) {
546
+ body.reasoning = reasoning;
547
+ }
292
548
  return body;
293
549
  }
294
550
  function summarizeImageRequestForLog(body) {
@@ -373,6 +629,7 @@ function buildResponseApiBody(result, includeRaw) {
373
629
  return responseBody;
374
630
  }
375
631
  function buildChatCompletionsBody(result) {
632
+ const hasToolCalls = result.toolCalls.length > 0;
376
633
  const body = {
377
634
  id: `chatcmpl_${randomUUID().replace(/-/g, "")}`,
378
635
  object: "chat.completion",
@@ -381,10 +638,11 @@ function buildChatCompletionsBody(result) {
381
638
  choices: [
382
639
  {
383
640
  index: 0,
384
- finish_reason: "stop",
641
+ finish_reason: hasToolCalls ? "tool_calls" : "stop",
385
642
  message: {
386
643
  role: "assistant",
387
- content: result.text
644
+ content: hasToolCalls ? result.text || null : result.text,
645
+ ...hasToolCalls ? { tool_calls: result.toolCalls } : {}
388
646
  }
389
647
  }
390
648
  ]
@@ -394,6 +652,79 @@ function buildChatCompletionsBody(result) {
394
652
  }
395
653
  return body;
396
654
  }
655
+ function writeChatCompletionsSseEvent(reply, data) {
656
+ reply.raw.write(`data: ${JSON.stringify(data)}
657
+
658
+ `);
659
+ }
660
+ function buildChatCompletionChunk(params) {
661
+ return {
662
+ id: params.id,
663
+ object: "chat.completion.chunk",
664
+ created: params.created,
665
+ model: params.model,
666
+ choices: [
667
+ {
668
+ index: 0,
669
+ delta: params.delta,
670
+ finish_reason: params.finishReason ?? null
671
+ }
672
+ ]
673
+ };
674
+ }
675
+ function sendChatCompletionsStream(reply, result) {
676
+ const id = `chatcmpl_${randomUUID().replace(/-/g, "")}`;
677
+ const created = Math.floor(Date.now() / 1e3);
678
+ reply.raw.writeHead(200, {
679
+ "Content-Type": "text/event-stream; charset=utf-8",
680
+ "Cache-Control": "no-cache, no-transform",
681
+ Connection: "keep-alive",
682
+ "X-Accel-Buffering": "no"
683
+ });
684
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
685
+ id,
686
+ created,
687
+ model: result.model,
688
+ delta: { role: "assistant" }
689
+ }));
690
+ if (result.text) {
691
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
692
+ id,
693
+ created,
694
+ model: result.model,
695
+ delta: { content: result.text }
696
+ }));
697
+ }
698
+ result.toolCalls.forEach((toolCall, index) => {
699
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
700
+ id,
701
+ created,
702
+ model: result.model,
703
+ delta: {
704
+ tool_calls: [
705
+ {
706
+ index,
707
+ id: toolCall.id,
708
+ type: toolCall.type,
709
+ function: {
710
+ name: toolCall.function.name,
711
+ arguments: toolCall.function.arguments
712
+ }
713
+ }
714
+ ]
715
+ }
716
+ }));
717
+ });
718
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
719
+ id,
720
+ created,
721
+ model: result.model,
722
+ delta: {},
723
+ finishReason: result.toolCalls.length > 0 ? "tool_calls" : "stop"
724
+ }));
725
+ reply.raw.write("data: [DONE]\n\n");
726
+ reply.raw.end();
727
+ }
397
728
  function validateImageRequest(data) {
398
729
  if (data.response_format === "url") {
399
730
  return "\u5F53\u524D\u7F51\u5173\u4EC5\u652F\u6301 response_format=b64_json\uFF0C\u6682\u4E0D\u652F\u6301\u8FD4\u56DE\u6258\u7BA1\u56FE\u7247 URL\u3002";
@@ -484,7 +815,7 @@ function getErrorStatusCode(error) {
484
815
  return normalized.statusCode;
485
816
  }
486
817
  const upstreamStatus = normalized.upstreamStatus;
487
- if (upstreamStatus === 401 || upstreamStatus === 403) {
818
+ if (typeof upstreamStatus === "number" && upstreamStatus >= 400 && upstreamStatus < 600) {
488
819
  return upstreamStatus;
489
820
  }
490
821
  const message = normalized.message;
@@ -496,12 +827,74 @@ function getErrorStatusCode(error) {
496
827
  }
497
828
  return 500;
498
829
  }
830
+ function isQuotaLimitError(error) {
831
+ const normalized = normalizeError(error);
832
+ const marker = `${normalized.upstreamErrorCode ?? ""} ${normalized.upstreamErrorType ?? ""} ${normalized.message}`.toLowerCase();
833
+ return normalized.upstreamStatus === 429 || marker.includes("usage_limit_reached");
834
+ }
835
+ function createSseStreamStats() {
836
+ return {
837
+ buffer: "",
838
+ bytes: 0,
839
+ completed: false
840
+ };
841
+ }
842
+ function trackSseChunk(stats, chunk) {
843
+ const text = typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString("utf8") : String(chunk);
844
+ stats.bytes += Buffer.byteLength(text);
845
+ stats.buffer += text.replace(/\r\n/g, "\n");
846
+ let separatorIndex = stats.buffer.indexOf("\n\n");
847
+ while (separatorIndex !== -1) {
848
+ const block = stats.buffer.slice(0, separatorIndex);
849
+ stats.buffer = stats.buffer.slice(separatorIndex + 2);
850
+ const eventName = block.split("\n").find((line) => line.startsWith("event:"))?.slice("event:".length).trim();
851
+ const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).trim()).join("\n");
852
+ let eventType = eventName;
853
+ if (data && data !== "[DONE]") {
854
+ try {
855
+ const parsed = JSON.parse(data);
856
+ if (typeof parsed.type === "string") {
857
+ eventType = parsed.type;
858
+ }
859
+ } catch {
860
+ }
861
+ }
862
+ if (eventType === "response.completed") {
863
+ stats.completed = true;
864
+ stats.terminalEvent = eventType;
865
+ } else if (eventType === "response.failed" || eventType === "response.incomplete") {
866
+ stats.terminalEvent = eventType;
867
+ }
868
+ separatorIndex = stats.buffer.indexOf("\n\n");
869
+ }
870
+ if (stats.buffer.length > 65536) {
871
+ stats.buffer = stats.buffer.slice(-65536);
872
+ }
873
+ }
499
874
  function createApp(params) {
500
875
  const app = Fastify({
501
876
  logger: false,
502
877
  bodyLimit: params?.bodyLimit
503
878
  });
504
879
  const ctx = createGatewayContext();
880
+ const gatewayRequestLogs = [];
881
+ function pushGatewayRequestLog(log) {
882
+ gatewayRequestLogs.unshift({
883
+ id: log.id ?? randomUUID(),
884
+ time: log.time ?? Date.now(),
885
+ method: log.method,
886
+ endpoint: log.endpoint,
887
+ account: log.account,
888
+ model: log.model,
889
+ statusCode: log.statusCode,
890
+ durationMs: log.durationMs,
891
+ source: log.source,
892
+ details: log.details
893
+ });
894
+ if (gatewayRequestLogs.length > MAX_GATEWAY_REQUEST_LOGS) {
895
+ gatewayRequestLogs.length = MAX_GATEWAY_REQUEST_LOGS;
896
+ }
897
+ }
505
898
  void app.register(cors, {
506
899
  origin: params?.corsOrigin ?? true,
507
900
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
@@ -524,6 +917,9 @@ function createApp(params) {
524
917
  }
525
918
  };
526
919
  });
920
+ app.get("/_gateway/admin/request-logs", async () => ({
921
+ data: gatewayRequestLogs
922
+ }));
527
923
  async function buildAdminConfig(request) {
528
924
  const [status, models, modelCatalog, versionStatus, settings, profile, profiles, codexStatus] = await Promise.all([
529
925
  ctx.authService.getStatus(),
@@ -547,7 +943,9 @@ function createApp(params) {
547
943
  codex: codexStatus,
548
944
  adminUrl: `${origin}/`,
549
945
  baseUrl: `${origin}/v1`,
946
+ codexBaseUrl: `${origin}/codex/v1`,
550
947
  restartSupported: Boolean(params?.onRestart),
948
+ codexRestartSupported: Boolean(params?.onRestartCodex),
551
949
  supportedEndpoints: [
552
950
  {
553
951
  method: "GET",
@@ -559,6 +957,11 @@ function createApp(params) {
559
957
  path: "/v1/responses",
560
958
  description: "OpenAI responses \u517C\u5BB9\u63A5\u53E3\u3002"
561
959
  },
960
+ {
961
+ method: "POST",
962
+ path: "/codex/v1/responses",
963
+ description: "Codex custom provider \u4E13\u7528 Responses SSE \u900F\u4F20\u63A5\u53E3\u3002"
964
+ },
562
965
  {
563
966
  method: "POST",
564
967
  path: "/v1/chat/completions",
@@ -805,6 +1208,45 @@ function createApp(params) {
805
1208
  config: await buildAdminConfig(request)
806
1209
  };
807
1210
  });
1211
+ app.post("/_gateway/admin/codex/configure-provider", async (request, reply) => {
1212
+ const parsed = codexProviderConfigSchema.safeParse(request.body ?? {});
1213
+ if (!parsed.success) {
1214
+ reply.code(400);
1215
+ return {
1216
+ error: {
1217
+ type: "validation_error",
1218
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1219
+ }
1220
+ };
1221
+ }
1222
+ const origin = resolveOrigin(request);
1223
+ const baseUrl = parsed.data.baseUrl ?? `${origin}/codex/v1`;
1224
+ return {
1225
+ codexProvider: await ctx.authService.applyGatewayToCodexProvider({
1226
+ baseUrl,
1227
+ providerId: parsed.data.providerId
1228
+ }),
1229
+ config: await buildAdminConfig(request)
1230
+ };
1231
+ });
1232
+ app.post("/_gateway/admin/codex/remove-provider", async (request, reply) => {
1233
+ const parsed = codexProviderConfigSchema.safeParse(request.body ?? {});
1234
+ if (!parsed.success) {
1235
+ reply.code(400);
1236
+ return {
1237
+ error: {
1238
+ type: "validation_error",
1239
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1240
+ }
1241
+ };
1242
+ }
1243
+ return {
1244
+ codexProvider: await ctx.authService.removeGatewayFromCodexProvider({
1245
+ providerId: parsed.data.providerId
1246
+ }),
1247
+ config: await buildAdminConfig(request)
1248
+ };
1249
+ });
808
1250
  app.put("/_gateway/admin/settings", async (request, reply) => {
809
1251
  const parsed = settingsUpdateSchema.safeParse(request.body);
810
1252
  if (!parsed.success) {
@@ -839,6 +1281,22 @@ function createApp(params) {
839
1281
  restarting: true
840
1282
  };
841
1283
  });
1284
+ app.post("/_gateway/admin/desktop/restart-codex", async (_request, reply) => {
1285
+ if (!params?.onRestartCodex) {
1286
+ reply.code(501);
1287
+ return {
1288
+ error: {
1289
+ type: "not_supported",
1290
+ message: "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u91CD\u542F Codex\u3002"
1291
+ }
1292
+ };
1293
+ }
1294
+ await params.onRestartCodex();
1295
+ return {
1296
+ ok: true,
1297
+ restarted: true
1298
+ };
1299
+ });
842
1300
  app.post("/_gateway/admin/settings/proxy-test", async (request, reply) => {
843
1301
  const parsed = proxyTestSchema.safeParse(request.body);
844
1302
  if (!parsed.success) {
@@ -959,9 +1417,171 @@ function createApp(params) {
959
1417
  owned_by: model.provider
960
1418
  }))
961
1419
  }));
962
- app.post("/v1/responses", async (request, reply) => {
1420
+ async function handleCodexResponsesPassthrough(request, reply, data, startedAt) {
1421
+ const abortController = new AbortController();
1422
+ let streamFinished = false;
1423
+ let headersCommitted = false;
1424
+ let profile = null;
1425
+ let retryCount = 0;
1426
+ let failureRecorded = false;
1427
+ reply.raw.on("close", () => {
1428
+ if (!streamFinished) {
1429
+ abortController.abort();
1430
+ }
1431
+ });
1432
+ try {
1433
+ const model = await ctx.modelService.resolveModel("openai-codex", data.model, {
1434
+ allowUnknown: data.experimental_codex?.allow_unknown_model
1435
+ });
1436
+ const codexBody = createCodexPassthroughBody(data, model);
1437
+ let upstream = null;
1438
+ const maxProfileAttempts = 5;
1439
+ for (let attempt = 0; attempt < maxProfileAttempts; attempt += 1) {
1440
+ profile = await ctx.authService.requireUsableProfile("openai-codex");
1441
+ try {
1442
+ upstream = await streamOpenAICodex({
1443
+ profile,
1444
+ model,
1445
+ bodyOverride: codexBody,
1446
+ passthroughBody: true,
1447
+ signal: abortController.signal
1448
+ });
1449
+ break;
1450
+ } catch (error) {
1451
+ const quota = error.quota;
1452
+ const switchedProfile = await ctx.authService.recordProfileRequestFailure(profile.profileId, error, quota, "openai-codex");
1453
+ failureRecorded = true;
1454
+ if (attempt < maxProfileAttempts - 1 && isQuotaLimitError(error) && switchedProfile && switchedProfile.profileId !== profile.profileId && !abortController.signal.aborted) {
1455
+ retryCount += 1;
1456
+ failureRecorded = false;
1457
+ continue;
1458
+ }
1459
+ throw error;
1460
+ }
1461
+ }
1462
+ if (!upstream || !profile) {
1463
+ throw new Error("Codex stream \u672A\u80FD\u5EFA\u7ACB\u3002");
1464
+ }
1465
+ await ctx.authService.recordProfileRequestSuccess(profile.profileId, upstream.quota, "openai-codex");
1466
+ const headers = {
1467
+ "Content-Type": upstream.headers["content-type"] ?? "text/event-stream; charset=utf-8",
1468
+ "Cache-Control": "no-cache, no-transform",
1469
+ Connection: "keep-alive",
1470
+ "X-Accel-Buffering": "no"
1471
+ };
1472
+ for (const [key, value] of Object.entries(upstream.headers)) {
1473
+ if (key.startsWith("x-codex-") || key === "x-request-id") {
1474
+ headers[key] = value;
1475
+ }
1476
+ }
1477
+ reply.raw.writeHead(upstream.status, headers);
1478
+ headersCommitted = true;
1479
+ reply.raw.flushHeaders?.();
1480
+ const streamStats = createSseStreamStats();
1481
+ for await (const chunk of Readable.fromWeb(upstream.body)) {
1482
+ trackSseChunk(streamStats, chunk);
1483
+ if (!reply.raw.write(chunk)) {
1484
+ await new Promise((resolve) => reply.raw.once("drain", resolve));
1485
+ }
1486
+ }
1487
+ streamFinished = true;
1488
+ reply.raw.end();
1489
+ if (!streamStats.completed) {
1490
+ console.warn("[gateway:codex:stream] upstream stream ended without response.completed", {
1491
+ requestId: request.id,
1492
+ upstreamRequestId: upstream.requestId,
1493
+ account: profileLogLabel(profile),
1494
+ model,
1495
+ bytes: streamStats.bytes,
1496
+ terminalEvent: streamStats.terminalEvent
1497
+ });
1498
+ }
1499
+ pushGatewayRequestLog({
1500
+ method: request.method,
1501
+ endpoint: request.url,
1502
+ account: profileLogLabel(profile),
1503
+ model,
1504
+ statusCode: upstream.status,
1505
+ durationMs: performance.now() - startedAt,
1506
+ source: "Codex",
1507
+ details: {
1508
+ requestId: request.id,
1509
+ upstreamRequestId: upstream.requestId,
1510
+ remoteAddress: request.ip,
1511
+ userAgent: request.headers["user-agent"],
1512
+ request: summarizeResponsesRequest(data),
1513
+ response: {
1514
+ stream: true,
1515
+ passthrough: true,
1516
+ retryCount,
1517
+ completed: streamStats.completed,
1518
+ terminalEvent: streamStats.terminalEvent,
1519
+ bytes: streamStats.bytes
1520
+ }
1521
+ }
1522
+ });
1523
+ return reply;
1524
+ } catch (error) {
1525
+ const quota = error.quota;
1526
+ if (profile && !failureRecorded) {
1527
+ await ctx.authService.recordProfileRequestFailure(profile.profileId, error, quota, "openai-codex");
1528
+ }
1529
+ const normalized = normalizeError(error);
1530
+ const statusCode = getErrorStatusCode(normalized);
1531
+ pushGatewayRequestLog({
1532
+ method: request.method,
1533
+ endpoint: request.url,
1534
+ account: profileLogLabel(profile),
1535
+ model: data.model ?? "default",
1536
+ statusCode,
1537
+ durationMs: performance.now() - startedAt,
1538
+ source: "Codex",
1539
+ details: {
1540
+ requestId: request.id,
1541
+ remoteAddress: request.ip,
1542
+ userAgent: request.headers["user-agent"],
1543
+ request: summarizeResponsesRequest(data),
1544
+ response: {
1545
+ retryCount
1546
+ },
1547
+ error: {
1548
+ message: normalized.message,
1549
+ upstreamStatus: normalized.upstreamStatus,
1550
+ upstreamErrorCode: normalized.upstreamErrorCode,
1551
+ upstreamErrorMessage: normalized.upstreamErrorMessage
1552
+ }
1553
+ }
1554
+ });
1555
+ if (headersCommitted) {
1556
+ streamFinished = true;
1557
+ reply.raw.end();
1558
+ return reply;
1559
+ }
1560
+ throw error;
1561
+ }
1562
+ }
1563
+ app.post("/codex/v1/responses", async (request, reply) => {
1564
+ const startedAt = performance.now();
963
1565
  const parsed = responsesBodySchema.safeParse(request.body);
964
1566
  if (!parsed.success) {
1567
+ pushGatewayRequestLog({
1568
+ method: request.method,
1569
+ endpoint: request.url,
1570
+ account: "-",
1571
+ model: "-",
1572
+ statusCode: 400,
1573
+ durationMs: performance.now() - startedAt,
1574
+ source: "Codex",
1575
+ details: {
1576
+ requestId: request.id,
1577
+ remoteAddress: request.ip,
1578
+ userAgent: request.headers["user-agent"],
1579
+ error: {
1580
+ type: "validation_error",
1581
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1582
+ }
1583
+ }
1584
+ });
965
1585
  reply.code(400);
966
1586
  return {
967
1587
  error: {
@@ -970,18 +1590,61 @@ function createApp(params) {
970
1590
  }
971
1591
  };
972
1592
  }
973
- if (parsed.data.stream) {
974
- reply.code(501);
1593
+ return handleCodexResponsesPassthrough(request, reply, parsed.data, startedAt);
1594
+ });
1595
+ app.post("/v1/responses", async (request, reply) => {
1596
+ const startedAt = performance.now();
1597
+ const parsed = responsesBodySchema.safeParse(request.body);
1598
+ if (!parsed.success) {
1599
+ pushGatewayRequestLog({
1600
+ method: request.method,
1601
+ endpoint: request.url,
1602
+ account: "-",
1603
+ model: "-",
1604
+ statusCode: 400,
1605
+ durationMs: performance.now() - startedAt,
1606
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1607
+ details: {
1608
+ requestId: request.id,
1609
+ remoteAddress: request.ip,
1610
+ userAgent: request.headers["user-agent"],
1611
+ error: {
1612
+ type: "validation_error",
1613
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1614
+ }
1615
+ }
1616
+ });
1617
+ reply.code(400);
975
1618
  return {
976
1619
  error: {
977
- type: "not_supported",
978
- message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301 stream=true"
1620
+ type: "validation_error",
1621
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
979
1622
  }
980
1623
  };
981
1624
  }
1625
+ const wantsEventStream = typeof request.headers.accept === "string" && request.headers.accept.toLowerCase().includes("text/event-stream");
982
1626
  const input = extractTextInput(parsed.data.input);
983
1627
  const hasInput = typeof parsed.data.input !== "undefined" || typeof parsed.data.experimental_codex?.body?.input !== "undefined";
984
1628
  if (!hasInput) {
1629
+ pushGatewayRequestLog({
1630
+ method: request.method,
1631
+ endpoint: request.url,
1632
+ account: "-",
1633
+ model: parsed.data.model ?? "default",
1634
+ statusCode: 400,
1635
+ durationMs: performance.now() - startedAt,
1636
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1637
+ details: {
1638
+ requestId: request.id,
1639
+ remoteAddress: request.ip,
1640
+ userAgent: request.headers["user-agent"],
1641
+ request: summarizeResponsesRequest(parsed.data),
1642
+ error: {
1643
+ type: "validation_error",
1644
+ message: "\u6CA1\u6709\u63D0\u4F9B input\uFF0C\u4E5F\u6CA1\u6709\u5728 experimental_codex.body \u91CC\u900F\u4F20 input"
1645
+ }
1646
+ }
1647
+ });
985
1648
  reply.code(400);
986
1649
  return {
987
1650
  error: {
@@ -990,34 +1653,35 @@ function createApp(params) {
990
1653
  }
991
1654
  };
992
1655
  }
993
- const codexBody = {
994
- ...parsed.data.experimental_codex?.body ?? {}
995
- };
996
- const normalizedInput = normalizeResponseInput(parsed.data.input);
997
- if (typeof normalizedInput !== "undefined") {
998
- codexBody.input = normalizedInput;
999
- }
1000
- if (typeof parsed.data.instructions === "string") {
1001
- codexBody.instructions = parsed.data.instructions;
1002
- }
1003
- if (parsed.data.tools) {
1004
- codexBody.tools = parsed.data.tools;
1005
- }
1006
- if (typeof parsed.data.tool_choice !== "undefined") {
1007
- codexBody.tool_choice = parsed.data.tool_choice;
1008
- }
1009
- if (parsed.data.include) {
1010
- codexBody.include = parsed.data.include;
1011
- }
1012
- if (parsed.data.text) {
1013
- codexBody.text = parsed.data.text;
1014
- }
1015
- if (typeof parsed.data.store === "boolean") {
1016
- codexBody.store = parsed.data.store;
1017
- }
1018
- if (typeof parsed.data.parallel_tool_calls === "boolean") {
1019
- codexBody.parallel_tool_calls = parsed.data.parallel_tool_calls;
1656
+ if (parsed.data.stream || wantsEventStream) {
1657
+ pushGatewayRequestLog({
1658
+ method: request.method,
1659
+ endpoint: request.url,
1660
+ account: "-",
1661
+ model: parsed.data.model ?? "default",
1662
+ statusCode: 501,
1663
+ durationMs: performance.now() - startedAt,
1664
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1665
+ details: {
1666
+ requestId: request.id,
1667
+ remoteAddress: request.ip,
1668
+ userAgent: request.headers["user-agent"],
1669
+ request: summarizeResponsesRequest(parsed.data),
1670
+ error: {
1671
+ type: "not_supported",
1672
+ message: "\u666E\u901A Responses stream \u5C1A\u672A\u5B9E\u73B0\uFF1BCodex custom provider \u8BF7\u6C42\u4F1A\u8D70\u4E13\u7528\u900F\u4F20\u8DEF\u5F84\u3002"
1673
+ }
1674
+ }
1675
+ });
1676
+ reply.code(501);
1677
+ return {
1678
+ error: {
1679
+ type: "not_supported",
1680
+ message: "\u666E\u901A Responses stream \u5C1A\u672A\u5B9E\u73B0\uFF1BCodex custom provider \u8BF7\u6C42\u4F1A\u8D70\u4E13\u7528\u900F\u4F20\u8DEF\u5F84\u3002"
1681
+ }
1682
+ };
1020
1683
  }
1684
+ const codexBody = createResponsesCodexBody(parsed.data);
1021
1685
  const result = await ctx.chatService.chat({
1022
1686
  model: parsed.data.model,
1023
1687
  input: input || void 0,
@@ -1030,8 +1694,27 @@ function createApp(params) {
1030
1694
  return buildResponseApiBody(result, parsed.data.experimental_codex?.include_raw);
1031
1695
  });
1032
1696
  app.post("/v1/chat/completions", async (request, reply) => {
1697
+ const startedAt = performance.now();
1033
1698
  const parsed = chatCompletionsBodySchema.safeParse(request.body);
1034
1699
  if (!parsed.success) {
1700
+ pushGatewayRequestLog({
1701
+ method: request.method,
1702
+ endpoint: request.url,
1703
+ account: "-",
1704
+ model: "-",
1705
+ statusCode: 400,
1706
+ durationMs: performance.now() - startedAt,
1707
+ source: "API",
1708
+ details: {
1709
+ requestId: request.id,
1710
+ remoteAddress: request.ip,
1711
+ userAgent: request.headers["user-agent"],
1712
+ error: {
1713
+ type: "validation_error",
1714
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1715
+ }
1716
+ }
1717
+ });
1035
1718
  reply.code(400);
1036
1719
  return {
1037
1720
  error: {
@@ -1040,16 +1723,24 @@ function createApp(params) {
1040
1723
  }
1041
1724
  };
1042
1725
  }
1043
- if (parsed.data.stream) {
1044
- reply.code(501);
1045
- return {
1046
- error: {
1047
- type: "not_supported",
1048
- message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301 chat.completions \u7684 stream=true"
1049
- }
1050
- };
1051
- }
1052
1726
  if (typeof parsed.data.n === "number" && parsed.data.n > 1) {
1727
+ pushGatewayRequestLog({
1728
+ method: request.method,
1729
+ endpoint: request.url,
1730
+ account: "-",
1731
+ model: parsed.data.model ?? "default",
1732
+ statusCode: 501,
1733
+ durationMs: performance.now() - startedAt,
1734
+ source: "API",
1735
+ details: {
1736
+ requestId: request.id,
1737
+ request: summarizeChatCompletionsRequest(parsed.data),
1738
+ error: {
1739
+ type: "not_supported",
1740
+ message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301\u4E00\u6B21\u8FD4\u56DE\u591A\u4E2A choices\uFF08n > 1\uFF09"
1741
+ }
1742
+ }
1743
+ });
1053
1744
  reply.code(501);
1054
1745
  return {
1055
1746
  error: {
@@ -1059,16 +1750,93 @@ function createApp(params) {
1059
1750
  };
1060
1751
  }
1061
1752
  const codexBody = createChatCompletionsCodexBody(parsed.data);
1753
+ console.info("[gateway:chat:request]", {
1754
+ requestId: request.id,
1755
+ remoteAddress: request.ip,
1756
+ userAgent: request.headers["user-agent"],
1757
+ ...summarizeChatCompletionsRequest(parsed.data),
1758
+ codex: summarizeCodexChatBody(codexBody)
1759
+ });
1062
1760
  const fallbackInput = parsed.data.messages.map(
1063
1761
  (message) => typeof message.content === "string" ? message.content : (message.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n")
1064
1762
  ).filter(Boolean).join("\n").trim();
1065
- const result = await ctx.chatService.chat({
1066
- model: parsed.data.model,
1067
- input: fallbackInput || void 0,
1068
- experimental: {
1069
- codexBody
1763
+ let result;
1764
+ try {
1765
+ result = await ctx.chatService.chat({
1766
+ model: parsed.data.model,
1767
+ input: fallbackInput || void 0,
1768
+ experimental: {
1769
+ codexBody
1770
+ }
1771
+ });
1772
+ } catch (error) {
1773
+ const normalized = normalizeError(error);
1774
+ const statusCode = getErrorStatusCode(normalized);
1775
+ pushGatewayRequestLog({
1776
+ method: request.method,
1777
+ endpoint: request.url,
1778
+ account: profileLogLabel(await ctx.authService.getActiveProfile()),
1779
+ model: parsed.data.model ?? "default",
1780
+ statusCode,
1781
+ durationMs: performance.now() - startedAt,
1782
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1783
+ details: {
1784
+ requestId: request.id,
1785
+ remoteAddress: request.ip,
1786
+ userAgent: request.headers["user-agent"],
1787
+ request: summarizeChatCompletionsRequest(parsed.data),
1788
+ codex: summarizeCodexChatBody(codexBody),
1789
+ error: {
1790
+ message: normalized.message,
1791
+ upstreamStatus: normalized.upstreamStatus,
1792
+ upstreamErrorCode: normalized.upstreamErrorCode,
1793
+ upstreamErrorMessage: normalized.upstreamErrorMessage
1794
+ }
1795
+ }
1796
+ });
1797
+ throw error;
1798
+ }
1799
+ pushGatewayRequestLog({
1800
+ method: request.method,
1801
+ endpoint: request.url,
1802
+ account: profileLogLabel(await ctx.authService.getActiveProfile()),
1803
+ model: result.model,
1804
+ statusCode: 200,
1805
+ durationMs: performance.now() - startedAt,
1806
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1807
+ details: {
1808
+ requestId: request.id,
1809
+ remoteAddress: request.ip,
1810
+ userAgent: request.headers["user-agent"],
1811
+ request: summarizeChatCompletionsRequest(parsed.data),
1812
+ codex: summarizeCodexChatBody(codexBody),
1813
+ response: {
1814
+ textPreview: truncateForLog(result.text),
1815
+ textLength: result.text.length,
1816
+ toolCallCount: result.toolCalls.length,
1817
+ toolCalls: result.toolCalls.map((toolCall) => ({
1818
+ id: toolCall.id,
1819
+ name: toolCall.function.name,
1820
+ argumentsPreview: truncateForLog(toolCall.function.arguments)
1821
+ })),
1822
+ artifactCount: result.artifacts.length,
1823
+ stream: parsed.data.stream ?? false
1824
+ }
1070
1825
  }
1071
1826
  });
1827
+ console.info("[gateway:chat:response]", {
1828
+ requestId: request.id,
1829
+ model: result.model,
1830
+ stream: parsed.data.stream ?? false,
1831
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1832
+ textLength: result.text.length,
1833
+ toolCallCount: result.toolCalls.length,
1834
+ artifactCount: result.artifacts.length
1835
+ });
1836
+ if (parsed.data.stream) {
1837
+ sendChatCompletionsStream(reply, result);
1838
+ return reply;
1839
+ }
1072
1840
  return buildChatCompletionsBody(result);
1073
1841
  });
1074
1842
  app.post("/v1/images/generations", async (request, reply) => {