ai-zero-token 2.0.4 → 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 (26) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +17 -2
  3. package/README.zh-CN.md +18 -3
  4. package/admin-ui/dist/assets/{accounts-CTjk9c4F.js → accounts-ABMyXo4H.js} +1 -1
  5. package/admin-ui/dist/assets/{docs-oNIugCIL.js → docs-Dh0aFha_.js} +1 -1
  6. package/admin-ui/dist/assets/{image-bed-CQtIhjg_.js → image-bed-C1M7-0q1.js} +1 -1
  7. package/admin-ui/dist/assets/{index-rgcJgVAu.js → index--rNjdmzf.js} +2 -2
  8. package/admin-ui/dist/assets/{index-By4r-wy3.css → index-DjtN30PC.css} +1 -1
  9. package/admin-ui/dist/assets/{launch-B-2Zdz9m.js → launch-pB7YlWFI.js} +1 -1
  10. package/admin-ui/dist/assets/{logs-JFuSf56b.js → logs-B7McijSi.js} +1 -1
  11. package/admin-ui/dist/assets/{network-detect-SfvK6uhx.js → network-detect-Bx3XmXPk.js} +1 -1
  12. package/admin-ui/dist/assets/{overview-X_WodIqE.js → overview-CV0H2Nsq.js} +1 -1
  13. package/admin-ui/dist/assets/settings-ynCIdUvZ.js +7 -0
  14. package/admin-ui/dist/assets/{tester-ocpF053C.js → tester-BG-up8qP.js} +1 -1
  15. package/admin-ui/dist/index.html +2 -2
  16. package/dist/core/providers/http-client.js +228 -3
  17. package/dist/core/providers/openai-codex/chat.js +83 -23
  18. package/dist/core/services/auth-service.js +14 -5
  19. package/dist/core/services/config-service.js +15 -5
  20. package/dist/core/store/codex-auth-store.js +295 -4
  21. package/dist/core/store/settings-store.js +54 -24
  22. package/dist/server/app.js +410 -49
  23. package/docs/API_USAGE.md +18 -1
  24. package/docs/DESKTOP_RELEASE.md +12 -1
  25. package/package.json +1 -1
  26. package/admin-ui/dist/assets/settings-0eXUAvcm.js +0 -1
@@ -2,12 +2,14 @@
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");
@@ -45,17 +47,9 @@ async function readAdminUiAsset(assetPath) {
45
47
  return null;
46
48
  }
47
49
  }
