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
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,48 @@ Use your **WorkBuddy / CodeBuddy (Tencent)** subscription as local **OpenAI- and
- Chat Completions, Responses and Anthropic Messages, with tool calling and streaming.
- Built-in **WebUI** for browser login, models, credentials, logs and settings — no desktop client required.
- Automatic multi-account routing across domestic and international sites, with credential refresh.
- Per-account automation: domestic check-in then Buddy travel by default, with independent switches; international check-in is opt-in.

## Quick start

Requires Git and Docker Compose. This builds from source and includes the WebUI.
Requires Git and Docker Compose. Use the prebuilt GHCR image; no local build is needed.

```bash
git clone https://github.com/maiphucgiang/codebuddy2api.git
cd codebuddy2api
cp .env.example .env
```

Edit `.env` and set `CODEBUDDY2API_KEY` to your own random key; do not overwrite an existing `.env`. Then start:
For first setup, edit `.env`, set `CODEBUDDY2API_KEY` to your own random key, and choose the image below. Preserve an existing `.env`:

```dotenv
CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest
```

```bash
docker compose build
docker compose up -d
docker compose pull
docker compose up -d --no-build
```

`latest` tracks stable releases; pin a published version tag for reproducible deployments. Image features belong to that version, not to unmerged source branches.

1. Open **http://127.0.0.1:8787/dashboard** and sign in with that API key.
2. In **Credentials**, add a domestic or international account through browser login, or import an `.info` file.
3. In **Models**, find an available model and use its public ID in your client.

The template binds to localhost only. Configure HTTPS and restrict network access before allowing remote connections; keep and securely back up the `auth/` data directory.

[Published images and local Python setup →](docs/deployment.md)
### Run current source locally

With Python 3.12+, uv, Node.js and the vp CLI installed, prepare `.env` as above:

```bash
uv sync --locked --no-build --python 3.12
(cd web && vp install --frozen-lockfile && vp build)
uv run --locked --no-build --env-file .env converter.py --desensitize
```

[Source image builds, CLI login and deployment details →](docs/deployment.md)

## Client setup

Expand Down
27 changes: 22 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,48 @@
- 支持 Chat Completions、Responses 和 Anthropic Messages,包含工具调用与流式输出。
- 内置 **WebUI**:扫码添加账号,管理模型、凭证、日志与设置,无需桌面端。
- 多账号自动选路,兼容国内/国际站,自动刷新凭证。
- 按账号配置自动任务:国内默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。

## 快速开始

需要 Git 和 Docker Compose;以下方式从源码构建,已包含 WebUI
需要 Git 和 Docker Compose;直接拉取 GHCR 已构建镜像,无需在本机编译

```bash
git clone https://github.com/maiphucgiang/codebuddy2api.git
cd codebuddy2api
cp .env.example .env
```

编辑 `.env`,将 `CODEBUDDY2API_KEY` 设置为你自己的随机密钥;已有 `.env` 请勿覆盖。然后启动:
首次部署时编辑 `.env`,将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥,并指定镜像;已有 `.env` 请保留:

```dotenv
CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest
```

```bash
docker compose build
docker compose up -d
docker compose pull
docker compose up -d --no-build
```

`latest` 跟随稳定发行版;需固定部署时改用已发布的版本标签。镜像功能以对应版本为准,不包含尚未合并的源码分支改动。

1. 打开 **http://127.0.0.1:8787/dashboard**,使用刚设置的 API key 登录。
2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件。
3. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。

按模板配置时仅允许本机访问。远程访问前请配置 HTTPS 并限制网络访问;保留并妥善备份 `auth/` 数据目录。

[使用发布镜像或本地 Python 运行 →](docs/deployment.zh-CN.md)
### 本地运行当前源码

安装 Python 3.12+、uv、Node.js 与 vp CLI,并按上面配置 `.env`:

```bash
uv sync --locked --no-build --python 3.12
(cd web && vp install --frozen-lockfile && vp build)
uv run --locked --no-build --env-file .env converter.py --desensitize
```

[开发用镜像构建、命令行登录与部署详情 →](docs/deployment.zh-CN.md)

## 客户端接入

Expand Down
94 changes: 76 additions & 18 deletions app/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import threading
import time
from urllib.parse import quote, urlsplit
import uuid
import zipfile

from fastapi import HTTPException, Request
Expand Down Expand Up @@ -46,6 +47,7 @@ def _public_credential(item):
"credits_by_profile", "catalog", "catalog_sync", "sync", "generation", "auth_broken",
"models", "remaining", "enterprise_id", "product", "status", "sync_pending", "sync_error",
"fail_until", "cooldown_until", "cooldown_remaining", "last_failure_at", "catalog_ready", "bindings",
"auto_checkin", "auto_travel", "travel_supported", "checkin", "travel",
"token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code"}
result = {key: value for key, value in item.items() if key in fields}
identity = item.get("account_key") or item.get("id")
Expand Down Expand Up @@ -250,18 +252,29 @@ def apply():

