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

feat(accounts): 支持按计划类型过滤图像模型账户

Browse files

- 添加了按计划类型筛选账户的功能,新增 _account_matches_plan_type 方法
- 扩展了图像模型类型定义,支持 plus、team、pro 计划前缀的模型
- 修改了账户服务中的令牌获取方法,支持 plan_type 参数过滤
- 在前端图像组件中添加了模型选择下拉框,支持新模型类型
- 实现了动态模型列表展示,根据账户类型显示可用的图像模型
- 添加了图像模型的计划类型解析和验证功能
- 更新了 API 接口以支持新的图像模型类型和账户筛选逻辑
- 添加了相关的单元测试验证计划类型过滤功能

services/account_service.py CHANGED
@@ -126,6 +126,16 @@ class AccountService:
126
  return True
127
  return int(account.get("quota") or 0) > 0
128
 
 
 
 
 
 
 
 
 
 
 
129
  @staticmethod
130
  def _normalize_account_type(value: object) -> str | None:
131
  raw = str(value or "").strip()
@@ -460,30 +470,45 @@ class AccountService:
460
  with self._lock:
461
  return list(self._accounts)
462
 
463
- def _list_ready_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
 
 
 
 
464
  excluded = set(excluded_tokens or set())
465
  return [
466
  token
467
  for item in self._accounts.values()
468
  if self._is_image_account_available(item)
 
469
  and (token := item.get("access_token") or "")
470
  and token not in excluded
471
  ]
472
 
473
- def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
 
 
 
 
474
  max_concurrency = max(1, int(config.image_account_concurrency or 1))
475
  return [
476
  token
477
- for token in self._list_ready_candidate_tokens(excluded_tokens)
478
  if int(self._image_inflight.get(token, 0)) < max_concurrency
479
  ]
480
 
481
- def _acquire_next_candidate_token(self, excluded_tokens: set[str] | None = None) -> str:
 
 
 
 
482
  with self._image_slot_condition:
483
  while True:
484
- if not self._list_ready_candidate_tokens(excluded_tokens):
485
- raise RuntimeError("no available image quota")
486
- tokens = self._list_available_candidate_tokens(excluded_tokens)
 
 
487
  if tokens:
488
  access_token = tokens[self._index % len(tokens)]
489
  self._index += 1
@@ -503,10 +528,13 @@ class AccountService:
503
  self._image_inflight[access_token] = current_inflight - 1
504
  self._image_slot_condition.notify_all()
505
 
506
- def get_available_access_token(self) -> str:
507
  attempted_tokens: set[str] = set()
508
  while True:
509
- access_token = self._acquire_next_candidate_token(excluded_tokens=attempted_tokens)
 
 
 
510
  attempted_tokens.add(access_token)
511
  try:
512
  account = self.fetch_remote_info(access_token, "get_available_access_token")
 
126
  return True
127
  return int(account.get("quota") or 0) > 0
128
 
129
+ @classmethod
130
+ def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
131
+ if not plan_type:
132
+ return True
133
+ normalized_plan = cls._normalize_account_type(plan_type)
134
+ normalized_account = cls._normalize_account_type(account.get("type"))
135
+ if not normalized_plan or not normalized_account:
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()
 
470
  with self._lock:
471
  return list(self._accounts)
472
 
