From dbb2fc4bb15163010363e2af214d02542ad2036f Mon Sep 17 00:00:00 2001 From: Gaoshou101 <188431084+Gaoshou101@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:42:03 +0800 Subject: [PATCH 1/2] fix: fall back on Claude model limits and raise the default timeout claude-sonnet-5-5 through CommandCode rejects thinking.type=disabled (asking for between_tools, which CommandCode's own validation rejects) and forced tool_choice (tool/any). The bridge now recognizes these upstream 400s on Messages-protocol models and retries once with the nearest accepted request: adaptive thinking at low effort, or tool_choice auto with the same tools. The change is logged in a new adjustments field and shown in the console request details; an accepted retry is remembered per model for the process and forgotten on restart or base_url change. Other errors are not retried. The default timeout_seconds rises from 600 to 1800 (grok-4.7 high effort took 658 s live). Instances with saved settings keep their stored value. commandcode_live.py: the thinking check uses a problem that needs reasoning, adds --thinking-off and --request-timeout, and records adjustments per request. cpa_round2.py emulates the sonnet-5-5 rejections through real CPA. --- CHANGELOG.md | 8 + config.example.yaml | 2 +- docs/DEPLOYMENT.md | 7 +- docs/VALIDATION.md | 12 + internal/bridge/anthropic_limits.go | 137 +++++++++++ internal/bridge/anthropic_limits_test.go | 296 +++++++++++++++++++++++ internal/bridge/executor.go | 37 ++- internal/bridge/observability.go | 1 + internal/bridge/service.go | 2 + internal/bridge/types.go | 2 +- internal/bridge/ui/console.js | 9 + internal/bridge/ui/index.html | 2 +- tests/commandcode_live.py | 61 +++-- tests/console-ui.test.cjs | 9 + tests/cpa_harness.py | 4 +- tests/cpa_round2.py | 33 ++- 16 files changed, 591 insertions(+), 31 deletions(-) create mode 100644 internal/bridge/anthropic_limits.go create mode 100644 internal/bridge/anthropic_limits_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1a398..934b2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ ## [Unreleased] +### 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` 后重新探测,重试仍失败时不记住。 + ### 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 测试改为按唯一标记切片,标记缺失时直接失败。 ## [0.2.13] - 2026-11-07 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..1a9dcc1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -199,7 +199,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 +250,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/VALIDATION.md b/docs/VALIDATION.md index f60263a..43e20bc 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,5 +1,17 @@ # 开发验证记录 +## 真实验收发现的兼容修复(2026-11-08,未发布源码) + +针对下方付费验收发现的三个问题: + +- **`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-08T08:30Z)后进行,**尚未验证**。 + ## 付费推理模型真实验收(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/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..6d773a9 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -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 ``; } +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 @@