@route("GET", "/admin/models")
async def models_get(request):
snapshot = control.snapshot()
models = []
for item in await run_in_threadpool(gateway.admin_model_inventory):
item = {"id": item} if isinstance(item, str) else dict(item)
source = item["id"]
rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False,
"region": None, "profile": None, "credential_ids": []})
models.append({**item, **rule})
return JSONResponse({"revision": snapshot["revision"], "models": models})

def checked_rule(source, data):
rule = validate_model(source, data, control.snapshot()["models"], known_models())
def build_models():
with mutation_lock:
inventory = gateway.admin_model_inventory()
snapshot = control.snapshot() # 扫描可能同步账号身份,随后读取对应的规则版本。
models = []
for item in inventory:
item = {"id": item} if isinstance(item, str) else dict(item)
source = item["id"]
rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False,
"region": None, "profile": None, "credential_ids": []})
models.append({**item, **rule})
return JSONResponse({"revision": snapshot["revision"], "models": models})
return await run_in_threadpool(build_models)

def checked_rule(source, data, *, creating=False):
if "custom" in data:
raise ValueError("custom 是只读字段")
existing = control.snapshot()["models"].get(source, {})
if creating and (not data.get("public_id") or not data.get("upstream_id")):
raise ValueError("对外 ID 和上游模型 ID 均不能为空")
values = {"upstream_id": existing.get("upstream_id", source), **data,
"custom": True if creating else existing.get("custom", False)}
rule = validate_model(source, values, control.snapshot()["models"], known_models())
for identity in rule["credential_ids"]:
item = selected(identity)
if item is None:
Expand All @@ -272,6 +285,42 @@ def checked_rule(source, data):
raise ValueError("绑定凭证与区域或产品规则冲突")
return rule

@route("POST", "/admin/models")
async def models_create(request):
data = await _body(request)
revision = data.pop("revision", None)
source = "custom:" + uuid.uuid4().hex
def apply():
with mutation_lock:
rule = checked_rule(source, data, creating=True)
snapshot = control.update_model(source, rule, revision, known_models())
event("model.created", {"model": rule["public_id"]})
return JSONResponse({"revision": snapshot["revision"], "model": {"id": source, **rule}}, status_code=201)
return await run_in_threadpool(apply)

@route("POST", "/admin/models/preview")
async def models_create_preview(request):
data = await _body(request)
data.pop("revision", None)
source = "custom:" + uuid.uuid4().hex
def preview():
return JSONResponse(gateway.admin_model_preview(source, checked_rule(source, data, creating=True)))
return await run_in_threadpool(preview)

@route("DELETE", "/admin/models/{id:path}")
async def models_delete(request):
data = await _body(request)
if set(data) != {"revision"}:
raise ValueError("删除模型只接受 revision")
source = request.path_params["id"]
def remove():
with mutation_lock:
snapshot = control.delete_model(source, data["revision"])
event("model.deleted", {"model": source})
return JSONResponse({"revision": snapshot["revision"], "ok": True})
return await run_in_threadpool(remove)


@route("PUT", "/admin/models/{id:path}")
async def models_put(request):
data = await _body(request)
Expand All @@ -297,17 +346,23 @@ def preview():
@route("PATCH", "/admin/credentials/{id}")
async def credentials_patch(request):
data = await _body(request)
if set(data) != {"enabled"} or type(data["enabled"]) is not bool:
raise ValueError("enabled 必须为布尔值")
if set(data) not in ({"enabled"}, {"auto_checkin"}, {"auto_travel"}) or any(type(value) is not bool for value in data.values()):
raise ValueError("仅接受一个布尔字段:enabled、auto_checkin 或 auto_travel")
field, value = next(iter(data.items()))
identity = request.path_params["id"]
def apply():
if selected(identity) is None:
return error_response(404, "凭证不存在")
with mutation_lock:
# The gateway persists under the pool lock before publishing routing state.
gateway.admin_set_credential_enabled(identity, data["enabled"])
event("credential.enabled", {"credential": identity, "enabled": data["enabled"]})
return JSONResponse({"id": identity, "enabled": data["enabled"], "revision": control.snapshot()["revision"]})
if field == "enabled":
gateway.admin_set_credential_enabled(identity, value)
elif field == "auto_checkin":
gateway.admin_set_auto_checkin(identity, value)
else:
gateway.admin_set_auto_travel(identity, value)
event("credential." + field, {"credential": identity, field: value})
return JSONResponse({"id": identity, field: value, "revision": control.snapshot()["revision"]})
return await run_in_threadpool(apply)

def upload(body):
Expand Down Expand Up @@ -442,8 +497,11 @@ async def dashboard_get(request):
raise ValueError("days 必须为 1、7、30 或 90") from None
if days not in (1, 7, 30, 90):
raise ValueError("days 必须为 1、7、30 或 90")
granularity = request.query_params.get("granularity", "auto")
if granularity not in ("auto", "hour", "day"):
raise ValueError("granularity 必须为 auto、hour 或 day")
def build_dashboard():
result = audit.dashboard(days)
result = audit.dashboard(days, granularity=granularity)
if result.get("degraded"):
return error_response(503, "统计暂时无法读取,不能确认当前数值;请检查审计存储状态")
rows = [_public_credential(item) for item in inventory()]
Expand Down
29 changes: 22 additions & 7 deletions app/audit_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,12 +475,16 @@ def fetch():
return json.loads(row[0]) if row else None
return self._run(fetch, write=True)

