mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 05:06:58 +08:00
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:
0
plugins/github-copilot/ab_llm_copilot/__init__.py
Normal file
0
plugins/github-copilot/ab_llm_copilot/__init__.py
Normal file
185
plugins/github-copilot/ab_llm_copilot/adapter.py
Normal file
185
plugins/github-copilot/ab_llm_copilot/adapter.py
Normal 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", []))
|
||||
15
plugins/github-copilot/plugin.json
Normal file
15
plugins/github-copilot/plugin.json
Normal 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 条款并触发滥用检测。请自担风险。"
|
||||
}
|
||||
Reference in New Issue
Block a user