// 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 模型实际可处理的最大工具数量(触发拒绝时的上限) // // 抽包 (ADR-0018): 探测 + 合并逻辑搬进 core/pkg/capability, 本命令退成 // 薄壳 — 保留 flag / loadEnvFile / 实例构造 / 持久化缓存 / printMatrix / // main 循环, 对每个 target 调 capability.ProbeModel. // // Extracted (ADR-0018): the probe + merge logic moved into // core/pkg/capability; this command is a thin wrapper -- it keeps flag // parsing / loadEnvFile / provider construction / persistence cache / // printMatrix / the main loop, and calls capability.ProbeModel per // target. // // 使用: // // source .env && go run ./cmd/capability-probe/ package main import ( "bufio" "context" "encoding/json" "flag" "fmt" "os" "path/filepath" "strings" "time" api "git.flytoex.net/yuanwei/flyto-agent/core/internal/transport" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/capability" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/flyto" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/providers/anthropic" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/providers/deepseek" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/providers/minimax" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/providers/openrouter" ) // cliTarget 是 CLI 侧的探测目标 — 持有 key-dependent 构造出的四个 provider // 句柄 + 静态 providerKind, 在 main 循环里映射成 capability.ProbeOpts. // // 升华改进(ELEVATED): probe 逻辑搬进 core/pkg/capability 后, 句柄构造 (依赖 // 各 provider 的 key / mode / region / thinking / caching 变体) 仍是 CLI // 关注点 — 它读 .env 拼 key, 是产品外部的元数据聚合器职责. // // cliTarget is the CLI-side probe target -- it holds the four // key-dependent provider handles plus the static providerKind, mapped // to capability.ProbeOpts in the main loop. After the probe logic moved // into core/pkg/capability, handle construction (depending on each // provider's key / mode / region / thinking / caching variant) is still // a CLI concern -- it reads .env to assemble keys. type cliTarget struct { providerName string provider flyto.ModelProvider model string thinkingProvider flyto.ModelProvider cachingClient *api.Client cachingProvider flyto.ModelProvider providerKind string } // toOpts 把 cliTarget 映射成 capability.ProbeOpts. // // 注意: CLI 跑的是 full probe (不 skip 任何贵探针, MaxProbeTools 用包默认 // 128), 保留抽包前的行为. server 等其他消费者按需 skip. // // toOpts maps cliTarget to capability.ProbeOpts. Note: the CLI runs a // full probe (no Skip*, MaxProbeTools uses the package default 128), // preserving pre-extraction behavior. Other consumers (server) skip as // needed. func (t cliTarget) toOpts() capability.ProbeOpts { return capability.ProbeOpts{ ProviderName: t.providerName, Provider: t.provider, ThinkingProvider: t.thinkingProvider, CachingClient: t.cachingClient, CachingProvider: t.cachingProvider, ProviderKind: t.providerKind, } } // 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): 持久化跳过的读取端-- // 配合 capability.IsFullyProbed 判断已有数据的完整性,避免对已探测的 target 重复消耗 API 配额. // 早期方案每次运行都全量重探,16 个 target * 7 能力 = 112 次 API 调用(~$2-5), // 多数情况下模型能力短期不变,持久化跳过可节省 90%+ 的 API 成本. func loadExistingReport() map[string]*capability.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 capability.CapabilityReport if err := json.Unmarshal(data, &report); err != nil { fmt.Fprintf(os.Stderr, "⚠ capabilities.json 解析失败 (将全量重探): %v\n", err) return nil } return report.Models } // writeJSONReport 将 allCapabilities 序列化为 JSON 并写入 ~/.flyto/capabilities/capabilities.json. func writeJSONReport(allCapabilities map[string]*capability.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 := &capability.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 } 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,deepseek); 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 配合持久化跳过使用-- // 默认行为:如果 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") deepseekKey := os.Getenv("DEEPSEEK_API_KEY") if antKey == "" && minimaxKey == "" && orKey == "" && deepseekKey == "" { 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 != "" case "deepseek": hasKey = deepseekKey != "" default: fmt.Fprintf(os.Stderr, "未知 provider: %q (支持: anthropic, minimax, openrouter, deepseek)\n", name) os.Exit(1) } if !hasKey { fmt.Fprintf(os.Stderr, "--providers 包含 %q 但对应 API key 未设置\n", name) os.Exit(1) } } var targets []cliTarget // --- 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, cliTarget{ providerName: "anthropic", providerKind: "direct", 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, cliTarget{ providerName: "minimax", providerKind: "direct", 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", // ADR-0007 capability tracking 实证 model: r22-r25 业务命门暴露 // reasoning_content passback / tool name regex / max_completion_tokens // 三处 wire 协议 gap, 必须实测 capability 落底。 // ADR-0007 capability tracking probed model: r22-r25 production audit // surfaced wire-protocol gaps (reasoning_content passback / tool name // regex / max_completion_tokens); needs first-class capability data. "deepseek/deepseek-v4-flash", "minimax/minimax-m2.7", } { targets = append(targets, cliTarget{ providerName: "openrouter", providerKind: "aggregator", 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, cliTarget{ providerName: "openrouter", providerKind: "aggregator", provider: orProvider, model: model, thinkingProvider: orProviderThinking, cachingProvider: orProviderCaching, }) } } // --- DeepSeek 官方直连(ADR-0007 § 2.1 第 5 个 direct provider)--- // // 走 ModeOpenAI 主路径: reasoning_content passback 协议 (r24 真因) 在 // OpenAI compat wire 层 (ADR-0007 C4) 已修, 直连此路径直接消费已修的 wire. // thinkingProvider 单独 ThinkingBudget=2048 实例供 probeThinking 触发思考模式. // caching 字段走 wire/openai.go 新增的 prompt_cache_hit_tokens fallback, // 不需 cache_control 标记 (DeepSeek kv_cache 自动启用). // // DeepSeek direct connection (ADR-0007 § 2.1 fifth direct provider). // Uses ModeOpenAI primary path so the wire-layer reasoning_content // passback fix (ADR-0007 C4) is consumed directly. thinkingProvider // runs ThinkingBudget=2048 for probeThinking. Caching is detected via // the wire/openai.go prompt_cache_hit_tokens fallback (DeepSeek // auto-enables kv_cache, no cache_control marker needed). if deepseekKey != "" && shouldProbe("deepseek") { dsProvider := deepseek.New(deepseek.Config{APIKey: deepseekKey}) dsProviderThinking := deepseek.New(deepseek.Config{ APIKey: deepseekKey, ThinkingBudget: 2048, }) // cachingProvider 走 probeCachingProvider 的 100/300/600/1200 重复 // 阶梯路径. probe 默认 generic 路径系统提示只 ~10 tokens, DeepSeek // auto-cache 触发需 prefix 足够长 (实测 ~17 tokens 不触发); // ladder 路径直接复用 dsProvider (DeepSeek 不需 EnableCaching=true // config, kv_cache 自动启用; "cachingProvider" 字段名沿袭 OpenRouter // 用例的历史命名, 实际是通用 long-prefix 探测器). // // cachingProvider routes through probeCachingProvider's // 100/300/600/1200 repetition ladder. The default generic path // uses a ~10-token system prompt that is too small to trigger // DeepSeek's auto-cache; the ladder reuses dsProvider directly // since DeepSeek auto-enables kv_cache (no EnableCaching config // needed). "cachingProvider" is a legacy name from the // OpenRouter use case but the underlying probe is generic. for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} { targets = append(targets, cliTarget{ providerName: "deepseek", providerKind: "direct", provider: dsProvider, model: model, thinkingProvider: dsProviderThinking, cachingProvider: dsProvider, }) } } // 加载已有探测结果(持久化跳过). // // 升华改进(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 && capability.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 matrix []*capability.ModelCapabilities allCapabilities := make(map[string]*capability.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 && capability.IsFullyProbed(cached) { fmt.Printf("⏭ 跳过 %s / %s (已有 probed 数据, 用 --force 覆盖)\n", t.providerName, t.model) matrix = append(matrix, cached) skipped++ continue } } fmt.Printf("探测 %s / %s ...\n", t.providerName, t.model) // 构建结构化 ModelCapabilities(probed + documented 合并) mc, err := capability.ProbeModel(ctx, t.model, t.toOpts()) if err != nil { fmt.Fprintf(os.Stderr, "探测 %s / %s 失败: %v\n", t.providerName, t.model, err) continue } matrix = append(matrix, mc) allCapabilities[key] = mc } if skipped > 0 { fmt.Printf("\n✓ 跳过 %d / %d 个 target (已有 probed 数据)\n", skipped, len(targets)) } // 输出 Markdown 矩阵(人类可读) printMatrix(matrix) // 输出 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) } } // printMatrix 输出 Markdown 表格. // // 抽包后改吃 *capability.ModelCapabilities (合并后的能力画像), 不再吃 // 包内私有的 CapabilityResult — 渲染各能力 Capability 的来源戳投影. // // After extraction this consumes *capability.ModelCapabilities (the // merged picture) instead of the now-package-private CapabilityResult, // rendering each capability's source-stamped projection. func printMatrix(rows []*capability.ModelCapabilities) { fmt.Println() fmt.Println("## Provider 能力矩阵") fmt.Println() fmt.Println("| Provider | Model | Streaming | Thinking | ToolUse | StructuredOut | Caching | SchemaRef | ToolCount |") fmt.Println("|----------|-------|-----------|----------|---------|---------------|---------|-----------|-----------|") for _, mc := range rows { if mc == nil { continue } toolCountStr := "-" if mc.MaxTools.Source == capability.SourceProbed { if n := capInt(mc.MaxTools); n > 0 { toolCountStr = fmt.Sprintf("%d", n) if exhaustive(mc.MaxTools) { toolCountStr += " (exh)" } } } fmt.Printf("| %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", mc.Provider, mc.Model, capMark(mc.Streaming), capMark(mc.Thinking), capMark(mc.ToolUse), capMark(mc.StructuredOut), capMark(mc.Caching), capMark(mc.SchemaRef), toolCountStr, ) } fmt.Println() } // capMark 把一个 bool 能力 Capability 渲成 ✓ / ✗ / - 标记. // 只信任 Source==probed 的 bool 值; 非 probed 或非 bool 显 "-". // // capMark renders a bool capability as ✓ / ✗ / - . It trusts only // Source==probed bool values; non-probed or non-bool show "-". func capMark(c capability.Capability) string { if c.Source != capability.SourceProbed { return "-" } if v, ok := c.Value.(bool); ok { if v { return "✓" } return "✗" } return "-" } // capInt 从 Capability 提取整数值 (MaxTools 等). JSON 反序列化的数字为 // float64, 同时兼容 int. // // capInt extracts the integer value from a Capability (MaxTools etc.). // JSON-decoded numbers are float64; int is also handled. func capInt(c capability.Capability) int { switch v := c.Value.(type) { case int: return v case float64: return int(v) } return 0 } // exhaustive 读 Capability.Exhaustive 标志 (nil -> false). // exhaustive reads the Capability.Exhaustive flag (nil -> false). func exhaustive(c capability.Capability) bool { return c.Exhaustive != nil && *c.Exhaustive } // 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) } } }