def dashboard(self, days=30):
def dashboard(self, days=30, granularity="auto"):
days = int(days)
if not 1 <= days <= 36500:
raise ValueError("invalid days")
if not 1 <= days <= 36500 or granularity not in ("auto", "hour", "day"):
raise ValueError("invalid dashboard range or granularity")
grain = ("hour" if days == 1 else "day") if granularity == "auto" else granularity
if grain == "hour" and days > 90:
raise ValueError("hourly range exceeds 90 days")
now = time.time()
start = int(now // 86400) * 86400 - (days - 1) * 86400
period = {"days": days, "start": start, "end": now, "timezone": "UTC", "granularity": grain}
def fetch():
summary = self._empty_stats()
series, models, profiles = [], {}, {}
Expand All @@ -489,17 +493,28 @@ def fetch():
stats = json.loads(row["payload"])
if row["dimension"] == "global":
self._merge(summary, stats)
series.append({"bucket": row["bucket"], "date": time.strftime("%Y-%m-%d", time.gmtime(row["bucket"])), **stats})
if grain == "day":
series.append({"bucket": row["bucket"], **stats})
elif row["dimension"] in ("model", "profile"):
target = models if row["dimension"] == "model" else profiles
self._merge(target.setdefault(row["dimension_key"], self._empty_stats()), stats)
if grain == "hour":
rows = self._db.execute("SELECT bucket,payload FROM stats_hourly WHERE dimension='global' AND bucket>=? AND bucket<=? ORDER BY bucket", (start, now)).fetchall()
series = [{"bucket": row["bucket"], **json.loads(row["payload"])} for row in rows]
partial = grain == "hour" and sum(row["requests"] for row in series) != summary["requests"]
step = 3600 if grain == "hour" else 86400
if days <= 90 and not partial:
recorded = {row["bucket"]: row for row in series}
series = [recorded.get(bucket, {"bucket": bucket, **self._empty_stats()})
for bucket in range(start, int(now // step) * step + 1, step)]
for row in series:
row["date"] = time.strftime("%Y-%m-%d %H:00" if grain == "hour" else "%Y-%m-%d", time.gmtime(row["bucket"]))
summary["success_rate"] = summary["success"] / summary["requests"] if summary["requests"] else None
return {"summary": summary, "series": series,
"models": [{"model": key, **value} for key, value in models.items()],
"profiles": [{"profile": key, **value} for key, value in profiles.items()],
"generated_at": now, "range": {"days": days, "start": start, "end": now, "timezone": "UTC"}}
return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": {"days": days, "start": start, "end": now}, "degraded": True})

"generated_at": now, "range": {**period, "partial": partial}}
return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": period, "degraded": True})
def storage(self):
def fetch():
self._prune()
Expand Down
48 changes: 48 additions & 0 deletions app/checkin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Activity-gated check-in with safe status labels shared by manual and scheduled work."""
from . import credits

MESSAGES = {
"success": "签到成功,余额可另行同步",
"already": "今日已签到",
"inactive": "签到活动未开放或已结束",
"not_eligible": "当前账号不具备签到资格",
"unknown": "活动状态未确认,未尝试领取",
"error": "签到未确认成功,请核验账号状态",
"cancelled": "自动签到设置或凭证已变化,已取消未发送的领取",
"changed": "凭证已变化,结果未写入,请刷新核验",
}


def normalize(result):
state = result.get("state")
if state not in MESSAGES:
state = "already" if result.get("already") else "success" if result.get("ok") is True else "error"
return {"state": state, "ok": state in {"success", "already"}, "already": state == "already",
"skipped": state in {"inactive", "not_eligible", "cancelled"},
"code": result.get("code"), "message": MESSAGES[state]}


def perform(access_token, uid="", domain="", *, can_claim=lambda: True):
if not can_claim():
return normalize({"state": "cancelled"})
status = credits.fetch_checkin_status(access_token, uid=uid, domain=domain)
if status.get("state") != "available":
return normalize(status)
# A setting change, account disable or new credential generation during the query cancels the claim.
if not can_claim():
return normalize({"state": "cancelled"})
return normalize(credits.daily_checkin(access_token, uid=uid, domain=domain))


def view(record):
if not record:
return {"state": "unknown", "message": "尚未查询签到活动"}
if record.get("state") in MESSAGES:
result = normalize(record)
elif record.get("ok") is True:
result = normalize({"state": "success"})
else:
result = normalize(credits.classify_checkin_result(False, record.get("code"), record.get("message", "")))
if result["ok"]:
result = normalize({"state": "unknown"})
return {"state": result["state"], "message": result["message"], "date": record.get("date"), "at": record.get("at")}
Loading
Loading