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
4 changes: 2 additions & 2 deletions 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.1**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。
当前版本 **2.1.2**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。

## 这是什么?

Expand Down Expand Up @@ -105,7 +105,7 @@ python server.py
4. 在客户端里填:
- Base URL:`http://127.0.0.1:8787/v1`
- API Key:刚复制的 Key
- 模型:WorkBuddy 用 `auto` 即可;QClaw 用 `auto`;千问办公用 `auto` 或 `qwork-advanced`;TraeWork 用 `auto` 或 `qwen-3.7-plus`
- 模型:WorkBuddy 用 `auto` 即可;QClaw 用 `auto`;千问办公用 `auto` 或 `qwork-advanced`;TraeWork 用 `auto` 或 `qwen-3.7-plus`。上游加了新模型时,到「模型配置」点「一键读取供应模型」;各通道目录分开保存,选错通道仍会 400/403。

管理页打不开或要远程访问时:

Expand Down
4 changes: 2 additions & 2 deletions 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.1**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database.
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.

## What is this?

Expand Down Expand Up @@ -78,7 +78,7 @@ The database migrates on startup. Existing keys stay on `workbuddy`. Startup no
| API Key | Created in the UI, bound to one channel |
| Model | WorkBuddy `auto`; QClaw `auto`; QwenWork `qwork-advanced` |

Unprefixed `auto` follows the key’s channel. Use a separate key per channel.
Unprefixed `auto` follows the key’s channel. Use a separate key per channel. On the Models page, “一键读取供应模型” refreshes each channel’s supplier list separately; a TraeWork-only id such as Doubao is never merged into WorkBuddy.

### Reasoning effort

Expand Down
252 changes: 252 additions & 0 deletions catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"""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.
"""

from __future__ import annotations

import sqlite3
import time
from typing import Any, Awaitable, Callable

import database as db

CATALOG_SETTING = "channel_catalogs"
REFRESH_SETTING = "channel_catalog_refresh"

Fetcher = Callable[[dict], Awaitable[list[dict]]]


def _load_map(key: str) -> dict:
try:
value = db.get_setting(key, {}) or {}
except sqlite3.OperationalError:
return {}
return value if isinstance(value, dict) else {}


def stored_catalog(channel: str) -> list[dict] | None:
items = _load_map(CATALOG_SETTING).get(channel)
if isinstance(items, list) and items:
return [item for item in items if isinstance(item, dict) and item.get("id")]
return None


def save_catalog(channel: str, models: list[dict]) -> None:
catalogs = _load_map(CATALOG_SETTING)
catalogs[channel] = models
db.set_setting(CATALOG_SETTING, catalogs)


def normalize_models(rows: Any) -> list[dict]:
if not isinstance(rows, list):
return []
models: list[dict] = []
seen: set[str] = set()
for row in rows:
if isinstance(row, str):
mid = row.strip()
name = mid
description = ""
elif isinstance(row, dict):
mid = str(
row.get("id")
or row.get("model_id")
or row.get("model_name")
or row.get("name")
or ""
).strip()
name = str(row.get("name") or row.get("display_id") or row.get("display_name") or mid)
description = str(row.get("description") or "")
else:
continue
if not mid or mid in seen:
continue
seen.add(mid)
item = {"id": mid, "name": name or mid}
if description:
item["description"] = description
models.append(item)
return models


def models_for(channel: str, fallback: list[dict]) -> list[dict]:
stored = stored_catalog(channel)
if stored:
return stored
return list(fallback)


def workbuddy_fallback_models() -> list[dict]:
import proxy

try:
models = db.get_setting("models", proxy.DEFAULT_MODELS)
except sqlite3.OperationalError:
return list(proxy.DEFAULT_MODELS)
if isinstance(models, list) and models:
return models
return list(proxy.DEFAULT_MODELS)


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 []


def _status_row(
channel: str,
*,
mode: str,
models: list[dict],
message: str = "",
display_name: str = "",
) -> dict:
return {
"channel": channel,
"display_name": display_name or channel,
"mode": mode,
"message": message,
"count": len(models),
"models": models,
"updated_at": int(time.time()),
}


async def _pick_account(provider) -> dict | None:
if provider is None:
return None
picker = getattr(provider, "pick_account_with_fallback", None)
if picker is None:
return None
return await picker()


async def _fetch_qclaw(account: dict) -> list[dict]:
from providers.qclaw.jprx import fetch_supplier_models

return await fetch_supplier_models(account)


async def _fetch_traework(account: dict) -> list[dict]:
from providers.traework.models import fetch_supplier_models

return await fetch_supplier_models(account)


LIVE_FETCHERS: dict[str, Fetcher] = {
"qclaw": _fetch_qclaw,
"traework": _fetch_traework,
}


async def refresh_one(channel: str) -> dict:
import providers

provider = providers.get_provider(channel)
display_name = getattr(provider, "display_name", channel) if provider else channel
fallback = _fallback_models(channel, provider)
fetcher = LIVE_FETCHERS.get(channel)
if fetcher is None:
return _status_row(
channel,
mode="fallback",
models=fallback,
message="no supplier-list API",
display_name=display_name,
)
account = await _pick_account(provider)
if not account:
return _status_row(
channel,
mode="fallback",
models=fallback,
message="no usable account",
display_name=display_name,
)
try:
fetched = normalize_models(await fetcher(account))
except Exception as exc:
return _status_row(
channel,
mode="fallback",
models=fallback,
message=str(exc)[:240],
display_name=display_name,
)
if not fetched:
return _status_row(
channel,
mode="fallback",
models=fallback,
message="empty supplier list",
display_name=display_name,
)
save_catalog(channel, fetched)
return _status_row(
channel,
mode="live",
models=fetched,
message="",
display_name=display_name,
)


