HouYunFei commited on
Commit
cf91d2c
·
1 Parent(s): 5c2bcd4

feat(accounts): 添加Codex账户支持和图像生成功能

Browse files

- 在账户导入对话框中添加source_type字段,默认设置为codex
- 实现账户服务中的源类型匹配和计划类型匹配方法
- 重构Codex账户导入逻辑,将type转换为export_type并设置source_type
- 扩展API接口支持AccountImportPayload类型和批量账户创建
- 实现Codex图像响应流处理和事件解析功能
- 在CPA和Sub2API服务中更新Codex账户添加逻辑
- 添加Codex图像模型识别和验证工具函数
- 更新图像生成界面支持动态模型选择和Codex模型限制
- 实现Codex后端API集成和账户ID解析功能
- 调整模型列表动态生成逻辑以区分Web和Codex账户类型
- 添加Codex相关常量和请求头配置
- 实现Codex图像生成输入处理和响应失败日志记录

services/account_service.py CHANGED
@@ -136,6 +136,28 @@ class AccountService:
136
  return False
137
  return normalized_plan.lower() == normalized_account.lower()
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  @staticmethod
140
  def _normalize_account_type(value: object) -> str | None:
141
  raw = str(value or "").strip()
@@ -180,13 +202,19 @@ class AccountService:
180
  normalized = dict(item)
181
  normalized.pop("accessToken", None)
182
  normalized["access_token"] = access_token
 
 
 
183
  normalized["type"] = normalized.get("type") or "free"
184
  normalized["status"] = normalized.get("status") or "正常"
185
  normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
186
  normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
187
  normalized["email"] = normalized.get("email") or None
188
  normalized["user_id"] = normalized.get("user_id") or None
189
- normalized["source_type"] = str(normalized.get("source_type") or "web").strip() or "web"
 
 
 
190
  limits_progress = normalized.get("limits_progress")
191
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
192
  normalized["default_model_slug"] = normalized.get("default_model_slug") or None
@@ -474,6 +502,8 @@ class AccountService:
474
  self,
475
  excluded_tokens: set[str] | None = None,
476
  plan_type: str | None = None,
 
 
477
  ) -> list[str]:
478
  excluded = set(excluded_tokens or set())
479
  return [
@@ -481,6 +511,8 @@ class AccountService:
481
  for item in self._accounts.values()
482
  if self._is_image_account_available(item)
483
  and self._account_matches_plan_type(item, plan_type)
 
 
484
  and (token := item.get("access_token") or "")
485
  and token not in excluded
486
  ]
@@ -489,11 +521,13 @@ class AccountService:
489
  self,
490
  excluded_tokens: set[str] | None = None,
491
  plan_type: str | None = None,
 
 
492
  ) -> list[str]:
493
  max_concurrency = max(1, int(config.image_account_concurrency or 1))
494
  return [
495
  token
496
- for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type)
497
  if int(self._image_inflight.get(token, 0)) < max_concurrency
498
  ]
499
 
@@ -501,14 +535,17 @@ class AccountService:
501
  self,
502
  excluded_tokens: set[str] | None = None,
503
  plan_type: str | None = None,
 
 
504
  ) -> str:
505
  with self._image_slot_condition:
506
  while True:
507
- if not self._list_ready_candidate_tokens(excluded_tokens, plan_type):
508
  raise RuntimeError(
509
- f"no available {plan_type} image quota" if plan_type else "no available image quota"
 
510
  )
511
- tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type)
512
  if tokens:
513
  access_token = tokens[self._index % len(tokens)]
514
  self._index += 1
@@ -528,12 +565,19 @@ class AccountService:
528
  self._image_inflight[access_token] = current_inflight - 1
529
  self._image_slot_condition.notify_all()
530
 
531
- def get_available_access_token(self, plan_type: str | None = None) -> str:
 
 
 
 
 
532
  attempted_tokens: set[str] = set()
533
  while True:
534
  access_token = self._acquire_next_candidate_token(
535
  excluded_tokens=attempted_tokens,
536
  plan_type=plan_type,
 
 
537
  )
538
  attempted_tokens.add(access_token)
539
  try:
@@ -541,7 +585,12 @@ class AccountService:
541
  except Exception:
542
  self.release_image_slot(access_token)
543
  continue
544
- if self._is_image_account_available(account or {}):
 
 
 
 
 
545
  return str((account or {}).get("access_token") or access_token)
546
  self.release_image_slot(access_token)
547
 
@@ -627,7 +676,10 @@ class AccountService:
627
  # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
628
  if str(payload.get("type") or "").strip().lower() == "codex":
629
  payload["export_type"] = "codex"
 
630
  payload.pop("type", None)
 
 
631
  if payload.get("plan_type") and not payload.get("type"):
632
  payload["type"] = str(payload.get("plan_type") or "").strip()
633
  return payload
@@ -640,11 +692,14 @@ class AccountService:
640
  ]
641
  return self._add_account_payloads(payloads)
642
 
643
- def add_accounts(self, tokens: list[str]) -> dict:
644
  tokens = list(dict.fromkeys(token for token in tokens if token))
645
  if not tokens:
646
  return {"added": 0, "skipped": 0, "items": self.list_accounts()}
647
- return self._add_account_payloads([{"access_token": token, "source_type": "web"} for token in tokens])
 
 
 
648
 
649
  def _add_account_payloads(self, payloads: list[dict]) -> dict:
650
  deduped: dict[str, dict] = {}
 
136
  return False
