feat: show chatgpt web image quota

This commit is contained in:
Codex
2026-05-06 22:58:47 +08:00
parent 7eaf3e7d03
commit 41f75a3f19
21 changed files with 1801 additions and 16 deletions

View File

@@ -19,6 +19,7 @@ class ProviderType(str, Enum):
CLAUDE_CODE = "claude_code" CLAUDE_CODE = "claude_code"
KIRO = "kiro" KIRO = "kiro"
CODEX = "codex" CODEX = "codex"
CHATGPT_WEB = "chatgpt_web"
GEMINI_CLI = "gemini_cli" GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity" ANTIGRAVITY = "antigravity"
VERTEX_AI = "vertex_ai" VERTEX_AI = "vertex_ai"

View File

@@ -23,6 +23,7 @@ from src.services.provider.pool.config import parse_pool_config
from src.services.provider_keys.key_side_effects import run_delete_key_side_effects from src.services.provider_keys.key_side_effects import run_delete_key_side_effects
from src.services.provider_keys.quota_refresh import ( from src.services.provider_keys.quota_refresh import (
refresh_antigravity_key_quota, refresh_antigravity_key_quota,
refresh_chatgpt_web_key_quota,
refresh_codex_key_quota, refresh_codex_key_quota,
refresh_kiro_key_quota, refresh_kiro_key_quota,
) )
@@ -34,6 +35,7 @@ CODEX_WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
_QUOTA_REFRESH_HANDLERS: dict[str, QuotaRefreshHandler] = { _QUOTA_REFRESH_HANDLERS: dict[str, QuotaRefreshHandler] = {
ProviderType.CODEX: refresh_codex_key_quota, ProviderType.CODEX: refresh_codex_key_quota,
ProviderType.CHATGPT_WEB: refresh_chatgpt_web_key_quota,
ProviderType.ANTIGRAVITY: refresh_antigravity_key_quota, ProviderType.ANTIGRAVITY: refresh_antigravity_key_quota,
ProviderType.KIRO: refresh_kiro_key_quota, ProviderType.KIRO: refresh_kiro_key_quota,
} }
@@ -64,6 +66,12 @@ def _select_refresh_endpoint(provider: Provider, provider_type: str) -> Provider
return ep return ep
raise InvalidRequestException("找不到有效的 gemini:chat/gemini:cli 端点") raise InvalidRequestException("找不到有效的 gemini:chat/gemini:cli 端点")
if provider_type == ProviderType.CHATGPT_WEB:
for ep in provider.endpoints:
if _normalize_api_format(ep.api_format) == "openai:image" and ep.is_active:
return ep
raise InvalidRequestException("找不到有效的 openai:image 端点")
# Kiro 不需要端点检查,直接使用 auth_config # Kiro 不需要端点检查,直接使用 auth_config
return None return None
@@ -73,7 +81,9 @@ def _resolve_quota_refresh_handler(provider_type: str) -> QuotaRefreshHandler:
handler = _QUOTA_REFRESH_HANDLERS.get(provider_type) handler = _QUOTA_REFRESH_HANDLERS.get(provider_type)
if handler is not None: if handler is not None:
return handler return handler
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额") raise InvalidRequestException(
"仅支持 Codex / ChatGPT Web / Antigravity / Kiro 类型的 Provider 刷新限额"
)
async def refresh_provider_quota_for_provider( async def refresh_provider_quota_for_provider(
@@ -89,7 +99,9 @@ async def refresh_provider_quota_for_provider(
provider_type = normalize_provider_type(getattr(provider, "provider_type", "")) provider_type = normalize_provider_type(getattr(provider, "provider_type", ""))
if provider_type not in QUOTA_REFRESH_PROVIDER_TYPES: if provider_type not in QUOTA_REFRESH_PROVIDER_TYPES:
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额") raise InvalidRequestException(
"仅支持 Codex / ChatGPT Web / Antigravity / Kiro 类型的 Provider 刷新限额"
)
pool_cfg = parse_pool_config(getattr(provider, "config", None)) pool_cfg = parse_pool_config(getattr(provider, "config", None))
auto_remove_abnormal_keys = bool(pool_cfg and pool_cfg.auto_remove_banned_keys) auto_remove_abnormal_keys = bool(pool_cfg and pool_cfg.auto_remove_banned_keys)

View File

@@ -6,6 +6,7 @@ import math
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Any from typing import Any
from src.core.provider_types import ProviderType, normalize_provider_type from src.core.provider_types import ProviderType, normalize_provider_type
@@ -28,6 +29,13 @@ def _normalize_plan(value: Any) -> str | None:
return normalized or None return normalized or None
def _clean_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
return text or None
def _is_truthy_flag(value: Any) -> bool: def _is_truthy_flag(value: Any) -> bool:
if isinstance(value, bool): if isinstance(value, bool):
return value return value
@@ -350,6 +358,161 @@ class KiroQuotaReader(PoolQuotaReader):
return None return None
class ChatGPTWebQuotaReader(PoolQuotaReader):
namespace = "chatgpt_web"
def _limits_progress(self) -> list[dict[str, Any]]:
limits_progress = self._data.get("limits_progress")
if not isinstance(limits_progress, list):
return []
return [item for item in limits_progress if isinstance(item, dict)]
def _image_feature(self) -> dict[str, Any] | None:
for item in self._limits_progress():
feature_name = _clean_text(
item.get("feature_name")
or item.get("featureName")
or item.get("feature")
or item.get("name")
)
if not feature_name:
continue
if feature_name.lower() in {"image_gen", "image_generation", "image_edit", "img_gen"}:
return item
return None
def _reset_at(self) -> float | None:
for raw in (
self._data.get("image_quota_reset_at"),
(self._image_feature() or {}).get("reset_at"),
(self._image_feature() or {}).get("resetAt"),
(self._image_feature() or {}).get("next_reset_at"),
(self._image_feature() or {}).get("nextResetAt"),
(self._image_feature() or {}).get("reset_after"),
(self._image_feature() or {}).get("resetAfter"),
):
if isinstance(raw, str):
text = raw.strip()
if not text:
continue
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except Exception:
parsed = _to_float(text)
else:
parsed = _to_float(raw)
if parsed is None or parsed <= 0:
continue
if parsed > 1_000_000_000_000:
return parsed / 1000.0
if parsed > 1_000_000_000:
return parsed
return time.time() + parsed
return None
def _image_remaining(self) -> float | None:
remaining = _to_float(self._data.get("image_quota_remaining"))
if remaining is not None:
return remaining
feature = self._image_feature()
if not isinstance(feature, dict):
return None
for field in ("remaining", "remaining_value", "remainingValue"):
parsed = _to_float(feature.get(field))
if parsed is not None:
return parsed
return None
def _image_limit(self) -> float | None:
limit = _to_float(self._data.get("image_quota_total"))
if limit is not None:
return limit
feature = self._image_feature()
if not isinstance(feature, dict):
return None
for field in ("max_value", "maxValue", "cap", "total", "limit", "quota"):
parsed = _to_float(feature.get(field))
if parsed is not None:
return parsed
return None
def _image_used(self) -> float | None:
used = _to_float(self._data.get("image_quota_used"))
if used is not None:
return used
feature = self._image_feature()
if isinstance(feature, dict):
for field in ("used", "used_value", "usedValue", "consumed"):
parsed = _to_float(feature.get(field))
if parsed is not None:
return parsed
limit = self._image_limit()
remaining = self._image_remaining()
if limit is not None and remaining is not None:
return max(0.0, limit - remaining)
return None
def is_exhausted(self, model_name: str | None = None) -> QuotaExhaustedResult:
_ = model_name
if _is_truthy_flag(self._data.get("image_quota_blocked")):
return QuotaExhaustedResult(True, "ChatGPT Web 生图额度已耗尽")
remaining = self._image_remaining()
if remaining is not None and remaining <= 0.0:
return QuotaExhaustedResult(True, "ChatGPT Web 生图额度已耗尽")
limit = self._image_limit()
used = self._image_used()
if limit is not None and used is not None and limit > 0 and used >= limit:
return QuotaExhaustedResult(True, "ChatGPT Web 生图额度已耗尽")
return QuotaExhaustedResult(False)
def usage_ratio(self) -> float | None:
limit = self._image_limit()
used = self._image_used()
if used is None or limit is None or limit <= 0:
return None
return max(0.0, min(used / limit, 1.0))
def plan_type(self) -> str | None:
return _normalize_plan(self._data.get("plan_type"))
def reset_seconds(self) -> float | None:
reset_at = self._reset_at()
if reset_at is None:
return None
return max(0.0, reset_at - time.time())
def account_block(self) -> AccountBlockResult:
return AccountBlockResult(blocked=False)
def display_summary(self) -> str | None:
if _is_truthy_flag(self._data.get("image_quota_blocked")):
return "生图额度已耗尽"
remaining = self._image_remaining()
limit = self._image_limit()
used = self._image_used()
reset_text = _format_reset_after(self.reset_seconds())
if remaining is not None and limit is not None and limit > 0:
percent = max(0.0, min((remaining / limit) * 100.0, 100.0))
part = f"生图剩余 {_format_percent(percent)}"
if used is not None:
part = f"{part} ({_format_quota_value(used)}/{_format_quota_value(limit)})"
if reset_text and (used is None or _has_quota_consumption(used)):
part = f"{part} ({reset_text})"
return part
if remaining is not None:
part = f"生图剩余 {_format_quota_value(remaining)}"
if reset_text and remaining <= 0:
part = f"{part} ({reset_text})"
return part
if reset_text:
return f"生图限额 ({reset_text})"
return None
class AntigravityQuotaReader(PoolQuotaReader): class AntigravityQuotaReader(PoolQuotaReader):
namespace = "antigravity" namespace = "antigravity"
@@ -543,6 +706,7 @@ class GeminiCliQuotaReader(PoolQuotaReader):
_READER_CLASSES: dict[str, type[PoolQuotaReader]] = { _READER_CLASSES: dict[str, type[PoolQuotaReader]] = {
ProviderType.CODEX: CodexQuotaReader, ProviderType.CODEX: CodexQuotaReader,
ProviderType.CHATGPT_WEB: ChatGPTWebQuotaReader,
ProviderType.GEMINI_CLI: GeminiCliQuotaReader, ProviderType.GEMINI_CLI: GeminiCliQuotaReader,
ProviderType.KIRO: KiroQuotaReader, ProviderType.KIRO: KiroQuotaReader,
ProviderType.ANTIGRAVITY: AntigravityQuotaReader, ProviderType.ANTIGRAVITY: AntigravityQuotaReader,
@@ -570,6 +734,7 @@ def get_quota_reader(provider_type: str | None, upstream_metadata: Any) -> PoolQ
__all__ = [ __all__ = [
"AccountBlockResult", "AccountBlockResult",
"AntigravityQuotaReader", "AntigravityQuotaReader",
"ChatGPTWebQuotaReader",
"CodexQuotaReader", "CodexQuotaReader",
"GeminiCliQuotaReader", "GeminiCliQuotaReader",
"KiroQuotaReader", "KiroQuotaReader",

View File

@@ -6,10 +6,14 @@ from src.services.provider_keys.quota_refresh.antigravity_refresher import (
refresh_antigravity_key_quota, refresh_antigravity_key_quota,
) )
from src.services.provider_keys.quota_refresh.codex_refresher import refresh_codex_key_quota from src.services.provider_keys.quota_refresh.codex_refresher import refresh_codex_key_quota
from src.services.provider_keys.quota_refresh.chatgpt_web_refresher import (
refresh_chatgpt_web_key_quota,
)
from src.services.provider_keys.quota_refresh.kiro_refresher import refresh_kiro_key_quota from src.services.provider_keys.quota_refresh.kiro_refresher import refresh_kiro_key_quota
__all__ = [ __all__ = [
"refresh_codex_key_quota", "refresh_codex_key_quota",
"refresh_chatgpt_web_key_quota",
"refresh_antigravity_key_quota", "refresh_antigravity_key_quota",
"refresh_kiro_key_quota", "refresh_kiro_key_quota",
] ]

View File

@@ -0,0 +1,362 @@
"""ChatGPT Web 生图配额刷新策略。"""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider.auth import get_provider_auth
from src.services.provider.pool.account_state import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
OAUTH_EXPIRED_PREFIX,
)
from src.services.provider_keys.quota_refresh._helpers import build_success_state_update
CHATGPT_WEB_DEFAULT_BASE_URL = "https://chatgpt.com"
CHATGPT_WEB_CONVERSATION_INIT_PATH = "/backend-api/conversation/init"
CHATGPT_WEB_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
)
CHATGPT_WEB_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
CHATGPT_WEB_BUILD_NUMBER = "5955942"
CHATGPT_WEB_SEC_CH_UA = '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"'
def _coerce_float(value: Any) -> float | None:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if parsed != parsed or parsed in (float("inf"), float("-inf")):
return None
return parsed
def _text(value: Any) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
return text or None
def _is_image_feature(value: str) -> bool:
return value.strip().lower() in {"image_gen", "image_generation", "image_edit", "img_gen"}
def _feature_name(item: dict[str, Any]) -> str | None:
return _text(
item.get("feature_name")
or item.get("featureName")
or item.get("feature")
or item.get("name")
)
def _feature_number(item: dict[str, Any], *fields: str) -> float | None:
for field in fields:
parsed = _coerce_float(item.get(field))
if parsed is not None:
return parsed
return None
def _parse_reset_at(raw: Any, observed_at: int) -> int | None:
if isinstance(raw, str):
text = raw.strip()
if not text:
return None
try:
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp())
except Exception:
parsed = _coerce_float(text)
else:
parsed = _coerce_float(raw)
if parsed is None or parsed <= 0:
return None
if parsed > 1_000_000_000_000:
return int(parsed / 1000)
if parsed > 1_000_000_000:
return int(parsed)
return observed_at + int(parsed)
def _auth_config_from_key(key: ProviderAPIKey) -> dict[str, Any]:
if not getattr(key, "auth_config", None):
return {}
try:
decrypted = crypto_service.decrypt(key.auth_config)
value = json.loads(decrypted)
except Exception:
return {}
return value if isinstance(value, dict) else {}
def parse_chatgpt_web_conversation_init_response(
data: Any,
*,
auth_config: dict[str, Any] | None = None,
observed_at: int | None = None,
) -> dict[str, Any] | None:
if not isinstance(data, dict):
return None
observed_at = observed_at or int(time.time())
limits_progress = data.get("limits_progress") or data.get("limitsProgress") or []
if not isinstance(limits_progress, list):
limits_progress = []
image_item = next(
(
item
for item in limits_progress
if isinstance(item, dict)
and _feature_name(item) is not None
and _is_image_feature(str(_feature_name(item)))
),
None,
)
blocked_features = [
value.strip()
for value in (data.get("blocked_features") or data.get("blockedFeatures") or [])
if isinstance(value, str) and value.strip()
]
image_blocked = any(_is_image_feature(value) for value in blocked_features)
if image_item is None and not image_blocked:
return None
metadata: dict[str, Any] = {
"updated_at": observed_at,
"blocked_features": blocked_features,
"limits_progress": limits_progress,
}
default_model_slug = _text(data.get("default_model_slug") or data.get("defaultModelSlug"))
if default_model_slug:
metadata["default_model_slug"] = default_model_slug
auth_config = auth_config or {}
for field in ("plan_type", "email", "account_id", "account_user_id", "user_id"):
value = _text(data.get(field)) or _text(auth_config.get(field))
if value:
metadata[field] = value.lower() if field == "plan_type" else value
if image_blocked:
metadata["image_quota_blocked"] = True
if isinstance(image_item, dict):
feature_name = _feature_name(image_item)
if feature_name:
metadata["image_quota_feature_name"] = feature_name
remaining = _feature_number(
image_item,
"remaining",
"remaining_value",
"remainingValue",
"remaining_count",
"remainingCount",
)
total = _feature_number(
image_item,
"max_value",
"maxValue",
"cap",
"total",
"limit",
"quota",
"usage_limit",
"usageLimit",
)
used = _feature_number(
image_item,
"used",
"used_value",
"usedValue",
"consumed",
"current_usage",
"currentUsage",
)
if used is None and total is not None and remaining is not None:
used = max(0.0, total - remaining)
reset_at = _parse_reset_at(
image_item.get("reset_at")
or image_item.get("resetAt")
or image_item.get("next_reset_at")
or image_item.get("nextResetAt")
or image_item.get("reset_after")
or image_item.get("resetAfter"),
observed_at,
)
if remaining is not None:
metadata["image_quota_remaining"] = remaining
elif image_blocked:
metadata["image_quota_remaining"] = 0.0
if total is not None:
metadata["image_quota_total"] = total
if used is not None:
metadata["image_quota_used"] = used
if reset_at is not None:
metadata["image_quota_reset_at"] = reset_at
reset_after = _text(image_item.get("reset_after") or image_item.get("resetAfter"))
if reset_after:
metadata["image_quota_reset_after"] = reset_after
elif image_blocked:
metadata["image_quota_remaining"] = 0.0
return metadata
def _build_headers(auth_header: str, auth_value: str, base_url: str) -> dict[str, str]:
return {
"Accept": "application/json",
"Content-Type": "application/json",
auth_header: auth_value,
"User-Agent": CHATGPT_WEB_USER_AGENT,
"Origin": base_url,
"Referer": f"{base_url}/",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Sec-CH-UA": CHATGPT_WEB_SEC_CH_UA,
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Windows"',
"OAI-Language": "zh-CN",
"OAI-Client-Version": CHATGPT_WEB_CLIENT_VERSION,
"OAI-Client-Build-Number": CHATGPT_WEB_BUILD_NUMBER,
"X-OpenAI-Target-Path": CHATGPT_WEB_CONVERSATION_INIT_PATH,
"X-OpenAI-Target-Route": CHATGPT_WEB_CONVERSATION_INIT_PATH,
}
def _extract_error_message_from_response(response: httpx.Response) -> str:
try:
payload = response.json()
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, dict):
message = str(err.get("message") or "").strip()
if message:
return message
if isinstance(err, str) and err.strip():
return err.strip()
message = str(payload.get("message") or "").strip()
if message:
return message
except Exception:
pass
text = str(getattr(response, "text", "") or "").strip()
return text[:300] if text else ""
async def refresh_chatgpt_web_key_quota(
*,
db: Session,
provider: Provider,
key: ProviderAPIKey,
endpoint: ProviderEndpoint | None,
codex_wham_usage_url: str,
metadata_updates: dict[str, dict],
state_updates: dict[str, dict],
) -> dict:
"""刷新单个 ChatGPT Web Key 的生图限额信息。"""
_ = db
_ = codex_wham_usage_url
if endpoint is None:
return {
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "找不到有效的 openai:image 端点",
}
auth_info = await get_provider_auth(endpoint, key)
if auth_info:
auth_header = auth_info.auth_header
auth_value = auth_info.auth_value
else:
decrypted_key = crypto_service.decrypt(key.api_key)
auth_header = "Authorization"
auth_value = f"Bearer {decrypted_key}"
base_url = str(getattr(endpoint, "base_url", "") or CHATGPT_WEB_DEFAULT_BASE_URL).strip()
base_url = base_url.rstrip("/") or CHATGPT_WEB_DEFAULT_BASE_URL
url = f"{base_url}{CHATGPT_WEB_CONVERSATION_INIT_PATH}"
headers = _build_headers(auth_header, auth_value, base_url)
body = {
"gizmo_id": None,
"requested_default_model": None,
"conversation_id": None,
"timezone_offset_min": -480,
"system_hints": ["picture_v2"],
}
response: httpx.Response
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
status_code = int(response.status_code)
err_msg = _extract_error_message_from_response(response)
if status_code in (401, 403):
prefix = OAUTH_EXPIRED_PREFIX if status_code == 401 else OAUTH_ACCOUNT_BLOCK_PREFIX
detail = err_msg or (
"ChatGPT Web Token 无效或已过期"
if status_code == 401
else "ChatGPT Web 账户访问受限"
)
state_updates[key.id] = {
"oauth_invalid_at": datetime.now(timezone.utc),
"oauth_invalid_reason": f"{prefix}{detail}",
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "auth_invalid" if status_code == 401 else "forbidden",
"message": f"conversation/init 返回状态码 {status_code}{f': {err_msg}' if err_msg else ''}",
"status_code": status_code,
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": f"conversation/init 返回状态码 {status_code}{f': {err_msg}' if err_msg else ''}",
"status_code": status_code,
}
try:
data = response.json()
except Exception:
return {
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "无法解析 conversation/init API 响应",
}
metadata = parse_chatgpt_web_conversation_init_response(
data,
auth_config=_auth_config_from_key(key),
observed_at=int(time.time()),
)
if metadata:
metadata_updates[key.id] = {"chatgpt_web": metadata}
state_updates[key.id] = build_success_state_update(key)
return {
"key_id": key.id,
"key_name": key.name,
"status": "success",
"metadata": metadata,
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "no_metadata",
"message": "响应中未包含 ChatGPT Web 生图限额信息",
"status_code": response.status_code,
}

View File

@@ -3,9 +3,10 @@ pub(crate) use crate::handlers::admin::{
build_internal_control_error_response, create_provider_oauth_catalog_key, build_internal_control_error_response, create_provider_oauth_catalog_key,
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response, find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
maybe_build_local_admin_response, provider_oauth_runtime_endpoint_for_provider, maybe_build_local_admin_response, provider_oauth_runtime_endpoint_for_provider,
refresh_antigravity_provider_quota_locally, refresh_codex_provider_quota_locally, refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
refresh_kiro_provider_quota_locally, refresh_provider_oauth_account_state_after_update, refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
update_existing_provider_oauth_catalog_key, AdminAppState, refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
AdminAppState,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange, AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
AdminStatsUsageFilter, AdminStatsUsageFilter,

View File

@@ -26,6 +26,7 @@ pub(crate) use self::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, update_existing_provider_oauth_catalog_key, create_provider_oauth_catalog_key, update_existing_provider_oauth_catalog_key,
}; };
pub(crate) use self::provider::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally; pub(crate) use self::provider::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::codex::refresh_codex_provider_quota_locally; pub(crate) use self::provider::oauth::quota::codex::refresh_codex_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally; pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
pub(crate) use self::provider::oauth::runtime::{ pub(crate) use self::provider::oauth::runtime::{

View File

@@ -14,6 +14,7 @@ use serde_json::json;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally; use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
use super::super::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally; use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally;
use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally; use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
use super::super::oauth::quota::shared::normalize_string_id_list; use super::super::oauth::quota::shared::normalize_string_id_list;
@@ -110,6 +111,13 @@ pub(super) async fn maybe_handle(
}) })
.cloned() .cloned()
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)), .or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
"chatgpt_web" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:image")
}),
_ => return Ok(None), _ => return Ok(None),
}; };
@@ -118,6 +126,7 @@ pub(super) async fn maybe_handle(
"codex" => "找不到有效的 openai:responses 端点", "codex" => "找不到有效的 openai:responses 端点",
"antigravity" => "找不到有效的 gemini:generate_content 端点", "antigravity" => "找不到有效的 gemini:generate_content 端点",
"kiro" => "找不到有效的 Kiro 端点", "kiro" => "找不到有效的 Kiro 端点",
"chatgpt_web" => "找不到有效的 openai:image 端点",
_ => "找不到有效端点", _ => "找不到有效端点",
}; };
return Ok(Some( return Ok(Some(
@@ -198,6 +207,10 @@ pub(super) async fn maybe_handle(
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys, None) refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys, None)
.await? .await?
} }
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(state, &provider, &endpoint, keys, None)
.await?
}
_ => None, _ => None,
}) else { }) else {
return Ok(None); return Ok(None);

View File

@@ -0,0 +1,519 @@
use super::shared::{
build_quota_snapshot_payload, default_provider_quota_execution_timeouts,
execute_provider_quota_plan, extract_execution_error_message,
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::payloads::{
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
};
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_admin::provider::quota::parse_chatgpt_web_conversation_init_response;
use aether_contracts::{
ExecutionPlan, ProxySnapshot, RequestBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com";
const CHATGPT_WEB_CONVERSATION_INIT_PATH: &str = "/backend-api/conversation/init";
const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad";
const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942";
const CHATGPT_WEB_SEC_CH_UA: &str =
r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#;
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
const CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT: f64 = 25.0;
fn chatgpt_web_base_url(endpoint: &StoredProviderCatalogEndpoint) -> String {
let base_url = endpoint.base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
CHATGPT_WEB_DEFAULT_BASE_URL.to_string()
} else {
base_url.to_string()
}
}
fn build_chatgpt_web_quota_headers(
authorization: (String, String),
base_url: &str,
) -> BTreeMap<String, String> {
let device_id = uuid::Uuid::new_v4().to_string();
let session_id = uuid::Uuid::new_v4().to_string();
let mut headers = BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
("user-agent".to_string(), CHATGPT_WEB_USER_AGENT.to_string()),
("origin".to_string(), base_url.to_string()),
("referer".to_string(), format!("{base_url}/")),
(
"accept-language".to_string(),
"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7".to_string(),
),
("cache-control".to_string(), "no-cache".to_string()),
("pragma".to_string(), "no-cache".to_string()),
("priority".to_string(), "u=1, i".to_string()),
("sec-ch-ua".to_string(), CHATGPT_WEB_SEC_CH_UA.to_string()),
("sec-ch-ua-arch".to_string(), r#""x86""#.to_string()),
("sec-ch-ua-bitness".to_string(), r#""64""#.to_string()),
("sec-ch-ua-mobile".to_string(), "?0".to_string()),
("sec-ch-ua-model".to_string(), r#""""#.to_string()),
("sec-ch-ua-platform".to_string(), r#""Windows""#.to_string()),
(
"sec-ch-ua-platform-version".to_string(),
r#""19.0.0""#.to_string(),
),
("sec-fetch-dest".to_string(), "empty".to_string()),
("sec-fetch-mode".to_string(), "cors".to_string()),
("sec-fetch-site".to_string(), "same-origin".to_string()),
("oai-device-id".to_string(), device_id),
("oai-session-id".to_string(), session_id),
("oai-language".to_string(), "zh-CN".to_string()),
(
"oai-client-version".to_string(),
CHATGPT_WEB_CLIENT_VERSION.to_string(),
),
(
"oai-client-build-number".to_string(),
CHATGPT_WEB_BUILD_NUMBER.to_string(),
),
(
"x-openai-target-path".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
(
"x-openai-target-route".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
(
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER.to_string(),
"true".to_string(),
),
]);
headers.insert(authorization.0.to_ascii_lowercase(), authorization.1);
headers
}
fn chatgpt_web_auth_config(transport: &AdminGatewayProviderTransportSnapshot) -> Option<serde_json::Value> {
transport
.key
.decrypted_auth_config
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
}
fn chatgpt_web_auth_config_string(
auth_config: Option<&serde_json::Value>,
fields: &[&str],
) -> Option<String> {
let object = auth_config.and_then(serde_json::Value::as_object)?;
fields.iter().find_map(|field| {
object
.get(*field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn enrich_chatgpt_web_quota_metadata(
metadata: &mut serde_json::Value,
auth_config: Option<&serde_json::Value>,
) {
let Some(object) = metadata.as_object_mut() else {
return;
};
for (target, fields) in [
("plan_type", &["plan_type", "tier", "plan"][..]),
("email", &["email"][..]),
("account_id", &["account_id", "accountId"][..]),
("account_user_id", &["account_user_id", "accountUserId"][..]),
("user_id", &["user_id", "userId"][..]),
] {
if object.contains_key(target) {
continue;
}
if let Some(value) = chatgpt_web_auth_config_string(auth_config, fields) {
object.insert(target.to_string(), json!(value));
}
}
}
fn chatgpt_web_json_number(value: Option<&serde_json::Value>) -> Option<f64> {
let value = value?;
if let Some(number) = value.as_f64() {
return number.is_finite().then_some(number);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite())
}
fn chatgpt_web_json_string(value: Option<&serde_json::Value>) -> Option<&str> {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn existing_chatgpt_web_image_quota_limit(
upstream_metadata: Option<&serde_json::Value>,
) -> Option<f64> {
upstream_metadata
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(serde_json::Value::as_object)
.and_then(|bucket| chatgpt_web_json_number(bucket.get("image_quota_total")))
.filter(|value| *value > 0.0)
}
fn infer_chatgpt_web_image_quota_limit(
plan_type: Option<&str>,
remaining: Option<f64>,
existing_limit: Option<f64>,
) -> Option<f64> {
let normalized_plan = plan_type.unwrap_or_default().trim().to_ascii_lowercase();
if normalized_plan == "free" {
return Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT);
}
if let Some(existing_limit) = existing_limit.filter(|value| *value > 0.0) {
return Some(existing_limit);
}
remaining.filter(|value| *value > 0.0)
}
fn normalize_chatgpt_web_image_quota_limit(
metadata: &mut serde_json::Value,
upstream_metadata: Option<&serde_json::Value>,
) {
let existing_limit = existing_chatgpt_web_image_quota_limit(upstream_metadata);
let Some(object) = metadata.as_object_mut() else {
return;
};
let remaining = chatgpt_web_json_number(object.get("image_quota_remaining"));
let explicit_limit = chatgpt_web_json_number(object.get("image_quota_total"))
.filter(|value| *value > 0.0);
let plan_type = chatgpt_web_json_string(object.get("plan_type"));
let is_free_plan = plan_type
.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
let limit = if is_free_plan {
Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT)
} else {
explicit_limit.or_else(|| {
infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit)
})
};
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit));
if !object.contains_key("image_quota_used") {
if let Some(remaining) = remaining {
object.insert(
"image_quota_used".to_string(),
json!((limit - remaining).max(0.0)),
);
} else if object
.get("image_quota_blocked")
.and_then(serde_json::Value::as_bool)
== Some(true)
{
object.insert("image_quota_used".to_string(), json!(limit));
}
}
}
}
async fn resolve_chatgpt_web_quota_auth(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
) -> Result<Option<(String, String)>, GatewayError> {
if let Some(auth) = state.resolve_local_oauth_header_auth(transport).await? {
return Ok(Some(auth));
}
let decrypted_key = transport.key.decrypted_api_key.trim();
if decrypted_key.is_empty() || decrypted_key == PLACEHOLDER_API_KEY {
return Ok(None);
}
Ok(Some((
"authorization".to_string(),
format!("Bearer {decrypted_key}"),
)))
}
async fn execute_chatgpt_web_quota_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
endpoint: &StoredProviderCatalogEndpoint,
authorization: (String, String),
proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let base_url = chatgpt_web_base_url(endpoint);
let proxy = match proxy_override {
Some(proxy) => Some(proxy.clone()),
None => {
state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await
}
};
let timeouts = state
.resolve_transport_execution_timeouts(transport)
.or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(),
)));
let plan = ExecutionPlan {
request_id: format!("chatgpt-web-quota:{}", transport.key.id),
candidate_id: None,
provider_name: Some("chatgpt_web".to_string()),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method: "POST".to_string(),
url: format!("{base_url}{CHATGPT_WEB_CONVERSATION_INIT_PATH}"),
headers: build_chatgpt_web_quota_headers(authorization, base_url.as_str()),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({
"gizmo_id": serde_json::Value::Null,
"requested_default_model": serde_json::Value::Null,
"conversation_id": serde_json::Value::Null,
"timezone_offset_min": -480,
"system_hints": ["picture_v2"],
})),
stream: false,
client_api_format: "openai:image".to_string(),
provider_api_format: "chatgpt_web:conversation_init".to_string(),
model_name: Some("chatgpt-web-conversation-init".to_string()),
proxy,
transport_profile: state.resolve_transport_profile(transport),
timeouts,
};
execute_provider_quota_plan(state, transport, plan, "chatgpt_web").await
}
fn chatgpt_web_quota_invalid_reason(status_code: u16, upstream_message: Option<&str>) -> String {
let message = upstream_message.unwrap_or_default().trim();
let detail = if message.is_empty() {
match status_code {
401 => "ChatGPT Web Token 无效或已过期",
403 => "ChatGPT Web 账户访问受限",
_ => "ChatGPT Web 请求失败",
}
} else {
message
};
match status_code {
401 => format!("{OAUTH_EXPIRED_PREFIX}{detail}"),
403 => format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}"),
_ => detail.to_string(),
}
}
pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoint: &StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> Result<Option<serde_json::Value>, GatewayError> {
let mut results = Vec::new();
let mut success_count = 0usize;
let mut failed_count = 0usize;
for key in keys {
let transport = match state
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
.await?
{
Some(transport) => transport,
None => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "Provider transport snapshot unavailable",
}));
continue;
}
};
let authorization = match resolve_chatgpt_web_quota_auth(state, &transport).await? {
Some(auth) => auth,
None => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "缺少 ChatGPT Web OAuth 认证信息,请先导入/刷新 Token",
}));
continue;
}
};
let result = match execute_chatgpt_web_quota_plan(
state,
&transport,
endpoint,
authorization,
proxy_override.as_ref(),
)
.await?
{
ProviderQuotaExecutionOutcome::Response(result) => result,
ProviderQuotaExecutionOutcome::Failure(detail) => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": format!("conversation/init 请求执行失败: {detail}"),
"status_code": 502,
}));
continue;
}
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let mut metadata_update = None::<serde_json::Value>;
let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) =
(key.oauth_invalid_at_unix_secs, key.oauth_invalid_reason.clone());
let mut status = "error".to_string();
let mut message = None::<String>;
if result.status_code == 200 {
if let Some(body_json) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
{
if let Some(mut metadata) =
parse_chatgpt_web_conversation_init_response(body_json, now_unix_secs)
{
let auth_config = chatgpt_web_auth_config(&transport);
enrich_chatgpt_web_quota_metadata(&mut metadata, auth_config.as_ref());
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
key.upstream_metadata.as_ref(),
);
metadata_update = Some(json!({ "chatgpt_web": metadata }));
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
quota_refresh_success_invalid_state(&key);
status = "success".to_string();
} else {
status = "no_metadata".to_string();
message = Some("响应中未包含 ChatGPT Web 生图限额信息".to_string());
}
} else {
status = "no_metadata".to_string();
message = Some("响应中未包含 ChatGPT Web 生图限额信息".to_string());
}
} else {
let err_msg = extract_execution_error_message(&result);
message = Some(match err_msg.as_deref() {
Some(detail) if !detail.is_empty() => {
format!(
"conversation/init 返回状态码 {}: {}",
result.status_code, detail
)
}
_ => format!("conversation/init 返回状态码 {}", result.status_code),
});
if matches!(result.status_code, 401 | 403) {
oauth_invalid_at_unix_secs = Some(now_unix_secs);
oauth_invalid_reason = Some(chatgpt_web_quota_invalid_reason(
result.status_code,
err_msg.as_deref(),
));
status = if result.status_code == 401 {
"auth_invalid".to_string()
} else {
"forbidden".to_string()
};
}
}
if !persist_provider_quota_refresh_state(
state,
&key.id,
metadata_update.as_ref(),
oauth_invalid_at_unix_secs,
oauth_invalid_reason,
None,
)
.await?
{
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "Key 状态写入失败",
}));
continue;
}
if status == "success" {
success_count += 1;
} else {
failed_count += 1;
}
let mut payload = serde_json::Map::new();
payload.insert("key_id".to_string(), json!(key.id));
payload.insert("key_name".to_string(), json!(key.name));
payload.insert("status".to_string(), json!(status));
if let Some(message) = message {
payload.insert("message".to_string(), json!(message));
}
if result.status_code != 200 {
payload.insert("status_code".to_string(), json!(result.status_code));
}
if let Some(metadata) = metadata_update
.as_ref()
.and_then(|value| value.get("chatgpt_web"))
.cloned()
{
payload.insert("metadata".to_string(), metadata);
}
if let Some(quota_snapshot) = build_quota_snapshot_payload(
"chatgpt_web",
key.status_snapshot.as_ref(),
metadata_update.as_ref(),
) {
payload.insert("quota_snapshot".to_string(), quota_snapshot);
}
results.push(serde_json::Value::Object(payload));
}
Ok(Some(json!({
"success": success_count,
"failed": failed_count,
"total": success_count + failed_count,
"results": results,
"message": format!("已处理 {} 个 Key", success_count + failed_count),
"auto_removed": 0,
})))
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod antigravity; pub(crate) mod antigravity;
pub(crate) mod chatgpt_web;
pub(crate) mod codex; pub(crate) mod codex;
pub(crate) mod kiro; pub(crate) mod kiro;
pub(crate) mod shared; pub(crate) mod shared;