async def refresh_supplier_catalogs() -> dict:
import providers

sources = []
for channel in providers.enabled_provider_ids():
sources.append(await refresh_one(channel))
db.set_setting(
REFRESH_SETTING,
{
item["channel"]: {
"mode": item["mode"],
"message": item["message"],
"count": item["count"],
"updated_at": item["updated_at"],
}
for item in sources
},
)
return {"sources": sources}


def catalog_snapshot() -> dict:
import providers

refresh = _load_map(REFRESH_SETTING)
sources = []
for channel in providers.enabled_provider_ids():
provider = providers.get_provider(channel)
if channel == "workbuddy":
models = workbuddy_fallback_models()
elif provider is not None:
models = list(provider.list_models())
else:
models = []
meta = refresh.get(channel) if isinstance(refresh.get(channel), dict) else {}
sources.append(
{
"channel": channel,
"display_name": getattr(provider, "display_name", channel) if provider else channel,
"mode": meta.get("mode") or ("fallback" if channel not in LIVE_FETCHERS else "static"),
"message": meta.get("message") or "",
"count": len(models),
"models": models,
"updated_at": meta.get("updated_at"),
}
)
return {"sources": sources}
25 changes: 25 additions & 0 deletions docs/releases/v2.1.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Buddy2api v2.1.2

发布日期:2026-08-28

管理页「模型配置」增加一键读取各通道供应模型,目录按通道保存,不再靠手改表格追新模型。

## 一键读取供应模型

- 一次操作覆盖当前启用的全部通道,并标明每个来源是在线读取还是回退本地/后台列表。
- QClaw 走官方 cmd `4320`;TraeWork 走 `/api/remote/v1/models`,按官方 `data.list[].models[]`(`name` 为模型 id)解析,豆包等独有 id 只挂在 TraeWork。
- WorkBuddy、QwenWork 没有供应商列表接口,回退现有后台或静态目录,不编造远程目录。
- 目录按通道写入,不合并。`GET /v1/models` 仍是 WorkBuddy 裸 id 加 `workbuddy/<id>`,其它通道只有 `channel/<id>`。
- 模型不属于当前 API Key 绑定的通道时,继续 400/403,不会换通道重试。
- WorkBuddy 模型表和别名仍可手改。

## 升级说明

- 无数据库迁移;刷新结果写在 settings 里。
- Docker 用户需要重新构建镜像并重启服务。
- 思考强度策略未改:非法档 400;DeepSeek V4 未指定时默认 `high`;其它通道不补默认档。

## 验证

- 完整测试集:`241 passed`。
- 已用本机账号实测一键读取:QClaw 在线 11 个模型;TraeWork 官方列表含豆包,补解析后可入库。
9 changes: 6 additions & 3 deletions providers/qclaw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@ class QClawProvider:
checkin_supported = False

def list_models(self) -> list[dict]:
return [{"id": item} for item in STATIC_MODELS]
import catalog

return catalog.models_for(self.id, [{"id": item} for item in STATIC_MODELS])

def alias_map(self) -> dict[str, str]:
return dict(ALIASES)

def accepts_model(self, inner: str) -> bool:
value = (inner or "").strip()
if value in STATIC_MODELS or value in ALIASES:
if value in ALIASES or value.startswith("pool-"):
return True
return value.startswith("pool-")
ids = {str(item.get("id")) for item in self.list_models() if isinstance(item, dict)}
return value in ids

def translate_model(self, model: str) -> str:
return chat.translate_model(model)
Expand Down
45 changes: 31 additions & 14 deletions providers/qclaw/jprx.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,41 @@ async def time_sync(account: dict) -> str:
return str(server_time or "")


async def list_remote_models(account: dict) -> list[dict]:
data, token = await post_cmd(CMD_MODEL_LIST, account)
apply_new_token(account, token)
rows = data.get("model_status_list") or data.get("models") or []
def parse_model_list(data: dict) -> list[dict]:
rows = []
if isinstance(data, dict):
rows = data.get("model_status_list") or data.get("models") or []
elif isinstance(data, list):
rows = data
models = []
seen: set[str] = set()
for row in rows:
if not isinstance(row, dict):
if isinstance(row, str):
mid = row.strip()
name = mid
description = ""
elif isinstance(row, dict):
mid = str(row.get("id") or row.get("model_id") or "").strip()
name = row.get("name") or row.get("display_id") or mid
description = row.get("description") or ""
else:
continue
mid = str(row.get("id") or "").strip()
if not mid:
if not mid or mid in seen:
continue
models.append(
{
"id": mid,
"name": row.get("name") or row.get("display_id") or mid,
"description": row.get("description") or "",
}
)
seen.add(mid)
models.append({"id": mid, "name": name, "description": description})
return models


async def fetch_supplier_models(account: dict) -> list[dict]:
"""Live cmd 4320 list. Empty means no remote ids; caller decides fallback."""
data, token = await post_cmd(CMD_MODEL_LIST, account)
apply_new_token(account, token)
return parse_model_list(data)


async def list_remote_models(account: dict) -> list[dict]:
models = await fetch_supplier_models(account)
return models or [{"id": item, "name": item} for item in STATIC_MODELS]


Expand Down
Loading