473
+ def _list_ready_candidate_tokens(
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 [
480
  token
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
  ]
487
 
488
+ def _list_available_candidate_tokens(
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
 
500
+ def _acquire_next_candidate_token(
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
  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:
540
  account = self.fetch_remote_info(access_token, "get_available_access_token")
services/openai_backend_api.py CHANGED
@@ -15,7 +15,7 @@ from PIL import Image
15
  from services.account_service import account_service
16
  from services.config import config
17
  from services.proxy_service import proxy_settings
18
- from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid
19
  from utils.log import logger
20
  from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
21
  from utils.turnstile import solve_turnstile_token
@@ -399,13 +399,13 @@ class OpenAIBackendAPI:
399
 
400
  def _image_model_slug(self, model: str) -> str:
401
  """把标准图片模型名映射到底层 model slug。"""
402
- model = str(model or "").strip()
403
- if not model:
404
  return "auto"
405
- if model == "gpt-image-2":
406
  return "gpt-5-3"
407
- if model == CODEX_IMAGE_MODEL:
408
- return model
409
  return "auto"
410
 
411
  def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
 
15
  from services.account_service import account_service
16
  from services.config import config
17
  from services.proxy_service import proxy_settings
18
+ from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model
19
  from utils.log import logger
20
  from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
21
  from utils.turnstile import solve_turnstile_token
 
399
 
400
  def _image_model_slug(self, model: str) -> str:
401
  """把标准图片模型名映射到底层 model slug。"""
402
+ _, base_model = split_image_model(model)
403
+ if not base_model:
404
  return "auto"
405
+ if base_model == "gpt-image-2":
406
  return "gpt-5-3"
407
+ if base_model == CODEX_IMAGE_MODEL:
408
+ return base_model
409
  return "auto"
410
 
411
  def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
services/protocol/conversation.py CHANGED
@@ -13,7 +13,7 @@ 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
17
  from utils.image_tokens import count_image_content_tokens
18
  from utils.log import logger
19
 
@@ -488,7 +488,7 @@ def conversation_events(
488
  quality: str = "auto",
489
  ) -> Iterator[dict[str, Any]]:
490
  normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
491
- image_model = str(model or "").strip() in IMAGE_MODELS
492
  history_text = "" if image_model else assistant_history_text(normalized)
493
  history_messages = [] if image_model else assistant_history_messages(normalized)
494
  final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
@@ -623,15 +623,16 @@ def stream_image_outputs(
623
 
624
 
625
  def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
626
- if str(request.model or "").strip() not in IMAGE_MODELS:
627
- raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(IMAGE_MODELS))
628
 
629
  emitted = False
630
  last_error = ""
631
  for index in range(1, request.n + 1):
632
  while True:
633
  try:
634
- token = account_service.get_available_access_token()
 
635
  except RuntimeError as exc:
636
  if emitted:
637
  return
 
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
 
 
488
  quality: str = "auto",
489
  ) -> Iterator[dict[str, Any]]:
490
  normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
491
+ image_model = is_supported_image_model(model)
492
  history_text = "" if image_model else assistant_history_text(normalized)
493
  history_messages = [] if image_model else assistant_history_messages(normalized)
494
  final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
 
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)))
628
 
629
  emitted = False
630
  last_error = ""
631
  for index in range(1, request.n + 1):
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
services/protocol/openai_v1_models.py CHANGED
@@ -2,8 +2,9 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
 
5
  from services.openai_backend_api import OpenAIBackendAPI
6
- from utils.helper import IMAGE_MODELS
7
 
8
 
9
  def list_models() -> dict[str, Any]:
@@ -12,7 +13,27 @@ def list_models() -> dict[str, Any]:
12
  if not isinstance(data, list):
13
  return result
14
  seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