View File

@@ -130,7 +130,7 @@ pub(crate) async fn persist_provider_quota_refresh_state(
metadata_update, metadata_update,
)); ));
quota_snapshot_provider_type = metadata_update.as_object().and_then(|object| { quota_snapshot_provider_type = metadata_update.as_object().and_then(|object| {
["codex", "kiro", "antigravity", "gemini_cli"] ["codex", "kiro", "antigravity", "gemini_cli", "chatgpt_web"]
.into_iter() .into_iter()
.find(|provider_type| object.contains_key(*provider_type)) .find(|provider_type| object.contains_key(*provider_type))
}); });

View File

@@ -1,4 +1,5 @@
use super::quota::antigravity::refresh_antigravity_provider_quota_locally; use super::quota::antigravity::refresh_antigravity_provider_quota_locally;
use super::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
use super::quota::codex::refresh_codex_provider_quota_locally; use super::quota::codex::refresh_codex_provider_quota_locally;
use super::quota::kiro::refresh_kiro_provider_quota_locally; use super::quota::kiro::refresh_kiro_provider_quota_locally;
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
@@ -72,7 +73,10 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
proxy_override: Option<&ProxySnapshot>, proxy_override: Option<&ProxySnapshot>,
) -> Result<(bool, Option<String>), GatewayError> { ) -> Result<(bool, Option<String>), GatewayError> {
let provider_type = provider.provider_type.trim().to_ascii_lowercase(); let provider_type = provider.provider_type.trim().to_ascii_lowercase();
if !matches!(provider_type.as_str(), "codex" | "kiro" | "antigravity") { if !matches!(
provider_type.as_str(),
"codex" | "kiro" | "antigravity" | "chatgpt_web"
) {
return Ok((false, None)); return Ok((false, None));
} }
@@ -127,6 +131,16 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
) )
.await? .await?
} }
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(
state,
provider,
&endpoint,
vec![key],
proxy_override,
)
.await?
}
_ => None, _ => None,
}; };
let Some(payload) = payload else { let Some(payload) = payload else {

View File

@@ -481,6 +481,43 @@ fn admin_pool_build_kiro_account_quota_from_snapshot(
} }
} }
fn admin_pool_build_chatgpt_web_account_quota_from_snapshot(
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let window = admin_pool_quota_window(quota_snapshot, "image_gen")
.or_else(|| admin_pool_quota_windows(quota_snapshot).into_iter().next())?;
let remaining_value = admin_pool_json_to_f64(window.get("remaining_value"));
let limit_value = admin_pool_json_to_f64(window.get("limit_value"));
let remaining_percent = admin_pool_json_to_f64(window.get("remaining_ratio"))
.map(|value| (value * 100.0).clamp(0.0, 100.0))
.or_else(|| {
admin_pool_json_to_f64(window.get("used_ratio"))
.map(|value| ((1.0 - value) * 100.0).clamp(0.0, 100.0))
});
let reset_seconds =
admin_pool_quota_window_reset_seconds(quota_snapshot, window, now_unix_secs);
let mut text = match (remaining_value, limit_value, remaining_percent) {
(Some(remaining), Some(limit), _) if limit > 0.0 => Some(format!(
"生图剩余 {}/{}",
admin_pool_format_quota_value(remaining),
admin_pool_format_quota_value(limit),
)),
(Some(remaining), _, _) => Some(format!(
"生图剩余 {}",
admin_pool_format_quota_value(remaining),
)),
(_, _, Some(percent)) => Some(format!("生图剩余 {}", admin_pool_format_percent(percent))),
_ => None,
}?;
if let Some(reset_text) = reset_seconds.and_then(admin_pool_format_reset_after) {
text.push_str(&format!(" ({reset_text})"));
}
Some(text)
}
fn admin_pool_build_antigravity_account_quota_from_snapshot( fn admin_pool_build_antigravity_account_quota_from_snapshot(
quota_snapshot: &serde_json::Map<String, serde_json::Value>, quota_snapshot: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> { ) -> Option<String> {
@@ -617,6 +654,13 @@ fn admin_pool_build_account_quota(
return Some(account_quota); return Some(account_quota);
} }
} }
"chatgpt_web" => {
if let Some(account_quota) =
admin_pool_build_chatgpt_web_account_quota_from_snapshot(quota_snapshot)
{
return Some(account_quota);
}
}
"antigravity" => { "antigravity" => {
if let Some(account_quota) = if let Some(account_quota) =
admin_pool_build_antigravity_account_quota_from_snapshot(quota_snapshot) admin_pool_build_antigravity_account_quota_from_snapshot(quota_snapshot)

View File

@@ -425,6 +425,31 @@ fn quota_window_reset_seconds(
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at)) .map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
} }
fn chatgpt_web_image_quota_limit(
metadata: &Map<String, Value>,
remaining: Option<f64>,
) -> Option<f64> {
let plan_type = metadata
.get("plan_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
if plan_type.as_deref() == Some("free") {
return Some(25.0);
}
let explicit_limit = metadata
.get("image_quota_total")
.and_then(admin_provider_quota_pure::coerce_json_f64)
.filter(|value| *value > 0.0);
if let Some(limit) = explicit_limit {
return Some(limit);
}
remaining.filter(|value| *value > 0.0)
}
fn model_quota_window_snapshot( fn model_quota_window_snapshot(
model_name: &str, model_name: &str,
item: &Map<String, Value>, item: &Map<String, Value>,
@@ -813,6 +838,95 @@ fn build_kiro_quota_status_snapshot(
})) }))
} }
fn build_chatgpt_web_quota_status_snapshot(
upstream_metadata: Option<&Value>,
source: &str,
) -> Option<Value> {
let metadata = provider_quota_metadata_bucket(upstream_metadata, "chatgpt_web")?;
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
let remaining = metadata
.get("image_quota_remaining")
.and_then(admin_provider_quota_pure::coerce_json_f64);
let limit = chatgpt_web_image_quota_limit(metadata, remaining);
let used = metadata
.get("image_quota_used")
.and_then(admin_provider_quota_pure::coerce_json_f64)
.or_else(|| limit.zip(remaining).map(|(limit, remaining)| (limit - remaining).max(0.0)));
let reset_at =
provider_quota_timestamp_unix_secs(metadata.get("image_quota_reset_at"));
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
let plan_type = metadata
.get("plan_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
let image_blocked = metadata
.get("image_quota_blocked")
.and_then(admin_provider_quota_pure::coerce_json_bool)
== Some(true);
let usage_ratio = used.zip(limit).and_then(|(used, limit)| {
(limit > 0.0).then_some((used / limit).clamp(0.0, 1.0))
});
let remaining_ratio = remaining.zip(limit).and_then(|(remaining, limit)| {
(limit > 0.0).then_some((remaining / limit).clamp(0.0, 1.0))
});
let mut windows = Vec::new();
if remaining.is_some()
|| limit.is_some()
|| used.is_some()
|| reset_at.is_some()
|| image_blocked
{
windows.push(json!({
"code": "image_gen",
"label": "生图",
"scope": "account",
"unit": "count",
"used_ratio": usage_ratio,
"remaining_ratio": remaining_ratio,
"used_value": used,
"remaining_value": remaining,
"limit_value": limit,
"reset_at": reset_at,
"reset_seconds": reset_seconds,
"is_exhausted": image_blocked || remaining.is_some_and(|value| value <= 0.0),
}));
}
if windows.is_empty() && plan_type.is_none() && observed_at_unix_secs.is_none() {
return None;
}
let exhausted = image_blocked
|| remaining.is_some_and(|value| value <= 0.0)
|| usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
let reason = if exhausted {
Some("生图额度已耗尽")
} else {
None
};
Some(json!({
"version": 2,
"provider_type": "chatgpt_web",
"code": if exhausted { "exhausted" } else { "ok" },
"label": if exhausted { Some("额度耗尽") } else { None::<&str> },
"reason": reason,
"freshness": "fresh",
"source": source,
"observed_at": observed_at_unix_secs,
"exhausted": exhausted,
"usage_ratio": usage_ratio,
"updated_at": observed_at_unix_secs,
"reset_at": reset_at,
"reset_seconds": reset_seconds,
"plan_type": plan_type,
"windows": windows,
}))
}
fn build_antigravity_quota_status_snapshot( fn build_antigravity_quota_status_snapshot(
upstream_metadata: Option<&Value>, upstream_metadata: Option<&Value>,
source: &str, source: &str,
@@ -993,6 +1107,7 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
let quota = match normalized_provider_type.as_str() { let quota = match normalized_provider_type.as_str() {
"codex" => build_codex_quota_status_snapshot(upstream_metadata, source), "codex" => build_codex_quota_status_snapshot(upstream_metadata, source),
"kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source), "kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source),
"chatgpt_web" => build_chatgpt_web_quota_status_snapshot(upstream_metadata, source),
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source), "antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source), "gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
_ => None, _ => None,
@@ -1784,6 +1899,42 @@ mod tests {
assert_eq!(window.get("reset_seconds"), Some(&json!(3_600u64))); assert_eq!(window.get("reset_seconds"), Some(&json!(3_600u64)));
} }
#[test]
fn provider_key_status_snapshot_payload_backfills_chatgpt_web_image_quota() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"chatgpt_web": {
"updated_at": 1_778_067_246u64,
"plan_type": "free",
"image_quota_remaining": 24.0,
"image_quota_reset_at": 1_778_157_172u64
}
}));
let payload = provider_key_status_snapshot_payload(&key, "chatgpt_web");
let quota = payload
.get("quota")
.and_then(Value::as_object)
.expect("quota snapshot should be object");
let window = quota
.get("windows")
.and_then(Value::as_array)
.and_then(|windows| windows.first())
.and_then(Value::as_object)
.expect("image quota window should exist");
assert_eq!(quota.get("provider_type"), Some(&json!("chatgpt_web")));
assert_eq!(quota.get("code"), Some(&json!("ok")));
assert_eq!(quota.get("plan_type"), Some(&json!("free")));
assert_eq!(quota.get("reset_at"), Some(&json!(1_778_157_172u64)));
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.04)));
assert_eq!(window.get("code"), Some(&json!("image_gen")));
assert_eq!(window.get("remaining_value"), Some(&json!(24.0)));
assert_eq!(window.get("limit_value"), Some(&json!(25.0)));
assert_eq!(window.get("used_value"), Some(&json!(1.0)));
assert_eq!(window.get("remaining_ratio"), Some(&json!(0.96)));
}
#[test] #[test]
fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() { fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() {
let mut key = sample_catalog_key(); let mut key = sample_catalog_key();

View File

@@ -10,8 +10,8 @@ use tracing::{debug, info, warn};
use crate::admin_api::{ use crate::admin_api::{
admin_provider_pool_config, provider_oauth_runtime_endpoint_for_provider, admin_provider_pool_config, provider_oauth_runtime_endpoint_for_provider,
refresh_antigravity_provider_quota_locally, refresh_codex_provider_quota_locally, refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
refresh_kiro_provider_quota_locally, AdminAppState, refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally, AdminAppState,
}; };
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
@@ -94,7 +94,7 @@ fn now_unix_secs() -> u64 {
fn provider_supports_quota_probe(provider_type: &str) -> bool { fn provider_supports_quota_probe(provider_type: &str) -> bool {
matches!( matches!(
provider_type.trim().to_ascii_lowercase().as_str(), provider_type.trim().to_ascii_lowercase().as_str(),
"codex" | "kiro" | "antigravity" "codex" | "kiro" | "antigravity" | "chatgpt_web"
) )
} }
@@ -116,6 +116,7 @@ fn extract_quota_updated_at(provider_type: &str, upstream_metadata: Option<&Valu
"codex" => "codex", "codex" => "codex",
"kiro" => "kiro", "kiro" => "kiro",
"antigravity" => "antigravity", "antigravity" => "antigravity",
"chatgpt_web" => "chatgpt_web",
_ => return None, _ => return None,
}; };
let bucket = metadata.get(bucket_name)?.as_object()?; let bucket = metadata.get(bucket_name)?.as_object()?;
@@ -382,6 +383,10 @@ async fn refresh_provider_probe_keys(
refresh_antigravity_provider_quota_locally(admin_state, provider, endpoint, keys, None) refresh_antigravity_provider_quota_locally(admin_state, provider, endpoint, keys, None)
.await .await
} }
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(admin_state, provider, endpoint, keys, None)
.await
}
_ => Ok(None), _ => Ok(None),
} }
} }

