diff --git a/README.md b/README.md index 318e4a9..e600343 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > 把本机已经登录的消费级 AI 客户端,接成 OpenAI 兼容接口,给 Codex、OpenCode、Cherry Studio、NextChat 等用。默认打开 Work Buddy / CodeBuddy、QClaw、千问办公(QwenWork)、TraeWork 四个通道;管理页下拉选其中一个。一次请求只走一个通道。 -当前版本 **2.1.4**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 +当前版本 **2.1.5**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 ## 这是什么? diff --git a/README_EN.md b/README_EN.md index 38ac034..6fcc795 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ > Local consumer AI clients → one OpenAI-compatible API for Codex, OpenCode, Cherry Studio, NextChat, and similar agents. Work Buddy / CodeBuddy, QClaw, QwenWork, and TraeWork are on by default; pick one in the UI dropdown. Each request stays on one channel. -Release **2.1.4**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. +Release **2.1.5**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. ## What is this? diff --git a/catalog.py b/catalog.py index f795612..ae7d53c 100644 --- a/catalog.py +++ b/catalog.py @@ -1,8 +1,8 @@ """Per-channel supplier model catalogs. Fetch+parse of each source's list is separate from persist and from chat I/O. -WorkBuddy and QwenWork have no supplier-list HTTP; they stay on the existing -static/admin catalog and are reported as fallback. +WorkBuddy live list is also written to the legacy `models` setting so chat +and the admin editor keep using the same catalog. """ from __future__ import annotations @@ -291,9 +291,23 @@ async def _fetch_traework(account: dict) -> list[dict]: return await fetch_supplier_models(account) +async def _fetch_qwenwork(account: dict) -> list[dict]: + from providers.qwenwork.models import fetch_supplier_models + + return await fetch_supplier_models(account) + + +async def _fetch_workbuddy(account: dict) -> list[dict]: + from providers.workbuddy.models import fetch_supplier_models + + return await fetch_supplier_models(account) + + LIVE_FETCHERS: dict[str, Fetcher] = { "qclaw": _fetch_qclaw, "traework": _fetch_traework, + "qwenwork": _fetch_qwenwork, + "workbuddy": _fetch_workbuddy, } @@ -340,6 +354,8 @@ async def refresh_one(channel: str) -> dict: display_name=display_name, ) save_catalog(channel, fetched) + if channel == "workbuddy": + db.set_setting("models", fetched) return _status_row( channel, mode="live", diff --git a/control_plane.py b/control_plane.py index ca8dd12..514d64a 100644 --- a/control_plane.py +++ b/control_plane.py @@ -332,16 +332,27 @@ async def credit_summary(force: bool = False) -> dict: ) continue remaining_values = [] + used_values = [] + limit_values = [] ok_count = 0 unsupported = False message = "" + channel_unit = "unknown" for account in accounts: snapshot = await fetch_quota(account) - unit = getattr(snapshot, "unit", None) if not isinstance(snapshot, dict) else snapshot.get("unit") + unit = str( + (getattr(snapshot, "unit", None) if not isinstance(snapshot, dict) else snapshot.get("unit")) + or "unknown" + ) ok = bool(getattr(snapshot, "ok", None) if not isinstance(snapshot, dict) else snapshot.get("ok")) snap_unsupported = bool( getattr(snapshot, "unsupported", False) if not isinstance(snapshot, dict) else snapshot.get("unsupported") ) + extra = getattr(snapshot, "extra", None) if not isinstance(snapshot, dict) else snapshot.get("extra") + if not isinstance(extra, dict): + extra = {} + if channel_unit == "unknown" and unit in {"credit", "token"}: + channel_unit = unit if snap_unsupported: unsupported = True message = ( @@ -350,20 +361,29 @@ async def credit_summary(force: bool = False) -> dict: if ok: ok_count += 1 value = getattr(snapshot, "remaining", None) if not isinstance(snapshot, dict) else snapshot.get("remaining") - if unit == "credit" and value is not None and not snap_unsupported: + if unit == channel_unit and value is not None and not snap_unsupported: remaining_values.append(float(value)) + if unit == "token" and not snap_unsupported: + if extra.get("used") is not None: + used_values.append(float(extra["used"])) + if extra.get("limit") is not None: + limit_values.append(float(extra["limit"])) remaining = round(sum(remaining_values), 4) if remaining_values else None + if channel_unit == "unknown": + channel_unit = "credit" if channel != "qclaw" else "token" channels.append( { "id": channel, "display_name": getattr(provider, "display_name", channel), - "unit": "credit", + "unit": channel_unit, "remaining": remaining, + "used": round(sum(used_values), 4) if used_values else None, + "limit": round(sum(limit_values), 4) if limit_values else None, "ok": True, "accounts": len(accounts), "ok_accounts": ok_count, - "unsupported": remaining is None, - "message": message or ("no credit balance" if remaining is None else ""), + "unsupported": remaining is None and not used_values and not limit_values, + "message": message or ("no quota number" if remaining is None else ""), } ) now_ts = int(time.time()) diff --git a/docs/releases/v2.1.5.md b/docs/releases/v2.1.5.md new file mode 100644 index 0000000..042c2bd --- /dev/null +++ b/docs/releases/v2.1.5.md @@ -0,0 +1,32 @@ +# Buddy2api v2.1.5 + +发布日期:2026-09-07 + +官方额度按通道单位显示;一键读取供应模型补上 QwenWork 和 WorkBuddy。本分支相对线上 `2.1.3` 还带上 `2.1.4` 的 `cryptography` 安全升级。 + +## 官方额度 + +- 管理页官方余额仍是一列,但按通道标注单位:WorkBuddy 积分、QClaw 每日 token,不再把不同单位加在一起。 +- QClaw 解析 jprx `4075` 的 `daily_token_used` / `daily_token_limit`。 +- QwenWork 导入优先 `auth-v2.dat` 的 JWT,避免被旧 `auth.dat` 设备 token 盖掉后额度 401。Linux Docker 解不开 Windows DPAPI,容器内请使用已经导入的 JWT,不要在容器里重扫 `auth-v2.dat`。 + +## 一键读取供应模型 + +- QwenWork:官方 COSY `GET /api/v2/model/list`(明文,不带 `Encode=1`)。关掉的模型不入库;`auto` / `qwork-advanced` 映射到 `pro`。 +- WorkBuddy:官方桌面端 `GET /v2/enterprises/personal/models`。丢掉 `text-to-image`;拉成功后同时写入通道目录和原来的 `models` 设置。 +- QClaw、TraeWork 的在线读取不变。四通道都可以 live。 + +## 安全(随本分支相对 2.1.3 带上) + +- `cryptography` 50.0.1,覆盖 CVE-2026-69247。详见 `docs/releases/v2.1.4.md`。 + +## 升级说明 + +- 无数据库迁移。 +- Docker 用户需要重新构建镜像并重启服务。 +- 思考强度策略未改。 + +## 验证 + +- 完整测试集:`249 passed`。 +- 本机已验收:QwenWork / WorkBuddy 一键读取 live;官方额度列按单位显示;Docker `8787` 与主机 `8788` 可用。 diff --git a/providers/protocol.py b/providers/protocol.py index b4e208f..29f8b69 100644 --- a/providers/protocol.py +++ b/providers/protocol.py @@ -83,7 +83,7 @@ class QuotaSnapshot: ok: bool channel: ChannelId account_id: int - unit: str + unit: str # "credit" | "token" | "unknown" remaining: float | None extra: dict = field(default_factory=dict) unsupported: bool = False diff --git a/providers/qclaw/__init__.py b/providers/qclaw/__init__.py index b9998e6..f368732 100644 --- a/providers/qclaw/__init__.py +++ b/providers/qclaw/__init__.py @@ -7,7 +7,7 @@ import auth_manager import database as db from providers.protocol import ChannelId, QuotaSnapshot -from providers.qclaw import chat, jprx, oauth, store +from providers.qclaw import chat, jprx, oauth, quota, store from providers.qclaw.constants import ( ALIASES, CHANNEL_ID, @@ -81,16 +81,7 @@ def import_path(self, path: str) -> dict: return store.import_discovered(path) async def fetch_quota(self, account: dict) -> QuotaSnapshot: - # Official balance column is credit-only. QClaw's daily token cap is not 积分. - return QuotaSnapshot( - ok=True, - channel=self.id, - account_id=int(account.get("id") or 0), - unit="credit", - remaining=None, - unsupported=True, - message="no credit balance", - ) + return await quota.fetch_quota(account) async def test_chat(self, account: dict, model: str = "default", prompt: str = "ping") -> dict: return await chat.test_chat(account, model, prompt) diff --git a/providers/qclaw/quota.py b/providers/qclaw/quota.py new file mode 100644 index 0000000..d22cd29 --- /dev/null +++ b/providers/qclaw/quota.py @@ -0,0 +1,136 @@ +"""QClaw daily token cap. Not credits — never mix into credit totals.""" + +from __future__ import annotations + +import httpx + +from providers.protocol import QuotaSnapshot +from providers.qclaw.constants import CHANNEL_ID +from providers.qclaw.jprx import JprxError, today_tokens + +_USED_KEYS = ( + "daily_token_used", + "today_used", + "used_tokens", + "token_used", + "tokens_used", + "used", + "consumed", + "today_tokens", +) +_LIMIT_KEYS = ( + "daily_token_limit", + "today_limit", + "total_tokens", + "token_limit", + "token_quota", + "limit", + "quota", + "cap", + "max", +) +_REMAIN_KEYS = ("remaining", "remain", "left", "available", "surplus") + + +def _number(value) -> float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + number = float(value) + if number > 10_000_000_000: + return None + return number + if isinstance(value, str): + text = value.strip().replace(",", "") + if not text: + return None + try: + number = float(text) + except ValueError: + return None + if number > 10_000_000_000: + return None + return number + return None + + +def _pick(data: dict, keys: tuple[str, ...]) -> float | None: + lower = {str(key).lower(): value for key, value in data.items()} + for key in keys: + if key in lower: + number = _number(lower[key]) + if number is not None: + return number + return None + + +def parse_today_tokens(data: dict | None) -> tuple[float | None, float | None, float | None]: + """Return (used, limit, remaining) from a jprx 4075 payload.""" + if not isinstance(data, dict): + return None, None, None + layers = [data] + for key in ("data", "resp", "usage", "today", "token", "tokens"): + nested = data.get(key) + if isinstance(nested, dict): + layers.append(nested) + used = limit = remaining = None + for layer in layers: + if used is None: + used = _pick(layer, _USED_KEYS) + if limit is None: + limit = _pick(layer, _LIMIT_KEYS) + if remaining is None: + remaining = _pick(layer, _REMAIN_KEYS) + if remaining is None and used is not None and limit is not None: + remaining = max(0.0, limit - used) + return used, limit, remaining + + +async def fetch_quota(account: dict) -> QuotaSnapshot: + account_id = int(account.get("id") or 0) + try: + data = await today_tokens(account) + except JprxError as exc: + return QuotaSnapshot( + ok=False, + channel=CHANNEL_ID, + account_id=account_id, + unit="token", + remaining=None, + message=str(exc)[:240], + ) + except httpx.HTTPError as exc: + return QuotaSnapshot( + ok=False, + channel=CHANNEL_ID, + account_id=account_id, + unit="token", + remaining=None, + message=str(exc)[:240], + ) + used, limit, remaining = parse_today_tokens(data if isinstance(data, dict) else {}) + extra = { + "used": used, + "limit": limit, + "raw_keys": sorted(data.keys())[:12] if isinstance(data, dict) else [], + } + if used is None and limit is None and remaining is None: + return QuotaSnapshot( + ok=True, + channel=CHANNEL_ID, + account_id=account_id, + unit="token", + remaining=None, + extra=extra, + unsupported=True, + message="today token fields unknown", + ) + return QuotaSnapshot( + ok=True, + channel=CHANNEL_ID, + account_id=account_id, + unit="token", + remaining=remaining, + extra=extra, + unsupported=False, + ) diff --git a/providers/qwenwork/__init__.py b/providers/qwenwork/__init__.py index da2fcb8..744d24a 100644 --- a/providers/qwenwork/__init__.py +++ b/providers/qwenwork/__init__.py @@ -91,43 +91,73 @@ def upsert_account(self, parsed: dict) -> dict: return store.upsert_account(parsed) async def fetch_quota(self, account: dict) -> QuotaSnapshot: - headers = openapi_headers() - access = str(account.get("access_token") or "") - if access: - headers["Authorization"] = f"Bearer {access}" - url = f"{GATEWAY}{ACCOUNT_CONTEXT_PATH}?include=user,plan,quota" + account_id = int(account.get("id") or 0) try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(url, headers=headers) + if is_token_expired(account): + account = await refresh_account(account) + response = await _account_context(account) + if response.status_code in {401, 403}: + first = _http_error_message(response) + try: + account = await refresh_account(account) + response = await _account_context(account) + except QwenWorkAuthError: + return QuotaSnapshot( + ok=False, + channel=self.id, + account_id=account_id, + unit="credit", + remaining=None, + message=f"{first};请在官方客户端登录后重新导入", + ) + if response.status_code >= 400: + return QuotaSnapshot( + ok=False, + channel=self.id, + account_id=account_id, + unit="credit", + remaining=None, + message=f"{first};请在官方客户端登录后重新导入", + ) + except QwenWorkAuthError as exc: + return QuotaSnapshot( + ok=False, + channel=self.id, + account_id=account_id, + unit="credit", + remaining=None, + message=str(exc)[:240], + ) except httpx.HTTPError as exc: return QuotaSnapshot( ok=False, channel=self.id, - account_id=int(account.get("id") or 0), - unit="unknown", + account_id=account_id, + unit="credit", remaining=None, - unsupported=False, message=str(exc)[:240], ) if response.status_code >= 400: return QuotaSnapshot( ok=False, channel=self.id, - account_id=int(account.get("id") or 0), - unit="unknown", + account_id=account_id, + unit="credit", remaining=None, - message=f"HTTP {response.status_code}", + message=_http_error_message(response), ) try: data = response.json() except ValueError: data = {} + if isinstance(data, dict) and isinstance(data.get("data"), dict): + data = data["data"] remaining = _quota_remaining(data) return QuotaSnapshot( ok=True, channel=self.id, - account_id=int(account.get("id") or 0), - unit="unknown" if remaining is None else "credit", + account_id=account_id, + unit="credit" if remaining is not None else "unknown", remaining=remaining, extra={"raw_keys": sorted(data.keys())[:12] if isinstance(data, dict) else []}, unsupported=remaining is None, @@ -141,20 +171,79 @@ async def refresh(self, account: dict) -> dict: return await refresh_account(account) +async def _account_context(account: dict): + headers = openapi_headers() + access = str(account.get("access_token") or "") + if access: + headers["Authorization"] = f"Bearer {access}" + url = f"{GATEWAY}{ACCOUNT_CONTEXT_PATH}?include=user,plan,quota" + async with httpx.AsyncClient(timeout=30.0) as client: + return await client.get(url, headers=headers) + + +def _http_error_message(response) -> str: + detail = "" + try: + payload = response.json() + except ValueError: + payload = {} + if isinstance(payload, dict): + detail = str(payload.get("errorMessage") or payload.get("errorCode") or payload.get("message") or "") + text = f"HTTP {response.status_code}" + if detail: + text += f" {detail}" + return text[:240] + + +_CREDIT_KEYS = ( + "remaining", + "remain", + "available", + "balance", + "credits", + "total_dosage", + "quota_remain", + "left_quota", +) + + +def _quota_number(value) -> float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + number = float(value) + if number > 10_000_000_000: + return None + return number + return None + + def _quota_remaining(data: dict) -> float | None: if not isinstance(data, dict): return None - quota = data.get("quota") if isinstance(data.get("quota"), dict) else data - for key in ("remaining", "remain", "available", "balance", "total_dosage"): - value = quota.get(key) if isinstance(quota, dict) else None - if isinstance(value, (int, float)): - return float(value) - plan = data.get("plan") if isinstance(data.get("plan"), dict) else {} - for key in ("remaining", "credits", "balance"): - value = plan.get(key) - if isinstance(value, (int, float)): - return float(value) - return None + found: list[float] = [] + + def walk(obj, depth: int = 0) -> None: + if depth > 6: + return + if isinstance(obj, list): + for item in obj[:24]: + walk(item, depth + 1) + return + if not isinstance(obj, dict): + return + lower = {str(key).lower(): value for key, value in obj.items()} + for key in _CREDIT_KEYS: + number = _quota_number(lower.get(key)) + if number is not None: + found.append(number) + return + for value in obj.values(): + if isinstance(value, (dict, list)): + walk(value, depth + 1) + + walk(data) + return found[0] if found else None PROVIDER = QwenWorkProvider() diff --git a/providers/qwenwork/chat.py b/providers/qwenwork/chat.py index 0876885..f4aac71 100644 --- a/providers/qwenwork/chat.py +++ b/providers/qwenwork/chat.py @@ -158,7 +158,7 @@ def build_body(payload: dict) -> tuple[dict, str, str]: "display_name": model, "model": "", "format": "openai", - "is_vl": model == "qwork-advanced", + "is_vl": model in {"pro", "qwork-advanced"}, "is_reasoning": is_reasoning, "api_key": "", "url": "", diff --git a/providers/qwenwork/constants.py b/providers/qwenwork/constants.py index 38a32a9..ece61e8 100644 --- a/providers/qwenwork/constants.py +++ b/providers/qwenwork/constants.py @@ -15,6 +15,7 @@ CHAT_QUERY = "FetchKeys=llm_model_result&AgentId=agent_common" REFRESH_PATH = "/api/v1/deviceToken/refresh" ACCOUNT_CONTEXT_PATH = "/api/v1/adapter/user/account-context" +MODELS_PATH = "/api/v2/model/list" IDE_VERSION = "0.1.8" RELEASE_VERSION = "0.1.8-26081406" @@ -38,6 +39,9 @@ -----END PUBLIC KEY-----""" STATIC_MODELS = ( + "pro", + "flash", + "qwen3.8-max-preview", "qwork-advanced", "qwork-auto", "qwork-lite", @@ -45,7 +49,8 @@ ) ALIASES = { - "auto": "qwork-advanced", + "auto": "pro", + "qwork-advanced": "pro", } RETRYABLE_STATUS = {408, 409, 425, 429, 500, 502, 503, 504} diff --git a/providers/qwenwork/models.py b/providers/qwenwork/models.py new file mode 100644 index 0000000..832eb45 --- /dev/null +++ b/providers/qwenwork/models.py @@ -0,0 +1,86 @@ +"""QwenWork supplier-list fetch. COSY GET with a plaintext query.""" + +from __future__ import annotations + +import time +import uuid + +import httpx + +from providers.qwenwork import cosy +from providers.qwenwork.chat import static_headers +from providers.qwenwork.constants import GATEWAY, MODELS_PATH, SCENE +from providers.qwenwork.token import QwenWorkAuthError + + +def parse_supplier_models(payload) -> list[dict]: + rows = _qwork_rows(payload) + models: list[dict] = [] + seen: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + if row.get("enable") is False or row.get("isEnabled") is False: + continue + mid = str(row.get("key") or row.get("value") or row.get("id") or row.get("modelId") or "").strip() + name = str(row.get("display_name") or row.get("displayName") or mid) + if not mid or mid in seen: + continue + seen.add(mid) + item = {"id": mid, "name": name or mid} + description = str(row.get("description") or "") + if description: + item["description"] = description + models.append(item) + return models + + +def _qwork_rows(payload) -> list: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + data = payload.get("data") if isinstance(payload.get("data"), (dict, list)) else payload + scene = data.get(SCENE) if isinstance(data, dict) else None + if isinstance(scene, list): + return scene + if isinstance(scene, dict): + for key in ("models", "list", "model_list"): + rows = scene.get(key) + if isinstance(rows, list): + return rows + if isinstance(data, dict): + for key in ("models", "model_list", "list"): + rows = data.get(key) + if isinstance(rows, list): + return rows + return [] + + +async def fetch_supplier_models(account: dict) -> list[dict]: + extra = account.get("extra") if isinstance(account.get("extra"), dict) else {} + url = f"{GATEWAY}{MODELS_PATH}" + request_id = uuid.uuid4().hex + headers = static_headers("pro", request_id, str(extra.get("login_device_id") or "")) + headers["Accept"] = "application/json" + headers.update( + cosy.auth_headers( + uid=str(account.get("uid") or extra.get("uid") or ""), + name=str(account.get("nickname") or account.get("name") or extra.get("name") or ""), + email=str(extra.get("email") or ""), + access_token=str(account.get("access_token") or ""), + url=url, + body="", + timestamp=int(time.time()), + request_id=request_id, + ) + ) + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + if response.status_code >= 400: + raise QwenWorkAuthError(f"models HTTP {response.status_code}") + try: + payload = response.json() + except ValueError as exc: + raise QwenWorkAuthError("models response is not JSON") from exc + return parse_supplier_models(payload) diff --git a/providers/qwenwork/store.py b/providers/qwenwork/store.py index a2148f0..45a94c4 100644 --- a/providers/qwenwork/store.py +++ b/providers/qwenwork/store.py @@ -252,12 +252,16 @@ def discover() -> dict: if not exists: continue count = 0 - for name in ("auth-v2.dat", "auth.dat"): - path = folder / name - if not path.is_file(): - continue + v2 = folder / "auth-v2.dat" + legacy = folder / "auth.dat" + # auth.dat is the old device-token file. Same uid as auth-v2.dat JWT; + # importing it last overwrites a working session and causes 401. + if v2.is_file(): count += 1 - files.append(_file_meta(path, existing)) + files.append(_file_meta(v2, existing)) + elif legacy.is_file(): + count += 1 + files.append(_file_meta(legacy, existing)) json_fallback = folder / "auth-v2.dat.json" if json_fallback.is_file(): count += 1 diff --git a/providers/workbuddy/models.py b/providers/workbuddy/models.py new file mode 100644 index 0000000..a790f56 --- /dev/null +++ b/providers/workbuddy/models.py @@ -0,0 +1,89 @@ +"""WorkBuddy supplier-list fetch. + +Official desktop CloudAgentService.listAvailableModels: +GET /v2/enterprises/personal/models, unwrap json.data ?? json, then data.models. +Skip rows whose tags include text-to-image. +""" + +from __future__ import annotations + +import httpx + +import auth_manager + +MODELS_PATH = "/v2/enterprises/personal/models" +_NON_CHAT_TAGS = frozenset({"text-to-image"}) + + +class WorkBuddyModelsError(ValueError): + """Supplier model list request failed.""" + + +def parse_supplier_models(payload) -> list[dict]: + models: list[dict] = [] + seen: set[str] = set() + for row in _model_rows(payload): + if not isinstance(row, dict): + continue + mid = row.get("id") + if not isinstance(mid, str): + continue + mid = mid.strip() + if not mid or mid in seen: + continue + if _has_non_chat_tag(row): + continue + seen.add(mid) + name = str(row.get("name") or row.get("display_name") or mid) + item = {"id": mid, "name": name or mid} + description = str(row.get("description") or "") + if description: + item["description"] = description + models.append(item) + return models + + +def _model_rows(payload) -> list: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + data = payload.get("data") if isinstance(payload.get("data"), (dict, list)) else payload + if isinstance(data, list): + return data + if isinstance(data, dict): + rows = data.get("models") + if isinstance(rows, list): + return rows + rows = payload.get("models") + return rows if isinstance(rows, list) else [] + + +def _has_non_chat_tag(row: dict) -> bool: + tags = row.get("tags") + if not isinstance(tags, list): + return False + return any(str(tag) in _NON_CHAT_TAGS for tag in tags) + + +async def fetch_supplier_models(account: dict) -> list[dict]: + headers = await auth_manager.get_billing_headers(account) + if not headers: + raise WorkBuddyModelsError("no usable WorkBuddy token") + headers = dict(headers) + headers.pop("Content-Type", None) + headers["Accept"] = "application/json" + url = f"{auth_manager.backend_url()}{MODELS_PATH}" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + if response.status_code >= 400: + raise WorkBuddyModelsError(f"models HTTP {response.status_code}") + try: + payload = response.json() + except ValueError as exc: + raise WorkBuddyModelsError("models response is not JSON") from exc + if isinstance(payload, dict): + code = payload.get("code") + if code not in (None, 0): + raise WorkBuddyModelsError(str(payload.get("msg") or payload.get("message") or code)) + return parse_supplier_models(payload) diff --git a/server.py b/server.py index e93b76c..84a7857 100644 --- a/server.py +++ b/server.py @@ -769,18 +769,24 @@ async def admin_account_resources( "message": "quota API not available", } snapshot = await fetch_quota(account) - unit = getattr(snapshot, "unit", "credit") or "credit" + extra = getattr(snapshot, "extra", None) or {} + if not isinstance(extra, dict): + extra = {} + unit = str(getattr(snapshot, "unit", "") or "unknown") remaining = getattr(snapshot, "remaining", None) - unsupported = bool(getattr(snapshot, "unsupported", False)) or unit != "credit" + unsupported = bool(getattr(snapshot, "unsupported", False)) credit_remaining = remaining if unit == "credit" and not unsupported else None return { "ok": bool(getattr(snapshot, "ok", False)), "account_id": aid, - "unit": "credit", - "remaining": credit_remaining, + "unit": unit, + "remaining": remaining, + "used": extra.get("used"), + "limit": extra.get("limit"), "total_dosage": credit_remaining, - "unsupported": unsupported or credit_remaining is None, - "message": getattr(snapshot, "message", "") or ("no credit balance" if credit_remaining is None else ""), + "available_total": credit_remaining, + "unsupported": unsupported, + "message": getattr(snapshot, "message", "") or "", "packages": [], } return await auth_manager.fetch_account_resources(account, force=bool(force)) diff --git a/tests/test_control_plane.py b/tests/test_control_plane.py index 07e3f6e..cd024cb 100644 --- a/tests/test_control_plane.py +++ b/tests/test_control_plane.py @@ -93,7 +93,7 @@ def test_credit_summary_has_null_total(isolated_db): assert any(item["id"] == "workbuddy" for item in payload["channels"]) -def test_credit_summary_qclaw_omits_token_cap(isolated_db, monkeypatch): +def test_credit_summary_qclaw_uses_token_unit(isolated_db, monkeypatch): monkeypatch.setenv("CB_GATEWAY_PROVIDERS", "workbuddy,qclaw") db.add_account( { @@ -104,11 +104,31 @@ def test_credit_summary_qclaw_omits_token_cap(isolated_db, monkeypatch): "status": "active", } ) + from providers.protocol import QuotaSnapshot + from providers.qclaw import PROVIDER + + async def fake_quota(account): + return QuotaSnapshot( + ok=True, + channel="qclaw", + account_id=int(account.get("id") or 0), + unit="token", + remaining=40, + extra={"used": 10, "limit": 50}, + unsupported=False, + ) + + monkeypatch.setattr(PROVIDER, "fetch_quota", fake_quota) payload = asyncio.run(control_plane.credit_summary()) + assert payload["total_balance"] is None qclaw = next(item for item in payload["channels"] if item["id"] == "qclaw") - assert qclaw["unit"] == "credit" - assert qclaw["remaining"] is None - assert qclaw["unsupported"] is True + assert qclaw["unit"] == "token" + assert qclaw["remaining"] == 40 + assert qclaw["used"] == 10 + assert qclaw["limit"] == 50 + assert qclaw["unsupported"] is False + workbuddy = next(item for item in payload["channels"] if item["id"] == "workbuddy") + assert workbuddy["unit"] == "credit" def test_startup_does_not_import_by_default(isolated_db, monkeypatch): diff --git a/tests/test_models_refresh.py b/tests/test_models_refresh.py index ec48f32..ffb148f 100644 --- a/tests/test_models_refresh.py +++ b/tests/test_models_refresh.py @@ -8,7 +8,6 @@ import credential_crypto import database as db import providers -import proxy import router import server from providers.protocol import KeyChannelMismatch, UnknownModel @@ -17,6 +16,8 @@ from providers.traework.constants import STATIC_MODELS as TRAE_STATIC QCLAW_NEW_ID = "qclaw-live-only-model" +QWEN_NEW_ID = "qwen-live-only-model" +WB_NEW_ID = "wb-live-only-model" TRAE_NEW_DOUBAO = "Doubao-Seed-2.2-Pro" TRAE_DOUBAO_TURBO = "Doubao-Seed-2.1-Turbo" TRAE_DOUBAO_CODE = "Doubao-Seed-2.0-Code" @@ -37,6 +38,28 @@ }, } +QWENWORK_HTTP_PAYLOAD = { + "qwork": [ + {"key": "pro", "display_name": "高级", "enable": True}, + {"key": QWEN_NEW_ID, "display_name": "Qwen Live Only", "enable": True}, + {"key": "disabled-model", "display_name": "Hidden", "enable": False}, + ] +} + +WORKBUDDY_HTTP_PAYLOAD = { + "code": 0, + "msg": "ok", + "data": { + "models": [ + {"id": "auto", "name": "Auto", "tags": ["craft"]}, + {"id": "glm-5.2", "name": "GLM-5.2", "tags": ["craft"]}, + {"id": WB_NEW_ID, "name": "WB Live Only", "tags": []}, + {"id": "hunyuan-image-v3.0", "name": "Hunyuan Image V3", "tags": ["text-to-image"]}, + {"id": 123, "name": "numeric-id-skipped"}, + ] + }, +} + TRAEWORK_HTTP_PAYLOAD = { "code": 0, "message": "success", @@ -90,6 +113,16 @@ def all_channels(monkeypatch): def _seed_live_accounts(): + db.add_account( + { + "name": "wb", + "uid": "wb-1", + "provider": "workbuddy", + "status": "active", + "access_token": "tok-wb", + "expires_at": 9_999_999_999_999, + } + ) db.add_account( { "name": "qc", @@ -112,6 +145,16 @@ def _seed_live_accounts(): "extra": {"device_id": "dev-1"}, } ) + db.add_account( + { + "name": "qw", + "uid": "qw-1", + "provider": "qwenwork", + "status": "active", + "access_token": "tok-qwen", + "extra": {"login_device_id": "dev-qw", "email": "a@b"}, + } + ) def _install_supplier_http(monkeypatch): @@ -137,10 +180,16 @@ async def get(self, url, **kwargs): requested.append(("GET", str(url))) if "/api/remote/v1/models" in str(url): return _FakeResponse(TRAEWORK_HTTP_PAYLOAD) + if "/api/v2/model/list" in str(url): + return _FakeResponse(QWENWORK_HTTP_PAYLOAD) + if "/v2/enterprises/personal/models" in str(url): + return _FakeResponse(WORKBUDDY_HTTP_PAYLOAD) raise AssertionError(f"unexpected GET {url}") monkeypatch.setattr("providers.qclaw.jprx.httpx.AsyncClient", FakeAsyncClient) monkeypatch.setattr("providers.traework.models.httpx.AsyncClient", FakeAsyncClient) + monkeypatch.setattr("providers.qwenwork.models.httpx.AsyncClient", FakeAsyncClient) + monkeypatch.setattr("providers.workbuddy.models.httpx.AsyncClient", FakeAsyncClient) return requested @@ -180,8 +229,10 @@ def test_supplier_catalog_refresh_keeps_channels_distinct(isolated_db, all_chann assert QCLAW_NEW_ID not in _ids(qclaw.list_models()) assert TRAE_NEW_DOUBAO not in _ids(traework.list_models()) + assert WB_NEW_ID not in _ids(workbuddy.list_models()) assert not qclaw.accepts_model(QCLAW_NEW_ID) assert not traework.accepts_model(TRAE_NEW_DOUBAO) + assert not workbuddy.accepts_model(WB_NEW_ID) _seed_live_accounts() requested = _install_supplier_http(monkeypatch) @@ -201,36 +252,48 @@ async def boom_chat(payload, api_key_info): assert sources["qclaw"]["mode"] == "live" assert sources["traework"]["mode"] == "live" - assert sources["workbuddy"]["mode"] == "fallback" - assert sources["workbuddy"]["message"] == "no supplier-list API" - assert sources["qwenwork"]["mode"] == "fallback" - assert sources["qwenwork"]["message"] == "no supplier-list API" + assert sources["workbuddy"]["mode"] == "live" + assert sources["qwenwork"]["mode"] == "live" assert QCLAW_NEW_ID in _ids(sources["qclaw"]["models"]) assert TRAE_NEW_DOUBAO in _ids(sources["traework"]["models"]) assert TRAE_DOUBAO_TURBO in _ids(sources["traework"]["models"]) wb_ids = _ids(sources["workbuddy"]["models"]) + assert WB_NEW_ID in wb_ids + assert "glm-5.2" in wb_ids + assert "auto" in wb_ids + assert "hunyuan-image-v3.0" not in wb_ids assert QCLAW_NEW_ID not in wb_ids assert TRAE_NEW_DOUBAO not in wb_ids assert TRAE_DOUBAO_TURBO not in wb_ids - assert wb_ids == _ids(proxy.DEFAULT_MODELS) qwen_ids = _ids(sources["qwenwork"]["models"]) - assert qwen_ids == set(QWEN_STATIC) + assert "pro" in qwen_ids + assert QWEN_NEW_ID in qwen_ids + assert "disabled-model" not in qwen_ids assert TRAE_NEW_DOUBAO not in qwen_ids assert QCLAW_NEW_ID not in qwen_ids assert any("/data/4320/forward" in url for method, url in requested if method == "POST") assert any("/api/remote/v1/models" in url for method, url in requested if method == "GET") - assert not any("copilot.tencent.com" in url for _, url in requested) - assert not any("qwenwork.cn" in url for _, url in requested) + assert any("/api/v2/model/list" in url for method, url in requested if method == "GET") + assert any("/v2/enterprises/personal/models" in url for method, url in requested if method == "GET") + copilot_urls = [url for _, url in requested if "copilot.tencent.com" in url] + assert copilot_urls + assert all("/v2/enterprises/personal/models" in url for url in copilot_urls) assert QCLAW_NEW_ID in _ids(qclaw.list_models()) assert TRAE_NEW_DOUBAO in _ids(traework.list_models()) + assert QWEN_NEW_ID in _ids(qwenwork.list_models()) + assert WB_NEW_ID in _ids(workbuddy.list_models()) assert qclaw.accepts_model(QCLAW_NEW_ID) assert traework.accepts_model(TRAE_NEW_DOUBAO) + assert qwenwork.accepts_model(QWEN_NEW_ID) + assert qwenwork.accepts_model("pro") assert traework.accepts_model(TRAE_DOUBAO_TURBO) + assert workbuddy.accepts_model(WB_NEW_ID) + assert not workbuddy.accepts_model("hunyuan-image-v3.0") assert not workbuddy.accepts_model(TRAE_NEW_DOUBAO) assert not workbuddy.accepts_model(TRAE_DOUBAO_TURBO) assert not qclaw.accepts_model(TRAE_NEW_DOUBAO) @@ -272,6 +335,10 @@ def attempt_chat(payload, key): assert "glm-5.2" in by_id assert by_id["glm-5.2"]["channel"] == "workbuddy" assert by_id["workbuddy/glm-5.2"]["channel"] == "workbuddy" + assert WB_NEW_ID in by_id + assert by_id[WB_NEW_ID]["channel"] == "workbuddy" + assert by_id["workbuddy/" + WB_NEW_ID]["channel"] == "workbuddy" + assert "hunyuan-image-v3.0" not in by_id evidence = os.environ.get("BUDDY2API_EVIDENCE_DIR") if evidence: @@ -282,6 +349,15 @@ def attempt_chat(payload, key): ) +def test_parse_workbuddy_supplier_models_skips_image_and_non_string_ids(): + from providers.workbuddy.models import parse_supplier_models + + models = parse_supplier_models(WORKBUDDY_HTTP_PAYLOAD) + ids = {item["id"] for item in models} + assert ids == {"auto", "glm-5.2", WB_NEW_ID} + assert next(item["name"] for item in models if item["id"] == WB_NEW_ID) == "WB Live Only" + + def test_manual_add_goes_to_selected_channel(isolated_db, all_channels): import catalog diff --git a/tests/test_qclaw.py b/tests/test_qclaw.py index 6d7c28a..c4051fd 100644 --- a/tests/test_qclaw.py +++ b/tests/test_qclaw.py @@ -33,11 +33,34 @@ def qclaw_enabled(monkeypatch): monkeypatch.delenv("CB_GATEWAY_PROVIDERS", raising=False) -def test_qclaw_quota_is_credit_not_token_cap(qclaw_enabled): +def test_parse_today_tokens_used_and_limit(): + from providers.qclaw.quota import parse_today_tokens + + used, limit, remaining = parse_today_tokens({"today_used": 12, "today_limit": 100}) + assert used == 12 + assert limit == 100 + assert remaining == 88 + used, limit, remaining = parse_today_tokens( + {"daily_token_limit": 40_000_000, "daily_token_used": 0, "rpm_limit": 60} + ) + assert (used, limit, remaining) == (0, 40_000_000, 40_000_000) + used, limit, remaining = parse_today_tokens({"data": {"used": 3, "quota": 10}}) + assert (used, limit, remaining) == (3, 10, 7) + used, limit, remaining = parse_today_tokens({"foo": 1}) + assert used is None and limit is None and remaining is None + + +def test_qclaw_quota_uses_token_unit(qclaw_enabled, monkeypatch): + async def fake_today_tokens(account): + return {"used": 20, "limit": 80} + + monkeypatch.setattr("providers.qclaw.quota.today_tokens", fake_today_tokens) snapshot = asyncio.run(providers.get_provider("qclaw").fetch_quota({"id": 1})) - assert snapshot.unit == "credit" - assert snapshot.remaining is None - assert snapshot.unsupported is True + assert snapshot.unit == "token" + assert snapshot.remaining == 60 + assert snapshot.unsupported is False + assert snapshot.extra["used"] == 20 + assert snapshot.extra["limit"] == 80 def test_qclaw_in_default_registry(monkeypatch): diff --git a/tests/test_qwenwork.py b/tests/test_qwenwork.py index 62bf416..e610c03 100644 --- a/tests/test_qwenwork.py +++ b/tests/test_qwenwork.py @@ -103,6 +103,16 @@ def test_parse_credentials_requires_token(): parse_credentials({"user": {"id": "1"}}) +def test_qwenwork_quota_remaining_nested(): + from providers.qwenwork import _quota_remaining + + assert _quota_remaining({"quota": {"remaining": 88}}) == 88 + assert _quota_remaining({"plan": {"credits": 12}}) == 12 + assert _quota_remaining({"data": {"user": {}, "quota": {"balance": 3.5}}}) == 3.5 + assert _quota_remaining({"code": 0, "data": {"quota": {"remaining": 2098.8}}}) == 2098.8 + assert _quota_remaining({"foo": 1}) is None + + def test_bind_qwenwork_when_enabled(qwen_enabled): bound = router.bind({"model": "auto"}, {"default_channel": "qwenwork"}) assert bound.channel == "qwenwork" @@ -157,8 +167,28 @@ def test_unwrap_outer_sse_envelope(): assert unwrap_sse_payload(inner) == [inner] -def test_static_models_match_official_0_1_8(): - assert STATIC_MODELS == ("qwork-advanced", "qwork-auto", "qwork-lite", "qmodel_latest") +def test_static_models_include_live_and_legacy_ids(): + assert "pro" in STATIC_MODELS + assert "flash" in STATIC_MODELS + assert "qwen3.8-max-preview" in STATIC_MODELS + assert "qwork-advanced" in STATIC_MODELS + + +def test_parse_qwenwork_supplier_models(): + from providers.qwenwork.models import parse_supplier_models + + models = parse_supplier_models( + { + "qwork": [ + {"key": "pro", "display_name": "高级", "enable": True}, + {"key": "flash", "display_name": "标准", "enable": True}, + {"key": "hidden", "display_name": "x", "enable": False}, + ] + } + ) + ids = {item["id"] for item in models} + assert ids == {"pro", "flash"} + assert next(item["name"] for item in models if item["id"] == "pro") == "高级" @pytest.mark.parametrize( diff --git a/tests/test_traework.py b/tests/test_traework.py index 61cace4..df116b7 100644 --- a/tests/test_traework.py +++ b/tests/test_traework.py @@ -63,7 +63,7 @@ def test_parse_credentials_requires_token(): parse_credentials({"account": {"username": "x"}}) -def test_bind_traework_when_enabled(traework_enabled): +def test_bind_traework_when_enabled(isolated_db, traework_enabled): bound = router.bind({"model": "auto"}, {"default_channel": "traework"}) assert bound.channel == "traework" assert bound.inner == "auto" diff --git a/version.py b/version.py index 5dfae46..b82fac5 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "2.1.4" +VERSION = "2.1.5" diff --git a/web/index.html b/web/index.html index cbaf6a8..aa10b65 100644 --- a/web/index.html +++ b/web/index.html @@ -388,7 +388,7 @@ template:`
-
B2
Buddy 2 API
Local model gateway · v2.1.4
+
B2
Buddy 2 API
Local model gateway · v2.1.5
@@ -412,6 +412,8 @@ function tok(v){v=Number(v||0);if(v>=1e9)return (v/1e9).toFixed(v>=1e10?1:2).replace(/\.?0+$/,'')+'B';if(v>=1e6)return (v/1e6).toFixed(v>=1e7?1:2).replace(/\.?0+$/,'')+'M';return v.toLocaleString()} function pct(v){return (Number(v||0)).toFixed(Number(v||0)%1?1:0)+'%'} function money(v){return Number(v||0).toFixed(4).replace(/\.?0+$/,'')} + function channelUnitLabel(ch){const u=ch&&ch.unit||'';if(u==='token')return '今日 token';if(u==='credit')return '积分';return '额度'} + function channelQuotaText(ch){if(!ch)return '—';if(ch.unit==='token'){if(ch.used!=null&&ch.limit!=null)return tok(ch.used)+' / '+tok(ch.limit);if(ch.remaining!=null)return tok(ch.remaining);return '—'}if(ch.remaining!=null)return money(ch.remaining);return '—'} function ms(v){v=Number(v||0);return v>=1000?(v/1000).toFixed(1)+'s':v+'ms'} function fmt(t){return t?new Date(t*1000).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-'} function healthClass(){if(!s.value?.active_accounts||!s.value?.active_keys)return 'err';if((s.value?.today?.errors||0)>0||s.value?.filtered_requests>0)return 'warn';return ''} @@ -433,7 +435,7 @@ function points(kind){const key=kind==='credit'?'credits':kind;const values=daily.map(d=>({date:d.date,value:Number(d[key]||0)}));return{values,max:Math.max(...values.map(x=>x.value),1)}} const creditPoints=points('credit'),requestPoints=points('requests'),tokenPoints=points('tokens'); return[ - {kind:'credit',label:'今日额度消耗',value:money(t.credit),unit:'Credit',meta:n(requests)+' 次调用 · 单次 '+money(requests?Number(t.credit||0)/requests:0),foot:credit.value?.channels?.length?credit.value.channels.map(c=>c.id+' '+(c.unit==='credit'&&c.remaining!=null?money(c.remaining):'—')).join(' · '):'累计 '+money(s.value?.total_credit),footLabel:credit.value?.channels?.length?'按通道积分':'累计消耗',icon:I.wallet,...creditPoints}, + {kind:'credit',label:'今日额度消耗',value:money(t.credit),unit:'Credit',meta:n(requests)+' 次调用 · 单次 '+money(requests?Number(t.credit||0)/requests:0),foot:credit.value?.channels?.length?credit.value.channels.map(c=>c.id+' '+channelQuotaText(c)).join(' · '):'累计 '+money(s.value?.total_credit),footLabel:credit.value?.channels?.length?'按通道额度':'累计消耗',icon:I.wallet,...creditPoints}, {kind:'requests',label:'今日调用次数',value:n(requests),unit:'次',meta:'成功 '+n(t.success)+' · 异常 '+n(Number(t.errors||0)+Number(t.filtered||0)),foot:pct(t.success_rate),footLabel:'成功率',icon:I.activity,...requestPoints}, {kind:'tokens',label:'今日 Token',value:tok(t.tokens),unit:'Tokens',meta:'单次平均 '+tok(requests?Math.round(Number(t.tokens||0)/requests):0),foot:'累计 '+tok(s.value?.total_tokens),footLabel:'全部时间',icon:I.tokens,...tokenPoints}, ] @@ -450,7 +452,7 @@ function sparkHeight(card,v){v=Number(v||0);return(v?Math.max(7,Math.round(v/Math.max(card.max,1)*36)):4)+'px'} function hourBarHeight(v,max){v=Number(v||0);return(v?Math.max(6,Math.round(v/Math.max(max,1)*156)):3)+'px'} onMounted(load); - return{s,credit,ld,cld,copied,err,updatedAt,todayLabel,todayUsage,todayChartMetric,todayChart,load,refreshCredit,n,tok,pct,money,ms,fmt,healthClass,healthText,rateWidth,modelPct,copy,age,expireMeta,mx,heatRows,heatStyle,heatValue,sparkHeight,hourBarHeight,I} + return{s,credit,ld,cld,copied,err,updatedAt,todayLabel,todayUsage,todayChartMetric,todayChart,load,refreshCredit,n,tok,pct,money,ms,fmt,healthClass,healthText,rateWidth,modelPct,copy,age,expireMeta,mx,heatRows,heatStyle,heatValue,sparkHeight,hourBarHeight,channelUnitLabel,channelQuotaText,I} },template:`

运行总览

网关状态、额度和调用强度

@@ -501,7 +503,7 @@
官方额度概览{{credit.ok_accounts}}/{{credit.active_accounts}} 账号已读取 · 缓存 {{credit.stale_accounts}}
-
{{ch.display_name||ch.id}}
{{ch.unit==='credit'&&ch.remaining!=null?money(ch.remaining):'—'}}
积分 · {{ch.accounts||0}} 账号{{ch.unsupported?' · 无积分接口':''}}
+
{{ch.display_name||ch.id}}
{{channelQuotaText(ch)}}
{{channelUnitLabel(ch)}} · {{ch.accounts||0}} 账号{{ch.unsupported?' · 未读取':''}}
官方额度
按通道分别统计,不跨厂加总
7 天内到期
{{money(credit.expiring_7d_total)}}
仅 WorkBuddy
30 天内到期
{{money(credit.expiring_30d_total)}}
{{credit.package_count}} 个额度包
@@ -640,8 +642,12 @@ function tok(v){v=Number(v||0);if(v>=1e9)return (v/1e9).toFixed(v>=1e10?1:2).replace(/\.?0+$/,'')+'B';if(v>=1e6)return (v/1e6).toFixed(v>=1e7?1:2).replace(/\.?0+$/,'')+'M';return v.toLocaleString()} function creditPct(a){return Math.max(0,Math.min(100,Number(a.credit_used_pct||0)))+'%'} function claimText(r){if(r.claimed)return '已领取 '+credit(r.credit);if(r.already_claimed)return '今日已领';return r.message||'失败'} - function officialBalance(a){const r=a.official_resource;if(!r||!r.ok||r.unsupported)return null;if((a.provider||'workbuddy')!=='workbuddy'&&r.unit&&r.unit!=='credit')return null;const v=r.total_dosage??r.available_total??r.remaining;if(v==null||v==='')return null;return Number(v)} - function officialMeta(a){const r=a.official_resource;if(!r)return '未刷新';if(r.unsupported)return '无积分接口';if(!r.ok)return r.message||'加载失败';if((a.provider||'workbuddy')!=='workbuddy')return '积分';return '30 天内到期 '+credit(r.expiring_30d_total)+' · '+(r.package_count||0)+' 包'+(r.stale?' · 旧缓存':'')} + function officialUnit(a){return a.official_resource?.unit||''} + function officialBalance(a){const r=a.official_resource;if(!r||!r.ok||r.unsupported)return null;const v=r.remaining??r.total_dosage??r.available_total;if(v==null||v==='')return null;return Number(v)} + function officialHasValue(a){const r=a.official_resource;if(!r||!r.ok||r.unsupported)return false;if(officialUnit(a)==='token')return r.used!=null||r.limit!=null||r.remaining!=null;return officialBalance(a)!==null} + function officialMain(a){if(busyKey(a.id,'resource'))return '读取中';const r=a.official_resource;if(!r)return '未刷新';if(!r.ok)return '失败';if(r.unsupported)return '未读取';if(officialUnit(a)==='token'){if(r.used!=null&&r.limit!=null)return tok(r.used)+' / '+tok(r.limit);if(r.remaining!=null)return tok(r.remaining)+' 剩余'}const v=officialBalance(a);return v!==null?credit(v):'未读取'} + function officialUnitRank(a){if(!officialHasValue(a))return 9;const u=officialUnit(a);if(u==='credit')return 0;if(u==='token')return 1;return 2} + function officialMeta(a){const r=a.official_resource;if(!r)return '未刷新';if(r.unsupported)return officialUnit(a)==='token'?'无今日 token 数字':'无积分数字';if(!r.ok)return r.message||'加载失败';if(officialUnit(a)==='token')return '今日 token';if((a.provider||'workbuddy')!=='workbuddy')return '积分';return '30 天内到期 '+credit(r.expiring_30d_total)+' · '+(r.package_count||0)+' 包'+(r.stale?' · 旧缓存':'')} function cacheAge(a){const r=a.official_resource;if(!r)return '';const v=Number(r.age_seconds||0);if(v<60)return v+'s 前';if(v<3600)return Math.floor(v/60)+'m 前';return Math.floor(v/3600)+'h 前'} function officialWarn(a){return Number(a.official_resource?.expiring_30d_total||0)>0} function checkinOf(a){return checkins.value[a.id]||null} @@ -662,7 +668,7 @@ }); rows=[...rows]; rows.sort((a,b)=>{ - if(filters.sort==='balance')return (officialBalance(b)??-1)-(officialBalance(a)??-1); + if(filters.sort==='balance'){const ra=officialUnitRank(a),rb=officialUnitRank(b);if(ra!==rb)return ra-rb;return (officialBalance(b)??-1)-(officialBalance(a)??-1)} if(filters.sort==='used')return Number(b.total_credits||0)-Number(a.total_credits||0); if(filters.sort==='requests')return Number(b.total_requests||0)-Number(a.total_requests||0); if(filters.sort==='expire')return Number(a.official_resource?.next_expire_ts||9999999999)-Number(b.official_resource?.next_expire_ts||9999999999); @@ -674,7 +680,7 @@ function expireText(x){if(x.expired)return '已过期';if(x.days_to_expire===null||x.days_to_expire===undefined)return x.expire_time||'长期';if(x.days_to_expire<0)return '已过期';if(x.days_to_expire<=7)return x.days_to_expire+' 天内';return shortTime(x.expire_time)} function pkgBadge(x){if(x.expired)return 'err';if(Number(x.days_to_expire)>=0&&Number(x.days_to_expire)<=7)return 'inactive';return 'ok'} function clearPath(){authPath.value='';discover('')} - onMounted(()=>{loadChannels();loadCatalogs();load(true);discover()});return{l,visibleAccounts,filters,ld,sa,ai,nm,disc,dl,scanning,adding,authPath,test,testModels,claim,pkg,tl,claimingAll,officialRefreshing,checkins,checkinSummary,checkinLoading,busyKey,dirty,load,loadCheckins,discover,scan,scanCustom,add,ref2,saveMeta,toggle,openTest,closeTest,runTest,claimOne,claimAll,refreshAllResources,openPackages,refreshPackages,del,fmt,size,credit,tok,creditPct,claimText,officialBalance,officialMeta,cacheAge,officialWarn,checkinClass,checkinText,tokenLife,shortTime,expireText,pkgBadge,clearPath,I,discChannel,channels} + onMounted(()=>{loadChannels();loadCatalogs();load(true);discover()});return{l,visibleAccounts,filters,ld,sa,ai,nm,disc,dl,scanning,adding,authPath,test,testModels,claim,pkg,tl,claimingAll,officialRefreshing,checkins,checkinSummary,checkinLoading,busyKey,dirty,load,loadCheckins,discover,scan,scanCustom,add,ref2,saveMeta,toggle,openTest,closeTest,runTest,claimOne,claimAll,refreshAllResources,openPackages,refreshPackages,del,fmt,size,credit,tok,creditPct,claimText,officialBalance,officialHasValue,officialMain,officialMeta,cacheAge,officialWarn,checkinClass,checkinText,tokenLife,shortTime,expireText,pkgBadge,clearPath,I,discChannel,channels} },template:`

账号管理

粘性主账号 · 失败自动切换

@@ -730,12 +736,12 @@

自定义路径

可领 {{checkinSummary.available}} · 已领 {{checkinSummary.already_claimed}}{{visibleAccounts.length}}/{{l.length}}个 · {{l.filter(a=>a.status==='active').length}}活跃
-
账号列表官方额度来自 Work Buddy 资源接口;每日领取的 150 按官方返回的约 1 个月到期时间展示
当前 {{visibleAccounts.length}} 条
- +
账号列表同一列按通道单位展示:积分与 QClaw 今日 token 不混加;到期包仅 WorkBuddy
当前 {{visibleAccounts.length}} 条
账号通道UID状态今日领取权重优先级官方余额即将到期本地估算本地快照Token 有效期请求Token累计已用
{{a.nickname||a.name}} 未保存{{a.provider||'workbuddy'}}{{a.uid?.slice(0,8)}}…{{a.status}}{{checkinText(a)}}
旧缓存
{{credit(officialBalance(a))}}{{busyKey(a.id,'resource')?'读取中':(a.official_resource?.unsupported?'无积分':(a.official_resource&&!a.official_resource.ok?'失败':'未刷新'))}}
{{officialMeta(a)}}
缓存 {{cacheAge(a)}}
{{credit(a.official_resource?.expiring_30d_total)}}{{shortTime(a.official_resource.next_expire_time)}}
最近到期 {{credit(a.official_resource?.next_expire_amount)}} · {{a.official_resource?.next_expire_days??'-'}} 天
{{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}}
{{a.credit_snapshot>0?'快照后已用 '+credit(a.credit_since_snapshot):'官方失败时可手动校准'}}
{{tokenLife(a)}}{{a.total_requests}}{{tok(a.total_tokens)}}{{credit(a.total_credits)}}
+
账号通道UID状态今日领取权重优先级官方余额即将到期本地估算本地快照Token 有效期请求Token累计已用
{{a.nickname||a.name}} 未保存{{a.provider||'workbuddy'}}{{a.uid?.slice(0,8)}}…{{a.status}}{{checkinText(a)}}
旧缓存
{{officialMain(a)}}
{{officialMeta(a)}}
缓存 {{cacheAge(a)}}
{{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}}
{{a.credit_snapshot>0?'快照后已用 '+credit(a.credit_since_snapshot):'官方失败时可手动校准'}}
{{tokenLife(a)}}{{a.total_requests}}{{tok(a.total_tokens)}}{{credit(a.total_credits)}}
没有匹配的账号
🔌

暂无账号 · 先使用上方本机登录检测导入

-
+