// capability-probe - Provider 能力探测工具. // // 向各 provider/model 组合发送最小化探测请求,检测实际支持的能力, // 输出能力矩阵(Markdown 表格). // // 能力探测项: // - streaming 基础 SSE 流式响应 // - thinking 扩展思考(thinking block 出现在响应中) // - tool_use 工具调用(模型返回 tool_use block) // - structured_out 结构化 JSON 输出(响应符合 JSON schema) // - caching Prompt Cache 命中(cache_read_tokens > 0) // - schema_ref 工具 InputSchema 中 $ref 引用是否被正确解析(部分模型拒绝) // - tool_count 模型实际可处理的最大工具数量(触发拒绝时的上限) // // 使用: // // source .env && go run ./cmd/capability-probe/ package main import ( "bufio" "context" "encoding/json" "flag" "fmt" "os" "path/filepath" "sort" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/internal/transport" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" "git.flytoex.net/yuanwei/flyto-agent/pkg/providers/anthropic" "git.flytoex.net/yuanwei/flyto-agent/pkg/providers/minimax" "git.flytoex.net/yuanwei/flyto-agent/pkg/providers/openrouter" ) // --- 能力数据模型(双格式输出核心)--- // // 升华改进(ELEVATED): 早期方案只输出 Markdown 人类可读格式,✓/✗ 信息丢失了"来源"维度-- // 无法区分"实测通过","官方文档声称支持","计划测但没测","无法程序化测试". // 新版用 Source 标签为每个能力字段打上来源戳,配合 JSON 结构化输出, // 下游调用方(引擎,UI,定价计算器)可以按来源过滤,例如只信任 Source=probed 的数据. // // 反向思维:是否直接把所有 documented 数据塞进 provider.Models() 即可? // 否决--Models() 返回的是"引擎运行所需最小规格",塞入 vision/pdf/batch/strict_json // 等驱动能力会污染核心类型;probe 工具是产品外部的元数据聚合器,职责分离. // Source 标识能力数据的来源. type Source string const ( SourceProbed Source = "probed" // 实测过 SourceDocumented Source = "documented" // 官方文档/AI 分析 SourceManual Source = "manual" // 人工标注(专家知识) SourceUntested Source = "untested" // 计划测但未实现 SourceUntestable Source = "untestable" // 无法程序化测试 ) // Capability 是一个带来源标签的能力字段. // // 精妙之处(CLEVER): Value 用 any 而非泛型-- // 一个能力可能是 bool(streaming),int(context_window),float64(price), // string(note),统一成 any 让 JSON 序列化自然处理类型差异. // Evidence 是可选的原始证据(如 token 计数,错误信息,API 响应摘要), // 让下游能重现 probe 的判断过程. type Capability struct { Value any `json:"value"` Source Source `json:"source"` // Exhaustive 表示 Value 是否是穷尽测试得到的真上限. // 升华改进(ELEVATED): 原先 max_tools 这种字段写 value=128 时,下游消费者无法区分 // "测了 128 都过(其实可能更多)"和"在 129 处确认拒绝"这两种语义. // nil = 概念不适用(如 streaming/thinking 这种 bool 字段); // false = 测试未触顶,Value 是已知下界,真值可能更大; // true = 测试穷尽,Value 是确认上限. // 替代方案:<把 value 写成 null + 文本 note 描述> - 否决:文本不可结构化解析, // 未来 loader/registry/UI 等程序消费者要正则提取数字才能用,违背"数据驱动行为"原则. Exhaustive *bool `json:"exhaustive,omitempty"` Evidence map[string]any `json:"evidence,omitempty"` Note string `json:"note,omitempty"` } // ModelCapabilities 是一个 provider+model 组合的完整能力画像. // // 字段分三组: // 1. 基础规格(Context/Tokens/Price):从 provider.Models() 查,标 SourceDocumented // 2. 可实测能力(Streaming/Thinking/Tool/...):probe 结果,标 SourceProbed // 3. 文档能力(Vision/PDF/Batch/Strict/Parallel):暂不实测,标 SourceDocumented/Untested type ModelCapabilities struct { Provider string `json:"provider"` Model string `json:"model"` ContextWindow Capability `json:"context_window"` MaxOutputTokens Capability `json:"max_output_tokens"` InputPricePer1M Capability `json:"input_price_per_1m"` OutputPricePer1M Capability `json:"output_price_per_1m"` CacheReadPricePer1M Capability `json:"cache_read_price_per_1m,omitempty"` CacheWritePricePer1M Capability `json:"cache_write_price_per_1m,omitempty"` Streaming Capability `json:"streaming"` Thinking Capability `json:"thinking"` ToolUse Capability `json:"tool_use"` StructuredOut Capability `json:"structured_output"` Caching Capability `json:"caching"` SchemaRef Capability `json:"schema_ref"` MaxTools Capability `json:"max_tools"` Vision Capability `json:"vision"` PDF Capability `json:"pdf"` Batch Capability `json:"batch"` ParallelToolCalls Capability `json:"parallel_tool_calls"` StrictJSON Capability `json:"strict_json"` // SchemaFeatures 是 JSON Schema 特性的详细支持数据 (L1174). // 不纳入 isFullyProbed 7 字段 -- 补充探测数据, 已缓存 target 需 --force 重跑. SchemaFeatures map[string]Capability `json:"schema_features,omitempty"` ProbedAt string `json:"probed_at"` ProbeErrors []string `json:"probe_errors,omitempty"` } // CapabilityReport 是整个 probe 运行输出的聚合报告. type CapabilityReport struct { SchemaVersion string `json:"schema_version"` GeneratedAt string `json:"generated_at"` Models map[string]*ModelCapabilities `json:"models"` } // --- 静态 documented 数据表 --- // // 来源:官方文档 + AI 总结(2026-04). // 精妙之处(CLEVER): key 是 "provider:model",能覆盖同一模型经由不同 provider 的差异 // (如 anthropic:claude-opus-4-6 vs openrouter:anthropic/claude-opus-4.6 路由差异). // // 反向思维:是否应该把这表挪到 provider 包内? // 否决--probe 工具是"跨 provider 的元数据聚合器",静态表属于 probe 的私有知识, // 往 provider 包里塞会造成"provider 既是实现者又是 probe 的证据库"的角色混乱. // documentedInfo 是单条静态能力记录. type documentedInfo struct { Vision *bool // nil = 未覆盖 PDF *bool Batch *bool ParallelToolCalls *bool StrictJSON *bool MaxTools *int // nil = 未记录; >0 = 官方文档明确的工具数量上限 (L1175) Note string } // boolPtr 是 bool 字面量转指针的 helper. func boolPtr(b bool) *bool { return &b } // intPtr 是 int 字面量转指针的 helper (L1175: MaxTools 交叉验证). func intPtr(i int) *int { return &i } // documentedCapabilities 是"provider:model" → documented 能力的静态查询表. // // 历史包袱(LEGACY): OpenRouter 路径的 cache_control 不转发给 Anthropic 后端, // 所以 openrouter 的 anthropic/* 条目 Batch 都标 false-- // OpenRouter 本身没有 batch API,只有直连 provider 才能用 batch. var documentedCapabilities = map[string]documentedInfo{ // --- Anthropic 官方 --- "anthropic:claude-opus-4-6": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(true), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), // Anthropic 无 strict 字段,tool schema 有格式限制 Note: "官方文档 2026-04", }, "anthropic:claude-sonnet-4-6": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(true), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), Note: "官方文档 2026-04", }, "anthropic:claude-haiku-4-5-20251001": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(true), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), Note: "官方文档 2026-04", }, // --- MiniMax --- "minimax:MiniMax-M2.7": { Vision: boolPtr(true), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), // response_format=json_object 在 Anthropic 兼容端点支持 Note: "MiniMax 平台文档 2026-04", }, "minimax:MiniMax-M2.7-highspeed": { Vision: boolPtr(true), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), Note: "MiniMax-M2.7 的 highspeed 变体,能力与 M2.7 基本一致(2026-04)", }, "minimax:MiniMax-M2.5": { Vision: boolPtr(true), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), Note: "MiniMax 平台文档 2026-04", }, // --- OpenRouter 路径的 Anthropic 模型(能力受 OpenRouter 网关限制)--- "openrouter:anthropic/claude-opus-4.6": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(false), // OpenRouter 无 batch API ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), Note: "经由 OpenRouter 网关,batch 不可用", }, "openrouter:anthropic/claude-sonnet-4.6": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), Note: "经由 OpenRouter 网关,batch 不可用", }, "openrouter:anthropic/claude-haiku-4.5": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(false), Note: "经由 OpenRouter 网关,batch 不可用", }, // --- OpenRouter 路径的非 Anthropic 模型 --- "openrouter:openai/gpt-4o": { Vision: boolPtr(true), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), // OpenAI Chat API 原生 strict MaxTools: intPtr(128), // OpenAI Chat API OpenAPI spec explicit (L1175) Note: "OpenAI GPT-4o 经由 OpenRouter(2026-04)", }, "openrouter:google/gemini-2.0-flash-001": { Vision: boolPtr(true), PDF: boolPtr(true), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), Note: "Google Gemini 经由 OpenRouter(2026-04)", }, "openrouter:deepseek/deepseek-r1": { Vision: boolPtr(false), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(false), // R1 thinking 模型,tool use 能力受限 StrictJSON: boolPtr(false), Note: "DeepSeek R1 thinking 模型,tool use 不稳定(2026-04)", }, "openrouter:minimax/minimax-m2.7": { Vision: boolPtr(true), PDF: boolPtr(false), Batch: boolPtr(false), ParallelToolCalls: boolPtr(true), StrictJSON: boolPtr(true), Note: "MiniMax M2.7 经由 OpenRouter(2026-04)", }, } // lookupDocumented 查询静态表返回 documented 能力. // 未命中返回零值(所有 *bool 为 nil),调用方据此填 SourceUntested. func lookupDocumented(providerName, model string) documentedInfo { key := providerName + ":" + model return documentedCapabilities[key] } // boolCap 把 *bool 转为带来源的 Capability-- // nil → SourceUntested,非 nil → SourceDocumented. func boolCap(v *bool, note string) Capability { if v == nil { return Capability{Source: SourceUntested} } return Capability{Value: *v, Source: SourceDocumented, Note: note} } // CapabilityResult 是单个 provider+model 的能力探测结果. type CapabilityResult struct { Provider string Model string Streaming tristate Thinking tristate ToolUse tristate StructuredOut tristate Caching tristate SchemaRef tristate ToolCount int // 实际可处理工具数,0 表示未探测或探测失败 ToolCountExhaustive bool // true=Value 是确认上限;false=测试未触顶,Value 是已知下界 ToolCountNote string // 探测诊断信息 SchemaFeatures map[string]bool // L1174: schema 特性 → 是否被 API 接受 Notes []string } // tristate 是三态结果:支持/不支持/未测试. type tristate int const ( tsUnknown tristate = iota tsYes tsNo tsError ) func (t tristate) String() string { switch t { case tsYes: return "✓" case tsNo: return "✗" case tsError: return "ERR" default: return "-" } } // capToTristate 将 Capability 的 Value 转换回 tristate(从 cached JSON 重建 Markdown 矩阵用). func capToTristate(c Capability) tristate { if c.Source != SourceProbed { return tsUnknown } switch v := c.Value.(type) { case bool: if v { return tsYes } return tsNo case float64: // JSON unmarshal 把 bool 以外的数字也可能走这里 if v > 0 { return tsYes } return tsNo } return tsUnknown } // capToInt 从 Capability 提取整数值(MaxTools 等). func capToInt(c Capability) int { switch v := c.Value.(type) { case int: return v case float64: return int(v) // JSON unmarshal 数字为 float64 } return 0 } // capToExhaustive 从 Capability 提取 Exhaustive 标志. func capToExhaustive(c Capability) bool { if c.Exhaustive != nil { return *c.Exhaustive } return false } // target 是一个探测目标. type target struct { providerName string provider flyto.ModelProvider model string // thinkingProvider 是专门配置了 ThinkingBudget 的 provider 实例(thinking 探测用). // 目前 thinking 在构造时通过 Config 配置,所以需要单独实例. thinkingProvider flyto.ModelProvider // cachingClient 是直接的 api.Client,用于能正确标记 cache_control 的 Anthropic 兼容端点. // nil = 使用通用 flyto.ModelProvider 路径(无法标记 cache_control) // MiniMax Anthropic 兼容端点需要此字段才能探测 caching. cachingClient *api.Client // cachingProvider 是配置了 EnableCaching=true 的 provider 实例(OpenRouter caching 探测用). // 精妙之处(CLEVER): OpenRouter caching 探测不能复用 cachingClient(那是 api.Client 直连 Anthropic), // 也不能复用 provider(未开启 EnableCaching,系统消息格式不含 cache_control). // 单独实例隔离配置,不影响其他能力探测的正常请求. // nil = 跳过 OpenRouter 路径,直接用 probeCachingGeneric(无法主动建立缓存). cachingProvider flyto.ModelProvider } // capabilitiesJSONPath 返回 capabilities.json 的标准路径. func capabilitiesJSONPath() string { homeDir, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(homeDir, ".flyto", "capabilities", "capabilities.json") } // loadExistingReport 从 ~/.flyto/capabilities/capabilities.json 加载已有的探测报告. // 文件不存在或解析失败时返回 nil(不阻断探测流程). // // 升华改进(ELEVATED): 持久化跳过(L1169)的读取端-- // 配合 isFullyProbed 判断已有数据的完整性,避免对已探测的 target 重复消耗 API 配额. // 早期方案每次运行都全量重探,16 个 target * 7 能力 = 112 次 API 调用(~$2-5), // 多数情况下模型能力短期不变,持久化跳过可节省 90%+ 的 API 成本. func loadExistingReport() map[string]*ModelCapabilities { path := capabilitiesJSONPath() if path == "" { return nil } data, err := os.ReadFile(path) if err != nil { return nil // file not found or permission error: treat as empty } var report CapabilityReport if err := json.Unmarshal(data, &report); err != nil { fmt.Fprintf(os.Stderr, "⚠ capabilities.json 解析失败 (将全量重探): %v\n", err) return nil } return report.Models } // isFullyProbed 检查一个 ModelCapabilities 的 7 个可实测字段是否全部 Source==probed. // // 精妙之处(CLEVER): 只检查可实测字段 (Streaming/Thinking/ToolUse/StructuredOut/ // Caching/SchemaRef/MaxTools). 文档字段 (Vision/PDF/Batch 等) 和基础规格 // (ContextWindow/Price 等) 不影响判断--它们来自静态表,每次都会重新填充. // 任何一个非 probed (包括 untested / empty) 都触发重新探测整个 target, // 因为 7 个探测按顺序执行(streaming 失败会 skip 后续),单独重跑某一个不可靠. func isFullyProbed(mc *ModelCapabilities) bool { if mc == nil { return false } fields := []Source{ mc.Streaming.Source, mc.Thinking.Source, mc.ToolUse.Source, mc.StructuredOut.Source, mc.Caching.Source, mc.SchemaRef.Source, mc.MaxTools.Source, } for _, s := range fields { if s != SourceProbed { return false } } return true } func main() { // 升华改进(ELEVATED): 早期方案默认探测所有 .env 里有 key 的 provider,无法子集化. // 2026-04-11 一次误操作意外消耗了 ~$3 anthropic 配额,因为 unset 环境变量对 // loadEnvFile(".env") 无效--程序直接从文件读 key,绕过 shell 环境. // 加 --providers flag 后,可以显式 opt-in 子集,避免再发生意外消耗. // 替代方案:<让 loadEnvFile 检查 os.LookupEnv 跳过已存在的> - 否决: // 那样仍然依赖调用方记得在 .env 之外 export 所需 key,认知成本高. providersFlag := flag.String("providers", "", "comma-separated provider list to probe (anthropic,minimax,openrouter); empty = all providers with available keys") // 升华改进(ELEVATED): 加 --dry-run 是因为 2026-04-11 主进程烟测 --providers flag 时 // 用 ANTHROPIC_API_KEY="" 想清空环境变量,结果 loadEnvFile(".env") 又把 key 读回来, // 触发了真实的 anthropic 探测调用(同款误支出复发). // dry-run 让烟测彻底安全:只打印计划探测的 (provider, model) 列表然后退出,不发任何 HTTP. // 替代方案:<让 loadEnvFile 跳过已显式 export 的字段> - 否决:调用方仍可能忘记 export, // dry-run 是 zero-cost 的硬保障. dryRunFlag := flag.Bool("dry-run", false, "print planned probe targets and exit without making any API calls") // 升华改进(ELEVATED): --force 配合持久化跳过(L1169)使用-- // 默认行为:如果 capabilities.json 中某 target 的 7 个可实测字段全部 source=probed, // 则跳过该 target 的探测(省钱省时间). --force 忽略缓存,全部重跑. // 使用场景:模型升级后需要刷新能力数据,或上次探测可能有误需要覆盖. forceFlag := flag.Bool("force", false, "ignore cached capabilities.json and re-probe all targets") flag.Parse() allowed := map[string]bool{} for _, p := range strings.Split(*providersFlag, ",") { if name := strings.TrimSpace(p); name != "" { allowed[name] = true } } shouldProbe := func(name string) bool { if len(allowed) == 0 { return true // 默认行为:跑所有有 key 的 } return allowed[name] } // 从 .env 补充缺失的环境变量 loadEnvFile(".env") antKey := os.Getenv("ANTHROPIC_API_KEY") minimaxKey := os.Getenv("MINIMAX_API_KEY") orKey := os.Getenv("OPENROUTER_API_KEY") if antKey == "" && minimaxKey == "" && orKey == "" { fmt.Fprintln(os.Stderr, "未找到任何 API key,请 source .env 或设置环境变量") os.Exit(1) } // 显式校验:用户用 --providers 指定了某 provider,但对应 key 没设置 → 失败而非静默跳过 for name := range allowed { var hasKey bool switch name { case "anthropic": hasKey = antKey != "" case "minimax": hasKey = minimaxKey != "" case "openrouter": hasKey = orKey != "" default: fmt.Fprintf(os.Stderr, "未知 provider: %q (支持: anthropic, minimax, openrouter)\n", name) os.Exit(1) } if !hasKey { fmt.Fprintf(os.Stderr, "--providers 包含 %q 但对应 API key 未设置\n", name) os.Exit(1) } } var targets []target // --- Anthropic 官方 --- if antKey != "" && shouldProbe("anthropic") { antProvider := anthropic.New(anthropic.Config{APIKey: antKey}) antProviderWithThinking := anthropic.New(anthropic.Config{ APIKey: antKey, ThinkingBudget: 1024, }) // 精妙之处(CLEVER): Anthropic caching 必须在请求体内显式标记 cache_control, // 无法通过 flyto.ModelProvider 接口传递,直接复用 api.Client(标准 x-api-key auth). antCachingClient := api.NewClient(antKey, "https://api.anthropic.com", api.WithMessagePath("/v1/messages"), api.WithAPIVersion("2023-06-01"), ) for _, model := range []string{"claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"} { targets = append(targets, target{ providerName: "anthropic", provider: antProvider, model: model, thinkingProvider: antProviderWithThinking, cachingClient: antCachingClient, }) } } // --- MiniMax --- if minimaxKey != "" && shouldProbe("minimax") { // MiniMax token plan key 走 Anthropic 兼容端点(Bearer auth),中国区节点. // 端点:https://api.minimaxi.com/anthropic/v1/messages // 文档:https://platform.minimaxi.com/docs/api-reference/text-anthropic-api // // 历史包袱(LEGACY): ModeNative 使用 OpenAI 兼容 SSE 格式(api.minimax.io 全球节点), // 但 token plan key 仅在中国节点 Anthropic 兼容端点有效. // 如果未来 MiniMax 统一 key 格式,可以切换回 ModeNative + RegionGlobal. mmAnthropic := minimax.New(minimax.Config{ APIKey: minimaxKey, Mode: minimax.ModeAnthropic, Region: minimax.RegionChina, }) mmAnthropicThinking := minimax.New(minimax.Config{ APIKey: minimaxKey, Mode: minimax.ModeAnthropic, Region: minimax.RegionChina, ThinkingBudget: 1024, }) // 精妙之处(CLEVER): caching 探测需要在系统提示中添加 cache_control 标记, // 无法通过 flyto.ModelProvider 接口传递,必须直接使用 api.Client. // MiniMax Anthropic 兼容端点 = api.minimaxi.com/anthropic,Bearer auth. mmCachingClient := api.NewClient(minimaxKey, "https://api.minimaxi.com/anthropic", api.WithMessagePath("/v1/messages"), api.WithBearerAuth(), ) for _, model := range []string{"MiniMax-M2.7", "MiniMax-M2.7-highspeed"} { targets = append(targets, target{ providerName: "minimax", provider: mmAnthropic, model: model, thinkingProvider: mmAnthropicThinking, cachingClient: mmCachingClient, }) } } // --- OpenRouter(聚合网关,覆盖多家模型)--- if orKey != "" && shouldProbe("openrouter") { orProvider := openrouter.New(openrouter.Config{APIKey: orKey}) // 精妙之处(CLEVER): thinking 探测用单独的 DefaultThinking=true 实例-- // OpenRouter 统一用 reasoning.enabled=true 参数开启思考, // 再由 OpenRouter 内部翻译为各家原生格式(Anthropic thinking / o1 budget / DeepSeek R1). // DefaultThinkingTokens=2048 给足 budget,避免 thinking 被截断. orProviderThinking := openrouter.New(openrouter.Config{ APIKey: orKey, DefaultThinking: true, DefaultThinkingTokens: 2048, }) // 非 Anthropic 模型(自有 thinking 或不支持) for _, model := range []string{ "openai/gpt-4o", "google/gemini-2.0-flash-001", "deepseek/deepseek-r1", "minimax/minimax-m2.7", } { targets = append(targets, target{ providerName: "openrouter", provider: orProvider, model: model, thinkingProvider: orProviderThinking, }) } // Anthropic Claude 模型经由 OpenRouter(验证 OpenAI-compat → Anthropic 转换路径) // 精妙之处(CLEVER): OpenRouter caching 要求系统消息使用数组+cache_control 格式-- // 不能复用 orProvider(普通字符串格式),单独构建 EnableCaching=true 实例. // 系统提示 token 数需覆盖 Anthropic 最高阈值(Haiku 2048),用 200 次重复保证充足. orProviderCaching := openrouter.New(openrouter.Config{ APIKey: orKey, EnableCaching: true, }) for _, model := range []string{ "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "anthropic/claude-haiku-4.5", } { targets = append(targets, target{ providerName: "openrouter", provider: orProvider, model: model, thinkingProvider: orProviderThinking, cachingProvider: orProviderCaching, }) } } // 加载已有探测结果(持久化跳过 L1169). // // 升华改进(ELEVATED): 持久化跳过将 probe 从"全量一次性工具"进化为"增量更新工具"-- // 首次运行全量探测(16 targets * 7 abilities = 112 API calls, ~$2-5); // 后续运行仅探测新增/失败的 target,成本降至 ~$0(无新 target 时); // --force 可随时覆盖(模型升级/数据刷新). // 替代方案:<--max-age 基于时间过期> - 否决:模型能力变化由版本驱动而非时间驱动, // 过期策略无法选出合理的 TTL(1 天太短,30 天太长),不如用 --force 显式控制. existing := loadExistingReport() if existing != nil && !*forceFlag { fmt.Printf("✓ 已加载 %d 个已有探测结果 (capabilities.json)\n", len(existing)) } // dry-run:在任何 HTTP 调用之前打印计划,立即退出. // 升华改进(ELEVATED): 放在 existing 加载之后,targets 构建完成后-- // 此时我们已经知道哪些 provider 通过了 key 检查 + --providers 过滤, // 以及哪些 target 会被缓存跳过,用户能精确预览将要发生的一切. if *dryRunFlag { fmt.Fprintln(os.Stderr, "[dry-run] 计划探测以下 (provider, model) — 不会发起任何 API 调用:") if len(targets) == 0 { fmt.Fprintln(os.Stderr, " (无目标 — 检查 --providers 与 API key 设置)") } for _, t := range targets { key := t.providerName + ":" + t.model if !*forceFlag && existing != nil { if cached, ok := existing[key]; ok && isFullyProbed(cached) { fmt.Fprintf(os.Stderr, " %s / %s [cached, skip]\n", t.providerName, t.model) continue } } fmt.Fprintf(os.Stderr, " %s / %s\n", t.providerName, t.model) } fmt.Fprintf(os.Stderr, "[dry-run] 共 %d 个目标。退出。\n", len(targets)) return } // 执行探测 ctx := context.Background() var results []CapabilityResult allCapabilities := make(map[string]*ModelCapabilities) // 精妙之处(CLEVER): 先把已有数据全量灌入 allCapabilities-- // 如果某些 target 被跳过(已有 probed 数据),它们的数据仍然出现在最终 JSON 里; // 新探测的 target 覆盖旧数据(map 赋值语义). 这保证 JSON 报告始终包含所有历史 target, // 而不仅仅是本次运行探测的 target. if existing != nil && !*forceFlag { for k, v := range existing { allCapabilities[k] = v } } var skipped int for _, t := range targets { key := t.providerName + ":" + t.model // 持久化跳过:7 个可实测字段全部 probed 则跳过. if !*forceFlag && existing != nil { if cached, ok := existing[key]; ok && isFullyProbed(cached) { fmt.Printf("⏭ 跳过 %s / %s (已有 probed 数据, 用 --force 覆盖)\n", t.providerName, t.model) // 为 Markdown 矩阵生成 cached 条目 cachedResult := CapabilityResult{ Provider: t.providerName, Model: t.model, Streaming: capToTristate(cached.Streaming), Thinking: capToTristate(cached.Thinking), ToolUse: capToTristate(cached.ToolUse), StructuredOut: capToTristate(cached.StructuredOut), Caching: capToTristate(cached.Caching), SchemaRef: capToTristate(cached.SchemaRef), ToolCount: capToInt(cached.MaxTools), ToolCountExhaustive: capToExhaustive(cached.MaxTools), Notes: []string{"[cached]"}, } // L1174: 从缓存恢复 SchemaFeatures if len(cached.SchemaFeatures) > 0 { cachedResult.SchemaFeatures = make(map[string]bool, len(cached.SchemaFeatures)) for name, cap := range cached.SchemaFeatures { if v, ok := cap.Value.(bool); ok { cachedResult.SchemaFeatures[name] = v } } } results = append(results, cachedResult) skipped++ continue } } fmt.Printf("探测 %s / %s ...\n", t.providerName, t.model) r := probe(ctx, t) results = append(results, r) // 构建结构化 ModelCapabilities(probed + documented 合并) mc := buildModelCapabilities(ctx, t, r) allCapabilities[key] = mc } if skipped > 0 { fmt.Printf("\n✓ 跳过 %d / %d 个 target (已有 probed 数据)\n", skipped, len(targets)) } // 输出 Markdown 矩阵(人类可读) printMatrix(results) // 输出 JSON 能力报告(下游消费) // // 升华改进(ELEVATED): 早期方案仅输出 stdout Markdown-- // Markdown 便于人类一眼看懂,但下游(引擎配置,UI,定价服务)无法结构化消费. // JSON 报告写入 ~/.flyto/capabilities/capabilities.json,schema 稳定可解析. // Markdown 保留作为 human-readable fallback. if err := writeJSONReport(allCapabilities); err != nil { fmt.Fprintf(os.Stderr, "写入 JSON 报告失败: %v\n", err) } } // buildModelCapabilities 将一次 probe 结果 + provider.Models() 元数据 + documented 表 // 合并为一个完整的 ModelCapabilities 对象. // // 精妙之处(CLEVER): 数据来源三分层-- // 1. 可实测字段(Streaming/Thinking/ToolUse/StructuredOut/Caching/SchemaRef/MaxTools) // → SourceProbed,Value 取 tristate 对应 bool,Evidence 记诊断信息 // 2. 基础规格(Context/Tokens/Price)从 provider.Models() 查,SourceDocumented // 如果 provider 返回 error 或未命中此 model,标 SourceUntested // 3. 文档字段(Vision/PDF/Batch/Parallel/Strict)从 documentedCapabilities 表查, // 未命中的标 SourceUntested // // 反向思维:是否应该把 Models() 查找移到 main 循环之外统一做一次? // 否决--provider 的 Models() 是本地静态表查找,代价可忽略, // 每次 target 单独查反而让 buildModelCapabilities 自包含无副作用,更易测试. func buildModelCapabilities(ctx context.Context, t target, r CapabilityResult) *ModelCapabilities { mc := &ModelCapabilities{ Provider: t.providerName, Model: t.model, ProbedAt: time.Now().UTC().Format(time.RFC3339), } // --- 1. 可实测字段(SourceProbed)--- mc.Streaming = tristateToCapability(r.Streaming, extractNote(r.Notes, "stream:")) mc.Thinking = tristateToCapability(r.Thinking, extractNote(r.Notes, "think:")) mc.ToolUse = tristateToCapability(r.ToolUse, extractNote(r.Notes, "tool:")) mc.StructuredOut = tristateToCapability(r.StructuredOut, extractNote(r.Notes, "struct:")) mc.Caching = tristateToCapability(r.Caching, extractNote(r.Notes, "cache:")) mc.SchemaRef = tristateToCapability(r.SchemaRef, extractNote(r.Notes, "schemaref:")) if r.ToolCount > 0 { exh := r.ToolCountExhaustive mc.MaxTools = Capability{ Value: r.ToolCount, Source: SourceProbed, Exhaustive: &exh, Note: r.ToolCountNote, Evidence: map[string]any{ "max_tested": r.ToolCount, // 二分搜索结束位置:穷尽时是 lo+1(首次拒绝点),未穷尽时为 null "first_refusal_at": func() any { if r.ToolCountExhaustive && r.ToolCount > 0 && r.ToolCount < 128 { return r.ToolCount + 1 } return nil }(), }, } } else { // 0 表示连 1 个工具都被拒绝;这本身是穷尽结果(确定性 false). exh := true mc.MaxTools = Capability{ Source: SourceUntested, // 注意:依然标 Untested,因为没有 Value 可供下游使用 Exhaustive: &exh, Note: r.ToolCountNote, } } // --- 2. 基础规格(SourceDocumented,从 provider.Models() 查)--- var matched *flyto.ModelInfo if models, err := t.provider.Models(ctx); err == nil { for i := range models { if models[i].ID == t.model { matched = &models[i] break } } } if matched != nil { mc.ContextWindow = Capability{Value: matched.ContextWindow, Source: SourceDocumented} mc.MaxOutputTokens = Capability{Value: matched.MaxOutputTokens, Source: SourceDocumented} mc.InputPricePer1M = Capability{Value: matched.InputPricePer1M, Source: SourceDocumented} mc.OutputPricePer1M = Capability{Value: matched.OutputPricePer1M, Source: SourceDocumented} if matched.CacheReadPricePer1M > 0 { mc.CacheReadPricePer1M = Capability{Value: matched.CacheReadPricePer1M, Source: SourceDocumented} } else { mc.CacheReadPricePer1M = Capability{Source: SourceUntested} } if matched.CacheWritePricePer1M > 0 { mc.CacheWritePricePer1M = Capability{Value: matched.CacheWritePricePer1M, Source: SourceDocumented} } else { mc.CacheWritePricePer1M = Capability{Source: SourceUntested} } } else { // provider.Models() 未命中此 model(如 MiniMax-M2.7-highspeed 不在静态表) mc.ContextWindow = Capability{Source: SourceUntested} mc.MaxOutputTokens = Capability{Source: SourceUntested} mc.InputPricePer1M = Capability{Source: SourceUntested} mc.OutputPricePer1M = Capability{Source: SourceUntested} mc.CacheReadPricePer1M = Capability{Source: SourceUntested} mc.CacheWritePricePer1M = Capability{Source: SourceUntested} } // --- 3. 文档字段(从 documentedCapabilities 查)--- doc := lookupDocumented(t.providerName, t.model) mc.Vision = boolCap(doc.Vision, doc.Note) mc.PDF = boolCap(doc.PDF, doc.Note) mc.Batch = boolCap(doc.Batch, doc.Note) mc.ParallelToolCalls = boolCap(doc.ParallelToolCalls, doc.Note) mc.StrictJSON = boolCap(doc.StrictJSON, doc.Note) // --- 4. MaxTools 文档交叉验证 (L1175) --- // // 精妙之处(CLEVER): 只在 exhaustive=true (确认硬上限) 时才报 mismatch-- // exhaustive=false 表示"测到 N 还能过", probed=128 vs documented=128 此时 // 不是矛盾而是"下界刚好等于文档值", 真上限可能更大. // 替代方案: - 否决: 大多数 provider 会走快速路径 // (tryN(128) 直接过), 返回 exhaustive=false, 与 documented=128 不是真冲突. if doc.MaxTools != nil && mc.MaxTools.Source == SourceProbed { documented := *doc.MaxTools probed := capToInt(mc.MaxTools) exhaustive := mc.MaxTools.Exhaustive != nil && *mc.MaxTools.Exhaustive if mc.MaxTools.Evidence == nil { mc.MaxTools.Evidence = map[string]any{} } mc.MaxTools.Evidence["documented"] = documented if documented > 0 && exhaustive && probed != documented { mc.ProbeErrors = append(mc.ProbeErrors, fmt.Sprintf( "MaxTools mismatch: probed=%d (exhaustive) documented=%d", probed, documented)) } } // --- 5. SchemaFeatures 转 Capability (L1174) --- if len(r.SchemaFeatures) > 0 { mc.SchemaFeatures = make(map[string]Capability, len(r.SchemaFeatures)) for name, supported := range r.SchemaFeatures { mc.SchemaFeatures[name] = Capability{ Value: supported, Source: SourceProbed, } } } // --- 6. probe 错误收集 --- for _, note := range r.Notes { if strings.Contains(note, "err:") || strings.Contains(note, "ERR") { mc.ProbeErrors = append(mc.ProbeErrors, note) } } return mc } // tristateToCapability 将 tristate 转换为带来源的 Capability. // // 精妙之处(CLEVER): tsUnknown 映射到 SourceUntested 而非 SourceProbed-- // unknown 意味着 probe 未执行(如 streaming 失败后跳过), // 这时候说"实测不支持"会误导,应标"未测". func tristateToCapability(t tristate, note string) Capability { switch t { case tsYes: return Capability{Value: true, Source: SourceProbed, Note: note} case tsNo: return Capability{Value: false, Source: SourceProbed, Note: note} case tsError: return Capability{Source: SourceProbed, Note: "probe error: " + note} default: return Capability{Source: SourceUntested, Note: note} } } // extractNote 从 Notes 列表中按前缀提取单条 note(去掉前缀). func extractNote(notes []string, prefix string) string { for _, n := range notes { if strings.HasPrefix(n, prefix) { return strings.TrimPrefix(n, prefix) } } return "" } // writeJSONReport 将 allCapabilities 序列化为 JSON 并写入 ~/.flyto/capabilities/capabilities.json. func writeJSONReport(allCapabilities map[string]*ModelCapabilities) error { jsonPath := capabilitiesJSONPath() if jsonPath == "" { return fmt.Errorf("获取 home 目录失败") } if err := os.MkdirAll(filepath.Dir(jsonPath), 0755); err != nil { return fmt.Errorf("创建目录: %w", err) } report := &CapabilityReport{ SchemaVersion: "1.0", GeneratedAt: time.Now().UTC().Format(time.RFC3339), Models: allCapabilities, } data, err := json.MarshalIndent(report, "", " ") if err != nil { return fmt.Errorf("序列化 JSON: %w", err) } if err := os.WriteFile(jsonPath, data, 0644); err != nil { return fmt.Errorf("写入文件: %w", err) } fmt.Printf("\n✓ JSON 能力报告已写入: %s\n", jsonPath) return nil } // probe 执行所有能力探测,返回结果(含诊断信息). // // 升华改进(ELEVATED): 早期方案每个探测只返回 tristate,✗ 原因不明-- // 不知道是"真不支持"还是"测法错了". // 新版每个探测额外返回诊断字符串:错误信息,实际输出,token 数等, // 所有诊断进入 Notes 列,可直接从矩阵读出失败原因. // caching 探测额外做自适应探测(见 probeCachingAnthropic/probeCachingProvider). func probe(ctx context.Context, t target) CapabilityResult { r := CapabilityResult{ Provider: t.providerName, Model: t.model, } var diag string // 1. Streaming(基础连通性) r.Streaming, diag, _ = probeStreaming(ctx, t.provider, t.model) if diag != "" { r.Notes = append(r.Notes, "stream:"+diag) } // 后续探测只有 streaming 通了才有意义 if r.Streaming != tsYes { r.Notes = append(r.Notes, "streaming 失败,跳过后续") return r } // 2. Thinking r.Thinking, diag, _ = probeThinking(ctx, t.thinkingProvider, t.model) if diag != "" { r.Notes = append(r.Notes, "think:"+diag) } // 3. Tool Use r.ToolUse, diag, _ = probeToolUse(ctx, t.provider, t.model) if diag != "" { r.Notes = append(r.Notes, "tool:"+diag) } // 4. Structured Output r.StructuredOut, diag, _ = probeStructuredOutput(ctx, t.provider, t.model) if diag != "" { r.Notes = append(r.Notes, "struct:"+diag) } // 5. Caching - 无论结果如何都记录诊断(原始 token 数是最重要的证据) r.Caching, diag, _ = probeCaching(ctx, t) if diag != "" { r.Notes = append(r.Notes, "cache:"+diag) } // 6. SchemaRef - 工具 InputSchema 中的 $ref 是否被正确解析 r.SchemaRef, diag, _ = probeSchemaRef(ctx, t.provider, t.model) if diag != "" { r.Notes = append(r.Notes, "schemaref:"+diag) } // 7. ToolCount - 模型可处理的最大工具数量(二分探测) r.ToolCount, r.ToolCountExhaustive, r.ToolCountNote, _ = probeToolCount(ctx, t.provider, t.model) if r.ToolCountNote != "" { r.Notes = append(r.Notes, "toolcount:"+r.ToolCountNote) } // 8. SchemaFeatures - JSON Schema 特性支持 (L1174) // 前置条件: ToolUse 必须通过, 否则发包含工具的请求无意义. if r.ToolUse == tsYes { r.SchemaFeatures = probeSchemaFeatures(ctx, t.provider, t.model) var parts []string for name, ok := range r.SchemaFeatures { if ok { parts = append(parts, name+"=✓") } else { parts = append(parts, name+"=✗") } } if len(parts) > 0 { // 排序保证输出稳定 sort.Strings(parts) r.Notes = append(r.Notes, "schema:"+strings.Join(parts, ",")) } } return r } // probeStreaming 发最小请求,验证 SSE 流是否正常返回文本. func probeStreaming(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() ch, err := p.Stream(ctx, &flyto.Request{ Model: model, MaxTokens: 5, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("Reply with the word OK only.")}}, }, }) if err != nil { return tsError, fmt.Sprintf("connect err: %v", err), err } for evt := range ch { switch e := evt.(type) { case *flyto.TextDeltaEvent, *flyto.ThinkingDeltaEvent: // 精妙之处(CLEVER): ThinkingDeltaEvent 同样证明 SSE streaming 正常工作. // MiniMax-M2.7 thinking 默认开启,小 token 限制(max_tokens=5)时 // 全部 token 消耗在 reasoning 上,content 为空--若只接受 TextDeltaEvent // 会误报 ✗,实际 streaming 完全正常. drain(ch) return tsYes, "", nil case *flyto.ErrorEvent: return tsError, fmt.Sprintf("err: %v", e.Err), nil } } return tsNo, "no text/thinking events received", nil } // probeThinking 发含 thinking_budget 的请求,检测响应是否含 ThinkingEvent. // 使用带 ThinkingBudget 配置的 provider 实例. func probeThinking(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { ctx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() ch, err := p.Stream(ctx, &flyto.Request{ Model: model, // 精妙之处(CLEVER): MaxTokens 必须 >= ThinkingBudget(当前 1024),否则 Anthropic 返回 400. // 额外留出 1024 给实际回复,避免思考消耗完预算后无法产出文本. MaxTokens: 2048, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("What is 3+5? Think step by step.")}}, }, }) if err != nil { return tsError, fmt.Sprintf("connect err: %v", err), err } for evt := range ch { switch e := evt.(type) { case *flyto.ThinkingEvent, *flyto.ThinkingDeltaEvent: drain(ch) return tsYes, "", nil case *flyto.ErrorEvent: return tsNo, fmt.Sprintf("err: %v", e.Err), nil } } return tsNo, "no thinking events (model may not support or budget too low)", nil } // probeToolUse 发含工具定义的请求,检测模型是否返回 tool_use block. func probeToolUse(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() req := &flyto.Request{ Model: model, // 精妙之处(CLEVER): MaxTokens=256 而非 64-- // Claude 4.x 模型在工具调用前可能有隐式 reasoning overhead, // 64 tokens 不足以完成 thinking + tool_use JSON block. MaxTokens: 256, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("What is the weather in Beijing? Use the get_weather tool.")}}, }, Tools: []flyto.Tool{ { Name: "get_weather", Description: "Get current weather for a city", InputSchema: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`), }, }, } ch, err := p.Stream(ctx, req) if err != nil { return tsError, fmt.Sprintf("connect err: %v", err), err } for evt := range ch { switch e := evt.(type) { case *flyto.ToolUseEvent: drain(ch) return tsYes, "", nil case *flyto.ErrorEvent: return tsNo, fmt.Sprintf("err: %v", e.Err), nil } } return tsNo, "no tool_use event (model responded in text only)", nil } // probeStructuredOutput 发含 ResponseFormat=json_object 的请求,检测输出是否为合法 JSON. // // 精妙之处(CLEVER): ResponseFormat 双保险-- // system prompt 引导 + response_format.type=json_object 双重约束. // 只用 system prompt 时,模型有时添加 markdown 代码块或解释文字,导致误判. // json_object 模式由 provider 在协议层强制约束,更可靠. func probeStructuredOutput(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { ctx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() ch, err := p.Stream(ctx, &flyto.Request{ Model: model, // 精妙之处(CLEVER): 256 tokens 而非 50-- // MiniMax-M2.7 thinking 默认开启,thinking 本身消耗约 50-100 tokens, // 50 tokens 的限制会在 JSON 输出中途截断,导致 json.Unmarshal 失败. // 256 tokens 足够覆盖 thinking overhead + 完整 JSON 输出. MaxTokens: 256, System: `Respond ONLY with valid JSON, no markdown fences, no explanations. Format: {"name":"string","score":number}`, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("Give me a fake person named Alice with score 95.")}}, }, ResponseFormat: &flyto.ResponseFormat{Type: "json_object"}, }) if err != nil { return tsError, fmt.Sprintf("connect err: %v", err), err } // 精妙之处(CLEVER): TextEvent 优先于 TextDeltaEvent 累积-- // wire 层同时发 TextDeltaEvent(逐块增量)和 TextEvent(finish 时的完整文本). // 若同时累积两者,buf 会变成 "delta1+delta2+...+fullText",即两倍文本,JSON 解析必失败. // 策略:优先取 TextEvent(完整,出现一次),fallback 到 TextDeltaEvent 累积. var deltaBuf strings.Builder var finalText string var streamErr string for evt := range ch { switch e := evt.(type) { case *flyto.TextDeltaEvent: deltaBuf.WriteString(e.Text) case *flyto.TextEvent: finalText = e.Text // 覆盖增量累积,取完整文本 case *flyto.ErrorEvent: streamErr = fmt.Sprintf("err: %v", e.Err) } } if streamErr != "" { return tsError, streamErr, nil } text := finalText if text == "" { text = deltaBuf.String() } text = strings.TrimSpace(text) // 精妙之处(CLEVER): 剥掉 markdown 代码块(```json ... ``` 或 ``` ... ```)-- // Haiku 4.5 即使系统提示明确要求"no markdown fences",仍会包裹代码块: // ```json\n{"name":"Alice","score":95}\n``` // json.Unmarshal 遇到反引号会立即失败,误判为"不支持结构化输出". // 剥掉外层 fence 后再解析,正确反映模型确实输出了合法 JSON. // 替代方案:<在系统提示中更强调禁止 markdown> - 否决: // 实测 prompt engineering 在小模型上不可靠,防御性 strip 更健壮. for _, prefix := range []string{"```json\n", "```\n", "```json", "```"} { if strings.HasPrefix(text, prefix) { text = strings.TrimPrefix(text, prefix) break } } for _, suffix := range []string{"\n```", "```"} { if strings.HasSuffix(text, suffix) { text = strings.TrimSuffix(text, suffix) break } } text = strings.TrimSpace(text) var v map[string]any if err := json.Unmarshal([]byte(text), &v); err != nil { // 诊断:截取实际输出前 80 字符,帮助判断是 format 问题还是模型不支持 preview := text if len(preview) > 80 { preview = preview[:80] + "..." } return tsNo, fmt.Sprintf("json-parse-err, output=%q", preview), nil } return tsYes, "", nil } // probeCaching 检测 provider 是否支持 prompt caching. // // 三条路径: // 1. cachingClient != nil(Anthropic/MiniMax 直连):直接用 api.Client 发 cache_control 请求. // 2. cachingProvider != nil(OpenRouter → Anthropic 路径):用配置了 EnableCaching=true // 的 provider 发自适应长度系统提示,检测 cached_tokens > 0. // 3. 通用路径:发两次相同请求,检测 cache_read_tokens > 0(依赖 provider 自动缓存). func probeCaching(ctx context.Context, t target) (tristate, string, error) { if t.cachingClient != nil { return probeCachingAnthropic(ctx, t.cachingClient, t.model) } if t.cachingProvider != nil { return probeCachingProvider(ctx, t.cachingProvider, t.model) } return probeCachingGeneric(ctx, t.provider, t.model) } // cachingReqClient 发一次 Anthropic caching 请求,返回 (inputTokens, creationTokens, readTokens, err). // // 精妙之处(CLEVER): 拆成独立 helper 是为了让 probeCachingAnthropic 能复用同一个请求逻辑 // 做自适应循环--不同长度的 system 只是参数变化,其余完全相同. // 同时捕获 ErrorEvent,避免错误被静默吞掉(channel 关闭但无 error 返回). // // 升华改进(ELEVATED): 早期方案只返回 (creation, read)--遇到 cr=0 无法判断 // 是"系统提示太短没达到阈值"还是"API 根本没算到 input_tokens". // 新版从 usage.input_tokens 读出实际计费的 input 数,让外层自适应探测 // 根据真实 token 数而非理论估算(reps*12)决定是否加长. func cachingReqClient(ctx context.Context, client *api.Client, model, system string) (inputTokens, creationTokens, readTokens int, err error) { reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() req := &api.MessageRequest{ Model: model, MaxTokens: 5, Beta: &api.BetaFeatures{PromptCaching: true}, } req.SetSystemBlocks([]api.SystemContentBlock{{ Type: "text", Text: system, CacheControl: &api.CacheControl{Type: "ephemeral"}, }}) req.Messages = []api.RequestMessage{api.NewTextMessage("user", "Say hi.")} ch, e := client.CreateMessageStream(reqCtx, req) if e != nil { return 0, 0, 0, e } for evt := range ch { switch ev := evt.(type) { case *flyto.UsageEvent: inputTokens = ev.InputTokens creationTokens = ev.CacheCreationTokens readTokens = ev.CacheReadTokens case *flyto.ErrorEvent: return 0, 0, 0, ev.Err } } return } // cachingReqProvider 发一次 provider caching 请求,返回 (inputTokens, creationTokens, readTokens, err). func cachingReqProvider(ctx context.Context, p flyto.ModelProvider, model, system string) (inputTokens, creationTokens, readTokens int, err error) { reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() ch, e := p.Stream(reqCtx, &flyto.Request{ Model: model, MaxTokens: 5, System: system, Messages: []flyto.Message{{Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("Say hi.")}}}, }) if e != nil { return 0, 0, 0, e } for evt := range ch { switch ev := evt.(type) { case *flyto.UsageEvent: inputTokens = ev.InputTokens creationTokens = ev.CacheCreationTokens readTokens = ev.CacheReadTokens case *flyto.ErrorEvent: return 0, 0, 0, ev.Err } } return } // probeCachingAnthropic 自适应探测 Anthropic 兼容端点的 prompt caching. // // 升华改进(ELEVATED): 早期方案硬编码 200 次重复(~2700t),靠猜是否超过阈值-- // 遇到 ✗ 时无法区分"token 不够"和"真不支持". // 新版逐步加长系统提示,通过 API 响应的 cache_creation_input_tokens 精确判断: // // cr=0 → 这个长度不够,继续加长 // cr>0, rd>0 → 缓存建立且命中,✓ // cr>0, rd=0 → 建立了但没命中(请求不一致 bug,理论上不应发生) // 所有长度均 cr=0 → 真的不支持或 tier 限制 // // bug 修复(FIXED): 早期方案有两个 bug-- // 1. 若上一次 probe 已把缓存建好,这次第一次请求会得到 cr=0 rd>0(直接命中), // 原逻辑 cr==0 continue 会错过这种情况,误判为 ✗. // 新版:第一次请求若 cr=0 rd>0,直接返回 tsYes(缓存已存在于 tier). // 2. 实际 token 数只靠 reps*12 估算,没有 ground truth-- // 新版从 usage.input_tokens 读出真实 token 数打印到 diag. // // 梯度(次数 → 约 tokens): // // 100 → ~1200t(覆盖 Sonnet 1024 阈值) // 300 → ~3600t(覆盖 Haiku 2048 阈值,低于 4096) // 600 → ~7200t(覆盖 Haiku 4096 阈值) // 1200 → ~14400t(为未知高阈值模型兜底) func probeCachingAnthropic(ctx context.Context, client *api.Client, model string) (tristate, string, error) { phrase := "You are a helpful AI assistant that answers concisely. " for _, reps := range []int{100, 300, 600, 1200} { system := strings.Repeat(phrase, reps) approxT := reps * 12 in1, cr, rd, err := cachingReqClient(ctx, client, model, system) if err != nil { return tsError, fmt.Sprintf("err@~%dt(in=%d): %v", approxT, in1, err), err } // bug 修复(FIXED): 第一次 cr=0 rd>0 表示缓存已在 tier 中存在(上次 probe 残留), // 直接返回 tsYes--这本身就是 caching 支持的铁证. if cr == 0 && rd > 0 { return tsYes, fmt.Sprintf("cache-hit-pre-existing in=%d cr=0 rd=%d (~%dt)", in1, rd, approxT), nil } if cr == 0 { continue // 未触发缓存建立,加长后重试 } // 缓存已建立,第二次验证命中 // 精妙之处(CLEVER): 同时捕获第二次请求的 cr2 和 rd-- // cr2>0 rd=0 → 每次都重新建缓存(cache key 不一致或 beta header 问题) // cr2=0 rd>0 → 正常命中 // cr2=0 rd=0 → 缓存在两次请求之间过期(TTL 异常) in2, cr2, rd2, err := cachingReqClient(ctx, client, model, system) if err != nil { return tsError, fmt.Sprintf("cr=%d in1=%d err@2nd: %v", cr, in1, err), err } if rd2 > 0 { return tsYes, fmt.Sprintf("in=%d/%d cr=%d rd=%d (~%dt)", in1, in2, cr, rd2, approxT), nil } return tsNo, fmt.Sprintf("in=%d/%d cr1=%d cr2=%d rd=0 (~%dt)", in1, in2, cr, cr2, approxT), nil } return tsNo, "cr=0@all-levels(up to ~14400t, not supported or tier-limit)", nil } // probeCachingProvider 自适应探测 OpenRouter → Anthropic 路径的 prompt caching. // // 逻辑与 probeCachingAnthropic 相同,只是通过 flyto.ModelProvider 接口(EnableCaching=true) // 而非直连 api.Client.OpenRouter 将 cache_control 透传给 Anthropic. func probeCachingProvider(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { phrase := "You are a helpful AI assistant that answers concisely. " for _, reps := range []int{100, 300, 600, 1200} { system := strings.Repeat(phrase, reps) approxT := reps * 12 in1, cr, rd, err := cachingReqProvider(ctx, p, model, system) if err != nil { return tsError, fmt.Sprintf("err@~%dt(in=%d): %v", approxT, in1, err), err } // bug 修复(FIXED): 同上--第一次 cr=0 rd>0 = 缓存已存在,直接 ✓ if cr == 0 && rd > 0 { return tsYes, fmt.Sprintf("cache-hit-pre-existing in=%d cr=0 rd=%d (~%dt)", in1, rd, approxT), nil } if cr == 0 { continue } in2, cr2, rd2, err := cachingReqProvider(ctx, p, model, system) if err != nil { return tsError, fmt.Sprintf("cr=%d in1=%d err@2nd: %v", cr, in1, err), err } if rd2 > 0 { return tsYes, fmt.Sprintf("in=%d/%d cr=%d rd=%d (~%dt)", in1, in2, cr, rd2, approxT), nil } return tsNo, fmt.Sprintf("in=%d/%d cr1=%d cr2=%d rd=0 (~%dt)", in1, in2, cr, cr2, approxT), nil } return tsNo, "cr=0@all-levels(up to ~14400t, not supported or tier-limit)", nil } // probeCachingGeneric 通过 flyto.ModelProvider 发两次相同请求,检测 cache_read_tokens > 0. // 适用于自动 caching 的 provider(如 MiniMax 自有缓存机制). // // 升华改进(ELEVATED): 同 probeCachingAnthropic--新版从 UsageEvent.InputTokens 读 // 实际 input 数,打印到 diag,让 ✗ 判定可追溯. func probeCachingGeneric(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { doReq := func() (in, cr, rd int, err error) { reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() ch, e := p.Stream(reqCtx, &flyto.Request{ Model: model, MaxTokens: 5, System: "You are a helpful assistant. Always be concise.", Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("Say hi.")}}, }, }) if e != nil { return 0, 0, 0, e } for evt := range ch { switch ev := evt.(type) { case *flyto.UsageEvent: in = ev.InputTokens cr = ev.CacheCreationTokens rd = ev.CacheReadTokens case *flyto.ErrorEvent: return 0, 0, 0, ev.Err } } return } in1, cr1, rd1, err := doReq() if err != nil { return tsError, fmt.Sprintf("err@1st: %v", err), err } // bug 修复(FIXED): 如果第一次就 rd>0,缓存已存在,直接返回 ✓ if rd1 > 0 { return tsYes, fmt.Sprintf("pre-existing in=%d cr=%d rd=%d", in1, cr1, rd1), nil } in2, _, rd2, err := doReq() if err != nil { return tsError, fmt.Sprintf("err@2nd: %v", err), err } if rd2 > 0 { return tsYes, fmt.Sprintf("auto-cache in=%d/%d cr=%d rd=%d", in1, in2, cr1, rd2), nil } return tsNo, fmt.Sprintf("no auto-cache in=%d/%d cr=%d rd=%d", in1, in2, cr1, rd2), nil } // probeSchemaRef 探测模型工具调用是否支持 JSON Schema $ref 引用. // // 精妙之处(CLEVER): $ref 是 JSON Schema 规范的合法特性,但部分模型(尤其是非 Anthropic 路由) // 在解析工具 InputSchema 时会直接报错或忽略 $ref,导致工具调用失败. // 探测方式:定义一个包含 $ref 的工具,检查模型能否正常返回 tool_use block. // // 反向思维:不能假设 $ref 展开由 provider 网关完成--OpenRouter 不展开 $ref, // 直接透传给底层模型,所以这个能力反映的是目标模型的真实支持情况. func probeSchemaRef(ctx context.Context, p flyto.ModelProvider, model string) (tristate, string, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // 包含 $ref 的 schema:definitions 中定义 Location 类型,properties 通过 $ref 引用 // 替代方案:<内联展开 $ref,不测这个特性> - 否决: // 若工具 builder 生成的 schema 含 $ref,运行时会静默失败,提前探测更安全. schemaWithRef := json.RawMessage(`{ "type": "object", "definitions": { "Location": { "type": "object", "properties": { "city": {"type": "string"}, "country": {"type": "string"} }, "required": ["city"] } }, "properties": { "location": {"$ref": "#/definitions/Location"} }, "required": ["location"] }`) ch, err := p.Stream(ctx, &flyto.Request{ Model: model, MaxTokens: 256, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock("Get weather for Paris, France using get_weather.")}}, }, Tools: []flyto.Tool{ { Name: "get_weather", Description: "Get weather for a location", InputSchema: schemaWithRef, }, }, }) if err != nil { return tsError, fmt.Sprintf("connect err: %v", err), err } for evt := range ch { switch e := evt.(type) { case *flyto.ToolUseEvent: drain(ch) return tsYes, "", nil case *flyto.ErrorEvent: // 精妙之处(CLEVER): $ref 不支持时,Anthropic 等 provider 返回 400 invalid_request_error, // 错误信息通常含 "$ref" 或 "schema",可作为额外诊断依据. errMsg := fmt.Sprintf("%v", e.Err) return tsNo, fmt.Sprintf("tool_use failed (likely $ref unsupported): %s", errMsg), nil } } return tsNo, "no tool_use event (model may not parse $ref schema)", nil } // probeToolCount 通过二分法探测模型实际可处理的最大工具数量. // // 升华改进(ELEVATED): 早期方案不探测工具数量上限,遇到"too many tools"错误时无诊断信息-- // 不知道是 64 个还是 128 个,也不知道是 provider 限制还是模型限制. // 新版二分搜索:从 [1, maxProbeTools] 区间找到最大可用数量, // 输出精确数字(如 "max=64"),帮助调用方合理分批工具. // // 二分策略: // - 上界 maxProbeTools=128(超过此数量的场景极罕见,不值得为此花更多 API 调用) // - 每次发包含 N 个工具的请求,N 个工具名为 tool_0..tool_{N-1} // - 模型正常返回(任何非错误响应)→ 此数量可用,尝试更多 // - 模型返回错误 → 此数量不可用,尝试更少 // - 返回最后一个成功的数量(0 表示连 1 个工具都失败) // // 反向思维:二分法假设"N 个可用则 =%d (upper bound not reached)", maxProbeTools), nil } // 二分搜索 [1, maxProbeTools-1] // 找到的 lo → exhaustive=true:lo 通过且 lo+1 拒绝,是确定上限. lo, hi := 1, maxProbeTools-1 for lo < hi { mid := (lo + hi + 1) / 2 if tryN(mid) { lo = mid } else { hi = mid - 1 } } return lo, true, fmt.Sprintf("max=%d", lo), nil } // probeSchemaFeatures 探测模型对 JSON Schema 各特性的支持 (L1174). // // 升华改进(ELEVATED): 早期方案只测 $ref(SchemaRef),不知道 enum/nested/array/数值约束 // 这些 OpenAI/Anthropic 文档有提但 MiniMax 文档未覆盖的特性是否可用. // 新版逐项发请求,每种 schema 特性单独一个工具,API 接受则标 true. // // 精妙之处(CLEVER): 测的是"API 是否接受此 schema"而非"模型是否遵守约束". // 前者是确定性的(ErrorEvent → 拒绝),后者是统计性的(需多次采样), // 对 probe 工具来说确定性结果更有价值. // // 替代方案: <合并到 SchemaRef 一起测> - 否决: $ref 测试的是引用展开, // enum/数值约束测试的是 schema validation,机制不同,合并会丢失粒度. func probeSchemaFeatures(ctx context.Context, p flyto.ModelProvider, model string) map[string]bool { schemas := []struct { name string schema json.RawMessage }{ {"enum", json.RawMessage(`{"type":"object","properties":{"color":{"type":"string","enum":["red","green","blue"]}},"required":["color"]}`)}, {"nested_object", json.RawMessage(`{"type":"object","properties":{"addr":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}},"required":["addr"]}`)}, {"array", json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}`)}, {"numeric_min_max", json.RawMessage(`{"type":"object","properties":{"n":{"type":"integer","minimum":1,"maximum":100}},"required":["n"]}`)}, } results := make(map[string]bool, len(schemas)) for _, s := range schemas { results[s.name] = trySchemaFeature(ctx, p, model, s.name, s.schema) } return results } // trySchemaFeature 发包含指定 schema 的单工具请求,检测 API 是否接受. func trySchemaFeature(ctx context.Context, p flyto.ModelProvider, model string, toolName string, schema json.RawMessage) bool { reqCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() ch, err := p.Stream(reqCtx, &flyto.Request{ Model: model, MaxTokens: 100, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock(fmt.Sprintf("Use the %s tool with any valid value.", toolName))}}, }, Tools: []flyto.Tool{{ Name: toolName, Description: fmt.Sprintf("Test tool for %s schema feature", toolName), InputSchema: schema, }}, }) if err != nil { return false } for evt := range ch { switch evt.(type) { case *flyto.ToolUseEvent: drain(ch) return true case *flyto.TextDeltaEvent, *flyto.TextEvent: // 模型用文本回复而非调用工具 -- schema 被 API 接受,只是模型选择不调用. drain(ch) return true case *flyto.ErrorEvent: drain(ch) return false } } return true // 流正常结束,无错误 } // drain 消费并丢弃 channel 剩余事件,防止 goroutine 泄漏. func drain(ch <-chan flyto.Event) { for range ch { } } // printMatrix 输出 Markdown 表格. func printMatrix(results []CapabilityResult) { fmt.Println() fmt.Println("## Provider 能力矩阵") fmt.Println() fmt.Println("| Provider | Model | Streaming | Thinking | ToolUse | StructuredOut | Caching | SchemaRef | ToolCount | Notes |") fmt.Println("|----------|-------|-----------|----------|---------|---------------|---------|-----------|-----------|-------|") for _, r := range results { notes := strings.Join(r.Notes, "; ") toolCountStr := "-" if r.ToolCount > 0 { toolCountStr = fmt.Sprintf("%d", r.ToolCount) // L1175: annotate with documented value for cross-reference if doc := lookupDocumented(r.Provider, r.Model); doc.MaxTools != nil { documented := *doc.MaxTools if r.ToolCountExhaustive && r.ToolCount != documented { toolCountStr = fmt.Sprintf("%d [doc:%d ⚠]", r.ToolCount, documented) } else { toolCountStr = fmt.Sprintf("%d [doc:%d]", r.ToolCount, documented) } } } fmt.Printf("| %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", r.Provider, r.Model, r.Streaming, r.Thinking, r.ToolUse, r.StructuredOut, r.Caching, r.SchemaRef, toolCountStr, notes, ) } fmt.Println() } // loadEnvFile 从文件加载 KEY=VALUE 到环境变量(已存在的不覆盖). func loadEnvFile(path string) { f, err := os.Open(path) if err != nil { return } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) if os.Getenv(key) == "" { os.Setenv(key, val) } } }