137
  return normalized_plan.lower() == normalized_account.lower()
138
 
139
+ @classmethod
140
+ def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
141
+ if not source_type:
142
+ return True
143
+ return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
144
+
145
+ @classmethod
146
+ def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
147
+ if not plan_types:
148
+ return True
149
+ normalized_account = cls._normalize_account_type(account.get("type"))
150
+ normalized_plans = {
151
+ normalized
152
+ for plan_type in plan_types
153
+ if (normalized := cls._normalize_account_type(plan_type))
154
+ }
155
+ return bool(normalized_account and normalized_account in normalized_plans)
156
+
157
+ @staticmethod
158
+ def _normalize_source_type(value: object) -> str:
159
+ return str(value or "web").strip().lower() or "web"
160
+
161
  @staticmethod
162
  def _normalize_account_type(value: object) -> str | None:
163
  raw = str(value or "").strip()
 
202
  normalized = dict(item)
203
  normalized.pop("accessToken", None)
204
  normalized["access_token"] = access_token
205
+ if str(normalized.get("type") or "").strip().lower() == "codex":
206
+ normalized["export_type"] = "codex"
207
+ normalized.pop("type", None)
208
  normalized["type"] = normalized.get("type") or "free"
209
  normalized["status"] = normalized.get("status") or "正常"
210
  normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
211
  normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
212
  normalized["email"] = normalized.get("email") or None
213
  normalized["user_id"] = normalized.get("user_id") or None
214
+ source_type = normalized.get("source_type")
215
+ if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
216
+ source_type = "codex"
217
+ normalized["source_type"] = self._normalize_source_type(source_type)
218
  limits_progress = normalized.get("limits_progress")
219
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
220
  normalized["default_model_slug"] = normalized.get("default_model_slug") or None
 
502
  self,
503
  excluded_tokens: set[str] | None = None,
504
  plan_type: str | None = None,
505
+ source_type: str | None = None,
506
+ plan_types: set[str] | tuple[str, ...] | None = None,
507
  ) -> list[str]:
508
  excluded = set(excluded_tokens or set())
509
  return [
 
511
  for item in self._accounts.values()
512
  if self._is_image_account_available(item)
513
  and self._account_matches_plan_type(item, plan_type)
514
+ and self._account_matches_any_plan_type(item, plan_types)
515
+ and self._account_matches_source_type(item, source_type)
516
  and (token := item.get("access_token") or "")
517
  and token not in excluded
518
  ]
 
521
  self,
522
  excluded_tokens: set[str] | None = None,
523
  plan_type: str | None = None,
524
+ source_type: str | None = None,
525
+ plan_types: set[str] | tuple[str, ...] | None = None,
526
  ) -> list[str]:
527
  max_concurrency = max(1, int(config.image_account_concurrency or 1))
528
  return [
529
  token
530
+ for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
531
  if int(self._image_inflight.get(token, 0)) < max_concurrency
532
  ]
533
 
 
535
  self,
536
  excluded_tokens: set[str] | None = None,
537
  plan_type: str | None = None,
538
+ source_type: str | None = None,
539
+ plan_types: set[str] | tuple[str, ...] | None = None,
540
  ) -> str:
541
  with self._image_slot_condition:
542
  while True:
543
+ if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
544
  raise RuntimeError(
545
+ f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
546
+ if plan_type or source_type else "no available image quota"
547
  )
548
+ tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
549
  if tokens:
550
  access_token = tokens[self._index % len(tokens)]
551
  self._index += 1
 
565
  self._image_inflight[access_token] = current_inflight - 1
566
  self._image_slot_condition.notify_all()
567
 
568
+ def get_available_access_token(
569
+ self,
570
+ plan_type: str | None = None,
571
+ source_type: str | None = None,
572
+ plan_types: set[str] | tuple[str, ...] | None = None,
573
+ ) -> str:
574
  attempted_tokens: set[str] = set()
575
  while True:
576
  access_token = self._acquire_next_candidate_token(
577
  excluded_tokens=attempted_tokens,
578
  plan_type=plan_type,
579
+ source_type=source_type,
580
+ plan_types=plan_types,
581
  )
582
  attempted_tokens.add(access_token)
583
  try:
 
585
  except Exception:
586
  self.release_image_slot(access_token)
587
  continue
588
+ if (
589
+ self._is_image_account_available(account or {})
590
+ and self._account_matches_plan_type(account or {}, plan_type)
591
+ and self._account_matches_any_plan_type(account or {}, plan_types)
592
+ and self._account_matches_source_type(account or {}, source_type)
593
+ ):
594
  return str((account or {}).get("access_token") or access_token)
595
  self.release_image_slot(access_token)
596
 
 
676
  # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
677
  if str(payload.get("type") or "").strip().lower() == "codex":
678
  payload["export_type"] = "codex"
679
+ payload["source_type"] = "codex"
680
  payload.pop("type", None)
681
+ if str(payload.get("export_type") or "").strip().lower() == "codex":
682
+ payload["source_type"] = "codex"
683
  if payload.get("plan_type") and not payload.get("type"):
684
  payload["type"] = str(payload.get("plan_type") or "").strip()
685
  return payload
 
692
  ]
693
  return self._add_account_payloads(payloads)
694
 
695
+ def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
696
  tokens = list(dict.fromkeys(token for token in tokens if token))
697
  if not tokens:
698
  return {"added": 0, "skipped": 0, "items": self.list_accounts()}
