ai-zero-token 1.0.2 → 1.0.4

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 1.0.4 - 2026-04-24
4
+
5
+ - Moved persistent account and settings state to the user home directory at `~/.ai-zero-token/.state`.
6
+ - Added automatic one-time migration from the old package-local `.state` directory when available.
7
+ - Added `AI_ZERO_TOKEN_HOME` support for overriding the persistent state location.
8
+ - Fixed repeated login prompts after npm upgrades or global package reinstalls.
9
+
10
+ ## 1.0.3 - 2026-04-24
11
+
12
+ - Added dynamic Codex model discovery from the local `~/.codex/models_cache.json` cache, with static model fallback when the cache is unavailable.
13
+ - Added `azt models --refresh` and a management-page action to re-read the local Codex model list without rebuilding the package.
14
+ - Added runtime version checks against npm, including a prominent update panel in the management UI when a newer version is available.
15
+ - Added 10-minute automatic refresh for quota snapshots and version status in the management UI.
16
+ - Improved quota display so account cards show used and remaining quota percentages clearly.
17
+ - Improved quota syncing so inactive or missing login state does not break runtime refresh.
18
+ - Improved image generation error handling with transient retries and clearer failure details.
19
+ - Preserved response headers when using the curl HTTP fallback so quota metadata can still be captured.
20
+ - Added Vibe Coding / OpenAI-compatible client integration documentation.
package/README.md CHANGED
@@ -18,6 +18,8 @@ AI Zero Token 是一个本地优先的单用户 AI CLI 和本地网关。
18
18
  - 直接代理 `gpt-image-2`,把图片生成能力暴露成 OpenAI 风格 `images.generations` 接口
19
19
  - 启动 `azt start` 后即可获得本地管理页和本地网关,适合脚本、前端和自动化流程接入
20
20
  - 支持多账号保存、切换当前账号、查看账号套餐 plan,以及当前账号是否支持生图
21
+ - 模型列表会优先同步本机 `~/.codex/models_cache.json`,不需要每次为新模型重新 build
22
+ - 管理页会每 10 分钟自动同步额度快照和版本状态,并提示当前版本是否可更新
21
23
  - `free` 账号会在管理页直接预警,并在网关层明确拦截生图请求
22
24
 
23
25
  如果你只关心一句话,可以把这个项目理解为:
@@ -78,6 +80,7 @@ AI Zero Token 就是围绕这些问题设计的。
78
80
  - 在 token 过期时自动刷新
79
81
  - 通过 `azt start` 一键启动本地 HTTP 网关和管理页面
80
82
  - 在管理页面里完成多账号登录、查看账号状态、切换当前账号、切换默认模型、测试接口
83
+ - 模型列表优先读取本机 Codex 最新缓存,并支持在 CLI / 管理页手动同步
81
84
  - 暴露 OpenAI 风格接口:
82
85
  - `GET /v1/models`
83
86
  - `POST /v1/responses`
@@ -131,6 +134,7 @@ npm install -g ai-zero-token
131
134
 
132
135
  ```bash
133
136
  azt start
137
+ azt models --refresh
134
138
  ```
135
139
 
136
140
  如果你是为了开发、构建、`npm link`、`npm pack` 或准备发布,单独看:
@@ -174,6 +178,10 @@ http://127.0.0.1:8787
174
178
  http://127.0.0.1:8787/v1
