Files
Estrella Pan e6d39e5624 feat: in-app notification center and LLM provider plugin framework
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
2026-07-06 09:43:45 +02:00

199 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]