ai-zero-token 1.0.6 → 1.0.7

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.7 - 2026-04-29
4
+
5
+ - Added JSON `POST /v1/images/edits` support for image-to-image workflows with URL, base64 data URL, or raw base64 image references.
6
+ - Added management-page Edits test examples and documented the JSON image editing request format.
7
+ - Added `id_token` persistence for login, refresh, import, export, and account transfer JSON.
8
+ - Added "Apply to Codex" account action to back up and update local `~/.codex/auth.json` for new Codex sessions.
9
+ - Updated the local AI-Zero-Token skill documentation package with image editing and Codex account switching guidance.
10
+
3
11
  ## 1.0.6 - 2026-04-28
4
12
 
5
13
  - Added account JSON import/export support in the management page.
package/README.md CHANGED
@@ -7,6 +7,7 @@ AI Zero Token 是一个本地优先的单用户 AI CLI 和本地网关。
7
7
  它把账号授权能力整理成 OpenAI 风格接口,重点是把图片生成能力也代理出来:
8
8
 
9
9
  - `POST /v1/images/generations`
10
+ - `POST /v1/images/edits`
10
11
  - `POST /v1/responses`
11
12
  - `POST /v1/chat/completions`
12
13
  - `GET /v1/models`
@@ -15,9 +16,10 @@ AI Zero Token 是一个本地优先的单用户 AI CLI 和本地网关。
15
16
 
16
17
  ## 这次迭代亮点
17
18
 
18
- - 直接代理 `gpt-image-2`,把图片生成能力暴露成 OpenAI 风格 `images.generations` 接口
19
+ - 直接代理 `gpt-image-2`,把图片生成能力暴露成 OpenAI 风格 `images.generations` / `images.edits` 接口
19
20
  - 启动 `azt start` 后即可获得本地管理页和本地网关,适合脚本、前端和自动化流程接入
20
21
  - 支持多账号保存、切换当前账号、查看账号套餐 plan,以及当前账号是否支持生图
22
+ - 支持账号 JSON 批量导入/勾选导出,并可一键把已保存账号应用到本机 Codex
21
23
  - 模型列表会优先同步本机 `~/.codex/models_cache.json`,不需要每次为新模型重新 build
22
24
  - 管理页会每 10 分钟自动同步额度快照和版本状态,并提示当前版本是否可更新
23
25
  - `free` 账号会在管理页直接预警,并在网关层明确拦截生图请求
@@ -222,6 +224,7 @@ azt start
222
224
  - 在多个已保存账号之间切换当前使用账号
223
225
  - 在“新增账号”里选择 OAuth 登录,或粘贴外部账号 JSON 批量导入
224
226
  - 导出单个账号,或勾选多个账号后批量导出所选账号 JSON
227
+ - 将任一已保存账号“应用到 Codex”,自动备份并更新本机 `~/.codex/auth.json`
225
228
  - 删除单个本地账号,或一键清空全部本地账号
226
229
  - 切换默认模型
227
230
  - 测试 `models` / `responses` / `chat.completions`
@@ -231,6 +234,8 @@ azt start
231
234
 
232
235
  导出的账号 JSON 包含完整 `access_token` 和 `refresh_token`,等同于账号登录凭据,只适合在可信环境中传递。
233
236
 
237
+ 账号卡片里的“应用到 Codex”会把该账号的 `access_token`、`refresh_token`、`id_token` 和 `account_id` 写入本机 Codex 的 `~/.codex/auth.json`。写入前会自动备份原文件;新开的 Codex 会话会使用该账号。
238
+
234
239
  如果当前网络访问海外上游不稳定,可以在管理页的“接口测试 / 系统设置”区域启用“上游代理”,并填写你自己的代理地址。保存后,OAuth 换取 token、模型刷新和接口转发都会通过该代理访问上游;本地管理页和 `127.0.0.1` 默认保持直连。
235
240
 
236
241
  默认监听地址:
@@ -322,9 +327,28 @@ curl http://127.0.0.1:8787/v1/images/generations \
322
327
  响应会返回 OpenAI 同类型结构的 `data[].b64_json`。如果你在管理页里测试,这张图片会直接显示预览。
323
328
  如果请求里不显式传 `model`,当前默认会使用 `gpt-image-2`。
324
329
 
330
+ JSON 版 `images.edits` 支持通过 URL 或 base64 data URL 传参考图:
331
+
332
+ ```bash
333
+ curl http://127.0.0.1:8787/v1/images/edits \
334
+ -H "content-type: application/json" \
335
+ -d '{
336
+ "model": "gpt-image-2",
337
+ "prompt": "参考这张图,生成一张更适合科技产品广告的版本。",
338
+ "images": [
339
+ {
340
+ "image_url": "data:image/png;base64,替换为你的图片base64"
341
+ }
342
+ ],
343
+ "size": "1024x1024",
344
+ "quality": "low",
345
+ "response_format": "b64_json"
346
+ }'
347
+ ```
348
+
325
349
  生图能力和账号套餐有关:
326
350
 
327
- - `plus` 或更高套餐账号可正常调用 `images.generations`
351
+ - `plus` 或更高套餐账号可正常调用 `images.generations` / `images.edits`
328
352
  - `free` 账号不支持生图,网关会直接返回明确错误,而不是继续请求上游
329
353
  - 管理页会显示当前账号的 `plan` 和“生图能力”状态
330
354
  - 当当前账号是 `free` 且你选中 `Images` 测试时,“发送请求”按钮会被直接禁用
