From 6078d424541c6212d955e44fa1497aa25a0ee8ae Mon Sep 17 00:00:00 2001 From: wicm84266964 Date: Mon, 7 Sep 2026 17:09:03 +0800 Subject: [PATCH 1/3] fix: show per-channel official quota and import QwenWork JWT Keep one official-balance column but label credit vs QClaw daily tokens. Parse jprx 4075 daily_token_used/limit. Prefer auth-v2.dat over legacy auth.dat so QwenWork quota uses the JWT instead of a 401 device token. --- control_plane.py | 30 +++++-- providers/protocol.py | 2 +- providers/qclaw/__init__.py | 13 +-- providers/qclaw/quota.py | 136 +++++++++++++++++++++++++++++++ providers/qwenwork/__init__.py | 141 +++++++++++++++++++++++++++------ providers/qwenwork/store.py | 14 ++-- server.py | 18 +++-- tests/test_control_plane.py | 28 ++++++- tests/test_qclaw.py | 31 +++++++- tests/test_qwenwork.py | 10 +++ web/index.html | 26 +++--- 11 files changed, 377 insertions(+), 72 deletions(-) create mode 100644 providers/qclaw/quota.py 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/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/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/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_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..06723c2 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" diff --git a/web/index.html b/web/index.html index cbaf6a8..05acf03 100644 --- a/web/index.html +++ b/web/index.html @@ -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)}}
没有匹配的账号
🔌

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

-
+