15
- for model in sorted(IMAGE_MODELS):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  if model not in seen:
17
  data.append({
18
  "id": model,
 
2
 
3
  from typing import Any
4
 
5
+ from services.account_service import account_service
6
  from services.openai_backend_api import OpenAIBackendAPI
7
+ from utils.helper import CODEX_IMAGE_MODEL
8
 
9
 
10
  def list_models() -> dict[str, Any]:
 
13
  if not isinstance(data, list):
14
  return result
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):
37
  if model not in seen:
38
  data.append({
39
  "id": model,
test/test_account_image_capabilities.py CHANGED
@@ -10,7 +10,7 @@ os.environ.setdefault("CHATGPT2API_AUTH_KEY", "test-auth")
10
  from services.account_service import AccountService
11
  from services.auth_service import AuthService
12
  from services.storage.json_storage import JSONStorageBackend
13
- from utils.helper import anonymize_token
14
 
15
 
16
  class AccountCapabilityTests(unittest.TestCase):
@@ -66,6 +66,34 @@ class AccountCapabilityTests(unittest.TestCase):
66
  self.assertEqual(updated["status"], "正常")
67
  self.assertTrue(updated["image_quota_unknown"])
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  class TokenLogTests(unittest.TestCase):
71
  def test_anonymize_token_hides_raw_value(self) -> None:
 
10
  from services.account_service import AccountService
11
  from services.auth_service import AuthService
12
  from services.storage.json_storage import JSONStorageBackend
13
+ from utils.helper import anonymize_token, split_image_model
14
 
15
 
16
  class AccountCapabilityTests(unittest.TestCase):
 
66
  self.assertEqual(updated["status"], "正常")
67
  self.assertTrue(updated["image_quota_unknown"])
68
 
69
+ def test_split_image_model_supports_plan_type_prefix(self) -> None:
70
+ self.assertEqual(split_image_model("gpt-image-2"), (None, "gpt-image-2"))
71
+ self.assertEqual(split_image_model("plus-codex-gpt-image-2"), ("plus", "codex-gpt-image-2"))
72
+ self.assertEqual(split_image_model("team-codex-gpt-image-2"), ("team", "codex-gpt-image-2"))
73
+ self.assertEqual(split_image_model("pro-codex-gpt-image-2"), ("pro", "codex-gpt-image-2"))
74
+ self.assertEqual(split_image_model("plus-gpt-image-2"), (None, None))
75
+ self.assertEqual(split_image_model("unknown-image-model"), (None, None))
76
+
77
+ def test_get_available_access_token_filters_by_plan_type(self) -> None:
78
+ with tempfile.TemporaryDirectory() as tmp_dir:
79
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
80
+ service.add_account_items(
81
+ [
82
+ {"access_token": "token-plus", "type": "Plus", "status": "正常", "quota": 3},
83
+ {"access_token": "token-pro", "type": "Pro", "status": "正常", "quota": 3},
84
+ ]
85
+ )
86
+
87
+ service.fetch_remote_info = lambda access_token, event="fetch_remote_info": service.get_account(access_token)
88
+
89
+ plus_token = service.get_available_access_token(plan_type="plus")
90
+ pro_token = service.get_available_access_token(plan_type="pro")
91
+ service.release_image_slot(plus_token)
92
+ service.release_image_slot(pro_token)
93
+
94
+ self.assertEqual(plus_token, "token-plus")
95
+ self.assertEqual(pro_token, "token-pro")
96
+
97
 
98
  class TokenLogTests(unittest.TestCase):
99
  def test_anonymize_token_hides_raw_value(self) -> None:
test/test_v1_models.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import json
4
  import unittest
 
5
 
6
  import requests
7
 
@@ -13,6 +14,31 @@ BASE_URL = "http://localhost:8000"
13
 
14
 
15
  class ModelListTests(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def test_list_models_function(self):
17
  """测试直接调用服务层获取模型列表。"""
18
  result = openai_v1_models.list_models()
 
2
 
3
  import json
4
  import unittest
5
+ from unittest import mock
6
 
7
  import requests
8
 
 
14
 
15
 
16
  class ModelListTests(unittest.TestCase):
17
+ def test_list_models_only_returns_image_models_backed_by_account_types(self):
18
+ with (
19
+ mock.patch.object(
20
+ openai_v1_models.OpenAIBackendAPI,
21
+ "list_models",
22
+ return_value={"object": "list", "data": []},
23
+ ),
24
+ mock.patch.object(
25
+ openai_v1_models.account_service,
26
+ "list_accounts",
27
+ return_value=[
28
+ {"access_token": "token-free", "type": "free"},
29
+ {"access_token": "token-team", "type": "Team"},
30
+ ],
31
+ ),
32
+ ):
33
+ result = openai_v1_models.list_models()
34
+
35
+ ids = {item["id"] for item in result["data"]}
36
+ self.assertIn("gpt-image-2", ids)
37
+ self.assertIn("codex-gpt-image-2", ids)
38
+ self.assertIn("team-codex-gpt-image-2", ids)
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()
utils/helper.py CHANGED
@@ -11,7 +11,15 @@ from curl_cffi import requests
11
  from fastapi import HTTPException
12
  from utils.log import logger
13
 
14
- IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"}
 
 
 
 
 
 
 
 
15
  OUTPUT_DIR = Path(__file__).resolve().parent / "output"
16
 
17
  SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
@@ -94,10 +102,30 @@ def new_uuid() -> str:
94
  return str(uuid.uuid4())
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def is_image_chat_request(body: dict[str, object]) -> bool:
98
  model = str(body.get("model") or "").strip()
99
  modalities = body.get("modalities")
100
- if model in IMAGE_MODELS:
101
  return True
102
  return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
103
 
 
11
  from fastapi import HTTPException
12
  from utils.log import logger
13
 
14
+ BASE_IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"}
15
+ IMAGE_MODEL_PLAN_TYPES = ("plus", "team", "pro")
16
+ CODEX_IMAGE_MODEL = "codex-gpt-image-2"
17
+ PREFIXED_CODEX_IMAGE_MODELS = {
18
+ f"{plan_type}-{CODEX_IMAGE_MODEL}"
19
+ for plan_type in IMAGE_MODEL_PLAN_TYPES
20
+ }
21
+ IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
22
+ PUBLIC_IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
23
  OUTPUT_DIR = Path(__file__).resolve().parent / "output"
24
 
25
  SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
 
102
  return str(uuid.uuid4())
103
 
104
 
105
+ def split_image_model(model: object) -> tuple[str | None, str | None]:
106
+ normalized = str(model or "").strip().lower()
107
+ if not normalized:
108
+ return None, None
109
+ if normalized in BASE_IMAGE_MODELS:
110
+ return None, normalized
111
+ for plan_type in IMAGE_MODEL_PLAN_TYPES:
112
+ prefix = f"{plan_type}-"
113
+ if normalized.startswith(prefix):
114
+ base_model = normalized[len(prefix):]
115
+ if base_model == CODEX_IMAGE_MODEL:
116
+ return plan_type, base_model
117
+ return None, None
118
+
119
+
120
+ def is_supported_image_model(model: object) -> bool:
121
+ _, base_model = split_image_model(model)
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")
128
+ if is_supported_image_model(model):
129
  return True
130
  return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
131
 
web/public/openai.svg ADDED
web/src/app/accounts/page.tsx CHANGED
@@ -43,10 +43,12 @@ import {
43
  import {
44
  deleteAccounts,
45
  fetchAccounts,
 
46
  refreshAccounts,
47
  updateAccount,
48
  type Account,
49
  type AccountStatus,
 
50
  } from "@/lib/api";
51
  import { useAuthGuard } from "@/lib/use-auth-guard";
52
  import { cn } from "@/lib/utils";
@@ -164,9 +166,21 @@ function displayAccountType(account: Account) {
164
  return account.type || "Free";
165
  }
166
 
 
 
 
 
 
 
 
 
 
 
 
167
  function AccountsPageContent() {
168
  const didLoadRef = useRef(false);
169
  const [accounts, setAccounts] = useState<Account[]>([]);
 
170
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
171
  const [query, setQuery] = useState("");
172
  const [typeFilter, setTypeFilter] = useState("all");
@@ -176,6 +190,7 @@ function AccountsPageContent() {
176
  const [editingAccount, setEditingAccount] = useState<Account | null>(null);
177
  const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
178
  const [isLoading, setIsLoading] = useState(true);
 
179
  const [isRefreshing, setIsRefreshing] = useState(false);
180
  const [isDeleting, setIsDeleting] = useState(false);
181
  const [isUpdating, setIsUpdating] = useState(false);
@@ -198,12 +213,26 @@ function AccountsPageContent() {
198
  }
199
  };
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  useEffect(() => {
202
  if (didLoadRef.current) {
203
  return;
204
  }
205
  didLoadRef.current = true;
206
  void loadAccounts();
 
207
  }, []);
208
 
209
  const filteredAccounts = useMemo(() => {
@@ -467,6 +496,42 @@ function AccountsPageContent() {
467
  );
468
  })}
469
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  </section>
471
 
472
  <section className="space-y-4">
@@ -589,7 +654,7 @@ function AccountsPageContent() {
589
  </div>
590
 
591
  <div className="overflow-x-auto">
592
- <table className="w-full min-w-[920px] text-left">
593
  <thead className="border-b border-stone-100 text-[11px] text-stone-400 uppercase tracking-[0.18em]">
594
  <tr>
595
  <th className="w-12 px-4 py-3">
@@ -600,6 +665,7 @@ function AccountsPageContent() {
600
  </th>
601
  <th className="w-56 px-4 py-3">token</th>
602
  <th className="w-28 px-4 py-3">类型</th>
 
603
  <th className="w-24 px-4 py-3">状态</th>
604
  <th className="w-56 px-4 py-3">账号信息</th>
605
  <th className="w-32 px-4 py-3">创建时间</th>
@@ -654,6 +720,11 @@ function AccountsPageContent() {
654
  {displayAccountType(account)}
655
  </Badge>
656
  </td>
 
 
 
 
 
657
  <td className="px-4 py-3">
658
  <Badge
659
  variant={status.badge}
 
43
  import {
44
  deleteAccounts,
45
  fetchAccounts,
46
+ fetchModels,
47
  refreshAccounts,
48
  updateAccount,
49
  type Account,
50
  type AccountStatus,
51
+ type Model,
52
  } from "@/lib/api";
53
  import { useAuthGuard } from "@/lib/use-auth-guard";
54
  import { cn } from "@/lib/utils";
 
166
  return account.type || "Free";
167
  }
168
 
169
+ function displayAccountSource(account: Account) {
170
+ const source = String(account.source_type || "").trim().toLowerCase();
171
+ if (!source) {
172
+ return "web";
173
+ }
174
+ if (source === "web") {
175
+ return "web";
176
+ }
177
+ return source;
178
+ }
179
+
180
  function AccountsPageContent() {
181
  const didLoadRef = useRef(false);
182
  const [accounts, setAccounts] = useState<Account[]>([]);
183
+ const [availableModels, setAvailableModels] = useState<Model[]>([]);
184
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
185
  const [query, setQuery] = useState("");
186
  const [typeFilter, setTypeFilter] = useState("all");
 
190
  const [editingAccount, setEditingAccount] = useState<Account | null>(null);
191
  const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
192
  const [isLoading, setIsLoading] = useState(true);
193
+ const [isLoadingModels, setIsLoadingModels] = useState(true);
194
  const [isRefreshing, setIsRefreshing] = useState(false);
195
  const [isDeleting, setIsDeleting] = useState(false);
196
  const [isUpdating, setIsUpdating] = useState(false);
 
213
  }
214
  };
215
 
216
+ const loadModels = async () => {
217
+ setIsLoadingModels(true);
218
+ try {
219
+ const data = await fetchModels();
220
+ setAvailableModels(Array.isArray(data.data) ? data.data : []);
221
+ } catch (error) {
222
+ const message = error instanceof Error ? error.message : "加载模型列表失败";
223
+ toast.error(message);
224
+ } finally {
225
+ setIsLoadingModels(false);
226
+ }
227
+ };
228
+
229
  useEffect(() => {
230
  if (didLoadRef.current) {
231
  return;
232
  }
233
  didLoadRef.current = true;
234
  void loadAccounts();
235
+ void loadModels();
236
  }, []);
237
 
238
  const filteredAccounts = useMemo(() => {
 
496
  );
497
  })}
498
  </div>
499
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
500
+ <CardContent className="p-4">
501
+ <div className="mb-3 text-sm font-medium text-stone-700">
502
+ 系统可用模型
503
+ <span className="ml-1 text-stone-400">({availableModels.length})</span>
504
+ </div>
505
+ <div className="flex flex-wrap gap-2">
506
+ {availableModels.length > 0 ? (
507
+ availableModels.map((model) => (
508
+ <button
509
+ key={model.id}
510
+ type="button"
511
+ className="inline-flex cursor-pointer items-center rounded-full border border-stone-200 bg-white px-2.5 py-1 text-xs font-medium text-stone-700 transition hover:border-stone-300 hover:bg-stone-50"
512
+ onClick={() => {
513
+ void navigator.clipboard.writeText(model.id);
514
+ toast.success("模型名已复制");
515
+ }}
516
+ title={`点击复制 ${model.id}`}
517
+ >
518
+ <img
519
+ src="/openai.svg"
520
+ alt=""
521
+ aria-hidden="true"
522
+ className="mr-1.5 size-3.5 shrink-0"
523
+ />
524
+ {model.id}
525
+ </button>
526
+ ))
527
+ ) : isLoadingModels ? (
528
+ <span className="text-sm text-stone-400">正在加载模型列表...</span>
529
+ ) : (
530
+ <span className="text-sm text-stone-400">当前暂无可用模型</span>
531
+ )}
532
+ </div>
533
+ </CardContent>
534
+ </Card>
535
  </section>
536
 
537
  <section className="space-y-4">
 
654
  </div>
655
 
656
  <div className="overflow-x-auto">
657
+ <table className="w-full min-w-[1000px] text-left">
658
  <thead className="border-b border-stone-100 text-[11px] text-stone-400 uppercase tracking-[0.18em]">
659
  <tr>
660
  <th className="w-12 px-4 py-3">
 
665
  </th>
666
  <th className="w-56 px-4 py-3">token</th>
667
  <th className="w-28 px-4 py-3">类型</th>
668
+ <th className="w-24 px-4 py-3">来源</th>
669
  <th className="w-24 px-4 py-3">状态</th>
670
  <th className="w-56 px-4 py-3">账号信息</th>
671
  <th className="w-32 px-4 py-3">创建时间</th>
 
720
  {displayAccountType(account)}
721
  </Badge>
722
  </td>
723
+ <td className="px-4 py-3">
724
+ <Badge variant="outline" className="rounded-md border-stone-200 text-stone-600">
725
+ {displayAccountSource(account)}
726
+ </Badge>
727
+ </td>
728
  <td className="px-4 py-3">
729
  <Badge
730
  variant={status.badge}
web/src/app/image/components/image-composer.tsx CHANGED
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEve
5
  import { ImageLightbox } from "@/components/image-lightbox";
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
 
8
  import { Textarea } from "@/components/ui/textarea";
9
  import type { ImageModel } from "@/lib/api";
10
  import { cn } from "@/lib/utils";
@@ -64,6 +65,9 @@ const qualityOptions = [
64
  const modelOptions: Array<{ value: ImageModel; label: string }> = [
65
  { value: "gpt-image-2", label: "gpt-image-2" },
66
  { value: "codex-gpt-image-2", label: "codex-gpt-image-2" },
 
 
 
67
  ];
68
 
69
  const aspectOptions = [
@@ -124,13 +128,21 @@ export function ImageComposer({
124
  const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
125
  const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
126
  const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
 
127
 
128
  useEffect(() => {
129
  if (!isSizeMenuOpen) {
130
  return;
131
  }
132
  const handlePointerDown = (event: MouseEvent) => {
133
- if (!sizeMenuRef.current?.contains(event.target as Node)) {
 
 
 
 
 
 
 
134
  setIsSizeMenuOpen(false);
135
  }
136
  };
@@ -346,24 +358,41 @@ export function ImageComposer({
346
  <h3 className="mb-3 text-base font-semibold text-stone-950">图像设置</h3>
347
  <div className="mb-3">
348
  <div className="mb-2 text-sm font-medium text-stone-900">模型</div>
349
- <div className="grid grid-cols-2 gap-2">
350
- {modelOptions.map((option) => {
351
- const active = option.value === imageModel;
352
- return (
353
- <button
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  key={option.value}
355
- type="button"
356
- className={cn(
357
- "h-9 cursor-pointer rounded-full border border-stone-200 bg-white px-3 text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
358
- active && "border-stone-950 bg-white font-medium text-stone-950",
359
- )}
360
- onClick={() => onImageModelChange(option.value)}
 
 
361
  >
362
  {option.label}
363
- </button>
364
- );
365
- })}
366
- </div>
367
  </div>
368
  <div className="mb-3">
369
  <div className="mb-2 text-sm font-medium text-stone-900">质量</div>
 
5
  import { ImageLightbox } from "@/components/image-lightbox";
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
9
  import { Textarea } from "@/components/ui/textarea";
10
  import type { ImageModel } from "@/lib/api";
11
  import { cn } from "@/lib/utils";
 
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 = [
 
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) {
135
  return;
136
  }
137
  const handlePointerDown = (event: MouseEvent) => {
138
+ const target = event.target;
139
+ if (
140
+ target instanceof Element &&
141
+ target.closest('[data-slot="select-content"], [data-slot="select-trigger"]')
142
+ ) {
143
+ return;
144
+ }
145
+ if (!sizeMenuRef.current?.contains(target as Node)) {
146
  setIsSizeMenuOpen(false);
147
  }
148
  };
 
358
  <h3 className="mb-3 text-base font-semibold text-stone-950">图像设置</h3>
359
  <div className="mb-3">
360
  <div className="mb-2 text-sm font-medium text-stone-900">模型</div>
361
+ <Select
362
+ value={imageModel}
363
+ onValueChange={(value) => {
364
+ onImageModelChange(value as ImageModel);
365
+ }}
366
+ >
367
+ <SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white text-sm shadow-none">
368
+ <div className="flex min-w-0 items-center gap-2">
369
+ <img
370
+ src="/openai.svg"
371
+ alt=""
372
+ aria-hidden="true"
373
+ className="size-4 shrink-0 text-stone-700"
374
+ />
375
+ <span className="truncate">{selectedModelLabel}</span>
376
+ </div>
377
+ </SelectTrigger>
378
+ <SelectContent className="z-[120]">
379
+ {modelOptions.map((option) => (
380
+ <SelectItem
381
  key={option.value}
382
+ value={option.value}
383
+ className="pl-10"
384
+ style={{
385
+ backgroundImage: "url('/openai.svg')",
386
+ backgroundRepeat: "no-repeat",
387
+ backgroundPosition: "12px center",
388
+ backgroundSize: "16px 16px",
389
+ }}
390
  >
391
  {option.label}
392
+ </SelectItem>
393
+ ))}
394
+ </SelectContent>
395
+ </Select>
396
  </div>
397
  <div className="mb-3">
398
  <div className="mb-2 text-sm font-medium text-stone-900">质量</div>
web/src/app/image/page.tsx CHANGED
@@ -50,6 +50,13 @@ 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
 
54
  function clampImageCount(value: string) {
55
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
@@ -118,6 +125,10 @@ function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
118
  return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
119
  }
120
 
 
 
 
 
121
  function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
122
  if (!image.b64_json) {
123
  return null;
@@ -481,7 +492,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
481
  setImageWidth("1024");
482
  setImageHeight("1024");
483
  setImageQuality(storedQuality || "auto");
484
- setImageModel(storedModel === "codex-gpt-image-2" ? "codex-gpt-image-2" : "gpt-image-2");
485
  setImageCount(storedCount ? clampImageCount(storedCount) : "1");
486
 
487
  const items = await listImageConversations();
 
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
  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 {
133
  if (!image.b64_json) {
134
  return null;
 
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();
web/src/lib/api.ts CHANGED
@@ -2,7 +2,12 @@ import { httpRequest, request } from "@/lib/request";
2
 
3
  export type AccountType = string;
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
- export type ImageModel = "gpt-image-2" | "codex-gpt-image-2";
 
 
 
 
 
6
  export type AuthRole = "admin" | "user";
7
  export type ImageStorageMode = "local" | "webdav" | "both";
8
 
@@ -19,6 +24,7 @@ export type ImageStorageSettings = {
19
  export type Account = {
20
  access_token: string;
21
  type: AccountType;
 
22
  status: AccountStatus;
23
  quota: number;
24
  image_quota_unknown?: boolean;
@@ -36,10 +42,25 @@ export type Account = {
36
  last_used_at?: string | null;
37
  };
38
 
 
 
 
 
 
 
 
 
 
 
39
  type AccountListResponse = {
40
  items: Account[];
41
  };
42
 
 
 
 
 
 
43
  type AccountMutationResponse = {
44
  items: Account[];
45
  added?: number;
@@ -267,6 +288,10 @@ export async function fetchAccounts() {
267
  return httpRequest<AccountListResponse>("/api/accounts");
268
  }
269
 
 
 
 
 
270
  export async function createAccounts(tokens: string[]) {
271
  return httpRequest<AccountMutationResponse>("/api/accounts", {
272
  method: "POST",
 
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
 
 
24
  export type Account = {
25
  access_token: string;
26
  type: AccountType;
27
+ source_type?: string | null;
28
  status: AccountStatus;
29
  quota: number;
30
  image_quota_unknown?: boolean;
 
42
  last_used_at?: string | null;
43
  };
44
 
45
+ export type Model = {
46
+ id: string;
47
+ object: string;
48
+ created: number;
49
+ owned_by: string;
50
+ permission: unknown[];
51
+ root: string;
52
+ parent: string | null;
53
+ };
54
+
55
  type AccountListResponse = {
56
  items: Account[];
57
  };
58
 
59
+ type ModelListResponse = {
60
+ object: string;
61
+ data: Model[];
62
+ };
63
+
64
  type AccountMutationResponse = {
65
  items: Account[];
66
  added?: number;
 
288
  return httpRequest<AccountListResponse>("/api/accounts");
289
  }
290
 
291
+ 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",