699
+ return self._add_account_payloads([
700
+ {"access_token": token, "source_type": self._normalize_source_type(source_type)}
701
+ for token in tokens
702
+ ])
703
 
704
  def _add_account_payloads(self, payloads: list[dict]) -> dict:
705
  deduped: dict[str, dict] = {}
services/cpa_service.py CHANGED
@@ -297,7 +297,7 @@ class CPAImportService:
297
  )
298
  return
299
 
300
- add_result = account_service.add_accounts(tokens)
301
  refresh_result = account_service.refresh_accounts(tokens)
302
  current = self._config.get_import_job(pool_id) or {}
303
  self._update_job(
 
297
  )
298
  return
299
 
300
+ add_result = account_service.add_accounts(tokens, source_type="codex")
301
  refresh_result = account_service.refresh_accounts(tokens)
302
  current = self._config.get_import_job(pool_id) or {}
303
  self._update_job(
services/openai_backend_api.py CHANGED
@@ -1,4 +1,5 @@
1
  import base64
 
2
  import os
3
  import random
4
  import re
@@ -43,6 +44,16 @@ DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
43
  DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
44
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
45
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
 
 
 
 
 
 
 
 
 
 
46
 
47
 
48
  class OpenAIBackendAPI:
@@ -145,6 +156,26 @@ class OpenAIBackendAPI:
145
  headers.update(extra)
146
  return headers
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  @staticmethod
149
  def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
150
  for item in limits_progress:
@@ -424,6 +455,144 @@ class OpenAIBackendAPI:
424
  headers["X-Oai-Turn-Trace-Id"] = new_uuid()
425
  return self._headers(path, headers)
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
428
  """为图片生成准备 conduit token。"""
429
  path = "/backend-api/f/conversation/prepare"
 
1
  import base64
2
+ import json
3
  import os
4
  import random
5
  import re
 
44
  DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
45
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
46
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
47
+ CODEX_RESPONSES_MODEL = "gpt-5.5"
48
+ CODEX_RESPONSES_VERSION = "0.122.0"
49
+ CODEX_RESPONSES_USER_AGENT = (
50
+ "codex-tui/0.122.0 (Manjaro 26.1.0-pre; x86_64) "
51
+ "vscode/3.0.12 (codex-tui; 0.122.0)"
52
+ )
53
+ CODEX_RESPONSES_INSTRUCTIONS = (
54
+ "Use the image_generation tool to create exactly one image for the user's request. "
55
+ "Return the generated image result."
56
+ )
57
 
58
 
59
  class OpenAIBackendAPI:
 
156
  headers.update(extra)
157
  return headers
158
 
159
+ @staticmethod
160
+ def _decode_token_payload(token: str) -> Dict[str, Any]:
161
+ try:
162
+ payload = str(token or "").split(".")[1]
163
+ payload += "=" * ((4 - len(payload) % 4) % 4)
164
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
165
+ return data if isinstance(data, dict) else {}
166
+ except Exception:
167
+ return {}
168
+
169
+ def _chatgpt_account_id(self) -> str:
170
+ account = account_service.get_account(self.access_token) if self.access_token else {}
171
+ account = account if isinstance(account, dict) else {}
172
+ if str(account.get("account_id") or "").strip():
173
+ return str(account.get("account_id") or "").strip()
174
+ payload = self._decode_token_payload(self.access_token)
175
+ auth_claim = payload.get("https://api.openai.com/auth")
176
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
177
+ return str(auth_claim.get("chatgpt_account_id") or account.get("user_id") or "").strip()
178
+
179
  @staticmethod
180
  def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
181
  for item in limits_progress:
 
455
  headers["X-Oai-Turn-Trace-Id"] = new_uuid()
456
  return self._headers(path, headers)
457
 
458
+ def _codex_responses_headers(self) -> Dict[str, str]:
459
+ headers = {
460
+ "Authorization": f"Bearer {self.access_token}",
461
+ "User-Agent": CODEX_RESPONSES_USER_AGENT,
462
+ "version": CODEX_RESPONSES_VERSION,
463
+ "originator": "codex_cli_rs",
464
+ "session_id": self.session_id,
465
+ "Accept": "text/event-stream",
466
+ "Content-Type": "application/json",
467
+ }
468
+ account_id = self._chatgpt_account_id()
469
+ if account_id:
470
+ headers["chatgpt-account-id"] = account_id
471
+ return headers
472
+
473
+ def _ensure_codex_source_account(self) -> None:
474
+ account = account_service.get_account(self.access_token)
475
+ source_type = str((account or {}).get("source_type") or "web").strip().lower()
476
+ if source_type != "codex":
477
+ raise RuntimeError("codex responses endpoint requires a codex source account")
478
+
479
+ @staticmethod
480
+ def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]:
481
+ content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
482
+ for image in images:
483
+ payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}"
484
+ content.append({"type": "input_image", "image_url": payload})
485
+ return [{"role": "user", "content": content}]
486
+
487
+ @staticmethod
488
+ def _codex_body_preview(body: Any, limit: int = 4000) -> str:
489
+ if isinstance(body, (dict, list)):
490
+ try:
491
+ text = json.dumps(body, ensure_ascii=False)
492
+ except Exception:
493
+ text = repr(body)
494
+ else:
495
+ text = str(body or "")
496
+ return text if len(text) <= limit else text[:limit] + "...[truncated]"
497
+
498
+ def _log_codex_response_failure(
499
+ self,
500
+ path: str,
501
+ response: requests.Response,
502
+ payload: Dict[str, Any],
503
+ body: Any,
504
+ ) -> None:
505
+ request_headers = self._codex_responses_headers()
506
+ safe_request_headers = {
507
+ key: value for key, value in request_headers.items() if key.lower() != "authorization"
508
+ }
509
+ tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
510
+ logger.warning({
511
+ "event": "codex_responses_http_error",
512
+ "path": path,
513
+ "status_code": response.status_code,
514
+ "request": {
515
+ "model": payload.get("model"),
516
+ "tool_model": tool.get("model"),
517
+ "tool_action": tool.get("action"),
518
+ "size": tool.get("size"),
519
+ "quality": tool.get("quality"),
520
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
521
+ "prompt_preview": self._codex_body_preview(
522
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
523
+ 500,
524
+ ),
525
+ "headers": safe_request_headers,
526
+ },
527
+ "response": {
528
+ "headers": dict(response.headers or {}),
529
+ "body_preview": self._codex_body_preview(body),
530
+ },
531
+ })
532
+
533
+ def iter_codex_image_response_events(
534
+ self,
535
+ prompt: str,
536
+ images: list[str] | None = None,
537
+ size: str | None = None,
538
+ quality: str = "auto",
539
+ ) -> Iterator[Dict[str, Any]]:
540
+ if not self.access_token:
541
+ raise RuntimeError("access_token is required for codex image endpoints")
542
+ self._ensure_codex_source_account()
543
+ path = "/backend-api/codex/responses"
544
+ payload = {
545
+ "model": CODEX_RESPONSES_MODEL,
546
+ "instructions": CODEX_RESPONSES_INSTRUCTIONS,
547
+ "store": False,
548
+ "input": self._codex_image_input(prompt, images or []),
549
+ "tools": [{
550
+ "type": "image_generation",
551
+ "model": "gpt-image-2",
552
+ "action": "edit" if images else "generate",
553
+ "size": str(size or "1024x1024"),
554
+ "quality": str(quality or "auto"),
555
+ "output_format": "png",
556
+ }],
557
+ "tool_choice": {"type": "image_generation"},
558
+ "stream": True,
559
+ }
560
+ response = self.session.post(
561
+ self.base_url + path,
562
+ headers=self._codex_responses_headers(),
563
+ json=payload,
564
+ timeout=1200,
565
+ stream=True,
566
+ )
567
+ try:
568
+ if not 200 <= response.status_code < 300:
569
+ body: Any = response.text
570
+ try:
571
+ body = response.json()
572
+ except Exception:
573
+ pass
574
+ self._log_codex_response_failure(path, response, payload, body)
575
+ retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
576
+ retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
577
+ raise UpstreamHTTPError(path, response.status_code, body, retry_after=retry_after)
578
+ content_type = str(response.headers.get("content-type") or "").lower()
579
+ if "application/json" in content_type:
580
+ data = response.json()
581
+ if isinstance(data, dict):
582
+ yield data
583
+ return
584
+ for payload_text in iter_sse_payloads(response):
585
+ if payload_text == "[DONE]":
586
+ break
587
+ try:
588
+ data = json.loads(payload_text)
589
+ except Exception:
590
+ continue
591
+ if isinstance(data, dict):
592
+ yield data
593
+ finally:
594
+ response.close()
595
+
596
  def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
