diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b0d4a3..055b16d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,11 +61,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.12 --repo Gaoshou101/CommandCodeBridge --pattern commandcodebridge_0.2.12_linux_amd64.zip --pattern checksums.txt --dir /tmp/bridge-previous + gh release download v0.2.13 --repo Gaoshou101/CommandCodeBridge --pattern commandcodebridge_0.2.13_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.12_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.13_linux_amd64.zip - uses: actions/download-artifact@v4 with: name: cpamp-manager-test diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1a398..b8111c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,19 @@ # Changelog -## [Unreleased] +## [0.2.14] - 2026-11-08 + +### Fixed + +- `claude-sonnet-5-5` 上的 `model(none)`(或原生 Messages 请求体中的 `thinking.type=disabled`)不再直接返回 400。该模型的上游拒绝 `disabled`、要求改用 `between_tools`,而 CommandCode 的请求校验又对所有模型拒绝 `between_tools`,经 CommandCode 无法关闭它的思考。插件现识别这一 400,改用最接近的可接受请求(自适应思考,`effort=low`)重试一次;请求日志新增 `adjustments` 字段,控制台请求详情显示「兼容调整」,`reasoning_to` 记为 `low`。低强度下模型对简单问题通常不思考(实测),但不能保证完全不思考。`claude-haiku-5-5` 接受 `disabled`,不受影响。 +- `claude-sonnet-5-5` 不支持强制工具选择:`tool_choice` 为 `tool`/`any` 时上游返回 400 “not supported”。插件现改为 `auto`(保留工具列表与 `disable_parallel_tool_use`)重试一次并同样记录。`auto` 下模型仍可能不调用工具,客户端需能处理普通回复。 +- 以上两项只作用于 Messages 协议模型、只匹配上述上游 400;其他错误照常返回,不重试。重试发生在向客户端输出任何内容之前,统计中仍计为一次请求。重试被接受后,插件在本进程内记住该模型,之后的请求直接改写、不再先失败一次;重启或修改 `base_url` 后重新探测,重试仍失败时不记住。2026-11-08 已对真实上游复测(见验证记录)。 ### Changed +- 默认 `timeout_seconds` 从 600 改为 **1800**(取值范围不变)。实测 `grok-4.7` 高等级推理用时 658 秒,旧默认会把它截断为 504。只影响新安装或从未保存过设置的实例:`settings.json` 优先于 YAML,已保存过设置的实例保留原值(通常为 600),需在「设置 → 请求策略 → 请求超时」手动改为 1800。设置页说明同步提示。 +- `tests/commandcode_live.py`:thinking 用例改用需要推理的计数题并请求 `effort=high`(自适应模型对“Reply only OK.”不思考);新增 `--thinking-off`(发送 `model(none)`)和 `--request-timeout`(默认 300 秒,此前插件超时固定 120 秒、客户端读超时 30 秒)。每个请求记录插件做出的兼容调整,出现预期外的调整即失败;失败报告带最后执行的检查名。`cpa_round2.py` 让模拟上游按 sonnet-5-5 的方式拒绝两类请求,经真实 CPA 宿主端到端验证重试、日志和计数。 - 插件控制台的 `console.js`、`console.css` 改为 Prettier 3.7.4 格式化的可读源码(原为长行手写压缩风格),按原样内嵌;JS 语法树与原文件一致(仅去掉空语句),CSS 仅引号、属性选择器引号和小数前导零写法变化。控制台页面增大约 17 KB,未加入压缩步骤。CI 检查这两个文件的格式;Node 测试改为按唯一标记切片,标记缺失时直接失败。 +- 热升级联测的前一版本改为 v0.2.13。部署说明新增 Claude 模型 403「The provider refused this request」的排查提示:实测与出口有关(香港出口拒绝、日本出口正常),不是插件故障。 ## [0.2.13] - 2026-11-07 diff --git a/README.md b/README.md index 36dbafd..5fb1132 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ - **原生额度窗口**:在 CPAMP 列表、卡片和详情查看 5h、周、月额度,查询窗口请求统计、预测和周期快照。 - **请求记录**:查看凭证健康度、tokens、缓存用量、预估花费、首字耗时、输出速率及模型统计;支持明暗主题和手机布局。 -当前版本 **v0.2.13**。插件调用官方 Provider API;Go 订阅的 `/alpha/generate` 路径和固定上游 provider 模式不在支持范围内。账户用量通过官方 `/alpha/...` 查询接口读取,模型权限以实际套餐为准。 +当前版本 **v0.2.14**。插件调用官方 Provider API;Go 订阅的 `/alpha/generate` 路径和固定上游 provider 模式不在支持范围内。账户用量通过官方 `/alpha/...` 查询接口读取,模型权限以实际套餐为准。 + +### v0.2.14 更新 + +`claude-sonnet-5-5` 上的 `model(none)` 和强制工具选择不再返回 400:插件识别这两类上游拒绝,改用最接近的可接受请求(自适应低强度思考、`tool_choice: auto`)重试一次,并在请求日志与控制台显示「兼容调整」。默认请求超时从 600 秒改为 1800 秒;已保存过设置的实例需在「设置 → 请求策略」手动修改。 ### v0.2.13 更新 @@ -47,7 +51,7 @@ plugins: - https://raw.githubusercontent.com/Gaoshou101/CommandCodeBridge/main/registry.json ``` -保存并重载配置,在 CPAMP「插件商店」找到 **CommandCodeBridge**,选择 Latest 或 **v0.2.13** 安装。支持热升级的宿主会下载匹配平台的 ZIP、验证校验值并切换版本;安装后检查插件控制台显示的运行版本。 +保存并重载配置,在 CPAMP「插件商店」找到 **CommandCodeBridge**,选择 Latest 或 **v0.2.14** 安装。支持热升级的宿主会下载匹配平台的 ZIP、验证校验值并切换版本;安装后检查插件控制台显示的运行版本。 也可从 [Releases](https://github.com/Gaoshou101/CommandCodeBridge/releases/latest) 手动下载 ZIP 和 `checksums.txt`。按 **CPA 所在服务器**选择动态库: @@ -73,7 +77,7 @@ Linux 发布包要求 glibc ≥2.28(v0.2.12 及更早为 ≥2.34);Alpine/m ## 安装或更新原生面板 -**升级插件后还要更新 CPAMP 页面**,才能使用新版凭证布局。v0.2.13 动态库已内嵌匹配页面。 +**升级插件后还要更新 CPAMP 页面**,才能使用新版凭证布局。v0.2.14 动态库已内嵌匹配页面。 CPA 与 Manager Server 在同一主机直接运行时: @@ -88,7 +92,7 @@ CPA 与 Manager Server 在同一主机直接运行时: | 首次启用外部页面,或更改 Manager Server 的页面路径 | 重启 Manager Server 一次 | | 迁移插件数据目录,或旧宿主不支持动态库热升级 | 安排相应服务重启 | -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 在另一台主机或独立容器中时,在其运行环境安装 `commandcodebridge_0.2.14_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 07e19df..50ea6a3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,7 +13,7 @@ plugins: cpamp_config_path: "" cpamp_panel_path: "" # Browser OAuth uses a local POST receiver and CPAMP result submission. - timeout_seconds: 600 + timeout_seconds: 1800 # 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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4cf649e..4b5849c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,14 @@ # CPAMP 部署、升级与回滚 -本文说明 CommandCodeBridge v0.2.13 的安装、页面更新与回滚。路径应以实际运行环境为准;历史排查中的路径仅用于说明当时的问题。 +本文说明 CommandCodeBridge v0.2.14 的安装、页面更新与回滚。路径应以实际运行环境为准;历史排查中的路径仅用于说明当时的问题。 + +## v0.2.14:Claude 兼容调整与默认超时 + +1. 更新插件至 v0.2.14,确认实际运行版本;配置无需修改。 +2. 默认 `timeout_seconds` 改为 1800,只对新安装或从未保存过设置的实例生效。已保存过设置的实例仍使用 `settings.json` 中的值(通常为 600),长推理模型会被截断为 504(实测 grok-4.7 高等级推理约 11 分钟);需要时在「设置 → 请求策略 → 请求超时」改为 1800。 +3. 对 `claude-sonnet-5-5` 使用 `model(none)` 或强制工具选择的客户端无需改动;请求日志的 `adjustments` 与控制台「兼容调整」显示插件所做的改写。`auto` 下模型仍可能不调用工具,客户端需能处理普通回复。 +4. 页面功能未变,但页面带插件版本标识,仍按下节步骤更新 CPAMP 页面并强刷浏览器。 +5. Claude 模型返回 403 `permission_error`「The provider refused this request」与插件版本无关,实测与出口有关:2026-11-08 同一密钥从香港出口请求两款 Claude 模型(含不经插件的直连)均返回该 403,经日本出口恢复正常,其他模型不受影响。CPA v8.0.23 会因该 403 冷却凭证上的这个模型,随后同模型返回 503 `auth_unavailable`。改用合适出口的代理后,重启 CPA 清除冷却。 ## v0.2.13:glibc 2.28、写盘与超时保护 @@ -199,7 +207,8 @@ 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`;默认关闭。实测 Claude/GPT 思考期间不发送数据,首字节即思考时长(难题或 57 万 tokens 上下文约 12~25 秒),grok-4.7 高等级出现过 58 秒段间间隔;启用时建议两项均 ≥120 秒。grok-4.7 等长推理可超过默认 `timeout_seconds: 600`(实测 658 秒),需要时提高到 1200~1800 | +| 上游长时间无响应才报 504 | 可在设置页启用「首字节超时」和「流空闲超时」(`first_byte_timeout_seconds`、`stream_idle_timeout_seconds`,0 为关闭,启用时 5–1800 秒)。日志的错误类型分别为 `upstream_first_byte_timeout`、`upstream_stream_idle_timeout`;默认关闭。实测 Claude/GPT 思考期间不发送数据,首字节即思考时长(难题或 57 万 tokens 上下文约 12~25 秒),grok-4.7 高等级出现过 58 秒段间间隔;启用时建议两项均 ≥120 秒。grok-4.7 等长推理可超过 600 秒(实测 658 秒)。`timeout_seconds` 默认值自 v0.2.14 起为 1800;此前保存过设置的实例仍是 `settings.json` 中的旧值(通常 600),需在「设置 → 请求策略 → 请求超时」手动提高 | +| `model(none)` 或强制工具选择的请求日志显示「兼容调整」 | 部分 Claude 模型(实测 `claude-sonnet-5-5`)经 CommandCode 不能关闭思考,也不支持 `tool_choice` 为 `tool`/`any`。插件把上游的 400 改为自适应低强度思考或 `auto` 后重试一次,在日志 `adjustments` 中记录,并在本进程内记住该模型;重启或改 `base_url` 后重新探测 | | 用量缺失 | 查看 `unavailable` / `status`;查询失败保留旧值 | | 日志写入失败 | 检查 data_dir 的磁盘空间、权限及 `storage_error` | | 重启后设置未按 YAML 改变 | 已存 settings.json 优先;采用明确的迁移方案 | @@ -249,7 +258,7 @@ Windows 使用 `Get-FileHash -Algorithm SHA256`,macOS 使用 `shasum -a 256` 本轮没有真实账号调用。可先运行无网络预览: ```sh -python tests/commandcode_live.py --dry-run --generate --chat-model YOUR_CHAT_MODEL --messages-model YOUR_CLAUDE_MODEL --tools --image --thinking --thinking-mode adaptive --zdr --max-requests 13 --max-tokens 2048 +python tests/commandcode_live.py --dry-run --generate --chat-model YOUR_CHAT_MODEL --messages-model YOUR_CLAUDE_MODEL --tools --image --thinking --thinking-off --thinking-mode adaptive --zdr --max-requests 14 --max-tokens 2048 ``` -实际执行需要仓库外 JSON 凭据文件(`api_key`)、CPA/动态库环境变量及明确允许的模型和请求预算,再去掉 `--dry-run` 并添加 `--credential-file`、`--report`。可分组降低预算;所有组合最多 13 次,本脚本硬限制最多 20 次、每次至多 8192 输出 tokens。实际收费规则以上游为准;ZDR 请求被接受也不能证明服务端保留策略。个人/组织与多 key 的共享身份仍待真实验收,因此采样保持逐凭据隔离。 +实际执行需要仓库外 JSON 凭据文件(`api_key`)、CPA/动态库环境变量及明确允许的模型和请求预算,再去掉 `--dry-run` 并添加 `--credential-file`、`--report`。可分组降低预算;所有组合最多 14 次,`--request-timeout` 默认每次 300 秒(10–1800)。thinking 用例使用需要推理的计数题,`--thinking-off` 发送 `model(none)`;报告逐项列出插件的兼容调整(如强制工具选择改为 `auto`),出现预期外的调整即失败。本脚本硬限制最多 20 次、每次至多 8192 输出 tokens。实际收费规则以上游为准;ZDR 请求被接受也不能证明服务端保留策略。个人/组织与多 key 的共享身份仍待真实验收,因此采样保持逐凭据隔离。 diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 05be460..aafacb9 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,6 +1,29 @@ # CommandCodeBridge 发布说明 -## v0.2.13 +## v0.2.14 + +发布日期:2026-11-08。CPA 原生插件,C ABI 1 / RPC schema 6;调用官方 Provider API。 + +### 本次更新 + +- **sonnet-5-5 的 `model(none)`**:该模型的上游拒绝 `thinking.type=disabled` 并要求改用 `between_tools`,而 CommandCode 的请求校验对所有模型拒绝 `between_tools`,经 CommandCode 无法关闭它的思考。插件现识别这一 400,改用自适应思考 + `effort=low` 重试一次。低强度下模型对简单问题通常不思考(实测),但不能保证。 +- **sonnet-5-5 的强制工具选择**:`tool_choice` 为 `tool`/`any`(含 Chat 客户端的 `required`)时上游返回 400,插件改为 `auto`(保留工具列表与 `disable_parallel_tool_use`)重试一次。 +- 两项只匹配上述上游 400、只作用于 Messages 协议模型;重试发生在任何输出之前,统计计为一次请求。重试被接受后本进程内记住该模型,重启或修改 `base_url` 后重新探测。请求日志新增 `adjustments`,控制台请求详情显示「兼容调整」。 +- **默认超时改为 1800 秒**:实测 grok-4.7 高等级推理用时 658 秒,旧默认 600 秒会截断为 504。 +- 验收脚本改用需要推理的思考用例,新增 `--thinking-off`、`--request-timeout`;控制台 `console.js`/`console.css` 改为可读源码(#17)。 + +### 升级步骤 + +1. 更新插件至 **v0.2.14**,确认实际运行版本。无需修改配置。 +2. 已保存过设置的实例仍使用原超时(通常 600 秒),需要时在「设置 → 请求策略 → 请求超时」改为 1800。 +3. 本次未改变 CPAMP 页面功能;页面带插件版本标识,仍建议在「设置 → CPAMP 原生面板」更新页面,异机/独立容器使用 `commandcodebridge_0.2.14_cpamp-web.zip`。 +4. Claude 模型返回 403「The provider refused this request」与插件无关,实测与出口有关,见部署说明 v0.2.14 第 5 条。 + +### 下载与校验 + +四平台原生 ZIP、独立 CPAMP 前端 ZIP、五个 `.zip.sha256` 和统一 `checksums.txt`,共 **11 个资产**。CPA 发布门槛仍为 v8.0.23,热升级联测的前一版本改为 v0.2.13;v8.0.59 在 Windows 本地完成全套宿主联测。合并前以同一源码构建的 Windows 动态库对真实上游完成付费复测(sonnet-5-5、haiku-5-5),详见验证记录;未访问或修改任何部署服务器。 + +## 已发布 v0.2.13 发布日期:2026-11-07。CPA 原生插件,C ABI 1 / RPC schema 6;调用官方 Provider API。 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index f60263a..eb8de2a 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,5 +1,34 @@ # 开发验证记录 +## v0.2.14 合并前真实上游复测(2026-11-08) + +使用 PR #18 head `dbb2fc4` 在 WSL 交叉编译的 Windows 动态库(版本号仍为 0.2.13,源码与发版仅差版本号和文档),宿主 CPA v8.0.23;凭证只放在仓库外的临时文件中,用后删除。 + +- **出口导致的 403**:首次复测时两款 Claude 模型均返回 403 `permission_error`「The provider refused this request. The refused request wasn't charged.」,不经插件直连上游也一样;同一时间 `gpt-5.6-sol`、`deepseek-v4-pro` 返回 200,账户额度正常(5 小时窗口几乎未用)。当时本机出口为香港(Cloudflare `colo=HKG`,IPv4/IPv6 相同)。只让测试 CPA 经日本出口的代理后,同一密钥的 Claude 请求恢复 200。插件对该 403 不做兼容调整(符合设计);CPA v8.0.23 把它当作凭证故障,同模型随后返回 503 `auth_unavailable`。部署说明已加入排查提示。 +- **上游报错原文**(直连):`thinking.type=disabled` → 400「To turn thinking off on this model, send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}…」;`tool_choice` 为 `tool`/`any` → 400「tool_choice: type "tool" and "any" are not supported for this model.」(CommandCode 把上游错误 JSON 作为字符串放在 `message` 中)。两者均命中现有匹配规则,无需修改。`auto` 返回 200 并调用工具;haiku-5-5 接受强制工具。 +- **兼容探测**(经插件,10 次请求全部 200): + - sonnet `(none)` 首次:日志 `adjustments=[thinking_off_unsupported:adaptive_low]`、`reasoning_to=low`,返回纯文本;第二次直接改写(1.7 s,首次含被拒请求 2.7 s);Chat 客户端同样改写,回复无推理内容。 + - sonnet 强制工具 `tool` 首次、`any`、Chat 客户端 `required`:均记录 `forced_tool_choice_unsupported:auto`,模型调用了 `read_file` 且参数正确。 + - haiku `(none)` 与强制工具:无调整,`reasoning_to=none`。 + - 重启 CPA 后的流式请求:两项都在输出前重试成功,流式 200;统计 10/10 成功且均有用量。 +- **验收脚本** `commandcode_live.py --generate --tools --thinking --thinking-off --max-tokens 2048 --max-requests 7`:haiku-5-5 7/7 PASS(无调整,4,052 tokens);sonnet-5-5 7/7 PASS(工具调用 1 次 `forced_tool_choice_unsupported:auto`、关闭思考 1 次 `thinking_off_unsupported:adaptive_low`,2,379 tokens);两者 CPA 用量计数与插件统计一致。 + +升版本号后的本地验证:Windows `go test`/`go vet`/gofmt、WSL Linux `go test -race`、Node 32 项、Prettier、CPAMP 页面校验 6 项通过;在固定基线源码上重建并内嵌 0.2.14 页面,WSL 交叉编译的 Windows 动态库在 CPA v8.0.23 与 v8.0.59 上跑完 9 项宿主联测(含 v0.2.13 正式包 → 新库热升级),18/18 通过。 + +**尚未验证**:付费模型在 CPA v8.0.59 上的复测(宿主剥离后缀后注入等级,插件映射路径相同);sonnet-5-5 低强度下“不思考”只是抽样观察,不作保证。 + +## 真实验收发现的兼容修复(2026-11-08,v0.2.14) + +针对下方付费验收发现的三个问题: + +- **`model(none)` 与 sonnet-5-5**:插件识别上游“改用 `between_tools`”的 400,改为自适应思考 + `effort=low` 重试一次,日志 `adjustments` 记录 `thinking_off_unsupported:adaptive_low`,`reasoning_to` 记为 `low`。依据:实测自适应低强度下 sonnet-5-5 对简单题不思考(见下方思考等级表);`between_tools` 被 CommandCode 校验拒绝,无法使用。 +- **强制工具选择与 sonnet-5-5**:识别 `tool_choice` “not supported” 的 400,改为 `auto`(保留工具列表与 `disable_parallel_tool_use`)重试一次,记录 `forced_tool_choice_unsupported:auto`。依据:实测 `auto` 在明确要求时会调用该工具。 +- 两项只作用于 Messages 协议模型和上述上游 400;重试在任何输出之前,统计计为一次请求。重试被接受后本进程内记住该模型,后续请求直接改写;重启或改 `base_url` 后重新探测;重试仍失败时不记住。 +- **默认超时**:`timeout_seconds` 默认改为 1800(grok-4.7 实测 658 秒)。已保存过设置的实例仍使用 `settings.json` 中的值,需手动修改;未做自动迁移,以免覆盖用户有意设置的 600。 +- **验收脚本**:`commandcode_live.py` 的 thinking 用例改用计数题 + `effort=high`,新增 `--thinking-off` 与 `--request-timeout`,逐请求记录兼容调整。 + +本地验证(Windows Go 1.27.2 + WSL Linux):新增 8 个 Go 单元测试覆盖重试、记住与重启遗忘、失败不记住、其他错误不重试、两项同时发生(流式)、Chat 协议不受影响和默认超时;`go test -race ./...`(WSL Linux)、`go vet`、gofmt、Node 32 项、Prettier 检查通过。`cpa_round2.py` 新增模拟 sonnet-5-5 拒绝的端到端用例。WSL 交叉编译的 Windows 动态库在 CPA v8.0.23 与 v8.0.59 上跑完 9 项宿主联测(含 v0.2.12 → 新库热升级),18/18 通过;新用例中 5 次客户端请求对应 2 次上游拒绝与重试,统计与 CPA 用量队列均计 5 次。真实上游复测见上节。 + ## 付费推理模型真实验收(2026-11-08,v0.2.13 正式包) 用户授权使用付费模型、预算不设上限。全部使用 v0.2.13 正式 Windows 动态库,宿主以 CPA v8.0.23 为主;凭证仍只放在仓库外的临时文件中,用后删除。账户为 GOAT 套餐,本轮最后一次请求触发 **5 小时用量上限**(429),随即停止付费请求。超出预期的消耗主要来自长上下文用例:填充文本实际为 57 万(Claude)/31 万(GPT)tokens,远大于预估。 diff --git a/integrations/cpamp/README.md b/integrations/cpamp/README.md index cc2e133..1f45319 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.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 源码目录应用补丁: +2. 通过支持热升级的 CPA 插件商店更新 CommandCodeBridge;确认运行版本为 `0.2.14`。从 [v0.2.14 Release](https://github.com/Gaoshou101/CommandCodeBridge/releases/tag/v0.2.14) 选择匹配平台 ZIP,并核验 `checksums.txt`。 +3. **同时升级管理页面**。同机使用上节一键入口;异机面板匹配包内 `BUILD.json` 的 CPAMP 基线时,下载 Release 的 `commandcodebridge_0.2.14_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/anthropic_limits.go b/internal/bridge/anthropic_limits.go new file mode 100644 index 0000000..c6c03e1 --- /dev/null +++ b/internal/bridge/anthropic_limits.go @@ -0,0 +1,137 @@ +package bridge + +import ( + "errors" + "strings" + "sync" +) + +// Some Claude models served by CommandCode reject request shapes that other +// Claude models accept, and the catalog does not describe these limits: +// +// - claude-sonnet-5-5 rejects thinking.type "disabled" and asks for +// "between_tools", which CommandCode's own validation rejects for every +// model. Thinking therefore cannot be turned off; the closest request is +// adaptive thinking at low effort, which in live tests answered without +// thinking. +// - claude-sonnet-5-5 rejects forced tool_choice ("tool" / "any"); "auto" +// with the same tools still calls the requested tool when asked to. +// +// The bridge retries such a rejection once with the nearest accepted request. +// Only after the adjusted request is accepted does it remember the model for +// later requests in this process (until restart or a base_url change), and it +// records every change on the request log so the substitution is never silent. +const ( + adjustThinkingOff = "thinking_off_unsupported:adaptive_low" + adjustForcedTool = "forced_tool_choice_unsupported:auto" +) + +type anthropicLimits struct { + mu sync.Mutex + models map[string]map[string]bool +} + +func (l *anthropicLimits) known(model string) []string { + l.mu.Lock() + defer l.mu.Unlock() + out := []string{} + for _, adjustment := range []string{adjustThinkingOff, adjustForcedTool} { + if l.models[model][adjustment] { + out = append(out, adjustment) + } + } + return out +} + +func (l *anthropicLimits) reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.models = nil +} + +func (l *anthropicLimits) learn(model, adjustment string) { + l.mu.Lock() + defer l.mu.Unlock() + if l.models == nil { + l.models = map[string]map[string]bool{} + } + if l.models[model] == nil { + l.models[model] = map[string]bool{} + } + l.models[model][adjustment] = true +} + +// rejectedAdjustment maps an upstream 400 to the adjustment that avoids it. +func rejectedAdjustment(err error, body map[string]any) string { + var api *APIError + if !errors.As(err, &api) || api.Status != 400 || api.Local { + return "" + } + message := strings.ToLower(api.Message) + if strings.Contains(message, "between_tools") && thinkingDisabled(body) { + return adjustThinkingOff + } + if forcedToolChoice(body) && mentionsToolChoice(message) && (strings.Contains(message, "not supported") || strings.Contains(message, "unsupported")) { + return adjustForcedTool + } + return "" +} + +func mentionsToolChoice(message string) bool { + for _, phrase := range []string{"tool_choice", "tool choice", "forced tool", "forces tool"} { + if strings.Contains(message, phrase) { + return true + } + } + return false +} + +func thinkingDisabled(body map[string]any) bool { + return str(object(body["thinking"])["type"]) == "disabled" +} + +func forcedToolChoice(body map[string]any) bool { + kind := str(object(body["tool_choice"])["type"]) + return kind == "tool" || kind == "any" +} + +// applyAdjustment rewrites body in place and reports whether anything changed. +func applyAdjustment(body map[string]any, adjustment string) bool { + switch adjustment { + case adjustThinkingOff: + if !thinkingDisabled(body) { + return false + } + body["thinking"] = map[string]any{"type": "adaptive"} + output := object(body["output_config"]) + if output == nil { + output = map[string]any{} + } + output["effort"] = "low" + body["output_config"] = output + return true + case adjustForcedTool: + if !forcedToolChoice(body) { + return false + } + choice := map[string]any{"type": "auto"} + if parallel, ok := object(body["tool_choice"])["disable_parallel_tool_use"]; ok { + choice["disable_parallel_tool_use"] = parallel + } + body["tool_choice"] = choice + return true + } + return false +} + +func noteAdjustment(entry *LogEntry, adjustment string) { + for _, existing := range entry.Adjustments { + if existing == adjustment { + return + } + } + entry.Adjustments = append(entry.Adjustments, adjustment) + if adjustment == adjustThinkingOff && entry.ReasoningTo == "none" { + entry.ReasoningTo = "low" + } +} diff --git a/internal/bridge/anthropic_limits_test.go b/internal/bridge/anthropic_limits_test.go new file mode 100644 index 0000000..82afbc5 --- /dev/null +++ b/internal/bridge/anthropic_limits_test.go @@ -0,0 +1,296 @@ +package bridge + +import ( + "net/http" + "reflect" + "testing" + "time" +) + +func anthropicTextStream(text string) string { + events := []string{ + `{"type":"message_start","message":{"id":"message-id","usage":{"input_tokens":5,"output_tokens":0}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":` + string(jsonBytes(text)) + `}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`, + `{"type":"message_stop"}`, + } + body := "" + for _, event := range events { + body += "data: " + event + "\n\n" + } + return body +} + +func anthropicError(status int, message string) mockReply { + return mockReply{status: status, headers: http.Header{"Content-Type": {"application/json"}}, body: string(jsonBytes(map[string]any{"type": "error", "error": map[string]any{"type": "invalid_request_error", "message": message}}))} +} + +// Live CommandCode wording for claude-sonnet-5-5, 2026-11-08. +const betweenToolsMessage = `To turn thinking off on this model, send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}. The model does not think before responding.` + +func useMessagesModel(t *testing.T, s *Service) { + t.Helper() + cfg := s.config() + cfg.Models[0].Endpoints = []string{"/messages"} + if e := s.saveConfig(cfg); e != nil { + t.Fatal(e) + } +} + +func requestBody(t *testing.T, h *mockHost, i int) map[string]any { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + body, e := decodeObject(bytesField(h.requests[i]["body"])) + if e != nil { + t.Fatal(e) + } + return body +} + +func requestCount(h *mockHost) int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.requests) +} + +func lastLog(s *Service) LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + return s.logs[len(s.logs)-1] +} + +func TestThinkingOffFallsBackToAdaptiveLowAndIsRemembered(t *testing.T) { + s, h, c := setup(t, func(_ string, raw map[string]any) mockReply { + body, _ := decodeObject(bytesField(raw["body"])) + if str(object(body["thinking"])["type"]) == "disabled" { + return anthropicError(400, betweenToolsMessage) + } + return streamReply(anthropicTextStream("ok")) + }) + useMessagesModel(t, s) + r := request(c) + r.Model = "alias(none)" + result, e := s.execute(r, false) + if e != nil { + t.Fatal(e) + } + j, _ := decodeObject(result.(Response).Payload) + if str(object(object(list(j["choices"])[0])["message"])["content"]) != "ok" { + t.Fatalf("unexpected response %s", result.(Response).Payload) + } + if n := requestCount(h); n != 2 { + t.Fatalf("expected one retry, got %d upstream requests", n) + } + if str(object(requestBody(t, h, 0)["thinking"])["type"]) != "disabled" { + t.Fatal("first request did not ask to disable thinking") + } + retried := requestBody(t, h, 1) + if str(object(retried["thinking"])["type"]) != "adaptive" || str(object(retried["output_config"])["effort"]) != "low" { + t.Fatalf("retry was not adaptive low: %v", retried) + } + entry := lastLog(s) + if !reflect.DeepEqual(entry.Adjustments, []string{adjustThinkingOff}) || entry.ReasoningFrom != "none" || entry.ReasoningTo != "low" || entry.Status != 200 { + t.Fatalf("adjustment not recorded: %+v", entry) + } + + // The model is remembered: the next request goes out adjusted at once. + if _, e := s.execute(r, false); e != nil { + t.Fatal(e) + } + if n := requestCount(h); n != 3 { + t.Fatalf("remembered adjustment still retried: %d upstream requests", n) + } + if str(object(requestBody(t, h, 2)["thinking"])["type"]) != "adaptive" { + t.Fatal("remembered adjustment not applied") + } + if entry := lastLog(s); !reflect.DeepEqual(entry.Adjustments, []string{adjustThinkingOff}) || entry.ReasoningTo != "low" { + t.Fatalf("remembered adjustment not recorded: %+v", entry) + } + + // Requests that do not disable thinking are untouched. + r.Model = "alias(high)" + if _, e := s.execute(r, false); e != nil { + t.Fatal(e) + } + if body := requestBody(t, h, 3); str(object(body["output_config"])["effort"]) != "high" { + t.Fatalf("unrelated request changed: %v", body) + } + if entry := lastLog(s); len(entry.Adjustments) != 0 { + t.Fatalf("unrelated request recorded an adjustment: %+v", entry) + } + + // A base_url change forgets learned limits. + cfg := s.config() + cfg.BaseURL = "http://other.example" + if e := s.saveConfig(cfg); e != nil { + t.Fatal(e) + } + if known := s.anthropicLimits.known("upstream-model"); len(known) != 0 { + t.Fatalf("limits survived base_url change: %v", known) + } +} + +func TestFailedRetryIsNotRemembered(t *testing.T) { + s, h, c := setup(t, func(_ string, raw map[string]any) mockReply { + body, _ := decodeObject(bytesField(raw["body"])) + if str(object(body["thinking"])["type"]) == "disabled" { + return anthropicError(400, betweenToolsMessage) + } + return anthropicError(400, "Invalid input at thinking") + }) + useMessagesModel(t, s) + r := request(c) + r.Model = "alias(none)" + if _, e := s.execute(r, false); statusOf(e) != 400 { + t.Fatalf("expected the retry's 400, got %v", e) + } + if n := requestCount(h); n != 2 { + t.Fatalf("expected exactly one retry, got %d upstream requests", n) + } + if known := s.anthropicLimits.known("upstream-model"); len(known) != 0 { + t.Fatalf("rejected adjustment was remembered: %v", known) + } + if entry := lastLog(s); !reflect.DeepEqual(entry.Adjustments, []string{adjustThinkingOff}) || entry.Status != 400 { + t.Fatalf("attempted adjustment not recorded: %+v", entry) + } +} + +func TestOtherUpstreamErrorsAreNotRetried(t *testing.T) { + for name, reply := range map[string]mockReply{ + "unrelated 400": anthropicError(400, "max_tokens is too large"), + "between_tools 500": anthropicError(500, betweenToolsMessage), + "other unsupported": anthropicError(400, "image input is not supported for this model"), + } { + t.Run(name, func(t *testing.T) { + s, h, c := setup(t, func(string, map[string]any) mockReply { return reply }) + useMessagesModel(t, s) + r := request(c) + r.Model = "alias(none)" + r.Payload = []byte(`{"messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object"}}}],"tool_choice":"required"}`) + if _, e := s.execute(r, false); statusOf(e) != reply.status { + t.Fatalf("expected %d, got %v", reply.status, e) + } + if n := requestCount(h); n != 1 { + t.Fatalf("unexpected retry: %d upstream requests", n) + } + if entry := lastLog(s); len(entry.Adjustments) != 0 { + t.Fatalf("unexpected adjustment: %+v", entry) + } + }) + } +} + +func TestForcedToolChoiceFallsBackToAuto(t *testing.T) { + s, h, c := setup(t, func(_ string, raw map[string]any) mockReply { + body, _ := decodeObject(bytesField(raw["body"])) + if kind := str(object(body["tool_choice"])["type"]); kind == "tool" || kind == "any" { + return anthropicError(400, "tool_choice type '"+kind+"' is not supported for this model") + } + return streamReply(anthropicTextStream("ok")) + }) + useMessagesModel(t, s) + r := request(c) + r.Payload = []byte(`{"messages":[{"role":"user","content":"read a"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"read"}},"parallel_tool_calls":false}`) + if _, e := s.execute(r, false); e != nil { + t.Fatal(e) + } + if n := requestCount(h); n != 2 { + t.Fatalf("expected one retry, got %d upstream requests", n) + } + if first := object(requestBody(t, h, 0)["tool_choice"]); str(first["type"]) != "tool" || str(first["name"]) != "read" { + t.Fatalf("first request was not forced: %v", first) + } + retried := object(requestBody(t, h, 1)["tool_choice"]) + if !reflect.DeepEqual(retried, map[string]any{"type": "auto", "disable_parallel_tool_use": true}) { + t.Fatalf("retry tool_choice = %v", retried) + } + if entry := lastLog(s); !reflect.DeepEqual(entry.Adjustments, []string{adjustForcedTool}) || entry.ReasoningTo != entry.ReasoningFrom { + t.Fatalf("adjustment not recorded: %+v", entry) + } + if known := s.anthropicLimits.known("upstream-model"); !reflect.DeepEqual(known, []string{adjustForcedTool}) { + t.Fatalf("limit not remembered: %v", known) + } +} + +func TestBothAdjustmentsInOneRequest(t *testing.T) { + s, h, c := setup(t, func(_ string, raw map[string]any) mockReply { + body, _ := decodeObject(bytesField(raw["body"])) + if str(object(body["thinking"])["type"]) == "disabled" { + return anthropicError(400, betweenToolsMessage) + } + if str(object(body["tool_choice"])["type"]) == "any" { + return anthropicError(400, "tool_choice is not supported for this model") + } + return streamReply(anthropicTextStream("ok")) + }) + useMessagesModel(t, s) + r := request(c) + r.Model = "alias(none)" + r.Payload = []byte(`{"messages":[{"role":"user","content":"read a"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object"}}}],"tool_choice":"required"}`) + // Streaming: the retries happen before any output reaches the client. + h.completed = make(chan struct{}, 1) + r.StreamID = "downstream" + r.Metadata = map[string]any{"request_path": "/v1/chat/completions"} + if _, e := s.execute(r, true); e != nil { + t.Fatal(e) + } + select { + case <-h.completed: + case <-time.After(3 * time.Second): + t.Fatal("output stream was not closed") + } + if n := requestCount(h); n != 3 { + t.Fatalf("expected two retries, got %d upstream requests", n) + } + final := requestBody(t, h, 2) + if str(object(final["thinking"])["type"]) != "adaptive" || str(object(final["tool_choice"])["type"]) != "auto" { + t.Fatalf("final request = %v", final) + } + if entry := lastLog(s); !reflect.DeepEqual(entry.Adjustments, []string{adjustThinkingOff, adjustForcedTool}) { + t.Fatalf("adjustments = %v", entry.Adjustments) + } +} + +func TestChatModelsAreNeverAdjusted(t *testing.T) { + s, h, c := setup(t, func(string, map[string]any) mockReply { + return anthropicError(400, betweenToolsMessage) + }) + r := request(c) + r.Model = "alias(none)" + if _, e := s.execute(r, false); statusOf(e) != 400 { + t.Fatalf("expected 400, got %v", e) + } + if n := requestCount(h); n != 1 { + t.Fatalf("chat request retried: %d upstream requests", n) + } + if entry := lastLog(s); len(entry.Adjustments) != 0 { + t.Fatalf("chat request adjusted: %+v", entry) + } +} + +func TestApplyAdjustmentOnlyChangesMatchingRequests(t *testing.T) { + body := map[string]any{"thinking": map[string]any{"type": "adaptive"}, "tool_choice": map[string]any{"type": "auto"}} + if applyAdjustment(body, adjustThinkingOff) || applyAdjustment(body, adjustForcedTool) || applyAdjustment(body, "unknown") { + t.Fatal("adjustment reported a change on a compatible request") + } + body = map[string]any{"thinking": map[string]any{"type": "disabled"}, "output_config": map[string]any{"format": "kept"}} + if !applyAdjustment(body, adjustThinkingOff) || !reflect.DeepEqual(body["output_config"], map[string]any{"format": "kept", "effort": "low"}) { + t.Fatalf("output_config not merged: %v", body) + } + if rejectedAdjustment(&APIError{Status: 400, Message: betweenToolsMessage, Local: true}, map[string]any{"thinking": map[string]any{"type": "disabled"}}) != "" { + t.Fatal("local error treated as an upstream limit") + } +} + +func TestDefaultTimeoutCoversLongReasoning(t *testing.T) { + if got := defaultConfig().TimeoutSeconds; got != 1800 { + t.Fatalf("default timeout_seconds = %d, want 1800", got) + } + cfg := defaultConfig() + if e := cfg.validate(); e != nil { + t.Fatal(e) + } +} diff --git a/internal/bridge/executor.go b/internal/bridge/executor.go index 0c337dc..c2b0f6b 100644 --- a/internal/bridge/executor.go +++ b/internal/bridge/executor.go @@ -410,6 +410,11 @@ func (s *Service) execute(r ExecutorRequest, stream bool) (any, error) { release(e) return early(e) } + for _, adjustment := range s.anthropicLimits.known(m.UpstreamID) { + if applyAdjustment(body, adjustment) { + noteAdjustment(&entry, adjustment) + } + } } else { opts := object(body["stream_options"]) if opts == nil { @@ -428,19 +433,35 @@ func (s *Service) execute(r ExecutorRequest, stream bool) (any, error) { 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 { + var up upstreamStream + var pending []string + for { + 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) + } + if up.StatusCode >= 200 && up.StatusCode < 300 { + for _, adjustment := range pending { + s.anthropicLimits.learn(m.UpstreamID, adjustment) + } + break + } _, e = s.readJSON(up) e = redactError(e, c.APIKey) + // A known model limit is retried with the nearest accepted request and + // remembered only once that request is accepted. Each adjustment applies + // at most once, so this loop is bounded. + if adjustment := rejectedAdjustment(e, body); anthropic && adjustment != "" && applyAdjustment(body, adjustment) { + pending = append(pending, adjustment) + noteAdjustment(&entry, adjustment) + continue + } release(e) return early(e) } + up.deadline, up.firstByteDeadline = overall, firstByteDeadline + up.idleTimeout = time.Duration(executionConfig.IdleTimeoutSeconds) * time.Second if !strings.Contains(strings.ToLower(up.Headers.Get("Content-Type")), "text/event-stream") { s.closeUpstream(up.StreamID) e = fail(502, "upstream returned non-SSE content for a streaming request") diff --git a/internal/bridge/observability.go b/internal/bridge/observability.go index 49a9382..e25f411 100644 --- a/internal/bridge/observability.go +++ b/internal/bridge/observability.go @@ -40,6 +40,7 @@ type LogEntry struct { ProviderSource string `json:"provider_source"` ReasoningFrom string `json:"reasoning_from"` ReasoningTo string `json:"reasoning_to"` + Adjustments []string `json:"adjustments,omitempty"` StreamEnd string `json:"stream_end"` UpstreamSkipped bool `json:"upstream_skipped"` RetryAt *time.Time `json:"retry_at,omitempty"` diff --git a/internal/bridge/service.go b/internal/bridge/service.go index 1f11d45..6fe9093 100644 --- a/internal/bridge/service.go +++ b/internal/bridge/service.go @@ -52,6 +52,7 @@ type Service struct { probes map[string]bool logins map[string]*loginSession expectedRegistration map[string]time.Time + anthropicLimits anthropicLimits } func NewService() *Service { @@ -432,6 +433,7 @@ func (s *Service) saveConfig(c Config) error { s.nextCompaction = time.Time{} s.compactStatisticsLocked(time.Now().UTC()) if c.BaseURL != old.BaseURL { + s.anthropicLimits.reset() s.usage = map[string]*usageCache{} s.accounts = map[string]string{} s.cooldown = map[string]time.Time{} diff --git a/internal/bridge/types.go b/internal/bridge/types.go index c310672..91c7ede 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -12,7 +12,7 @@ import ( "time" ) -const Version = "0.2.13" +const Version = "0.2.14" const PluginID = "commandcodebridge" const Provider = "commandcode" @@ -136,7 +136,7 @@ type Config struct { } func defaultConfig() Config { - return Config{DataDir: "plugins/commandcodebridge-data", BaseURL: "https://api.commandcode.ai", Models: []Model{}, TimeoutSeconds: 600, LogRetention: 1000, MaxResponseBytes: 16 << 20, UsageRefreshSeconds: 60} + return Config{DataDir: "plugins/commandcodebridge-data", BaseURL: "https://api.commandcode.ai", Models: []Model{}, TimeoutSeconds: 1800, LogRetention: 1000, MaxResponseBytes: 16 << 20, UsageRefreshSeconds: 60} } var efforts = map[string]bool{"unset": true, "none": true, "minimal": true, "low": true, "medium": true, "high": true, "xhigh": true, "max": true, "ultra": true} diff --git a/internal/bridge/ui/console.js b/internal/bridge/ui/console.js index 392c372..baf214b 100644 --- a/internal/bridge/ui/console.js +++ b/internal/bridge/ui/console.js @@ -834,6 +834,14 @@ function logHTML(x) { date = x.time ? new Date(x.time) : null; return `时间${esc(date ? date.toLocaleTimeString('zh-CN', { hour12: false }) : '未知')}${esc(date ? date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '未知')}模型${esc(x.model)}${esc(x.upstream_model || '—')}${status}${esc(x.status)} · ${x.stream ? '流式' : '非流式'}凭据 / 渠道${esc(x.credential || '未知凭据')}${esc(provider)}${latencyHTML('首字', x.ttft_ms, local, 1500)}${latencyHTML('耗时', x.duration_ms, local, 10000)}输入 / 输出 tokens${usage ? fmt(x.prompt_tokens) + ' / ' + fmt(x.completion_tokens) : '—'}缓存 ${usage ? fmt(x.cached_tokens) : '—'}›`; } +const adjustmentLabels = { + 'thinking_off_unsupported:adaptive_low': '模型不支持关闭思考,已改为自适应低强度', + 'forced_tool_choice_unsupported:auto': '模型不支持强制工具选择,已改为 auto', +}; +function logAdjustments(x) { + const items = Array.isArray(x.adjustments) ? x.adjustments : []; + return items.length ? items.map((item) => adjustmentLabels[item] || item).join(';') : '无'; +} function logDetailHTML(x) { const { local, status, tone, provider, usage, reasoning, speed } = logPresentation(x); const route = [ @@ -845,6 +853,7 @@ function logDetailHTML(x) { ['客户端 / 上游协议', (x.client_protocol || '未知') + ' / ' + (x.upstream_protocol || '未知')], ['ZDR 请求', x.zdr_requested ? '已请求,保留策略待官方核实' : '未请求'], ['思考映射', reasoning], + ['兼容调整', logAdjustments(x)], ['响应状态', x.status], ['流终态', x.stream_end || '—'], ['重试时间', x.retry_at ? when(x.retry_at) : '—'], diff --git a/internal/bridge/ui/index.html b/internal/bridge/ui/index.html index f6760af..dc678e4 100644 --- a/internal/bridge/ui/index.html +++ b/internal/bridge/ui/index.html @@ -44,7 +44,7 @@ 设置请求策略、数据保留与运行环境。保存设置 -请求策略保存后即时生效请求超时(秒)首字节超时(秒,0 为关闭)流空闲超时(秒,0 为关闭)用量刷新间隔(秒)响应大小上限(MiB)仅使用零数据保留路由首字节超时从请求开始计算,流空闲超时限制上游两段数据之间的间隔;启用时取 5–1800 秒,均不超过请求超时。没有可用路由时请求将失败;实际保留策略仍待官方核实。 +请求策略保存后即时生效请求超时(秒)首字节超时(秒,0 为关闭)流空闲超时(秒,0 为关闭)用量刷新间隔(秒)响应大小上限(MiB)仅使用零数据保留路由请求超时默认 1800 秒;长推理可能超过 600 秒(实测 grok-4.7 高等级 658 秒),设得过低会被截断为 504。首字节超时从请求开始计算,流空闲超时限制上游两段数据之间的间隔;启用时取 5–1800 秒,均不超过请求超时。没有可用路由时请求将失败;实际保留策略仍待官方核实。 数据保留保留日志条数统计保留天数0 为永久保留最近七个完整 UTC 日保留分钟统计,更早按日汇总。设置自动过期会删除更早数据,之后调大无法找回。 CPAMP 原生面板默认探测同机运行实例和 ~/cpa-manager-plus。首次启用需重启一次 Manager Server,后续原路径更新只需刷新浏览器。高级路径CPAMP 安装目录配置文件路径面板文件路径检查和安装将先保存这些路径。检查生效状态安装原生面板 diff --git a/registry.json b/registry.json index 6d05533..608c92c 100644 --- a/registry.json +++ b/registry.json @@ -6,7 +6,7 @@ "name": "CommandCodeBridge", "description": "CommandCode 官方 Provider API:多凭据、模型映射、套餐用量和请求统计。", "author": "CommandCodeBridge contributors", - "version": "0.2.13", + "version": "0.2.14", "repository": "https://github.com/Gaoshou101/CommandCodeBridge", "homepage": "https://github.com/Gaoshou101/CommandCodeBridge", "license": "MIT", diff --git a/tests/commandcode_live.py b/tests/commandcode_live.py index f7afc5d..f6374cb 100644 --- a/tests/commandcode_live.py +++ b/tests/commandcode_live.py @@ -2,7 +2,11 @@ --dry-run prints the request budget without credentials or network requests. The report contains no prompts, response content, signatures or account IDs. +Each generated request records the compatibility adjustments the bridge made +(for example forced tool choice falling back to auto on models that reject +it); unexpected adjustments fail the run. A failed run names the last check. """ +from collections import Counter import argparse import base64 import json @@ -16,6 +20,14 @@ from cpa_e2e import ROOT from cpa_harness import API, CPAHost +ADJUST_THINKING_OFF = "thinking_off_unsupported:adaptive_low" +ADJUST_FORCED_TOOL = "forced_tool_choice_unsupported:auto" +KNOWN_ADJUSTMENTS = {ADJUST_THINKING_OFF, ADJUST_FORCED_TOOL} +# Adaptive-thinking models do not think about trivial prompts such as +# "Reply only OK."; this short problem reliably needs reasoning. +REASONING_PROMPT = ("Count the ordered pairs of positive integers (a, b) with a <= 60 and b <= 60 " + "such that a^2 + b^2 is divisible by 13. Answer with the number only.") + def image_fixture(): def chunk(kind, data): @@ -26,9 +38,9 @@ def chunk(kind, data): def generation_plan(args): - if not 1 <= args.max_requests <= 20 or not 32 <= args.max_tokens <= 8192: - raise ValueError("Request/token limits are outside the supported bounds") - if any((args.image, args.thinking)) and not args.messages_model: + if not 1 <= args.max_requests <= 20 or not 32 <= args.max_tokens <= 8192 or not 10 <= args.request_timeout <= 1800: + raise ValueError("Request/token/timeout limits are outside the supported bounds") + if any((args.image, args.thinking, args.thinking_off)) and not args.messages_model: raise ValueError("Image/thinking checks require --messages-model") if args.thinking and args.max_tokens < 2048: raise ValueError("Thinking requires an explicit --max-tokens of at least 2048") @@ -45,6 +57,8 @@ def generation_plan(args): cases += [("Messages image", args.messages_model, 1)] if args.thinking: cases += [("thinking signature replay", args.messages_model, 2)] + if args.thinking_off: + cases += [("thinking off", args.messages_model, 1)] count = sum(case[2] for case in cases) if args.generate and (not count or count > args.max_requests): raise ValueError("Selected cases exceed --max-requests or no model was selected") @@ -52,6 +66,7 @@ def generation_plan(args): "generation_enabled": args.generate, "planned_requests": count if args.generate else 0, "maximum_output_tokens": count * args.max_tokens if args.generate else 0, "max_requests": args.max_requests, "max_tokens_per_request": args.max_tokens, + "request_timeout_seconds": args.request_timeout, "zdr_requested": args.zdr, "retention_policy": "unverified"} @@ -71,7 +86,7 @@ def run(args): if not isinstance(key, str) or not key.strip(): raise ValueError("Credential file has no API key") proxy = urllib.request.getproxies().get("https") if args.system_proxy else None - with CPAHost(base_url="https://api.commandcode.ai", timeout=120, proxy_url=proxy) as host: + with CPAHost(base_url="https://api.commandcode.ai", timeout=args.request_timeout, proxy_url=proxy) as host: cid = host.credential(key, "live verification account") host.call(API + "/config", {"zdr": args.zdr}, method="PUT") if not args.skip_usage: @@ -93,12 +108,13 @@ def run(args): aliases = {m["upstream_id"]: m["id"] for m in selected} count = 0 - def call(check, upstream, path, body, stream=False): + def call(check, upstream, path, body, stream=False, suffix="", allowed=()): nonlocal count assert count < args.max_requests count += 1 - result = host.call(path, {**body, "model": aliases[upstream], "stream": stream}, - management=False, stream=stream) + report["last_check"] = check + result = host.call(path, {**body, "model": aliases[upstream] + suffix, "stream": stream}, + management=False, stream=stream, timeout=args.request_timeout + 30) if stream: assert ("message_stop" if path.endswith("messages") else "response.completed" if path.endswith("responses") else "[DONE]") in result @@ -108,8 +124,12 @@ def call(check, upstream, path, body, stream=False): assert result["status"] == "completed" else: assert result["choices"][0]["message"] + # Logs are newest first; streamed requests are logged when the stream ends. + host.wait(lambda: len(host.logs()) >= count, "request log") + adjustments = host.logs()[0].get("adjustments") or [] + assert set(adjustments) <= set(allowed), "unexpected compatibility adjustment" model = next(m for m in selected if m["upstream_id"] == upstream) - report["results"].append({"check": check, "model": upstream, "status": "PASS", + report["results"].append({"check": check, "model": upstream, "status": "PASS", "adjustments": adjustments, "client_protocol": path, "upstream_protocol": "messages" if "/messages" in model["supported_endpoints"] and "/chat/completions" not in model["supported_endpoints"] else "chat-completions", "stream": stream, "zdr_requested": args.zdr, "upstream_accepted": True, "retention_policy": "unverified"}) @@ -141,7 +161,8 @@ def call(check, upstream, path, body, stream=False): first = {**base, "messages": [{"role": "user", "content": "Call read_file with path a.txt."}], "tools": [{"name": "read_file", "description": "Read the synthetic a.txt fixture", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}], "tool_choice": {"type": "tool", "name": "read_file"}} - result = call("Messages tool call", args.messages_model, "/v1/messages", first) + # Models that reject forced tool choice are retried with auto by the bridge. + result = call("Messages tool call", args.messages_model, "/v1/messages", first, allowed=(ADJUST_FORCED_TOOL,)) tc = next(block for block in result["content"] if block["type"] == "tool_use") assert tc["name"] == "read_file" and tc["input"].get("path") == "a.txt" call("Messages tool result", args.messages_model, "/v1/messages", {**first, "tool_choice": {"type": "auto"}, @@ -153,11 +174,19 @@ def call(check, upstream, path, body, stream=False): {"type": "text", "text": "Describe this image briefly."}]}]}) if args.thinking: thinking = {"type": "enabled", "budget_tokens": 1024} if args.thinking_mode == "budget" else {"type": "adaptive"} - first = {**base, "thinking": thinking} + question = [{"role": "user", "content": REASONING_PROMPT}] + first = {**base, "messages": question, "thinking": thinking} + if args.thinking_mode == "adaptive": + first["output_config"] = {"effort": "high"} result = call("thinking signature", args.messages_model, "/v1/messages", first) - assert any(b["type"] == "thinking" and b.get("signature") for b in result["content"]) + assert any(b["type"] == "thinking" and b.get("signature") for b in result["content"]), "no signed thinking block" call("thinking signature replay", args.messages_model, "/v1/messages", {**first, - "messages": [*prompt, {"role": "assistant", "content": result["content"]}, {"role": "user", "content": "Reply only OK again."}]}) + "messages": [*question, {"role": "assistant", "content": result["content"]}, {"role": "user", "content": "Reply only OK."}]}) + if args.thinking_off: + # model(none) asks to disable thinking; models that cannot are retried + # with adaptive low effort and the substitution is logged. + result = call("thinking off", args.messages_model, "/v1/messages", base, suffix="(none)", allowed=(ADJUST_THINKING_OFF,)) + report["results"][-1]["thinking_blocks"] = sum(b["type"] == "thinking" for b in result["content"]) assert count == plan["planned_requests"] host.wait(lambda: host.call(API + "/statistics")["totals"]["requests"] == count, "live accounting") stats = host.call(API + "/statistics")["totals"] @@ -165,6 +194,7 @@ def call(check, upstream, path, body, stream=False): logs = host.logs() assert len(logs) == count and all(r["source"] == "cpa_request" and r["status"] == 200 and r["usage_reported"] for r in logs) assert all(r["zdr_requested"] == args.zdr and r["client_protocol"] in ("messages", "responses", "chat-completions") for r in logs) + assert all(set(r.get("adjustments") or []) <= KNOWN_ADJUSTMENTS for r in logs) if args.chat_model: assert sum(r["client_protocol"] == "responses" for r in logs) == 2 cpa_usage = [] @@ -176,7 +206,9 @@ def accounted(): assert sum(r["tokens"]["total_tokens"] for r in cpa_usage) == stats["input_tokens"] + stats["output_tokens"] for file in host.data.glob("*.json*"): assert key not in file.read_text(), "Credential persisted in runtime data" - report.update(status="PASS", requests=count, tokens=stats["input_tokens"] + stats["output_tokens"], CPA_accounting_matches=True) + report.pop("last_check", None) + report.update(status="PASS", requests=count, tokens=stats["input_tokens"] + stats["output_tokens"], CPA_accounting_matches=True, + adjustments=dict(Counter(a for r in report["results"] for a in r["adjustments"]))) return report @@ -184,13 +216,14 @@ def parser(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--credential-file", type=pathlib.Path) p.add_argument("--report", type=pathlib.Path) - for flag in ("generate", "dry-run", "skip-usage", "system-proxy", "tools", "image", "thinking", "zdr"): + for flag in ("generate", "dry-run", "skip-usage", "system-proxy", "tools", "image", "thinking", "thinking-off", "zdr"): p.add_argument("--" + flag, action="store_true") p.add_argument("--chat-model") p.add_argument("--messages-model") p.add_argument("--thinking-mode", choices=("adaptive", "budget"), default="adaptive") p.add_argument("--max-requests", type=int, default=8) p.add_argument("--max-tokens", type=int, default=128) + p.add_argument("--request-timeout", type=int, default=300, help="seconds per generated request (10-1800)") return p diff --git a/tests/console-ui.test.cjs b/tests/console-ui.test.cjs index 99c9537..2e0b6f4 100644 --- a/tests/console-ui.test.cjs +++ b/tests/console-ui.test.cjs @@ -30,6 +30,15 @@ test('request drawer escapes long errors and only renders approved metadata fiel for(const visible of ['路由与协议','时延与用量','权限拒绝','long-error','缓存写入 tokens']) assert.ok(html.includes(visible)); assert.ok(!html.includes('100')); }); +test('request drawer names compatibility adjustments and escapes unknown ones', () => { + const c = functions('function logPresentation(', 'async function loadLogs()', {esc:escape,fmt:String,when:String,latencyHTML:()=>''}); + const adjusted = c.logDetailHTML({id:'r',model:'alias(none)',status:200,reasoning_from:'none',reasoning_to:'low',adjustments:['thinking_off_unsupported:adaptive_low','forced_tool_choice_unsupported:auto','']}); + assert.ok(adjusted.includes('兼容调整模型不支持关闭思考,已改为自适应低强度;模型不支持强制工具选择,已改为 auto;<unknown>')); + assert.ok(!adjusted.includes('')); + const plain = c.logDetailHTML({id:'r',model:'alias',status:200}); + assert.ok(plain.includes('兼容调整无')); + assert.equal(c.logAdjustments({adjustments:'not-a-list'}), '无'); +}); function logs(api) { const nodes = new Map(); const c = {state:{authenticated:true,key:'one',offset:0,logs:[{id:'cached'}]},api,URLSearchParams,fmt:String,diagnosticIdentity:()=>c.state.key,logHTML:x=>x.id, diff --git a/tests/cpa_harness.py b/tests/cpa_harness.py index a9d083a..0f488ea 100644 --- a/tests/cpa_harness.py +++ b/tests/cpa_harness.py @@ -120,8 +120,8 @@ def request(self, path, body=None, method=None, management=True): self.base + path, data=None if body is None else json.dumps(body).encode(), headers=headers, method=method or ("GET" if body is None else "POST")) - def call(self, path, body=None, method=None, management=True, stream=False): - with urllib.request.urlopen(self.request(path, body, method, management), timeout=30) as response: + def call(self, path, body=None, method=None, management=True, stream=False, timeout=30): + with urllib.request.urlopen(self.request(path, body, method, management), timeout=timeout) as response: raw = response.read().decode() return raw if stream else json.loads(raw) diff --git a/tests/cpa_round2.py b/tests/cpa_round2.py index 01f955d..1295587 100644 --- a/tests/cpa_round2.py +++ b/tests/cpa_round2.py @@ -14,6 +14,10 @@ class EvidenceUpstream(MockUpstream): outcomes = {} zdr_headers = [] + # Models that reject disabled thinking and forced tool choice the way + # claude-sonnet-5-5 does through CommandCode (2026-11-08 live wording). + limited = set() + rejections = [] def do_POST(self): raw = self.rfile.read(int(self.headers["Content-Length"])) @@ -27,6 +31,15 @@ def do_POST(self): self.end_headers() self.wfile.write(json.dumps({"error": {"code": outcome[1], "message": "fixture error"}}).encode()) return + if body["model"] in self.limited and self.path.endswith("/messages"): + message = None + if (body.get("thinking") or {}).get("type") == "disabled": + message = 'To turn thinking off on this model, send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}.' + elif (body.get("tool_choice") or {}).get("type") in ("tool", "any"): + message = "tool_choice type is not supported for this model" + if message: + self.rejections.append(message) + return self.send_json({"type": "error", "error": {"type": "invalid_request_error", "message": message}}, 400) self.rfile = io.BytesIO(raw) super().do_POST() @@ -101,13 +114,31 @@ def run(): args.credential_file.write_text(json.dumps({"api_key": "fixture-live-runner-key"})) with unittest.mock.patch.object(commandcode_live, "CPAHost", side_effect=lambda **_: CPAHost(EvidenceUpstream)): report = commandcode_live.run(args) - assert report["status"] == "PASS" and report["requests"] == 13 + assert report["status"] == "PASS" and report["requests"] == 13 and report["adjustments"] == {} assert len(EvidenceUpstream.zdr_headers) == 13 assert all(any(k.lower() == "x-cmd-zdr" and v == "1" for k, v in h.items()) for h in EvidenceUpstream.zdr_headers) print(json.dumps({"check": "bounded live runner against mock", "status": "PASS", "requests": 13, "protocols": ["Chat", "Responses", "Messages"], "tools": True, "image_transport": True, "signature_replay": True, "zdr_header": True, "real_requests": 0}), flush=True) + # A model that rejects disabled thinking and forced tool choice: each request + # is retried once with the nearest accepted shape, logged, and counted once. + EvidenceUpstream.limited = {"mock-claude"} + EvidenceUpstream.rejections = [] + args = commandcode_live.parser().parse_args(["--generate", "--messages-model", "mock-claude", "--tools", "--thinking-off", + "--max-requests", "5", "--max-tokens", "256"]) + with tempfile.TemporaryDirectory() as folder: + args.credential_file = pathlib.Path(folder) / "fixture.json" + args.credential_file.write_text(json.dumps({"api_key": "fixture-live-runner-key"})) + with unittest.mock.patch.object(commandcode_live, "CPAHost", side_effect=lambda **_: CPAHost(EvidenceUpstream)): + report = commandcode_live.run(args) + EvidenceUpstream.limited = set() + assert report["status"] == "PASS" and report["requests"] == 5, report + assert report["adjustments"] == {"forced_tool_choice_unsupported:auto": 1, "thinking_off_unsupported:adaptive_low": 1}, report + assert len(EvidenceUpstream.rejections) == 2 + print(json.dumps({"check": "model limit fallbacks through CPA", "status": "PASS", "requests": 5, + "upstream_rejections_retried": 2, "real_requests": 0}), flush=True) + if __name__ == "__main__": run()
请求策略、数据保留与运行环境。
首字节超时从请求开始计算,流空闲超时限制上游两段数据之间的间隔;启用时取 5–1800 秒,均不超过请求超时。
没有可用路由时请求将失败;实际保留策略仍待官方核实。
请求超时默认 1800 秒;长推理可能超过 600 秒(实测 grok-4.7 高等级 658 秒),设得过低会被截断为 504。首字节超时从请求开始计算,流空闲超时限制上游两段数据之间的间隔;启用时取 5–1800 秒,均不超过请求超时。
最近七个完整 UTC 日保留分钟统计,更早按日汇总。设置自动过期会删除更早数据,之后调大无法找回。
默认探测同机运行实例和 ~/cpa-manager-plus。首次启用需重启一次 Manager Server,后续原路径更新只需刷新浏览器。
检查和安装将先保存这些路径。