// Package anthropic 实现 Anthropic Messages API 的 ModelProvider. // // 这是 flyto.ModelProvider 的 Anthropic 官方实现. // 内部复用 internal/api/client.go(已有完整的 SSE 解析,流守卫,错误分类). // 对外仅暴露 New(Config) 工厂函数,所有 Anthropic 特有能力(Thinking,Caching,Beta flags) // 在 Config 中配置,引擎层完全不感知. // // 升华改进(ELEVATED): 早期实现将 Anthropic API 调用硬编码在 engine.go 中, // 无法替换 provider.我们将其提取为独立的工厂方法, // 引擎只持有 flyto.ModelProvider 接口,不依赖 Anthropic 特有的任何类型. // 替代方案:<保留 engine.go 中的直接调用> - 否决:无法支持 OpenAI/MiniMax 等其他 provider, // 也无法在测试中 mock. package anthropic import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/internal/transport" "git.flytoex.net/yuanwei/flyto-agent/internal/transport/retry" "git.flytoex.net/yuanwei/flyto-agent/internal/wire" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" "git.flytoex.net/yuanwei/flyto-agent/pkg/providers/shared" ) // Config 是 Anthropic provider 的配置. // // 所有 Anthropic 特有功能(Thinking,Caching,Beta flags)在此配置, // flyto.Engine 通过 flyto.ModelProvider 接口调用,完全不感知这些字段. type Config struct { // APIKey 是 Anthropic API 密钥(ANTHROPIC_API_KEY 环境变量的值). APIKey string // BaseURL 覆盖 API 端点(默认 https://api.anthropic.com). // 用于测试环境,私有化部署或代理. BaseURL string // ThinkingBudget 启用 Extended Thinking 并设置 budget_tokens. // 0 = 禁用(默认).建议值:8000-16000. ThinkingBudget int // EnableCaching 启用 Prompt Caching(需要模型支持,如 claude-sonnet-4-6). // 开启后系统提示词会自动加 cache_control 标记. EnableCaching bool // HTTPClient 注入自定义 HTTP 客户端(代理,超时,重试等). // nil = 使用默认 http.Client (带 Timeout 字段配置的 ResponseHeaderTimeout). // 非 nil 时 consumer 完全接管超时责任,下面的 Timeout 字段被忽略. HTTPClient *http.Client // Timeout 限制"从请求发出到收到响应首字节"的时间. // // 通过 http.Transport.ResponseHeaderTimeout 实现,**不影响** SSE 流式响应的后续 // body 读取 -- 长流式回复 (2-5 分钟) 可以正常读完.这是 LLM provider 的正确 // 超时语义: 捕捉服务端死等,放行长流式输出. // // 精妙之处(CLEVER): 不要误用 http.Client.Timeout -- 那会把 SSE 流砍死. // 详见 internal/transport/client.go DefaultResponseHeaderTimeout 注释. // // 0 = 使用 defaultTimeout (60s,适合 Anthropic 云端). // 仅当 HTTPClient 为 nil 时生效; 提供自定义 HTTPClient 时此字段被忽略. Timeout time.Duration // BearerAuth 切换鉴权方式为 "Authorization: Bearer "(默认 "x-api-key: "). // 用于对接兼容 Anthropic 协议但使用 Bearer 鉴权的代理端点(如 AWS/Azure 转发层). BearerAuth bool // ModelOverrides 覆盖静态模型表(用于厂商发布新模型但我们尚未更新代码时). // key = 模型 ID,value = 模型信息.nil = 使用内置静态表. ModelOverrides []flyto.ModelInfo } // GoString 实现 fmt.GoStringer,防止 %#v 打印时泄露 APIKey. // 升华改进(ELEVATED): 默认 %#v 会把结构体所有字段打印出来,APIKey 明文进日志必成安全事故. // 替代方案:<自定义 log/slog.LogValuer 接口> - 当前 GoString 覆盖 fmt.Sprintf("%#v") 足矣. func (c Config) GoString() string { return shared.GoStringWithMaskedKey("Config", c.APIKey) } // Provider 是 Anthropic ModelProvider 实现. type Provider struct { cfg Config client *api.Client } // defaultTimeout 是 Config.Timeout == 0 且 Config.HTTPClient == nil 时的 ResponseHeaderTimeout 兜底值. // 60s 对云端 Anthropic 合理: LLM 通常在 < 10s 内开始响应,60s 给网络抖动 / 排队 / 冷启动充足余量. // 流式响应的 body 读取阶段不受此值影响 (见 Config.Timeout 注释). // // 反向思维: 为什么不读环境变量或用 package var 允许运行时覆盖? // - 运行时覆盖鼓励进程级 tuning,违反 Provider 必填原则 (见 memory project_architecture_decisions.md). // 每个 provider 实例应通过 Config.Timeout 显式配置,不靠 global state. const defaultTimeout = 60 * time.Second // defaultThinkingBudget 是 NeedsThinking=true 但 Config.ThinkingBudget=0 时的默认预算. // 8000 tokens 覆盖大多数推理任务,且低于所有模型的 MaxOutputTokens(≥8000). // 精妙之处(CLEVER): 不用模型的 MaxOutputTokens 作默认值-- // 那会消耗大量 token 并显著提高费用;8000 是实用性与成本的平衡点. const defaultThinkingBudget = 8000 // cachingMinTokens 返回指定模型触发 prompt caching 的最低 system token 数. // 从静态表查找;未知模型保守返回 4096(Haiku 的较高阈值),避免漏打 cache_control. func cachingMinTokens(model string) int { for _, m := range anthropicModels { if m.ID == model { return m.CachingMinTokens } } return 4096 // 未知模型保守估计 } // estimateTokens 粗估文本的 token 数(每 4 个字节约 1 token,英文偏多中文偏少). // 精妙之处(CLEVER): 粗估而非精确--精确需要调用 tokenizer API 或本地模型. // 目的是判断是否超过缓存阈值,保守估计(宁可错估多了也要打缓存). // 替代方案:<接入 tiktoken-go 做精确计算> - 否决: // 引入外部依赖(违反零外部依赖原则),且网络调用增加延迟. func estimateTokens(text string) int { return len(text) / 4 } // modelSupportsThinking 返回指定模型是否支持扩展思考(从静态表查找). func modelSupportsThinking(model string) bool { for _, m := range anthropicModels { if m.ID == model { return m.SupportsThinking } } return false } // resolveThinkingSupport 返回当前请求模型是否支持 thinking,registry 优先 + 包内表兜底. // // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.2 - 单一真相源. // buildRequest 决定是否注入 thinking budget,Stream 决定是否发 want×can warning, // 两处都用这个 helper,确保两处对 "supports" 的判断永远一致. // // 替代方案:<两处分别 inline 写 registry-first 逻辑> - 否决: // 任何修复(比如未来加 SupportsThinkingBudget 字段)都得改两处,容易漂移. func resolveThinkingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsThinking } return modelSupportsThinking(req.Model) } // resolveCachingSupport 返回当前请求模型是否支持 caching,registry 优先 + 兜底假设支持. // // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.3 - caching 单一真相源. // 兜底语义:registry 没有数据时 (req.Capabilities == nil),包内 cachingMinTokens 表 // 隐含假设所有 anthropic 模型都支持 caching(返回的阈值非零),所以兜底返回 true. // // 替代方案:<兜底返回 false> - 否决:会让所有现有调用(没接 registry 的)突然失去缓存, // 是破坏性的零回归违规.registry 路径 vs 兜底路径行为必须等同于 PR1.3 之前. func resolveCachingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsCaching } // 兜底:anthropic 包内静态表所有模型都支持 caching(2026-04), // 真实情况通过 cachingMinTokens 表的非零值隐式表达. return true } // resolveCachingMinTokens 返回触发 caching 的最低 system token 数,registry 优先 + 包内表兜底. // // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.3 - caching 阈值单一真相源. // 字段语义(沿用 flyto.ModelInfo.CachingMinTokens int 类型,选项 A): // // 0 = 未知/未探测,降级到包内 cachingMinTokens(model) 表 // 非 0 = 显式阈值,直接使用 // // 反向思维(2026-04-11 MiniMax 反向揭示的隐藏假设): // "0 不是合法 caching 阈值" - 即如果未来某 provider 文档说"caching from 0 token", // 即"cache everything",这个 0 哨兵会和"未知"语义冲突.当前 anthropic/minimax/openai // 文档无一例,所以保留 int + 0 哨兵.如果未来真出现这种 provider,**先把 ModelInfo.CachingMinTokens // 改成 *int 三态再支持那个 provider**(这是 trip-wire 注释的目的). // // 替代方案 B:<*int 三态(nil/*0/*N)> - 否决:侵入式改 7 个 provider 的所有静态表 + 测试 fixture, // 而当前任何 provider 都不需要区分"未探测" vs "显式无门槛".YAGNI. func resolveCachingMinTokens(req *flyto.Request) int { if req.Capabilities != nil && req.Capabilities.CachingMinTokens > 0 { return req.Capabilities.CachingMinTokens } return cachingMinTokens(req.Model) } // modelMaxTools 返回指定模型的工具数量上限(0 = 未知/不限制). // 当前 Anthropic 普通模式无明确上限,strict 模式 ≤ 20 由消费者控制. // 静态表 MaxTools 均为 0,此函数为未来 strict 模式集成预留扩展点. func modelMaxTools(model string) int { for _, m := range anthropicModels { if m.ID == model { return m.MaxTools } } return 0 } // detectFeatureWarnings 检测 want × can 不一致的场景,返回 WarningEvent 列表. // // 升华改进(ELEVATED): data-driven-capabilities RFC §4.4 双开关协议. // 用户主动 opt-in 某能力(Config.EnableXxx 或 req.NeedsXxx)但模型不支持时, // 该能力会被 silently disabled.这种场景之前完全无感知,用户以为开了实际没开. // 现在生成 WarningEvent 在流开头 emit,让消费者(engine/observer/TUI)能看到 silent disable. // // 当前 PR1.2 仅检测 thinking;PR1.3 会加 caching 检测. // // 反向思维:为什么不在 buildRequest 内部检测? // 否决--buildRequest 已经被 12 个调用点(主要是 tests)使用,改 signature 添加 warnings // 返回值会污染所有调用点.Stream 是唯一会消费 warnings 的入口,在这里检测最内聚. func (p *Provider) detectFeatureWarnings(req *flyto.Request) []*flyto.WarningEvent { var warnings []*flyto.WarningEvent // thinking want × can 检测 wantsThinking := p.cfg.ThinkingBudget > 0 || req.NeedsThinking if wantsThinking && !resolveThinkingSupport(req) { warnings = append(warnings, &flyto.WarningEvent{ Code: "feature_unsupported", Message: "Config.ThinkingBudget/NeedsThinking 已设置但模型 " + req.Model + " 不支持 thinking,thinking 已被忽略", Detail: "model=" + req.Model + " feature=thinking", }) } // Extended thinking enforces temperature == 1.0 and top_p in [0.95, 1.0] // server-side. When caller-set values violate this, Stream() overrides // them; emit a WarningEvent so the consumer sees the override rather // than the value being silently changed. // // Extended thinking 服务端硬约束 temperature == 1.0 与 top_p 在 // [0.95, 1.0]. 调用方传的值违反约束时 Stream() 会覆盖, 这里发 // WarningEvent 让消费者看到覆盖发生而非静默改值. thinkingActive := p.cfg.ThinkingBudget > 0 || (req.NeedsThinking && resolveThinkingSupport(req)) if thinkingActive { if req.Temperature != nil && *req.Temperature != 1.0 { warnings = append(warnings, &flyto.WarningEvent{ Code: "parameter_overridden", Message: fmt.Sprintf("anthropic extended thinking requires temperature=1.0; caller value %v overridden", *req.Temperature), Detail: fmt.Sprintf("model=%s feature=thinking parameter=temperature value=%v override=1.0", req.Model, *req.Temperature), }) } if req.TopP != nil && *req.TopP < 0.95 { warnings = append(warnings, &flyto.WarningEvent{ Code: "parameter_overridden", Message: fmt.Sprintf("anthropic extended thinking requires top_p in [0.95, 1.0]; caller value %v overridden to 1.0", *req.TopP), Detail: fmt.Sprintf("model=%s feature=thinking parameter=top_p value=%v override=1.0", req.Model, *req.TopP), }) } } // caching want × can 检测 // RFC §4.4 双开关协议在 caching 路径上有 3 种 want 信号,但只有 2 种是"用户显式意图": // ✅ Config.EnableCaching=true 全局开关 (用户主动设置) // ✅ SystemBlocks 中有非空 CacheScope (用户显式标记某 block 需要缓存) // ❌ system 长度 ≥ 阈值 (引擎自动优化,非用户意图,不发 warning) // // 为什么自动阈值不发 warning?消费者可能在不感知 caching 的情况下传 system, // 引擎自动决定 "system 长就缓存一下" 是优化策略而非用户期望. // 这种场景不发 warning 才符合 "warning 反映用户期望落空" 的语义. supportsCaching := resolveCachingSupport(req) if !supportsCaching { var triggers []string if p.cfg.EnableCaching { triggers = append(triggers, "Config.EnableCaching=true") } for _, b := range req.SystemBlocks { if b.CacheScope != "" { triggers = append(triggers, "SystemBlocks.CacheScope") break // 一次提到即可,不重复 } } if len(triggers) > 0 { warnings = append(warnings, &flyto.WarningEvent{ Code: "feature_unsupported", Message: "用户主动开启了 caching (" + strings.Join(triggers, ", ") + "),但模型 " + req.Model + " 不支持 caching,cache_control 已被跳过", Detail: "model=" + req.Model + " feature=caching triggers=" + strings.Join(triggers, ","), }) } } return warnings } // prependWarnings 在下游 channel 前面 prepend 一组 WarningEvent. // // 精妙之处(CLEVER): 用 buffered channel(容量 = warnings 数)避免发送方阻塞, // 然后单 goroutine forward 下游事件.下游 close 时 out 也 close,保持 channel 语义一致. // // 性能:零 warnings 时调用方应直接返回原 channel,不调用本函数(避免无意义的 goroutine + buffer). func prependWarnings(downstream <-chan flyto.Event, warnings []*flyto.WarningEvent) <-chan flyto.Event { out := make(chan flyto.Event, len(warnings)) for _, w := range warnings { out <- w } go func() { defer close(out) for evt := range downstream { out <- evt } }() return out } // New 创建 Anthropic ModelProvider. // // 精妙之处(CLEVER): 返回 *Provider 而非 flyto.ModelProvider 接口-- // 调用方可以将 *Provider 用于类型断言以获取 Anthropic 特有方法(未来扩展), // 同时也满足 flyto.ModelProvider 接口约束(由编译器静态验证). // 如果返回接口,就把这个可能性堵死了. func New(cfg Config) *Provider { if cfg.BaseURL == "" { cfg.BaseURL = "https://api.anthropic.com" } var opts []api.ClientOption // Anthropic 特有配置:消息路径,API 版本,错误分类器,重试策略 opts = append(opts, api.WithMessagePath("/v1/messages"), api.WithAPIVersion("2023-06-01"), api.WithClassifier(&api.AnthropicClassifier{Hinter: &api.DefaultHinter{}}), api.WithRetryPolicy(retry.NewAnthropicRetryPolicy(retry.AnthropicRetryOpts{})), ) // 溢出修正器 overflowHandler := retry.DefaultOverflowHandler() opts = append(opts, api.WithOverflowHandler(overflowHandler)) // 超时配置: HTTPClient 和 Timeout 二选一,不混用. // 精妙之处(CLEVER): 二选一比"两个字段叠加"简单 -- 消费者注入 HTTPClient 就是 // 完全接管超时策略,我们不去 mutate 他的 Transport (那是 rude).消费者不注入, // 我们用 Timeout 字段 (或 defaultTimeout 兜底) 配置 ResponseHeaderTimeout. if cfg.HTTPClient != nil { opts = append(opts, api.WithHTTPClient(cfg.HTTPClient)) } else { timeout := cfg.Timeout if timeout == 0 { timeout = defaultTimeout } opts = append(opts, api.WithResponseHeaderTimeout(timeout)) } if cfg.BearerAuth { opts = append(opts, api.WithBearerAuth()) } return &Provider{ cfg: cfg, client: api.NewClient(cfg.APIKey, cfg.BaseURL, opts...), } } // 编译期接口合规检查--确保 *Provider 实现 flyto.ModelProvider. var _ flyto.ModelProvider = (*Provider)(nil) // Name 返回 provider 标识. func (p *Provider) Name() string { return "anthropic" } // Stream 向 Anthropic API 发起流式请求,返回 flyto.Event channel. // // 升华改进(ELEVATED): 早期方案需要 convertStream goroutine 将 api.StreamEvent 转为 flyto.Event-- // 因为 client.CreateMessageStream 曾返回 api.StreamEvent(Anthropic 专有中间类型). // 现在 wire.ParseAnthropicStream + StreamGuard 直接产出 flyto.Event, // provider 层零转换成本,一行返回即可. // 替代方案:<保留 convertStream + goroutine> - 否决:多余的 goroutine 增加内存开销和调试难度. func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) { apiReq, err := p.buildRequest(req) if err != nil { return nil, fmt.Errorf("anthropic: build request: %w", err) } applyThinkingSamplingConstraints(apiReq) // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.2 - 双开关协议(want × can). // 检测用户 opt-in (Config.ThinkingBudget>0 或 req.NeedsThinking) 但模型不支持的场景, // 收集为 WarningEvent 在流开头 prepend,让消费者(engine/observer/TUI)看到 silent disable. // 此前这种场景是 silent - 用户以为开了 thinking 但实际没开,无感知. warnings := p.detectFeatureWarnings(req) ch, err := p.client.CreateMessageStream(ctx, apiReq) if err != nil { return nil, err } // 升华改进(ELEVATED): Anthropic API 不支持 response_format(会返回 400 "Extra inputs not permitted"). // 当消费者请求 JSON 输出时,我们在 TextEvent 层做 markdown fence 剥离-- // Haiku 4.5 实测在明确要求"no fences"后仍会包裹 ```json...```, // 导致消费者的 json.Unmarshal 失败.在 provider 层透明处理,消费者零感知. // 替代方案:<在消费者层手动 strip> - 否决:每个消费者都要重复实现,维护成本高. // 替代方案:<用 Anthropic Beta json_schema> - 否决:2026-04 实测 Beta 标识不合法. if req.ResponseFormat != nil && req.ResponseFormat.Type == "json_object" { ch = wrapFenceStrip(ch) } // 精妙之处(CLEVER): warnings prepend 走 channel,不引入新的 observer 接口. // engine 主循环已经在消费这个 channel 并转发到 observer/TUI,WarningEvent 会自然被处理. // 替代方案:<给 provider Config 加 Observer 字段> - 否决:多一条通信通道,违反 channel 单一职责. // 性能优化:零 warnings 时直接返回 ch,无 forwarding goroutine 开销. if len(warnings) > 0 { return prependWarnings(ch, warnings), nil } return ch, nil } // applyThinkingSamplingConstraints overrides apiReq.Temperature / apiReq.TopP // to comply with Anthropic extended-thinking server-side constraints // (temperature must equal 1.0; top_p must lie in [0.95, 1.0]). The // override target is 1.0 -- the safe end of the top_p window which // effectively disables nucleus filtering. No-op when extended thinking // is not enabled on this request (apiReq.Thinking == nil). // // applyThinkingSamplingConstraints 按 Anthropic extended thinking 服务端 // 硬约束 (temperature 必须 1.0; top_p 必须在 [0.95, 1.0]) 覆盖 apiReq 的 // Temperature / TopP. 覆盖目标 1.0 是 top_p 窗口的安全端 (实际禁用 // nucleus 过滤). 未启用 thinking (apiReq.Thinking == nil) 时无操作. func applyThinkingSamplingConstraints(apiReq *api.MessageRequest) { if apiReq.Thinking == nil { return } if apiReq.Temperature != nil && *apiReq.Temperature != 1.0 { one := 1.0 apiReq.Temperature = &one } if apiReq.TopP != nil && *apiReq.TopP < 0.95 { one := 1.0 apiReq.TopP = &one } } // wrapFenceStrip 对 TextEvent 做 markdown 代码块剥离,其他事件透传. // // 精妙之处(CLEVER): 只处理 TextEvent(完整文本),不处理 TextDeltaEvent(增量)-- // TextDeltaEvent 是流式展示用的,增量块可能把 fence 分开(``` 在第一块,json 在第二块). // 剥离只在 TextEvent(message 结束时的完整文本)上做,保证逻辑正确. // TextDeltaEvent 透传不变,streaming UI 体验不受影响(代价:UI 可能短暂看到反引号). func wrapFenceStrip(src <-chan flyto.Event) <-chan flyto.Event { dst := make(chan flyto.Event, 32) go func() { defer close(dst) for evt := range src { if te, ok := evt.(*flyto.TextEvent); ok { dst <- &flyto.TextEvent{Text: stripFences(te.Text)} } else { dst <- evt } } }() return dst } // stripFences 剥离 markdown 代码块前后缀(```json\n...\n``` 或 ```\n...\n```). func stripFences(s string) string { s = strings.TrimSpace(s) for _, pfx := range []string{"```json\n", "```\n", "```json", "```"} { if strings.HasPrefix(s, pfx) { s = strings.TrimPrefix(s, pfx) break } } for _, sfx := range []string{"\n```", "```"} { if strings.HasSuffix(s, sfx) { s = strings.TrimSuffix(s, sfx) break } } return strings.TrimSpace(s) } // buildRequest 将 flyto.Request 转换为 api.MessageRequest. func (p *Provider) buildRequest(req *flyto.Request) (*api.MessageRequest, error) { // CAP-7: Tool 数量上限检查. // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.1 - registry 优先 + 包内表兜底. // 优先级链: // 1. req.Capabilities (registry 注入,含 probe 实测的 MaxTools + Exhaustive) // 2. 包内 modelMaxTools(req.Model) 静态表(向后兼容兜底) // // Exhaustive 语义: // - true = registry 数据是确认上限,超出即客户端硬拒(防止 API 模糊 400) // - false = registry 数据是已知下界,软处理(不在客户端拒,让 API 自行处理) // - 来自 modelMaxTools() 的兜底值同样按"已知下界"对待(保守默认) // // 反向思维:为什么不让 Exhaustive=false 时也客户端拒? // 否决--会误伤 probe 测试覆盖不全的模型.Exhaustive=false 意味着"我们知道至少 N, // 真上限可能更高",客户端硬拒反而比 API 更严格,丢失 model 的真实能力. maxTools := modelMaxTools(req.Model) exhaustive := false // 兜底值视为软处理 if req.Capabilities != nil && req.Capabilities.MaxTools > 0 { maxTools = req.Capabilities.MaxTools exhaustive = req.Capabilities.MaxToolsExhaustive } if maxTools > 0 && exhaustive && len(req.Tools) > maxTools { // 硬拒:仅在 Exhaustive=true 且超过上限时报错 if err := wire.CheckToolCount(req.Tools, maxTools); err != nil { return nil, fmt.Errorf("anthropic: %w", err) } } // 注:Exhaustive=false 路径不主动检查 - 让 API 决定是否接受 N 个工具, // 即使 N 超过我们已知的下界.这是"data-driven-capabilities" §4.4 的"软处理"约定. apiReq := &api.MessageRequest{ Model: req.Model, MaxTokens: req.MaxTokens, Stream: true, // Temperature / TopP 直接透传; Stream 在 buildRequest 之后会按 // extended-thinking 互斥规则 override (avoid server 400). // // Temperature / TopP 直接透传; Stream 在 buildRequest 之后会按 // extended-thinking 互斥规则覆盖 (避免服务端 400). Temperature: req.Temperature, TopP: req.TopP, } // Extended Thinking:Config 级别(ThinkingBudget)或 per-request(NeedsThinking)均可触发. // 升华改进(ELEVATED): 传统做法只能在 Config 构造时全局开启 thinking-- // 同一 provider 实例无法按请求动态切换(需要为 thinking/非 thinking 请求分别创建 provider). // NeedsThinking 字段让同一实例支持 per-request 动态控制,无需多实例. // 替代方案:<保留 Config 级别 ThinkingBudget,每次 thinking 请求创建新 provider> - // 否决:对象创建代价不高但语义混乱,且无法在运行时根据 message 内容决定是否 thinking. budget := p.cfg.ThinkingBudget if budget == 0 && req.NeedsThinking && resolveThinkingSupport(req) { // 精妙之处(CLEVER): 只有模型确认支持时才注入默认 budget-- // 若 registry 未注入且静态表中未收录此模型(返回 false),silently skip 而非报错, // 给新发布模型保留"先用起来再更新表"的窗口期. // data-driven-capabilities RFC PR1.2: registry 优先 + 包内表兜底,逻辑在 resolveThinkingSupport. budget = defaultThinkingBudget } if budget > 0 { apiReq.Thinking = &api.ThinkingConfig{ Type: "enabled", BudgetTokens: budget, } // 升华改进(ELEVATED): Claude 4.x+ thinking 已是 GA 功能,不再需要 beta header. // 旧的 extended-thinking-2025-01-24 在 Claude 4.x 上会触发 400 "Unexpected value". // 替代方案:<按模型 ID 前缀判断是否需要 beta header> - 否决: // 版本判断逻辑脆弱,Anthropic 随时可能更新,不如统一不发 beta header. // Claude 3.7 Sonnet(唯一需要 beta 的 thinking 模型)不在当前支持列表中. } // NeedsCaching 已移除,引擎自动检查 system 长度决策是否缓存-- // 消费者无需声明,只要 system prompt 超过模型阈值,自动打 cache_control. // 升华改进(ELEVATED): data-driven-capabilities RFC PR1.3 - registry 优先 + 包内表兜底. // wantCache 优先级: // Config.EnableCaching=true (全局,跳过阈值检查) // > 自动阈值检查 (per-request,system token 数 >= resolveCachingMinTokens) // AND 模型支持 caching (resolveCachingSupport,registry 优先) // // 关键修复:之前不检查模型是否支持就盲目走自动阈值,现在加 resolveCachingSupport 守卫, // 防止给一个 SupportsCaching=false 的模型注入 cache_control 导致 silent disable // (cache_control 被 API 静默忽略,用户以为缓存但实际没缓存). // // 精妙之处(CLEVER): estimateTokens 粗估(/4)而非精确 tokenize-- // 缓存打多了无成本(Anthropic 静默忽略不足阈值的 cache_control),打少了损失缓存节省. // 保守估计(宁多勿少)在这个场景是最优策略. supportsCaching := resolveCachingSupport(req) wantCache := false if supportsCaching { wantCache = p.cfg.EnableCaching || (req.System != "" && estimateTokens(req.System) >= resolveCachingMinTokens(req)) } // 系统提示词:优先使用 SystemBlocks(分段缓存),否则使用 System(单块) if len(req.SystemBlocks) > 0 { // SystemBlocks:分段缓存,每块独立 cache_control // 升华改进(ELEVATED): RFC PR1.3 - 即使用户在 SystemBlocks 显式设了 CacheScope, // 也必须先 check supportsCaching.模型不支持时跳过 cache_control 注入, // 避免 API 静默忽略 + 用户以为开了缓存的 silent disable bug. // detectFeatureWarnings 会检测这种 want=true × can=false 场景并发 WarningEvent. apiBlocks := make([]api.SystemContentBlock, 0, len(req.SystemBlocks)) for _, b := range req.SystemBlocks { block := api.SystemContentBlock{Type: "text", Text: b.Text} if b.CacheScope != "" && supportsCaching { block.CacheControl = &api.CacheControl{Type: "ephemeral"} wantCache = true // SystemBlocks 有 CacheScope 意味着需要缓存 } apiBlocks = append(apiBlocks, block) } apiReq.SetSystemBlocks(apiBlocks) } else if req.System != "" { if wantCache { apiReq.SetSystemBlocks([]api.SystemContentBlock{ { Type: "text", Text: req.System, CacheControl: &api.CacheControl{Type: "ephemeral"}, }, }) } else { apiReq.SetSystemString(req.System) } } // FastMode:影响 max_tokens 默认值 + beta.FastMode if req.FastMode && apiReq.MaxTokens <= 16384 { apiReq.MaxTokens = 8192 } // Prompt Caching beta header(与 wantCache 同步,确保 cache_control 和 header 一致) if wantCache { if apiReq.Beta == nil { apiReq.Beta = &api.BetaFeatures{} } apiReq.Beta.PromptCaching = true } // FastMode beta header if req.FastMode { if apiReq.Beta == nil { apiReq.Beta = &api.BetaFeatures{} } apiReq.Beta.FastMode = true } // Effort beta header if req.Effort != "" { if apiReq.Beta == nil { apiReq.Beta = &api.BetaFeatures{} } apiReq.Beta.Effort = req.Effort } // 结构化输出(JSONSchema) // 升华改进(ELEVATED): 早期路径只在 engine.go 直连路径中设置 ResponseFormat + beta header, // Provider 路径无法使用 JSONSchema.现在 Provider 统一处理. if req.ResponseFormat != nil && req.ResponseFormat.Type == "json_schema" { apiReq.ResponseFormat = &api.ResponseFormat{ Type: req.ResponseFormat.Type, JSONSchema: req.ResponseFormat.JSONSchema, } if apiReq.Beta == nil { apiReq.Beta = &api.BetaFeatures{} } apiReq.Beta.StructuredOutput = true } // 消息转换 for _, msg := range req.Messages { apiMsg, err := flytoMessageToAPI(msg) if err != nil { return nil, err } apiReq.Messages = append(apiReq.Messages, apiMsg) } // 工具转换 for i, t := range req.Tools { // CAP-6: Anthropic 不支持 minimum/maximum/multipleOf/minLength/maxLength 等约束-- // 裁剪不支持的字段,并将约束信息写入 description,确保模型仍能"读懂"约束. toolDef := api.ToolDef{ Name: t.Name, Description: t.Description, InputSchema: wire.AdaptSchema(t.InputSchema, "anthropic"), } // 精妙之处(CLEVER): 在最后一个工具上加 cache_control-- // Anthropic 推荐把稳定工具排前面,不稳定工具排后面,在最后一个稳定工具处打断点. // 我们简单实现:最后一个工具打断点,覆盖大多数场景. // 替代方案:<让调用方显式指定哪些工具打断点> - 否决:引擎层接口不应暴露 Anthropic 内部细节. if p.cfg.EnableCaching && i == len(req.Tools)-1 { toolDef.CacheControl = &api.CacheControl{Type: "ephemeral"} } apiReq.Tools = append(apiReq.Tools, toolDef) } return apiReq, nil } // flytoMessageToAPI 将单条 flyto.Message 转换为 api.RequestMessage. func flytoMessageToAPI(msg flyto.Message) (api.RequestMessage, error) { role := string(msg.Role) // 判断是否为纯文本消息(优化:单个 text block 用字符串格式,减少 JSON 体积) if len(msg.Blocks) == 1 && msg.Blocks[0].Type == flyto.BlockText { return api.NewTextMessage(role, msg.Blocks[0].Text), nil } var blocks []api.ContentBlock for _, b := range msg.Blocks { switch b.Type { case flyto.BlockText: blocks = append(blocks, api.ContentBlock{Type: "text", Text: b.Text}) case flyto.BlockToolUse: // tool_use block:模型请求调用工具(出现在 assistant 消息中) inputRaw, err := json.Marshal(b.ToolInput) if err != nil { return api.RequestMessage{}, fmt.Errorf("marshal tool input for %q: %w", b.ToolName, err) } // Anthropic API 的 tool_use block input 字段必须是 JSON object, // 但 api.ContentBlock.Input 是 map[string]any. // 我们先 Unmarshal 到 map 再传给 ContentBlock(json.Marshal 会正确序列化). var inputMap map[string]any _ = json.Unmarshal(inputRaw, &inputMap) blocks = append(blocks, api.ContentBlock{ Type: "tool_use", ID: b.ToolUseID, Name: b.ToolName, Input: inputMap, }) case flyto.BlockToolResult: // tool_result block:工具执行结果(出现在 user 消息中). // 路径 B: b.ResultBlocks 非空 (工具返图) 时走 array-form // "content":[{...},{...}] wire; 否则沿用 string "content":"...". // ContentBlock.MarshalJSON 按 ContentItems 是否为空决定 shape. // // Path B: when b.ResultBlocks is non-empty (tool returns image), // emit array-form "content":[...] wire; otherwise fall back to // string "content":"...". ContentBlock.MarshalJSON branches on // ContentItems length. if len(b.ResultBlocks) > 0 { items := make([]api.ContentBlock, 0, len(b.ResultBlocks)) for _, nb := range b.ResultBlocks { switch nb.Type { case flyto.BlockText: items = append(items, api.ContentBlock{Type: "text", Text: nb.Text}) case flyto.BlockImage: if nb.ImageSource == nil { return api.RequestMessage{}, fmt.Errorf("anthropic: nested BlockImage missing ImageSource") } items = append(items, api.ContentBlock{ Type: "image", Source: &api.ImageSource{ Type: nb.ImageSource.SourceType, MediaType: nb.ImageSource.MediaType, Data: nb.ImageSource.Data, URL: nb.ImageSource.URL, }, }) default: return api.RequestMessage{}, fmt.Errorf("anthropic: unsupported nested block type %q in tool_result", nb.Type) } } blocks = append(blocks, api.ContentBlock{ Type: "tool_result", ToolUseID: b.ToolUseID, ContentItems: items, IsError: b.IsError, }) } else { blocks = append(blocks, api.ContentBlock{ Type: "tool_result", ToolUseID: b.ToolUseID, Content: b.ResultText, IsError: b.IsError, }) } case flyto.BlockThinking: // 精妙之处(CLEVER): thinking block 回传时必须携带 signature-- // API 用 signature 验证 thinking 内容未被篡改. // ProviderMetadata 为 nil 或缺少 key 时(非本 provider 生成的块)仍然安全-- // 空 signature 在 API 会返回 400,但这只会在错误用法下发生. var sig string if b.ProviderMetadata != nil { sig = b.ProviderMetadata["thinking_signature"] } blocks = append(blocks, api.ContentBlock{ Type: "thinking", Text: b.ThinkingText, Signature: sig, }) case flyto.BlockImage: // Anthropic image block: {type:"image", source:{type:"base64"|"url", ...}}. // spec 参考 https://docs.anthropic.com/en/api/messages (2026-04 verify). // Engine 已在 input.go 和 tool_result 构造点填好 ImageSource, 此处直译. // // Anthropic image block wire shape: {type:"image",source:{type:"base64"|"url",...}}. // Spec verified 2026-04 against docs.anthropic.com/en/api/messages. Engine // fills ImageSource at input.go and tool_result sites; provider translates. if b.ImageSource == nil { return api.RequestMessage{}, fmt.Errorf("anthropic: BlockImage missing ImageSource") } src := &api.ImageSource{ Type: b.ImageSource.SourceType, MediaType: b.ImageSource.MediaType, Data: b.ImageSource.Data, URL: b.ImageSource.URL, } blocks = append(blocks, api.ContentBlock{ Type: "image", Source: src, }) default: // Unknown block types are not silently dropped -- surface as // error so callers learn they hit an unimplemented path instead // of wondering why the model never saw their data. // // 未知 Block 类型不静默丢弃 -- 显式报错, 让调用方知道踩到未实现 // 路径, 而不是纳闷"为啥模型没看到". return api.RequestMessage{}, fmt.Errorf("anthropic: unsupported block type %q", b.Type) } } return api.NewBlockMessage(role, blocks), nil } // Models 返回 Anthropic 可用模型列表(静态表). // // 历史包袱(LEGACY): Anthropic 官方没有提供公开的 /models 列表 API. // 我们维护一张静态表,通过 Config.ModelOverrides 允许运行时注入新模型. // 理想做法:等 Anthropic 开放模型列表 API 后,改为 live 拉取. // 当前条件:2026-04 尚无公开 models 端点. func (p *Provider) Models(_ context.Context) ([]flyto.ModelInfo, error) { if len(p.cfg.ModelOverrides) > 0 { return p.cfg.ModelOverrides, nil } return anthropicModels, nil } // anthropicModels 是 Anthropic 已知模型的静态表. // // 价格单位:USD / 1M tokens. // 数据来源:Anthropic 官方定价页(2026-04 更新). var anthropicModels = []flyto.ModelInfo{ { ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6", Provider: "anthropic", ContextWindow: 200_000, MaxOutputTokens: 32_000, InputPricePer1M: 5.0, OutputPricePer1M: 25.0, SupportsCaching: true, SupportsBatch: true, SupportsThinking: true, SupportsVision: true, // 官方文档:Opus 4.x 缓存阈值 1024 tokens(2026-04) // probe 实测:cr=7203 rd=7203 @ 600 reps(~7200t 系统提示),阈值 1024 在范围内 CachingMinTokens: 1024, // MaxTools=0:strict 模式 ≤20,普通模式无明确文档上限(官方文档 2026-04). // 用 0 表示"不由引擎强制检查",消费者若使用 strict 模式应自行控制 ≤20. MaxTools: 0, }, { ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", Provider: "anthropic", ContextWindow: 200_000, MaxOutputTokens: 64_000, InputPricePer1M: 3.0, OutputPricePer1M: 15.0, SupportsCaching: true, SupportsBatch: true, SupportsThinking: true, SupportsVision: true, // 官方文档:Sonnet 4.x 缓存阈值 1024 tokens(2026-04) // probe 实测:cr=3003 rd=3003 @ 250 reps(~3000t 系统提示),100 reps(~1000t)不触发 // 精妙之处(CLEVER): 100 reps = ~1000t,略低于 1024 阈值,因此 cr=0;250 reps = ~2500t 触发. // 文档值与 probe 结果完全吻合. CachingMinTokens: 1024, // MaxTools=0:strict 模式 ≤20,普通模式无明确文档上限(官方文档 2026-04). MaxTools: 0, }, { ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5", Provider: "anthropic", ContextWindow: 200_000, MaxOutputTokens: 8_000, InputPricePer1M: 0.80, OutputPricePer1M: 4.0, SupportsCaching: true, SupportsBatch: true, // 升华改进(ELEVATED): 原来标注为 false,实测直连 Anthropic + OpenRouter 均有 ThinkingDeltaEvent 输出. // Claude Haiku 4.5(2025-10 发布)已支持 Extended Thinking,与 Claude 4.x 系列一致. SupportsThinking: true, SupportsVision: true, // 官方文档:Haiku 4.5 缓存阈值 4096 tokens(2026-04) // probe 实测:250 reps(~2500t)cr=0,600 reps(~7200t)cr=7202 rd=7202 // 历史包袱(LEGACY): Claude 3 Haiku 阈值为 2048,Haiku 4.5 翻倍到 4096. // 原先 200 次重复(~2400t)探测失败的根因:低于 4096 阈值,不是 tier 限制. CachingMinTokens: 4096, // MaxTools=0:strict 模式 ≤20,普通模式无明确文档上限(官方文档 2026-04). MaxTools: 0, }, }