mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-30 04:06:04 +08:00
Two 3.3 platform features sharing core infra (notification events, DB migrations/wiring, config model, API registration), committed together because they cannot be split at file granularity without broken intermediate builds. Notification center: - New `inboxmessage` table (migration 16) + repo with coalescing (unread rows with the same dedup_key increment a count; `once` kinds like update_available notify per-version) and 500-row pruning. - SystemEvent contract (kind/severity/dedup_key/payload) with new DownloaderUnavailable / UpdateAvailable / UpdateApplied events; NotificationManager.send_event persists to the inbox first, then broadcasts to external providers only when notification.enable. - SSE `notification` frames driven by an in-process revision counter; REST endpoints for list/unread-count/read/read-all/delete/clear. - Wire-ups: qBittorrent auth now reports last_auth_error (credentials|banned|unreachable) and stops retrying rejected credentials to avoid a WebUI IP ban; the startup wait loop aborts early on credential errors and emits a notification; a 24h update-check tick surfaces new versions; apply/rollback emit results. - WebUI bell + unread badge + dropdown inbox (severity, coalesce count, relative time, deep links), SSE-driven with a 30s poll fallback. LLM provider plugin framework + subscription auth: - LLMParser refactored to a thin facade over a provider adapter contract + registry; the three built-ins (openai/anthropic/gemini) move to adapters with zero behaviour change (existing tests unchanged). - base_url presets for zai/minimax/deepseek/kimi (mainland + overseas) reusing the OpenAI-compatible adapter, with a json_object structured- output fallback for providers without json_schema. - Downloadable signed plugins: catalog + installer clone the update system's ed25519 verify / zip-slip-guarded extract pipeline (unsigned refused, catalog signed against downgrade); versioned-module-name loader hot-loads without restart. - Subscription auth: `llmcredential` table (migration 17) stores OAuth tokens server-side only (never sent to the frontend); device-flow auth runs a server-side polling task keyed by an opaque handle so the device_code stays off the wire; AuthExpiredError trips the breaker and notifies. Two grey-area device-flow plugins (github-copilot, codex-chatgpt) under plugins/, flagged at-your-own-risk. - WebUI: grouped provider select (built-in/preset/subscription/ downloadable), install with risk confirmation, device-flow connect dialog, connected-account chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
199 lines
7.5 KiB
Python
199 lines
7.5 KiB
Python
"""ChatGPT (Codex) 订阅适配器(device flow → codex responses 端点)。
|
||
|
||
⚠️ 自担风险:ChatGPT 订阅凭据仅供官方 Codex 客户端使用,token 被限定到
|
||
Codex client。第三方使用未获官方书面许可(灰色默许),originator 白名单
|
||
是其执法手段,可能收紧。
|
||
|
||
⚠️ 待核实(研究标注中/低置信度):device code 端点 URL、responses 端点的
|
||
精确请求头大小写。发货前须对 openai/codex 源码复核;下方以结构化占位实现,
|
||
便于单元测试与后续替换。
|
||
|
||
流程:
|
||
1. device flow:POST auth.openai.com/oauth/device/code → user_code;
|
||
服务端轮询 oauth/token 换 access_token(JWT,含 chatgpt_account_id)。
|
||
2. 刷新:refresh_token 换新 access_token(临期主动刷新)。
|
||
3. 推理:POST chatgpt.com/backend-api/codex/responses(Responses API),
|
||
头带 Bearer + ChatGPT-Account-Id + originator=codex_cli_rs。
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import time
|
||
|
||
from module.parser.analyser.providers.base import (
|
||
AdapterContext,
|
||
AuthChallenge,
|
||
AuthExpiredError,
|
||
LLMProviderAdapter,
|
||
ProviderInfo,
|
||
TokenSet,
|
||
)
|
||
from module.parser.analyser.providers.schema import DEFAULT_PROMPT, EPISODE_JSON_SCHEMA
|
||
|
||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # Codex CLI 公开应用
|
||
# TODO(ship): 对 openai/codex 源码复核以下端点/头
|
||
DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code"
|
||
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||
RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||
DEFAULT_MODEL = "gpt-5.1-codex"
|
||
|
||
_ORIGINATOR = "codex_cli_rs"
|
||
|
||
|
||
def _decode_account_id(access_token: str) -> str:
|
||
"""从 JWT 的 chatgpt_account_id claim 取账号 id(best-effort,失败返回空)。"""
|
||
try:
|
||
payload_b64 = access_token.split(".")[1]
|
||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||
return claims.get("chatgpt_account_id", "") or claims.get(
|
||
"https://api.openai.com/auth", {}
|
||
).get("chatgpt_account_id", "")
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
class CodexChatgptAdapter(LLMProviderAdapter):
|
||
info = ProviderInfo(
|
||
id="codex-chatgpt",
|
||
display_name="ChatGPT (Codex)",
|
||
auth_kind="device_code",
|
||
builtin=False,
|
||
default_model=DEFAULT_MODEL,
|
||
)
|
||
|
||
def __init__(self, ctx: AdapterContext) -> None:
|
||
super().__init__(ctx)
|
||
self.model = ctx.model or DEFAULT_MODEL
|
||
self._http_client = ctx.build_http_client(ctx.timeout)
|
||
|
||
# ------------------------------------------------------------ device flow
|
||
|
||
async def begin_auth(self) -> AuthChallenge:
|
||
resp = await self._http_client.post(
|
||
DEVICE_CODE_URL,
|
||
data={"client_id": CLIENT_ID, "scope": "openid profile email"},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
state = json.dumps(
|
||
{"device_code": data["device_code"], "interval": data.get("interval", 5)}
|
||
)
|
||
return AuthChallenge(
|
||
method="device_code",
|
||
user_code=data["user_code"],
|
||
verification_uri=data.get("verification_uri_complete")
|
||
or data.get("verification_uri"),
|
||
expires_in=data.get("expires_in", 900),
|
||
state=state,
|
||
)
|
||
|
||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||
blob = json.loads(state)
|
||
resp = await self._http_client.post(
|
||
TOKEN_URL,
|
||
data={
|
||
"client_id": CLIENT_ID,
|
||
"device_code": blob["device_code"],
|
||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||
},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if "access_token" not in data:
|
||
raise AuthExpiredError(data.get("error", "authorization_pending"))
|
||
return self._tokens_from_response(data)
|
||
|
||
def _tokens_from_response(self, data: dict) -> TokenSet:
|
||
access = data["access_token"]
|
||
account_id = _decode_account_id(access)
|
||
expires_at = time.time() + float(data.get("expires_in", 3600))
|
||
return TokenSet(
|
||
access_token=access,
|
||
refresh_token=data.get("refresh_token", ""),
|
||
expires_at=expires_at,
|
||
account_label="ChatGPT",
|
||
extra={"account_id": account_id},
|
||
)
|
||
|
||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||
if not tokens.refresh_token:
|
||
raise AuthExpiredError("no refresh token")
|
||
resp = await self._http_client.post(
|
||
TOKEN_URL,
|
||
data={
|
||
"client_id": CLIENT_ID,
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": tokens.refresh_token,
|
||
},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise AuthExpiredError("refresh failed")
|
||
data = resp.json()
|
||
refreshed = self._tokens_from_response(data)
|
||
# 有些实现刷新响应不回 refresh_token,沿用旧的
|
||
if not refreshed.refresh_token:
|
||
refreshed = refreshed.model_copy(
|
||
update={"refresh_token": tokens.refresh_token}
|
||
)
|
||
return refreshed
|
||
|
||
# ------------------------------------------------------------ inference
|
||
|
||
async def _valid_tokens(self) -> TokenSet:
|
||
tokens = await self.ctx.credentials.load()
|
||
if tokens is None or not tokens.access_token:
|
||
raise AuthExpiredError("ChatGPT is not connected")
|
||
if tokens.expires_at is not None and tokens.expires_at - 60 < time.time():
|
||
tokens = await self.refresh(tokens)
|
||
await self.ctx.credentials.save(tokens)
|
||
return tokens
|
||
|
||
async def parse(self, raw: str) -> dict | None:
|
||
tokens = await self._valid_tokens()
|
||
resp = await self._http_client.post(
|
||
RESPONSES_URL,
|
||
headers={
|
||
"Authorization": f"Bearer {tokens.access_token}",
|
||
"ChatGPT-Account-Id": tokens.extra.get("account_id", ""),
|
||
"originator": _ORIGINATOR,
|
||
"User-Agent": _ORIGINATOR,
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": self.model,
|
||
"instructions": DEFAULT_PROMPT,
|
||
"input": raw,
|
||
"text": {
|
||
"format": {
|
||
"type": "json_schema",
|
||
"name": "episode",
|
||
"schema": EPISODE_JSON_SCHEMA,
|
||
}
|
||
},
|
||
},
|
||
)
|
||
if resp.status_code == 401:
|
||
raise AuthExpiredError("ChatGPT token rejected")
|
||
resp.raise_for_status()
|
||
return self._extract_json(resp.json())
|
||
|
||
def _extract_json(self, data: dict) -> dict | None:
|
||
# Responses API:output[].content[].text 承载 JSON 字符串
|
||
try:
|
||
for item in data.get("output", []):
|
||
for block in item.get("content", []):
|
||
text = block.get("text")
|
||
if text:
|
||
return json.loads(text)
|
||
except (json.JSONDecodeError, AttributeError):
|
||
return None
|
||
return None
|
||
|
||
async def list_models(self) -> list[str]:
|
||
# Codex 后端不暴露稳定的 /models;返回已知默认,交由用户自定义
|
||
return [DEFAULT_MODEL, "gpt-5.1"]
|