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 +64 -9
- services/cpa_service.py +1 -1
- services/openai_backend_api.py +169 -0
- services/protocol/conversation.py +86 -3
- services/protocol/openai_v1_models.py +12 -6
- services/sub2api_service.py +1 -1
- test/test_v1_models.py +24 -1
- utils/helper.py +5 -0
- web/src/app/accounts/components/account-import-dialog.tsx +1 -0
- web/src/app/image/components/image-composer.tsx +13 -8
- web/src/app/image/page.tsx +47 -11
- web/src/lib/api.ts +12 -8
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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([
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 26 |
dynamic_models.add("gpt-image-2")
|
| 27 |
-
if
|
| 28 |
dynamic_models.add(CODEX_IMAGE_MODEL)
|
| 29 |
-
if "Plus" in
|
| 30 |
dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
|
| 31 |
-
if "Team" in
|
| 32 |
dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
|
| 33 |
-
if "Pro" in
|
| 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
|
| 129 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|