48
- const inputPartSchema = z.object({
49
- type: z.string().optional(),
50
- text: z.string().optional()
51
- }).passthrough();
52
- const inputMessageSchema = z.object({
53
- role: z.string().optional(),
54
- content: z.array(inputPartSchema).optional()
55
- }).passthrough();
56
50
  const responsesBodySchema = z.object({
57
51
  model: z.string().optional(),
58
- input: z.union([z.string(), z.array(inputMessageSchema)]).optional(),
52
+ input: z.unknown().optional(),
59
53
  instructions: z.string().optional(),
60
54
  stream: z.boolean().optional(),
61
55
  tools: z.array(z.unknown()).optional(),
@@ -69,7 +63,7 @@ const responsesBodySchema = z.object({
69
63
  allow_unknown_model: z.boolean().optional(),
70
64
  include_raw: z.boolean().optional()
71
65
  }).passthrough().optional()
72
- });
66
+ }).passthrough();
73
67
  const chatCompletionContentPartSchema = z.object({
74
68
  type: z.string().optional(),
75
69
  text: z.string().optional(),
@@ -114,7 +108,8 @@ const settingsUpdateSchema = z.object({
114
108
  noProxy: z.string().optional()
115
109
  }).optional(),
116
110
  autoSwitch: z.object({
117
- enabled: z.boolean()
111
+ enabled: z.boolean().optional(),
112
+ excludedProfileIds: z.array(z.string()).optional()
118
113
  }).optional(),
119
114
  runtime: z.object({
120
115
  quotaSyncConcurrency: z.number().int().min(1).max(32).optional()
@@ -150,6 +145,10 @@ const profileExportSchema = z.object({
150
145
  const codexApplySchema = z.object({
151
146
  profileId: z.string().min(1)
152
147
  });
148
+ const codexProviderConfigSchema = z.object({
149
+ baseUrl: z.string().min(1).optional(),
150
+ providerId: z.string().min(1).optional()
151
+ });
153
152
  const githubImageBedConfigSchema = z.object({
154
153
  token: z.string().min(1)
155
154
  });
@@ -199,6 +198,21 @@ const imageEditsBodySchema = z.object({
199
198
  response_format: z.enum(["b64_json", "url"]).optional(),
200
199
  user: z.string().optional()
201
200
  }).passthrough();
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
+ }
202
216
  function extractTextInput(input) {
203
217
  if (typeof input === "undefined") {
204
218
  return "";
@@ -207,12 +221,14 @@ function extractTextInput(input) {
207
221
  return input;
208
222
  }
209
223
  const chunks = [];
224
+ if (!Array.isArray(input)) {
225
+ return "";
226
+ }
210
227
  for (const item of input) {
211
- for (const part of item.content ?? []) {
212
- if (typeof part.text === "string" && part.text.trim()) {
213
- chunks.push(part.text.trim());
214
- }
228
+ if (!item || typeof item !== "object") {
229
+ continue;
215
230
  }
231
+ chunks.push(...extractTextFromInputContent(item.content));
216
232
  }
217
233
  return chunks.join("\n").trim();
218
234
  }
@@ -415,6 +431,46 @@ function summarizeToolNames(tools) {
415
431
  return typeof fn?.name === "string" ? fn.name : typeof record.name === "string" ? record.name : "";
416
432
  }).filter(Boolean);
417
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
+ }
418
474
  function summarizeChatCompletionsRequest(data) {
419
475
  const lastUserMessage = [...data.messages].reverse().find((message) => (message.role ?? "user") === "user");
420
476
  const toolNames = summarizeToolNames(data.tools);
@@ -455,7 +511,17 @@ function profileLogLabel(profile) {
455
511
  return profile?.email || profile?.accountId || profile?.profileId || "-";
456
512
  }
457
513
  function requestSourceFromUserAgent(userAgent) {
458
- return typeof userAgent === "string" && userAgent.toLowerCase().includes("openclaw") ? "OpenClaw" : "API";
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";
459
525
  }
460
526
  function createChatCompletionsCodexBody(data) {
461
527
  const body = {
@@ -749,7 +815,7 @@ function getErrorStatusCode(error) {
749
815
  return normalized.statusCode;
750
816
  }
751
817
  const upstreamStatus = normalized.upstreamStatus;
752
- if (upstreamStatus === 401 || upstreamStatus === 403) {
818
+ if (typeof upstreamStatus === "number" && upstreamStatus >= 400 && upstreamStatus < 600) {
753
819
  return upstreamStatus;
754
820
  }
755
821
  const message = normalized.message;
@@ -761,6 +827,50 @@ function getErrorStatusCode(error) {
761
827
  }
762
828
  return 500;
763
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
+ }
764
874
  function createApp(params) {
765
875
  const app = Fastify({
766
876
  logger: false,
@@ -833,6 +943,7 @@ function createApp(params) {
833
943
  codex: codexStatus,
834
944
  adminUrl: `${origin}/`,
835
945
  baseUrl: `${origin}/v1`,
946
+ codexBaseUrl: `${origin}/codex/v1`,
836
947
  restartSupported: Boolean(params?.onRestart),
837
948
  codexRestartSupported: Boolean(params?.onRestartCodex),
838
949
  supportedEndpoints: [
@@ -846,6 +957,11 @@ function createApp(params) {
846
957
  path: "/v1/responses",
847
958
  description: "OpenAI responses \u517C\u5BB9\u63A5\u53E3\u3002"
848
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
+ },
849
965
  {
850
966
  method: "POST",
851
967
  path: "/v1/chat/completions",
@@ -1092,6 +1208,45 @@ function createApp(params) {
1092
1208
  config: await buildAdminConfig(request)
1093
1209
  };
1094
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
+ });
1095
1250
  app.put("/_gateway/admin/settings", async (request, reply) => {
1096
1251
  const parsed = settingsUpdateSchema.safeParse(request.body);
1097
1252
  if (!parsed.success) {
@@ -1262,9 +1417,171 @@ function createApp(params) {
1262
1417
  owned_by: model.provider
1263
1418
  }))
1264
1419
  }));
1265
- 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();
1266
1565
  const parsed = responsesBodySchema.safeParse(request.body);
1267
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
+ });
1268
1585
  reply.code(400);
1269
1586
  return {
1270
1587
  error: {
@@ -1273,18 +1590,61 @@ function createApp(params) {
1273
1590
  }
1274
1591
  };
1275
1592
  }
1276
- if (parsed.data.stream) {
1277
- 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);
1278
1618
  return {
1279
1619
  error: {
1280
- type: "not_supported",
1281
- 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"
1282
1622
  }
1283
1623
  };
1284
1624
  }
1625
+ const wantsEventStream = typeof request.headers.accept === "string" && request.headers.accept.toLowerCase().includes("text/event-stream");
1285
1626
  const input = extractTextInput(parsed.data.input);
1286
1627
  const hasInput = typeof parsed.data.input !== "undefined" || typeof parsed.data.experimental_codex?.body?.input !== "undefined";
1287
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
+ });
1288
1648
  reply.code(400);
1289
1649
  return {
1290
1650
  error: {
@@ -1293,34 +1653,35 @@ function createApp(params) {
1293
1653
  }
1294
1654
  };
1295
1655
  }
1296
- const codexBody = {
1297
- ...parsed.data.experimental_codex?.body ?? {}
1298
- };
1299
- const normalizedInput = normalizeResponseInput(parsed.data.input);
1300
- if (typeof normalizedInput !== "undefined") {
1301
- codexBody.input = normalizedInput;
1302
- }
1303
- if (typeof parsed.data.instructions === "string") {
1304
- codexBody.instructions = parsed.data.instructions;
1305
- }
1306
- if (parsed.data.tools) {
1307
- codexBody.tools = parsed.data.tools;
1308
- }
1309
- if (typeof parsed.data.tool_choice !== "undefined") {
1310
- codexBody.tool_choice = parsed.data.tool_choice;
1311
- }
1312
- if (parsed.data.include) {
1313
- codexBody.include = parsed.data.include;
1314
- }
1315
- if (parsed.data.text) {
1316
- codexBody.text = parsed.data.text;
1317
- }
1318
- if (typeof parsed.data.store === "boolean") {
1319
- codexBody.store = parsed.data.store;
1320
- }
1321
- if (typeof parsed.data.parallel_tool_calls === "boolean") {
1322
- 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
+ };
1323
1683
  }
1684
+ const codexBody = createResponsesCodexBody(parsed.data);
1324
1685
  const result = await ctx.chatService.chat({
1325
1686
  model: parsed.data.model,
1326
1687
  input: input || void 0,
package/docs/API_USAGE.md CHANGED
@@ -59,6 +59,23 @@ The gateway accepts OpenClaw-style `chat.completions` requests with `tools`, `to
59
59
 
60
60
  OpenClaw requests are visible in the management console request log when the client sends an OpenClaw user agent. The log keeps safe summaries only; it does not store full access tokens.
61
61
 
62
+ ## Codex Custom Provider
63
+
64
+ Codex CLI/Desktop can route model traffic through AI Zero Token by using a custom Responses provider in `~/.codex/config.toml`. The management console Settings page can write this automatically with "接管 Codex 请求" and remove it with "解除接管":
65
+
66
+ ```toml
67
+ model = "gpt-5.4"
68
+ model_provider = "ai-zero-token"
69
+
70
+ [model_providers.ai-zero-token]
71
+ name = "AI Zero Token"
72
+ base_url = "http://127.0.0.1:8787/codex/v1"
73
+ wire_api = "responses"
74
+ supports_websockets = false
75
+ ```
76
+
77
+ Codex sends `POST /codex/v1/responses` with `Accept: text/event-stream`; the gateway forwards that request to the active Codex OAuth account and streams upstream Responses SSE events back to Codex. The regular `/v1/*` routes remain OpenAI-compatible API routes for non-Codex clients.
78
+
62
79
  ## Models
63
80
 
64
81
  ```bash
@@ -196,7 +213,7 @@ console.log(response.choices[0]?.message?.content);
196
213
 
197
214
  - Login first through the management page or `azt login`.
198
215
  - A model appearing in `/v1/models` means the local Codex cache lists it. Final availability still depends on the active account.
199
- - `stream=true` is supported for `/v1/chat/completions` through OpenAI-style SSE chunks. `/v1/responses` streaming is still not implemented.
216
+ - `stream=true` is supported for `/v1/chat/completions` through OpenAI-style SSE chunks. Codex passthrough streaming is isolated under `/codex/v1/responses`.
200
217
  - `n > 1` is not supported for `/v1/chat/completions`.
201
218
  - Tool/function calling is supported for common OpenAI-compatible clients, including OpenClaw, but exact upstream behavior still depends on the active Codex model and account.
202
219
  - The default listener is `0.0.0.0:8787`, so local-network clients can call the gateway by using the machine IP.
@@ -2,6 +2,17 @@
2
2
 
3
3
  This project ships the desktop app with Electron. The desktop main process starts the existing local Fastify gateway and loads the React management UI served by that gateway.
4
4
 
5
+ ## 2.0.5 Release Notes
6
+
7
+ Version `2.0.5` adds Codex custom provider routing and finer account rotation controls:
8
+
9
+ - Settings-page Codex provider setup for writing or removing the AI Zero Token managed `~/.codex/config.toml` provider.
10
+ - Local and remote Codex gateway URL modes, including automatic normalization to `/codex/v1`.
11
+ - Dedicated `POST /codex/v1/responses` passthrough route for Codex CLI/Desktop Responses SSE traffic.
12
+ - Provider status reporting in the management console, including active provider, base URL, and config path.
13
+ - Auto-switch exclusion list for accounts that should not participate in automatic quota rotation.
14
+ - Safer settings persistence through normalized loads, deduplicated profile IDs, queued saves, and atomic writes.
15
+
5
16
  ## 2.0.4 Release Notes
6
17
 
7
18
  Version `2.0.4` adds the macOS menu-bar account panel and OpenClaw compatibility work:
@@ -103,7 +114,7 @@ AI Zero Token Setup {version}.exe
103
114
  AI Zero Token-{version}-win.zip
104
115
  ```
105
116
 
106
- For `2.0.4`, replace `{version}` with `2.0.4`.
117
+ For `2.0.5`, replace `{version}` with `2.0.5`.
107
118
 
108
119
  Artifact purpose:
109
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-zero-token",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Local-first OpenAI-compatible AI CLI and gateway with Codex OAuth, multi-account management, and gpt-image-2 image generation/editing.",
5
5
  "license": "MIT",
6
6
  "author": "AI Zero Token Contributors",
@@ -1 +0,0 @@
1
- import{a as e,n as t,r as n,t as r}from"./jsx-runtime-DqpGtLhh.js";import{t as i}from"./refresh-cw-CAAH2rqe.js";import{w as a}from"./profiles-DMOjJORP.js";import{_ as o,p as s,r as c}from"./index-rgcJgVAu.js";var l=t(`settings-2`,[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),u=e(n(),1),d=r();function f(e){return{defaultModel:e.settings.defaultModel,proxyEnabled:e.settings.networkProxy.enabled,proxyUrl:e.settings.networkProxy.url,proxyNoProxy:e.settings.networkProxy.noProxy||`localhost,127.0.0.1,::1`,autoSwitchEnabled:e.settings.autoSwitch.enabled,quotaSyncConcurrency:String(e.settings.runtime?.quotaSyncConcurrency||16),serverPort:String(e.settings.server.port||8787)}}function p(e){let[t,n]=(0,u.useState)({defaultModel:``,proxyEnabled:!1,proxyUrl:``,proxyNoProxy:`localhost,127.0.0.1,::1`,autoSwitchEnabled:!1,quotaSyncConcurrency:`16`,serverPort:`8787`}),[r,p]=(0,u.useState)(!1);(0,u.useEffect)(()=>{!e.config||r||n(f(e.config))},[e.config,r]);function m(e){n(t=>({...t,...e})),p(!0)}async function h(n){let r=Number.parseInt(t.serverPort,10);if(!Number.isInteger(r)||r<1||r>65535){e.setStatus(`端口必须是 1 到 65535 之间的整数。`);return}let i=Number.parseInt(t.quotaSyncConcurrency,10);if(!Number.isInteger(i)||i<1||i>32){e.setStatus(`全局额度刷新并发数必须是 1 到 32 之间的整数。`);return}let o=n?.restart?`restart`:`settings`;e.setBusy(o);try{let o=await s(`/_gateway/admin/settings`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:a({defaultModel:t.defaultModel,networkProxy:{enabled:t.proxyEnabled,url:t.proxyUrl,noProxy:t.proxyNoProxy},autoSwitch:{enabled:t.autoSwitchEnabled},runtime:{quotaSyncConcurrency:i},server:{port:r}})});e.setConfig(o),p(!1),n?.restart?(e.setStatus(`设置已保存,正在重启本地网关...`),await s(`/_gateway/admin/restart`,{method:`POST`}),e.setStatus(`本地网关正在重启,页面会自动恢复。`)):e.setStatus(`设置已保存。`)}catch(t){e.setStatus(c(t))}finally{e.setBusy(null)}}async function g(){e.setBusy(`proxy`);try{let n=await s(`/_gateway/admin/settings/proxy-test`,{method:`POST`,headers:{"Content-Type":`application/json`},body:a({networkProxy:{enabled:t.proxyEnabled,url:t.proxyUrl,noProxy:t.proxyNoProxy}})});e.setStatus(`代理测试通过: HTTP ${n.status},耗时 ${n.elapsedMs} ms。`)}catch(t){e.setStatus(`代理测试失败: ${c(t)}`)}finally{e.setBusy(null)}}async function _(){e.setBusy(`models`);try{await s(`/_gateway/models/refresh`,{method:`POST`}),await e.refreshConfig({silent:!0}),e.setStatus(`Codex 模型列表已同步。`)}catch(t){e.setStatus(c(t))}finally{e.setBusy(null)}}return(0,d.jsxs)(`section`,{className:`settings-page`,children:[(0,d.jsxs)(`div`,{className:`settings-page-head`,children:[(0,d.jsxs)(`div`,{children:[(0,d.jsxs)(`div`,{className:`settings-page-kicker`,children:[(0,d.jsx)(l,{size:14}),`系统设置`]}),(0,d.jsx)(`h2`,{children:`本地网关和运行策略`}),(0,d.jsx)(`p`,{children:`设置会保存到本地状态目录,CLI、桌面端和本地服务共享。`})]}),(0,d.jsx)(`div`,{className:`settings-page-actions`,children:(0,d.jsxs)(`button`,{className:`btn-secondary`,type:`button`,onClick:_,disabled:e.busy===`models`,children:[e.busy===`models`?(0,d.jsx)(o,{className:`spin`,size:16}):(0,d.jsx)(i,{size:16}),`同步 Codex 模型`]})})]}),(0,d.jsxs)(`div`,{className:`settings-grid`,children:[(0,d.jsxs)(`section`,{className:`settings-section`,children:[(0,d.jsx)(`h4`,{children:`模型`}),(0,d.jsxs)(`label`,{className:`field`,children:[(0,d.jsx)(`span`,{children:`默认文本模型`}),(0,d.jsx)(`select`,{className:`control`,value:t.defaultModel,onChange:e=>m({defaultModel:e.target.value}),children:(e.config?.models||[]).map(e=>(0,d.jsx)(`option`,{value:e.id,children:e.id},e.id))})]}),(0,d.jsxs)(`p`,{className:`hint`,children:[`模型列表来源:`,e.config?.modelCatalog.source||`-`,`,共 `,e.config?.modelCatalog.modelCount||0,` 个。`]})]}),(0,d.jsxs)(`section`,{className:`settings-section`,children:[(0,d.jsx)(`h4`,{children:`上游代理`}),(0,d.jsxs)(`label`,{className:`switch-line`,children:[(0,d.jsx)(`input`,{type:`checkbox`,checked:t.proxyEnabled,onChange:e=>m({proxyEnabled:e.target.checked})}),(0,d.jsx)(`span`,{children:`启用 OAuth、模型刷新和接口转发代理`})]}),(0,d.jsxs)(`label`,{className:`field`,children:[(0,d.jsx)(`span`,{children:`代理地址`}),(0,d.jsx)(`input`,{className:`input`,value:t.proxyUrl,onChange:e=>m({proxyUrl:e.target.value}),placeholder:`http://127.0.0.1:7890`})]}),(0,d.jsxs)(`label`,{className:`field`,children:[(0,d.jsx)(`span`,{children:`No Proxy`}),(0,d.jsx)(`input`,{className:`input`,value:t.proxyNoProxy,onChange:e=>m({proxyNoProxy:e.target.value})})]}),(0,d.jsx)(`button`,{className:`btn-secondary`,type:`button`,onClick:g,disabled:e.busy===`proxy`,children:`测试代理`})]}),(0,d.jsxs)(`section`,{className:`settings-section`,children:[(0,d.jsx)(`h4`,{children:`端口`}),(0,d.jsxs)(`label`,{className:`field`,children:[(0,d.jsx)(`span`,{children:`网关端口`}),(0,d.jsx)(`input`,{className:`input`,inputMode:`numeric`,type:`number`,min:1,max:65535,value:t.serverPort,onChange:e=>m({serverPort:e.target.value})})]}),(0,d.jsx)(`p`,{className:`hint`,children:`修改后重启本地网关生效,桌面窗口不会退出。若端口被占用,启动时会自动顺延到下一个可用端口。`})]}),(0,d.jsxs)(`section`,{className:`settings-section`,children:[(0,d.jsx)(`h4`,{children:`账号运行策略`}),(0,d.jsxs)(`label`,{className:`switch-line`,children:[(0,d.jsx)(`input`,{type:`checkbox`,checked:t.autoSwitchEnabled,onChange:e=>m({autoSwitchEnabled:e.target.checked})}),(0,d.jsx)(`span`,{children:`当前 API 账号额度耗尽后自动切换到下一个仍有额度的账号`})]}),(0,d.jsxs)(`label`,{className:`field`,children:[(0,d.jsx)(`span`,{children:`全局额度刷新并发数`}),(0,d.jsx)(`input`,{className:`input`,inputMode:`numeric`,max:32,min:1,type:`number`,value:t.quotaSyncConcurrency,onChange:e=>m({quotaSyncConcurrency:e.target.value})})]}),(0,d.jsx)(`p`,{className:`hint`,children:`手动刷新全部账号额度时使用,默认 16。账号很多可以调高,遇到限流或失败增多时调低。`}),(0,d.jsx)(`p`,{className:`hint`,children:e.status})]}),(0,d.jsxs)(`section`,{className:`settings-section`,children:[(0,d.jsx)(`h4`,{children:`显示`}),(0,d.jsxs)(`label`,{className:`switch-line`,children:[(0,d.jsx)(`input`,{type:`checkbox`,checked:e.showEmails,onChange:t=>e.setShowEmails(t.target.checked)}),(0,d.jsx)(`span`,{children:`脱敏模式`})]}),(0,d.jsx)(`p`,{className:`hint`,children:`开启后账号邮箱将以脱敏形式展示。`})]})]}),(0,d.jsxs)(`div`,{className:`settings-page-actions settings-page-footer-actions`,children:[(0,d.jsx)(`button`,{className:`btn-secondary`,type:`button`,onClick:()=>void h(),disabled:e.busy===`settings`||e.busy===`restart`||!r,children:`保存设置`}),(0,d.jsx)(`button`,{className:`btn-primary`,type:`button`,onClick:()=>void h({restart:!0}),disabled:e.busy===`settings`||e.busy===`restart`||!r||!e.config?.restartSupported,children:`保存并重启网关`})]})]})}export{p as SettingsPage};