597
  """为图片生成准备 conduit token。"""
598
  path = "/backend-api/f/conversation/prepare"
services/protocol/conversation.py CHANGED
@@ -13,7 +13,13 @@ from services.account_service import account_service
13
  from services.config import config
14
  from services.image_storage_service import image_storage_service
15
  from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
16
- from utils.helper import IMAGE_MODELS, extract_image_from_message_content, is_supported_image_model, split_image_model
 
 
 
 
 
 
17
  from utils.image_tokens import count_image_content_tokens
18
  from utils.log import logger
19
 
@@ -622,6 +628,77 @@ def stream_image_outputs(
622
  yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
623
 
624
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
626
  if not is_supported_image_model(request.model):
627
  raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
@@ -632,7 +709,12 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima
632
  while True:
633
  try:
634
  plan_type, _ = split_image_model(request.model)
635
- token = account_service.get_available_access_token(plan_type=plan_type)
 
 
 
 
 
636
  except RuntimeError as exc:
637
  if emitted:
638
  return
@@ -643,7 +725,8 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima
643
  returned_result = False
644
  try:
645
  backend = OpenAIBackendAPI(access_token=token)
646
- for output in stream_image_outputs(backend, request, index, request.n):
 
647
  if output.kind == "message" and request.message_as_error:
648
  raise ImageGenerationError(
649
  output.text or "Image generation was rejected by upstream policy.",
 
13
  from services.config import config
14
  from services.image_storage_service import image_storage_service
15
  from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
16
+ from utils.helper import (
17
+ IMAGE_MODELS,
18
+ extract_image_from_message_content,
19
+ is_codex_image_model,
20
+ is_supported_image_model,
21
+ split_image_model,
22
+ )
23
  from utils.image_tokens import count_image_content_tokens
24
  from utils.log import logger
25
 
 
628
  yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
629
 
630
 
631
+ def _codex_response_images(value: Any) -> list[str]:
632
+ if isinstance(value, dict):
633
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
634
+ result = value["result"].strip()
635
+ if result:
636
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
637
+ images: list[str] = []
638
+ for item in value.values():
639
+ images.extend(_codex_response_images(item))
640
+ return images
641
+ if isinstance(value, list):
642
+ images: list[str] = []
643
+ for item in value:
644
+ images.extend(_codex_response_images(item))
645
+ return images
646
+ return []
647
+
648
+
649
+ def stream_codex_image_outputs(
650
+ backend: OpenAIBackendAPI,
651
+ request: ConversationRequest,
652
+ index: int = 1,
653
+ total: int = 1,
654
+ ) -> Iterator[ImageOutput]:
655
+ prompt = prompt_with_global_system(build_image_prompt(request.prompt, request.size, request.quality))
656
+ text_parts: list[str] = []
657
+ for event in backend.iter_codex_image_response_events(
658
+ prompt=prompt,
659
+ images=request.images or [],
660
+ size=request.size,
661
+ quality=request.quality,
662
+ ):
663
+ event_type = str(event.get("type") or "")
664
+ if event_type == "response.output_text.delta":
665
+ delta = str(event.get("delta") or "")
666
+ if delta:
667
+ text_parts.append(delta)
668
+ yield ImageOutput(
669
+ kind="progress",
670
+ model=request.model,
671
+ index=index,
672
+ total=total,
673
+ text=delta,
674
+ upstream_event_type=event_type,
675
+ )
676
+ continue
677
+ images = _codex_response_images(event)
678
+ if images:
679
+ data = format_image_result(
680
+ [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
681
+ request.prompt,
682
+ request.response_format,
683
+ request.base_url,
684
+ int(time.time()),
685
+ )["data"]
686
+ if data:
687
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
688
+ return
689
+ if event_type:
690
+ yield ImageOutput(
691
+ kind="progress",
692
+ model=request.model,
693
+ index=index,
694
+ total=total,
695
+ upstream_event_type=event_type,
696
+ )
697
+ message = "".join(text_parts).strip()
698
+ if message:
699
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
700
+
701
+
702
  def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
703
  if not is_supported_image_model(request.model):
704
  raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
 
709
  while True:
710
  try:
711
  plan_type, _ = split_image_model(request.model)
712
+ codex_model = is_codex_image_model(request.model)
713
+ token = account_service.get_available_access_token(
714
+ plan_type=plan_type,
715
+ source_type="codex" if codex_model else None,
716
+ plan_types=("plus", "team", "pro") if codex_model and not plan_type else None,
717
+ )
718
  except RuntimeError as exc:
719
  if emitted:
720
  return
 
725
  returned_result = False
726
  try:
727
  backend = OpenAIBackendAPI(access_token=token)
728
+ stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
729
+ for output in stream_fn(backend, request, index, request.n):
730
  if output.kind == "message" and request.message_as_error:
731
  raise ImageGenerationError(
732
  output.text or "Image generation was rejected by upstream policy.",
services/protocol/openai_v1_models.py CHANGED
@@ -15,22 +15,28 @@ def list_models() -> dict[str, Any]:
15
  seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
16
  dynamic_models: set[str] = set()
17
  accounts = account_service.list_accounts()
18
- normalized_types = {
 
 
 
 
 
19
  normalized
20
  for account in accounts
21
  if isinstance(account, dict)
 
22
  and (normalized := account_service._normalize_account_type(account.get("type")))
23
  }
24
 
25
- if accounts:
26
  dynamic_models.add("gpt-image-2")
27
- if normalized_types & {"Plus", "Team", "Pro"}:
28
  dynamic_models.add(CODEX_IMAGE_MODEL)
29
- if "Plus" in normalized_types:
30
  dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
31
- if "Team" in normalized_types:
32
  dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
33
- if "Pro" in normalized_types:
34
  dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}")
35
 
36
  for model in sorted(dynamic_models):
 
15
  seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
16
  dynamic_models: set[str] = set()
17
  accounts = account_service.list_accounts()
18
+ web_image_accounts = [
19
+ account
20
+ for account in accounts
21
+ if isinstance(account, dict)
22
+ ]
23
+ codex_types = {
24
  normalized
25
  for account in accounts
26
  if isinstance(account, dict)
27
+ and account_service._normalize_source_type(account.get("source_type")) == "codex"
28
  and (normalized := account_service._normalize_account_type(account.get("type")))
29
  }
30
 
31
+ if web_image_accounts:
32
  dynamic_models.add("gpt-image-2")
33
+ if codex_types & {"Plus", "Team", "Pro"}:
34
  dynamic_models.add(CODEX_IMAGE_MODEL)
35
+ if "Plus" in codex_types:
36
  dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
37
+ if "Team" in codex_types:
38
  dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
39
+ if "Pro" in codex_types:
40
  dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}")
41
 
42
  for model in sorted(dynamic_models):
services/sub2api_service.py CHANGED
@@ -505,7 +505,7 @@ class Sub2APIImportService:
505
  )
506
  return
507
 
508
- add_result = account_service.add_accounts(tokens)
509
  refresh_result = account_service.refresh_accounts(tokens)
510
  current = self._config.get_import_job(server_id) or {}
511
  self._update_job(
 
505
  )
506
  return
507
 
508
+ add_result = account_service.add_accounts(tokens, source_type="codex")
509
  refresh_result = account_service.refresh_accounts(tokens)
510
  current = self._config.get_import_job(server_id) or {}
511
  self._update_job(
test/test_v1_models.py CHANGED
@@ -26,7 +26,8 @@ class ModelListTests(unittest.TestCase):
26
  "list_accounts",
27
  return_value=[
28
  {"access_token": "token-free", "type": "free"},
29
- {"access_token": "token-team", "type": "Team"},
 
30
  ],
31
  ),
32
  ):
