chore: omit deprecated python quota changes

This commit is contained in:
fawney19
2026-05-07 01:39:48 +08:00
parent 2a124318b9
commit 6c0ac2e8da
10 changed files with 52 additions and 578 deletions

View File

@@ -19,7 +19,6 @@ 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,7 +23,6 @@ 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,
) )
@@ -35,7 +34,6 @@ 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,
} }
@@ -66,12 +64,6 @@ 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
@@ -81,9 +73,7 @@ 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( raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
"仅支持 Codex / ChatGPT Web / Antigravity / Kiro 类型的 Provider 刷新限额"
)
async def refresh_provider_quota_for_provider( async def refresh_provider_quota_for_provider(
@@ -99,9 +89,7 @@ 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( raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
"仅支持 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,7 +6,6 @@ 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
@@ -29,13 +28,6 @@ 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
@@ -358,161 +350,6 @@ 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"
@@ -706,7 +543,6 @@ 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,
@@ -734,7 +570,6 @@ 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,14 +6,10 @@ 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

@@ -1,362 +0,0 @@
"""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

@@ -6,10 +6,9 @@ pub(crate) use crate::handlers::admin::{
refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally, refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally, refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key, refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
AdminAppState, AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext, AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange, AdminStatsTimeRange, AdminStatsUsageFilter,
AdminStatsUsageFilter,
}; };
use crate::handlers::admin::{ use crate::handlers::admin::{

View File

@@ -99,7 +99,9 @@ fn build_chatgpt_web_quota_headers(
headers headers
} }
fn chatgpt_web_auth_config(transport: &AdminGatewayProviderTransportSnapshot) -> Option<serde_json::Value> { fn chatgpt_web_auth_config(
transport: &AdminGatewayProviderTransportSnapshot,
) -> Option<serde_json::Value> {
transport transport
.key .key
.decrypted_auth_config .decrypted_auth_config
@@ -205,17 +207,15 @@ fn normalize_chatgpt_web_image_quota_limit(
}; };
let remaining = chatgpt_web_json_number(object.get("image_quota_remaining")); let remaining = chatgpt_web_json_number(object.get("image_quota_remaining"));
let explicit_limit = chatgpt_web_json_number(object.get("image_quota_total")) let explicit_limit =
.filter(|value| *value > 0.0); 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 plan_type = chatgpt_web_json_string(object.get("plan_type"));
let is_free_plan = plan_type let is_free_plan = plan_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
let limit = if is_free_plan { let limit = if is_free_plan {
Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT) Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT)
} else { } else {
explicit_limit.or_else(|| { explicit_limit
infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit) .or_else(|| infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit))
})
}; };
if let Some(limit) = limit { if let Some(limit) = limit {
@@ -397,8 +397,10 @@ pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or(0); .unwrap_or(0);
let mut metadata_update = None::<serde_json::Value>; let mut metadata_update = None::<serde_json::Value>;
let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) = let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) = (
(key.oauth_invalid_at_unix_secs, key.oauth_invalid_reason.clone()); key.oauth_invalid_at_unix_secs,
key.oauth_invalid_reason.clone(),
);
let mut status = "error".to_string(); let mut status = "error".to_string();
let mut message = None::<String>; let mut message = None::<String>;

View File

@@ -851,9 +851,12 @@ fn build_chatgpt_web_quota_status_snapshot(
let used = metadata let used = metadata
.get("image_quota_used") .get("image_quota_used")
.and_then(admin_provider_quota_pure::coerce_json_f64) .and_then(admin_provider_quota_pure::coerce_json_f64)
.or_else(|| limit.zip(remaining).map(|(limit, remaining)| (limit - remaining).max(0.0))); .or_else(|| {
let reset_at = limit
provider_quota_timestamp_unix_secs(metadata.get("image_quota_reset_at")); .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 reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
let plan_type = metadata let plan_type = metadata
.get("plan_type") .get("plan_type")
@@ -865,9 +868,9 @@ fn build_chatgpt_web_quota_status_snapshot(
.get("image_quota_blocked") .get("image_quota_blocked")
.and_then(admin_provider_quota_pure::coerce_json_bool) .and_then(admin_provider_quota_pure::coerce_json_bool)
== Some(true); == Some(true);
let usage_ratio = used.zip(limit).and_then(|(used, limit)| { let usage_ratio = used
(limit > 0.0).then_some((used / limit).clamp(0.0, 1.0)) .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)| { let remaining_ratio = remaining.zip(limit).and_then(|(remaining, limit)| {
(limit > 0.0).then_some((remaining / limit).clamp(0.0, 1.0)) (limit > 0.0).then_some((remaining / limit).clamp(0.0, 1.0))
}); });

View File

@@ -251,9 +251,9 @@ pub fn admin_pool_key_account_quota_exhausted(
if admin_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) { if admin_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) {
return true; return true;
} }
if admin_pool_json_f64(bucket.get("image_quota_remaining")).is_some_and(|value| { if admin_pool_json_f64(bucket.get("image_quota_remaining"))
value <= 0.0 .is_some_and(|value| value <= 0.0)
}) { {
return true; return true;
} }
match ( match (

View File

@@ -672,18 +672,22 @@ fn chatgpt_web_is_image_quota_feature(value: &str) -> bool {
) )
} }
fn chatgpt_web_feature_number( fn chatgpt_web_feature_number(feature: &serde_json::Value, fields: &[&str]) -> Option<f64> {
feature: &serde_json::Value,
fields: &[&str],
) -> Option<f64> {
fields fields
.iter() .iter()
.find_map(|field| feature.get(*field).and_then(coerce_json_f64)) .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> { fn parse_chatgpt_web_reset_timestamp(
value: Option<&serde_json::Value>,
observed_at: u64,
) -> Option<u64> {
let value = value?; let value = value?;
if let Some(text) = value.as_str().map(str::trim).filter(|value| !value.is_empty()) { 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) { if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
return u64::try_from(parsed.timestamp()).ok(); return u64::try_from(parsed.timestamp()).ok();
} }
@@ -769,10 +773,16 @@ pub fn parse_chatgpt_web_conversation_init_response(
.or_else(|| root.get("planType")) .or_else(|| root.get("planType"))
.or_else(|| root.get("subscription_plan")), .or_else(|| root.get("subscription_plan")),
) { ) {
result.insert("plan_type".to_string(), json!(plan_type.to_ascii_lowercase())); result.insert(
"plan_type".to_string(),
json!(plan_type.to_ascii_lowercase()),
);
} }
result.insert("blocked_features".to_string(), json!(blocked_features)); result.insert("blocked_features".to_string(), json!(blocked_features));
result.insert("limits_progress".to_string(), serde_json::Value::Array(limits_progress)); result.insert(
"limits_progress".to_string(),
serde_json::Value::Array(limits_progress),
);
if image_blocked { if image_blocked {
result.insert("image_quota_blocked".to_string(), json!(true)); result.insert("image_quota_blocked".to_string(), json!(true));
@@ -817,7 +827,11 @@ pub fn parse_chatgpt_web_conversation_init_response(
"currentUsage", "currentUsage",
], ],
) )
.or_else(|| total.zip(remaining).map(|(total, remaining)| (total - remaining).max(0.0))); .or_else(|| {
total
.zip(remaining)
.map(|(total, remaining)| (total - remaining).max(0.0))
});
let reset_source = image_limit let reset_source = image_limit
.get("reset_at") .get("reset_at")
.or_else(|| image_limit.get("resetAt")) .or_else(|| image_limit.get("resetAt"))