View File

@@ -247,6 +247,23 @@ pub fn admin_pool_key_account_quota_exhausted(
_ => false, _ => false,
} }
} }
"chatgpt_web" => {
if admin_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) {
return true;
}
if admin_pool_json_f64(bucket.get("image_quota_remaining")).is_some_and(|value| {
value <= 0.0
}) {
return true;
}
match (
admin_pool_json_f64(bucket.get("image_quota_total")),
admin_pool_json_f64(bucket.get("image_quota_used")),
) {
(Some(limit), Some(used)) if limit > 0.0 => used >= limit,
_ => false,
}
}
_ => false, _ => false,
} }
} }

View File

@@ -655,9 +655,213 @@ pub fn parse_kiro_usage_response(
Some(serde_json::Value::Object(result)) Some(serde_json::Value::Object(result))
} }
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
coerce_json_string(
value
.get("feature_name")
.or_else(|| value.get("featureName"))
.or_else(|| value.get("feature"))
.or_else(|| value.get("name")),
)
}
fn chatgpt_web_is_image_quota_feature(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"image_gen" | "image_generation" | "image_edit" | "img_gen"
)
}
fn chatgpt_web_feature_number(
feature: &serde_json::Value,
fields: &[&str],
) -> Option<f64> {
fields
.iter()
.find_map(|field| feature.get(*field).and_then(coerce_json_f64))
}
fn parse_chatgpt_web_reset_timestamp(value: Option<&serde_json::Value>, observed_at: u64) -> Option<u64> {
let value = value?;
if let Some(text) = value.as_str().map(str::trim).filter(|value| !value.is_empty()) {
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
return u64::try_from(parsed.timestamp()).ok();
}
if let Ok(parsed) = text.parse::<f64>() {
return normalize_chatgpt_web_numeric_reset(parsed, observed_at);
}
return None;
}
value
.as_f64()
.and_then(|parsed| normalize_chatgpt_web_numeric_reset(parsed, observed_at))
}
fn normalize_chatgpt_web_numeric_reset(value: f64, observed_at: u64) -> Option<u64> {
if !value.is_finite() || value <= 0.0 {
return None;
}
if value > 1_000_000_000_000.0 {
return Some((value / 1000.0).floor() as u64);
}
if value > 1_000_000_000.0 {
return Some(value.floor() as u64);
}
Some(observed_at.saturating_add(value.floor() as u64))
}
fn chatgpt_web_blocked_features(value: &serde_json::Value) -> Vec<String> {
value
.get("blocked_features")
.or_else(|| value.get("blockedFeatures"))
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub fn parse_chatgpt_web_conversation_init_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let root = value.as_object()?;
let limits_progress = root
.get("limits_progress")
.or_else(|| root.get("limitsProgress"))
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let image_limit = limits_progress
.iter()
.find(|item| {
chatgpt_web_quota_feature_name(item)
.as_deref()
.is_some_and(chatgpt_web_is_image_quota_feature)
})
.cloned();
let blocked_features = chatgpt_web_blocked_features(value);
let image_blocked = blocked_features
.iter()
.any(|feature| chatgpt_web_is_image_quota_feature(feature));
if image_limit.is_none() && !image_blocked {
return None;
}
let mut result = serde_json::Map::new();
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
if let Some(default_model_slug) = coerce_json_string(
root.get("default_model_slug")
.or_else(|| root.get("defaultModelSlug")),
) {
result.insert("default_model_slug".to_string(), json!(default_model_slug));
}
if let Some(plan_type) = coerce_json_string(
root.get("plan_type")
.or_else(|| root.get("planType"))
.or_else(|| root.get("subscription_plan")),
) {
result.insert("plan_type".to_string(), json!(plan_type.to_ascii_lowercase()));
}
result.insert("blocked_features".to_string(), json!(blocked_features));
result.insert("limits_progress".to_string(), serde_json::Value::Array(limits_progress));
if image_blocked {
result.insert("image_quota_blocked".to_string(), json!(true));
}
if let Some(image_limit) = image_limit.as_ref() {
if let Some(feature_name) = chatgpt_web_quota_feature_name(image_limit) {
result.insert("image_quota_feature_name".to_string(), json!(feature_name));
}
let remaining = chatgpt_web_feature_number(
image_limit,
&[
"remaining",
"remaining_value",
"remainingValue",
"remaining_count",
"remainingCount",
],
);
let total = chatgpt_web_feature_number(
image_limit,
&[
"max_value",
"maxValue",
"cap",
"total",
"limit",
"quota",
"usage_limit",
"usageLimit",
],
);
let used = chatgpt_web_feature_number(
image_limit,
&[
"used",
"used_value",
"usedValue",
"consumed",
"current_usage",
"currentUsage",
],
)
.or_else(|| total.zip(remaining).map(|(total, remaining)| (total - remaining).max(0.0)));
let reset_source = image_limit
.get("reset_at")
.or_else(|| image_limit.get("resetAt"))
.or_else(|| image_limit.get("next_reset_at"))
.or_else(|| image_limit.get("nextResetAt"))
.or_else(|| image_limit.get("reset_after"))
.or_else(|| image_limit.get("resetAfter"));
let reset_at = parse_chatgpt_web_reset_timestamp(reset_source, updated_at_unix_secs);
if let Some(remaining) = remaining {
result.insert("image_quota_remaining".to_string(), json!(remaining));
} else if image_blocked {
result.insert("image_quota_remaining".to_string(), json!(0.0));
}
if let Some(total) = total {
result.insert("image_quota_total".to_string(), json!(total));
}
if let Some(used) = used {
result.insert("image_quota_used".to_string(), json!(used));
}
if let Some(reset_at) = reset_at {
result.insert("image_quota_reset_at".to_string(), json!(reset_at));
}
if let Some(reset_after) = coerce_json_string(
image_limit
.get("reset_after")
.or_else(|| image_limit.get("resetAfter")),
) {
result.insert("image_quota_reset_after".to_string(), json!(reset_after));
}
} else if image_blocked {
result.insert("image_quota_remaining".to_string(), json!(0.0));
}
Some(serde_json::Value::Object(result))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{codex_runtime_invalid_reason, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX}; use super::{
codex_runtime_invalid_reason, parse_chatgpt_web_conversation_init_response,
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
};
use serde_json::json;
#[test] #[test]
fn codex_runtime_invalid_reason_marks_401_as_expired() { fn codex_runtime_invalid_reason_marks_401_as_expired() {
@@ -681,4 +885,45 @@ mod tests {
fn codex_runtime_invalid_reason_ignores_generic_403() { fn codex_runtime_invalid_reason_ignores_generic_403() {
assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None); assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None);
} }
#[test]
fn parses_chatgpt_web_image_quota_from_conversation_init() {
let parsed = parse_chatgpt_web_conversation_init_response(
&json!({
"default_model_slug": "auto",
"blocked_features": [],
"limits_progress": [
{
"feature_name": "image_gen",
"remaining": 24,
"reset_after": "2026-05-07T12:32:52.826482+00:00"
}
]
}),
1_778_067_246,
)
.expect("chatgpt web quota should parse");
assert_eq!(parsed.get("default_model_slug"), Some(&json!("auto")));
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(24.0)));
assert_eq!(
parsed.get("image_quota_reset_at"),
Some(&json!(1_778_157_172u64))
);
}
#[test]
fn parses_chatgpt_web_blocked_image_feature_as_zero_remaining() {
let parsed = parse_chatgpt_web_conversation_init_response(
&json!({
"blocked_features": ["image_generation"],
"limits_progress": []
}),
1_778_067_246,
)
.expect("blocked image feature should produce metadata");
assert_eq!(parsed.get("image_quota_blocked"), Some(&json!(true)));
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(0.0)));
}
} }