@@ -39,6 +40,28 @@ class ModelListTests(unittest.TestCase):
39
  self.assertNotIn("plus-codex-gpt-image-2", ids)
40
  self.assertNotIn("pro-codex-gpt-image-2", ids)
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def test_list_models_function(self):
43
  """测试直接调用服务层获取模型列表。"""
44
  result = openai_v1_models.list_models()
 
26
  "list_accounts",
27
  return_value=[
28
  {"access_token": "token-free", "type": "free"},
29
+ {"access_token": "token-web-team", "type": "Team", "source_type": "web"},
30
+ {"access_token": "token-codex-team", "type": "Team", "source_type": "codex"},
31
  ],
32
  ),
33
  ):
 
40
  self.assertNotIn("plus-codex-gpt-image-2", ids)
41
  self.assertNotIn("pro-codex-gpt-image-2", ids)
42
 
43
+ def test_list_models_does_not_return_codex_models_for_web_plus_accounts(self):
44
+ with (
45
+ mock.patch.object(
46
+ openai_v1_models.OpenAIBackendAPI,
47
+ "list_models",
48
+ return_value={"object": "list", "data": []},
49
+ ),
50
+ mock.patch.object(
51
+ openai_v1_models.account_service,
52
+ "list_accounts",
53
+ return_value=[
54
+ {"access_token": "token-web-plus", "type": "Plus", "source_type": "web"},
55
+ ],
56
+ ),
57
+ ):
58
+ result = openai_v1_models.list_models()
59
+
60
+ ids = {item["id"] for item in result["data"]}
61
+ self.assertIn("gpt-image-2", ids)
62
+ self.assertNotIn("codex-gpt-image-2", ids)
63
+ self.assertNotIn("plus-codex-gpt-image-2", ids)
64
+
65
  def test_list_models_function(self):
