package anthropic import ( "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) func TestCachingMinTokens(t *testing.T) { cases := []struct { model string want int }{ {"claude-sonnet-4-6", 1024}, {"claude-haiku-4-5-20251001", 4096}, {"claude-opus-4-6", 1024}, {"unknown-model", 4096}, // 保守值 } for _, c := range cases { got := cachingMinTokens(c.model) if got != c.want { t.Errorf("cachingMinTokens(%q) = %d, want %d", c.model, got, c.want) } } } func TestEstimateTokens(t *testing.T) { cases := []struct { text string want int }{ {"", 0}, {"abcd", 1}, // 4 bytes = 1 token {"abcdefgh", 2}, // 8 bytes = 2 tokens {strings.Repeat("x", 4096), 1024}, // 4096 bytes = 1024 tokens {strings.Repeat("x", 4200), 1050}, // 4200 bytes = 1050 tokens } for _, c := range cases { got := estimateTokens(c.text) if got != c.want { t.Errorf("estimateTokens(len=%d) = %d, want %d", len(c.text), got, c.want) } } } func TestModelSupportsThinking(t *testing.T) { cases := []struct { model string want bool }{ {"claude-sonnet-4-6", true}, {"claude-opus-4-6", true}, {"claude-haiku-4-5-20251001", true}, {"unknown-model", false}, } for _, c := range cases { got := modelSupportsThinking(c.model) if got != c.want { t.Errorf("modelSupportsThinking(%q) = %v, want %v", c.model, got, c.want) } } } func TestStripFences(t *testing.T) { cases := []struct { name string input string want string }{ { name: "no fence", input: `{"key": "value"}`, want: `{"key": "value"}`, }, { name: "json fence with newline", input: "```json\n{\"key\": \"value\"}\n```", want: `{"key": "value"}`, }, { name: "plain fence with newline", input: "```\n{\"key\": \"value\"}\n```", want: `{"key": "value"}`, }, { name: "json fence without newline after backticks", input: "```json{\"key\": \"value\"}```", want: `{"key": "value"}`, }, { name: "plain fence without newline", input: "```{\"key\": \"value\"}```", want: `{"key": "value"}`, }, { name: "leading and trailing whitespace", input: " ```json\n{\"k\":1}\n``` ", want: `{"k":1}`, }, { name: "empty string", input: "", want: "", }, { name: "only whitespace", input: " ", want: "", }, { name: "multiline json in fence", input: "```json\n{\n \"a\": 1,\n \"b\": 2\n}\n```", want: "{\n \"a\": 1,\n \"b\": 2\n}", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := stripFences(c.input) if got != c.want { t.Errorf("stripFences(%q) = %q, want %q", c.input, got, c.want) } }) } } // TestBuildRequest_AutoCaching 验证引擎自动缓存行为: // 系统提示超过阈值时自动打 cache_control + beta header(无需消费者声明). func TestBuildRequest_AutoCaching(t *testing.T) { p := New(Config{APIKey: "test-key"}) // ~1050 tokens(4200 bytes / 4),超过 claude-sonnet-4-6 的 1024 阈值 longSystem := strings.Repeat("x", 4200) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, System: longSystem, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // Beta.PromptCaching 必须为 true(引擎自动决策,system 超过阈值) if apiReq.Beta == nil || !apiReq.Beta.PromptCaching { t.Error("expected Beta.PromptCaching = true when system exceeds threshold (auto-caching)") } } // TestBuildRequest_AutoCaching_BelowThreshold 验证系统提示不足阈值时不打缓存. func TestBuildRequest_AutoCaching_BelowThreshold(t *testing.T) { p := New(Config{APIKey: "test-key"}) // ~100 tokens(400 bytes / 4),低于 claude-sonnet-4-6 的 1024 阈值 shortSystem := strings.Repeat("x", 400) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, System: shortSystem, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // 低于阈值,引擎自动不打缓存 if apiReq.Beta != nil && apiReq.Beta.PromptCaching { t.Error("expected Beta.PromptCaching = false when system is below threshold (auto-caching)") } } // TestBuildRequest_NeedsThinking 验证 NeedsThinking per-request 行为: // 支持 thinking 的模型自动注入默认 budget. func TestBuildRequest_NeedsThinking(t *testing.T) { p := New(Config{APIKey: "test-key"}) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, NeedsThinking: true, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("think about this")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // 应该注入默认 thinking budget if apiReq.Thinking == nil { t.Fatal("expected Thinking to be set when NeedsThinking=true and model supports thinking") } if apiReq.Thinking.BudgetTokens != defaultThinkingBudget { t.Errorf("expected BudgetTokens = %d, got %d", defaultThinkingBudget, apiReq.Thinking.BudgetTokens) } if apiReq.Thinking.Type != "enabled" { t.Errorf("expected Thinking.Type = %q, got %q", "enabled", apiReq.Thinking.Type) } } // TestBuildRequest_NeedsThinking_UnsupportedModel 验证不支持 thinking 的模型 silently skip. func TestBuildRequest_NeedsThinking_UnsupportedModel(t *testing.T) { p := New(Config{APIKey: "test-key"}) req := &flyto.Request{ Model: "unknown-model-no-thinking", MaxTokens: 100, NeedsThinking: true, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // 未知模型不支持 thinking,应 silently skip if apiReq.Thinking != nil { t.Error("expected Thinking = nil for unknown model with NeedsThinking=true") } } // TestBuildRequest_ConfigThinkingBudgetTakesPrecedence 验证 Config.ThinkingBudget 优先于 NeedsThinking. func TestBuildRequest_ConfigThinkingBudgetTakesPrecedence(t *testing.T) { p := New(Config{APIKey: "test-key", ThinkingBudget: 5000}) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, NeedsThinking: true, // Config 已设置,NeedsThinking 不应覆盖 budget Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } if apiReq.Thinking == nil { t.Fatal("expected Thinking to be set") } // Config.ThinkingBudget=5000 应该被使用,而不是 defaultThinkingBudget=8000 if apiReq.Thinking.BudgetTokens != 5000 { t.Errorf("expected BudgetTokens = 5000 (from Config), got %d", apiReq.Thinking.BudgetTokens) } } // TestBuildRequest_EnableCachingGlobal 验证 Config.EnableCaching 全局开关(跳过阈值检查). func TestBuildRequest_EnableCachingGlobal(t *testing.T) { p := New(Config{APIKey: "test-key", EnableCaching: true}) // 系统提示很短,但 EnableCaching=true 全局开关不检查阈值 req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, System: "short system", Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } if apiReq.Beta == nil || !apiReq.Beta.PromptCaching { t.Error("expected Beta.PromptCaching = true when Config.EnableCaching=true (global, no threshold check)") } } // ============================================================================ // data-driven-capabilities RFC PR1.1 - max_tools 路径测试(4 个 case) // ============================================================================ // TestBuildRequest_CapabilitiesMaxToolsExhaustive 验证 registry 注入的 MaxTools + // Exhaustive=true 时,客户端硬拒超出工具数量的请求. // // 这是 RFC §11 验收测试 1 的实现. func TestBuildRequest_CapabilitiesMaxToolsExhaustive(t *testing.T) { p := New(Config{APIKey: "test-key"}) // 故意把 MaxTools 设极小(5) + Exhaustive=true 触发硬拒 caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", MaxTools: 5, MaxToolsExhaustive: true, } tools := make([]flyto.Tool, 10) // 超过 5 for i := range tools { tools[i] = flyto.Tool{Name: "tool_x", Description: "x", InputSchema: []byte(`{"type":"object"}`)} } req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, Tools: tools, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: caps, } _, err := p.buildRequest(req) if err == nil { t.Fatal("expected error: 10 tools > MaxTools=5 with Exhaustive=true") } if !strings.Contains(err.Error(), "exceeds") { t.Errorf("error should mention 'exceeds', got: %v", err) } } // TestBuildRequest_CapabilitiesMaxToolsNotExhaustive 验证 Exhaustive=false 时软处理(不硬拒). // // 反向场景:probe 测到 N 但未触顶,registry 写 MaxTools=5 但 Exhaustive=false. // 即使发了 10 个工具,buildRequest 也不报错 - 让 API 自行决定接受或拒绝. // 这是 RFC §4.4/§5 "已知下界 vs 真上限" 区分的核心保证. func TestBuildRequest_CapabilitiesMaxToolsNotExhaustive(t *testing.T) { p := New(Config{APIKey: "test-key"}) caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", MaxTools: 5, MaxToolsExhaustive: false, // 已知下界,不是上限 } tools := make([]flyto.Tool, 10) for i := range tools { tools[i] = flyto.Tool{Name: "tool_x", Description: "x", InputSchema: []byte(`{"type":"object"}`)} } req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, Tools: tools, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: caps, } _, err := p.buildRequest(req) if err != nil { t.Errorf("Exhaustive=false 时不应硬拒,got error: %v", err) } } // TestBuildRequest_NoCapabilitiesFallsBackToConst 验证 Capabilities=nil 时降级到包内 modelMaxTools. // // 这是向后兼容的核心保证:engine 没注入 capabilities 时(mock/单元测试/老代码路径), // provider 行为完全等同于现状,使用包内静态表的 modelMaxTools(). func TestBuildRequest_NoCapabilitiesFallsBackToConst(t *testing.T) { p := New(Config{APIKey: "test-key"}) // 不注入 Capabilities,Anthropic 静态表 MaxTools=0(无限制) tools := make([]flyto.Tool, 50) // 即使 50 个工具也应通过 for i := range tools { tools[i] = flyto.Tool{Name: "tool_x", Description: "x", InputSchema: []byte(`{"type":"object"}`)} } req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, Tools: tools, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: nil, // 故意不注入 } _, err := p.buildRequest(req) if err != nil { t.Errorf("Capabilities=nil 应降级到包内 modelMaxTools=0(无限制),got error: %v", err) } } // ============================================================================ // data-driven-capabilities RFC PR1.2 - thinking 路径 + 双开关协议测试 // ============================================================================ // TestResolveThinkingSupport_RegistryOverridesConst 验证 registry 注入的 // SupportsThinking 覆盖包内 modelSupportsThinking 静态表. func TestResolveThinkingSupport_RegistryOverridesConst(t *testing.T) { // 包内静态表里所有 claude-* 模型 SupportsThinking=true // 故意设 registry SupportsThinking=false,期望 resolveThinkingSupport 返回 false caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsThinking: false, } req := &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: caps, } if got := resolveThinkingSupport(req); got != false { t.Errorf("registry 注入 false 应覆盖包内表的 true,got %v", got) } } // TestResolveThinkingSupport_NilCapabilitiesFallsBack 验证 Capabilities=nil 时降级到包内表. func TestResolveThinkingSupport_NilCapabilitiesFallsBack(t *testing.T) { req := &flyto.Request{ Model: "claude-sonnet-4-6", // 包内表里支持 thinking Capabilities: nil, } if got := resolveThinkingSupport(req); got != true { t.Errorf("Capabilities=nil 应降级到 modelSupportsThinking()=true,got %v", got) } } // TestBuildRequest_ThinkingRespectsRegistry 验证 buildRequest 用 registry SupportsThinking // 决定是否注入 thinking budget. // // 场景:静态表说支持,registry 说不支持 → 不应注入 thinking. func TestBuildRequest_ThinkingRespectsRegistry(t *testing.T) { p := New(Config{APIKey: "test-key"}) // 没设 ThinkingBudget // registry 说不支持 caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsThinking: false, } req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, NeedsThinking: true, // 用户要 thinking Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: caps, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } if apiReq.Thinking != nil { t.Errorf("registry SupportsThinking=false 时不应注入 thinking,got %+v", apiReq.Thinking) } } // TestDetectFeatureWarnings_ThinkingWantTrueCanFalse 验证 want×can 的核心 // silent disable 场景被检测出来. // // 这是 RFC §4.4 双开关协议的关键测试 - 用户主动 opt-in 但模型不支持时, // 必须发 WarningEvent 让消费者知道,而不是 silent. func TestDetectFeatureWarnings_ThinkingWantTrueCanFalse(t *testing.T) { p := New(Config{APIKey: "test-key", ThinkingBudget: 8000}) // 用户全局开了 thinking caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsThinking: false, // 但模型不支持 } req := &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: caps, } warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("expected 1 warning,got %d: %+v", len(warnings), warnings) } if warnings[0].Code != "feature_unsupported" { t.Errorf("expected Code=feature_unsupported,got %q", warnings[0].Code) } if !strings.Contains(warnings[0].Message, "thinking") { t.Errorf("expected message about thinking,got %q", warnings[0].Message) } } // TestDetectFeatureWarnings_ThinkingPerRequest 验证 NeedsThinking per-request 路径 // 也能触发 silent disable warning(不只是 Config 全局开关). func TestDetectFeatureWarnings_ThinkingPerRequest(t *testing.T) { p := New(Config{APIKey: "test-key"}) // Config 没开 thinking caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsThinking: false, } req := &flyto.Request{ Model: "claude-sonnet-4-6", NeedsThinking: true, // 但 per-request 开了 Capabilities: caps, } warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Errorf("per-request NeedsThinking=true + can=false 应触发 warning,got %d", len(warnings)) } } // TestDetectFeatureWarnings_NoMismatch 验证 want=false 或 can=true 时无 warning. func TestDetectFeatureWarnings_NoMismatch(t *testing.T) { p := New(Config{APIKey: "test-key"}) cases := []struct { name string cfg Config req *flyto.Request }{ { name: "want=false can=true (用户没开,模型支持)", cfg: Config{APIKey: "k"}, req: &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: &flyto.ModelInfo{ID: "claude-sonnet-4-6", SupportsThinking: true}, }, }, { name: "want=false can=false (都没开)", cfg: Config{APIKey: "k"}, req: &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: &flyto.ModelInfo{ID: "claude-sonnet-4-6", SupportsThinking: false}, }, }, { name: "want=true can=true (都开了,正常工作)", cfg: Config{APIKey: "k", ThinkingBudget: 8000}, req: &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: &flyto.ModelInfo{ID: "claude-sonnet-4-6", SupportsThinking: true}, }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { pp := New(c.cfg) _ = p warnings := pp.detectFeatureWarnings(c.req) if len(warnings) != 0 { t.Errorf("不应有 warning,got %d: %+v", len(warnings), warnings) } }) } } // ============================================================================ // data-driven-capabilities RFC PR1.3 - caching 路径 + 双开关协议测试 // ============================================================================ // TestResolveCachingSupport_RegistryOverridesDefault 验证 registry 注入的 // SupportsCaching=false 覆盖兜底的 true. func TestResolveCachingSupport_RegistryOverridesDefault(t *testing.T) { req := &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, }, } if got := resolveCachingSupport(req); got != false { t.Errorf("registry false 应覆盖兜底 true,got %v", got) } } // TestResolveCachingSupport_NilCapabilitiesAssumesTrue 验证 nil 兜底返回 true(向后兼容). func TestResolveCachingSupport_NilCapabilitiesAssumesTrue(t *testing.T) { req := &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: nil, } if got := resolveCachingSupport(req); got != true { t.Errorf("Capabilities=nil 应兜底为 true,got %v", got) } } // TestResolveCachingMinTokens_RegistryOverridesPackageTable 验证 registry 注入的 // CachingMinTokens 覆盖包内 cachingMinTokens() 表. func TestResolveCachingMinTokens_RegistryOverridesPackageTable(t *testing.T) { req := &flyto.Request{ Model: "claude-sonnet-4-6", // 包内表返回 1024 Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", CachingMinTokens: 9999, // registry 显式覆盖 }, } if got := resolveCachingMinTokens(req); got != 9999 { t.Errorf("registry 9999 应覆盖包内 1024,got %d", got) } } // TestResolveCachingMinTokens_RegistryZeroFallsBack 验证 registry 注入的 0 视为"未知", // 降级到包内表(0 哨兵语义). func TestResolveCachingMinTokens_RegistryZeroFallsBack(t *testing.T) { req := &flyto.Request{ Model: "claude-sonnet-4-6", // 包内表返回 1024 Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", CachingMinTokens: 0, // 哨兵 = 未知 }, } if got := resolveCachingMinTokens(req); got != 1024 { t.Errorf("registry 0 应降级到包内表 1024,got %d", got) } } // TestBuildRequest_CachingSkippedWhenSupportsCachingFalse 验证 registry 说不支持时, // 即使 system 长到超过阈值也不注入 cache_control. func TestBuildRequest_CachingSkippedWhenSupportsCachingFalse(t *testing.T) { p := New(Config{APIKey: "test-key"}) // 故意构造 4500 字符的 system,远超 1024 token 阈值 longSystem := strings.Repeat("0123456789", 450) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, System: longSystem, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, // 模型不支持 }, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // 不应注入 PromptCaching beta header if apiReq.Beta != nil && apiReq.Beta.PromptCaching { t.Error("registry SupportsCaching=false 时不应启用 PromptCaching beta") } } // TestBuildRequest_CachingSystemBlocksSkippedWhenUnsupported 验证 SystemBlocks 中 // 显式 CacheScope 也会被 supportsCaching=false 跳过. func TestBuildRequest_CachingSystemBlocksSkippedWhenUnsupported(t *testing.T) { p := New(Config{APIKey: "test-key"}) req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, SystemBlocks: []flyto.SystemBlock{ {Text: "block1", CacheScope: "session"}, // 用户显式标记 }, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, }, } apiReq, err := p.buildRequest(req) if err != nil { t.Fatalf("buildRequest failed: %v", err) } // 即使用户显式 CacheScope,supportsCaching=false 也应跳过 cache_control if apiReq.Beta != nil && apiReq.Beta.PromptCaching { t.Error("CacheScope + SupportsCaching=false 时不应启用 PromptCaching") } } // TestDetectFeatureWarnings_CachingConfigGlobal 验证 Config.EnableCaching=true × Can=false // 触发 caching warning. func TestDetectFeatureWarnings_CachingConfigGlobal(t *testing.T) { p := New(Config{APIKey: "test-key", EnableCaching: true}) req := &flyto.Request{ Model: "claude-sonnet-4-6", Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, }, } warnings := p.detectFeatureWarnings(req) // 应有 1 条 caching warning var cachingWarnings int for _, w := range warnings { if strings.Contains(w.Message, "caching") { cachingWarnings++ } } if cachingWarnings != 1 { t.Errorf("expected 1 caching warning,got %d (all warnings: %+v)", cachingWarnings, warnings) } } // TestDetectFeatureWarnings_CachingSystemBlocksScope 验证 SystemBlocks 显式 CacheScope × // Can=false 触发 caching warning(per-block 入口). func TestDetectFeatureWarnings_CachingSystemBlocksScope(t *testing.T) { p := New(Config{APIKey: "test-key"}) // Config 没开 caching req := &flyto.Request{ Model: "claude-sonnet-4-6", SystemBlocks: []flyto.SystemBlock{ {Text: "b1", CacheScope: "session"}, }, Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, }, } warnings := p.detectFeatureWarnings(req) var cachingWarnings int for _, w := range warnings { if strings.Contains(w.Message, "caching") { cachingWarnings++ } } if cachingWarnings != 1 { t.Errorf("SystemBlocks 显式 CacheScope 应触发 caching warning,got %d", cachingWarnings) } } // TestDetectFeatureWarnings_CachingAutoThresholdNoWarning 验证自动阈值不发 warning. // // 关键反向场景:用户没主动开 caching,只是传了一个长 system.即使模型不支持, // 也不发 warning - 因为这是引擎的自动优化策略,不是用户期望落空. // "warning 反映用户期望落空" 的语义边界在这里测试. func TestDetectFeatureWarnings_CachingAutoThresholdNoWarning(t *testing.T) { p := New(Config{APIKey: "test-key"}) // 没开 EnableCaching req := &flyto.Request{ Model: "claude-sonnet-4-6", System: strings.Repeat("x", 9999), // 长 system 触发自动阈值 Capabilities: &flyto.ModelInfo{ ID: "claude-sonnet-4-6", SupportsCaching: false, }, } warnings := p.detectFeatureWarnings(req) var cachingWarnings int for _, w := range warnings { if strings.Contains(w.Message, "caching") { cachingWarnings++ } } if cachingWarnings != 0 { t.Errorf("自动阈值路径不应触发 caching warning,got %d (warnings: %+v)", cachingWarnings, warnings) } } // TestPrependWarnings_FlushesWarningsBeforeDownstream 验证 prependWarnings 把 warnings // 放在下游事件之前,且最终下游 close 时 out 也 close. func TestPrependWarnings_FlushesWarningsBeforeDownstream(t *testing.T) { downstream := make(chan flyto.Event, 2) downstream <- &flyto.TextDeltaEvent{Text: "downstream-1"} downstream <- &flyto.TextDeltaEvent{Text: "downstream-2"} close(downstream) warnings := []*flyto.WarningEvent{ {Code: "feature_unsupported", Message: "test warning 1"}, {Code: "feature_unsupported", Message: "test warning 2"}, } out := prependWarnings(downstream, warnings) var got []flyto.Event for evt := range out { got = append(got, evt) } if len(got) != 4 { t.Fatalf("expected 4 events (2 warnings + 2 downstream),got %d", len(got)) } // 前 2 个必须是 warnings if _, ok := got[0].(*flyto.WarningEvent); !ok { t.Errorf("got[0] should be *WarningEvent,got %T", got[0]) } if _, ok := got[1].(*flyto.WarningEvent); !ok { t.Errorf("got[1] should be *WarningEvent,got %T", got[1]) } // 后 2 个是 downstream if _, ok := got[2].(*flyto.TextDeltaEvent); !ok { t.Errorf("got[2] should be *TextDeltaEvent,got %T", got[2]) } if _, ok := got[3].(*flyto.TextDeltaEvent); !ok { t.Errorf("got[3] should be *TextDeltaEvent,got %T", got[3]) } } // TestBuildRequest_CapabilitiesOverridesConst 验证即使包内 const 是 0(无限制), // 注入的 Capabilities.MaxTools > 0 + Exhaustive=true 也应当生效. // // 这是数据驱动的关键证明:registry 数据**真的**覆盖了包内常量. // 没有这个测试,我们无法保证 RFC §3 描述的"硬编码常量被 registry 覆盖"在 anthropic 路径生效. func TestBuildRequest_CapabilitiesOverridesConst(t *testing.T) { p := New(Config{APIKey: "test-key"}) // modelMaxTools("claude-sonnet-4-6") 当前返回 0(无限制) // 但 registry 注入 MaxTools=3 + Exhaustive=true,期望 3 个生效 caps := &flyto.ModelInfo{ ID: "claude-sonnet-4-6", MaxTools: 3, MaxToolsExhaustive: true, } tools := make([]flyto.Tool, 4) // 超过 3 for i := range tools { tools[i] = flyto.Tool{Name: "tool_x", Description: "x", InputSchema: []byte(`{"type":"object"}`)} } req := &flyto.Request{ Model: "claude-sonnet-4-6", MaxTokens: 100, Tools: tools, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("hi")}}}, Capabilities: caps, } _, err := p.buildRequest(req) if err == nil { t.Fatal("registry 注入的 MaxTools=3 应覆盖包内常量 0,4>3 应报错") } }