@@ -345,6 +369,7 @@ curl http://127.0.0.1:8787/v1/images/generations \
345
369
  - `POST /v1/responses`
346
370
  - `POST /v1/chat/completions`
347
371
  - `POST /v1/images/generations`
372
+ - `POST /v1/images/edits`
348
373
 
349
374
  ### 7. 当前支持的主要参数
350
375
 
@@ -394,6 +419,24 @@ curl http://127.0.0.1:8787/v1/images/generations \
394
419
  - `response_format`
395
420
  - `user`
396
421
 
422
+ `POST /v1/images/edits` 当前主要支持 JSON 请求:
423
+
424
+ - `prompt`
425
+ - `images`
426
+ - `image`
427
+ - `model`
428
+ - `n`
429
+ - `size`
430
+ - `quality`
431
+ - `background`
432
+ - `output_format`
433
+ - `output_compression`
434
+ - `moderation`
435
+ - `response_format`
436
+ - `user`
437
+
438
+ 其中 `images` / `image` 支持 `image_url`,可以是 `https://...` URL、`data:image/...;base64,...`,或裸 base64 字符串。
439
+
397
440
  ### 8. 当前限制
398
441
 
399
442
  - `stream=true` 目前只识别,不返回真实流式结果
@@ -402,7 +445,8 @@ curl http://127.0.0.1:8787/v1/images/generations \
402
445
  - `images.generations` 暂不支持 `n > 1`
403
446
  - `images.generations` 当前只返回 `b64_json`,暂不支持托管图片 `url`
404
447
  - `images.generations` 当前只透传 GPT Image 路径,不兼容 DALL·E 专有参数
405
- - `images.generations` 对账号套餐有要求;`free` 账号会被网关直接拦截并返回“不支持图片生成”
448
+ - `images.edits` 当前只支持 `application/json`,暂不支持 `multipart/form-data`、`mask` 和 `file_id`
449
+ - `images.generations` / `images.edits` 对账号套餐有要求;`free` 账号会被网关直接拦截并返回“不支持图片生成”
406
450
  - 网关当前默认面向本地单用户使用
407
451
 
408
452
  ## 兼容说明
@@ -80,7 +80,7 @@ function parseAuthorizationInput(value) {
80
80
  }
81
81
  return { code: trimmed };
82
82
  }
