diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5c6abb..89b82c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,11 +59,11 @@ jobs: CPA_BINARY=/tmp/cpa-test/cli-proxy-api python3 tests/cpa_round2.py CPA_BINARY=/tmp/cpa-test/cli-proxy-api python3 tests/cpa_count_tokens.py CPA_BINARY=/tmp/cpa-test/cli-proxy-api python3 tests/cpa_thinking_suffix.py - gh release download v0.2.11 --repo Gaoshou101/CommandCodeBridge --pattern commandcodebridge_0.2.11_linux_amd64.zip --pattern checksums.txt --dir /tmp/bridge-previous + gh release download v0.2.12 --repo Gaoshou101/CommandCodeBridge --pattern commandcodebridge_0.2.12_linux_amd64.zip --pattern checksums.txt --dir /tmp/bridge-previous cd /tmp/bridge-previous sha256sum --check --ignore-missing checksums.txt cd "$GITHUB_WORKSPACE" - CPA_BINARY=/tmp/cpa-test/cli-proxy-api python3 tests/cpa_hot_reload.py --previous-archive /tmp/bridge-previous/commandcodebridge_0.2.11_linux_amd64.zip + CPA_BINARY=/tmp/cpa-test/cli-proxy-api python3 tests/cpa_hot_reload.py --previous-archive /tmp/bridge-previous/commandcodebridge_0.2.12_linux_amd64.zip - uses: actions/download-artifact@v4 with: name: cpamp-manager-test @@ -92,12 +92,18 @@ jobs: fail-fast: false matrix: include: + # Linux libraries are built in manylinux_2_28 so they load on glibc 2.28+ hosts, not only + # on the runner's glibc. Pinned by digest; bump deliberately. - runner: ubuntu-24.04 target: linux-amd64 extension: so + manylinux: quay.io/pypa/manylinux_2_28_x86_64:2026.11.06-1@sha256:ae830e2ecbf4568743f8b760920cd7483538375b6b242a9fb02b5b2ad0245f68 + cpa-archive: CLIProxyAPI_8.0.23_linux_amd64.tar.gz - runner: ubuntu-24.04-arm target: linux-arm64 extension: so + manylinux: quay.io/pypa/manylinux_2_28_aarch64:2026.11.06-1@sha256:6c4f9b6d9453bb47b21b4c1e4f04f3c662584f60f4ed8455f4561a336ff19414 + cpa-archive: CLIProxyAPI_8.0.23_linux_aarch64.tar.gz - runner: windows-2022 target: windows-amd64 extension: dll @@ -128,9 +134,28 @@ jobs: REPOSITORY: https://github.com/${{ github.repository }} # Only read by the darwin toolchain; v0.2.11 and earlier shipped with minos 14.0. MACOSX_DEPLOYMENT_TARGET: '14.0' + MANYLINUX_IMAGE: ${{ matrix.manylinux }} + # Supported Linux glibc minimum: the build fails above it and package.py records it in BUILD.json. + GLIBC_FLOOR: ${{ matrix.manylinux != '' && '2.28' || '' }} run: | mkdir -p dist - go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="-s -w -X commandcodebridge/internal/bridge.Repository=$REPOSITORY" -o dist/commandcodebridge.${{ matrix.extension }} ./cmd/commandcodebridge + ldflags="-s -w -X commandcodebridge/internal/bridge.Repository=$REPOSITORY" + if [ -n "$MANYLINUX_IMAGE" ]; then + # Reuse the runner's official (statically linked) Go toolchain and caches inside the container. + goroot="$(go env GOROOT)"; gomodcache="$(go env GOMODCACHE)"; gocache="$(go env GOCACHE)" + mkdir -p "$gomodcache" "$gocache" + docker run --rm -u "$(id -u):$(id -g)" -v "$GITHUB_WORKSPACE:/src" -w /src -v "$goroot:/goroot:ro" \ + -v "$gomodcache:/gomodcache" -v "$gocache:/gocache" \ + -e HOME=/tmp -e GOMODCACHE=/gomodcache -e GOCACHE=/gocache -e GOTOOLCHAIN=local -e CGO_ENABLED=1 \ + "$MANYLINUX_IMAGE" /goroot/bin/go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="$ldflags" -o dist/commandcodebridge.so ./cmd/commandcodebridge + required="$(objdump -T dist/commandcodebridge.so | grep -o 'GLIBC_[0-9.]*' | sed 's/^GLIBC_//' | sort -V | tail -1)" + echo "linux library requires glibc $required" + if [ "$(printf '%s\n%s\n' "$required" "$GLIBC_FLOOR" | sort -V | tail -1)" != "$GLIBC_FLOOR" ]; then + echo "::error::linux library requires glibc $required > $GLIBC_FLOOR"; exit 1 + fi + else + go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="$ldflags" -o dist/commandcodebridge.${{ matrix.extension }} ./cmd/commandcodebridge + fi if [ "${{ matrix.target }}" = darwin-arm64 ]; then minos="$(otool -l dist/commandcodebridge.dylib | awk '/LC_BUILD_VERSION/ {found=1} found && $1 == "minos" {print $2; exit}')" echo "darwin dylib minos: $minos" @@ -140,6 +165,29 @@ jobs: if [ "${{ matrix.target }}" = windows-amd64 ]; then helper="$helper.exe"; fi CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$helper" ./cmd/commandcodebridge-login python scripts/package.py dist/commandcodebridge.${{ matrix.extension }} --target ${{ matrix.target }} + - name: Load and run on glibc 2.28 + if: matrix.manylinux != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANYLINUX_IMAGE: ${{ matrix.manylinux }} + CPA_ARCHIVE: ${{ matrix.cpa-archive }} + run: | + task_dir="$RUNNER_TEMP/cpa-glibc228" + mkdir -p "$task_dir" + gh release download v8.0.23 --repo router-for-me/CLIProxyAPI --pattern "$CPA_ARCHIVE" --pattern checksums.txt --dir "$task_dir" + (cd "$task_dir" && sha256sum --check --ignore-missing checksums.txt && tar -xzf "$CPA_ARCHIVE") + # The pinned CPA release only needs glibc 2.3.2, so the whole host runs inside the old userland. + docker run --rm -u "$(id -u):$(id -g)" -v "$GITHUB_WORKSPACE:/src" -w /src -v "$task_dir:/cpa:ro" \ + -e HOME=/tmp -e CPA_BINARY=/cpa/cli-proxy-api -e BRIDGE_LIBRARY=/src/dist/commandcodebridge.so \ + "$MANYLINUX_IMAGE" bash -euc ' + ldd --version | head -1 + py=/opt/python/cp312-cp312/bin/python3 + $py -c "import ctypes, os; ctypes.CDLL(os.environ[\"BRIDGE_LIBRARY\"]); print(\"dlopen OK\")" + $py tests/cpa_e2e.py + $py tests/cpa_count_tokens.py + $py tests/cpa_thinking_suffix.py + ' - name: Windows native host integration if: matrix.target == 'windows-amd64' shell: pwsh @@ -358,7 +406,7 @@ jobs: pattern: commandcodebridge-* path: dist merge-multiple: true - - run: python scripts/checksums.py dist --include-cpamp + - run: python scripts/checksums.py dist --include-cpamp --max-glibc 2.28 - uses: actions/upload-artifact@v4 with: name: commandcodebridge-checksums @@ -387,7 +435,7 @@ jobs: env: RELEASE_TAG: ${{ github.ref_name }} run: | - python scripts/checksums.py dist --include-cpamp + python scripts/checksums.py dist --include-cpamp --max-glibc 2.28 python - <<'PY' import json, os, pathlib, re, zipfile version = re.search(r'const Version = "([^"]+)"', pathlib.Path('internal/bridge/types.go').read_text()).group(1) diff --git a/CHANGELOG.md b/CHANGELOG.md index c11d9b4..2c18b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.2.13] - 2026-11-07 + +### Added + +- 可选的上游停滞保护:`first_byte_timeout_seconds` 限制从请求开始到首段上游响应数据的时间(含等待响应头),`stream_idle_timeout_seconds` 限制此后两段数据之间的间隔。两者都在 `timeout_seconds` 之内生效,0 为关闭(默认),启用时取 5–1800 秒;可在控制台设置页或 YAML 配置,保存即时生效。触发时返回 504,日志错误类型为 `upstream_first_byte_timeout` 或 `upstream_stream_idle_timeout`,并关闭上游连接。真实上游下长推理模型的首字节与段间间隔尚未实测,因此默认不启用。 + +### Changed + +- 运行数据文件(`requests.json`、`statistics.json`、`usage.json` 及检查点)改为紧凑 JSON,结构不变;同一文件持续变更时,两次重写的间隔按上次写入大小折算为约 1 MiB/s(最长 10 秒)。小文件仍为 250ms,正常卸载立即写入全部待写文件。合成测量中,90 天 × 2 模型的统计文件从 9.8 MB 缩小到 7.5 MB,持续流量下写入从约 39 MB/s 降至约 1 MB/s;1,000 条请求记录从约 3.9 MB/s 降至约 1 MB/s。代价是强制结束进程时大文件可能丢失最多约 10 秒的变更。 +- Linux 动态库改在固定摘要的 manylinux_2_28 容器中构建,最低 glibc 从 2.34 降至 **2.28**(RHEL/AlmaLinux 8、Debian 10、Ubuntu 20.04 及更新版本)。CI 在 glibc 2.28 用户态中加载动态库,并运行 CPA v8.0.23 宿主联测;构建与发布校验会拒绝要求更高 glibc 的 Linux 包。`BUILD.json` 的 `glibc_minimum` 记录受支持的最低版本,`glibc_symbol_version_max` 记录符号版本的实际上限。 +- Antigravity 额度行为回归(请求层 Vitest、生产页面 Playwright 场景、Manager 快照与 SQLite 恢复、源码降版变异、真实 CPA 转发)纳入 `cpamp-web → test → build → release` 发布门槛(#14)。热升级联测的前一版本改为 v0.2.12。 + ## [0.2.12] - 2026-11-07 ### Fixed diff --git a/README.md b/README.md index d001080..d236164 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ - **原生额度窗口**:在 CPAMP 列表、卡片和详情查看 5h、周、月额度,查询窗口请求统计、预测和周期快照。 - **请求记录**:查看凭证健康度、tokens、缓存用量、预估花费、首字耗时、输出速率及模型统计;支持明暗主题和手机布局。 -当前版本 **v0.2.12**。插件调用官方 Provider API;Go 订阅的 `/alpha/generate` 路径和固定上游 provider 模式不在支持范围内。账户用量通过官方 `/alpha/...` 查询接口读取,模型权限以实际套餐为准。 +当前版本 **v0.2.13**。插件调用官方 Provider API;Go 订阅的 `/alpha/generate` 路径和固定上游 provider 模式不在支持范围内。账户用量通过官方 `/alpha/...` 查询接口读取,模型权限以实际套餐为准。 + +### v0.2.13 更新 + +Linux 包的 glibc 要求从 2.34 降至 **2.28**(RHEL/AlmaLinux 8、Debian 10、Ubuntu 20.04 起);运行数据改为紧凑 JSON,并限制大文件的持续重写频率,持续流量下统计文件写盘约降为原来的 1/37;新增可选的首字节超时与流空闲超时(默认关闭,在「设置 → 请求策略」启用)。 ### v0.2.12 更新 @@ -43,7 +47,7 @@ plugins: - https://raw.githubusercontent.com/Gaoshou101/CommandCodeBridge/main/registry.json ``` -保存并重载配置,在 CPAMP「插件商店」找到 **CommandCodeBridge**,选择 Latest 或 **v0.2.12** 安装。支持热升级的宿主会下载匹配平台的 ZIP、验证校验值并切换版本;安装后检查插件控制台显示的运行版本。 +保存并重载配置,在 CPAMP「插件商店」找到 **CommandCodeBridge**,选择 Latest 或 **v0.2.13** 安装。支持热升级的宿主会下载匹配平台的 ZIP、验证校验值并切换版本;安装后检查插件控制台显示的运行版本。 也可从 [Releases](https://github.com/Gaoshou101/CommandCodeBridge/releases/latest) 手动下载 ZIP 和 `checksums.txt`。按 **CPA 所在服务器**选择动态库: @@ -54,7 +58,7 @@ plugins: | Windows x64 | `_windows_amd64.zip` | `commandcodebridge.dll` | | macOS Apple Silicon | `_darwin_arm64.zip` | `commandcodebridge.dylib` | -Linux 发布包要求 glibc ≥2.34;Alpine/musl 需自行构建。手动安装时核验校验值,将动态库放入实际 `plugins.dir`,合并 [config.example.yaml](./config.example.yaml),按宿主的加载方式启用插件。完整步骤见[部署说明](./docs/DEPLOYMENT.md)。 +Linux 发布包要求 glibc ≥2.28(v0.2.12 及更早为 ≥2.34);Alpine/musl 需自行构建。手动安装时核验校验值,将动态库放入实际 `plugins.dir`,合并 [config.example.yaml](./config.example.yaml),按宿主的加载方式启用插件。完整步骤见[部署说明](./docs/DEPLOYMENT.md)。 ## 快速开始 @@ -69,7 +73,7 @@ Linux 发布包要求 glibc ≥2.34;Alpine/musl 需自行构建。手动安装 ## 安装或更新原生面板 -**升级插件后还要更新 CPAMP 页面**,才能使用新版凭证布局。v0.2.12 动态库已内嵌匹配页面。 +**升级插件后还要更新 CPAMP 页面**,才能使用新版凭证布局。v0.2.13 动态库已内嵌匹配页面。 CPA 与 Manager Server 在同一主机直接运行时: @@ -84,7 +88,7 @@ CPA 与 Manager Server 在同一主机直接运行时: | 首次启用外部页面,或更改 Manager Server 的页面路径 | 重启 Manager Server 一次 | | 迁移插件数据目录,或旧宿主不支持动态库热升级 | 安排相应服务重启 | -Manager Server 在另一台主机或独立容器中时,在其运行环境安装 `commandcodebridge_0.2.12_cpamp-web.zip` 内的 `management.html`,通过 `PANEL_PATH` 或 `panelPath` 托管。面板源码基线见 [compatibility.json](./internal/bridge/compatibility.json) 的 `cpamp_source_commit`;不同版本应在匹配源码上应用适配。 +Manager Server 在另一台主机或独立容器中时,在其运行环境安装 `commandcodebridge_0.2.13_cpamp-web.zip` 内的 `management.html`,通过 `PANEL_PATH` 或 `panelPath` 托管。面板源码基线见 [compatibility.json](./internal/bridge/compatibility.json) 的 `cpamp_source_commit`;不同版本应在匹配源码上应用适配。 **完整周期快照另需 Manager Server 接受 `commandcode` 平台。** 前端包附带适配脚本、服务器名单补丁和测试;按现有 Manager Server 的同一源码版本重建并部署。仅更新插件和页面不能替代这个服务器改动。[原生集成说明](./integrations/cpamp/README.md)包含构建、HTTP 生效核验和回滚步骤。 diff --git a/config.example.yaml b/config.example.yaml index d0ab568..07e19df 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,6 +14,10 @@ plugins: cpamp_panel_path: "" # Browser OAuth uses a local POST receiver and CPAMP result submission. timeout_seconds: 600 + # Optional stall guards inside timeout_seconds (0 = off, otherwise 5-1800): + # time to the first upstream response data, and the longest gap between chunks. + first_byte_timeout_seconds: 0 + stream_idle_timeout_seconds: 0 usage_refresh_seconds: 60 log_retention: 1000 max_response_bytes: 16777216 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9604681..e4c80ef 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,13 @@ # CPAMP 部署、升级与回滚 -本文说明 CommandCodeBridge v0.2.12 的安装、页面更新与回滚。路径应以实际运行环境为准;历史排查中的路径仅用于说明当时的问题。 +本文说明 CommandCodeBridge v0.2.13 的安装、页面更新与回滚。路径应以实际运行环境为准;历史排查中的路径仅用于说明当时的问题。 + +## v0.2.13:glibc 2.28、写盘与超时保护 + +1. 更新插件至 v0.2.13,确认实际运行版本;配置无需修改。Linux 主机只需 glibc ≥2.28,此前因 glibc 过低无法加载的主机(如 RHEL/AlmaLinux 8、Debian 10)可以直接安装。 +2. 运行数据文件改为紧凑 JSON,结构不变,旧版本可直接读取;降级不需要迁移。 +3. 首字节超时与流空闲超时默认关闭。需要时在「设置 → 请求策略」启用,取值宜宽松,详见下方常见问题。 +4. 页面功能未变,但页面带插件版本标识,仍按下节步骤更新 CPAMP 页面并强刷浏览器。 ## v0.2.12:count_tokens 与思考后缀兼容 @@ -35,7 +42,7 @@ v0.2.8 的四平台插件内嵌新版 CPAMP 页面,支持官方套餐月限额 | 操作 | 生效方式 | | --- | --- | -| 控制台修改超时、日志保留、用量刷新、响应大小、ZDR、模型或面板探测路径 | 保存后即时生效,不重启 CPA | +| 控制台修改超时(含首字节与流空闲超时)、日志保留、用量刷新、响应大小、ZDR、模型或面板探测路径 | 保存后即时生效,不重启 CPA | | CPA 配置页修改插件的 CPAMP 路径 | 宿主重载调用 `plugin.reconfigure`;显式非空路径覆盖旧保存值 | | 更新已启用的外部原生面板 | 点击「更新原生面板」,原路径原子替换;强刷浏览器,不重启 Manager Server | | 通过支持热升级的 CPA 插件商店升级动态库 | 宿主选择新的版本化文件并切换插件;确认运行版本,无需重启 CPA | @@ -138,12 +145,12 @@ GitHub 仓库首页用于浏览源码;在“插件源”输入框应填写上 | 宿主 | 安装包后缀 | 动态库 | 要求 | | --- | --- | --- | --- | -| Linux x64 | `_linux_amd64.zip` | `commandcodebridge.so` | glibc ≥2.34 | -| Linux ARM64 | `_linux_arm64.zip` | `commandcodebridge.so` | glibc ≥2.34 | +| Linux x64 | `_linux_amd64.zip` | `commandcodebridge.so` | glibc ≥2.28 | +| Linux ARM64 | `_linux_arm64.zip` | `commandcodebridge.so` | glibc ≥2.28 | | Windows x64 | `_windows_amd64.zip` | `commandcodebridge.dll` | 64 位 CPA | | macOS Apple Silicon | `_darwin_arm64.zip` | `commandcodebridge.dylib` | ARM64 CPA | -以上均已在对应平台使用 CPA v8.0.23 运行模拟集成与异常恢复测试。Linux Alpine/musl 需另外构建。 +以上均已在对应平台使用 CPA v8.0.23 运行模拟集成与异常恢复测试;Linux x64/ARM64 另在 glibc 2.28 用户态中加载并运行宿主联测。Linux Alpine/musl 需另外构建。 Linux 下载后,在文件所在目录核验: @@ -191,6 +198,7 @@ Windows 使用 `Get-FileHash -Algorithm SHA256`,macOS 使用 `shasum -a 256` | 无法加载库 | CPA 是否含插件支持;CPU/系统/运行库是否匹配;文件是否可读 | | 模型可见但返回 403 | 目录不保证套餐权限,检查 `MODEL_NOT_IN_PLAN` | | 接口直连失败或超时 | 确认 CPA 全局代理;插件不继承 Windows 系统代理 | +| 上游长时间无响应才报 504 | 可在设置页启用「首字节超时」和「流空闲超时」(`first_byte_timeout_seconds`、`stream_idle_timeout_seconds`,0 为关闭,启用时 5–1800 秒)。日志的错误类型分别为 `upstream_first_byte_timeout`、`upstream_stream_idle_timeout`;默认关闭,长推理模型在真实上游中的首字节与段间间隔尚未实测,取值宜宽松 | | 用量缺失 | 查看 `unavailable` / `status`;查询失败保留旧值 | | 日志写入失败 | 检查 data_dir 的磁盘空间、权限及 `storage_error` | | 重启后设置未按 YAML 改变 | 已存 settings.json 优先;采用明确的迁移方案 | @@ -216,7 +224,7 @@ Windows 使用 `Get-FileHash -Algorithm SHA256`,macOS 使用 `shasum -a 256` ### 文件与写入 -`settings.json`、凭证仍走原配置/宿主流程。`requests.json`、`statistics.json`、`usage.json` 保留原数组/字典结构,分别标脏、250ms 批量刷盘;只有变更的文件写入。`runtime-format.json` 记录格式版本及最近 20 条恢复警告。未知格式、设置损坏或不可读均明确报错,保留原文件。 +`settings.json`、凭证仍走原配置/宿主流程。`requests.json`、`statistics.json`、`usage.json` 保留原数组/字典结构(紧凑 JSON),分别标脏、250ms 批量刷盘;只有变更的文件写入。持续变更时,同一文件两次重写的间隔按上次写入大小折算为约 1 MiB/s,最长 10 秒:小文件仍为 250ms,数 MB 的统计文件约数秒写一次;正常卸载会立即写入全部待写文件。强制结束进程或断电时,大文件可能丢失这一间隔内的变更。`runtime-format.json` 记录格式版本及最近 20 条恢复警告。未知格式、设置损坏或不可读均明确报错,保留原文件。 每个运行文件的 `.last-good` 检查点最多每分钟随变更刷新一次。恢复可能丢失检查点之后的数据,独立文件不构成跨文件事务,也不承诺断电 fsync 耐久性。状态/设置页显示最近成功刷盘、待写文件与起始时间,恢复警告重启后仍保留。无有效检查点时,仅重建损坏的观察文件,并提示该历史范围未知;配置与其他有效历史保留。 diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index cbb22b9..b8de40d 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,6 +1,27 @@ # CommandCodeBridge 发布说明 -## v0.2.12 +## v0.2.13 + +发布日期:2026-11-07。CPA 原生插件,C ABI 1 / RPC schema 6;调用官方 Provider API。 + +### 本次更新 + +- **Linux 要求降至 glibc 2.28**:此前的 `.so` 在新系统上构建,引入了 glibc 2.32/2.34 的符号,在 RHEL/AlmaLinux 8、Debian 10 等主机上无法加载;CPA 本身只需 glibc 2.3.2。Linux 包现在在 manylinux_2_28 容器中构建,CI 在 glibc 2.28 用户态中加载插件并运行宿主联测,发布校验会拒绝超出 2.28 的包。 +- **减少运行数据写盘**:请求记录、统计和额度文件改为紧凑 JSON,结构不变;同一文件持续变更时,重写间隔按文件大小折算为约 1 MiB/s(最长 10 秒),小文件仍为 250ms,正常卸载立即写入。合成测量中,90 天统计文件的持续写入从约 39 MB/s 降至约 1 MB/s。代价是强制结束进程时,大文件可能丢失最多约 10 秒的变更。 +- **可选的首字节超时与流空闲超时**:在「设置 → 请求策略」或 YAML 中配置 `first_byte_timeout_seconds`、`stream_idle_timeout_seconds`(0 为关闭,启用时 5–1800 秒)。触发时返回 504 并关闭上游连接,日志错误类型分别为 `upstream_first_byte_timeout`、`upstream_stream_idle_timeout`。默认关闭:真实上游下长推理模型的首字节与段间间隔尚未实测。 +- **发布门槛**:Antigravity 额度行为回归测试纳入构建链路(#14)。 + +### 升级步骤 + +1. 更新插件至 **v0.2.13**,确认实际运行版本。无需修改配置。 +2. 本次未改变 CPAMP 页面功能;页面带插件版本标识,仍建议在「设置 → CPAMP 原生面板」更新页面,异机/独立容器使用 `commandcodebridge_0.2.13_cpamp-web.zip`。 +3. 如需启用超时保护,建议取值宽松(例如首字节 120 秒以上、流空闲 120 秒以上),观察日志中的错误类型后再调整。 + +### 下载与校验 + +四平台原生 ZIP、独立 CPAMP 前端 ZIP、五个 `.zip.sha256` 和统一 `checksums.txt`,共 **11 个资产**。Linux 包的 `BUILD.json` 中 `glibc_minimum` 为 2.28。CPA 发布门槛仍为 v8.0.23;v8.0.59 在 Windows 本地完成全套宿主联测,并由 CI 候选任务持续复核。本次只使用本地模拟上游,未进行真实模型生成,未访问或修改任何部署服务器。 + +## 已发布 v0.2.12 发布日期:2026-11-07。CPA 原生插件,C ABI 1 / RPC schema 6;调用官方 Provider API。 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 9ee4d90..e3907c9 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,5 +1,40 @@ # 开发验证记录 +## v0.2.13 glibc 下限、写盘节流与卡顿超时(2026-11-07,未发布源码) + +本轮只使用本地模拟上游,未进行真实模型生成,未访问或修改任何部署服务器。CPA 发布门槛仍为 v8.0.23;`compatibility.json` 未改动。包含 PR #14(Antigravity 额度回归纳入发布门槛)与 PR #16。 + +### glibc 下限 + +- v0.2.12 的 Linux 动态库在 runner 自带的 glibc 上构建:amd64 与 arm64 发布包 `objdump -T` 的最高符号版本均为 **GLIBC_2.34**(另含 2.32),而固定的 CPA v8.0.23 Linux 版本身只需 glibc 2.3.2,因此插件把 CPA 原本支持的 glibc 2.28–2.33 主机(如 RHEL/AlmaLinux 8、Debian 11)挡在外面。 +- Linux 改在按 digest 固定的 `manylinux_2_28` 镜像(`2026.11.06-1`)中构建。新库 amd64 最高符号版本为 GLIBC_2.3.2,arm64 为 GLIBC_2.17;构建步骤在超过 2.28 时失败,`BUILD.json` 记录 `glibc_minimum: "2.28"` 与实际 `glibc_symbol_version_max`,`checksums.py --max-glibc 2.28` 在两处发布校验中拒绝超标的包(`test_cpamp_panel_validation.py` 新增拒绝用例)。 +- 本机 manylinux_2_28 容器(glibc 2.28)中运行 Linux CPA v8.0.23:v0.2.12 动态库 `dlopen` 失败(缺 GLIBC_2.34),新库加载成功,并通过 `cpa_e2e.py`、`cpa_count_tokens.py`、`cpa_thinking_suffix.py`。CI 新增 “Load and run on glibc 2.28” 步骤,amd64 与 arm64 各在 glibc 2.28 用户态内完整运行上述三项;rebase 前 PR #16 的 CI(run 42362813526、42371142936)全部通过。 + +### 写盘节流 + +本机合成测量(90 天 × 2 模型的用量桶,共 21,768 个): + +- 用量数据缩进 JSON 为 9,823,672 B,改为紧凑 JSON 后为 7,450,959 B,单次编码约 24 ms。 +- 持续请求时,旧逻辑每次变更都重写整个文件,折合约 39.29 MB/s;新逻辑按文件大小把重写间隔限制在 250 ms~10 s(按 1 MiB/s 折算),该规模下间隔约 7.1 s、约 1.05 MB/s,约降为 1/37.5。 +- 1,000 条最近请求记录从 988,002 B 降为 788,001 B,持续写入从约 3.92 MB/s 降为约 1.05 MB/s。 +- 插件关闭时仍强制立即写出全部待写文件(`flushRuntimeFiles(true)`),写入仍为原子替换;热升级联测确认记账数据在升级后保留。`persistence_rate_test.go` 覆盖间隔计算、大文件节流与紧凑 JSON。 + +### 首字节与流空闲超时 + +- 新增 `first_byte_timeout_seconds` 与 `stream_idle_timeout_seconds`,默认 0(关闭,保持旧行为),启用时取值 5~1800 秒;超时分别以 504 `upstream_first_byte_timeout`/`upstream_stream_idle_timeout` 结束并记入失败计数。 +- `upstream_timeouts_test.go` 5 项通过。`cpa_reliability.py` 新增“上游先回首字节后停止输出”的故障用例:使用 v0.2.12 动态库时因新设置被拒绝(400)而失败,使用新库通过。 +- 浏览器实测设置页可保存两个字段并回读 90/120;`config.example.yaml` 已补充说明。 + +### 自动化与宿主联测 + +- Windows Go 1.26 `go vet ./internal/... ./cmd/...`、`gofmt -l` 与 `go test ./internal/... ./cmd/... -count=1` 通过;Node **31 项**通过;`test_cpamp_panel_validation.py` 在 WSL 下 6 项通过。 +- Windows amd64 动态库(版本 0.2.13,WSL 交叉编译,晚于最后一次源码修改)在实际 Windows CPA **v8.0.23** 与 **v8.0.59** 上各运行 9 项,共 **18/18 通过**:`cpa_e2e.py`、`cpa_native_management.py`、`cpa_reliability.py`、`cpa_diagnostics.py`、`cpa_round2.py`、`cpa_count_tokens.py`、`cpa_thinking_suffix.py`、`cpa_hot_reload.py`,以及以 v0.2.12 正式 Windows 包为前一版本的 `cpa_hot_reload.py --previous-archive`(同一 CPA 进程内 0.2.12 → 0.2.13)。CI 热升级联测的前一版本同步改为 v0.2.12。 + +### 待验证 + +- 真实上游下两项超时的合适取值(默认关闭)、思考后缀等级效果与 count_tokens 客户端行为;插件热升级是否清除宿主已记录的冷却。 +- 真实 RHEL/AlmaLinux 8 等 glibc 2.28 主机上的部署(目前仅容器与 CI 验证)。 + ## v0.2.12 发版记录(2026-11-07) 用户已确认合并 PR #15 并发版。下方“未发布源码”一节保留为当轮验证时的状态;真实上游验收仍未完成,本次未部署或访问任何服务器。 diff --git a/integrations/cpamp/README.md b/integrations/cpamp/README.md index 5e9ae09..cc2e133 100644 --- a/integrations/cpamp/README.md +++ b/integrations/cpamp/README.md @@ -63,8 +63,8 @@ python tests/cpa_antigravity_forwarding.py --cpa /path/to/cli-proxy-api ## 安装 1. 备份原插件动态库、CPA auth-dir、插件 data_dir 以及 CPAMP 当前管理页面。 -2. 通过支持热升级的 CPA 插件商店更新 CommandCodeBridge;确认运行版本为 `0.2.12`。从 [v0.2.12 Release](https://github.com/Gaoshou101/CommandCodeBridge/releases/tag/v0.2.12) 选择匹配平台 ZIP,并核验 `checksums.txt`。 -3. **同时升级管理页面**。同机使用上节一键入口;异机面板匹配包内 `BUILD.json` 的 CPAMP 基线时,下载 Release 的 `commandcodebridge_0.2.12_cpamp-web.zip`,使用包内 `management.html`,按下节安装。需要自行构建时,在对应 CPAMP 源码目录应用补丁: +2. 通过支持热升级的 CPA 插件商店更新 CommandCodeBridge;确认运行版本为 `0.2.13`。从 [v0.2.13 Release](https://github.com/Gaoshou101/CommandCodeBridge/releases/tag/v0.2.13) 选择匹配平台 ZIP,并核验 `checksums.txt`。 +3. **同时升级管理页面**。同机使用上节一键入口;异机面板匹配包内 `BUILD.json` 的 CPAMP 基线时,下载 Release 的 `commandcodebridge_0.2.13_cpamp-web.zip`,使用包内 `management.html`,按下节安装。需要自行构建时,在对应 CPAMP 源码目录应用补丁: ```bash python /path/to/CommandCodeBridge/integrations/cpamp/apply.py /path/to/CPA-Manager-Plus --check diff --git a/internal/bridge/executor.go b/internal/bridge/executor.go index 7466473..0c337dc 100644 --- a/internal/bridge/executor.go +++ b/internal/bridge/executor.go @@ -15,6 +15,9 @@ type upstreamStream struct { Headers http.Header `json:"headers"` StreamID string `json:"stream_id"` deadline time.Time + // Optional progress guards; zero values disable them. + firstByteDeadline time.Time + idleTimeout time.Duration } type readChunk struct { Payload []byte `json:"payload"` @@ -23,6 +26,12 @@ type readChunk struct { } func (s *Service) openUpstream(payload any, deadline time.Time) (upstreamStream, error) { + return s.openUpstreamUntil(payload, deadline, fail(504, "upstream request timed out")) +} + +// openUpstreamUntil waits for response headers until deadline and reports +// timeoutErr when it passes. +func (s *Service) openUpstreamUntil(payload any, deadline time.Time, timeoutErr error) (upstreamStream, error) { if deadline.IsZero() { deadline = time.Now().Add(time.Duration(s.config().TimeoutSeconds) * time.Second) } @@ -74,7 +83,7 @@ func (s *Service) openUpstream(payload any, deadline time.Time) (upstreamStream, } return r.up, nil case <-timer.C: - return upstreamStream{}, fail(504, "upstream request timed out") + return upstreamStream{}, timeoutErr case <-s.stopCh: return upstreamStream{}, fail(503, "plugin is shutting down") } @@ -94,24 +103,65 @@ func (s *Service) read(up upstreamStream, fn func([]byte) error) error { if deadline.IsZero() { deadline = time.Now().Add(time.Duration(s.config().TimeoutSeconds) * time.Second) } + // A watchdog closes the host stream when the overall deadline, the first + // byte deadline or the idle limit passes; the blocked read then returns. var mu sync.Mutex - timedOut := false - done := make(chan struct{}) - timer := time.AfterFunc(time.Until(deadline), func() { defer close(done); mu.Lock(); timedOut = true; mu.Unlock(); s.closeUpstream(up.StreamID) }) - defer func() { - if !timer.Stop() { - <-done + var expired error + progress := make(chan struct{}, 1) + finished := make(chan struct{}) + watching := make(chan struct{}) + go func() { + defer close(watching) + expire := func(err error) { + mu.Lock() + expired = err + mu.Unlock() + s.closeUpstream(up.StreamID) } + overall := time.NewTimer(time.Until(deadline)) + defer overall.Stop() + guard := time.NewTimer(time.Hour) + guard.Stop() + defer guard.Stop() + var guardC <-chan time.Time + var guardErr error + if !up.firstByteDeadline.IsZero() && up.firstByteDeadline.Before(deadline) { + guard.Reset(time.Until(up.firstByteDeadline)) + guardC, guardErr = guard.C, errFirstByteTimeout() + } + for { + select { + case <-overall.C: + expire(fail(504, "upstream request timed out")) + return + case <-guardC: + expire(guardErr) + return + case <-progress: + guard.Stop() + guardC = nil + if up.idleTimeout > 0 { + guard.Reset(up.idleTimeout) + guardC, guardErr = guard.C, errStreamIdleTimeout() + } + case <-finished: + return + } + } + }() + defer func() { + close(finished) + <-watching }() total := 0 for { var chunk readChunk e := s.call("host.http.stream_read", map[string]string{"stream_id": up.StreamID}, &chunk) mu.Lock() - timeout := timedOut + timeout := expired mu.Unlock() - if timeout { - return fail(504, "upstream request timed out") + if timeout != nil { + return timeout } if e != nil || chunk.Error != "" { return fail(502, "upstream stream interrupted") @@ -121,6 +171,10 @@ func (s *Service) read(up upstreamStream, fn func([]byte) error) error { return fail(502, "upstream response exceeds configured limit") } if len(chunk.Payload) > 0 { + select { + case progress <- struct{}{}: + default: + } if e := fn(chunk.Payload); e != nil { return e } @@ -366,11 +420,21 @@ func (s *Service) execute(r ExecutorRequest, stream bool) (any, error) { } body["stream"] = true entry.UpstreamAttempted = true - up, e := s.openUpstream(map[string]any{"host_callback_id": r.HostCallbackID, "method": "POST", "url": executionConfig.BaseURL + path, "headers": upstreamHeaders(c, executionConfig.ZDR), "body": jsonBytes(body)}, start.Add(time.Duration(executionConfig.TimeoutSeconds)*time.Second)) + overall := start.Add(time.Duration(executionConfig.TimeoutSeconds) * time.Second) + headerDeadline, headerTimeout := overall, fail(504, "upstream request timed out") + var firstByteDeadline time.Time + if executionConfig.FirstByteTimeoutSeconds > 0 { + if at := start.Add(time.Duration(executionConfig.FirstByteTimeoutSeconds) * time.Second); at.Before(overall) { + firstByteDeadline, headerDeadline, headerTimeout = at, at, errFirstByteTimeout() + } + } + up, e := s.openUpstreamUntil(map[string]any{"host_callback_id": r.HostCallbackID, "method": "POST", "url": executionConfig.BaseURL + path, "headers": upstreamHeaders(c, executionConfig.ZDR), "body": jsonBytes(body)}, headerDeadline, headerTimeout) if e != nil { release(e) return early(e) } + up.deadline, up.firstByteDeadline = overall, firstByteDeadline + up.idleTimeout = time.Duration(executionConfig.IdleTimeoutSeconds) * time.Second if up.StatusCode < 200 || up.StatusCode >= 300 { _, e = s.readJSON(up) e = redactError(e, c.APIKey) diff --git a/internal/bridge/management.go b/internal/bridge/management.go index a543863..c9e4bed 100644 --- a/internal/bridge/management.go +++ b/internal/bridge/management.go @@ -186,7 +186,7 @@ func (s *Service) management(raw json.RawMessage) (any, error) { e = fail(400, "invalid settings JSON") } else { for k := range in { - if k != "timeout_seconds" && k != "log_retention" && k != "max_response_bytes" && k != "usage_refresh_seconds" && k != "statistics_retention_days" && k != "zdr" && k != "cpamp_install_dir" && k != "cpamp_config_path" && k != "cpamp_panel_path" { + if k != "timeout_seconds" && k != "first_byte_timeout_seconds" && k != "stream_idle_timeout_seconds" && k != "log_retention" && k != "max_response_bytes" && k != "usage_refresh_seconds" && k != "statistics_retention_days" && k != "zdr" && k != "cpamp_install_dir" && k != "cpamp_config_path" && k != "cpamp_panel_path" { e = fail(400, "unsupported editable setting") break } diff --git a/internal/bridge/persistence.go b/internal/bridge/persistence.go index 66ce8ed..2f5fbf6 100644 --- a/internal/bridge/persistence.go +++ b/internal/bridge/persistence.go @@ -8,6 +8,33 @@ import ( const statisticsFlushInterval = 250 * time.Millisecond +// Under sustained traffic each runtime file is rewritten in full on every +// tick, so a large statistics or request log would cost several megabytes per +// second of disk writes. Consecutive rewrites of one file are spaced so that +// its sustained write rate stays near runtimeWriteBytesPerSecond. Small files +// keep the 250ms window, the first change after a quiet period is written on +// the next tick, and shutdown always flushes every pending file. +const ( + runtimeWriteBytesPerSecond = 1 << 20 + runtimeWriteMaxInterval = 10 * time.Second +) + +// runtimeWriteInterval is the minimum spacing between rewrites of a runtime +// file whose last successful snapshot was size bytes. +func runtimeWriteInterval(size int) time.Duration { + interval := time.Duration(int64(size) * int64(time.Second) / runtimeWriteBytesPerSecond) + switch { + case interval < statisticsFlushInterval: + return statisticsFlushInterval + case interval > runtimeWriteMaxInterval: + return runtimeWriteMaxInterval + default: + return interval + } +} + +var runtimeFileNames = map[runtimeFiles]string{dirtyLogs: "requests.json", dirtyStatistics: "statistics.json", dirtyUsage: "usage.json"} + type runtimeFiles uint8 const ( @@ -48,7 +75,7 @@ func (s *Service) startPersistenceLocked() { for { select { case <-ticker.C: - s.flushStatistics() + s.flushRuntimeFiles(false) case <-stop: s.flushStatistics() return @@ -57,10 +84,17 @@ func (s *Service) startPersistenceLocked() { }() } +// flushStatistics writes every pending runtime file immediately. It is used at +// shutdown and by tests; the background worker uses the rate-limited path. +func (s *Service) flushStatistics() { + s.flushRuntimeFiles(true) +} + // persistMu serializes disk snapshots with reset so an older in-flight snapshot // cannot overwrite a freshly cleared counter. Serialization and IO run without // the service lock; new requests and management reads can proceed concurrently. -func (s *Service) flushStatistics() { +// Unless force is set, files rewritten too recently stay pending for a later tick. +func (s *Service) flushRuntimeFiles(force bool) { s.persistMu.Lock() defer s.persistMu.Unlock() s.mu.Lock() @@ -68,8 +102,16 @@ func (s *Service) flushStatistics() { s.mu.Unlock() return } - s.compactStatisticsLocked(time.Now().UTC()) + now := time.Now() + s.compactStatisticsLocked(now.UTC()) dirty := s.persistDirty + if !force { + for flag, name := range runtimeFileNames { + if dirty&flag != 0 && now.Sub(s.persistLastWrite[name]) < runtimeWriteInterval(s.persistLastSize[name]) { + dirty &^= flag + } + } + } if dirty == 0 { s.mu.Unlock() return @@ -95,12 +137,12 @@ func (s *Service) flushStatistics() { values["usage.json"] = usage } dir := s.cfg.DataDir - s.persistDirty = 0 + s.persistDirty &^= dirty s.persistWriting = true s.mu.Unlock() failed := runtimeFiles(0) for name, value := range values { - err := s.writeRuntimeSnapshot(dir, name, value, false) + size, err := s.writeRuntimeSnapshot(dir, name, value, false) s.mu.Lock() if s.persistErrors == nil { s.persistErrors = map[string]bool{} @@ -108,12 +150,18 @@ func (s *Service) flushStatistics() { if s.persistWrites == nil { s.persistWrites = map[string]uint64{} } + if s.persistLastWrite == nil { + s.persistLastWrite = map[string]time.Time{} + s.persistLastSize = map[string]int{} + } if err != nil { s.persistErrors[name] = true failed |= map[string]runtimeFiles{"requests.json": dirtyLogs, "statistics.json": dirtyStatistics, "usage.json": dirtyUsage}[name] } else { delete(s.persistErrors, name) s.persistWrites[name]++ + s.persistLastWrite[name] = time.Now() + s.persistLastSize[name] = size } s.mu.Unlock() } diff --git a/internal/bridge/persistence_rate_test.go b/internal/bridge/persistence_rate_test.go new file mode 100644 index 0000000..445c3d5 --- /dev/null +++ b/internal/bridge/persistence_rate_test.go @@ -0,0 +1,81 @@ +package bridge + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRuntimeWriteInterval(t *testing.T) { + for _, item := range []struct { + size int + want time.Duration + }{ + {0, statisticsFlushInterval}, + {64 << 10, statisticsFlushInterval}, + {512 << 10, 500 * time.Millisecond}, + {6 << 20, 6 * time.Second}, + {100 << 20, runtimeWriteMaxInterval}, + } { + if got := runtimeWriteInterval(item.size); got != item.want { + t.Fatalf("runtimeWriteInterval(%d) = %v, want %v", item.size, got, item.want) + } + } +} + +func TestBackgroundFlushPacesLargeRuntimeFiles(t *testing.T) { + s, _, _ := setup(t, func(string, map[string]any) mockReply { return streamReply(chatStream("ok")) }) + // A first write initializes the pacing state for every runtime file. + s.record(LogEntry{ID: id(), Model: "model", Status: 200}) + s.flushStatistics() + // Pretend statistics.json was just written at 6MiB; requests.json stays small. + s.mu.Lock() + s.persistLastWrite["statistics.json"] = time.Now() + s.persistLastSize["statistics.json"] = 6 << 20 + s.persistLastWrite["requests.json"] = time.Now().Add(-time.Second) + before := s.persistWrites["statistics.json"] + s.mu.Unlock() + s.record(LogEntry{ID: id(), Model: "model", Status: 200}) + s.flushRuntimeFiles(false) + s.mu.RLock() + pending, written, logs := s.persistDirty, s.persistWrites["statistics.json"], s.persistWrites["requests.json"] + s.mu.RUnlock() + if pending&dirtyStatistics == 0 || pending&dirtyLogs != 0 { + t.Fatalf("large statistics should stay pending and logs should be written: dirty=%b", pending) + } + if written != before || logs == 0 { + t.Fatalf("unexpected writes: statistics %d -> %d, requests %d", before, written, logs) + } + // Shutdown and explicit flushes ignore pacing. + s.flushStatistics() + s.mu.RLock() + pending, written = s.persistDirty, s.persistWrites["statistics.json"] + s.mu.RUnlock() + if pending != 0 || written != before+1 { + t.Fatalf("forced flush left files pending: dirty=%b writes=%d", pending, written) + } +} + +func TestRuntimeSnapshotsAreCompactJSON(t *testing.T) { + s, _, _ := setup(t, func(string, map[string]any) mockReply { return streamReply(chatStream("ok")) }) + s.record(LogEntry{ID: "compact", Model: "model", Status: 200}) + s.flushStatistics() + for _, name := range []string{"requests.json", "statistics.json"} { + data, err := os.ReadFile(filepath.Join(s.cfg.DataDir, name)) + if err != nil || !json.Valid(data) { + t.Fatalf("invalid %s: %v", name, err) + } + if bytes.Contains(data, []byte("\n")) { + t.Fatalf("%s is not compact", name) + } + s.mu.RLock() + size := s.persistLastSize[name] + s.mu.RUnlock() + if size != len(data) { + t.Fatalf("%s recorded size %d, file has %d bytes", name, size, len(data)) + } + } +} diff --git a/internal/bridge/runtime_recovery.go b/internal/bridge/runtime_recovery.go index fd741cf..77006e0 100644 --- a/internal/bridge/runtime_recovery.go +++ b/internal/bridge/runtime_recovery.go @@ -151,18 +151,24 @@ func decodeRuntime(raw []byte, target any) bool { // Checkpoints are refreshed at most once a minute rather than doubling every // request-log write. Forced checkpoints are used when explicitly resetting. -func (s *Service) writeRuntimeSnapshot(dir, name string, value any, force bool) error { - if err := atomicJSON(filepath.Join(dir, name), value); err != nil { - return err +// Runtime snapshots are compact JSON with the same structure as before; the +// encoded size is returned so the writer can pace rewrites of large files. +func (s *Service) writeRuntimeSnapshot(dir, name string, value any, force bool) (int, error) { + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + if err := atomicWrite(filepath.Join(dir, name), data); err != nil { + return 0, err } if s.checkpointAt == nil { s.checkpointAt = map[string]time.Time{} } if force || time.Since(s.checkpointAt[name]) >= time.Minute { - if err := atomicJSON(filepath.Join(dir, name+".last-good"), value); err != nil { - return err + if err := atomicWrite(filepath.Join(dir, name+".last-good"), data); err != nil { + return 0, err } s.checkpointAt[name] = time.Now() } - return nil + return len(data), nil } diff --git a/internal/bridge/service.go b/internal/bridge/service.go index 44c0a92..1f11d45 100644 --- a/internal/bridge/service.go +++ b/internal/bridge/service.go @@ -41,6 +41,8 @@ type Service struct { checkpointAt map[string]time.Time persistErrors map[string]bool persistWrites map[string]uint64 + persistLastWrite map[string]time.Time + persistLastSize map[string]int storageWarnings []runtimeRecovery nextCompaction time.Time persistStop, persistDone chan struct{} @@ -80,15 +82,20 @@ func id() string { return hex.EncodeToString(b) } func atomicJSON(path string, v any) error { - if e := os.MkdirAll(filepath.Dir(path), 0700); e != nil { - return e - } b, e := json.MarshalIndent(v, "", " ") if e != nil { return e } + return atomicWrite(path, b) +} + +// atomicWrite replaces path through a private temporary file and rename. +func atomicWrite(path string, b []byte) error { + if e := os.MkdirAll(filepath.Dir(path), 0700); e != nil { + return e + } tmp := path + ".tmp-" + id() - if e = os.WriteFile(tmp, b, 0600); e != nil { + if e := os.WriteFile(tmp, b, 0600); e != nil { return e } defer os.Remove(tmp) diff --git a/internal/bridge/types.go b/internal/bridge/types.go index fd81015..c310672 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -12,7 +12,7 @@ import ( "time" ) -const Version = "0.2.12" +const Version = "0.2.13" const PluginID = "commandcodebridge" const Provider = "commandcode" @@ -35,6 +35,16 @@ func fail(status int, message string) error { return &APIError{Status: status, Kind: "invalid_request", Message: message} } +// Upstream progress guards are reported separately from the overall request +// timeout so logs and clients can tell a stalled upstream from a slow one. +func errFirstByteTimeout() error { + return &APIError{Status: 504, Kind: "upstream_first_byte_timeout", Message: "upstream sent no response data within first_byte_timeout_seconds"} +} + +func errStreamIdleTimeout() error { + return &APIError{Status: 504, Kind: "upstream_stream_idle_timeout", Message: "upstream stream was idle longer than stream_idle_timeout_seconds"} +} + // CommandCode has no exact token counting endpoint, and the plugin never // fabricates a count. Report an endpoint-level gap rather than HTTP 501: CPA // treats a count_tokens 404 as availability-neutral on every supported host, @@ -102,6 +112,11 @@ type Model struct { ThinkingMode string `json:"thinking_mode,omitempty" yaml:"thinking_mode,omitempty"` Reasoning ReasoningMapping `json:"reasoning_mapping" yaml:"reasoning_mapping"` } + +// Config is the plugin configuration. FirstByteTimeoutSeconds limits the time +// from request start to the first upstream response data and IdleTimeoutSeconds +// (stream_idle_timeout_seconds) the gap between later chunks of an upstream +// stream; both run inside TimeoutSeconds and zero disables them. type Config struct { DataDir string `json:"data_dir" yaml:"data_dir"` BaseURL string `json:"base_url" yaml:"base_url"` @@ -111,6 +126,8 @@ type Config struct { CPAMPPanelPath string `json:"cpamp_panel_path" yaml:"cpamp_panel_path"` Models []Model `json:"models" yaml:"models"` TimeoutSeconds int `json:"timeout_seconds" yaml:"timeout_seconds"` + FirstByteTimeoutSeconds int `json:"first_byte_timeout_seconds" yaml:"first_byte_timeout_seconds"` + IdleTimeoutSeconds int `json:"stream_idle_timeout_seconds" yaml:"stream_idle_timeout_seconds"` LogRetention int `json:"log_retention" yaml:"log_retention"` MaxResponseBytes int `json:"max_response_bytes" yaml:"max_response_bytes"` UsageRefreshSeconds int `json:"usage_refresh_seconds" yaml:"usage_refresh_seconds"` @@ -145,6 +162,11 @@ func (c *Config) validate() error { } c.PublicBaseURL = strings.TrimRight(c.PublicBaseURL, "/") } + for _, guard := range []int{c.FirstByteTimeoutSeconds, c.IdleTimeoutSeconds} { + if guard != 0 && (guard < 5 || guard > 1800) { + return fail(400, "first_byte_timeout_seconds and stream_idle_timeout_seconds must be 0 or 5 to 1800") + } + } if c.DataDir == "" || c.TimeoutSeconds < 10 || c.TimeoutSeconds > 1800 || c.LogRetention < 10 || c.LogRetention > 10000 || c.MaxResponseBytes < 65536 || c.MaxResponseBytes > 64<<20 || c.UsageRefreshSeconds < 30 || c.UsageRefreshSeconds > 3600 { return fail(400, "invalid data directory, timeout, retention, response limit or refresh interval") } diff --git a/internal/bridge/ui/console.js b/internal/bridge/ui/console.js index f570995..7b91f22 100644 --- a/internal/bridge/ui/console.js +++ b/internal/bridge/ui/console.js @@ -17,7 +17,7 @@ function apiURL(path){return new URL(API_BASE+path,location.origin).href} async function api(path,options={}){if(state.blocked&&!options.verify)throw new Error('管理访问已暂停,请重新验证登录');const headers={...(window.CommandCodeBridgeAuthHeaders?.()||{}),...options.headers};if(state.key){delete headers.Authorization;delete headers['X-Management-Key'];headers[state.authType==='x-management-key'?'X-Management-Key':'Authorization']=state.authType==='x-management-key'?state.key:'Bearer '+state.key}if(options.body!==undefined)headers['Content-Type']='application/json';state.busy++;$('busy').textContent='正在更新';try{const response=await fetch(apiURL(path),{method:options.method||'GET',headers,body:options.body===undefined?undefined:JSON.stringify(options.body),signal:AbortSignal.timeout(options.timeout||650000)});const text=await response.text();let data;try{data=JSON.parse(text)}catch{data={error:text.slice(0,200)}}if(!response.ok){if((response.status===401||response.status===403)&&path!=='/models/test'){state.authenticated=false;state.blocked=true;resetDiagnosticViews();if(response.status===401){window.CommandCodeBridgeClearAuth?.(headers);state.key=''}openDialog('authDialog')}throw new Error(data.error||`HTTP ${response.status}`)}return data}finally{state.busy--;$('busy').textContent=state.busy?'正在更新':''}} async function confirmed(title,text){$('confirmTitle').textContent=title;$('confirmText').textContent=text;openDialog('confirmDialog');return new Promise(resolve=>{const dialog=$('confirmDialog');let settled=false;const onclose=()=>end(false);const end=value=>{if(settled)return;settled=true;dialog.removeEventListener('close',onclose);if(dialog.open)dialog.close();resolve(value)};dialog.addEventListener('close',onclose);$('confirmCancel').onclick=()=>end(false);$('confirmYes').onclick=()=>end(true);dialog.oncancel=e=>{e.preventDefault();end(false)}})} function period(){const range=$('statsRange').value;let from='',to='';if(range==='today'){const date=new Date();date.setHours(0,0,0,0);from=date.toISOString()}else if(range==='24h')from=new Date(Date.now()-86400000).toISOString();else if(range==='custom'){if($('statsFrom').value)from=new Date($('statsFrom').value).toISOString();if($('statsTo').value)to=new Date($('statsTo').value).toISOString()}const q=new URLSearchParams();if(from)q.set('from',from);if(to)q.set('to',to);return q.size?'?'+q.toString():''} -async function loadConfig(){const identity=diagnosticIdentity(),config=await api('/config');if(!state.authenticated||identity!==diagnosticIdentity())return;state.config=config;renderModels();const f=$('settingsForm');for(const k of ['timeout_seconds','log_retention','usage_refresh_seconds','statistics_retention_days'])f.elements[k].value=state.config[k];for(const k of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])f.elements[k].value=state.config[k]||'';f.elements.max_response_mb.value=state.config.max_response_bytes/1048576;f.elements.zdr.checked=state.config.zdr;$('dataDir').textContent=state.config.data_dir;const model=state.config.models[0]?.id||'你的模型名称';$('apiExample').textContent=JSON.stringify({model,messages:[{role:'user',content:'你好'}],stream:true},null,2);if(state.view==='settings')await Promise.all([loadCPAMPPanel(),loadCompatibility(),loadStorage()])} +async function loadConfig(){const identity=diagnosticIdentity(),config=await api('/config');if(!state.authenticated||identity!==diagnosticIdentity())return;state.config=config;renderModels();const f=$('settingsForm');for(const k of ['timeout_seconds','first_byte_timeout_seconds','stream_idle_timeout_seconds','log_retention','usage_refresh_seconds','statistics_retention_days'])f.elements[k].value=state.config[k]??0;for(const k of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])f.elements[k].value=state.config[k]||'';f.elements.max_response_mb.value=state.config.max_response_bytes/1048576;f.elements.zdr.checked=state.config.zdr;$('dataDir').textContent=state.config.data_dir;const model=state.config.models[0]?.id||'你的模型名称';$('apiExample').textContent=JSON.stringify({model,messages:[{role:'user',content:'你好'}],stream:true},null,2);if(state.view==='settings')await Promise.all([loadCPAMPPanel(),loadCompatibility(),loadStorage()])} function showCPAMPPanel(result){ state.cpampPanel=result; const served=result.served_page; @@ -242,7 +242,7 @@ $('authForm').onsubmit=async e=>{e.preventDefault();resetDiagnosticViews();state $('credentialForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget,cid=f.elements.id.value,body={label:f.elements.label.value,priority:Number(f.elements.priority.value),disabled:f.elements.disabled.checked};if(f.elements.api_key.value.trim())body.api_key=f.elements.api_key.value.trim();try{const saved=await api('/credentials'+(cid?'?id='+encodeURIComponent(cid):''),{method:cid?'PUT':'POST',body});f.elements.api_key.value='';$('credentialDialog').close();toast('凭据已保存 · '+(saved.registration?.message||'宿主登记待确认'));await Promise.all([loadCredentials(),loadDiagnostics()]).catch(globalError)}catch(e){dialogError('credentialDialog',e)}}; $('modelForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const rules=JSON.parse(f.elements.rules.value);if(!rules||Array.isArray(rules)||typeof rules!=='object')throw new Error('映射规则必须是 JSON 对象');const index=f.elements.index.value,old=index===''?{}:state.config.models[Number(index)],m={...old,id:f.elements.id.value.trim(),upstream_id:f.elements.upstream_id.value.trim(),name:f.elements.name.value.trim(),supported_endpoints:[f.elements.endpoint.value],thinking_mode:f.elements.endpoint.value==="/messages"?f.elements.thinking_mode.value:"",reasoning_mapping:{enabled:f.elements.reasoning_enabled.checked,rules}};const models=structuredClone(state.config.models);if(index==='')models.push(m);else models[Number(index)]=m;await saveModels(models);$('modelDialog').close();toast('模型已保存')}catch(e){dialogError('modelDialog',e)}}; $('testForm').onsubmit=async e=>{e.preventDefault();const identity=diagnosticIdentity(),f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;$('testResult').hidden=false;$('testResult').textContent='等待模型回复…';try{const result=await api('/models/test',{method:'POST',body:Object.fromEntries(new FormData(f))});if(!state.authenticated||identity!==diagnosticIdentity())return;$('testResult').textContent=JSON.stringify(result.response,null,2);toast('测试完成')}catch(e){if(state.authenticated&&identity===diagnosticIdentity()){$('testResult').textContent='';dialogError('testDialog',e)}}finally{b.disabled=false;if(state.authenticated&&identity===diagnosticIdentity())await loadModelEvidence().catch(globalError)}}; -$('settingsForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const retention=Number(f.elements.statistics_retention_days.value);if(retention>0&&retention!==state.config.statistics_retention_days&&!await confirmed('设置历史自动过期','超过 '+retention+' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?'))return;await api('/config',{method:'PUT',body:{statistics_retention_days:retention,timeout_seconds:Number(f.elements.timeout_seconds.value),log_retention:Number(f.elements.log_retention.value),usage_refresh_seconds:Number(f.elements.usage_refresh_seconds.value),max_response_bytes:Number(f.elements.max_response_mb.value)*1048576,zdr:f.elements.zdr.checked,cpamp_install_dir:f.elements.cpamp_install_dir.value.trim(),cpamp_config_path:f.elements.cpamp_config_path.value.trim(),cpamp_panel_path:f.elements.cpamp_panel_path.value.trim()}});await loadConfig();toast('设置已保存并生效,无需重启')}catch(e){globalError(e)}}; +$('settingsForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const retention=Number(f.elements.statistics_retention_days.value);if(retention>0&&retention!==state.config.statistics_retention_days&&!await confirmed('设置历史自动过期','超过 '+retention+' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?'))return;await api('/config',{method:'PUT',body:{statistics_retention_days:retention,timeout_seconds:Number(f.elements.timeout_seconds.value),first_byte_timeout_seconds:Number(f.elements.first_byte_timeout_seconds.value),stream_idle_timeout_seconds:Number(f.elements.stream_idle_timeout_seconds.value),log_retention:Number(f.elements.log_retention.value),usage_refresh_seconds:Number(f.elements.usage_refresh_seconds.value),max_response_bytes:Number(f.elements.max_response_mb.value)*1048576,zdr:f.elements.zdr.checked,cpamp_install_dir:f.elements.cpamp_install_dir.value.trim(),cpamp_config_path:f.elements.cpamp_config_path.value.trim(),cpamp_panel_path:f.elements.cpamp_panel_path.value.trim()}});await loadConfig();toast('设置已保存并生效,无需重启')}catch(e){globalError(e)}}; $('detectCPAMPPanel').onclick=async()=>{try{await saveCPAMPPaths();await Promise.all([loadCPAMPPanel(),loadCompatibility()])}catch(e){globalError(e)}}; $('installCPAMPPanel').onclick=async()=>{const button=$('installCPAMPPanel');button.disabled=true;try{await saveCPAMPPaths();await loadCPAMPPanel();const plan=state.cpampPanel;if(!plan?.installable)throw new Error(plan?.message||'尚未确认安装目标');if(!await confirmed(plan.hot_update_ready?'更新原生面板':'安装原生面板',[...(plan.planned_changes||[]),'操作完成后将检查实际服务页面。'].join('\n'))){button.disabled=false;return}const result=await api('/cpamp/panel/install',{method:'POST',body:{}});showCPAMPPanel(result);toast(result.message);await loadCompatibility().catch(globalError)}catch(e){globalError(e);button.disabled=false}}; for(const name of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])$('settingsForm').elements[name].oninput=()=>{$('installCPAMPPanel').disabled=!state.cpampPanel?.available}; diff --git a/internal/bridge/ui/index.html b/internal/bridge/ui/index.html index 735973a..f6760af 100644 --- a/internal/bridge/ui/index.html +++ b/internal/bridge/ui/index.html @@ -44,7 +44,7 @@