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

Expand Down
170 changes: 158 additions & 12 deletions catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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]:
Expand All @@ -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(
Expand All @@ -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()),
}

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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"),
}
)
Expand Down
27 changes: 27 additions & 0 deletions docs/releases/v2.1.3.md
Original file line number Diff line number Diff line change
@@ -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`。
2 changes: 2 additions & 0 deletions providers/traework/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
29 changes: 29 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading