diff --git a/README.md b/README.md index a1a0069..6551ef9 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.2**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 +当前版本 **2.1.3**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 ## 这是什么? diff --git a/README_EN.md b/README_EN.md index 89282fc..2dbc2b9 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.2**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. +Release **2.1.3**. 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 2f63201..f795612 100644 --- a/catalog.py +++ b/catalog.py @@ -15,10 +15,15 @@ CATALOG_SETTING = "channel_catalogs" REFRESH_SETTING = "channel_catalog_refresh" +EXTRAS_SETTING = "channel_catalog_extras" Fetcher = Callable[[dict], Awaitable[list[dict]]] +class CatalogError(ValueError): + """Invalid channel or model id for a catalog write.""" + + def _load_map(key: str) -> dict: try: value = db.get_setting(key, {}) or {} @@ -72,11 +77,155 @@ def normalize_models(rows: Any) -> list[dict]: return models +def extras_for(channel: str) -> list[dict]: + items = _load_map(EXTRAS_SETTING).get(channel) + if isinstance(items, list) and items: + return normalize_models(items) + return [] + + +def save_extras(channel: str, models: list[dict]) -> None: + extras = _load_map(EXTRAS_SETTING) + extras[channel] = normalize_models(models) + db.set_setting(EXTRAS_SETTING, extras) + + +def _merge_models(base: list[dict], extra: list[dict]) -> list[dict]: + return normalize_models(list(base) + list(extra)) + + def models_for(channel: str, fallback: list[dict]) -> list[dict]: stored = stored_catalog(channel) - if stored: - return stored - return list(fallback) + base = stored if stored else list(fallback) + return _merge_models(base, extras_for(channel)) + + +def _with_manual(channel: str, models: list[dict]) -> list[dict]: + extra_ids = {str(item.get("id")) for item in extras_for(channel)} + annotated = [] + for item in models: + row = dict(item) + row["manual"] = str(row.get("id") or "") in extra_ids + annotated.append(row) + return annotated + + +def _normalize_model_id(channel: str, model_id: str) -> str: + mid = str(model_id or "").strip() + prefix = f"{channel}/" + if mid.startswith(prefix): + mid = mid[len(prefix) :].strip() + return mid + + +def _require_enabled_channel(channel: str) -> str: + import providers + + value = str(channel or "").strip() + if not value or not providers.is_channel_enabled(value): + raise CatalogError("unknown or disabled channel") + return value + + +def current_models(channel: str) -> list[dict]: + import providers + + provider = providers.get_provider(channel) + if provider is not None: + return list(provider.list_models()) + if channel == "workbuddy": + return workbuddy_fallback_models() + return extras_for(channel) + + +def upsert_model(channel: str, model_id: str, name: str = "") -> dict: + channel = _require_enabled_channel(channel) + mid = _normalize_model_id(channel, model_id) + if not mid: + raise CatalogError("model id is required") + label = str(name or "").strip() or mid + current = current_models(channel) + extra_ids = {str(item.get("id")) for item in extras_for(channel)} + current_ids = {str(item.get("id")) for item in current if isinstance(item, dict)} + if mid in current_ids and (channel == "workbuddy" or mid not in extra_ids): + if channel == "workbuddy": + models = [] + for item in current: + row = dict(item) if isinstance(item, dict) else {"id": str(item), "name": str(item)} + if str(row.get("id")) == mid: + row["name"] = label + models.append(row) + db.set_setting("models", models) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": models, + "updated": True, + } + raise CatalogError("model already exists in this channel") + if channel == "workbuddy": + models = [dict(item) if isinstance(item, dict) else {"id": str(item), "name": str(item)} for item in current] + models.append({"id": mid, "name": label}) + db.set_setting("models", models) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": models, + "updated": False, + } + extras = extras_for(channel) + found = False + for item in extras: + if item.get("id") == mid: + item["name"] = label + found = True + break + if not found: + extras.append({"id": mid, "name": label}) + save_extras(channel, extras) + models = current_models(channel) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": _with_manual(channel, models), + "updated": found, + } + + +def remove_model(channel: str, model_id: str) -> dict: + channel = _require_enabled_channel(channel) + mid = _normalize_model_id(channel, model_id) + if not mid: + raise CatalogError("model id is required") + if channel == "workbuddy": + current = workbuddy_fallback_models() + models = [ + item + for item in current + if str((item.get("id") if isinstance(item, dict) else item) or "") != mid + ] + if len(models) == len(current): + raise CatalogError("model not found") + db.set_setting("models", models) + return {"channel": channel, "id": mid, "count": len(models), "models": models} + extras = extras_for(channel) + kept = [item for item in extras if item.get("id") != mid] + if len(kept) == len(extras): + raise CatalogError("not a manually added model") + save_extras(channel, kept) + models = current_models(channel) + return { + "channel": channel, + "id": mid, + "count": len(models), + "models": _with_manual(channel, models), + } def workbuddy_fallback_models() -> list[dict]: @@ -92,17 +241,14 @@ def workbuddy_fallback_models() -> list[dict]: def _fallback_models(channel: str, provider) -> list[dict]: - if channel == "workbuddy": - return workbuddy_fallback_models() if provider is not None: - stored = stored_catalog(channel) - if stored: - return stored try: return list(provider.list_models()) except Exception: pass - return [] + if channel == "workbuddy": + return workbuddy_fallback_models() + return extras_for(channel) def _status_row( @@ -119,7 +265,7 @@ def _status_row( "mode": mode, "message": message, "count": len(models), - "models": models, + "models": _with_manual(channel, models), "updated_at": int(time.time()), } @@ -197,7 +343,7 @@ async def refresh_one(channel: str) -> dict: return _status_row( channel, mode="live", - models=fetched, + models=_merge_models(fetched, extras_for(channel)), message="", display_name=display_name, ) @@ -245,7 +391,7 @@ def catalog_snapshot() -> dict: "mode": meta.get("mode") or ("fallback" if channel not in LIVE_FETCHERS else "static"), "message": meta.get("message") or "", "count": len(models), - "models": models, + "models": _with_manual(channel, models), "updated_at": meta.get("updated_at"), } ) diff --git a/docs/releases/v2.1.3.md b/docs/releases/v2.1.3.md new file mode 100644 index 0000000..d04bb9d --- /dev/null +++ b/docs/releases/v2.1.3.md @@ -0,0 +1,27 @@ +# Buddy2api v2.1.3 + +发布日期:2026-08-30 + +管理页补齐两个小操作:手动添加模型时先选通道;账号测试时自己选模型。 + +## 按通道添加模型 + +- 「添加模型」弹出通道、模型 ID、显示名称。名称只写入所选通道,不会进其它通道的 `/v1/models`。 +- WorkBuddy 仍写后台模型表;QClaw / QwenWork / TraeWork 记为该通道的手动项,列表里带「手动」标记,可单独删除。 +- 一键读取供应模型不会冲掉手动添加的条目。 +- 模型仍按通道隔离:不属于当前 API Key 绑定通道时继续 400/403。 + +## 账号测试选模型 + +- 账号页「测试」不再直接发 `auto`。先打开窗口,从该账号所在通道的目录里选模型,也可改提示词。 +- 默认仍是 `auto`(通道默认路由);同一通道会记住上次选过的模型。 + +## 升级说明 + +- 无数据库迁移;手动模型写在 settings 里。 +- Docker 用户需要重新构建镜像并重启服务。 +- 思考强度策略未改。 + +## 验证 + +- 完整测试集:`245 passed`。 diff --git a/providers/traework/chat.py b/providers/traework/chat.py index a28f296..5516dfe 100644 --- a/providers/traework/chat.py +++ b/providers/traework/chat.py @@ -375,9 +375,11 @@ async def test_chat(account: dict, model: str = "qwen-3.7-plus", prompt: str = " "duration_ms": int((time.time() - t0) * 1000), "message": str(exc)[:400], } + chosen = translate_model(model or "auto") return { "ok": True, "status_code": 200, "duration_ms": int((time.time() - t0) * 1000), + "model": chosen, "message": text[:400], } diff --git a/server.py b/server.py index 86af03d..e93b76c 100644 --- a/server.py +++ b/server.py @@ -1024,6 +1024,35 @@ async def admin_update_models( return {"status": "ok"} +@app.post("/admin/models/catalogs") +async def admin_upsert_catalog_model( + request: Request, + authorization: str | None = Header(default=None), +): + _check_admin(authorization) + data = await _read_json_object(request) + channel = str(data.get("channel") or "").strip() + model_id = str(data.get("id") or data.get("model") or "").strip() + name = str(data.get("name") or "").strip() + try: + return catalog.upsert_model(channel, model_id, name) + except catalog.CatalogError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.delete("/admin/models/catalogs") +async def admin_remove_catalog_model( + channel: str, + model_id: str, + authorization: str | None = Header(default=None), +): + _check_admin(authorization) + try: + return catalog.remove_model(channel, model_id) + except catalog.CatalogError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # --- Codex 一键配置 --- @app.post("/admin/codex/setup") diff --git a/tests/test_models_refresh.py b/tests/test_models_refresh.py index 9f83ffb..ec48f32 100644 --- a/tests/test_models_refresh.py +++ b/tests/test_models_refresh.py @@ -153,6 +153,19 @@ def test_admin_models_page_has_one_click_control(): assert "一键读取供应模型" in html assert "/admin/models/refresh" in html assert "syncSources" in html + assert "请选择通道" in html + assert "addForm.channel" in html + assert "/admin/models/catalogs" in html + assert "submitAdd" in html + + +def test_account_test_ui_lets_user_pick_model(): + html = (Path(__file__).resolve().parents[1] / "web" / "index.html").read_text(encoding="utf-8") + assert "openTest" in html + assert "runTest" in html + assert "test.model" in html + assert "开始测试" in html + assert "{model:'auto',prompt:'ping'}" not in html def test_supplier_catalog_refresh_keeps_channels_distinct(isolated_db, all_channels, monkeypatch): @@ -267,3 +280,75 @@ def attempt_chat(payload, key): json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) + + +def test_manual_add_goes_to_selected_channel(isolated_db, all_channels): + import catalog + + custom_qw = "qwork-user-added" + custom_wb = "wb-user-added" + custom_qc = "qclaw-user-added" + + qwenwork = providers.get_provider("qwenwork") + workbuddy = providers.get_provider("workbuddy") + qclaw = providers.get_provider("qclaw") + + catalog.upsert_model("qwenwork", custom_qw, "Qwen extra") + catalog.upsert_model("workbuddy", custom_wb, "WB extra") + catalog.upsert_model("qclaw", "qclaw/" + custom_qc, "QC extra") + + assert qwenwork.accepts_model(custom_qw) + assert not workbuddy.accepts_model(custom_qw) + assert not qclaw.accepts_model(custom_qw) + assert workbuddy.accepts_model(custom_wb) + assert not qwenwork.accepts_model(custom_wb) + assert qclaw.accepts_model(custom_qc) + assert not workbuddy.accepts_model(custom_qc) + + by_id = {item["id"]: item for item in server.collect_v1_models()} + assert by_id["qwenwork/" + custom_qw]["channel"] == "qwenwork" + assert custom_qw not in by_id + assert custom_wb in by_id + assert by_id[custom_wb]["channel"] == "workbuddy" + assert by_id["qclaw/" + custom_qc]["channel"] == "qclaw" + assert custom_qc not in by_id + + snap = {item["channel"]: item for item in catalog.catalog_snapshot()["sources"]} + qw_manual = [item for item in snap["qwenwork"]["models"] if item.get("id") == custom_qw] + assert qw_manual and qw_manual[0].get("manual") is True + + catalog.remove_model("qwenwork", custom_qw) + assert not qwenwork.accepts_model(custom_qw) + + +def test_manual_extra_survives_live_refresh(isolated_db, all_channels, monkeypatch): + import catalog + + extra = "qclaw-hand-added" + catalog.upsert_model("qclaw", extra, "Hand added") + assert extra not in _ids(QCLAW_STATIC) + assert providers.get_provider("qclaw").accepts_model(extra) + + _seed_live_accounts() + _install_supplier_http(monkeypatch) + monkeypatch.setattr(server, "ALLOW_NO_ADMIN_AUTH", True) + result = asyncio.run(server.admin_refresh_models()) + sources = _by_channel(result) + + assert sources["qclaw"]["mode"] == "live" + assert extra in _ids(sources["qclaw"]["models"]) + assert QCLAW_NEW_ID in _ids(sources["qclaw"]["models"]) + assert providers.get_provider("qclaw").accepts_model(extra) + assert providers.get_provider("qclaw").accepts_model(QCLAW_NEW_ID) + assert extra not in _ids(providers.get_provider("workbuddy").list_models()) + + +def test_manual_add_rejects_unknown_channel(isolated_db, all_channels): + import catalog + + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("not-a-channel", "foo") + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("qwenwork", "") + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("qwenwork", "qwork-advanced") diff --git a/version.py b/version.py index b777579..4260069 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "2.1.2" +VERSION = "2.1.3" diff --git a/web/index.html b/web/index.html index 5c56bd8..d041f6f 100644 --- a/web/index.html +++ b/web/index.html @@ -388,7 +388,7 @@ template:`
粘性主账号 · 失败自动切换
| 账号 | 通道 | 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?'失败':'未刷新'))}} 缓存 {{cacheAge(a)}} | {{credit(a.official_resource?.expiring_30d_total)}}{{shortTime(a.official_resource.next_expire_time)}} | {{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}} | {{tokenLife(a)}} | {{a.total_requests}} | {{tok(a.total_tokens)}} | {{credit(a.total_credits)}} | ||||
| {{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?'失败':'未刷新'))}} 缓存 {{cacheAge(a)}} | {{credit(a.official_resource?.expiring_30d_total)}}{{shortTime(a.official_resource.next_expire_time)}} | {{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}} | {{tokenLife(a)}} | {{a.total_requests}} | {{tok(a.total_tokens)}} | {{credit(a.total_credits)}} | ||||
| 没有匹配的账号 | |||||||||||||||
暂无账号 · 先使用上方本机登录检测导入
| 额度包 | 剩余 | 已用 | 大小 | 周期 | 到期 | 状态 |
|---|---|---|---|---|---|---|
{{x.package_name}} {{x.product_name||x.package_type||x.resource_type}} | {{credit(x.remaining_precise)}} | {{credit(x.cycle_used||x.used)}} | {{credit(x.cycle_size||x.size)}} | {{shortTime(x.cycle_start)}} → {{shortTime(x.cycle_end)}} | {{x.expire_time||'-'}} {{expireText(x)}} | {{x.expired?'已过期':'可用'}} |
| 账号 | 结果 | 连续 | HTTP |
|---|---|---|---|
| {{r.account_name}} | {{claimText(r)}} | {{r.streak_days??'-'}} | {{r.status_code||'-'}} |
/v1/models 按通道列出;一键读取各来源供应模型,独有模型(如 TraeWork 豆包)只挂在该通道
/v1/models 按通道列出;一键读取各来源供应模型,手动添加时先选通道,名称只挂在该通道
| 通道 | 方式 | 数量 | 说明 |
|---|---|---|---|
| {{s.display_name||s.channel}} {{s.channel}} | {{modeText(s)}} | {{s.count||0}} | {{s.message||'—'}} |
| ID | 名称 | |
|---|---|---|
| {{s.channel}}/{{x.id}} | {{x.name||x.id}} | |
| 暂无 | ||
| ID | 名称 | |
|---|---|---|
| {{s.channel}}/{{x.id}} 手动 | {{x.name||x.id}} | |
| 暂无 | ||