Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、数据库文件发给别人。

## 这是什么?

Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
20 changes: 18 additions & 2 deletions catalog.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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",
Expand Down
30 changes: 25 additions & 5 deletions control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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())
Expand Down
32 changes: 32 additions & 0 deletions docs/releases/v2.1.5.md
Original file line number Diff line number Diff line change
@@ -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` 可用。
2 changes: 1 addition & 1 deletion providers/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 2 additions & 11 deletions providers/qclaw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
136 changes: 136 additions & 0 deletions providers/qclaw/quota.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading