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
This commit is contained in:
Estrella Pan
2026-07-06 09:43:45 +02:00
parent 2cdf146f91
commit e6d39e5624
76 changed files with 6507 additions and 342 deletions

View File

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

View File

@@ -0,0 +1,15 @@
{
"schema": 1,
"id": "codex-chatgpt",
"name": "ChatGPT (Codex)",
"version": "1.0.0",
"min_ab_version": "3.3.0",
"entry": "ab_llm_codex.adapter:CodexChatgptAdapter",
"auth_kind": "device_code",
"needs_base_url": false,
"default_model": "gpt-5.1-codex",
"description_en": "Use a ChatGPT Plus/Pro subscription for title parsing via the Codex device-flow login.",
"description_zh": "通过 Codex device flow 登录,用 ChatGPT Plus/Pro 订阅解析标题。",
"risk_notice_en": "ChatGPT subscription credentials are scoped to the official Codex client; third-party use is unsanctioned and may be blocked. Use at your own risk.",
"risk_notice_zh": "ChatGPT 订阅凭据被限定到官方 Codex 客户端,第三方使用未获许可、可能被封锁。请自担风险。"
}

View File

@@ -0,0 +1,185 @@
"""GitHub Copilot 订阅适配器(device flow → OpenAI 兼容端点)。
⚠️ 自担风险:Copilot 内部端点仅供官方客户端使用,第三方调用可能违反
GitHub 条款并触发滥用检测。低请求量降低但不消除风险。
流程(2026-07 核实):
1. device flow:POST github.com/login/device/code → 展示 user_code;
服务端轮询 login/oauth/access_token 换取长期 ``ghu_`` token。
2. 每次调用前用 ``ghu_`` 换短命 Copilot token(~30min,主动刷新):
GET api.github.com/copilot_internal/v2/token。
3. 推理走 api.githubcopilot.com/chat/completions(OpenAI 兼容),
仿编辑器请求头。结构化输出用 tool-calling 兜底。
"""
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 = "Iv1.b507a08c87ecfe98" # VS Code Copilot 公开应用
DEVICE_CODE_URL = "https://github.com/login/device/code"
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
API_BASE = "https://api.githubcopilot.com"
DEFAULT_MODEL = "gpt-4o"
# 仿 VS Code Copilot 客户端的请求头(值会随版本漂移,保持可配置)。
_EDITOR_HEADERS = {
"Editor-Version": "vscode/1.95.0",
"Editor-Plugin-Version": "copilot-chat/0.22.0",
"Copilot-Integration-Id": "vscode-chat",
"User-Agent": "GithubCopilot/1.155.0",
}
_EPISODE_TOOL = {
"type": "function",
"function": {
"name": "emit_episode",
"description": "Return the structured anime episode fields.",
"parameters": EPISODE_JSON_SCHEMA,
},
}
class GithubCopilotAdapter(LLMProviderAdapter):
info = ProviderInfo(
id="github-copilot",
display_name="GitHub Copilot",
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)
# (copilot_token, expires_at) 短命缓存
self._copilot_token: tuple[str, float] | None = None
# ------------------------------------------------------------ device flow
async def begin_auth(self) -> AuthChallenge:
resp = await self._http_client.post(
DEVICE_CODE_URL,
data={"client_id": CLIENT_ID, "scope": "read:user"},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
# device_code 是敏感态,藏进 state(服务端 pending 存储,不下发前端)
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["verification_uri"],
expires_in=data.get("expires_in", 900),
state=state,
)
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
"""轮询一次 access_token 端点(服务端轮询循环每次调用一次本方法)。"""
blob = json.loads(state)
resp = await self._http_client.post(
ACCESS_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:
# authorization_pending / slow_down:告知调用方继续轮询
raise AuthExpiredError(data.get("error", "authorization_pending"))
return TokenSet(
access_token=data["access_token"], # ghu_ 长期 token
account_label="GitHub Copilot",
)
async def refresh(self, tokens: TokenSet) -> TokenSet:
# ghu_ token 本身长期有效;短命 Copilot token 在 _ensure_copilot_token 刷新
return tokens
# ------------------------------------------------------------ inference
async def _ensure_copilot_token(self, ghu_token: str) -> str:
now = time.time()
if self._copilot_token is not None and self._copilot_token[1] - 60 > now:
return self._copilot_token[0]
resp = await self._http_client.get(
COPILOT_TOKEN_URL,
headers={
"Authorization": f"token {ghu_token}",
"Accept": "application/json",
},
)
resp.raise_for_status()
data = resp.json()
token = data["token"]
expires_at = float(data.get("expires_at", now + 1500))
self._copilot_token = (token, expires_at)
return token
async def parse(self, raw: str) -> dict | None:
tokens = await self.ctx.credentials.load()
if tokens is None or not tokens.access_token:
raise AuthExpiredError("GitHub Copilot is not connected")
copilot_token = await self._ensure_copilot_token(tokens.access_token)
resp = await self._http_client.post(
f"{API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {copilot_token}",
"Content-Type": "application/json",
**_EDITOR_HEADERS,
},
json={
"model": self.model,
"messages": [
{"role": "system", "content": DEFAULT_PROMPT},
{"role": "user", "content": raw},
],
"tools": [_EPISODE_TOOL],
"tool_choice": {
"type": "function",
"function": {"name": "emit_episode"},
},
"temperature": 0,
},
)
if resp.status_code == 401:
self._copilot_token = None
raise AuthExpiredError("Copilot token rejected")
resp.raise_for_status()
data = resp.json()
try:
call = data["choices"][0]["message"]["tool_calls"][0]
return json.loads(call["function"]["arguments"])
except (KeyError, IndexError, json.JSONDecodeError):
return None
async def list_models(self) -> list[str]:
tokens = await self.ctx.credentials.load()
if tokens is None or not tokens.access_token:
raise AuthExpiredError("GitHub Copilot is not connected")
copilot_token = await self._ensure_copilot_token(tokens.access_token)
resp = await self._http_client.get(
f"{API_BASE}/models",
headers={"Authorization": f"Bearer {copilot_token}", **_EDITOR_HEADERS},
)
resp.raise_for_status()
data = resp.json()
return sorted(m["id"] for m in data.get("data", []))

View File

@@ -0,0 +1,15 @@
{
"schema": 1,
"id": "github-copilot",
"name": "GitHub Copilot",
"version": "1.0.0",
"min_ab_version": "3.3.0",
"entry": "ab_llm_copilot.adapter:GithubCopilotAdapter",
"auth_kind": "device_code",
"needs_base_url": false,
"default_model": "gpt-4o",
"description_en": "Use a GitHub Copilot subscription for title parsing via the device-flow login.",
"description_zh": "通过 device flow 登录,用 GitHub Copilot 订阅解析标题。",
"risk_notice_en": "Copilot's internal endpoint is for official clients only; third-party use may violate GitHub's terms and trigger abuse detection. Use at your own risk.",
"risk_notice_zh": "Copilot 内部端点仅供官方客户端使用,第三方调用可能违反 GitHub 条款并触发滥用检测。请自担风险。"
}