83
- function extractProfile(accessToken, refreshToken, expires) {
83
+ function extractProfile(accessToken, refreshToken, expires, idToken) {
84
84
  const payload = decodeJwtPayload(accessToken);
85
85
  const authClaim = payload?.[JWT_CLAIM_PATH];
86
86
  const accountId = authClaim?.chatgpt_account_id;
@@ -94,6 +94,7 @@ function extractProfile(accessToken, refreshToken, expires) {
94
94
  mode: "oauth_account",
95
95
  access: accessToken,
96
96
  refresh: refreshToken,
97
+ idToken,
97
98
  expires,
98
99
  accountId,
99
100
  email
@@ -124,6 +125,7 @@ async function exchangeAuthorizationCode(code, verifier) {
124
125
  return {
125
126
  access: json.access_token,
126
127
  refresh: json.refresh_token,
128
+ idToken: json.id_token,
127
129
  expires: Date.now() + json.expires_in * 1e3
128
130
  };
129
131
  }
@@ -150,7 +152,8 @@ async function refreshOpenAICodexToken(profile) {
150
152
  return extractProfile(
151
153
  json.access_token,
152
154
  json.refresh_token,
153
- Date.now() + json.expires_in * 1e3
155
+ Date.now() + json.expires_in * 1e3,
156
+ json.id_token ?? profile.idToken
154
157
  );
155
158
  }
156
159
  function tryOpenBrowser(url) {
@@ -284,7 +287,7 @@ async function loginOpenAICodex() {
284
287
  console.log("\u5DF2\u6536\u5230\u6388\u6743\u56DE\u8C03\uFF0C\u6B63\u5728\u4EA4\u6362 access token...");
285
288
  const token = await exchangeAuthorizationCode(code, verifier);
286
289
  console.log("token \u4EA4\u6362\u6210\u529F\uFF0C\u6B63\u5728\u89E3\u6790\u8D26\u53F7\u4FE1\u606F...");
287
- return extractProfile(token.access, token.refresh, token.expires);
290
+ return extractProfile(token.access, token.refresh, token.expires, token.idToken);
288
291
  } finally {
289
292
  callbackServer.close();
290
293
  }
@@ -13,6 +13,10 @@ import {
13
13
  refreshOpenAICodexToken
14
14
  } from "../providers/openai-codex/oauth.js";
15
15
  import { askOpenAICodex } from "../providers/openai-codex/chat.js";
16
+ import {
17
+ applyProfileToCodexAuth,
18
+ getCodexAuthStatus
19
+ } from "../store/codex-auth-store.js";
16
20
  import {
17
21
  exportProfilesToJson,
18
22
  exportProfileToJson,
@@ -97,6 +101,13 @@ class AuthService {
97
101
  getProfileImportTemplate() {
98
102
  return getProfileImportTemplate();
99
103
  }
104
+ async getCodexStatus() {
105
+ return getCodexAuthStatus();
106
+ }
107
+ async applyProfileToCodex(profileId, provider = "openai-codex") {
108
+ const profile = await this.requireFreshProfileWithIdToken(profileId, provider);
109
+ return applyProfileToCodexAuth(profile);
110
+ }
100
111
  async getActiveProfile(provider = "openai-codex") {
101
112
  const profile = await getActiveProfile();
102
113
  if (!profile || profile.provider !== provider) {
@@ -152,6 +163,22 @@ class AuthService {
152
163
  await saveProfile(refreshed);
153
164
  return this.toManagedProfile(refreshed);
154
165
  }
166
+ async requireFreshProfileWithIdToken(profileId, provider = "openai-codex") {
167
+ const profiles = await listProfiles();
168
+ const profile = profiles.find((item) => item.provider === provider && item.profileId === profileId);
169
+ if (!profile) {
170
+ throw new Error(`\u6CA1\u6709\u627E\u5230\u8D26\u53F7: ${profileId}`);
171
+ }
172
+ if (profile.idToken && Date.now() < profile.expires) {
173
+ return this.toManagedProfile(profile);
174
+ }
175
+ const refreshed = await refreshOpenAICodexToken(profile);
176
+ await saveProfile(refreshed);
177
+ if (!refreshed.idToken) {
178
+ throw new Error("\u5237\u65B0 token \u6210\u529F\uFF0C\u4F46\u4E0A\u6E38\u6CA1\u6709\u8FD4\u56DE id_token\u3002");
179
+ }
180
+ return this.toManagedProfile(refreshed);
181
+ }
155
182
  async logoutAll() {
156
183
  await clearStore();
157
184
  }
@@ -280,7 +280,8 @@ class ImageService {
280
280
  background: request.background ?? "default",
281
281
  outputFormat: request.outputFormat ?? "default",
282
282
  outputCompression: typeof request.outputCompression === "number" ? request.outputCompression : void 0,
283
- moderation: request.moderation ?? "default"
283
+ moderation: request.moderation ?? "default",
284
+ inputImageCount: request.inputImages?.length ?? 0
284
285
  };
285
286
  console.info("[gateway:image] upstream request", requestSummary);
286
287
  const tool = {
@@ -305,6 +306,9 @@ class ImageService {
305
306
  if (request.moderation) {
306
307
  tool.moderation = request.moderation;
307
308
  }
309
+ if (request.inputImages && request.inputImages.length > 0) {
310
+ tool.action = "edit";
311
+ }
308
312
  for (let attempt = 1; attempt <= IMAGE_GENERATION_MAX_ATTEMPTS; attempt += 1) {
309
313
  let result;
310
314
  try {
@@ -320,7 +324,11 @@ class ImageService {
320
324
  {
321
325
  type: "input_text",
322
326
  text: request.prompt
323
- }
327
+ },
328
+ ...(request.inputImages ?? []).map((image) => ({
329
+ type: "input_image",
330
+ image_url: image.imageUrl
331
+ }))
324
332
  ]
325
333
  }
326
334
  ],
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ function getCodexHomeDir() {
6
+ return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
7
+ }
8
+ function getCodexAuthPath() {
9
+ return path.join(getCodexHomeDir(), "auth.json");
10
+ }
11
+ function createBackupSuffix() {
12
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ async function readCodexAuth() {
18
+ try {
19
+ const raw = await fs.readFile(getCodexAuthPath(), "utf8");
20
+ const parsed = JSON.parse(raw);
21
+ return isRecord(parsed) ? parsed : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function getCodexAuthStatus() {
27
+ const authPath = getCodexAuthPath();
28
+ const auth = await readCodexAuth();
29
+ if (!auth) {
30
+ return {
31
+ path: authPath,
32
+ exists: false,
33
+ hasIdToken: false
34
+ };
35
+ }
36
+ const tokens = isRecord(auth.tokens) ? auth.tokens : {};
37
+ return {
38
+ path: authPath,
39
+ exists: true,
40
+ accountId: typeof tokens.account_id === "string" ? tokens.account_id : void 0,
41
+ hasIdToken: typeof tokens.id_token === "string" && tokens.id_token.length > 0,
42
+ lastRefresh: typeof auth.last_refresh === "string" ? auth.last_refresh : void 0
43
+ };
44
+ }
45
+ async function applyProfileToCodexAuth(profile) {
46
+ if (!profile.idToken) {
47
+ throw new Error("\u5F53\u524D\u8D26\u53F7\u7F3A\u5C11 id_token\u3002\u8BF7\u5148\u5237\u65B0\u8D26\u53F7 token \u6216\u91CD\u65B0\u5BFC\u5165\u5305\u542B id_token \u7684\u8D26\u53F7 JSON\u3002");
48
+ }
49
+ const authPath = getCodexAuthPath();
50
+ const codexHomeDir = path.dirname(authPath);
51
+ await fs.mkdir(codexHomeDir, { recursive: true });
52
+ let backupPath;
53
+ try {
54
+ await fs.access(authPath);
55
+ backupPath = `${authPath}.azt-backup-${createBackupSuffix()}`;
56
+ await fs.copyFile(authPath, backupPath);
57
+ } catch {
58
+ backupPath = void 0;
59
+ }
60
+ const authFile = {
61
+ auth_mode: "chatgpt",
62
+ OPENAI_API_KEY: null,
63
+ tokens: {
64
+ id_token: profile.idToken,
65
+ access_token: profile.access,
66
+ refresh_token: profile.refresh,
67
+ account_id: profile.accountId
68
+ },
69
+ last_refresh: (/* @__PURE__ */ new Date()).toISOString()
70
+ };
71
+ const tmpPath = `${authPath}.tmp-${process.pid}`;
72
+ await fs.writeFile(tmpPath, `${JSON.stringify(authFile, null, 2)}
73
+ `, {
74
+ encoding: "utf8",
75
+ mode: 384
76
+ });
77
+ await fs.rename(tmpPath, authPath);
78
+ await fs.chmod(authPath, 384);
79
+ return {
80
+ path: authPath,
81
+ exists: true,
82
+ accountId: profile.accountId,
83
+ hasIdToken: true,
84
+ lastRefresh: authFile.last_refresh,
85
+ backupPath,
86
+ appliedProfileId: profile.profileId,
87
+ appliedEmail: profile.email
88
+ };
89
+ }
90
+ export {
91
+ applyProfileToCodexAuth,
92
+ getCodexAuthPath,
93
+ getCodexAuthStatus
94
+ };
@@ -67,6 +67,7 @@ function importProfileFromJson(value) {
67
67
  }
68
68
  const access = getString(value.access_token) ?? getString(value.access);
69
69
  const refresh = getString(value.refresh_token) ?? getString(value.refresh);
70
+ const idToken = getString(value.id_token) ?? getString(value.idToken);
70
71
  if (!access || !refresh) {
71
72
  throw new Error("\u5BFC\u5165\u5931\u8D25: \u7F3A\u5C11 access_token/access \u6216 refresh_token/refresh\u3002");
72
73
  }
@@ -80,6 +81,7 @@ function importProfileFromJson(value) {
80
81
  mode: "oauth_account",
81
82
  access,
82
83
  refresh,
84
+ idToken,
83
85
  expires,
84
86
  accountId,
85
87
  email
@@ -101,6 +103,7 @@ function exportProfileToJson(profile) {
101
103
  type: "codex",
102
104
  access_token: profile.access,
103
105
  refresh_token: profile.refresh,
106
+ id_token: profile.idToken,
104
107
  expired: new Date(profile.expires).toISOString(),
105
108
  email: profile.email,
106
109
  account_id: profile.accountId,
@@ -124,6 +127,7 @@ function getProfileImportTemplate() {
124
127
  type: "codex",
125
128
  access_token: "eyJ...access_token",
126
129
  refresh_token: "rt_...",
130
+ id_token: "eyJ...id_token",
127
131
  expired: "2026-05-04T22:13:00.000Z",
128
132
  email: "user@example.com",
129
133
  account_id: "\u53EF\u9009\uFF0C\u901A\u5E38\u4F1A\u4ECE access_token \u81EA\u52A8\u89E3\u6790",
@@ -1498,6 +1498,7 @@ function renderAdminPage() {
1498
1498
  <div class="tester-tabs" id="testerTabs">
1499
1499
  <button class="tab-btn is-active" type="button" data-endpoint="/v1/chat/completions">Chat</button>
1500
1500
  <button class="tab-btn" type="button" data-endpoint="/v1/images/generations">Images</button>
1501
+ <button class="tab-btn" type="button" data-endpoint="/v1/images/edits">Edits</button>
1501
1502
  <button class="tab-btn" type="button" data-endpoint="/v1/responses">Responses</button>
1502
1503
  <button class="tab-btn" type="button" data-endpoint="/v1/models">Models</button>
1503
1504
  </div>
@@ -1545,6 +1546,7 @@ function renderAdminPage() {
1545
1546
  <button class="btn-secondary" type="button" data-example="/v1/responses">\u793A\u4F8B Responses</button>
1546
1547
  <button class="btn-secondary" type="button" data-example="/v1/chat/completions">\u793A\u4F8B Chat</button>
1547
1548
  <button class="btn-secondary" type="button" data-example="/v1/images/generations">\u793A\u4F8B Images</button>
1549
+ <button class="btn-secondary" type="button" data-example="/v1/images/edits">\u793A\u4F8B Edits</button>
1548
1550
  <button class="btn-primary" id="runTestBtn" type="button">\u53D1\u9001\u8BF7\u6C42</button>
1549
1551
  </div>
1550
1552
 
@@ -1720,6 +1722,11 @@ function renderAdminPage() {
1720
1722
  tab: "Images",
1721
1723
  description: "\u517C\u5BB9 OpenAI images.generations \u63A5\u53E3\u3002",
1722
1724
  },
1725
+ "/v1/images/edits": {
1726
+ method: "POST",
1727
+ tab: "Edits",
1728
+ description: "\u517C\u5BB9 OpenAI images.edits JSON \u63A5\u53E3\u3002",
1729
+ },
1723
1730
  };
1724
1731
 
1725
1732
  const endpointSelect = document.getElementById("endpointSelect");
@@ -2325,6 +2332,21 @@ function renderAdminPage() {
2325
2332
  });
2326
2333
  }
2327
2334
 
2335
+ if (endpoint === "/v1/images/edits") {
2336
+ return formatJson({
2337
+ model: "gpt-image-2",
2338
+ prompt: "\u53C2\u8003\u8FD9\u5F20\u56FE\u7247\uFF0C\u751F\u6210\u4E00\u5F20\u66F4\u9002\u5408\u79D1\u6280\u4EA7\u54C1\u5E7F\u544A\u7684\u7248\u672C\uFF0C\u4FDD\u7559\u4E3B\u4F53\u6784\u56FE\uFF0C\u589E\u5F3A\u5149\u7EBF\u548C\u8D28\u611F\u3002",
2339
+ images: [
2340
+ {
2341
+ image_url: "data:image/png;base64,\u66FF\u6362\u4E3A\u4F60\u7684\u56FE\u7247base64",
2342
+ },
2343
+ ],
2344
+ size: "1024x1024",
2345
+ quality: "low",
2346
+ response_format: "b64_json",
2347
+ });
2348
+ }
2349
+
2328
2350
  return formatJson({
2329
2351
  model: model,
2330
2352
  input: "\u8BF7\u53EA\u56DE\u590D OK",
@@ -2336,6 +2358,10 @@ function renderAdminPage() {
2336
2358
  const avg = requests.length
2337
2359
  ? requests.reduce(function (sum, item) { return sum + (item.durationMs || 0); }, 0) / requests.length
2338
2360
  : 0;
2361
+ const codexAccountId = config.codex && config.codex.accountId ? config.codex.accountId : "";
2362
+ const codexProfile = codexAccountId && Array.isArray(config.profiles)
2363
+ ? config.profiles.find(function (profile) { return profile.accountId === codexAccountId; })
2364
+ : null;
2339
2365
 
2340
2366
  return [
2341
2367
  {
@@ -2357,6 +2383,12 @@ function renderAdminPage() {
2357
2383
  detail: "\u672A\u663E\u5F0F\u6307\u5B9A model \u65F6\u751F\u6548",
2358
2384
  compact: true,
2359
2385
  },
2386
+ {
2387
+ label: "Codex \u5F53\u524D\u8D26\u53F7",
2388
+ value: codexProfile ? getProfileDisplayLabel(codexProfile) : (codexAccountId ? maskIdentifier(codexAccountId) : "\u672A\u68C0\u6D4B\u5230"),
2389
+ detail: config.codex && config.codex.exists ? "\u6765\u81EA ~/.codex/auth.json" : "\u5C1A\u672A\u5E94\u7528\u5230 Codex",
2390
+ compact: true,
2391
+ },
2360
2392
  {
2361
2393
  label: "\u5F53\u524D\u7248\u672C",
2362
2394
  value: getVersionValue(config),
@@ -2550,6 +2582,7 @@ function renderAdminPage() {
2550
2582
  + "</div>"
2551
2583
  + '<div class="account-actions">'
2552
2584
  + actionButton
2585
+ + '<button class="btn-secondary" type="button" data-profile-action="apply-codex" data-profile-id="' + escapeHtml(profile.profileId) + '">\u5E94\u7528\u5230 Codex</button>'
2553
2586
  + '<button class="btn-secondary" type="button" data-profile-action="export" data-profile-id="' + escapeHtml(profile.profileId) + '">\u5BFC\u51FA</button>'
2554
2587
  + '<button class="btn-danger" type="button" data-profile-action="remove" data-profile-id="' + escapeHtml(profile.profileId) + '">\u5220\u9664</button>'
2555
2588
  + "</div>"
@@ -2561,13 +2594,15 @@ function renderAdminPage() {
2561
2594
  const profiles = Array.isArray(config.profiles) ? config.profiles : [];
2562
2595
  const now = Date.now();
2563
2596
  const seeds = profiles.slice(0, 5).map(function (profile, index) {
2564
- const endpoint = index % 4 === 0
2597
+ const endpoint = index % 5 === 0
2565
2598
  ? "/v1/chat/completions"
2566
- : index % 4 === 1
2599
+ : index % 5 === 1
2567
2600
  ? "/v1/responses"
2568
- : index % 4 === 2
2601
+ : index % 5 === 2
2569
2602
  ? "/v1/models"
2570
- : "/v1/images/generations";
2603
+ : index % 5 === 3
2604
+ ? "/v1/images/generations"
2605
+ : "/v1/images/edits";
2571
2606
  const method = endpointMeta[endpoint].method;
2572
2607
  return {
2573
2608
  time: now - index * 15 * 60 * 1000,
@@ -2575,7 +2610,7 @@ function renderAdminPage() {
2575
2610
  endpoint: endpoint,
2576
2611
  accountEmail: profile.email || "",
2577
2612
  accountFallback: profile.accountId || profile.profileId || "\u672A\u547D\u540D\u8D26\u53F7",
2578
- model: endpoint === "/v1/images/generations" ? "gpt-image-2" : config.settings.defaultModel,
2613
+ model: endpoint.indexOf("/v1/images/") === 0 ? "gpt-image-2" : config.settings.defaultModel,
2579
2614
  statusCode: 200,
2580
2615
  durationMs: 860 + index * 230 + getPrimaryUsage(profile) * 8,
2581
2616
  source: index % 2 === 0 ? "\u7BA1\u7406\u9875" : "CLI",
@@ -2806,7 +2841,7 @@ function renderAdminPage() {
2806
2841
 
2807
2842
  function syncImageCapabilityHint(config) {
2808
2843
  const capability = getImageCapability(config ? config.profile : null);
2809
- const isImageEndpoint = endpointSelect.value === "/v1/images/generations";
2844
+ const isImageEndpoint = endpointSelect.value === "/v1/images/generations" || endpointSelect.value === "/v1/images/edits";
2810
2845
  imageCapabilityHint.textContent = capability.detail;
2811
2846
  imageCapabilityHint.className = capability.supported && !isImageEndpoint ? "hint" : "hint warn";
2812
2847
  runTestBtn.disabled = isImageEndpoint && !capability.supported;
@@ -2987,6 +3022,10 @@ function renderAdminPage() {
2987
3022
  await exportProfile(profileId, button);
2988
3023
  return;
2989
3024
  }
3025
+ if (action === "apply-codex") {
3026
+ await applyProfileToCodex(profileId, button);
3027
+ return;
3028
+ }
2990
3029
 
2991
3030
  setBusy(button, true);
2992
3031
  authStatus.textContent = action === "activate" ? "\u6B63\u5728\u5207\u6362\u5F53\u524D\u8D26\u53F7..." : "\u6B63\u5728\u5220\u9664\u8D26\u53F7...";
@@ -3020,6 +3059,31 @@ function renderAdminPage() {
3020
3059
  }
3021
3060
  }
3022
3061
 
3062
+ async function applyProfileToCodex(profileId, button) {
3063
+ setBusy(button, true);
3064
+ authStatus.textContent = "\u6B63\u5728\u5E94\u7528\u8D26\u53F7\u5230 Codex...";
3065
+ try {
3066
+ const result = await fetchJson("/_gateway/admin/codex/apply", {
3067
+ method: "POST",
3068
+ headers: {
3069
+ "Content-Type": "application/json",
3070
+ },
3071
+ body: formatJson({
3072
+ profileId: profileId,
3073
+ }),
3074
+ });
3075
+ const config = result.config || await fetchJson("/_gateway/admin/config");
3076
+ renderConfig(config);
3077
+ const codex = result.codex || config.codex || {};
3078
+ authStatus.textContent = "\u5DF2\u5E94\u7528\u5230 Codex\u3002\u65B0\u5F00\u7684 Codex \u4F1A\u8BDD\u5C06\u4F7F\u7528\u8BE5\u8D26\u53F7\u3002"
3079
+ + (codex.backupPath ? " \u5DF2\u5907\u4EFD\u539F auth.json\u3002" : "");
3080
+ } catch (error) {
3081
+ authStatus.textContent = error.message;
3082
+ } finally {
3083
+ setBusy(button, false);
3084
+ }
3085
+ }
3086
+
3023
3087
  function downloadJsonFile(fileName, value) {
3024
3088
  const blob = new Blob([formatJson(value) + "\\n"], { type: "application/json" });
3025
3089
  const url = URL.createObjectURL(blob);
@@ -85,6 +85,9 @@ const profileExportSchema = z.object({
85
85
  profileIds: z.array(z.string().min(1)).optional(),
86
86
  all: z.boolean().optional()
87
87
  });
88
+ const codexApplySchema = z.object({
89
+ profileId: z.string().min(1)
90
+ });
88
91
  const imageGenerationsBodySchema = z.object({
89
92
  prompt: z.string().min(1),
90
93
  model: z.string().optional(),
@@ -98,6 +101,29 @@ const imageGenerationsBodySchema = z.object({
98
101
  response_format: z.enum(["b64_json", "url"]).optional(),
99
102
  user: z.string().optional()
100
103
  }).passthrough();
104
+ const imageReferenceSchema = z.union([
105
+ z.string().min(1),
106
+ z.object({
107
+ image_url: z.string().min(1).optional(),
108
+ file_id: z.string().min(1).optional()
109
+ }).passthrough()
110
+ ]);
111
+ const imageEditsBodySchema = z.object({
112
+ prompt: z.string().min(1),
113
+ images: z.array(imageReferenceSchema).min(1).max(16).optional(),
114
+ image: z.union([imageReferenceSchema, z.array(imageReferenceSchema).min(1).max(16)]).optional(),
115
+ mask: imageReferenceSchema.optional(),
116
+ model: z.string().optional(),
117
+ n: z.number().int().positive().optional(),
118
+ quality: z.enum(["low", "medium", "high", "auto"]).optional(),
119
+ size: z.string().min(1).optional(),
120
+ background: z.enum(["transparent", "opaque", "auto"]).optional(),
121
+ output_format: z.enum(["png", "webp", "jpeg"]).optional(),
122
+ output_compression: z.number().int().min(0).max(100).optional(),
123
+ moderation: z.enum(["auto", "low"]).optional(),
124
+ response_format: z.enum(["b64_json", "url"]).optional(),
125
+ user: z.string().optional()
126
+ }).passthrough();
101
127
  const chatCompletionExcludedKeys = /* @__PURE__ */ new Set([
102
128
  "messages",
103
129
  "n",
@@ -205,6 +231,46 @@ function summarizeImageRequestForLog(body) {
205
231
  user: body.user ?? void 0
206
232
  };
207
233
  }
234
+ function getImageEditReferences(data) {
235
+ if (Array.isArray(data.images)) {
236
+ return data.images;
237
+ }
238
+ if (Array.isArray(data.image)) {
239
+ return data.image;
240
+ }
241
+ if (data.image) {
242
+ return [data.image];
243
+ }
244
+ return [];
245
+ }
246
+ function normalizeJsonImageReference(reference) {
247
+ if (typeof reference === "string") {
248
+ return {
249
+ imageUrl: normalizeJsonImageUrl(reference)
250
+ };
251
+ }
252
+ return {
253
+ imageUrl: reference.image_url ? normalizeJsonImageUrl(reference.image_url) : void 0,
254
+ fileId: reference.file_id
255
+ };
256
+ }
257
+ function normalizeJsonImageUrl(value) {
258
+ const trimmed = value.trim();
259
+ if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) {
260
+ return trimmed;
261
+ }
262
+ if (/^[A-Za-z0-9+/=_-]+$/.test(trimmed) && trimmed.length > 80) {
263
+ return `data:image/png;base64,${trimmed}`;
264
+ }
265
+ return trimmed;
266
+ }
267
+ function summarizeImageEditRequestForLog(body) {
268
+ return {
269
+ ...summarizeImageRequestForLog(body),
270
+ imageCount: getImageEditReferences(body).length,
271
+ hasMask: Boolean(body.mask)
272
+ };
273
+ }
208
274
  function buildResponseApiBody(result, includeRaw) {
209
275
  const responseBody = {
210
276
  object: "response",
@@ -266,6 +332,30 @@ function validateImageRequest(data) {
266
332
  }
267
333
  return null;
268
334
  }
335
+ function validateImageEditRequest(data) {
336
+ const generationValidationError = validateImageRequest(data);
337
+ if (generationValidationError) {
338
+ return generationValidationError;
339
+ }
340
+ if (data.mask) {
341
+ return "\u5F53\u524D\u7F51\u5173\u7684 JSON \u7248 images.edits \u6682\u4E0D\u652F\u6301 mask\uFF1B\u8BF7\u5148\u4F7F\u7528\u53C2\u8003\u56FE\u7F16\u8F91\u3002";
342
+ }
343
+ const references = getImageEditReferences(data);
344
+ if (references.length === 0) {
345
+ return "images.edits \u8BF7\u6C42\u7F3A\u5C11 images \u6216 image\u3002";
346
+ }
347
+ const normalized = references.map((reference) => normalizeJsonImageReference(reference));
348
+ if (normalized.some((reference) => reference.fileId)) {
349
+ return "\u5F53\u524D\u7F51\u5173\u7684 JSON \u7248 images.edits \u6682\u4E0D\u652F\u6301 file_id\uFF0C\u8BF7\u4F7F\u7528 image_url URL \u6216 base64 data URL\u3002";
350
+ }
351
+ if (normalized.some((reference) => !reference.imageUrl)) {
352
+ return "images.edits \u7684\u6BCF\u4E2A\u56FE\u7247\u5F15\u7528\u90FD\u9700\u8981\u63D0\u4F9B image_url\u3002";
353
+ }
354
+ if (normalized.some((reference) => reference.imageUrl && !/^https?:\/\//i.test(reference.imageUrl) && !/^data:image\//i.test(reference.imageUrl))) {
355
+ return "images.edits \u7684 image_url \u9700\u8981\u662F http(s) URL\u3001data:image/...;base64,...\uFF0C\u6216\u88F8 base64 \u5B57\u7B26\u4E32\u3002";
356
+ }
357
+ return null;
358
+ }
269
359
  function maskSecret(value) {
270
360
  if (value.length <= 12) {
271
361
  return value;
@@ -352,14 +442,15 @@ function createApp(params) {
352
442
  };
353
443
  });
354
444
  async function buildAdminConfig(request) {
355
- const [status, models, modelCatalog, versionStatus, settings, profile, profiles] = await Promise.all([
445
+ const [status, models, modelCatalog, versionStatus, settings, profile, profiles, codexStatus] = await Promise.all([
356
446
  ctx.authService.getStatus(),
357
447
  ctx.modelService.listModels(),
358
448
  ctx.modelService.getCatalog(),
359
449
  ctx.versionService.getVersionStatus(),
360
450
  ctx.configService.getSettings(),
361
451
  ctx.authService.getActiveProfile(),
362
- ctx.authService.listProfiles()
452
+ ctx.authService.listProfiles(),
453
+ ctx.authService.getCodexStatus()
363
454
  ]);
364
455
  const origin = resolveOrigin(request);
365
456
  return {
@@ -370,6 +461,7 @@ function createApp(params) {
370
461
  versionStatus,
371
462
  profile: serializeProfile(profile),
372
463
  profiles: profiles.map((item) => serializeManagedProfile(item)),
464
+ codex: codexStatus,
373
465
  adminUrl: `${origin}/`,
374
466
  baseUrl: `${origin}/v1`,
375
467
  supportedEndpoints: [
@@ -392,6 +484,11 @@ function createApp(params) {
392
484
  method: "POST",
393
485
  path: "/v1/images/generations",
394
486
  description: "OpenAI images.generations \u517C\u5BB9\u63A5\u53E3\u3002"
487
+ },
488
+ {
489
+ method: "POST",
490
+ path: "/v1/images/edits",
491
+ description: "OpenAI images.edits JSON \u517C\u5BB9\u63A5\u53E3\u3002"
395
492
  }
396
493
  ]
397
494
  };
@@ -518,6 +615,22 @@ function createApp(params) {
518
615
  profile: await ctx.authService.exportProfile(parsed.data.profileId)
519
616
  };
520
617
  });
618
+ app.post("/_gateway/admin/codex/apply", async (request, reply) => {
619
+ const parsed = codexApplySchema.safeParse(request.body);
620
+ if (!parsed.success) {
621
+ reply.code(400);
622
+ return {
623
+ error: {
624
+ type: "validation_error",
625
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
626
+ }
627
+ };
628
+ }
629
+ return {
630
+ codex: await ctx.authService.applyProfileToCodex(parsed.data.profileId),
631
+ config: await buildAdminConfig(request)
632
+ };
633
+ });
521
634
  app.put("/_gateway/admin/settings", async (request, reply) => {
522
635
  const parsed = settingsUpdateSchema.safeParse(request.body);
523
636
  if (!parsed.success) {
@@ -733,6 +846,96 @@ function createApp(params) {
733
846
  });
734
847
  return response;
735
848
  });
849
+ app.post("/v1/images/edits", async (request, reply) => {
850
+ const contentType = request.headers["content-type"] ?? "";
851
+ if (!String(contentType).toLowerCase().includes("application/json")) {
852
+ reply.code(415);
853
+ return {
854
+ error: {
855
+ type: "unsupported_media_type",
856
+ message: "\u5F53\u524D\u7F51\u5173\u4EC5\u652F\u6301 JSON \u7248 images.edits\uFF1B\u8BF7\u4F7F\u7528 application/json\uFF0C\u5E76\u901A\u8FC7 images[].image_url \u4F20 URL \u6216 base64 data URL\u3002"
857
+ }
858
+ };
859
+ }
860
+ const parsed = imageEditsBodySchema.safeParse(request.body);
861
+ if (!parsed.success) {
862
+ console.error("[gateway:image:edit] validation failure", {
863
+ method: request.method,
864
+ url: request.url,
865
+ issue: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
866
+ });
867
+ reply.code(400);
868
+ return {
869
+ error: {
870
+ type: "validation_error",
871
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
872
+ }
873
+ };
874
+ }
875
+ const validationError = validateImageEditRequest(parsed.data);
876
+ if (validationError) {
877
+ console.error("[gateway:image:edit] validation failure", {
878
+ method: request.method,
879
+ url: request.url,
880
+ summary: summarizeImageEditRequestForLog(parsed.data),
881
+ issue: validationError
882
+ });
883
+ reply.code(400);
884
+ return {
885
+ error: {
886
+ type: "validation_error",
887
+ message: validationError
888
+ }
889
+ };
890
+ }
891
+ if (typeof parsed.data.n === "number" && parsed.data.n > 1) {
892
+ console.error("[gateway:image:edit] not supported", {
893
+ method: request.method,
894
+ url: request.url,
895
+ summary: summarizeImageEditRequestForLog(parsed.data),
896
+ issue: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301 images.edits \u4E00\u6B21\u8FD4\u56DE\u591A\u5F20\u56FE\uFF08n > 1\uFF09"
897
+ });
898
+ reply.code(501);
899
+ return {
900
+ error: {
901
+ type: "not_supported",
902
+ message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301 images.edits \u4E00\u6B21\u8FD4\u56DE\u591A\u5F20\u56FE\uFF08n > 1\uFF09"
903
+ }
904
+ };
905
+ }
906
+ const imageReferences = getImageEditReferences(parsed.data).map((reference) => normalizeJsonImageReference(reference)).map((reference) => ({
907
+ imageUrl: reference.imageUrl ?? ""
908
+ }));
909
+ const requestSummary = summarizeImageEditRequestForLog(parsed.data);
910
+ console.info("[gateway:image:edit] request accepted", {
911
+ method: request.method,
912
+ url: request.url,
913
+ summary: requestSummary
914
+ });
915
+ const response = await ctx.imageService.generate({
916
+ prompt: parsed.data.prompt,
917
+ inputImages: imageReferences,
918
+ model: parsed.data.model,
919
+ n: parsed.data.n,
920
+ size: parsed.data.size,
921
+ quality: parsed.data.quality,
922
+ background: parsed.data.background,
923
+ outputFormat: parsed.data.output_format,
924
+ outputCompression: parsed.data.output_compression,
925
+ moderation: parsed.data.moderation
926
+ });
927
+ console.info("[gateway:image:edit] response ready", {
928
+ method: request.method,
929
+ url: request.url,
930
+ summary: requestSummary,
931
+ created: response.created,
932
+ imageCount: response.data.length,
933
+ output_format: response.output_format,
934
+ quality: response.quality,
935
+ size: response.size
936
+ });
937
+ return response;
938
+ });
736
939
  return app;
737
940
  }
738
941
  export {
package/docs/API_USAGE.md CHANGED
@@ -91,6 +91,25 @@ curl http://127.0.0.1:8787/v1/images/generations \
91
91
  }'
92
92
  ```
93
93
 
94
+ JSON image edit with a reference image URL or base64 data URL:
95
+
96
+ ```bash
97
+ curl http://127.0.0.1:8787/v1/images/edits \
98
+ -H "Content-Type: application/json" \
99
+ -d '{
100
+ "model": "gpt-image-2",
101
+ "prompt": "Use this reference image and make it look like a clean product advertisement.",
102
+ "images": [
103
+ {
104
+ "image_url": "data:image/png;base64,REPLACE_WITH_IMAGE_BASE64"
105
+ }
106
+ ],
107
+ "size": "1024x1024",
108
+ "quality": "low",
109
+ "response_format": "b64_json"
110
+ }'
111
+ ```
112
+
94
113
  ## JavaScript SDK Example
95
114
 
96
115
  ```ts
@@ -117,4 +136,3 @@ console.log(response.choices[0]?.message?.content);
117
136
  - A model appearing in `/v1/models` means the local Codex cache lists it. Final availability still depends on the active account.
118
137
  - `stream=true` is not supported yet.
119
138
  - The default listener is `0.0.0.0:8787`, so local-network clients can call the gateway by using the machine IP.
120
-
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-zero-token",
3
- "version": "1.0.6",
4
- "description": "Local-first OpenAI-compatible AI CLI and gateway with Codex OAuth, multi-account management, and gpt-image-2 image generation.",
3
+ "version": "1.0.7",
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
  "type": "module",
7
7
  "repository": {