View File

@@ -358,10 +358,30 @@ export interface KiroUpstreamMetadata {
banned_at?: number // 封禁时间Unix 时间戳,秒) banned_at?: number // 封禁时间Unix 时间戳,秒)
} }
export interface ChatGPTWebUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
plan_type?: string | null
default_model_slug?: string | null
blocked_features?: string[] | null
image_quota_feature_name?: string | null
image_quota_remaining?: number | null
image_quota_total?: number | null
image_quota_used?: number | null
image_quota_reset_at?: number | null
image_quota_reset_after?: string | null
image_quota_blocked?: boolean | null
limits_progress?: Array<Record<string, unknown>> | null
email?: string | null
account_id?: string | null
account_user_id?: string | null
user_id?: string | null
}
export interface UpstreamMetadata { export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata kiro?: KiroUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
} }
// 按格式的健康度数据 // 按格式的健康度数据

View File

@@ -818,6 +818,51 @@
</div> </div>
</template> </template>
</div> </div>
<!-- ChatGPT Web 上游额度信息(生图配额) -->
<div
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
class="mt-2 p-2 rounded-md bg-muted/30"
>
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">账号配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="getChatGPTWebQuotaDisplay(key)?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatKiroUpdatedAt(getChatGPTWebQuotaDisplay(key)?.updated_at || 0) }}
</span>
</div>
</div>
<div>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">使用额度</span>
<span :class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))">
{{ getChatGPTWebQuotaRemainingPercent(key).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getChatGPTWebQuotaUsedPercent(key))"
:style="{ width: `${Math.max(getChatGPTWebQuotaRemainingPercent(key), 0)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_used) }} /
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
</span>
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
{{ formatKiroResetTime(getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at) }}重置
</span>
</div>
</div>
</div>
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 --> <!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground"> <div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
<!-- 优先级放最前面,支持点击编辑 --> <!-- 优先级放最前面,支持点击编辑 -->
@@ -1165,6 +1210,7 @@ import type {
AntigravityModelQuota, AntigravityModelQuota,
AntigravityUpstreamMetadata, AntigravityUpstreamMetadata,
CodexUpstreamMetadata, CodexUpstreamMetadata,
ChatGPTWebUpstreamMetadata,
KiroUpstreamMetadata, KiroUpstreamMetadata,
QuotaStatusSnapshot, QuotaStatusSnapshot,
QuotaWindowSnapshot, QuotaWindowSnapshot,
@@ -1815,7 +1861,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
} }
} }
// Codex / Antigravity / Kiro打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发) // Codex / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60 const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新 // 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60 const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
@@ -1834,7 +1880,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider( function getQuotaSnapshotForProvider(
key: EndpointAPIKey, key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'antigravity' | 'gemini_cli', providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli',
): QuotaStatusSnapshot | null { ): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota const quota = key.status_snapshot?.quota
if (!quota) return null if (!quota) return null
@@ -2006,6 +2052,86 @@ function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined) return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
} }
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
image_quota_remaining_percent?: number
image_quota_used_percent?: number
}
function getChatGPTWebQuotaDisplay(key: EndpointAPIKey): ChatGPTWebQuotaDisplay | null {
const quota = getQuotaSnapshotForProvider(key, 'chatgpt_web')
if (!quota) return null
const display: ChatGPTWebQuotaDisplay = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota.plan_type) display.plan_type = quota.plan_type
if (quota.code === 'exhausted' || quota.code === 'banned') display.image_quota_blocked = true
const imageWindow =
getQuotaWindow(quota, 'image_gen')
?? getQuotaWindowByScope(quota, 'account')[0]
?? null
if (imageWindow) {
const remainingValue = typeof imageWindow.remaining_value === 'number' ? imageWindow.remaining_value : undefined
const limitValue = typeof imageWindow.limit_value === 'number' ? imageWindow.limit_value : undefined
const usedValue = typeof imageWindow.used_value === 'number' ? imageWindow.used_value : undefined
const remainingPercent = getQuotaWindowRemainingPercent(imageWindow)
const usedPercent = getQuotaWindowUsedPercent(imageWindow)
if (remainingValue !== undefined) display.image_quota_remaining = remainingValue
if (limitValue !== undefined) display.image_quota_total = limitValue
if (usedValue !== undefined) display.image_quota_used = usedValue
if (remainingPercent !== undefined) display.image_quota_remaining_percent = remainingPercent
if (usedPercent !== undefined) display.image_quota_used_percent = usedPercent
if (typeof imageWindow.reset_at === 'number') display.image_quota_reset_at = imageWindow.reset_at
if (typeof imageWindow.reset_seconds === 'number') {
const resetAt = updatedAt === undefined ? undefined : updatedAt + imageWindow.reset_seconds
if (resetAt !== undefined && display.image_quota_reset_at === undefined) {
display.image_quota_reset_at = resetAt
}
}
}
return Object.keys(display).length > 0 ? display : null
}
function hasChatGPTWebQuotaDisplayData(key: EndpointAPIKey): boolean {
const display = getChatGPTWebQuotaDisplay(key)
return !!display && (
display.image_quota_remaining_percent !== undefined
|| display.image_quota_total !== undefined
|| display.image_quota_used !== undefined
)
}
function getChatGPTWebQuotaUsedPercent(key: EndpointAPIKey): number {
const display = getChatGPTWebQuotaDisplay(key)
if (!display) return 0
if (typeof display.image_quota_used_percent === 'number') return display.image_quota_used_percent
if (typeof display.image_quota_remaining_percent === 'number') {
return Math.max(100 - display.image_quota_remaining_percent, 0)
}
return 0
}
function getChatGPTWebQuotaRemainingPercent(key: EndpointAPIKey): number {
const display = getChatGPTWebQuotaDisplay(key)
if (!display) return 0
if (typeof display.image_quota_remaining_percent === 'number') return display.image_quota_remaining_percent
if (typeof display.image_quota_used_percent === 'number') {
return Math.max(100 - display.image_quota_used_percent, 0)
}
return 0
}
function formatChatGPTWebUsage(value: number | null | undefined): string {
if (value === undefined || value === null) return '-'
if (Math.abs(value - Math.round(value)) < 1e-6) {
return String(Math.round(value))
}
return value.toFixed(1)
}
function isKiroBannedKey(key: EndpointAPIKey): boolean { function isKiroBannedKey(key: EndpointAPIKey): boolean {
const quota = getQuotaSnapshotForProvider(key, 'kiro') const quota = getQuotaSnapshotForProvider(key, 'kiro')
return String(quota?.code || '').trim().toLowerCase() === 'banned' return String(quota?.code || '').trim().toLowerCase() === 'banned'
@@ -2138,7 +2264,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
return false return false
} }
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro // 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro / ChatGPT Web
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean { function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase() const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') { if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
@@ -2193,6 +2319,28 @@ function shouldAutoRefreshKiroQuota(): boolean {
return false return false
} }
function shouldAutoRefreshChatGPTWebQuota(): boolean {
if (provider.value?.provider_type !== 'chatgpt_web') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasChatGPTWebQuotaDisplayData(key)) {
return true
}
const updatedAt = getChatGPTWebQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function defaultQuotaSnapshot(): QuotaStatusSnapshot { function defaultQuotaSnapshot(): QuotaStatusSnapshot {
return { return {
code: 'unknown', code: 'unknown',
@@ -2270,14 +2418,14 @@ function applyQuotaResults(
return applied return applied
} }
// 通用的自动刷新配额函数(支持 Codex、AntigravityKiro // 通用的自动刷新配额函数(支持 Codex、AntigravityKiro 和 ChatGPT Web
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) { async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
const providerId = props.providerId const providerId = props.providerId
if (!providerId) return if (!providerId) return
if (refreshingQuota.value) return if (refreshingQuota.value) return
const providerType = provider.value?.provider_type const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro') return if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web') return
// 检查是否需要刷新 // 检查是否需要刷新
let shouldRefresh = false let shouldRefresh = false
@@ -2287,6 +2435,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshAntigravityQuota() shouldRefresh = shouldAutoRefreshAntigravityQuota()
} else if (providerType === 'kiro') { } else if (providerType === 'kiro') {
shouldRefresh = shouldAutoRefreshKiroQuota() shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
} }
if (!shouldRefresh) return if (!shouldRefresh) return
if (!options.ignoreCooldown && isProviderQuotaAutoRefreshCoolingDown(providerId)) return if (!options.ignoreCooldown && isProviderQuotaAutoRefreshCoolingDown(providerId)) return
@@ -2298,6 +2448,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key)) hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
} else if (providerType === 'kiro') { } else if (providerType === 'kiro') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key)) hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
} }
refreshingQuota.value = true refreshingQuota.value = true