175
179
  ```
176
180
 
181
+ Vibe Coding、OpenAI-compatible SDK 和脚本接入可以参考:
182
+
183
+ - [API 使用说明](docs/API_USAGE.md)
184
+
177
185
  如果你要让本地网页直接从浏览器请求这个网关,现在已经默认开启 CORS。
178
186
 
179
187
  如需限制来源,可以在启动前指定:
@@ -320,6 +328,7 @@ curl http://127.0.0.1:8787/v1/images/generations \
320
328
  - `GET /_gateway/health`
321
329
  - `GET /_gateway/status`
322
330
  - `GET /_gateway/models`
331
+ - `POST /_gateway/models/refresh`
323
332
  - `GET /_gateway/admin/config`
324
333
  - `POST /_gateway/admin/login`
325
334
  - `POST /_gateway/admin/logout`
@@ -4,6 +4,7 @@ function printHelp() {
4
4
 
5
5
  azt login
6
6
  azt models
7
+ azt models --refresh
7
8
  azt status
8
9
  azt ask "\u4F60\u597D\uFF0C\u8BF7\u7B80\u5355\u4ECB\u7ECD\u4E00\u4E0B\u81EA\u5DF1"
9
10
  azt ask --model gpt-5.3-codex "\u4F60\u597D"
@@ -16,7 +17,7 @@ function printHelp() {
16
17
  \u8BF4\u660E:
17
18
 
18
19
  login \u8D70\u771F\u5B9E OpenAI Codex OAuth\uFF0C\u65B0\u589E\u5E76\u4FDD\u5B58\u4E00\u4E2A\u8D26\u53F7 profile
19
- models \u67E5\u770B\u8FD9\u4E2A demo \u5F53\u524D\u5185\u7F6E\u652F\u6301\u7684\u6A21\u578B\u5217\u8868
20
+ models \u67E5\u770B\u5F53\u524D\u53EF\u7528\u6A21\u578B\u5217\u8868\uFF1B\u4F18\u5148\u8BFB\u53D6 ~/.codex/models_cache.json\uFF0C--refresh \u53EF\u624B\u52A8\u91CD\u8BFB
20
21
  status \u67E5\u770B\u5F53\u524D demo \u5F53\u524D\u6FC0\u6D3B\u8D26\u53F7\u3001\u8D26\u53F7\u6570\u91CF\u548C\u8FC7\u671F\u65F6\u95F4
21
22
  ask \u7528\u4FDD\u5B58\u7684 token \u8C03\u771F\u5B9E Codex Responses API
22
23
  \u5B9E\u9A8C\u6A21\u5F0F\u53EF\u7528 --payload-file \u900F\u4F20\u989D\u5916\u8BF7\u6C42\u4F53\uFF0C\u914D\u5408 --dump-raw / --print-raw \u89C2\u5BDF SSE \u539F\u59CB\u4E8B\u4EF6
@@ -1,9 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { createGatewayContext } from "../../core/context.js";
3
- async function runModelsCommand() {
3
+ async function runModelsCommand(args = []) {
4
4
  const ctx = createGatewayContext();
5
- console.log("\u5F53\u524D demo \u5185\u7F6E\u652F\u6301\u7684\u6A21\u578B:");
6
- for (const model of await ctx.modelService.listModels()) {
5
+ const refresh = args.includes("--refresh");
6
+ const result = refresh ? await ctx.modelService.refreshModels() : {
7
+ models: await ctx.modelService.listModels(),
8
+ catalog: await ctx.modelService.getCatalog()
9
+ };
10
+ console.log(refresh ? "\u5DF2\u91CD\u65B0\u8BFB\u53D6 Codex \u6A21\u578B\u5217\u8868:" : "\u5F53\u524D demo \u53EF\u7528\u6A21\u578B\u5217\u8868:");
11
+ console.log(`- \u6765\u6E90: ${result.catalog.source === "codex-cache" ? "Codex \u672C\u5730\u7F13\u5B58" : "\u9879\u76EE\u5185\u7F6E\u56DE\u9000\u5217\u8868"}`);
12
+ console.log(`- \u8DEF\u5F84: ${result.catalog.cachePath}`);
13
+ if (result.catalog.fetchedAt) {
14
+ console.log(`- Codex \u66F4\u65B0\u65F6\u95F4: ${result.catalog.fetchedAt}`);
15
+ }
16
+ console.log(`- \u6570\u91CF: ${result.catalog.modelCount}`);
17
+ for (const model of result.models) {
7
18
  const suffix = model.isDefault ? " (\u9ED8\u8BA4)" : "";
8
19
  console.log(`- ${model.id}${suffix}`);
9
20
  }
package/dist/cli/index.js CHANGED
@@ -17,7 +17,7 @@ async function runCli(argv = process.argv.slice(2)) {
17
17
  await runStatusCommand();
18
18
  return;
19
19
  case "models":
20
- await runModelsCommand();
20
+ await runModelsCommand(rest);
21
21
  return;
22
22
  case "ask":
23
23
  await runAskCommand(rest);
@@ -4,10 +4,12 @@ import { AuthService } from "./services/auth-service.js";
4
4
  import { ChatService } from "./services/chat-service.js";
5
5
  import { ImageService } from "./services/image-service.js";
6
6
  import { ModelService } from "./services/model-service.js";
7
+ import { VersionService } from "./services/version-service.js";
7
8
  function createGatewayContext() {
8
9
  const configService = new ConfigService();
9
10
  const authService = new AuthService(configService);
10
11
  const modelService = new ModelService(configService);
12
+ const versionService = new VersionService();
11
13
  const chatService = new ChatService({
12
14
  authService,
13
15
  modelService
@@ -20,6 +22,7 @@ function createGatewayContext() {
20
22
  configService,
21
23
  authService,
22
24
  modelService,
25
+ versionService,
23
26
  chatService,
24
27
  imageService
25
28
  };
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  const DEFAULT_CODEX_MODEL = "gpt-5.4";
3
6
  const CODEX_MODEL_INFOS = [
4
7
  { provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.4", input: ["text", "image"], source: "static" },
@@ -22,6 +25,88 @@ const SUPPORTED_CODEX_MODELS = [
22
25
  "gpt-5.1-codex-mini",
23
26
  "gpt-5.1-codex-max"
24
27
  ];
28
+ function getCodexModelsCachePath() {
29
+ return process.env.CODEX_MODELS_CACHE_PATH || path.join(os.homedir(), ".codex", "models_cache.json");
30
+ }
31
+ function normalizeInputModalities(input) {
32
+ const rawValues = Array.isArray(input) ? input : [];
33
+ const values = /* @__PURE__ */ new Set();
34
+ for (const item of rawValues) {
35
+ if (item === "text" || item === "image") {
36
+ values.add(item);
37
+ }
38
+ }
39
+ if (values.size === 0) {
40
+ values.add("text");
41
+ }
42
+ return Array.from(values);
43
+ }
44
+ function normalizeCodexCacheEntry(entry) {
45
+ if (!entry || typeof entry.slug !== "string" || !entry.slug) {
46
+ return null;
47
+ }
48
+ if (typeof entry.visibility === "string" && entry.visibility !== "list") {
49
+ return null;
50
+ }
51
+ return {
52
+ provider: "openai-codex",
53
+ id: entry.slug,
54
+ name: typeof entry.display_name === "string" && entry.display_name ? entry.display_name : entry.slug,
55
+ input: normalizeInputModalities(entry.input_modalities),
56
+ source: "codex-cache"
57
+ };
58
+ }
59
+ function dedupeModels(models) {
60
+ const seen = /* @__PURE__ */ new Set();
61
+ const next = [];
62
+ for (const model of models) {
63
+ if (seen.has(model.id)) {
64
+ continue;
65
+ }
66
+ seen.add(model.id);
67
+ next.push(model);
68
+ }
69
+ return next;
70
+ }
71
+ async function getCodexModelCatalog() {
72
+ const cachePath = getCodexModelsCachePath();
73
+ try {
74
+ const raw = await fs.readFile(cachePath, "utf8");
75
+ const parsed = JSON.parse(raw);
76
+ const models = dedupeModels((parsed.models ?? []).map(normalizeCodexCacheEntry).filter(Boolean));
77
+ if (models.length > 0) {
78
+ return {
79
+ models,
80
+ catalog: {
81
+ source: "codex-cache",
82
+ cachePath,
83
+ fetchedAt: parsed.fetched_at,
84
+ modelCount: models.length
85
+ }
86
+ };
87
+ }
88
+ } catch {
89
+ }
90
+ return {
91
+ models: CODEX_MODEL_INFOS,
92
+ catalog: {
93
+ source: "static-fallback",
94
+ cachePath,
95
+ modelCount: CODEX_MODEL_INFOS.length
96
+ }
97
+ };
98
+ }
99
+ async function hasCodexModel(model) {
100
+ const { models } = await getCodexModelCatalog();
101
+ return models.some((item) => item.id === model);
102
+ }
103
+ async function getPreferredCodexModel() {
104
+ const { models } = await getCodexModelCatalog();
105
+ if (models.some((item) => item.id === DEFAULT_CODEX_MODEL)) {
106
+ return DEFAULT_CODEX_MODEL;
107
+ }
108
+ return models[0]?.id ?? DEFAULT_CODEX_MODEL;
109
+ }
25
110
  function isSupportedCodexModel(model) {
26
111
  return SUPPORTED_CODEX_MODELS.includes(model);
27
112
  }
@@ -29,5 +114,9 @@ export {
29
114
  CODEX_MODEL_INFOS,
30
115
  DEFAULT_CODEX_MODEL,
31
116
  SUPPORTED_CODEX_MODELS,
117
+ getCodexModelCatalog,
118
+ getCodexModelsCachePath,
119
+ getPreferredCodexModel,
120
+ hasCodexModel,
32
121
  isSupportedCodexModel
33
122
  };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  const CURL_STATUS_MARKER = "\n__CURL_STATUS__:";
4
+ const CURL_HEADERS_MARKER = "\n__CURL_HEADERS__:";
4
5
  let requestSequence = 0;
5
6
  function nextRequestId() {
6
7
  requestSequence += 1;
@@ -37,6 +38,23 @@ function normalizeHeaders(headers) {
37
38
  });
38
39
  return normalized;
39
40
  }
41
+ function normalizeCurlHeaders(value) {
42
+ if (!value || typeof value !== "object") {
43
+ return {};
44
+ }
45
+ return Object.fromEntries(
46
+ Object.entries(value).flatMap(([key, rawValue]) => {
47
+ if (typeof rawValue === "string" && rawValue.trim()) {
48
+ return [[key.toLowerCase(), rawValue.trim()]];
49
+ }
50
+ if (Array.isArray(rawValue)) {
51
+ const joined = rawValue.filter((item) => typeof item === "string" && item.trim()).join(", ");
52
+ return joined ? [[key.toLowerCase(), joined]] : [];
53
+ }
54
+ return [];
55
+ })
56
+ );
57
+ }
40
58
  async function runCurlRequest(init, params) {
41
59
  const requestId = params?.requestId ?? nextRequestId();
42
60
  const startedAt = performance.now();
@@ -48,7 +66,7 @@ async function runCurlRequest(init, params) {
48
66
  init.method,
49
67
  init.url,
50
68
  "--write-out",
51
- `${CURL_STATUS_MARKER}%{http_code}`
69
+ `${CURL_STATUS_MARKER}%{http_code}${CURL_HEADERS_MARKER}%{header_json}`
52
70
  ];
53
71
  for (const [key, value] of Object.entries(init.headers ?? {})) {
54
72
  args.push("--header", `${key}: ${value}`);
@@ -82,16 +100,33 @@ async function runCurlRequest(init, params) {
82
100
  throw new Error(stderr.trim() || `curl \u8BF7\u6C42\u5931\u8D25\uFF0C\u9000\u51FA\u7801 ${exitCode}`);
83
101
  }
84
102
  const parseStartedAt = performance.now();
85
- const markerIndex = stdout.lastIndexOf(CURL_STATUS_MARKER);
86
- if (markerIndex === -1) {
103
+ const statusMarkerIndex = stdout.lastIndexOf(CURL_STATUS_MARKER);
104
+ const headersMarkerIndex = stdout.lastIndexOf(CURL_HEADERS_MARKER);
105
+ if (statusMarkerIndex === -1) {
87
106
  throw new Error("curl \u54CD\u5E94\u7F3A\u5C11\u72B6\u6001\u7801\u6807\u8BB0\u3002");
88
107
  }
89
- const body = stdout.slice(0, markerIndex);
90
- const statusText = stdout.slice(markerIndex + CURL_STATUS_MARKER.length).trim();
108
+ if (headersMarkerIndex === -1 || headersMarkerIndex < statusMarkerIndex) {
109
+ throw new Error("curl \u54CD\u5E94\u7F3A\u5C11\u54CD\u5E94\u5934\u6807\u8BB0\u3002");
110
+ }
111
+ const body = stdout.slice(0, statusMarkerIndex);
112
+ const statusText = stdout.slice(statusMarkerIndex + CURL_STATUS_MARKER.length, headersMarkerIndex).trim();
91
113
  const status = Number.parseInt(statusText, 10);
92
114
  if (!Number.isFinite(status)) {
93
115
  throw new Error(`\u65E0\u6CD5\u89E3\u6790 curl \u72B6\u6001\u7801: ${statusText}`);
94
116
  }
117
+ const headersText = stdout.slice(headersMarkerIndex + CURL_HEADERS_MARKER.length).trim();
118
+ let headers = {};
119
+ if (headersText) {
120
+ try {
121
+ headers = normalizeCurlHeaders(JSON.parse(headersText));
122
+ } catch (error) {
123
+ console.warn("[http] failed to parse curl response headers", {
124
+ requestId,
125
+ url: init.url,
126
+ error: error instanceof Error ? error.message : String(error)
127
+ });
128
+ }
129
+ }
95
130
  phases.parseResponseMs = performance.now() - parseStartedAt;
96
131
  const timing = finalizeTiming(startedAt, phases);
97
132
  logHttpTiming({
@@ -110,7 +145,7 @@ async function runCurlRequest(init, params) {
110
145
  transport: "curl",
111
146
  timing,
112
147
  requestId,
113
- headers: {}
148
+ headers
114
149
  };
115
150
  }
116
151
  async function requestText(init) {
@@ -12,6 +12,7 @@ import {
12
12
  loginOpenAICodex,
13
13
  refreshOpenAICodexToken
14
14
  } from "../providers/openai-codex/oauth.js";
15
+ import { askOpenAICodex } from "../providers/openai-codex/chat.js";
15
16
  class AuthService {
16
17
  constructor(configService) {
17
18
  this.configService = configService;
@@ -107,6 +108,41 @@ class AuthService {
107
108
  async logoutAll() {
108
109
  await clearStore();
109
110
  }
111
+ async syncActiveProfileQuota(provider = "openai-codex", options) {
112
+ let profile;
113
+ try {
114
+ profile = await this.requireUsableProfile(provider);
115
+ } catch (error) {
116
+ if (options?.suppressErrors) {
117
+ return;
118
+ }
119
+ throw error;
120
+ }
121
+ const model = await this.configService.getDefaultModel(provider);
122
+ try {
123
+ const result = await askOpenAICodex({
124
+ profile,
125
+ model,
126
+ system: "Reply with OK only.",
127
+ prompt: "ping",
128
+ bodyOverride: {
129
+ text: { verbosity: "low" }
130
+ }
131
+ });
132
+ await this.updateProfileQuota(profile.profileId, result.quota, provider);
133
+ } catch (error) {
134
+ const quota = error.quota;
135
+ await this.updateProfileQuota(profile.profileId, quota, provider);
136
+ if (!options?.suppressErrors) {
137
+ throw error;
138
+ }
139
+ console.warn("[auth] sync active profile quota failed", {
140
+ provider,
141
+ profileId: profile.profileId,
142
+ error: error instanceof Error ? error.message : String(error)
143
+ });
144
+ }
145
+ }
110
146
  async updateProfileQuota(profileId, quota, provider = "openai-codex") {
111
147
  if (!quota) {
112
148
  return;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CODEX_MODEL, isSupportedCodexModel } from "../models/openai-codex-models.js";
2
+ import { getPreferredCodexModel, hasCodexModel } from "../models/openai-codex-models.js";
3
3
  import {
4
4
  createDefaultSettings,
5
5
  loadSettings,
@@ -23,14 +23,14 @@ class ConfigService {
23
23
  if (provider !== "openai-codex") {
24
24
  throw new Error(`\u6682\u4E0D\u652F\u6301 provider: ${provider}`);
25
25
  }
26
- return isSupportedCodexModel(settings.defaultModel) ? settings.defaultModel : DEFAULT_CODEX_MODEL;
26
+ return await hasCodexModel(settings.defaultModel) ? settings.defaultModel : getPreferredCodexModel();
27
27
  }
28
28
  async setDefaultModel(model, provider = "openai-codex") {
29
29
  if (provider !== "openai-codex") {
30
30
  throw new Error(`\u6682\u4E0D\u652F\u6301 provider: ${provider}`);
31
31
  }
32
- if (!isSupportedCodexModel(model)) {
33
- throw new Error(`\u5F53\u524D demo \u672A\u5185\u7F6E\u6A21\u578B: ${model}`);
32
+ if (!await hasCodexModel(model)) {
33
+ throw new Error(`\u5F53\u524D\u7F51\u5173\u672A\u627E\u5230\u53EF\u7528\u6A21\u578B: ${model}`);
34
34
  }
35
35
  const settings = await this.getSettings();
36
36
  const next = {
@@ -25,6 +25,8 @@ const SUPPORTED_IMAGE_BACKGROUNDS = /* @__PURE__ */ new Set([
25
25
  "transparent",
26
26
  "opaque"
27
27
  ]);
28
+ const IMAGE_GENERATION_MAX_ATTEMPTS = 3;
29
+ const IMAGE_GENERATION_RETRY_DELAYS_MS = [1500, 4e3];
28
30
  function truncateForLog(value, max = 160) {
29
31
  if (value.length <= max) {
30
32
  return value;
@@ -167,7 +169,24 @@ function summarizeImageDebug(raw) {
167
169
  image_events: imageEvents
168
170
  };
169
171
  }
170
- function extractImageFailureMessage(raw) {
172
+ function extractRequestIdFromMessage(message) {
173
+ const match = message.match(/request ID ([a-z0-9-]+)/i);
174
+ return match?.[1];
175
+ }
176
+ function createImageFailureDetails(code, message) {
177
+ const normalizedMessage = typeof message === "string" && message.trim() ? message.trim() : typeof code === "string" && code.trim() ? code.trim() : null;
178
+ if (!normalizedMessage) {
179
+ return null;
180
+ }
181
+ const normalizedCode = typeof code === "string" && code.trim() ? code.trim() : void 0;
182
+ return {
183
+ code: normalizedCode,
184
+ message: normalizedMessage,
185
+ requestId: extractRequestIdFromMessage(normalizedMessage),
186
+ transient: normalizedCode === "server_error" || /retry your request/i.test(normalizedMessage) || /temporar/i.test(normalizedMessage)
187
+ };
188
+ }
189
+ function extractImageFailureDetails(raw) {
171
190
  if (!isRecord(raw)) {
172
191
  return null;
173
192
  }
@@ -175,9 +194,9 @@ function extractImageFailureMessage(raw) {
175
194
  if (response) {
176
195
  const responseError = isRecord(response.error) ? response.error : null;
177
196
  const responseStatus = typeof response.status === "string" ? response.status : void 0;
178
- const responseMessage = typeof responseError?.message === "string" ? responseError.message : typeof responseError?.code === "string" ? responseError.code : null;
179
- if (responseStatus === "failed" && responseMessage) {
180
- return responseMessage;
197
+ const details = createImageFailureDetails(responseError?.code, responseError?.message);
198
+ if (responseStatus === "failed" && details) {
199
+ return details;
181
200
  }
182
201
  }
183
202
  const events = Array.isArray(raw.events) ? raw.events : [];
@@ -187,21 +206,29 @@ function extractImageFailureMessage(raw) {
187
206
  }
188
207
  if (event.type === "error") {
189
208
  const eventError = isRecord(event.error) ? event.error : event;
190
- const message = typeof eventError.message === "string" ? eventError.message : typeof eventError.code === "string" ? eventError.code : null;
191
- if (message) {
192
- return message;
209
+ const details = createImageFailureDetails(eventError.code, eventError.message);
210
+ if (details) {
211
+ return details;
193
212
  }
194
213
  }
195
214
  if (event.type === "response.failed" && isRecord(event.response)) {
196
215
  const responseError = isRecord(event.response.error) ? event.response.error : null;
197
- const message = typeof responseError?.message === "string" ? responseError.message : typeof responseError?.code === "string" ? responseError.code : null;
198
- if (message) {
199
- return message;
216
+ const details = createImageFailureDetails(responseError?.code, responseError?.message);
217
+ if (details) {
218
+ return details;
200
219
  }
201
220
  }
202
221
  }
203
222
  return null;
204
223
  }
224
+ function createError(message, statusCode) {
225
+ const error = new Error(message);
226
+ error.statusCode = statusCode;
227
+ return error;
228
+ }
229
+ function sleep(ms) {
230
+ return new Promise((resolve) => setTimeout(resolve, ms));
231
+ }
205
232
  function extractImageUsage(raw) {
206
233
  if (!isRecord(raw) || !isRecord(raw.response)) {
207
234
  return void 0;
@@ -283,76 +310,94 @@ class ImageService {
283
310
  if (request.moderation) {
284
311
  tool.moderation = request.moderation;
285
312
  }
286
- let result;
287
- try {
288
- result = await askOpenAICodex({
289
- profile,
290
- model: orchestratorModel,
291
- bodyOverride: {
313
+ for (let attempt = 1; attempt <= IMAGE_GENERATION_MAX_ATTEMPTS; attempt += 1) {
314
+ let result;
315
+ try {
316
+ result = await askOpenAICodex({
317
+ profile,
292
318
  model: orchestratorModel,
293
- input: [
294
- {
295
- role: "user",
296
- content: [
297
- {
298
- type: "input_text",
299
- text: request.prompt
300
- }
301
- ]
302
- }
303
- ],
304
- tools: [tool],
305
- tool_choice: {
306
- type: "image_generation"
307
- },
308
- include: ["reasoning.encrypted_content"]
319
+ bodyOverride: {
320
+ model: orchestratorModel,
321
+ input: [
322
+ {
323
+ role: "user",
324
+ content: [
325
+ {
326
+ type: "input_text",
327
+ text: request.prompt
328
+ }
329
+ ]
330
+ }
331
+ ],
332
+ tools: [tool],
333
+ tool_choice: {
334
+ type: "image_generation"
335
+ },
336
+ include: ["reasoning.encrypted_content"]
337
+ }
338
+ });
339
+ await this.deps.authService.updateProfileQuota(profile.profileId, result.quota, "openai-codex");
340
+ } catch (error) {
341
+ const quota = error.quota;
342
+ await this.deps.authService.updateProfileQuota(profile.profileId, quota, "openai-codex");
343
+ throw error;
344
+ }
345
+ const raw = isRecord(result.raw) ? result.raw : {};
346
+ const response = isRecord(raw.response) ? raw.response : null;
347
+ const images = collectImageGenerationOutputs(raw);
348
+ const debugSummary = summarizeImageDebug(raw);
349
+ if (images.length === 0) {
350
+ const upstreamFailure = extractImageFailureDetails(raw);
351
+ console.error("[gateway:image] parse failure", {
352
+ ...requestSummary,
353
+ attempt,
354
+ upstreamFailure,
355
+ debug: debugSummary
356
+ });
357
+ if (upstreamFailure?.transient && attempt < IMAGE_GENERATION_MAX_ATTEMPTS) {
358
+ const retryDelayMs = IMAGE_GENERATION_RETRY_DELAYS_MS[attempt - 1] ?? 4e3;
359
+ console.warn("[gateway:image] transient upstream failure, retrying", {
360
+ ...requestSummary,
361
+ attempt,
362
+ retryDelayMs,
363
+ code: upstreamFailure.code,
364
+ requestId: upstreamFailure.requestId
365
+ });
366
+ await sleep(retryDelayMs);
367
+ continue;
309
368
  }
310
- });
311
- await this.deps.authService.updateProfileQuota(profile.profileId, result.quota, "openai-codex");
312
- } catch (error) {
313
- const quota = error.quota;
314
- await this.deps.authService.updateProfileQuota(profile.profileId, quota, "openai-codex");
315
- throw error;
316
- }
317
- const raw = isRecord(result.raw) ? result.raw : {};
318
- const response = isRecord(raw.response) ? raw.response : null;
319
- const images = collectImageGenerationOutputs(raw);
320
- const debugSummary = summarizeImageDebug(raw);
321
- if (images.length === 0) {
322
- const upstreamFailure = extractImageFailureMessage(raw);
323
- console.error("[gateway:image] parse failure", {
369
+ if (upstreamFailure) {
370
+ const reason = upstreamFailure.code ? `${upstreamFailure.code}: ${upstreamFailure.message}` : upstreamFailure.message;
371
+ throw createError(`\u4E0A\u6E38\u56FE\u7247\u751F\u6210\u5931\u8D25: ${reason}`, upstreamFailure.transient ? 503 : 502);
372
+ }
373
+ throw createError("\u56FE\u7247\u751F\u6210\u8BF7\u6C42\u5DF2\u5B8C\u6210\uFF0C\u4F46\u6CA1\u6709\u89E3\u6790\u51FA image_generation_call \u7ED3\u679C\u3002", 502);
374
+ }
375
+ const first = images[0];
376
+ const imageResult = {
377
+ created: typeof response?.created_at === "number" ? response.created_at : Math.floor(Date.now() / 1e3),
378
+ data: images.map((image) => ({
379
+ b64_json: image.result ?? "",
380
+ ...image.revised_prompt ? { revised_prompt: image.revised_prompt } : {}
381
+ })),
382
+ background: normalizeReturnedBackground(first.background),
383
+ output_format: normalizeReturnedFormat(first.output_format),
384
+ quality: normalizeReturnedQuality(first.quality),
385
+ size: normalizeReturnedSize(first.size, request.size),
386
+ usage: extractImageUsage(raw)
387
+ };
388
+ console.info("[gateway:image] upstream response", {
324
389
  ...requestSummary,
325
- upstreamFailure,
390
+ attempt,
391
+ imageCount: imageResult.data.length,
392
+ firstImageBase64Length: imageResult.data[0]?.b64_json.length ?? 0,
393
+ outputFormat: imageResult.output_format ?? request.outputFormat ?? "unknown",
394
+ quality: imageResult.quality ?? request.quality ?? "unknown",
395
+ size: imageResult.size ?? request.size ?? "unknown",
326
396
  debug: debugSummary
327
397
  });
328
- if (upstreamFailure) {
329
- throw new Error(`\u4E0A\u6E38\u56FE\u7247\u751F\u6210\u5931\u8D25: ${upstreamFailure}`);
330
- }
331
- throw new Error("\u56FE\u7247\u751F\u6210\u8BF7\u6C42\u5DF2\u5B8C\u6210\uFF0C\u4F46\u6CA1\u6709\u89E3\u6790\u51FA image_generation_call \u7ED3\u679C\u3002");
398
+ return imageResult;
332
399
  }
333
- const first = images[0];
334
- const imageResult = {
335
- created: typeof response?.created_at === "number" ? response.created_at : Math.floor(Date.now() / 1e3),
336
- data: images.map((image) => ({
337
- b64_json: image.result ?? "",
338
- ...image.revised_prompt ? { revised_prompt: image.revised_prompt } : {}
339
- })),
340
- background: normalizeReturnedBackground(first.background),
341
- output_format: normalizeReturnedFormat(first.output_format),
342
- quality: normalizeReturnedQuality(first.quality),
343
- size: normalizeReturnedSize(first.size, request.size),
344
- usage: extractImageUsage(raw)
345
- };
346
- console.info("[gateway:image] upstream response", {
347
- ...requestSummary,
348
- imageCount: imageResult.data.length,
349
- firstImageBase64Length: imageResult.data[0]?.b64_json.length ?? 0,
350
- outputFormat: imageResult.output_format ?? request.outputFormat ?? "unknown",
351
- quality: imageResult.quality ?? request.quality ?? "unknown",
352
- size: imageResult.size ?? request.size ?? "unknown",
353
- debug: debugSummary
354
- });
355
- return imageResult;
400
+ throw createError("\u56FE\u7247\u751F\u6210\u5931\u8D25\uFF1A\u8D85\u8FC7\u6700\u5927\u91CD\u8BD5\u6B21\u6570\u3002", 503);
356
401
  }
357
402
  }
358
403
  export {