66
  """测试直接调用服务层获取模型列表。"""
67
  result = openai_v1_models.list_models()
utils/helper.py CHANGED
@@ -122,6 +122,11 @@ def is_supported_image_model(model: object) -> bool:
122
  return base_model is not None
123
 
124
 
 
 
 
 
 
125
  def is_image_chat_request(body: dict[str, object]) -> bool:
126
  model = str(body.get("model") or "").strip()
127
  modalities = body.get("modalities")
 
122
  return base_model is not None
123
 
124
 
125
+ def is_codex_image_model(model: object) -> bool:
126
+ _, base_model = split_image_model(model)
127
+ return base_model == CODEX_IMAGE_MODEL
128
+
129
+
130
  def is_image_chat_request(body: dict[str, object]) -> bool:
131
  model = str(body.get("model") or "").strip()
132
  modalities = body.get("modalities")
web/src/app/accounts/components/account-import-dialog.tsx CHANGED
@@ -71,6 +71,7 @@ function getCpaAccount(value: unknown): AccountImportPayload | null {
71
  const payload: AccountImportPayload = {
72
  ...raw,
73
  access_token: token,
 
74
  };
75
  delete payload.accessToken;
76
  if (payload.type === "codex") {
 
71
  const payload: AccountImportPayload = {
72
  ...raw,
73
  access_token: token,
74
+ source_type: "codex",
75
  };
76
  delete payload.accessToken;
77
  if (payload.type === "codex") {
web/src/app/image/components/image-composer.tsx CHANGED
@@ -19,6 +19,7 @@ type ImageComposerProps = {
19
  imageHeight: string;
20
  imageQuality: string;
21
  imageModel: ImageModel;
 
22
  availableQuota: string;
23
  activeTaskCount: number;
24
  referenceImages: Array<{ name: string; dataUrl: string }>;
@@ -62,14 +63,6 @@ const qualityOptions = [
62
  { value: "medium", label: "中" },
63
  { value: "high", label: "高" },
64
  ];
65
- const modelOptions: Array<{ value: ImageModel; label: string }> = [
66
- { value: "gpt-image-2", label: "gpt-image-2" },
67
- { value: "codex-gpt-image-2", label: "codex-gpt-image-2" },
68
- { value: "plus-codex-gpt-image-2", label: "plus-codex-gpt-image-2" },
69
- { value: "team-codex-gpt-image-2", label: "team-codex-gpt-image-2" },
70
- { value: "pro-codex-gpt-image-2", label: "pro-codex-gpt-image-2" },
71
- ];
72
-
73
  const aspectOptions = [
74
  { ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square },
75
  { ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical },
@@ -96,6 +89,7 @@ export function ImageComposer({
96
  imageHeight,
97
  imageQuality,
98
  imageModel,
 
99
  availableQuota,
100
  activeTaskCount,
101
  referenceImages,
@@ -125,10 +119,15 @@ export function ImageComposer({
125
  () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
126
  [referenceImages],
127
  );
 
 
 
 
128
  const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
129
  const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
130
  const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
131
  const selectedModelLabel = modelOptions.find((option) => option.value === imageModel)?.label || imageModel;
 
132
 
133
  useEffect(() => {
134
  if (!isSizeMenuOpen) {
@@ -453,15 +452,21 @@ export function ImageComposer({
453
  {aspectOptions.map((option) => {
454
  const active = option.ratio === imageRatio && option.tier === imageTier && option.width === imageWidth && option.height === imageHeight;
455
  const Icon = option.icon;
 
456
  return (
457
  <button
458
  key={`${option.ratio}-${option.tier}-${option.label}`}
459
  type="button"
 
460
  className={cn(
461
  "flex h-[64px] cursor-pointer flex-col items-center justify-center gap-1 rounded-2xl border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
462
  active && "border-stone-950",
 
463
  )}
464
  onClick={() => {
 
 
 
465
  onImageRatioChange(option.ratio);
466
  onImageTierChange(option.tier);
467
  onImageWidthChange(option.width);
 
19
  imageHeight: string;
20
  imageQuality: string;
21
  imageModel: ImageModel;
22
+ imageModels: ImageModel[];
23
  availableQuota: string;
24
  activeTaskCount: number;
25
  referenceImages: Array<{ name: string; dataUrl: string }>;
 
63
  { value: "medium", label: "中" },
64
  { value: "high", label: "高" },
65
  ];
 
 
 
 
 
 
 
 
66
  const aspectOptions = [
67
  { ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square },
68
  { ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical },
 
89
  imageHeight,
90
  imageQuality,
91
  imageModel,
92
+ imageModels,
93
  availableQuota,
94
  activeTaskCount,
95
  referenceImages,
 
119
  () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
120
  [referenceImages],
121
  );
122
+ const modelOptions = useMemo(
123
+ () => imageModels.map((model) => ({ value: model, label: model })),
124
+ [imageModels],
125
+ );
126
  const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
127
  const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
128
  const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
129
  const selectedModelLabel = modelOptions.find((option) => option.value === imageModel)?.label || imageModel;
130
+ const isCodexModel = imageModel.toLowerCase().includes("codex");
131
 
132
  useEffect(() => {
133
  if (!isSizeMenuOpen) {
 
452
  {aspectOptions.map((option) => {
453
  const active = option.ratio === imageRatio && option.tier === imageTier && option.width === imageWidth && option.height === imageHeight;
454
  const Icon = option.icon;
455
+ const disabled = !isCodexModel && (option.tier === "2k" || option.tier === "4k");
456
  return (
457
  <button
458
  key={`${option.ratio}-${option.tier}-${option.label}`}
459
  type="button"
460
+ disabled={disabled}
461
  className={cn(
462
  "flex h-[64px] cursor-pointer flex-col items-center justify-center gap-1 rounded-2xl border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
463
  active && "border-stone-950",
464
+ disabled && "cursor-not-allowed border-stone-100 bg-stone-50 text-stone-300 hover:border-stone-100 hover:bg-stone-50",
465
  )}
466
  onClick={() => {
467
+ if (disabled) {
468
+ return;
469
+ }
470
  onImageRatioChange(option.ratio);
471
  onImageTierChange(option.tier);
472
  onImageWidthChange(option.width);
web/src/app/image/page.tsx CHANGED
@@ -21,9 +21,11 @@ import {
21
  createImageEditTask,
22
  createImageGenerationTask,
23
  fetchAccounts,
 
24
  fetchImageTasks,
25
  type Account,
26
  type ImageModel,
 
27
  type ImageTask,
28
  } from "@/lib/api";
29
  import { useAuthGuard } from "@/lib/use-auth-guard";
@@ -50,13 +52,6 @@ const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
50
  const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
51
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
52
  const SCROLL_TO_LATEST_THRESHOLD = 160;
53
- const SUPPORTED_IMAGE_MODELS: ImageModel[] = [
54
- "gpt-image-2",
55
- "codex-gpt-image-2",
56
- "plus-codex-gpt-image-2",
57
- "team-codex-gpt-image-2",
58
- "pro-codex-gpt-image-2",
59
- ];
60
 
61
  function clampImageCount(value: string) {
62
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
@@ -125,8 +120,18 @@ function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
125
  return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
126
  }
127
 
128
- function normalizeStoredImageModel(value: string | null): ImageModel {
129
- return SUPPORTED_IMAGE_MODELS.includes(value as ImageModel) ? (value as ImageModel) : "gpt-image-2";
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
  function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
@@ -380,6 +385,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
380
  const [imageHeight, setImageHeight] = useState("1024");
381
  const [imageQuality, setImageQuality] = useState("auto");
382
  const [imageModel, setImageModel] = useState<ImageModel>("gpt-image-2");
 
383
  const [isHistoryOpen, setIsHistoryOpen] = useState(false);
384
  const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
385
  const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
@@ -485,14 +491,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
485
  const storedRatio = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
486
  const storedTier = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
487
  const storedQuality = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
488
- const storedModel = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_MODEL_STORAGE_KEY) : null;
489
  const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
490
  setImageRatio(storedRatio || "1:1");
491
  setImageTier(storedTier || "1k");
492
  setImageWidth("1024");
493
  setImageHeight("1024");
494
  setImageQuality(storedQuality || "auto");
495
- setImageModel(normalizeStoredImageModel(storedModel));
496
  setImageCount(storedCount ? clampImageCount(storedCount) : "1");
497
 
498
  const items = await listImageConversations();
@@ -526,6 +530,37 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
526
  };
527
  }, []);
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  const loadQuota = useCallback(async () => {
530
  if (!isAdmin) {
531
  setAvailableQuota("--");
@@ -1364,6 +1399,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1364
  imageHeight={imageHeight}
1365
  imageQuality={imageQuality}
1366
  imageModel={imageModel}
 
1367
  availableQuota={availableQuota}
1368
  activeTaskCount={activeTaskCount}
1369
  referenceImages={referenceImages}
 
21
  createImageEditTask,
22
  createImageGenerationTask,
23
  fetchAccounts,
24
+ fetchModels,
25
  fetchImageTasks,
26
  type Account,
27
  type ImageModel,
28
+ type Model,
29
  type ImageTask,
30
  } from "@/lib/api";
31
  import { useAuthGuard } from "@/lib/use-auth-guard";
 
52
  const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
53
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
54
  const SCROLL_TO_LATEST_THRESHOLD = 160;
 
 
 
 
 
 
 
55
 
56
  function clampImageCount(value: string) {
57
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
 
120
  return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
121
  }
122
 
123
+ function filterImageModels(items: Model[]): ImageModel[] {
124
+ return items
125
+ .map((item) => String(item.id || "").trim())
126
+ .filter((id, index, list) => id.toLowerCase().includes("image") && list.indexOf(id) === index);
127
+ }
128
+
129
+ function normalizeStoredImageModel(value: string | null, availableModels: ImageModel[]): ImageModel {
130
+ const normalized = String(value || "").trim();
131
+ if (normalized && availableModels.includes(normalized)) {
132
+ return normalized;
133
+ }
134
+ return availableModels[0] || "gpt-image-2";
135
  }
136
 
137
  function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
 
385
  const [imageHeight, setImageHeight] = useState("1024");
386
  const [imageQuality, setImageQuality] = useState("auto");
387
  const [imageModel, setImageModel] = useState<ImageModel>("gpt-image-2");
388
+ const [imageModels, setImageModels] = useState<ImageModel[]>(["gpt-image-2"]);
389
  const [isHistoryOpen, setIsHistoryOpen] = useState(false);
390
  const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
391
  const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
 
491
  const storedRatio = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
492
  const storedTier = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
493
  const storedQuality = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
 
494
  const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
495
  setImageRatio(storedRatio || "1:1");
496
  setImageTier(storedTier || "1k");
497
  setImageWidth("1024");
498
  setImageHeight("1024");
499
  setImageQuality(storedQuality || "auto");
 
500
  setImageCount(storedCount ? clampImageCount(storedCount) : "1");
501
 
502
  const items = await listImageConversations();
 
530
  };
531
  }, []);
532
 
533
+ useEffect(() => {
534
+ let cancelled = false;
535
+
536
+ const loadImageModels = async () => {
537
+ try {
538
+ const data = await fetchModels();
539
+ const available = filterImageModels(Array.isArray(data.data) ? data.data : []);
540
+ if (cancelled || available.length === 0) {
541
+ return;
542
+ }
543
+ setImageModels(available);
544
+ const storedModel = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_MODEL_STORAGE_KEY) : null;
545
+ setImageModel((current) => {
546
+ if (available.includes(current)) {
547
+ return current;
548
+ }
549
+ return normalizeStoredImageModel(storedModel, available);
550
+ });
551
+ } catch {
552
+ if (!cancelled) {
553
+ setImageModels(["gpt-image-2"]);
554
+ }
555
+ }
556
+ };
557
+
558
+ void loadImageModels();
559
+ return () => {
560
+ cancelled = true;
561
+ };
562
+ }, []);
563
+
564
  const loadQuota = useCallback(async () => {
565
  if (!isAdmin) {
566
  setAvailableQuota("--");
 
1399
  imageHeight={imageHeight}
1400
  imageQuality={imageQuality}
1401
  imageModel={imageModel}
1402
+ imageModels={imageModels}
1403
  availableQuota={availableQuota}
1404
  activeTaskCount={activeTaskCount}
1405
  referenceImages={referenceImages}
web/src/lib/api.ts CHANGED
@@ -2,12 +2,7 @@ import { httpRequest, request } from "@/lib/request";
2
 
3
  export type AccountType = string;
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
- export type ImageModel =
6
- | "gpt-image-2"
7
- | "codex-gpt-image-2"
8
- | "plus-codex-gpt-image-2"
9
- | "team-codex-gpt-image-2"
10
- | "pro-codex-gpt-image-2";
11
  export type AuthRole = "admin" | "user";
12
  export type ImageStorageMode = "local" | "webdav" | "both";
13
 
@@ -42,6 +37,15 @@ export type Account = {
42
  last_used_at?: string | null;
43
  };
44
 
 
 
 
 
 
 
 
 
 
45
  export type Model = {
46
  id: string;
47
  object: string;
@@ -292,10 +296,10 @@ export async function fetchModels() {
292
  return httpRequest<ModelListResponse>("/v1/models");
293
  }
294
 
295
- export async function createAccounts(tokens: string[]) {
296
  return httpRequest<AccountMutationResponse>("/api/accounts", {
297
  method: "POST",
298
- body: { tokens },
299
  });
300
  }
301
 
 
2
 
3
  export type AccountType = string;
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
+ export type ImageModel = string;
 
 
 
 
 
6
  export type AuthRole = "admin" | "user";
7
  export type ImageStorageMode = "local" | "webdav" | "both";
8
 
 
37
  last_used_at?: string | null;
38
  };
39
 
40
+ export type AccountImportPayload = {
41
+ access_token: string;
42
+ accessToken?: string;
43
+ type?: string;
44
+ export_type?: string;
45
+ source_type?: string;
46
+ [key: string]: unknown;
47
+ };
48
+
49
  export type Model = {
50
  id: string;
51
  object: string;
 
296
  return httpRequest<ModelListResponse>("/v1/models");
297
  }
298
 
299
+ export async function createAccounts(tokens: string[], accounts: AccountImportPayload[] = []) {
300
  return httpRequest<AccountMutationResponse>("/api/accounts", {
301
  method: "POST",
302
+ body: { tokens, accounts },
303
  });
304
  }
305