View File

@@ -189,6 +189,31 @@ function getGeminiCliQuotaText(quota: QuotaStatusSnapshot): string | null {
return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)` return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)`
} }
function getChatGPTWebQuotaText(quota: QuotaStatusSnapshot): string | null {
const window = getQuotaWindow(quota, 'image_gen') ?? getQuotaWindowsByScope(quota, 'account')[0] ?? null
if (!window) return normalizeText(quota.label)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (remainingPercent != null) {
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
return `生图剩余 ${formatPercent(remainingPercent)} (${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)})`
}
return `生图剩余 ${formatPercent(remainingPercent)}`
}
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (typeof window.remaining_value === 'number') {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}`
}
return normalizeText(quota.label)
}
export function getLegacyAccountQuotaText( export function getLegacyAccountQuotaText(
input: ProviderKeyQuotaCarrier, input: ProviderKeyQuotaCarrier,
): string | null { ): string | null {
@@ -212,6 +237,8 @@ export function getQuotaSnapshotFallbackText(
return getAntigravityQuotaText(quota) return getAntigravityQuotaText(quota)
case 'gemini_cli': case 'gemini_cli':
return getGeminiCliQuotaText(quota) return getGeminiCliQuotaText(quota)
case 'chatgpt_web':
return getChatGPTWebQuotaText(quota)
default: default:
return normalizeText(quota.label) return normalizeText(quota.label)
} }

View File

@@ -1498,6 +1498,7 @@ const showAccountQuotaColumn = computed(() => {
|| selectedProviderType.value === 'gemini_cli' || selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro' || selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity' || selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'chatgpt_web'
}) })
const desktopColumnWidths = computed(() => { const desktopColumnWidths = computed(() => {
@@ -1794,6 +1795,7 @@ const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex' return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro' || selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity' || selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'chatgpt_web'
}) })
const refreshCurrentPageLoading = computed(() => { const refreshCurrentPageLoading = computed(() => {
@@ -2875,6 +2877,7 @@ function getQuotaLabelOrder(label: string): number {
if (label === '周') return 1 if (label === '周') return 1
if (label === '剩余') return 2 if (label === '剩余') return 2
if (label === '最低') return 3 if (label === '最低') return 3
if (label === '生图') return 4
return 10 return 10
} }
@@ -3062,6 +3065,34 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
}] }]
} }
if (providerType === 'chatgpt_web') {
const window = getQuotaSnapshotWindow(quota, 'image_gen')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
const remainingValue = typeof window?.remaining_value === 'number' ? window.remaining_value : null
const limitValue = typeof window?.limit_value === 'number' ? window.limit_value : null
const usedValue = typeof window?.used_value === 'number' ? window.used_value : null
const detail = usedValue != null && limitValue != null
? `${formatQuotaValue(usedValue)}/${formatQuotaValue(limitValue)}`
: remainingValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - remainingValue, 0))}/${formatQuotaValue(limitValue)}`
: remainingValue != null
? `剩余 ${formatQuotaValue(remainingValue)}`
: undefined
return [{
label: '生图',
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
return [] return []
} }