diff --git a/.env.example b/.env.example index b292782..e90e967 100644 --- a/.env.example +++ b/.env.example @@ -1,78 +1,129 @@ # Copy to .env for first use; preserve existing settings when updating. -# Compose and native launches read .env; native zero-configuration startup can omit it. +# 首次使用复制为 .env;升级时保留已有配置。 +# Compose loads every key in this file into the container, so unrelated keys are harmless; +# native launches read it too, and native zero-configuration startup can omit it. +# Compose 会把本文件的每个键加载进容器,无关的键不会造成影响;原生启动同样读取它, +# 零配置的原生启动可以省略该文件。 +# Runtime keys here stay read-only in the WebUI. To let the WebUI own them, stop the +# service and run `python3 converter.py config migrate` (add --data-dir/--env-file when +# the paths differ), then delete the printed lines. Deployment keys below are never migrated. +# 这里列出的运行参数在 WebUI 中只读。要让 WebUI 接管,请停止服务后运行 +# `python3 converter.py config migrate`(路径不同时加 --data-dir/--env-file), +# 再删除它打印的行;下方的部署类键不会被迁移。 # Native listener and Compose host mapping; explicit --host/--port override native values. +# 原生监听地址与 Compose 宿主机映射;显式 --host/--port 覆盖原生取值。 CODEBUDDY2API_BIND=127.0.0.1 CODEBUDDY2API_PORT=8787 -# Compose-only image and host data directory; container data stays in /data/auth. -CODEBUDDY2API_IMAGE=codebuddy2api:local +# Compose image; uncomment to override, leaving it commented keeps the published latest tag. +# Compose 镜像;取消注释才会覆盖,保持注释即使用已发布的 latest 标签。 +# CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest +# Compose host data directory; container data stays in /data/auth. +# Compose 宿主机数据目录;容器内数据固定在 /data/auth。 CODEBUDDY2API_AUTH_PATH=./auth +# Container time zone; daily guards follow it, and an unset value keeps UTC. +# 容器时区;每日任务按它跨天,未设置时保持 UTC。 +# TZ=Asia/Shanghai +# Optional Compose override for the env file path, used instead of .env. +# 可选的 Compose 覆盖项:改读其他 env 文件而非 .env。 +# CODEBUDDY2API_ENV_FILE=.env # Optional native data directory; Compose fixes CODEBUDDY_AUTH_DIR to /data/auth. +# 可选的原生数据目录;Compose 会把 CODEBUDDY_AUTH_DIR 固定为 /data/auth。 # CODEBUDDY_AUTH_DIR=./auth # Import directory defaults to the data directory's imports/; use container paths in Compose. +# 导入目录默认是数据目录下的 imports/;Compose 中请使用容器内路径。 # CODEBUDDY_IMPORT_DIR=./auth/imports # Empty locks management; set a random key before exposing inference beyond loopback. +# 留空即锁定管理;对外暴露推理端口前请设置随机密钥。 # Native: omit/comment the variable to use the saved default key (first local start displays it once). +# 原生:省略或注释该变量会使用已保存的默认 key(首次本地启动会显示一次)。 CODEBUDDY2API_KEY= -# Native-only unsafe opt-in; Compose uses its host mapping to control exposure. +# Opt-in for headless/non-loopback no-auth runs; the image and Compose already force it on. +# 原生后台或非回环免鉴权运行选项;镜像与 Compose 已固定开启。 # CODEBUDDY2API_ALLOW_OPEN_NOAUTH=false # Disabling CSRF checks does not disable authentication; use only on trusted local networks. +# 关闭 CSRF 校验不等于关闭认证;仅在可信本地网络使用。 CODEBUDDY2API_ADMIN_CSRF=true # Extra trusted management origins behind reverse proxies (comma separated; bare domains use HTTPS). +# 反代后额外信任的管理来源(逗号分隔;裸域名按 HTTPS 处理)。 # Unset leaves the list configurable in the WebUI; an explicit value locks it. +# 不设置时列表可在 WebUI 修改;显式设置会锁定。 # CODEBUDDY2API_ADMIN_ORIGINS=https://chat.example.com -# 可信代理 IP/CIDR(逗号分隔);命中时审计改记 X-Forwarded-For/X-Real-IP 的真实客户端地址 +# Trusted proxy IPs/CIDRs; a matching peer makes the audit log record X-Forwarded-For/X-Real-IP. +# 可信代理 IP/CIDR;命中时审计改记 X-Forwarded-For/X-Real-IP 中的真实客户端地址。 # CODEBUDDY2API_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8 # Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it. +# 不设置时工具元数据可在 WebUI 修改;显式布尔值会锁定。 # CODEBUDDY2API_KEEP_TOOL_METADATA=true # Image overflow keeps the newest blocks or returns 413; zero forbids all images. +# 图片超限时保留最新内容或返回 413;设为 0 禁止全部图片。 CODEBUDDY2API_MAX_IMAGES=16 CODEBUDDY2API_IMAGE_POLICY=truncate # Positive upstream JSON byte limit after image policy and protocol adaptation. +# 图片策略与协议适配之后,发往上游的 JSON 字节上限(正数)。 CODEBUDDY2API_MAX_REQUEST_BYTES=33554432 # Positive raw inbound byte limit, enforced before JSON parsing. +# 原始入站字节上限(正数),在解析 JSON 之前生效。 CODEBUDDY2API_MAX_INBOUND_BYTES=67108864 # Aggregated output bytes and concurrent inference requests; zero disables each limit. +# 聚合输出字节数与并发推理请求数;设为 0 分别关闭对应限制。 CODEBUDDY2API_MAX_COLLECT_BYTES=8388608 CODEBUDDY2API_MAX_CONCURRENT=64 # Optional bounded upstream connection reuse; restart to apply, disabled by default. +# 可选的上游连接复用(有上限);修改后需重启,默认关闭。 # CODEBUDDY2API_UPSTREAM_KEEPALIVE=false -# Per-account in-flight limit; zero preserves unlimited account capacity. +# Per-account in-flight limit; zero keeps account capacity unlimited. +# 单账号在途请求上限;设为 0 保持账号容量不受限。 # CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT=0 -# Optional session/attempt tracing; legacy preserves existing upstream header behavior. +# Optional session/attempt tracing; legacy keeps existing upstream header behavior. +# 可选的会话/尝试追踪;legacy 保持现有上游请求头行为。 # CODEBUDDY2API_REQUEST_CONTEXT_MODE=legacy # Responses projection is hot and WebUI-editable unless explicitly set here. -# balanced rewrites only recognized harness blocks with stable summaries; other text stays verbatim. +# Responses 投影可热生效且默认可在 WebUI 修改,除非在此显式设置。 +# balanced only rewrites recognized harness blocks with stable summaries; other text stays verbatim. +# balanced 只改写可识别的 harness 块并生成稳定摘要,其余文本保持原样。 # CODEBUDDY2API_RESPONSES_PROJECTION_MODE=balanced # Per-item head/tail limit for generated assistant text, tool arguments and tool results; # zero disables per-item trimming. Global request/output gates still apply. +# 生成文本、工具参数与工具结果的单项首尾截断上限;设为 0 关闭单项截断, +# 全局请求与输出限制仍然生效。 # Non-zero values must be between 256 and 33554432 bytes. +# 非零取值须在 256 至 33554432 字节之间。 # CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES=40000 -# compatible preserves aggregation; realtime forwards all protocol output incrementally +# compatible keeps aggregation; realtime forwards all protocol output incrementally # and does not regenerate malformed tool arguments. +# compatible 保持聚合;realtime 增量转发全部协议输出,且不重新生成损坏的工具参数。 # CODEBUDDY2API_STREAM_MODE=compatible -# Disable only declared model-capability preflight; other safeguards and Intl image merging remain enabled. +# Disable only the declared model-capability preflight; other safeguards and Intl image merging remain. +# 只关闭声明的模型能力预检;其他保护与国际站图片合并仍然生效。 # CODEBUDDY2API_MODEL_CAPABILITY_GUARD=true # Retired: this setting only warns; all persistent logs use logs.sqlite3. +# 已停用:该项只会给出告警;所有持久日志都写入 logs.sqlite3。 CODEBUDDY2API_LOG= # Legacy text-preview limit; text output is retired. SQLite diagnostics have a separate budget. +# 旧版文本预览上限;文本输出已停用,SQLite 诊断另有独立预算。 CODEBUDDY2API_LOG_BODY_LIMIT=65536 # International trial credits remain manual-only in the WebUI. -# Preauthorize first-Buddy tasks, one bounded chat (may use credits), adoption and travel. +# 国际站试用积分仍需在 WebUI 手动领取。 +# Preauthorize first-Buddy tasks: one bounded chat (may use credits), adoption and travel. +# 预授权首次 Buddy 任务:一次有上限的对话(可能消耗积分)、领养与旅行。 CODEBUDDY2API_AUTO_ACCEPT_BUDDY=false # Extra generations for malformed tool arguments; each may consume credits, zero disables retries. +# 损坏工具参数的额外生成次数;每次可能消耗积分,设为 0 关闭重试。 CODEBUDDY2API_TOOL_CALL_MAX_RETRY=3 # Pre-response credential failovers; zero disables replay. See docs/advanced.md for billing risks. +# 响应前的凭证故障切换次数;设为 0 关闭重放。计费风险见 docs/advanced.md。 CODEBUDDY2API_FAILOVER_MAX=0 # Replay incomplete writes only when partial requests cannot be billed upstream. +# 仅在部分写入不会被上游计费时才重放未完成的请求。 CODEBUDDY2API_RETRY_WRITE_TIMEOUT=false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e6cd283..90b6098 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -119,14 +119,13 @@ jobs: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} - flavor: latest=auto + # Three published tags: the release version, edge for main, latest for releases. + # Pull requests get an unpublished ref tag so the build still has an identity. tags: | - type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} type=ref,event=pr - type=sha type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} labels: | org.opencontainers.image.version=${{ needs.test.outputs.version }} - name: Build and publish diff --git a/Dockerfile b/Dockerfile index 67319f6..65d8c21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,4 +30,4 @@ COPY --from=frontend /web/dist/ ./web/dist/ EXPOSE 8787 # Listen on container interfaces; host port mapping controls external access. ENV CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true -CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", "--skip-check"] +CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", "--skip-check", "--desensitize"] diff --git a/README.md b/README.md index 2b82e72..091816f 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,19 @@ cd codebuddy2api cp .env.example .env ``` -Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key and pick the image. Preserve an existing `.env` on upgrades: - -```dotenv -CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest -``` +Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key. Compose loads the whole file into the container, so every key applies and unknown keys are harmless. Preserve an existing `.env` on upgrades. ```bash 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. +Compose pulls `latest` by default (`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest`); images also publish the release version (for example `1.3.6`) and `edge` for `main`. Pin a version tag for reproducible deployments; image features belong to that version, not to unmerged source branches. First use: 1. Open **http://127.0.0.1:8787/dashboard** and sign in with your API key. -2. In **Credentials**, add a domestic or international account through browser login, or import an `.info` file. +2. In **Credentials**, add a domestic or international account through browser login, import an `.info` file, or paste refresh tokens for a chosen endpoint. 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. To run current source or log in from a terminal instead, see the [deployment guide](docs/deployment.md). @@ -82,7 +78,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces ## FAQ - **WebUI sign-in fails behind an HTTPS domain/reverse proxy?** Trust your public origin via `admin_allowed_origins` — see [Management Origin checks](docs/advanced.md#management-origin--csrf-switch). -- **No Docker?** After installing dependencies and the WebUI, run `uv run converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). +- **No Docker?** After installing dependencies and the WebUI, run `uv run --locked --no-build converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). - **Where is my data?** Everything lives in `auth/` (or `/data/auth` in Docker): credentials, settings and log databases — see [Data and backups](docs/webui.md#data-and-backups). - **Which image tag should I use?** `latest` follows stable releases, `edge` follows main, version tags pin one release — see [Published images](docs/deployment.md#use-published-images). - **Responses tool output/arguments compressed or need verbatim passthrough?** Set `responses_projection_mode` (default `balanced`, or `passthrough` to disable projection) and `responses_projection_max_bytes` (default `40000`, `0` disables per-item trimming). Balanced trimming keeps the head/tail and reports original bytes, estimated tokens and total lines; client addresses stay unchanged — see [Responses projection](docs/clients.md#responses-projection) and [details](docs/advanced.md#responses-projection). diff --git a/README.zh-CN.md b/README.zh-CN.md index 71f4056..cf03c7f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,23 +19,19 @@ cd codebuddy2api cp .env.example .env ``` -编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥,并指定镜像;升级时保留已有 `.env`: - -```dotenv -CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest -``` +编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。Compose 会把整个文件加载进容器,每个键都会生效,无关的键也无影响。升级时保留已有 `.env`。 ```bash docker compose pull docker compose up -d --no-build ``` -`latest` 跟随稳定发行版;需固定部署时改用已发布的版本标签。镜像功能以对应版本为准,不包含尚未合并的源码分支改动。 +Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest`);镜像同时发布发行版本(例如 `1.3.6`)与跟随 `main` 的 `edge`。需固定部署时改用版本标签;镜像功能以对应版本为准,不包含尚未合并的源码分支改动。 首次使用: 1. 打开 **http://127.0.0.1:8787/dashboard**,使用刚设置的 API key 登录。 -2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件。 +2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件,或按端点粘贴刷新令牌批量导入。 3. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。 按模板配置时仅允许本机访问。远程访问前请配置 HTTPS 并限制网络访问;保留并妥善备份 `auth/` 数据目录。如需直接运行源码或在终端登录,见[部署指南](docs/deployment.zh-CN.md)。 @@ -82,7 +78,7 @@ docker compose up -d --no-build ## 常见问题 - **绑定域名后经 HTTPS 反代无法登录 WebUI?** 将对外来源加入 `admin_allowed_origins` 信任列表——见[管理 Origin 校验](docs/advanced.zh-CN.md#管理-origin--csrf-开关)。 -- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 +- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run --locked --no-build converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 - **数据在哪里?** 全部位于 `auth/`(Docker 中为 `/data/auth`):凭证、设置与日志数据库——见[数据与备份](docs/webui.zh-CN.md#数据与备份)。 - **镜像标签怎么选?** `latest` 跟随稳定版,`edge` 跟随 main,版本标签固定某一发行版——见[使用已发布镜像](docs/deployment.zh-CN.md#使用已发布镜像)。 - **Responses 工具输出/参数被压缩或需要完全原样?** 设置 `responses_projection_mode`(默认 `balanced`,设 `passthrough` 可完全关闭投影)及 `responses_projection_max_bytes`(默认 `40000`,`0` 禁用单项裁剪)。balanced 裁剪保留头尾并显示原始 bytes、估算 tokens 与总行数,客户端地址不变——见 [Responses 投影](docs/clients.zh-CN.md#responses-投影)及[详细说明](docs/advanced.zh-CN.md#responses-投影)。 diff --git a/app/admin_api.py b/app/admin_api.py index 7cc6a2e..a1f064a 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -4,6 +4,7 @@ from collections import OrderedDict import io import json +import os from pathlib import Path import threading import time @@ -20,6 +21,7 @@ from .control_store import ConflictError, validate_model from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file from .settings import SCHEMA, resolve_settings, validate_settings +from .site_routing import PROFILE_DOMAINS MAX_UPLOAD_BYTES = 32 * 1024 * 1024 CLEAR_CONFIRMATION = "清空全部日志与统计" @@ -120,6 +122,20 @@ def settings_result(): return {"revision": control.snapshot()["revision"], "items": items, "audit": audit.storage(), "session": auth.storage()} + def config_notice(): + """List runtime settings still supplied by the environment, until the user migrates or mutes.""" + if config.get("config_notice_muted"): + return None + items = [{"key": key, "label": spec["label"]} + for key, spec in SCHEMA.items() + if spec.get("env") and spec.get("env_scope") == "runtime" + and not spec.get("hidden") and not spec.get("sensitive") + and spec["env"] in os.environ] + if not items: + return None + return {"count": len(items), "items": items, + "command": "python3 converter.py config migrate"} + def audit_settings(values): mapping = {"audit_max_bytes": "max_bytes", "audit_retention_days": "retention_days", "audit_diagnostic_bytes": "preview_limit"} @@ -286,6 +302,22 @@ def apply(): return JSONResponse(settings_result()) return await run_in_threadpool(apply) + @route("POST", "/admin/config-notice/dismiss") + async def config_notice_dismiss(request): + body = await _body(request) + + def apply(): + with mutation_lock: + revision = body.get("revision") + if not isinstance(revision, int): + revision = control.snapshot()["revision"] + control.update_settings({"config_notice_muted": True}, revision) + config["config_notice_muted"] = True + config.setdefault("settings_sources", {})["config_notice_muted"] = "management" + event("config.notice_dismissed") + return JSONResponse({"muted": True, "revision": revision + 1}) + return await run_in_threadpool(apply) + @route("GET", "/admin/models") async def models_get(request): def build_models(): @@ -459,6 +491,68 @@ async def credentials_upload(request): body = await _body(request, MAX_UPLOAD_BYTES * 6 + 65536) return JSONResponse({"results": await run_in_threadpool(upload, body)}) + refresh_lock = threading.Lock() + + def refresh_import(body): + profile = body.get("profile") + if profile not in PROFILE_DOMAINS: + raise ValueError("端点必须为 cn-cli、cn-work、intl-cli 或 intl-work") + tokens = body.get("refresh_tokens") + if not isinstance(tokens, list) or not 1 <= len(tokens) <= 50: + raise ValueError("refresh_tokens 必须包含 1 至 50 项") + lines = [] + for item in tokens: + if not isinstance(item, str): + raise ValueError("每项必须是一个刷新令牌字符串") + token = item.strip() + if not token: + continue + if len(token) > 8192: + raise ValueError("刷新令牌长度超出限制") + if token not in lines: + lines.append(token) + if not lines: + raise ValueError("至少需要一个刷新令牌") + results = [] + directory = gateway.managed_auth_dir().resolve() + # Serialize the whole batch: parallel refreshes would trip upstream risk control. + with refresh_lock: + for token in lines: + uid = auth_oauth.token_uid(token) + try: + if not uid: + raise ValueError("刷新令牌缺少账号身份(sub)") + payload = auth_oauth.refresh_tokens(token, profile, uid=uid) + account = auth_oauth.fetch_account(payload["accessToken"], PROFILE_DOMAINS[profile]) + if account["uid"] != uid: + raise ValueError("账号信息与刷新令牌不一致") + cred = auth_oauth.build_auth_file(payload, account) + invalid = auth_oauth.validate_cred_data(cred)[1] + if invalid: + raise ValueError(invalid) + content = json.dumps(auth_oauth.normalize_cred_data(cred), ensure_ascii=False).encode("utf-8") + # Keep a product-specific name when the same account already exists for another endpoint. + name = f"{uid}.info" + if (directory / name).exists(): + name = f"{uid}-{profile}.info" + target = gateway._store_credential(directory, name, content, uid, replace_existing=False) + results.append({"ok": True, "name": Path(target).name, "uid": uid, "profile": profile}) + except (ValueError, RuntimeError) as error: + results.append({"ok": False, "name": uid or "未知账号", "uid": uid or None, + "profile": profile, "error": str(error)}) + except Exception: + results.append({"ok": False, "name": uid or "未知账号", "uid": uid or None, + "profile": profile, "error": "刷新或保存失败,请检查网络与存储状态"}) + event("credentials.refresh_imported", + {"profile": profile, "count": len(results), "succeeded": sum(item["ok"] for item in results)}) + return results + + @route("POST", "/admin/credentials/refresh-import") + async def credentials_refresh_import(request): + # 50 tokens of at most 8 KiB each, with JSON escaping, stay under 1 MiB. + body = await _body(request, 1024 * 1024) + return JSONResponse({"results": await run_in_threadpool(refresh_import, body)}) + def export(body): ids = body.get("ids") if body.get("confirm") is not True or not isinstance(ids, list) or not 1 <= len(ids) <= 100 or any(not isinstance(i, str) for i in ids): @@ -586,6 +680,9 @@ def build_dashboard(): result["official_credits"] = {row["id"]: {"credits": row.get("credits"), "name": row.get("name"), "fetched_at": (row.get("credits") or {}).get("fetched_at")} for row in rows} + notice = config_notice() + if notice: + result["config_notice"] = notice return result result = await run_in_threadpool(build_dashboard) return result if isinstance(result, Response) else JSONResponse(result) diff --git a/app/auth_oauth.py b/app/auth_oauth.py index 2cd3929..357cd8f 100644 --- a/app/auth_oauth.py +++ b/app/auth_oauth.py @@ -13,8 +13,8 @@ import httpx -from .client_profiles import canonical_uid -from .site_routing import profile_for_auth +from .client_profiles import canonical_uid, refresh_headers +from .site_routing import PROFILE_DOMAINS, PROFILE_ENDPOINTS, profile_for_auth, refresh_url_for_auth PLUGIN_PREFIX = "/v2/plugin" OAUTH_TIMEOUT_S = 600 # Authorization deadline in seconds @@ -62,6 +62,88 @@ def _token_issuer_origin(access_token: str) -> str: return "" +def token_uid(token) -> str: + """Read the account uid from a JWT subject, returning an empty string on failure.""" + if not isinstance(token, str) or token.count(".") != 2: + return "" + part = token.split(".")[1] + try: + claims = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))) + except Exception: + return "" + sub = claims.get("sub") if isinstance(claims, dict) else None + return sub.strip() if isinstance(sub, str) else "" + + +def refresh_tokens(refresh_token, profile, *, uid="") -> dict: + """Exchange a refresh token for a full token payload without an existing credential file. + + The endpoint is an explicit choice: a domestic issuer cannot tell the CLI and + WorkBuddy products apart, and a cross-region endpoint rejects the token. + """ + if not isinstance(refresh_token, str) or not refresh_token.strip(): + raise ValueError("刷新令牌为空") + if profile not in PROFILE_DOMAINS: + raise ValueError("未知的凭证端点") + token = refresh_token.strip() + account_uid = str(uid or token_uid(token) or "") + domain = PROFILE_DOMAINS[profile] + headers = refresh_headers(domain, account_uid) + headers["X-Refresh-Token"] = token + headers["X-Auth-Refresh-Source"] = "plugin" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT_S) as client: + response = client.post(refresh_url_for_auth({"domain": domain}), headers=headers, json={}) + except httpx.HTTPError as error: + raise RuntimeError(f"刷新令牌网络失败: {type(error).__name__}") from None + if response.status_code != 200: + raise RuntimeError(f"刷新令牌被拒绝(HTTP {response.status_code})") + try: + data = response.json() + except ValueError: + raise RuntimeError("刷新令牌返回无法解析") from None + payload = data.get("data") if isinstance(data, dict) else None + if not isinstance(data, dict) or data.get("code") != 0 or not isinstance(payload, dict): + raise RuntimeError("刷新令牌已失效或端点不匹配") + if not isinstance(payload.get("accessToken"), str) or not payload["accessToken"]: + raise RuntimeError("刷新令牌未返回有效的 accessToken") + if not isinstance(payload.get("refreshToken"), str) or not payload["refreshToken"]: + raise RuntimeError("刷新令牌未返回新的 refreshToken") + tokens = dict(payload) + tokens["domain"] = domain # Pin the selected product; the response only echoes X-Domain back. + return tokens + + +def fetch_account(access_token, domain) -> dict: + """Read the official account block for a token, including any tenant id. + + Refresh-token imports have no account payload, and an enterprise credential stored + with only a uid would bill through the personal endpoint and look empty. + """ + if not isinstance(access_token, str) or not access_token: + raise ValueError("缺少有效的 accessToken") + profile = profile_for_auth({"domain": domain}) + headers = refresh_headers(domain, "") + headers["Authorization"] = f"Bearer {access_token}" + url = PROFILE_ENDPOINTS[profile] + PLUGIN_PREFIX + "/login/account" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT_S) as client: + response = client.get(url, headers=headers) + except httpx.HTTPError as error: + raise RuntimeError(f"账号信息网络失败: {type(error).__name__}") from None + if response.status_code != 200: + raise RuntimeError(f"账号信息被拒绝(HTTP {response.status_code})") + try: + data = response.json() + except ValueError: + raise RuntimeError("账号信息返回无法解析") from None + account = data.get("data") if isinstance(data, dict) else None + if not isinstance(data, dict) or data.get("code") != 0 or not isinstance(account, dict): + raise RuntimeError("账号信息读取失败") + if not isinstance(account.get("uid"), str) or not account["uid"]: + raise RuntimeError("账号信息缺少 uid") + return account + def _reject_constant(value): raise ValueError(f"非标准 JSON 常量: {value}") diff --git a/app/client_profiles.py b/app/client_profiles.py index d6ceec9..8dcd7fd 100644 --- a/app/client_profiles.py +++ b/app/client_profiles.py @@ -56,6 +56,11 @@ def _auth_headers(auth: dict, account: dict) -> dict: def credential_headers(auth: dict, account: dict) -> dict: return {**SDK_HEADERS, **_auth_headers(auth, account)} +def refresh_headers(domain: str, uid: str = "") -> dict: + """Build headers for a refresh exchange that has no access token yet.""" + headers = {**SDK_HEADERS, **_auth_headers({"accessToken": "", "domain": domain}, {"uid": uid})} + headers.pop("Authorization", None) # The refresh token is the only credential in this request. + return headers def catalog_headers(auth: dict, account: dict | None = None, *, user_agent="") -> dict: headers = _auth_headers(auth, account or {}) diff --git a/app/config_migration.py b/app/config_migration.py new file mode 100644 index 0000000..040b78d --- /dev/null +++ b/app/config_migration.py @@ -0,0 +1,256 @@ +"""Offline `.env` -> control store migration with target identity checks. + +The service is the only writer of `control.sqlite3`; this module therefore refuses to +touch a database that is still leased by a running instance, and it verifies that the +target really is this project's control store before opening it read-write. +""" + +from __future__ import annotations + +from pathlib import Path +import io +import json +import os +import sqlite3 +import stat +import time +from urllib.parse import quote + +from dotenv import parser as dotenv_parser + +from .control_store import ControlStore +from .settings import SCHEMA, validate_settings + +MAX_ENV_BYTES = 256 * 1024 +LEASE_STALE_S = 90 # Three heartbeat intervals (see converter.LEASE_INTERVAL_S) +SQLITE_HEADER = b"SQLite format 3\x00" +CONTROL_TABLES = ("control", "runtime_state", "gateway_secrets") +SUPPORTED_VERSION = 2 + + +class MigrationError(ValueError): + """A user-facing refusal that leaves the target untouched.""" + + +def read_env_file(path) -> dict: + """Parse one env file without mutating `os.environ`; mirror startup's file checks.""" + path = Path(path) + try: + metadata = path.lstat() + except FileNotFoundError: + raise MigrationError(f"env 文件不存在: {path}") from None + if not stat.S_ISREG(metadata.st_mode): + raise MigrationError(f"env 文件必须是普通文件,且不支持符号链接: {path}") + if metadata.st_size > MAX_ENV_BYTES: + raise MigrationError(f"env 文件超过 {MAX_ENV_BYTES // 1024} KiB: {path}") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + fd = os.open(path, flags) + with os.fdopen(fd, "rb") as stream: + opened = os.fstat(stream.fileno()) + if (not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino)): + raise MigrationError(f"env 文件在读取期间发生变化: {path}") + content = stream.read(MAX_ENV_BYTES + 1) + if len(content) > MAX_ENV_BYTES: + raise MigrationError(f"env 文件超过 {MAX_ENV_BYTES // 1024} KiB: {path}") + text = content.decode("utf-8-sig") + values = {} + for binding in dotenv_parser.parse_stream(io.StringIO(text)): + if binding.error: + raise MigrationError(f"env 文件第 {binding.original.line} 行语法错误") + values[binding.key] = binding.value + return values + + +def resolve_targets(*, env_file=None, no_env=False, data_dir=None, db=None, + environ=None, cwd=None) -> dict: + """Resolve the env file and control database, honouring the file's own data directory.""" + environ = os.environ if environ is None else environ + cwd = Path.cwd() if cwd is None else Path(cwd) + if no_env and env_file: + raise MigrationError("--no-env 与 --env-file 不能同时使用") + if db and data_dir: + raise MigrationError("--db 与 --data-dir 不能同时使用") + env_path = None + env_values = {} + if not no_env: + env_path = Path(env_file).expanduser().resolve() if env_file else cwd / ".env" + if env_file or env_path.exists(): + env_values = read_env_file(env_path) + else: + env_path = None # A missing default file simply means "no .env". + if db: + db_path = Path(db).expanduser().resolve() + else: + base = data_dir or env_values.get("CODEBUDDY_AUTH_DIR") or environ.get("CODEBUDDY_AUTH_DIR") + if base: + base_path = Path(base).expanduser() + db_path = (base_path if base_path.is_absolute() else (cwd / base_path).resolve()) / "control.sqlite3" + else: + db_path = Path(__file__).resolve().parents[1] / "auth" / "control.sqlite3" + return {"env_path": env_path, "env_values": env_values, "db_path": db_path.resolve()} + + +def inspect_database(path) -> dict: + """Read-only identity check; never creates or upgrades the file.""" + path = Path(path) + if not path.exists(): + raise MigrationError( + f"控制库不存在: {path};请用 --data-dir 或 --db 指定,容器内通常是 --data-dir /data/auth") + if not path.is_file(): + raise MigrationError(f"控制库必须是普通文件: {path}") + if path.name == "logs.sqlite3": + raise MigrationError("目标是日志库 logs.sqlite3;迁移需要 control.sqlite3") + with path.open("rb") as stream: + header = stream.read(16) + if not header: + raise MigrationError(f"文件为空,不是 codebuddy2api 控制库: {path}") + if header != SQLITE_HEADER: + raise MigrationError(f"不是 SQLite 数据库: {path}") + uri = f"file:{quote(str(path))}?mode=ro" + try: + conn = sqlite3.connect(uri, uri=True, timeout=1, isolation_level=None) + except sqlite3.Error as error: + raise MigrationError(f"无法只读打开控制库: {error}") from None + try: + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version == 0: + raise MigrationError("目标不是 codebuddy2api 控制库(user_version=0,且未包含本项目数据表)") + if version != SUPPORTED_VERSION: + raise MigrationError(f"控制库 schema={version},需要先用当前版本服务启动一次完成升级") + tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + missing = [name for name in CONTROL_TABLES if name not in tables] + if missing: + raise MigrationError(f"目标缺少必需的数据表 {', '.join(missing)},不是 codebuddy2api 控制库") + row = conn.execute("SELECT revision,payload FROM control WHERE id=1").fetchone() + if row is None or type(row[0]) is not int or row[0] < 0: + raise MigrationError("控制库状态缺失或 revision 非法") + try: + payload = json.loads(row[1]) + except (ValueError, UnicodeError): + raise MigrationError("控制库状态不是有效 JSON") from None + if not isinstance(payload, dict) or not isinstance(payload.get("settings"), dict): + raise MigrationError("控制库状态缺少 settings,不是有效的控制库") + if conn.execute("PRAGMA quick_check").fetchone()[0] != "ok": + raise MigrationError("控制库 quick_check 失败;请先恢复备份") + lease = None + if "service_lease" in tables: + found = conn.execute("SELECT pid,started_at,heartbeat_at FROM service_lease WHERE id=1").fetchone() + if found: + lease = {"pid": found[0], "started_at": found[1], "heartbeat_at": found[2]} + return {"path": path, "version": version, "revision": row[0], "settings": payload["settings"], + "accounts": len(payload.get("credentials") or {}), "lease": lease, + "mtime": path.stat().st_mtime} + except sqlite3.Error as error: + raise MigrationError(f"读取控制库失败: {error}") from None + finally: + conn.close() + + +def lease_is_fresh(lease, *, now=None) -> bool: + """A heartbeat inside the stale window means an instance is still running.""" + now = time.time() if now is None else now + return bool(lease) and now - float(lease.get("heartbeat_at") or 0) < LEASE_STALE_S + + +def _coerce(spec, raw): + """Convert an env string to the schema type before validation runs.""" + if spec["type"] == "boolean": + lowered = str(raw).strip().lower() + if lowered not in ("1", "0", "true", "false", "yes", "no", "on", "off"): + raise ValueError("布尔值无效") + return lowered in ("1", "true", "yes", "on") + if spec["type"] == "integer": + return int(str(raw).strip()) + if spec["type"] == "number": + return float(str(raw).strip()) + return raw + + +def collect_runtime_values(env_values, environ=None) -> dict: + """Effective values for runtime-scoped keys; process env wins, exactly like startup.""" + environ = os.environ if environ is None else environ + collected = {} + for key, spec in SCHEMA.items(): + name = spec.get("env") + if not name or spec.get("sensitive") or spec.get("hidden") or spec.get("env_scope") != "runtime": + continue + raw = environ.get(name, env_values.get(name)) + if raw is None: + continue + try: + collected[key] = validate_settings({key: _coerce(spec, raw)})[key] + except (ValueError, TypeError) as error: + raise MigrationError(f"{name}: {error}") from None + return collected + + +def plan(values, settings, *, keys=None) -> dict: + """Split effective env values into pending changes and already-equal entries.""" + if keys: + unknown = sorted(set(keys) - set(SCHEMA)) + if unknown: + raise MigrationError(f"未知配置项: {', '.join(unknown)}") + values = {key: value for key, value in values.items() if key in keys} + changes = {key: value for key, value in values.items() if settings.get(key) != value} + unchanged = {key: value for key, value in values.items() if settings.get(key) == value} + return {"changes": changes, "unchanged": unchanged} + + +def _probe_write_lock(path) -> None: + """Fail fast when another process holds the write lock at this moment.""" + try: + conn = sqlite3.connect(str(path), timeout=0, isolation_level=None) + except sqlite3.Error as error: + raise MigrationError(f"无法打开控制库: {error}") from None + try: + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute("ROLLBACK") + except sqlite3.OperationalError: + raise MigrationError("控制库正在被写入,请先停止服务后重试") from None + finally: + conn.close() + + +def migrate(*, env_file=None, no_env=False, data_dir=None, db=None, dry_run=False, + force=False, keys=None, language=None, environ=None, cwd=None) -> dict: + """Copy runtime-scoped env values into the control store and mute the notice.""" + targets = resolve_targets(env_file=env_file, no_env=no_env, data_dir=data_dir, db=db, + environ=environ, cwd=cwd) + info = inspect_database(targets["db_path"]) + lease = info["lease"] + report = {"env_path": targets["env_path"], "db_path": targets["db_path"], + "version": info["version"], "revision": info["revision"], + "accounts": info["accounts"], "mtime": info["mtime"], + "lease": lease, "lease_fresh": lease_is_fresh(lease), + "dry_run": dry_run, "written": False, "muted": False} + if report["lease_fresh"] and not force: + raise MigrationError( + f"检测到运行中的实例(pid {lease.get('pid')},心跳 {int(time.time() - lease['heartbeat_at'])} 秒前);" + "请先停止服务,或确认后使用 --force") + values = collect_runtime_values(targets["env_values"], environ) + report.update(plan(values, info["settings"], keys=keys)) + language_update = bool(language) and info["settings"].get("language") != language + report["language"] = language if language_update else None + if dry_run: + return report + if not report["changes"] and not language_update: + report["muted"] = bool(info["settings"].get("config_notice_muted")) + return report + _probe_write_lock(targets["db_path"]) + store = ControlStore(targets["db_path"]) + try: + snapshot = store.snapshot() + if snapshot["revision"] != info["revision"]: + raise MigrationError("控制库在检查后被其它进程修改,请重新执行") + updates = {**report["changes"], "config_notice_muted": True} + if language_update: + updates["language"] = language + store.update_settings(updates, snapshot["revision"]) + report["written"] = True + report["muted"] = True + report["revision"] = snapshot["revision"] + 1 + finally: + store.close() + return report diff --git a/app/control_store.py b/app/control_store.py index c4a6236..c8e063b 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -4,6 +4,7 @@ import copy import json from datetime import date +import os from pathlib import Path import sqlite3 import threading @@ -134,6 +135,9 @@ def __init__(self, path): self._db.execute("CREATE TABLE IF NOT EXISTS runtime_state (name TEXT PRIMARY KEY, payload TEXT NOT NULL)") self._db.execute("CREATE TABLE IF NOT EXISTS state_imports (name TEXT PRIMARY KEY, imported INTEGER NOT NULL, migrated_at REAL NOT NULL)") self._db.execute("CREATE TABLE IF NOT EXISTS gateway_secrets (name TEXT PRIMARY KEY, value TEXT NOT NULL, announced INTEGER NOT NULL DEFAULT 0 CHECK(announced IN (0,1)))") + # Running-instance lease; heartbeat writes bypass the settings revision on purpose. + self._db.execute("CREATE TABLE IF NOT EXISTS service_lease (id INTEGER PRIMARY KEY CHECK(id=1), " + "pid INTEGER NOT NULL, started_at REAL NOT NULL, heartbeat_at REAL NOT NULL)") self._db.execute("PRAGMA user_version=2") self._db.execute("COMMIT") except Exception: @@ -572,6 +576,32 @@ def prune_daily_chats(self, keep_days=30): self._db.execute("DELETE FROM daily_chats WHERE day None: + """Mark this process as the running instance so offline tools can refuse to write.""" + now = time.time() + with self._lock: + self._db.execute( + "INSERT INTO service_lease (id, pid, started_at, heartbeat_at) VALUES (1, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET pid=excluded.pid, started_at=excluded.started_at, " + "heartbeat_at=excluded.heartbeat_at", (os.getpid(), now, now)) + + def heartbeat_service_lease(self) -> None: + """Refresh only our own lease row; a superseded process never extends another's.""" + with self._lock: + self._db.execute("UPDATE service_lease SET heartbeat_at=? WHERE id=1 AND pid=?", + (time.time(), os.getpid())) + + def release_service_lease(self) -> None: + """Drop a cleanly stopped process's lease so an offline migration need not wait it out.""" + with self._lock: + self._db.execute("DELETE FROM service_lease WHERE id=1 AND pid=?", (os.getpid(),)) + + def service_lease(self) -> dict | None: + with self._lock: + row = self._db.execute( + "SELECT pid, started_at, heartbeat_at FROM service_lease WHERE id=1").fetchone() + return {"pid": row[0], "started_at": row[1], "heartbeat_at": row[2]} if row else None + def close(self): with self._lock: self._db.close() diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..86e45bb --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,74 @@ +"""Minimal CLI translation layer; the WebUI keeps its own client-side catalog.""" + +from __future__ import annotations + +import os + +CHOICES = ("auto", "zh", "en") +_FALLBACK = "zh" + +MESSAGES = { + "config.usage": { + "zh": "请指定 migrate 或 status:python3 converter.py config migrate", + "en": "Name migrate or status: python3 converter.py config migrate", + }, + "config.envFile": {"zh": "env 文件 : {path}", "en": "env file : {path}"}, + "config.noEnv": {"zh": "(未读取 env 文件)", "en": "(no env file)"}, + "config.target": {"zh": "目标库 : {path}", "en": "target : {path}"}, + "config.schema": { + "zh": "schema : user_version={version} · revision={revision} · 账号 {accounts}", + "en": "schema : user_version={version} · revision={revision} · accounts {accounts}", + }, + "config.running": { + "zh": "运行中(pid {pid},{age} 秒前心跳)", + "en": "running (pid {pid}, heartbeat {age}s ago)", + }, + "config.released": {"zh": "已释放(心跳过期)", "en": "released (stale heartbeat)"}, + "config.idle": {"zh": "无运行中实例", "en": "no running instance"}, + "config.state": {"zh": "运行状态 : {state}", "en": "state : {state}"}, + "config.pending": {"zh": "待迁移 : {count} 项", "en": "pending : {count}"}, + "config.unchanged": {"zh": "已一致 : {count} 项", "en": "unchanged: {count}"}, + "config.dryRun": {"zh": "dry-run:未写入任何内容", "en": "dry run: nothing was written"}, + "config.noChanges": { + "zh": "没有需要迁移的差异;提醒开关保持原值", + "en": "no differences to migrate; the notice flag is unchanged", + }, + "config.written": { + "zh": "已写入 {count} 项,revision -> {revision},迁移提醒已关闭", + "en": "wrote {count} item(s), revision -> {revision}, migration notice disabled", + }, + "config.language": { + "zh": "界面语言已保存为 {language}", + "en": "interface language saved as {language}", + }, + "config.removeEnv": { + "zh": "可以删除或注释 .env 中以下行(之后这些项改为配置库提供,可在 WebUI 修改):", + "en": "You can delete or comment these .env lines; the WebUI then supplies and edits them:", + }, +} + + +def system_language() -> str: + """Map the process locale to a supported language; default Chinese.""" + raw = (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG") or "") + return "zh" if raw.lower().startswith("zh") else ("en" if raw else _FALLBACK) + + +def resolve_language(value=None) -> str: + """Resolve auto/zh/en (from CLI, config or locale) to `zh` or `en`.""" + if value not in CHOICES: + return system_language() + if value == "auto": + return system_language() + return value + + +def normalize(value): + """Validate a stored/CLI language value, returning None when unacceptable.""" + return value if value in CHOICES else None + + +def tr(lang, key, **values) -> str: + entry = MESSAGES.get(key, {}) + template = entry.get(lang) or entry.get(_FALLBACK) or key + return template.format(**values) if values else template diff --git a/app/settings.py b/app/settings.py index c2cbebd..d3d19c6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -59,9 +59,10 @@ def validate_projection_max_bytes(value): def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=None, choices=None, sensitive=False, allow_empty=False, max_length=255, validator=None, - hidden=False): + hidden=False, env_scope="runtime", lockable=True): value = {"default": default, "type": type_, "label": label, "mode": mode, - "env": env, "sensitive": sensitive, "hidden": hidden} + "env": env, "sensitive": sensitive, "hidden": hidden, + "env_scope": env_scope, "lockable": lockable} if minimum is not None: value["min"] = minimum if maximum is not None: @@ -78,13 +79,13 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= SCHEMA = { - "host": _item("127.0.0.1", "string", "监听地址", mode="restart", env="CODEBUDDY2API_BIND"), - "port": _item(8787, "integer", "监听端口", mode="restart", env="CODEBUDDY2API_PORT", minimum=1, maximum=65535), - "api_key": _item(None, "secret", "管理与推理密钥", mode="startup", env="CODEBUDDY2API_KEY", sensitive=True), - "auth_file": _item(None, "paths", "显式凭证文件", mode="startup", sensitive=True), - "auth_dir": _item(None, "path", "凭证目录", mode="startup", env="CODEBUDDY_AUTH_DIR", sensitive=True), - "import_dir": _item(None, "path", "导入目录", mode="startup", env="CODEBUDDY_IMPORT_DIR", sensitive=True), - "log_path": _item(None, "path", "旧文本日志(已停用)", mode="startup", env="CODEBUDDY2API_LOG", sensitive=True, hidden=True), + "host": _item("127.0.0.1", "string", "监听地址", mode="restart", env="CODEBUDDY2API_BIND", env_scope="deployment"), + "port": _item(8787, "integer", "监听端口", mode="restart", env="CODEBUDDY2API_PORT", minimum=1, maximum=65535, env_scope="deployment"), + "api_key": _item(None, "secret", "管理与推理密钥", mode="startup", env="CODEBUDDY2API_KEY", sensitive=True, env_scope="deployment"), + "auth_file": _item(None, "paths", "显式凭证文件", mode="startup", sensitive=True, env_scope="deployment"), + "auth_dir": _item(None, "path", "凭证目录", mode="startup", env="CODEBUDDY_AUTH_DIR", sensitive=True, env_scope="deployment"), + "import_dir": _item(None, "path", "导入目录", mode="startup", env="CODEBUDDY_IMPORT_DIR", sensitive=True, env_scope="deployment"), + "log_path": _item(None, "path", "旧文本日志(已停用)", mode="startup", env="CODEBUDDY2API_LOG", sensitive=True, hidden=True, env_scope="deployment"), "admin_allowed_origins": _item("", "string", "管理页额外信任来源", env="CODEBUDDY2API_ADMIN_ORIGINS", allow_empty=True, max_length=2000, validator=normalize_allowed_origins), "desensitize": _item(False, "boolean", "提示词脱敏"), @@ -126,6 +127,11 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "trusted_proxies": _item("", "string", "可信代理地址(IP/CIDR,逗号分隔)", env="CODEBUDDY2API_TRUSTED_PROXIES", allow_empty=True, max_length=2000, validator=normalize_trusted_proxies), + # Interface preference: WebUI and `--lang` write it, nothing maps it from the environment. + "language": _item("auto", "string", "界面语言", choices=["auto", "zh", "en"], + env_scope="ui", lockable=False), + # One-time migration notice; written by `config migrate` and the WebUI dismiss button. + "config_notice_muted": _item(False, "boolean", "配置迁移提醒已关闭", hidden=True, env_scope="none"), } @@ -199,7 +205,8 @@ def resolve_settings(config): if spec.get("hidden"): # Retired knobs stay out of the management UI. continue source = sources.get(key, "default") - locked = spec["sensitive"] or source in ("cli", "environment", "env", "dotenv") + locked = spec.get("lockable", True) and ( + spec["sensitive"] or source in ("cli", "environment", "env", "dotenv")) item = {"key": key, "value": None if spec["sensitive"] else (config[key] if config.get(key) is not None else spec["default"]), "stored": None if spec["sensitive"] else saved.get(key), "source": source, "mode": spec["mode"], "type": spec["type"], "label": spec["label"], "locked": locked} diff --git a/app/site_routing.py b/app/site_routing.py index 26d79e7..42c41b8 100644 --- a/app/site_routing.py +++ b/app/site_routing.py @@ -23,6 +23,13 @@ "www.codebuddy.ai": "intl-cli", "www.workbuddy.ai": "intl-work", } +# Credential domain stored for each product; refresh echoes X-Domain back as auth.domain. +PROFILE_DOMAINS = { + "cn-cli": "www.codebuddy.cn", + "cn-work": "www.workbuddy.cn", + "intl-cli": "www.codebuddy.ai", + "intl-work": "www.workbuddy.ai", +} # Verified CLI and WorkBuddy packages share these relative authentication paths. _REFRESH_PATH = "/v2/plugin/auth/token/refresh" diff --git a/app/startup.py b/app/startup.py index d68c88f..1d72357 100644 --- a/app/startup.py +++ b/app/startup.py @@ -3,6 +3,8 @@ import io import os +import signal +import threading from pathlib import Path import stat @@ -97,7 +99,32 @@ def announce_default_key(config): config["announce_default_key"] = False + + +def _install_lease_release(config): + """Release the running-instance lease before uvicorn re-raises the stop signal. + + uvicorn restores the original handlers and re-raises the captured signal, so a + `finally` around `Server.run` never executes on SIGTERM. Our handler is that + original, so it runs once more and then re-delivers the signal with defaults. + """ + store = config.get("control_store") + if store is None or threading.current_thread() is not threading.main_thread(): + return + + def release(signum, _frame): + try: + store.release_service_lease() + finally: + signal.signal(signum, signal.SIG_DFL) + os.kill(os.getpid(), signum) + + for signum in (signal.SIGTERM, signal.SIGINT): + signal.signal(signum, release) + + def run_server(app, config, *, host, port): + _install_lease_release(config) if not config.get("announce_default_key"): return uvicorn.run(app, host=host, port=port, log_level="warning") diff --git a/converter.py b/converter.py index 3a3fb04..35b3cc1 100644 --- a/converter.py +++ b/converter.py @@ -49,6 +49,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, ) from app import auth_oauth +from app.settings import SCHEMA from app import trial_rewards from app import buddy, daily_chat, checkin as checkin_service, model_policy, travel from app.credential_cooldowns import CredentialCooldowns @@ -1875,6 +1876,19 @@ def _housekeep_once(pool: CredentialPool, ledger, *, pending_only=False): pool.end_sync(ids, failed) +LEASE_INTERVAL_S = 30 # Running-instance heartbeat; offline tools treat older leases as stale. + + +def _lease_loop(store) -> None: + """Refresh the running-instance lease without touching the settings revision.""" + while True: + time.sleep(LEASE_INTERVAL_S) + try: + store.heartbeat_service_lease() + except Exception: + pass + + def _housekeeper_loop(pool: CredentialPool, ledger) -> None: """Wake for new credentials, retry failed work with backoff, and run hourly maintenance.""" next_full = time.monotonic() + CHECKIN_FIRST_DELAY @@ -3240,7 +3254,7 @@ def _network_error_text(error: Exception) -> str: return sanitize_log_text(f"{type(error).__name__}: {str(error).strip() or 'upstream transport failed'}", 512) async def _coalesce_reasoning_sse(lines, *, max_bytes=0): - """Coalesce normalized Chat SSE reasoning before downstream protocol adapters.""" + """Replay one reasoning block for compatible mode; realtime skips this wrapper.""" pending: list[str] = [] template = None buffer_budget = StreamOutputBudget(max_bytes) @@ -3583,9 +3597,11 @@ async def _stream_upstream(url: str, headers: dict, body: dict, model_name: str = "?", t0: float = 0.0, rid: str = "", cred=None): policy = body.pop(_REQUEST_POLICY_KEY, None) or _snapshot_stream_policy("chat", body) sent = False - upstream = _coalesce_reasoning_sse(_chat_sse_lines( - url, headers, body, model_name, t0, rid, cred, policy=policy), - max_bytes=policy.max_collect_bytes) + # Realtime delivers every reasoning delta as it arrives; only compatible keeps the + # historical single-block replay that hides the reasoning timeline from clients. + upstream = _chat_sse_lines(url, headers, body, model_name, t0, rid, cred, policy=policy) + if not policy.realtime: + upstream = _coalesce_reasoning_sse(upstream, max_bytes=policy.max_collect_bytes) try: try: async for line in upstream: @@ -4005,10 +4021,10 @@ async def _stream_adapted(url, headers, body, model_name, t0, rid, cred=None, *, parallel_tool_calls=body.get("parallel_tool_calls", True), tool_registry=tool_registry)) sent = False - upstream = _coalesce_reasoning_sse(_chat_sse_lines( - url, headers, body, model_name, t0, rid, cred, - policy=policy, tracker=tracker, state=state), - max_bytes=policy.max_collect_bytes) + upstream = _chat_sse_lines(url, headers, body, model_name, t0, rid, cred, + policy=policy, tracker=tracker, state=state) + if not policy.realtime: + upstream = _coalesce_reasoning_sse(upstream, max_bytes=policy.max_collect_bytes) try: try: async for line in upstream: @@ -4278,11 +4294,155 @@ def _boolean_arg(value): raise argparse.ArgumentTypeError("必须为 true 或 false") +def _is_config_invocation(argv) -> bool: + """True when the first non-option token is the offline config command.""" + for token in argv: + if token.startswith("-"): + continue + return token == "config" + return False + + +def _persist_cli_language(language, store=None, config=None) -> None: + """`--lang` writes the interface language once; an unchanged value never bumps the revision.""" + if not language: + return + owned = store is None + if store is None: + from app.control_store import ControlStore + path = managed_auth_dir() / "control.sqlite3" + if not path.exists(): + sys.stderr.write("[lang] 配置库尚未创建,语言仅本次运行生效\n") + return + store = ControlStore(path) + try: + snapshot = store.snapshot() + if snapshot["settings"].get("language") != language: + store.update_settings({"language": language}, snapshot["revision"]) + if config is not None: + config["language"] = language + config.setdefault("settings_sources", {})["language"] = "cli" + except Exception as error: + sys.stderr.write(f"[lang] 语言未保存: {error}\n") + finally: + if owned: + store.close() + + +def _config_keys(raw): + keys = [item.strip() for item in str(raw or "").split(",") if item.strip()] + return keys or None + + +def _config_status_lines(report, lang) -> list: + from app import i18n as cli_i18n + + env_path = report["env_path"] or cli_i18n.tr(lang, "config.noEnv") + lease = report["lease"] + if lease and report["lease_fresh"]: + lease_text = cli_i18n.tr(lang, "config.running", pid=lease["pid"], + age=int(time.time() - lease["heartbeat_at"])) + elif lease: + lease_text = cli_i18n.tr(lang, "config.released") + else: + lease_text = cli_i18n.tr(lang, "config.idle") + pending = cli_i18n.tr(lang, "config.pending", count=len(report["changes"])) + if report["changes"]: + pending += f"({', '.join(report['changes'])})" if lang == "zh" else f" ({', '.join(report['changes'])})" + return [ + cli_i18n.tr(lang, "config.envFile", path=env_path), + cli_i18n.tr(lang, "config.target", path=report["db_path"]), + cli_i18n.tr(lang, "config.schema", version=report["version"], + revision=report["revision"], accounts=report["accounts"]), + cli_i18n.tr(lang, "config.state", state=lease_text), + pending, + cli_i18n.tr(lang, "config.unchanged", count=len(report["unchanged"])), + ] + + +def config_command(args) -> int: + """Offline `config migrate|status`; never starts the server or touches credentials.""" + from app import config_migration + from app import i18n as cli_i18n + + if args.action is None: + sys.stderr.write(cli_i18n.tr(cli_i18n.resolve_language(args.lang), "config.usage") + "\n") + return 2 + keys = _config_keys(args.keys) + options = {"env_file": args.env_file, "no_env": args.no_env, + "data_dir": args.data_dir, "db": args.db} + try: + if args.action == "status": + targets = config_migration.resolve_targets(**options) + info = config_migration.inspect_database(targets["db_path"]) + values = config_migration.collect_runtime_values(targets["env_values"]) + planned = config_migration.plan(values, info["settings"], keys=keys) + report = {"env_path": targets["env_path"], "db_path": targets["db_path"], + "version": info["version"], "revision": info["revision"], + "accounts": info["accounts"], "lease": info["lease"], + "lease_fresh": config_migration.lease_is_fresh(info["lease"]), + **planned} + lang = cli_i18n.resolve_language(args.lang or info["settings"].get("language")) + for line in _config_status_lines(report, lang): + print(line) + return 0 + report = config_migration.migrate(**options, dry_run=args.dry_run, force=args.force, + keys=keys, language=args.lang) + except config_migration.MigrationError as error: + sys.stderr.write(f"[config] {error}\n") + return 2 + lang = cli_i18n.resolve_language(args.lang or report.get("language") + or config_migration.inspect_database(report["db_path"])["settings"].get("language")) + for line in _config_status_lines(report, lang): + print(line) + if report["dry_run"]: + print(cli_i18n.tr(lang, "config.dryRun")) + return 0 + if not report["written"]: + print(cli_i18n.tr(lang, "config.noChanges")) + return 0 + env_names = [] + for key, value in report["changes"].items(): + name = SCHEMA[key].get("env") + if name: + rendered = str(value).lower() if isinstance(value, bool) else value + env_names.append(f"{name}={rendered}") + print(cli_i18n.tr(lang, "config.written", count=len(report["changes"]), + revision=report["revision"])) + if report.get("language"): + print(cli_i18n.tr(lang, "config.language", language=report["language"])) + if env_names: + print(cli_i18n.tr(lang, "config.removeEnv")) + for line in env_names: + print(f" {line}") + return 0 + + def main(): - dotenv_keys = set() if any(arg in ("-h", "--help") for arg in sys.argv[1:]) else load_startup_env() + offline_config = _is_config_invocation(sys.argv[1:]) + if any(arg in ("-h", "--help") for arg in sys.argv[1:]) or offline_config: + dotenv_keys = set() + else: + dotenv_keys = load_startup_env() ap = argparse.ArgumentParser(description="CodeBuddy -> OpenAI 兼容转换器(直连后端)") - ap.add_argument("command", nargs="?", choices=("serve", "login"), default="serve", - help="serve 启动服务(默认);login 扫码登录、自动轮询并保存账号") + ap.add_argument("command", nargs="?", choices=("serve", "login", "config"), default="serve", + help="serve 启动服务(默认);login 扫码登录;config 离线配置迁移(migrate/status)") + ap.add_argument("action", nargs="?", choices=("migrate", "status"), + help="config 子命令:migrate 写入配置库;status 只读查看解析结果") + ap.add_argument("--env-file", metavar="PATH", + help="config 读取的 env 文件;默认当前目录 .env") + ap.add_argument("--no-env", action="store_true", + help="config 不读取 env 文件,只使用进程环境变量") + ap.add_argument("--data-dir", metavar="PATH", + help="config 目标数据目录(其中含 control.sqlite3);默认按 CODEBUDDY_AUTH_DIR 解析") + ap.add_argument("--db", metavar="PATH", + help="config 直接指定 control.sqlite3 路径") + ap.add_argument("--dry-run", action="store_true", help="config migrate 只打印差异,不写库") + ap.add_argument("--keys", metavar="LIST", help="config 仅处理逗号分隔的配置项") + ap.add_argument("--force", action="store_true", + help="config migrate 在检测到运行中实例时仍然写入(危险)") + ap.add_argument("--lang", choices=("auto", "zh", "en"), + help="界面与本次 CLI 输出语言;带该参数启动会把语言写入配置库(config status 只读)") ap.add_argument("--site", choices=tuple(auth_oauth.SITE_HOSTS), default="cn", help="login 站点:cn 国内站(默认);intl 国际 WorkBuddy;intl-codebuddy 国际 CodeBuddy") ap.add_argument("--no-browser", action="store_true", @@ -4384,7 +4544,10 @@ def main(): del args.auto_trial if args.image_policy not in ("truncate", "error"): ap.error("CODEBUDDY2API_IMAGE_POLICY 必须为 truncate 或 error") + if args.command == "config": + return config_command(args) if args.command == "login": + _persist_cli_language(args.lang) return login(site=args.site, open_browser=not args.no_browser) for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", @@ -4405,6 +4568,9 @@ def main(): CONFIG["log_path"] = None from app import runtime_management runtime_management.initialize(sys.modules[__name__], args, parser=ap, dotenv_keys=dotenv_keys) + # The running-instance lease is claimed with the other background threads, after + # catalog and usage hydration, so an offline tool never races the first write. + control_store = CONFIG.get("control_store") # Validate effective binding and authentication before credential scans or background work. if (args.host not in ("127.0.0.1", "::1", "localhost") and not CONFIG.get("api_key") and not allows_open_noauth()): @@ -4437,6 +4603,11 @@ def main(): # a later start under the superseded key could adopt it and revive admin cookies. runtime_management.close(CONFIG) ap.error(str(error)) + if control_store is not None: + control_store.claim_service_lease() + _persist_cli_language(args.lang, control_store, CONFIG) + threading.Thread(target=_lease_loop, args=(control_store,), + daemon=True, name="service-lease").start() threading.Thread(target=_refresher_loop, args=(CONFIG["cred_pool"],), daemon=True, name="cred-refresher").start() if credits_mod is not None: @@ -4480,6 +4651,11 @@ def main(): try: run_server(app, CONFIG, host=args.host, port=args.port) finally: + if CONFIG.get("control_store") is not None: + try: + CONFIG["control_store"].release_service_lease() + except Exception: + pass runtime_management.close(CONFIG) diff --git a/docker-compose.yml b/docker-compose.yml index 850317b..79e2ee6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ -# Support legacy Compose syntax; shell variables override automatic .env interpolation. +# Support legacy Compose syntax; .env is loaded wholesale through env_file. version: "3.8" services: codebuddy2api: - image: ${CODEBUDDY2API_IMAGE:-ghcr.io/maiphucgiang/codebuddy2api:1.3.6} + image: ${CODEBUDDY2API_IMAGE:-ghcr.io/maiphucgiang/codebuddy2api:latest} build: . container_name: codebuddy2api ports: @@ -12,45 +12,15 @@ services: volumes: # Persist credentials, SQLite databases and WAL/SHM files in one writable local directory. - "${CODEBUDDY2API_AUTH_PATH:-./auth}:/data/auth" + env_file: + # Every key in .env reaches the container; keys the gateway does not know are ignored. + # Optional, so a missing .env keeps the gateway defaults and settings saved in SQLite. + - path: ${CODEBUDDY2API_ENV_FILE:-.env} + required: false environment: - CODEBUDDY2API_KEY: ${CODEBUDDY2API_KEY:-} - CODEBUDDY2API_ADMIN_CSRF: ${CODEBUDDY2API_ADMIN_CSRF:-true} - # Daily guards key on a local calendar day, so the container should share the - # operator's zone; a UTC container turns those guards over at 08:00 Beijing. - TZ: ${TZ:-UTC} - # First-Buddy onboarding may send one bounded real WorkBuddy conversation per account. - CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} - # Unset variables remain configurable through the WebUI. - CODEBUDDY2API_ADMIN_ORIGINS: - # Trusted proxy IPs/CIDRs; forwarded headers count only from these peers. - CODEBUDDY2API_TRUSTED_PROXIES: - CODEBUDDY2API_KEEP_TOOL_METADATA: - CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} - CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} - CODEBUDDY2API_MAX_REQUEST_BYTES: ${CODEBUDDY2API_MAX_REQUEST_BYTES:-33554432} - CODEBUDDY2API_LOG_BODY_LIMIT: ${CODEBUDDY2API_LOG_BODY_LIMIT:-65536} - CODEBUDDY2API_LOG: ${CODEBUDDY2API_LOG:-} - CODEBUDDY2API_MAX_INBOUND_BYTES: ${CODEBUDDY2API_MAX_INBOUND_BYTES:-67108864} - CODEBUDDY2API_MAX_COLLECT_BYTES: ${CODEBUDDY2API_MAX_COLLECT_BYTES:-8388608} - CODEBUDDY2API_MAX_CONCURRENT: ${CODEBUDDY2API_MAX_CONCURRENT:-64} - CODEBUDDY2API_UPSTREAM_KEEPALIVE: - CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT: - CODEBUDDY2API_REQUEST_CONTEXT_MODE: - CODEBUDDY2API_RESPONSES_PROJECTION_MODE: - CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES: - CODEBUDDY2API_STREAM_MODE: - CODEBUDDY2API_MODEL_CAPABILITY_GUARD: - CODEBUDDY2API_TOOL_CALL_MAX_RETRY: ${CODEBUDDY2API_TOOL_CALL_MAX_RETRY:-3} - CODEBUDDY2API_FAILOVER_MAX: - CODEBUDDY2API_RETRY_WRITE_TIMEOUT: - CODEBUDDY_IMPORT_DIR: ${CODEBUDDY_IMPORT_DIR:-/data/auth/imports} - # Bind container interfaces; host port mapping controls external access. - CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" + # Container paths stay fixed; a host-side CODEBUDDY_AUTH_DIR would point outside the mount. CODEBUDDY_AUTH_DIR: /data/auth - command: > - python3 converter.py - --host 0.0.0.0 - --port 8787 - --skip-check - --desensitize + # Bind every container interface and let the host mapping control external exposure. + CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" + # The image CMD already runs converter.py with --host 0.0.0.0, --port 8787 and --desensitize. restart: unless-stopped diff --git a/docs/advanced.md b/docs/advanced.md index 17150de..c2a3d23 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -8,7 +8,11 @@ Use the [WebUI](webui.md) for everyday management. See [deployment](deployment.m Precedence: **explicit CLI flags > process environment > `.env` > saved SQLite settings > defaults**. Hot settings apply immediately; restart-marked settings require a manual restart. Change locked options at their source; the WebUI does not edit `.env` or expose API keys. -Compose explicitly passes some environment variables and CLI flags, so deleting a line from `.env` may not unlock it. Recreate the container after changing these values; to let the WebUI manage them, also remove the corresponding explicit Compose settings. +Compose loads every key in `.env` into the container, so commenting out a line is enough to unlock it; only `CODEBUDDY_AUTH_DIR` and `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` are fixed by the Compose file. Recreate the container after changing these values. + +Runtime keys in `.env` stay read-only in the WebUI until they are moved into the configuration store: stop the service and run `python3 converter.py config migrate` (see [configuration migration](deployment.md#configuration-migration)). The Dashboard shows a one-time banner listing those keys; migrating or pressing "Do not remind me" records `config_notice_muted` in the store and stops the reminder, including for later `.env` additions. + +`language` (`auto|zh|en`) is the only interface preference the WebUI and the CLI share: it has no environment mapping and is never locked. `--lang` writes it on `serve`, `login` and `config migrate`; `config status` is read-only and only affects that invocation's output. | Flag | Default | Description | |------|---------|-------------| @@ -45,11 +49,11 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--max-request-bytes` | `33554432` | Positive byte limit for processed upstream JSON, excluding gateway-only metadata | | `--log-body-limit` | `65536` | Legacy text-preview option; text output is retired and SQLite diagnostics use their own budget | -Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_ADMIN_ORIGINS`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_STREAM_MODE`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_RESPONSES_PROJECTION_MODE`, `CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. +Every active environment variable is listed with its default in `.env.example`; startup examples are in [deployment](deployment.md). ### Responses projection -Configure these hot settings through the WebUI, CLI, process environment or `.env`. Precedence is CLI > process environment > `.env` > saved SQLite value > default; CLI/environment values lock the WebUI fields. Compose forwards a variable only when the host environment sets it, so leaving both unset keeps the WebUI editable. +Configure these hot settings through the WebUI, CLI, process environment or `.env`. Precedence is CLI > process environment > `.env` > saved SQLite value > default; CLI/environment values lock the WebUI fields. Compose loads `.env` wholesale, so a commented-out line keeps the WebUI editable. `responses_projection_mode` defaults to `balanced` and accepts only `balanced` or `passthrough`. Balanced mode rewrites only recognized harness blocks that have stable summaries; text outside those blocks is not budgeted or truncated. It also applies Codex-style head/tail truncation to generated assistant content, complete tool-argument JSON and tool results according to `responses_projection_max_bytes`. Text markers report original bytes, estimated tokens and total lines. Oversized tool arguments remain valid JSON and use a bounded object containing the original head, tail and size metadata. Passthrough disables Responses projection completely. @@ -63,16 +67,16 @@ Responses projection no longer changes tool definitions or schemas. When desensi - **WebUI:** Settings → Keep tool descriptions; unlocked changes apply immediately and persist. - **CLI:** append `--keep-tool-metadata` or `--keep-tool-metadata true` to the existing command; explicit `false` overrides the environment. -- **Environment:** set `CODEBUDDY2API_KEEP_TOOL_METADATA=true`. Compose passes it only when set, leaving the WebUI unlocked otherwise. Remove or comment out the variable to remove the environment lock; do not set an empty string. +- **Environment:** set `CODEBUDDY2API_KEEP_TOOL_METADATA=true` in `.env`; comment the line out to remove the lock, and do not set an empty string. -Use a source/image build and Compose configuration containing this feature; recreate containers after changing its environment. Retained descriptions may increase input tokens and content-filter risk; set `false` to restore the desensitization default. This setting does not change Responses balanced/passthrough mode or relax request-size limits. +Use an image or source build containing this feature; recreate the container after changing its environment. Retained descriptions may increase input tokens and content-filter risk; set `false` to restore the desensitization default. This setting does not change Responses balanced/passthrough mode or relax request-size limits. ### Streaming modes `stream_mode` defaults to `compatible`. Configure it through `--stream-mode compatible|realtime`, `CODEBUDDY2API_STREAM_MODE`, or the WebUI enum; explicit CLI/environment sources lock that field. Every generation request, including non-streaming, records the selected mode and freezes it with `max_collect_bytes` before routing. Hot changes affect only later requests, not in-flight failovers. Non-streaming responses remain aggregated JSON; there is no client mode override. - `compatible` preserves existing behavior: Responses streams aggregate first; Chat and Messages aggregate when tools are present and otherwise pass through upstream increments. Aggregated output is validated and replayed in fragments. Non-stream requests always use the validated aggregate path in either mode. -- `realtime` forwards text, refusal and tool-argument increments for all three protocols, while coalescing reasoning fragments into one delta before visible output so clients keep one thinking section. Responses assigns stable indexes when items start; Anthropic uses stable block indexes. Adapters buffer tools with missing identity until the argument phase or terminal marker, append metadata fragments without guessing from prefixes, and reject identity changes after an item starts. `max_collect_bytes` bounds retained UTF-8 output; `0` disables that limit. +- `realtime` forwards text, refusal, tool-argument and reasoning increments for all three protocols as they arrive, so reasoning timing stays visible to clients; Responses and Anthropic still deliver one logical thinking item/block with stable indexes. `compatible` keeps the older behaviour of coalescing reasoning into one delta before the first visible output, which hides the reasoning timeline and can inflate client-side TPS/TTFT. Adapters buffer tools with missing identity until the argument phase or terminal marker, append metadata fragments without guessing from prefixes, and reject identity changes after an item starts. `max_collect_bytes` bounds retained UTF-8 output; `0` disables that limit. Realtime Messages keeps one content block open at a time. The active tool remains incremental; later tool, text or thinking blocks may wait until upstream completion. Deferred event bytes share `max_collect_bytes`. A tool still awaiting its identity does not block unrelated text before its block starts. @@ -119,7 +123,7 @@ Older strict setting validators reject the unknown saved key, so changing only i ### Connection reuse and account capacity -Both settings are available in the WebUI; their environment variables are `CODEBUDDY2API_UPSTREAM_KEEPALIVE` and `CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT`. Unset variables leave Compose settings unlocked. Connection reuse defaults to off; when enabled, each official origin permits 64 connections with 16 idle connections and a 30-second keepalive expiry. Authentication is request-scoped, upstream cookies are not stored, and shutdown closes the pools. Proxy environment, timeouts and replay rules are unchanged; disable and restart to restore fresh connections. +Both settings are available in the WebUI; their environment variables are `CODEBUDDY2API_UPSTREAM_KEEPALIVE` and `CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT`. Commenting a line out of `.env` keeps the WebUI in control. Connection reuse defaults to off; when enabled, each official origin permits 64 connections with 16 idle connections and a 30-second keepalive expiry. Authentication is request-scoped, upstream cookies are not stored, and shutdown closes the pools. Proxy environment, timeouts and replay rules are unchanged; disable and restart to restore fresh connections. The account limit defaults to `0`. Positive limits skip full accounts within existing routing and free-first rules; a full free tier never spills into paid accounts. No capacity returns `503 / credential_concurrency_limit` with `Retry-After: 3`, without queueing or penalizing the account. Completion, cancellation and failed-account rotation release capacity. The credentials API exposes `in_flight` and `max_in_flight`. Only the three client generation endpoints count; limits are per process, not shared between instances. Setting `0` restores unlimited account capacity without interrupting active requests. @@ -193,7 +197,7 @@ Normal same-origin access does not require disabling protection. Behind a revers CODEBUDDY2API_ADMIN_CSRF=false ``` -This is startup-only, CLI takes precedence over the environment, and the WebUI cannot change it. Use a source/image build containing this option and the updated Compose configuration. Recreate the container after changing its environment; `docker compose restart` alone is insufficient. Adding the variable does not add this feature to an older image. +This is startup-only, CLI takes precedence over the environment, and the WebUI cannot change it. Write the value in `.env` or pass the flag, then recreate the container; `docker compose restart` alone is insufficient. Disabling skips login Origin checks and Origin/CSRF checks on Cookie-authenticated management writes and OAuth polling. API keys, session expiry, OAuth task ownership, official authorization-site validation and dangerous-action confirmations remain enforced; `/v1/*` is unaffected. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index d280c06..7b2c076 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -8,7 +8,11 @@ 配置优先级:**显式 CLI 参数 > 进程环境变量 > `.env` > SQLite 保存值 > 默认值**。热更新设置立即生效,标记为重启的项需手动重启;锁定项请在对应来源修改,WebUI 不改写 `.env` 或返回 API key 明文。 -Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的一行不一定解除锁定。修改这些值后重建容器;若要由 WebUI 接管,还需取消 Compose 中对应的显式设置。 +Compose 会加载 `.env` 中的每个键,注释掉一行即可解除锁定;只有 `CODEBUDDY_AUTH_DIR` 和 `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` 由 Compose 文件固定。修改这些值后重建容器。 + +`.env` 里的运行参数在 WebUI 中始终只读,直到被搬进配置库:停止服务后执行 `python3 converter.py config migrate`(见[配置迁移](deployment.zh-CN.md#配置迁移))。Dashboard 会显示一次性横幅列出这些键;迁移或点击“不再提醒”都会在配置库写入 `config_notice_muted` 并停止提示,之后新增的 `.env` 运行参数也不再提醒。 + +`language`(`auto|zh|en`)是 WebUI 与 CLI 共用的唯一界面偏好:没有环境变量映射,也永不被锁定。`--lang` 会在 `serve`、`login`、`config migrate` 时写入该值;`config status` 只读,仅影响本次输出。 | 参数 | 默认值 | 说明 | |------|--------|------| @@ -45,11 +49,11 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--max-request-bytes` | `33554432` | 处理后的上游 JSON 字节上限,不含网关内部元数据,须为正整数 | | `--log-body-limit` | `65536` | 旧文本预览兼容项;文本输出已停用,SQLite 诊断使用独立预算 | -环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_ADMIN_ORIGINS`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_STREAM_MODE`、`CODEBUDDY2API_LOG`、`CODEBUDDY2API_RESPONSES_PROJECTION_MODE`、`CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES`、`CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见[部署指南](deployment.zh-CN.md)。 +全部生效的环境变量及默认值见 `.env.example`;启动示例见[部署指南](deployment.zh-CN.md)。 ### Responses 投影 -通过 WebUI、CLI、环境变量或 `.env` 配置这两项热更新设置。优先级为 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值;CLI/环境变量会锁定 WebUI 字段。Compose 仅转发宿主环境已设置的变量,两项均未设置时 WebUI 仍可编辑。 +通过 WebUI、CLI、环境变量或 `.env` 配置这两项热更新设置。优先级为 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值;CLI/环境变量会锁定 WebUI 字段。Compose 整体加载 `.env`,注释掉对应行即保持 WebUI 可编辑。 `responses_projection_mode` 默认为 `balanced`,仅接受 `balanced` 和 `passthrough`。balanced 仅改写有固定摘要的已识别 harness;这些块之外的文本不计入预算、不会被裁剪。它还会按 `responses_projection_max_bytes` 对 assistant 内容、完整工具参数 JSON 和工具结果采用 Codex 风格头尾截断;文本标记显示原始 bytes、估算 tokens 与总行数。超限工具参数仍是有效 JSON,并以有界对象保留原始头尾和大小元数据。passthrough 完全关闭 Responses 投影。 @@ -63,16 +67,16 @@ Responses 投影不再修改工具定义或 schema。启用脱敏时,默认剥 - **WebUI**:系统设置 → 保留工具描述,未被启动来源锁定时可立即生效并持久化。 - **CLI**:在原启动命令追加 `--keep-tool-metadata` 或 `--keep-tool-metadata true`;显式 `false` 可覆盖环境变量。 -- **环境变量**:设置 `CODEBUDDY2API_KEEP_TOOL_METADATA=true`;Compose 会传入已设置的值,未设置时不锁定 WebUI。删除或注释变量可解除环境锁定,不要设为空串。 +- **环境变量**:在 `.env` 中设置 `CODEBUDDY2API_KEEP_TOOL_METADATA=true`;注释掉该行即解除锁定,不要设为空串。 -需使用包含此功能的源码/镜像和 Compose 配置;修改容器环境后重新创建容器。保留描述可能增加输入 token 和审核拦截风险;设为 `false` 恢复脱敏默认剥离行为。此开关不改变 Responses 的 balanced/passthrough 模式,也不放宽请求体预算。 +需使用包含此功能的源码或镜像;修改容器环境后重新创建容器。保留描述可能增加输入 token 和审核拦截风险;设为 `false` 恢复脱敏默认剥离行为。此开关不改变 Responses 的 balanced/passthrough 模式,也不放宽请求体预算。 ### 流式模式 `stream_mode` 默认 `compatible`,可通过 `--stream-mode compatible|realtime`、`CODEBUDDY2API_STREAM_MODE` 或 WebUI 枚举配置;显式 CLI/环境来源会锁定该项。所有生成请求(含非流式)均在选路前冻结模式及 `max_collect_bytes`,并记录所选模式。热更新只影响后续请求,不改变在途请求及换号;非流式仍返回聚合 JSON,客户端不能按请求覆盖模式。 - `compatible` 保持现有行为:Responses 流式先聚合;Chat/Messages 带工具时先聚合,无工具时沿用上游增量。聚合结果先校验,再按片段重放。两种模式下,非流式请求始终走已校验的聚合路径。 -- `realtime` 让三个协议都增量发送正文、拒绝及工具参数,并在正文开始前合并思考增量为一个事件,避免客户端显示多个思考段。Responses 在输出项开始时分配稳定索引,Anthropic 使用稳定 block index。适配器在参数阶段或结束标记处确认工具身份,缺失时暂缓工具输出;元数据分片按顺序追加,不按字符串前缀猜测,输出项开始后禁止更换身份。`max_collect_bytes` 约束所保留的 UTF-8 输出,`0` 不限制。 +- `realtime` 让三个协议都实时增量发送正文、拒绝、工具参数与思考,客户端能看到真实的思考时间轴;Responses 与 Anthropic 仍只产生一个逻辑思考项/块并使用稳定索引。`compatible` 保留旧行为:在正文开始前把思考合并成一个事件,会隐藏思考时间轴,并可能让客户端 TPS/首字指标虚高。适配器在参数阶段或结束标记处确认工具身份,缺失时暂缓工具输出;元数据分片按顺序追加,不按字符串前缀猜测,输出项开始后禁止更换身份。`max_collect_bytes` 约束所保留的 UTF-8 输出,`0` 不限制。 实时 Messages 同时只打开一个内容块:当前工具仍增量输出,后续工具、正文或思考块可能等待上游结束,暂存事件字节计入 `max_collect_bytes`。尚未确认身份、未开始内容块的工具不会阻塞其它正文。 @@ -119,7 +123,7 @@ PY ### 连接复用与账号容量 -WebUI 系统设置可配置这两项;环境变量为 `CODEBUDDY2API_UPSTREAM_KEEPALIVE` 和 `CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT`,未设置时 Compose 不锁定 WebUI。连接复用默认关闭,启用后每个官方入口最多 64 条连接、保留 16 条空闲连接,空闲复用期限 30 秒;认证头逐请求设置,不保存上游 Cookie,关闭服务时释放连接池。原有代理环境、超时和重放规则不变;关闭并重启恢复逐请求连接。 +WebUI 系统设置可配置这两项;环境变量为 `CODEBUDDY2API_UPSTREAM_KEEPALIVE` 和 `CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT`,在 `.env` 中注释掉对应行时保持 WebUI 可编辑。连接复用默认关闭,启用后每个官方入口最多 64 条连接、保留 16 条空闲连接,空闲复用期限 30 秒;认证头逐请求设置,不保存上游 Cookie,关闭服务时释放连接池。原有代理环境、超时和重放规则不变;关闭并重启恢复逐请求连接。 账号上限默认 `0`;设为正数后,仅在原路由及免费优先范围内避开满载账号,不因免费账号满载而转向收费账号。没有名额时返回 `503 / credential_concurrency_limit` 和 `Retry-After: 3`,不排队、不熔断;结束、断连和失败换号均释放名额。管理凭据 API 提供 `in_flight`、`max_in_flight`;限制只涵盖三个客户端生成接口,每进程独立,多个实例不共享计数。设回 `0` 即恢复原容量策略,不中断已开始的请求。 @@ -193,7 +197,7 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` CODEBUDDY2API_ADMIN_CSRF=false ``` -仅启动时生效,CLI 优先于环境变量,不能通过 WebUI 修改。使用包含该开关的源码或镜像,并更新 Compose 配置;修改环境后须重新创建容器,不能仅执行 `docker compose restart`。旧镜像不会因新增变量自动获得此功能。 +仅启动时生效,CLI 优先于环境变量,不能通过 WebUI 修改。在 `.env` 中写入取值或传入对应参数后重新创建容器,不能仅执行 `docker compose restart`。 关闭会跳过登录请求的 Origin 检查,以及 Cookie 管理写操作和 OAuth 轮询的 Origin/CSRF 检查;API key、会话有效期、OAuth 任务归属、官方授权地址白名单和危险操作确认仍保留,不影响 `/v1/*`。 diff --git a/docs/clients.md b/docs/clients.md index 93de1c1..23f0e49 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -60,7 +60,7 @@ claude - Claude Code, Anthropic SDKs and CC Switch's Anthropic provider use a Base URL without `/v1/messages`; SDKs append the path themselves. Only clients explicitly asking for a complete endpoint should include `/v1/messages`. - `POST /v1/messages` is converted to upstream Chat Completions, retaining native tool calls and reasoning content. -- `--desensitize` controls WorkBuddy's fixed CLI-template adaptation. It is off by default and enabled in the project's Compose configuration. See the [advanced reference](advanced.md) for options such as retaining fuller instructions. +- `--desensitize` controls WorkBuddy's fixed CLI-template adaptation. It is off by default and enabled in the published image. See the [advanced reference](advanced.md) for options such as retaining fuller instructions. ## Other OpenAI-compatible clients diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 8d7e906..aaf4156 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -60,7 +60,7 @@ claude - Claude Code、Anthropic SDK 与 CC Switch 的 Anthropic 提供方使用不含 `/v1/messages` 的 Base URL;SDK 会自行追加路径。只有明确要求完整端点的客户端才应包含 `/v1/messages`。 - `POST /v1/messages` 会转换为上游 Chat Completions,保留原生工具调用与推理内容。 -- `--desensitize` 控制 WorkBuddy 固定 CLI 模板的适配,默认关闭,项目的 Compose 配置已启用。保留更完整指令等选项见[进阶参考](advanced.zh-CN.md)。 +- `--desensitize` 控制 WorkBuddy 固定 CLI 模板的适配,默认关闭,已发布镜像中启用。保留更完整指令等选项见[进阶参考](advanced.zh-CN.md)。 ## 其他 OpenAI 兼容客户端 diff --git a/docs/deployment.md b/docs/deployment.md index 27a3257..7950dde 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,15 +10,18 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o | Setting | Purpose | |---------|---------| -| `CODEBUDDY2API_IMAGE` | Compose image; the template uses `codebuddy2api:local` | +| `CODEBUDDY2API_IMAGE` | Compose image; defaults to the published `latest` tag | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | Native listener and Compose host mapping; default `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` | +| `TZ` | Container time zone; daily guards follow it, and an unset value keeps UTC | +| `CODEBUDDY2API_ENV_FILE` | Optional Compose override for the env file path; defaults to `.env` | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | Headless no-auth opt-in for the image and Compose, where it stays `true`; configure `CODEBUDDY2API_KEY` before exposing the port | | `CODEBUDDY2API_TRUSTED_PROXIES` | Trusted proxy IPs/CIDRs (comma separated); when the direct peer matches, the audit log records the client from `X-Forwarded-For`/`X-Real-IP` instead of the proxy address | | `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container | | `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose | -The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, Responses projection, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. +`.env.example` lists every active runtime variable with its default. Compose loads the whole file, so a key you comment out stays editable in the WebUI (tool metadata, Responses projection, origin allowlist, failover and similar). Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. -Compose reads declared variables from `.env`; shell variables take precedence. The default host mapping is loopback. Set a random key, HTTPS and access restrictions before allowing remote connections. Container binding remains `0.0.0.0:8787`; change host exposure using `BIND/PORT`, not container listener arguments. +Compose loads `.env` into the container; shell variables still win for the image, host mapping and data path. The default host mapping is loopback. Set a random key, HTTPS and access restrictions before allowing remote connections. Container binding remains `0.0.0.0:8787`; change host exposure using `BIND/PORT`, not container listener arguments. Mount the entire data directory on writable local storage, not just one SQLite file, and do not share it between instances. Stop the gateway and back up the whole directory before upgrading; see [data and backups](webui.md#data-and-backups). @@ -26,7 +29,7 @@ Mount the entire data directory on writable local storage, not just one SQLite f ### Use published images -Set `CODEBUDDY2API_IMAGE` in `.env` to `ghcr.io/maiphucgiang/codebuddy2api:`, choosing an existing release, then run: +Compose loads every key in `.env` into the container, so optional settings reach the gateway without repeating variable names; a missing `.env` still starts with the gateway defaults and the settings saved in SQLite. Set `CODEBUDDY2API_IMAGE` in `.env` to `ghcr.io/maiphucgiang/codebuddy2api:` to pin an existing release, then run: ```bash docker compose pull @@ -58,23 +61,52 @@ The default loopback mapping is the only safe exposure without extra work. When ## Local Python setup -Requires Python 3.12+, uv, and Node.js with the vp CLI to build the interface: +Requires Python 3.12+ and uv. Build the interface only when the distribution does not include it, using Node.js 24 and the vp CLI pinned by `vite-plus` in `web/package.json`. ```bash uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run converter.py +uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 ``` +`--locked` stops instead of re-resolving when `uv.lock` is stale, so a source launch never upgrades packages. `.env` in the working directory is read automatically, so `uv run` needs no `--env-file`. Distribution archives already contain the WebUI; skip the frontend build there. + Local startup does not require `.env`. Without an explicit key, the first loopback startup generates a `cb-…` default key in `auth/control.sqlite3` and displays it once in the interactive terminal after listening succeeds. Restarts reuse it without printing it again. Keep it safe: management and API requests share this key. Configure a key explicitly for first-time headless or non-loopback deployment. One-time disclosure goes directly to the controlling terminal (`/dev/tty` or Windows `CONOUT$`), not stdout/stderr; redirecting standard output does not capture the key. Terminal recording remains outside the gateway's control. A failed terminal write or database commit leaves the same key pending for the next interactive start. A crash between display and commit can therefore repeat the notice; a successfully committed display is not repeated. -Without uv, run `python3 -m venv .venv`, activate it, install dependencies with `pip install --require-hashes --only-binary=:all: -r requirements.txt`, then run `python3 converter.py`. Distribution archives include the WebUI; source installs still need the frontend build. +Without uv, keep the same hash-locked set in a virtual environment: + +```bash +python3 -m venv .venv +. .venv/bin/activate # Windows: .venv\Scripts\activate +python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt +python3 converter.py --host 127.0.0.1 --port 8787 +``` + +Both startup paths read `.env` in the current working directory when present, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` only affect Compose. + +### Configuration migration + +`.env` values stay authoritative, so any runtime key listed there occupies its WebUI field. To move them into the configuration store, stop the service and run the offline command; it never edits `.env` itself: + +```bash +python3 converter.py config migrate # cwd/.env -> ./auth/control.sqlite3 +python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app/auth +python3 converter.py config status --db /srv/app/auth/control.sqlite3 # read-only +``` + +Both commands resolve the env file first, then the data directory it declares (`CODEBUDDY_AUTH_DIR`), so `--data-dir`/`--db` only override the resolved target. Before writing, `migrate` verifies the target really is this project's control store (SQLite header, `user_version=2`, `control`/`runtime_state`/`gateway_secrets`, readable payload, `quick_check`) and refuses while a running instance holds the lease or the write lock; `--force` bypasses the lease warning only. Deployment keys (key, listener, data paths) are never migrated. Inside a container there is no `.env` file, so use the process environment: + +```bash +docker compose stop +docker compose run --rm --no-deps -e CODEBUDDY_AUTH_DIR=/data/auth \ + codebuddy2api python3 converter.py config migrate --no-env --data-dir /data/auth +``` -Both commands optionally read `.env` in the current working directory, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` are Compose-only. +A successful run writes the values and disables the Dashboard notice in one revision; delete the printed `.env` lines when you want the WebUI to supply them. Instances started before this release write no lease, so confirm the service is stopped once after upgrading. `config status` never writes. `--lang zh|en|auto` selects the CLI output language and, for `serve`, `login` and `config migrate`, also saves the interface language; see [configuration and CLI](advanced.md#configuration-and-cli). > The audit schema is single-writer: stop the old instance before starting a new version, and never run two gateway versions against one auth directory. @@ -90,14 +122,14 @@ Older versions cannot read the upgraded control database. Downgrades require a s ## Dependency locks -`pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. `requirements.in` and the hash-locked `requirements.txt` are generated compatibility files for pip, Docker and CI: +`pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. The hash-locked `requirements.txt` is the generated pip input for Docker and CI: ```bash uv lock python3 scripts/export_requirements.py ``` -Use `uv add`/`uv remove` for intentional dependency changes, then export and review both locks. Normal startup uses `--locked` and never upgrades packages. Metadata stays at the current `VERSION`; releases must update both version fields. Pip/Docker still require matching hashes and binary wheels; do not disable these checks. +Use `uv add`/`uv remove` for intentional dependency changes, then export and review the lock. Normal startup uses `--locked` and never upgrades packages. Metadata stays at the current `VERSION`; releases must update both version fields. Pip/Docker still require matching hashes and binary wheels; do not disable these checks. Docker's build frontend, Node and Python images are pinned by multi-platform digest. When refreshing them, retain `linux/amd64` and `linux/arm64` support and verify the build. Locks prevent drift, not future vulnerabilities; security updates still require reviewed refreshes. @@ -106,9 +138,9 @@ Docker's build frontend, Node and Python images are pinned by multi-platform dig When the WebUI is unavailable, browser login also works without starting the server: ```bash -uv run --locked --no-build --env-file .env converter.py login -uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser -uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build converter.py login +uv run --locked --no-build converter.py login --site intl --no-browser +uv run --locked --no-build converter.py login --site intl-codebuddy --no-browser ``` The first command uses the domestic site. `--site intl` selects international WorkBuddy (`www.workbuddy.ai`); `--site intl-codebuddy` selects international CodeBuddy (`www.codebuddy.ai`). `--no-browser` prints a link you can open on another device. Even after the browser reports success, wait for the terminal to confirm that credentials were saved. Links expire after 10 minutes; press `Ctrl+C` to cancel. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index b867106..5c63aab 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -10,16 +10,19 @@ | 配置 | 作用 | |------|------| -| `CODEBUDDY2API_IMAGE` | Compose 镜像;模板默认 `codebuddy2api:local` | +| `CODEBUDDY2API_IMAGE` | Compose 镜像;默认使用已发布的 `latest` 标签 | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | 本地监听与 Compose 宿主机映射;默认 `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose 宿主机数据目录;默认 `./auth`,挂载到容器 `/data/auth` | +| `TZ` | 容器时区;每日任务按它跨天,未设置时保持 UTC | +| `CODEBUDDY2API_ENV_FILE` | 可选的 Compose 覆盖项,指定 env 文件路径;默认 `.env` | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | 镜像与 Compose 的后台免鉴权选项,在两者中都固定为 `true`;对外暴露端口前请配置 `CODEBUDDY2API_KEY` | | `CODEBUDDY2API_TRUSTED_PROXIES` | 可信代理 IP/CIDR(逗号分隔);直连地址命中时审计改记 `X-Forwarded-For`/`X-Real-IP` 里的真实客户端地址,而不是代理地址 | | `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` | | `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 | -示例文件列出了全部生效的运行时变量,包括入站/聚合字节上限、并发、工具重试与故障转移。Compose 会转发这些限制;未设置的可选项(工具元数据、Responses 投影、来源白名单、故障转移等)仍可在 WebUI 配置。取零表示关闭聚合/并发限制、Responses 单项裁剪或额外重试,而入站上限必须为正值。 +`.env.example` 列出了全部生效的运行时变量及其默认值。Compose 会整体加载该文件,注释掉的键仍可在 WebUI 配置(工具元数据、Responses 投影、来源白名单、故障转移等)。取零表示关闭聚合/并发限制、Responses 单项裁剪或额外重试,而入站上限必须为正值。 -Compose 从 `.env` 读取已声明变量,shell 变量优先。默认宿主机映射仅回环。开放远程访问前请设置随机密钥、HTTPS 与访问限制。容器内监听保持 `0.0.0.0:8787`;调整对外暴露用 `BIND/PORT`,不要改容器监听参数。 +Compose 会把 `.env` 加载进容器;镜像、宿主机映射与数据路径仍以 shell 变量优先。默认宿主机映射仅回环。开放远程访问前请设置随机密钥、HTTPS 与访问限制。容器内监听保持 `0.0.0.0:8787`;调整对外暴露用 `BIND/PORT`,不要改容器监听参数。 数据目录需整体挂载在可写本地存储,不要只挂载单个 SQLite 文件,也不要在多个实例间共享。升级前停止网关并备份整个目录,见[数据与备份](webui.zh-CN.md#数据与备份)。 @@ -27,7 +30,7 @@ Compose 从 `.env` 读取已声明变量,shell 变量优先。默认宿主机 ### 使用已发布镜像 -在 `.env` 中将 `CODEBUDDY2API_IMAGE` 设为 `ghcr.io/maiphucgiang/codebuddy2api:<版本>`(选择已发布的版本),然后运行: +Compose 会把 `.env` 中的每个键加载进容器,可选配置无需在 Compose 里重复变量名;缺少 `.env` 时仍以网关默认值和 SQLite 中保存的设置为准。将 `CODEBUDDY2API_IMAGE` 设为 `ghcr.io/maiphucgiang/codebuddy2api:<版本>` 可固定使用已发布版本,然后运行: ```bash docker compose pull @@ -59,23 +62,52 @@ docker compose up -d ## 本地 Python 运行 -需要 Python 3.12+、uv,以及 Node.js 与 vp CLI 来构建界面: +需要 Python 3.12+ 与 uv。仅在发行包未自带界面时才需构建:Node.js 24 与 `web/package.json` 中 `vite-plus` 锁定的 vp CLI。 ```bash uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run converter.py +uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 ``` +`uv.lock` 过期时 `--locked` 会直接失败而不是重新解析,因此源码启动不会升级依赖。当前工作目录的 `.env` 会被自动读取,`uv run` 无需 `--env-file`。发行包已包含 WebUI,可跳过界面构建。 + 本地启动无需 `.env`。没有显式 key 时,首次回环启动会生成 `cb-…` 默认密钥,保存到 `auth/control.sqlite3`,监听成功后仅在交互终端显示一次;以后重启复用且不再打印。请妥善保存,管理登录与 API 请求共用。首次后台或非回环部署请显式配置 key。 密码提示直接写入控制终端(`/dev/tty` 或 Windows `CONOUT$`),不经过 stdout/stderr,重定向标准输出不会收集密码;外部终端录制不在程序控制范围内。 终端写入或数据库提交失败时,同一密钥保留为待显示,下次交互启动可重试。显示后、提交前崩溃可能再次提示;成功提交后不重复显示。 -没有 uv 时:运行 `python3 -m venv .venv` 并激活,用 `pip install --require-hashes --only-binary=:all: -r requirements.txt` 安装依赖,再执行 `python3 converter.py`。发行包已包含 WebUI;源码安装仍需构建界面。 +没有 uv 时,用虚拟环境安装同一份带哈希的依赖: + +```bash +python3 -m venv .venv +. .venv/bin/activate # Windows:.venv\Scripts\activate +python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt +python3 converter.py --host 127.0.0.1 --port 8787 +``` + +两种启动方式都会读取当前工作目录的 `.env`(若存在),不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 只作用于 Compose。 + +### 配置迁移 + +`.env` 中的值仍然优先,因此任何写在里面的运行参数都会占住 WebUI 的对应项。要统一到配置库,先停止服务,再执行离线命令;命令不会改动 `.env` 本身: + +```bash +python3 converter.py config migrate # 当前目录 .env -> ./auth/control.sqlite3 +python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app/auth +python3 converter.py config status --db /srv/app/auth/control.sqlite3 # 只读 +``` + +两条命令都先解析 env 文件,再采用其中声明的数据目录(`CODEBUDDY_AUTH_DIR`),`--data-dir`/`--db` 只覆盖解析结果。写入前 `migrate` 会验证目标确实是本项目的控制库(SQLite 魔数、`user_version=2`、`control`/`runtime_state`/`gateway_secrets`、可解析的 payload、`quick_check`),并在检测到运行中实例的租约或写锁时拒绝;`--force` 只跳过租约提示。部署类键(密钥、监听地址、数据路径)不会迁移。容器内没有 `.env` 文件,改用进程环境变量: + +```bash +docker compose stop +docker compose run --rm --no-deps -e CODEBUDDY_AUTH_DIR=/data/auth \ + codebuddy2api python3 converter.py config migrate --no-env --data-dir /data/auth +``` -两种启动方式均可选读取当前工作目录的 `.env`,不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 仅用于 Compose。 +成功时值写入与“关闭 Dashboard 提醒”在同一次 revision 内提交;按提示删除 `.env` 中对应行后,这些项改由配置库提供、可在 WebUI 修改。旧版本启动的实例不写租约,升级后首次迁移前请自行确认服务已停止。`config status` 不写库。`--lang zh|en|auto` 决定 CLI 输出语言,并在 `serve`、`login`、`config migrate` 时把界面语言一并保存;见[配置与 CLI](advanced.zh-CN.md#配置与-cli)。 > 审计库为单写者设计:升级新版本前先停止旧实例,不要让两个版本的网关共用同一 auth 目录。 @@ -91,14 +123,14 @@ SQLite 状态读取或校验失败会阻止启动,不删除会话、不回退 ## 依赖锁定 -`pyproject.toml` 声明直接依赖;`uv.lock` 锁定全部解析版本。`requirements.in` 与带哈希的 `requirements.txt` 是供 pip、Docker 与 CI 使用的生成文件: +`pyproject.toml` 声明直接依赖;`uv.lock` 锁定全部解析版本。带哈希的 `requirements.txt` 是供 pip、Docker 与 CI 使用的生成文件: ```bash uv lock python3 scripts/export_requirements.py ``` -有意调整依赖时使用 `uv add`/`uv remove`,然后导出并核对两份锁定。日常启动使用 `--locked`,不会升级任何包。元数据与当前 `VERSION` 保持一致;发版必须同时更新两处版本字段。pip/Docker 仍要求哈希匹配与二进制 wheel,不要关闭这些校验。 +有意调整依赖时使用 `uv add`/`uv remove`,然后导出并核对锁定文件。日常启动使用 `--locked`,不会升级任何包。元数据与当前 `VERSION` 保持一致;发版必须同时更新两处版本字段。pip/Docker 仍要求哈希匹配与二进制 wheel,不要关闭这些校验。 Docker 构建所用的前端、Node 与 Python 镜像均按多平台摘要锁定。刷新它们时保留 `linux/amd64` 与 `linux/arm64` 支持并验证构建。锁定只能防止漂移,不能免疫未来漏洞;安全更新仍需评审后刷新。 @@ -107,9 +139,9 @@ Docker 构建所用的前端、Node 与 Python 镜像均按多平台摘要锁定 WebUI 不可用时不启动服务也能完成浏览器登录: ```bash -uv run --locked --no-build --env-file .env converter.py login -uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser -uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build converter.py login +uv run --locked --no-build converter.py login --site intl --no-browser +uv run --locked --no-build converter.py login --site intl-codebuddy --no-browser ``` 第一条使用国内站。`--site intl` 选择国际 WorkBuddy(`www.workbuddy.ai`);`--site intl-codebuddy` 选择国际 CodeBuddy(`www.codebuddy.ai`)。`--no-browser` 打印授权链接,可在其他设备打开。即使浏览器显示成功,也要等终端确认凭证已保存。链接 10 分钟过期;按 `Ctrl+C` 取消。 diff --git a/docs/webui.md b/docs/webui.md index 7703c10..efd2821 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -16,7 +16,7 @@ Hover or tap the trend for the period's requests, successes and failures; keyboa ## Credentials -Add accounts by browser login — Mainland China (CN), International WorkBuddy or International CodeBuddy — or import `.info`/ZIP files. +Add accounts by browser login — Mainland China (CN), International WorkBuddy or International CodeBuddy — import `.info`/ZIP files, or paste up to 50 refresh tokens for one chosen endpoint; each token is exchanged serially and the returned account block keeps enterprise tenants intact. - **Runtime states:** distinguish manual disabling, credential-level authentication circuits and model-level 429 cooldowns. Disabling keeps files; deletion removes them and requires removing model bindings first. Exports preserve safe UTF-8 filenames (otherwise `credential.info`/`credentials.zip`) and contain plaintext credentials; do not share them. - **Login drawer:** successful OAuth enrollment closes the drawer and refreshes the list. The official tab keeps `noopener` isolation and must be closed manually. Failures stay visible; closing the drawer stops polling. @@ -47,7 +47,7 @@ Clearing **all logs and statistics** is irreversible. Enter the confirmation tex ## Settings -Edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md#configuration-and-cli). +Edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md#configuration-and-cli). The Dashboard banner lists runtime keys still taken from `.env`; stop the service, migrate them with `python3 converter.py config migrate`, or dismiss the banner once — after either action it never returns, even if `.env` gains more keys. - "Keep tool descriptions" is off by default and works across all three protocols, independently of prompt compaction; see [tool metadata retention](advanced.md#tool-metadata-retention). - "Model capability preflight" defaults to on. Disabling it affects new requests only, not metadata display, international image-run merging or existing security limits; see [model declarations](advanced.md#model-declarations-and-image-compatibility). @@ -57,7 +57,7 @@ Edit unlocked options; hot changes apply immediately, while restart-marked setti The sidebar remembers its icon-only mode. Drawers lock background scrolling, close on backdrop clicks or Esc, and restore focus; saves, imports and deletions prevent accidental dismissal while pending. Details use labeled fields and status groups with folded raw diagnostics. Glass surfaces fall back to solid colors when transparency is reduced or blur is unsupported. -The appearance icon offers light, dark and system-following modes. Four palettes affect light mode only; dark mode stays fixed and the light preference is retained. Preferences are browser-local. Drawers fade/slide in and out, retaining the scroll lock through exit; reduced-motion settings skip animation. +The appearance icon offers light, dark and system-following modes. Four palettes affect light mode only; dark mode stays fixed and the light preference is retained. Language is stored in the configuration store, so the CLI and every browser share it; other preferences stay browser-local. Drawers fade/slide in and out, retaining the scroll lock through exit; reduced-motion settings skip animation. ## Model mappings and statistics API diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index ed30c2f..f93695a 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -16,7 +16,7 @@ ## 凭证管理 -通过浏览器扫码登录添加账号——国内站(CN)、国际 WorkBuddy 或国际 CodeBuddy——或导入 `.info`/ZIP 文件。 +通过浏览器扫码登录添加账号——国内站(CN)、国际 WorkBuddy 或国际 CodeBuddy——导入 `.info`/ZIP 文件,或选定端点后粘贴最多 50 个刷新令牌:逐条换取并写入新凭证,账号信息保留企业租户。 - **运行状态:** 区分人工停用、凭证级认证熔断与模型级 429 冷却。停用保留文件;删除会移除文件,且须先解除模型绑定。导出保留安全的 UTF-8 文件名(否则用 `credential.info`/`credentials.zip`),内容为明文凭证,不要分享。 - **登录抽屉:** OAuth 入库成功后自动关闭抽屉并刷新列表。官方授权标签页保持 `noopener` 隔离,需手动关闭。失败状态保持可见;关闭抽屉即停止轮询。 @@ -47,7 +47,7 @@ ## 系统设置 -编辑未锁定的选项;即时生效项立即应用,标记为重启的项需手动重启。被锁定的选项请在启动配置中修改;见[配置优先级](advanced.zh-CN.md#配置与-cli)。 +编辑未锁定的选项;即时生效项立即应用,标记为重启的项需手动重启。被锁定的选项请在启动配置中修改;见[配置优先级](advanced.zh-CN.md#配置与-cli)。Dashboard 顶部横幅会列出仍由 `.env` 提供的运行参数;停止服务后用 `python3 converter.py config migrate` 迁移,或点一次“不再提醒”——两种方式之后都不再出现,即使 `.env` 又新增键。 - 「保留工具描述」默认关闭,对三种协议生效,与提示词压缩相互独立;见[工具元数据保留](advanced.zh-CN.md#工具元数据保留)。 - 「表外模型拦截」与「模型能力预检」开关位于「模型路由」顶部栏,默认开启;关闭能力预检只影响新请求,不影响元数据展示、国际图片消息合并与既有安全限制;见[模型声明](advanced.zh-CN.md#模型声明与图片兼容)。 @@ -57,7 +57,7 @@ 侧栏会记住图标模式。抽屉锁定背景滚动,点击遮罩或按 Esc 关闭并恢复焦点;保存、导入与删除进行中会阻止误触关闭。详情使用带标签的字段与状态分组,原始诊断默认折叠。不支持透明或模糊时,玻璃质感会回退为纯色。 -外观图标提供浅色、深色与跟随系统。四套配色只影响浅色模式;深色固定,浅色偏好保留。偏好保存在浏览器本地。抽屉淡入淡出,退出动画期间保持滚动锁定;减少动效的系统设置会跳过动画。 +外观图标提供浅色、深色与跟随系统。四套配色只影响浅色模式;深色固定,浅色偏好保留。语言保存在配置库中,CLI 与所有浏览器共用;其余偏好保留在浏览器本地。抽屉淡入淡出,退出动画期间保持滚动锁定;减少动效的系统设置会跳过动画。 ## 模型映射与统计 API diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 46362e5..0000000 --- a/requirements.in +++ /dev/null @@ -1,6 +0,0 @@ -# Generated from pyproject.toml by scripts/export_requirements.py; do not edit. -fastapi>=0.141.1 -httpx>=0.28.1 -pytest>=9.1.1 -python-dotenv>=1.2.3 -uvicorn[standard]>=0.52.4 diff --git a/scripts/export_requirements.py b/scripts/export_requirements.py index 5cfacfd..cc0dd46 100644 --- a/scripts/export_requirements.py +++ b/scripts/export_requirements.py @@ -13,9 +13,6 @@ def main(): subprocess.run(["uv", "export", "--locked", "--no-dev", "--no-emit-project", "--format", "requirements-txt", "--output-file", "requirements.txt"], cwd=ROOT, check=True, stdout=subprocess.DEVNULL) - (ROOT / "requirements.in").write_text( - "# Generated from pyproject.toml by scripts/export_requirements.py; do not edit.\n" - + "\n".join(project["dependencies"]) + "\n") if __name__ == "__main__": diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 9e3f4dc..731d12c 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -560,6 +560,79 @@ def test_upload_normalizes_token_aliases_to_canonical_fields(self): self.assertNotIn("refresh_token", saved["auth"]) self.assertNotIn("token", saved["auth"]) + def refresh_token(self, uid): + import base64 + claims = base64.urlsafe_b64encode(json.dumps({"sub": uid}).encode()).decode().rstrip("=") + return f"header.{claims}.signature" + + def test_refresh_import_rejects_bad_endpoint_and_batches(self): + post = lambda body: self.client.post("/admin/credentials/refresh-import", headers=self.headers, json=body) + self.assertEqual(post({"profile": "unknown", "refresh_tokens": ["a"]}).status_code, 400) + for tokens in ([], [""], [1], ["x" * 8193], ["a"] * 51): + with self.subTest(count=len(tokens)): + self.assertEqual(post({"profile": "cn-work", "refresh_tokens": tokens}).status_code, 400) + self.gateway._store_credential.assert_not_called() + + def test_refresh_import_isolates_failures_and_dedupes_tokens(self): + from app import admin_api + from unittest.mock import patch + calls = [] + + def fake(token, profile, *, uid=""): + calls.append((token, profile, uid)) + if uid == "uid-2": + raise RuntimeError("刷新令牌已失效或端点不匹配") + return {"accessToken": "synthetic-access", "refreshToken": "synthetic-refresh", + "expiresIn": 3600, "domain": "www.workbuddy.cn"} + + tokens = [self.refresh_token("uid-1"), self.refresh_token("uid-2"), "not-a-jwt", + self.refresh_token("uid-1")] + def fake_account(token, domain): + return {"uid": "uid-1", "type": "ultimate", "enterpriseId": "tenant-7", "nickname": "企业账号"} + + tokens = [self.refresh_token("uid-1"), self.refresh_token("uid-2"), "not-a-jwt", + self.refresh_token("uid-1")] + self.gateway._store_credential.side_effect = lambda directory, name, content, uid, **kw: Path(directory) / name + with patch.object(admin_api.auth_oauth, "refresh_tokens", side_effect=fake), \ + patch.object(admin_api.auth_oauth, "fetch_account", side_effect=fake_account): + response = self.client.post("/admin/credentials/refresh-import", headers=self.headers, + json={"profile": "cn-work", "refresh_tokens": tokens}) + self.assertEqual(response.status_code, 200, response.text) + rows = response.json()["results"] + self.assertEqual([row["ok"] for row in rows], [True, False, False]) + self.assertEqual(rows[0]["name"], "uid-1.info") + self.assertIn("失效", rows[1]["error"]) + self.assertIn("sub", rows[2]["error"]) + self.assertEqual([call[2] for call in calls], ["uid-1", "uid-2"]) + self.gateway._store_credential.assert_called_once() + saved = json.loads(self.gateway._store_credential.call_args.args[2].decode("utf-8")) + self.assertEqual(saved["auth"]["accessToken"], "synthetic-access") + self.assertEqual(saved["auth"]["domain"], "www.workbuddy.cn") + self.assertEqual(saved["account"]["uid"], "uid-1") + # The account block supplies the tenant, so enterprise credentials must not fall back to a bare uid. + self.assertEqual(saved["account"]["enterpriseId"], "tenant-7") + self.assertEqual(saved["account"]["type"], "ultimate") + self.assertNotIn("synthetic-access", response.text) + + def test_config_notice_lists_runtime_env_and_dismissal_persists(self): + from unittest.mock import patch + self.store.update_settings({"config_notice_muted": False}, self.store.snapshot()["revision"]) + # Each dashboard build must start from a fresh payload, not the mocked shared dict. + self.audit.dashboard.side_effect = lambda *args, **kwargs: { + "summary": {}, "series": [], "models": [], "profiles": []} + with patch.dict("os.environ", {"CODEBUDDY2API_MAX_IMAGES": "8", "CODEBUDDY2API_BIND": "0.0.0.0"}): + dashboard = self.client.get("/admin/dashboard", headers=self.headers) + notice = dashboard.json()["config_notice"] + self.assertEqual(notice["count"], 1) + self.assertEqual(notice["items"][0]["key"], "max_images") + self.assertIn("config migrate", notice["command"]) + dismissed = self.client.post("/admin/config-notice/dismiss", headers=self.headers, json={}) + self.assertEqual(dismissed.status_code, 200, dismissed.text) + self.assertTrue(self.store.snapshot()["settings"]["config_notice_muted"]) + again = self.client.get("/admin/dashboard", headers=self.headers) + self.assertNotIn("config_notice", again.json()) + self.assertEqual(self.client.post("/admin/config-notice/dismiss").status_code, 401) + def test_export_only_selected_info_and_symlink_identity_protection(self): content = json.dumps(self.credential()) (self.root / "first.info").write_text(content) diff --git a/tests/test_build_lock.py b/tests/test_build_lock.py index 5096f2c..aa837e8 100644 --- a/tests/test_build_lock.py +++ b/tests/test_build_lock.py @@ -20,6 +20,12 @@ def requirement_lines(path): yield line.strip() +def direct_requirements(): + """Canonical names of the project's direct dependencies, read from pyproject.""" + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + return {canonicalize_name(Requirement(dep).name) for dep in project["dependencies"]} + + class BuildLockTests(unittest.TestCase): def test_every_resolved_dependency_has_an_exact_version_and_sha256(self): locked = set() @@ -36,15 +42,15 @@ def test_every_resolved_dependency_has_an_exact_version_and_sha256(self): for digest in hashes: self.assertRegex(digest.strip(), r"^sha256:[0-9a-f]{64}$") locked.add(canonicalize_name(parsed.name)) - direct = {canonicalize_name(Requirement(line).name) - for line in requirement_lines(ROOT / "requirements.in")} + direct = direct_requirements() self.assertTrue(direct) self.assertTrue(direct <= locked) def test_uv_metadata_and_exported_locks_stay_consistent(self): project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] self.assertEqual(project["version"], (ROOT / "VERSION").read_text().strip()) - self.assertEqual(set(project["dependencies"]), set(requirement_lines(ROOT / "requirements.in"))) + self.assertEqual(direct_requirements(), {canonicalize_name(Requirement(dep).name) + for dep in project["dependencies"]}) lock = tomllib.loads((ROOT / "uv.lock").read_text()) packages = {p["name"]: p for p in lock["package"] if p["name"] != project["name"]} exported = set() @@ -90,6 +96,21 @@ def test_ci_workflow_parses_and_keeps_install_flags_in_the_run_string(self): "--require-hashes", "--only-binary=:all:", "-r", "requirements.txt"]) + def test_published_image_tags_are_version_edge_and_latest(self): + """Only the release version, edge for main and latest for releases are published.""" + workflow = yaml.safe_load((ROOT / ".github/workflows/docker.yml").read_text()) + step = next(step for step in workflow["jobs"]["image"]["steps"] + if str(step.get("uses", "")).startswith("docker/metadata-action")) + tags = [line.strip() for line in step["with"]["tags"].splitlines() if line.strip()] + self.assertEqual(tags, [ + # Pull-request builds carry an identity but are never pushed. + "type=ref,event=pr", + "type=semver,pattern={{version}}", + "type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}", + "type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}", + ]) + self.assertNotIn("flavor", step["with"]) + def test_ci_and_container_installs_require_verified_wheels(self): installs = [] for path in (ROOT / "Dockerfile", ROOT / ".github/workflows/docker.yml"): diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py new file mode 100644 index 0000000..53d5132 --- /dev/null +++ b/tests/test_config_migration.py @@ -0,0 +1,169 @@ +"""Offline configuration migration: target identity, lease refusal and atomic writes.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import hashlib +import os +import sqlite3 +import tempfile +import unittest + +from app import config_migration +from app.control_store import ControlStore + + +class ConfigMigrationTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.auth = self.root / "auth" + self.auth.mkdir() + self.db = self.auth / "control.sqlite3" + store = ControlStore(self.db) + store.close() + self.env = self.root / ".env" + + def write_env(self, text): + self.env.write_text(text, encoding="utf-8") + return self.env + + def snapshot_settings(self): + store = ControlStore(self.db) + try: + return store.snapshot() + finally: + store.close() + + def test_resolves_the_env_file_and_the_data_directory_it_declares(self): + env = self.write_env("CODEBUDDY_AUTH_DIR=./auth\nCODEBUDDY2API_MAX_IMAGES=8\n") + targets = config_migration.resolve_targets(cwd=self.root, env_file=env, environ={}) + self.assertEqual(targets["db_path"], (self.auth / "control.sqlite3").resolve()) + self.assertEqual(targets["env_values"]["CODEBUDDY2API_MAX_IMAGES"], "8") + explicit = config_migration.resolve_targets(cwd=self.root, env_file=env, environ={}, + data_dir=self.root / "other") + self.assertEqual(explicit["db_path"], (self.root / "other" / "control.sqlite3").resolve()) + + def test_rejects_conflicting_or_unsafe_paths(self): + with self.assertRaises(config_migration.MigrationError): + config_migration.resolve_targets(no_env=True, env_file=self.env, cwd=self.root) + with self.assertRaises(config_migration.MigrationError): + config_migration.resolve_targets(db="a", data_dir="b", cwd=self.root) + link = self.root / "linked.env" + link.symlink_to(self.write_env("A=1\n")) + with self.assertRaisesRegex(config_migration.MigrationError, "符号链接"): + config_migration.read_env_file(link) + broken = self.write_env('A="unterminated\n') + with self.assertRaises(config_migration.MigrationError): + config_migration.read_env_file(broken) + + def test_identity_check_rejects_foreign_or_stale_databases(self): + plain = self.root / "foreign.sqlite3" + conn = sqlite3.connect(plain) + conn.execute("CREATE TABLE other (name TEXT)") + conn.commit() + conn.close() + with self.assertRaisesRegex(config_migration.MigrationError, "user_version=0"): + config_migration.inspect_database(plain) + empty = self.root / "empty.sqlite3" + empty.touch() + with self.assertRaisesRegex(config_migration.MigrationError, "文件为空"): + config_migration.inspect_database(empty) + text_file = self.root / "notes.txt" + text_file.write_text("hello", encoding="utf-8") + with self.assertRaisesRegex(config_migration.MigrationError, "不是 SQLite"): + config_migration.inspect_database(text_file) + logs = self.root / "logs.sqlite3" + ControlStore(logs).close() + with self.assertRaisesRegex(config_migration.MigrationError, "logs.sqlite3"): + config_migration.inspect_database(logs) + older = self.root / "older.sqlite3" + conn = sqlite3.connect(older) + conn.execute("CREATE TABLE control (id INTEGER PRIMARY KEY, revision INTEGER, payload TEXT)") + conn.execute("PRAGMA user_version=1") + conn.commit() + conn.close() + with self.assertRaisesRegex(config_migration.MigrationError, "schema=1"): + config_migration.inspect_database(older) + + def test_identity_check_accepts_a_control_store(self): + info = config_migration.inspect_database(self.db) + self.assertEqual((info["version"], info["revision"], info["accounts"]), (2, 0, 0)) + self.assertIsNone(info["lease"]) + + def test_dry_run_reports_changes_without_writing(self): + self.write_env("CODEBUDDY2API_MAX_IMAGES=8\nCODEBUDDY2API_STREAM_MODE=realtime\n") + report = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}, dry_run=True) + self.assertFalse(report["written"]) + self.assertEqual(report["changes"], {"max_images": 8, "stream_mode": "realtime"}) + self.assertEqual(self.snapshot_settings()["revision"], 0) + + def test_migrate_writes_runtime_values_and_mutes_the_notice(self): + self.write_env("CODEBUDDY2API_MAX_IMAGES=8\nCODEBUDDY2API_BIND=0.0.0.0\n") + report = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}) + self.assertTrue(report["written"]) + snapshot = self.snapshot_settings() + self.assertEqual(snapshot["revision"], 1) + self.assertEqual(snapshot["settings"]["max_images"], 8) + self.assertTrue(snapshot["settings"]["config_notice_muted"]) + self.assertNotIn("host", snapshot["settings"]) # Deployment keys stay in .env. + again = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}) + self.assertFalse(again["written"]) + self.assertEqual(self.snapshot_settings()["revision"], 1) + + def test_invalid_value_leaves_the_database_untouched(self): + self.write_env("CODEBUDDY2API_MAX_IMAGES=not-a-number\n") + digest = hashlib.sha256(self.db.read_bytes()).hexdigest() + with self.assertRaises(config_migration.MigrationError): + config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}) + self.assertEqual(hashlib.sha256(self.db.read_bytes()).hexdigest(), digest) + + def test_running_lease_blocks_migration_unless_forced(self): + store = ControlStore(self.db) + try: + store.claim_service_lease() + finally: + store.close() + self.write_env("CODEBUDDY2API_MAX_IMAGES=8\n") + with self.assertRaisesRegex(config_migration.MigrationError, "运行中的实例"): + config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}) + forced = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}, force=True) + self.assertTrue(forced["written"]) + + def test_key_filter_limits_the_batch(self): + self.write_env("CODEBUDDY2API_MAX_IMAGES=8\nCODEBUDDY2API_STREAM_MODE=realtime\n") + report = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, + environ={}, keys=["max_images"]) + self.assertEqual(report["changes"], {"max_images": 8}) + with self.assertRaisesRegex(config_migration.MigrationError, "未知配置项"): + config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}, keys=["nope"]) + + def test_language_is_saved_with_the_same_transaction(self): + self.write_env("CODEBUDDY2API_MAX_IMAGES=8\n") + report = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, + environ={}, language="en") + self.assertEqual(report["language"], "en") + snapshot = self.snapshot_settings() + self.assertEqual(snapshot["revision"], 1) + self.assertEqual(snapshot["settings"]["language"], "en") + again = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, + environ={}, language="en") + self.assertFalse(again["written"]) + self.assertIsNone(again["language"]) + + def test_language_only_change_does_not_require_env_values(self): + report = config_migration.migrate(cwd=self.root, db=self.db, no_env=True, + environ={}, language="zh") + self.assertEqual(report["changes"], {}) + self.assertEqual(report["language"], "zh") + self.assertTrue(report["written"]) + self.assertEqual(self.snapshot_settings()["settings"]["language"], "zh") + + def test_missing_database_is_never_created(self): + missing = self.root / "nope" / "control.sqlite3" + with self.assertRaisesRegex(config_migration.MigrationError, "控制库不存在"): + config_migration.migrate(cwd=self.root, db=missing, no_env=True, environ={}) + self.assertFalse(missing.parent.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 9e72736..1f94b14 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -78,7 +78,9 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertEqual(values["CODEBUDDY2API_" + env_key], str(RUNTIME_DEFAULTS[config_key])) self.assertEqual(values["CODEBUDDY2API_KEY"], "") self.assertEqual(values["CODEBUDDY2API_BIND"], "127.0.0.1") - self.assertEqual(values["CODEBUDDY2API_IMAGE"], "codebuddy2api:local") + # The template leaves the image commented so Compose falls back to the published latest tag. + self.assertNotIn("CODEBUDDY2API_IMAGE", values) + self.assertEqual(values["CODEBUDDY2API_AUTH_PATH"], "./auth") self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", values) self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", (ROOT / "docker-compose.yml").read_text()) self.assertEqual(values["CODEBUDDY2API_ADMIN_CSRF"], str(RUNTIME_DEFAULTS["admin_csrf"]).lower()) @@ -87,20 +89,22 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", values) self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES", values) - def test_tool_metadata_compose_environment_is_optional(self): - key = "CODEBUDDY2API_KEEP_TOOL_METADATA" - self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{key}: *$") - self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {key}=(true|false)$") - origins = "CODEBUDDY2API_ADMIN_ORIGINS" - self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{origins}: *$") - self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {origins}=https://") - - def test_responses_projection_compose_environment_is_optional(self): + def test_tool_metadata_and_origins_stay_optional_through_dotenv(self): + compose = (ROOT / "docker-compose.yml").read_text() + example = (ROOT / ".env.example").read_text() + self.assertIn("env_file:", compose) + for key in ("CODEBUDDY2API_KEEP_TOOL_METADATA", "CODEBUDDY2API_ADMIN_ORIGINS"): + with self.subTest(key=key): + # .env decides these; leaving them out keeps the WebUI authoritative. + self.assertNotIn(key + ":", compose) + self.assertRegex(example, rf"(?m)^# {key}=") + + def test_responses_projection_stays_optional_through_dotenv(self): compose = (ROOT / "docker-compose.yml").read_text() example = (ROOT / ".env.example").read_text() for key in ("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", "CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES"): - self.assertRegex(compose, rf"(?m)^ +{key}: *$") + self.assertNotIn(key + ":", compose) self.assertRegex(example, rf"(?m)^# {key}=") def test_docker_copies_and_allows_all_local_runtime_imports(self): @@ -177,14 +181,23 @@ def deny_external_access(event, args): capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr) - def test_compose_has_old_syntax_fallbacks_without_required_env_file(self): + def test_compose_loads_dotenv_and_defaults_to_the_latest_image(self): text = (ROOT / "docker-compose.yml").read_text() self.assertIn('version: "3.8"', text) - self.assertNotIn("env_file:", text) - self.assertNotIn("required:", text) - for name, fallback in (("MAX_IMAGES", "16"), ("IMAGE_POLICY", "truncate"), - ("MAX_REQUEST_BYTES", "33554432"), ("LOG_BODY_LIMIT", "65536"), ("ADMIN_CSRF", "true")): - self.assertIn("${CODEBUDDY2API_" + name + ":-" + fallback + "}", text) + # The whole .env reaches the container and a missing file stays valid. + self.assertIn("env_file:", text) + self.assertIn("required: false", text) + self.assertIn("ghcr.io/maiphucgiang/codebuddy2api:latest", text) + # Optional knobs come from .env instead of per-variable fallbacks in the service. + for name in ("MAX_IMAGES", "IMAGE_POLICY", "MAX_REQUEST_BYTES", "LOG_BODY_LIMIT", "ADMIN_CSRF"): + self.assertNotIn("${CODEBUDDY2API_" + name, text) + self.assertEqual(re.findall(r"(?m)^ +([A-Z][A-Z0-9_]*):", text), + ["CODEBUDDY_AUTH_DIR", "CODEBUDDY2API_ALLOW_OPEN_NOAUTH"]) + # The image CMD owns the listener flags, so Compose does not restate the command. + self.assertNotIn("command:", text) + dockerfile = (ROOT / "Dockerfile").read_text() + self.assertIn('CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", ' + '"--skip-check", "--desensitize"]', dockerfile) for filename in ("README.md", "README.zh-CN.md"): doc = (ROOT / filename).read_text() self.assertIn("cp .env.example .env", doc) diff --git a/tests/test_environment_config.py b/tests/test_environment_config.py index 358d2cd..bc1b1e3 100644 --- a/tests/test_environment_config.py +++ b/tests/test_environment_config.py @@ -246,6 +246,16 @@ def test_request_context_mode_precedence_and_validation(self): self.start({'CODEBUDDY2API_REQUEST_CONTEXT_MODE': 'invalid'}) + def test_language_is_never_locked_and_the_notice_flag_stays_hidden(self): + from app.settings import SCHEMA, resolve_settings + config = {"settings_sources": {key: "dotenv" for key in SCHEMA}} + config.update({key: spec["default"] for key, spec in SCHEMA.items()}) + items = {item["key"]: item for item in resolve_settings(config)} + self.assertFalse(items["language"]["locked"]) + self.assertEqual(items["language"]["value"], "auto") + self.assertNotIn("config_notice_muted", items) + self.assertTrue(items["host"]["locked"]) + def test_example_covers_all_active_runtime_environment_names(self): example = (ROOT / '.env.example').read_text() documented = set(re.findall(r'(?m)^(?:# )?(CODEBUDDY[A-Z0-9_]+)=', example)) @@ -267,6 +277,8 @@ def compose(self, values): self.skipTest('Compose plugin unavailable') dotenv = self.root / 'compose.env' dotenv.write_text(''.join(name + '=' + value + '\n' for name, value in values.items())) + # The service reads .env through env_file; point it at this batch instead of the checkout. + env['CODEBUDDY2API_ENV_FILE'] = str(dotenv) command = ['docker', 'compose', '--project-directory', str(self.root), '--env-file', str(dotenv), '-f', str(ROOT / 'docker-compose.yml'), 'config', '--format', 'json'] result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=20) @@ -289,7 +301,8 @@ def test_compose_forwards_dotenv_limits_and_retries_without_changing_internal_bi service = self.compose(values) port = service['ports'][0] self.assertEqual((port['host_ip'], str(port['published']), port['target']), ('127.0.0.2', '9087', 8787)) - self.assertEqual(service['command'][:5], ['python3', 'converter.py', '--host', '0.0.0.0', '--port']) + # Compose inherits the image command instead of repeating the listener flags. + self.assertIsNone(service.get('command')) self.assertEqual(service['environment']['CODEBUDDY_AUTH_DIR'], '/data/auth') for name, value in values.items(): if name not in ('CODEBUDDY2API_BIND', 'CODEBUDDY2API_PORT'): @@ -306,7 +319,8 @@ def test_compose_unset_optional_settings_do_not_override_webui(self): 'CODEBUDDY2API_ADMIN_ORIGINS'): self.assertIsNone(service['environment'].get(name)) self.assertEqual(service['ports'][0]['host_ip'], '127.0.0.1') - self.assertEqual(service['environment']['CODEBUDDY_IMPORT_DIR'], '/data/auth/imports') + # Container defaults to the data directory's imports/; .env may override it. + self.assertIsNone(service['environment'].get('CODEBUDDY_IMPORT_DIR')) if __name__ == '__main__': diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py index eda4be0..f04cf7a 100644 --- a/tests/test_identity_sync.py +++ b/tests/test_identity_sync.py @@ -428,7 +428,8 @@ def barrier(*args, **kwargs): patch.object(c.uvicorn, "run", side_effect=barrier), \ patch("sys.stderr", new=io.StringIO()): c.main() - self.assertEqual(len(observed), 3) + # refresher, housekeeper, lease heartbeat and the server itself. + self.assertEqual(len(observed), 4) if __name__ == "__main__": diff --git a/tests/test_realtime_streaming.py b/tests/test_realtime_streaming.py index 07c8771..6eeb5d6 100644 --- a/tests/test_realtime_streaming.py +++ b/tests/test_realtime_streaming.py @@ -644,7 +644,7 @@ async def test_realtime_http_failure_preserves_status_and_retry_after(self): self.assertIsInstance(failure, UpstreamResponseError) self.assertEqual((failure.status, failure.headers.get("Retry-After")), (429, "7")) - async def test_reasoning_deltas_are_coalesced_before_visible_output(self): + async def test_realtime_streams_reasoning_while_compatible_coalesces(self): rows = [ _line({"reasoning_content": "W"}), _line({"reasoning_content": "line"}), @@ -654,47 +654,63 @@ async def test_reasoning_deltas_are_coalesced_before_visible_output(self): _line({}, "stop", {"prompt_tokens": 1, "completion_tokens": 4, "total_tokens": 5}), "data: [DONE]\n\n", ] - for protocol in ("chat", "responses", "messages"): - with self.subTest(protocol=protocol): - response = _FixedResponse(rows) - - @asynccontextmanager - async def backend(*args, **kwargs): - yield response - - path = {"chat": "/v1/chat/completions", "responses": "/v1/responses", - "messages": "/v1/messages"}[protocol] - sent = await self.asgi_post( - path, self.payload(protocol, False), backend, - config={"stream_mode": "realtime"}) - wire = b"".join(message.get("body", b"") for message in sent - if message["type"] == "http.response.body") - if protocol == "chat": - events = [json.loads(line[6:]) for line in wire.decode().splitlines() - if line.startswith("data: ") and line[6:] != "[DONE]"] - reasoning = [event["choices"][0]["delta"].get("reasoning_content") - for event in events - if event.get("choices") and event["choices"][0]["delta"].get("reasoning_content")] - content = [event["choices"][0]["delta"].get("content") - for event in events - if event.get("choices") and event["choices"][0]["delta"].get("content")] - self.assertEqual(reasoning, ["Wline478"]) - self.assertEqual(content, ["85", "done"]) - self.assertLess( - next(i for i, event in enumerate(events) - if event["choices"][0]["delta"].get("reasoning_content")), - next(i for i, event in enumerate(events) - if event["choices"][0]["delta"].get("content"))) - elif protocol == "responses": - events = _events(wire.decode()) - reasoning = [event["delta"] for event in events - if event["type"] == "response.reasoning_summary_text.delta"] - self.assertEqual(reasoning, ["Wline478"]) - else: - blocks = _serial_blocks(self, wire.decode()) - self.assertEqual([block["type"] for block in blocks], ["thinking", "text"]) - self.assertEqual(blocks[0]["thinking"], "Wline478") - self.assertEqual(blocks[1]["text"], "85done") + + async def collected(protocol, mode): + response = _FixedResponse(rows) + + @asynccontextmanager + async def backend(*args, **kwargs): + yield response + + path = {"chat": "/v1/chat/completions", "responses": "/v1/responses", + "messages": "/v1/messages"}[protocol] + sent = await self.asgi_post( + path, self.payload(protocol, False), backend, + config={"stream_mode": mode}) + return b"".join(message.get("body", b"") for message in sent + if message["type"] == "http.response.body") + + # Chat: realtime forwards each reasoning delta; compatible keeps one replay block. + wire = await collected("chat", "realtime") + events = [json.loads(line[6:]) for line in wire.decode().splitlines() + if line.startswith("data: ") and line[6:] != "[DONE]"] + deltas = [event["choices"][0]["delta"] for event in events if event.get("choices")] + self.assertEqual([delta.get("reasoning_content") for delta in deltas if delta.get("reasoning_content")], + ["W", "line", "478"]) + self.assertEqual([delta.get("content") for delta in deltas if delta.get("content")], + ["85", "done"]) + first_reasoning = next(i for i, delta in enumerate(deltas) if delta.get("reasoning_content")) + first_content = next(i for i, delta in enumerate(deltas) if delta.get("content")) + self.assertLess(first_reasoning, first_content) + + wire = await collected("chat", "compatible") + events = [json.loads(line[6:]) for line in wire.decode().splitlines() + if line.startswith("data: ") and line[6:] != "[DONE]"] + deltas = [event["choices"][0]["delta"] for event in events if event.get("choices")] + self.assertEqual([delta.get("reasoning_content") for delta in deltas if delta.get("reasoning_content")], + ["Wline478"]) + + # Responses: one logical reasoning item, delivered as incremental deltas. + wire = await collected("responses", "realtime") + events = _events(wire.decode()) + reasoning = [event["delta"] for event in events + if event["type"] == "response.reasoning_summary_text.delta"] + self.assertEqual(reasoning, ["W", "line", "478"]) + response_items = [event["item"]["type"] for event in events + if event["type"] == "response.output_item.done"] + self.assertEqual(response_items.count("reasoning"), 1) + + # Messages: one thinking block, delivered as incremental deltas. + wire = await collected("messages", "realtime") + events = _events(wire.decode()) + thinking = [event["delta"]["thinking"] for event in events + if event["type"] == "content_block_delta" + and event["delta"]["type"] == "thinking_delta"] + self.assertEqual(thinking, ["W", "line", "478"]) + blocks = _serial_blocks(self, wire.decode()) + self.assertEqual([block["type"] for block in blocks], ["thinking", "text"]) + self.assertEqual(blocks[0]["thinking"], "Wline478") + self.assertEqual(blocks[1]["text"], "85done") async def test_hot_mode_change_affects_only_the_next_request(self): before, _ = await self.drive("responses", "realtime", True, change_to="compatible") diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts new file mode 100644 index 0000000..b9e4286 --- /dev/null +++ b/web/e2e/layout.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Guards narrow layouts: tab rows wrap and dropdown menus escape scrolling tables. +const credentials = Array.from({ length: 12 }, (_, i) => ({ + id: `account-${i}`, + name: `very-long-credential-name-${i}.info`, + enabled: true, + profile: "cn-cli", + nickname: `隔离测试账号 ${i}`, + health: "ready", + credits: { remaining: 100 + i }, + cooldowns: [], + token_expires_at: Date.now() + 86400000, +})); + +const settings = { + revision: 1, + items: [ + { + key: "stream_mode", + label: "流式模式", + type: "string", + value: "compatible", + stored: null, + source: "default", + mode: "hot", + locked: false, + choices: ["compatible", "realtime"], + }, + { + key: "host", + label: "监听地址", + type: "string", + value: "0.0.0.0", + stored: null, + source: "env", + mode: "restart", + locked: true, + }, + { + key: "audit_budget", + label: "审计预算", + type: "integer", + value: 8192, + stored: null, + source: "internal", + mode: "restart", + locked: true, + }, + ], + audit: { logical_bytes: 0, db_bytes: 0, wal_bytes: 0, shm_bytes: 0, degraded: false }, +}; + +async function mockAPI(page: Page) { + await page.route("**/admin/**", async (route) => { + const url = new URL(route.request().url()); + const send = (json: unknown) => route.fulfill({ json, status: 200 }); + if (url.pathname === "/admin/session") return send({ authenticated: true, csrf_token: "cs" }); + if (url.pathname === "/admin/settings") return send(settings); + if (url.pathname === "/admin/credentials") return send({ credentials }); + return send({}); + }); +} + +async function openDashboard(page: Page) { + await mockAPI(page); + await page.goto("/dashboard/settings"); +} + +test.describe("narrow layout", () => { + test("settings tabs wrap instead of pushing the page sideways", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await openDashboard(page); + await expect(page.getByRole("tab", { name: /系统只读/ })).toBeVisible(); + const numbers = await page.evaluate(() => { + const scroller = document.scrollingElement!; + const tabs = document.querySelector("[class*=tabs]"); + return { + overflow: scroller.scrollWidth - scroller.clientWidth, + tabOverflow: tabs ? tabs.scrollWidth - tabs.clientWidth : 0, + }; + }); + expect(numbers.overflow).toBeLessThanOrEqual(1); + expect(numbers.tabOverflow).toBeLessThanOrEqual(1); + }); + + test("row menu escapes the scrolling table and stays inside the viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await openDashboard(page); + await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "列表视图" }).first().click(); + const menus = page.getByRole("button", { name: /更多/ }); + await expect(menus.first()).toBeVisible(); + await menus.nth((await menus.count()) - 1).click(); + const menu = page.locator("[role=menu]").first(); + await expect(menu).toBeVisible(); + const box = await menu.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(390); + expect(box!.y + box!.height).toBeLessThanOrEqual(820); + // Portaled to , so a `overflow: auto` table wrapper cannot clip it. + const portaled = await menu.evaluate((el) => el.parentElement === document.body); + expect(portaled).toBe(true); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e7f3fc5..9102ff4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { NavLink, Navigate, Outlet, Route, Routes, useLocation } from "react-router"; -import { api, establishSession, errorMessage, useSession } from "./api"; +import { api, establishSession, errorMessage, list, object, useSession } from "./api"; import { ErrorNotice, Icon } from "./components"; import { Dashboard } from "./pages/Dashboard"; import { Models } from "./pages/Models"; @@ -10,7 +10,7 @@ import { Settings } from "./pages/Settings"; import { Storage } from "./pages/Storage"; import { Appearance } from "./appearance"; import s from "./ui.module.scss"; -import { LanguageProvider, useT } from "./i18n"; +import { LanguageProvider, chooseLanguage, serverPreference, useT } from "./i18n"; const navigation = [ ["", "nav.overview", "grid"], @@ -239,6 +239,19 @@ export default function App() { useEffect(() => { void establishSession(); }, []); + const status = useSession((state) => state.status); + useEffect(() => { + if (status !== "authenticated") return; + // The stored language wins over the browser cache once the session is known. + void api + .get("/settings") + .then((response) => { + const data = object(response.data); + const stored = list(data.items).find((item) => item.key === "language"); + if (stored) chooseLanguage(serverPreference(stored.value)); + }) + .catch(() => undefined); + }, [status]); return ( diff --git a/web/src/appearance.tsx b/web/src/appearance.tsx index 3ee8382..3e4583c 100644 --- a/web/src/appearance.tsx +++ b/web/src/appearance.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; +import { api, object } from "./api"; import { Drawer, DrawerPresence, Icon } from "./components"; import { chooseLanguage, languagePreference, useT, type LangPref } from "./i18n"; import s from "./ui.module.scss"; @@ -152,7 +153,20 @@ export function Appearance() { name="ui-language" value={value} checked={languagePreference() === value} - onChange={() => chooseLanguage(value)} + onChange={() => { + chooseLanguage(value); + // Persist through the API so the CLI and other browsers share it. + void api + .get("/settings") + .then((response) => { + const data = object(response.data); + return api.patch("/settings", { + values: { language: value === "system" ? "auto" : value }, + revision: data.revision, + }); + }) + .catch(() => undefined); + }} /> {label} diff --git a/web/src/configNotice.test.tsx b/web/src/configNotice.test.tsx new file mode 100644 index 0000000..d28bc9a --- /dev/null +++ b/web/src/configNotice.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource } from "./api"; +import { Dashboard } from "./pages/Dashboard"; + +vi.mock("./api", async (original) => ({ + ...(await original()), + useResource: vi.fn(), +})); +afterEach(() => vi.restoreAllMocks()); + +function payload(configNotice?: Record) { + return { + data: { + summary: { requests: 1, success_rate: 1, total_tokens: null, credit: null }, + series: [], + models: [], + profiles: [], + health: { credentials: [] }, + storage: {}, + range: { granularity: "day" }, + ...(configNotice ? { config_notice: configNotice } : {}), + }, + reload: vi.fn(), + loading: false, + error: null, + }; +} + +describe("configuration source notice", () => { + it("lists environment-backed runtime keys and dismisses through the API", async () => { + vi.mocked(useResource).mockReturnValue( + payload({ + count: 2, + items: [ + { key: "max_images", label: "单请求图片上限" }, + { key: "stream_mode", label: "流式模式" }, + ], + command: "python3 converter.py config migrate", + }) as never, + ); + const post = vi.spyOn(api, "post").mockResolvedValue({ data: { muted: true } } as never); + render(); + expect(await screen.findByText("配置来源分散")).toBeTruthy(); + expect(screen.getByText(/max_images、stream_mode/)).toBeTruthy(); + expect(screen.getByText("python3 converter.py config migrate")).toBeTruthy(); + // Dismissal is confirmed in a drawer before the API call. + fireEvent.click(screen.getByRole("button", { name: "不再提醒" })); + expect(post).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: "确认关闭提醒" })); + await waitFor(() => expect(post).toHaveBeenCalledWith("/config-notice/dismiss", {})); + await waitFor(() => expect(screen.queryByText("配置来源分散")).toBeNull()); + }); + + it("stays hidden when the server sends no notice and keeps it on failure", async () => { + vi.mocked(useResource).mockReturnValue(payload() as never); + const { unmount } = render(); + await waitFor(() => expect(useResource).toHaveBeenCalled()); + expect(screen.queryByText("配置来源分散")).toBeNull(); + unmount(); + + vi.mocked(useResource).mockReturnValue( + payload({ count: 1, items: [{ key: "max_images" }], command: "migrate" }) as never, + ); + vi.spyOn(api, "post").mockRejectedValue(new Error("nope")); + render(); + fireEvent.click(await screen.findByRole("button", { name: "不再提醒" })); + fireEvent.click(await screen.findByRole("button", { name: "确认关闭提醒" })); + await waitFor(() => expect(screen.getByText("nope")).toBeTruthy()); + // The banner stays because the server never recorded the dismissal. + fireEvent.click(screen.getByRole("button", { name: "取消" })); + await waitFor(() => expect(screen.getByText("配置来源分散")).toBeTruthy()); + }); +}); diff --git a/web/src/i18n.test.tsx b/web/src/i18n.test.tsx index 9aca297..84009c2 100644 --- a/web/src/i18n.test.tsx +++ b/web/src/i18n.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { chooseLanguage, LanguageProvider, translate, useT } from "./i18n"; +import { chooseLanguage, LanguageProvider, serverPreference, translate, useT } from "./i18n"; beforeEach(() => { try { @@ -64,3 +64,13 @@ describe("i18n", () => { expect(screen.getByTestId("probe").textContent).toBe("Overview"); }); }); + +describe("stored language preference", () => { + it("maps stored values onto UI preferences", () => { + expect(serverPreference("zh")).toBe("zh"); + expect(serverPreference("en")).toBe("en"); + expect(serverPreference("auto")).toBe("system"); + expect(serverPreference(undefined)).toBe("system"); + expect(serverPreference("klingon")).toBe("system"); + }); +}); diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 5f8b46e..6e3d593 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -153,6 +153,20 @@ const zh: Record = { "logs.action.credentials_exported": "凭证已导出", "logs.action.storage_archive": "日志归档", "dash.refresh": "刷新", + "dash.configNoticeTitle": "配置来源分散", + "dash.configNoticeBody": + "有 {count} 项运行参数来自 .env 或环境变量,在 WebUI 中只读:{keys}。停止服务后执行下面的命令把它们迁移到配置库,之后即可在 WebUI 修改。", + "dash.configNoticeCopy": "复制命令", + "dash.configNoticeCopied": "已复制", + "dash.configNoticeDismiss": "不再提醒", + "dash.configNoticeConfirmTitle": "关闭迁移提醒", + "dash.configNoticeConfirmStrong": "关闭后不再提示,即使之后又新增了运行参数", + "dash.configNoticeConfirmBody": + "当前 {count} 项仍由 .env 或环境变量提供,在 WebUI 中保持只读。要真正在 WebUI 修改,请停止服务后执行迁移命令;只关闭提醒不会改变这些配置。", + "dash.configNoticeCancel": "取消", + "dash.configNoticeConfirm": "确认关闭提醒", + "dash.configNoticeConfirming": "正在关闭…", + "dash.configNoticeFailed": "未能关闭提醒,请稍后重试", "dash.runtime": "运行概览", "dash.trend": "请求趋势", "dash.range": "统计时间范围", @@ -430,6 +444,21 @@ const en: Record = { "logs.action.credentials_exported": "Credentials exported", "logs.action.storage_archive": "Log archive", "dash.refresh": "Refresh", + "dash.configNoticeTitle": "Scattered configuration sources", + "dash.configNoticeBody": + "{count} runtime settings still come from .env or the environment and stay read-only in the WebUI: {keys}. Stop the service, run the command below to move them into the configuration store, then edit them here.", + "dash.configNoticeCopy": "Copy command", + "dash.configNoticeCopied": "Copied", + "dash.configNoticeDismiss": "Do not remind me", + "dash.configNoticeConfirmTitle": "Stop the migration reminder", + "dash.configNoticeConfirmStrong": + "The reminder never returns, even after new runtime keys appear", + "dash.configNoticeConfirmBody": + "{count} settings still come from .env or the environment and stay read-only here. To edit them, stop the service and run the migration command; dismissing this reminder changes no settings.", + "dash.configNoticeCancel": "Cancel", + "dash.configNoticeConfirm": "Stop the reminder", + "dash.configNoticeConfirming": "Stopping…", + "dash.configNoticeFailed": "Could not dismiss the notice; try again later", "dash.runtime": "Runtime overview", "dash.trend": "Request trend", "dash.range": "Statistics range", @@ -579,6 +608,10 @@ function savedPref(): LangPref { } } let currentPref: LangPref = "system"; +export function serverPreference(value: unknown): LangPref { + if (value === "zh" || value === "en") return value; + return "system"; +} export function languagePreference(): LangPref { return currentPref; } diff --git a/web/src/imports.test.ts b/web/src/imports.test.ts index eb57f9f..ebe26a4 100644 --- a/web/src/imports.test.ts +++ b/web/src/imports.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { zipSync, strToU8 } from "fflate"; -import { prepareImports, validName, MAX_FILE } from "./imports"; +import { + MAX_FILE, + MAX_REFRESH_TOKEN_LENGTH, + parseRefreshTokens, + prepareImports, + validName, +} from "./imports"; function file(name: string, bytes: Uint8Array): File { return { name, @@ -42,3 +48,16 @@ describe("bounded credential import", () => { expect(result.results).toHaveLength(2); }); }); + +describe("refresh token batch input", () => { + it("splits lines, ignores comments and blanks, and dedupes tokens", () => { + const result = parseRefreshTokens(" rt-a \n# note\n\nrt-b\nrt-a\n"); + expect(result.tokens).toEqual(["rt-a", "rt-b"]); + expect(result.rejected).toBe(0); + }); + it("skips oversized lines without dropping the remaining ones", () => { + const result = parseRefreshTokens(`ok\n${"x".repeat(MAX_REFRESH_TOKEN_LENGTH + 1)}\nnext`); + expect(result.tokens).toEqual(["ok", "next"]); + expect(result.rejected).toBe(1); + }); +}); diff --git a/web/src/imports.ts b/web/src/imports.ts index 6884c7e..f1d8543 100644 --- a/web/src/imports.ts +++ b/web/src/imports.ts @@ -101,3 +101,33 @@ export async function prepareImports( if (!count) throw new Error("ZIP 中没有可导入文件"); return { files: accepted, results }; } + +// The endpoint is always explicit: a domestic refresh token cannot tell CLI from WorkBuddy. +export const REFRESH_PROFILES = [ + { value: "cn-work", label: "国内 WorkBuddy" }, + { value: "cn-cli", label: "国内 CodeBuddy CLI" }, + { value: "intl-work", label: "国际 WorkBuddy" }, + { value: "intl-cli", label: "国际 CodeBuddy CLI" }, +] as const; +export const MAX_REFRESH_TOKENS = 50; +export const MAX_REFRESH_TOKEN_LENGTH = 8192; +export type RefreshProfile = (typeof REFRESH_PROFILES)[number]["value"]; + +/** Split textarea input into unique tokens, skipping blanks, comments and oversized lines. */ +export function parseRefreshTokens(input: string): { tokens: string[]; rejected: number } { + const seen = new Set(); + const tokens: string[] = []; + let rejected = 0; + for (const raw of input.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.length > MAX_REFRESH_TOKEN_LENGTH) { + rejected++; + continue; + } + if (seen.has(line)) continue; + seen.add(line); + tokens.push(line); + } + return { tokens, rejected }; +} diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index fe04595..6104ccd 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { OAuth } from "../OAuth"; import { Trial } from "../Trial"; import { TravelSummary } from "../Travel"; @@ -30,7 +31,14 @@ import { ResourceState, profileLabel, } from "../components"; -import { prepareImports, type ImportResult } from "../imports"; +import { + MAX_REFRESH_TOKENS, + REFRESH_PROFILES, + parseRefreshTokens, + prepareImports, + type ImportResult, + type RefreshProfile, +} from "../imports"; import { downloadFilename } from "../downloads"; import s from "../ui.module.scss"; @@ -60,55 +68,160 @@ function boundModels(credential: Credential): string[] { } function ImportDrawer({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { + const [tab, setTab] = useState<"file" | "token">("file"); const [results, setResults] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [profile, setProfile] = useState("cn-work"); + const [input, setInput] = useState(""); + const parsed = parseRefreshTokens(input); + const tooMany = parsed.tokens.length > MAX_REFRESH_TOKENS; + const switchTab = (next: "file" | "token") => { + if (busy) return; + setTab(next); + setResults([]); + setError(null); + }; + const importFiles = (files: File[]) => { + setBusy(true); + setError(null); + setResults([]); + void prepareImports(files) + .then(async (batch) => { + setResults(batch.results); + if (!batch.files.length) return; + const response = await api.post("/credentials/upload", { + files: batch.files, + replace: false, + }); + const server = list(object(response.data).results).map((item) => { + if (typeof item.name !== "string" || typeof item.ok !== "boolean") + throw new Error("导入结果缺少 name / ok"); + return { + name: item.name, + ok: item.ok, + ...(item.error ? { error: text(item.error) } : {}), + }; + }); + setResults([...batch.results, ...server]); + if (server.some((item) => item.ok)) onDone(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; + const importTokens = () => { + if (busy || !parsed.tokens.length || tooMany) return; + setBusy(true); + setError(null); + setResults([]); + void api + .post("/credentials/refresh-import", { + profile, + refresh_tokens: parsed.tokens, + }) + .then((response) => { + const server = list(object(response.data).results).map((item) => { + if (typeof item.name !== "string" || typeof item.ok !== "boolean") + throw new Error("导入结果缺少 name / ok"); + return { + name: item.name, + ok: item.ok, + ...(item.error ? { error: text(item.error) } : {}), + }; + }); + setResults(server); + if (server.some((item) => item.ok)) onDone(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; return ( -

- 支持 UTF-8 .info 或 ZIP,ZIP 仅接受根目录 .info。单项 ≤ 1 MiB,每批 ≤ 100 项 / 32 - MiB,不覆盖现有文件。 -

- - {busy &&

正在检查并上传,请勿关闭…

} +
+ {( + [ + ["file", "文件导入"], + ["token", "仅刷新令牌"], + ] as ["file" | "token", string][] + ).map(([key, label]) => ( + + ))} +
+ {tab === "file" ? ( + <> +

+ 支持 UTF-8 .info 或 ZIP,ZIP 仅接受根目录 .info。单项 ≤ 1 MiB,每批 ≤ 100 项 / 32 + MiB,不覆盖现有文件。 +

+ + {busy &&

正在检查并上传,请勿关闭…

} + + ) : ( + <> +

+ 每行一个刷新令牌,逐条换取访问令牌后写入新凭证。端点决定产品与地区:国内刷新令牌分不出 + CLI 与 WorkBuddy,跨区令牌会被上游直接拒绝。 +

+ +