// Package openrouter 实现 OpenRouter 聚合网关的 ModelProvider. // // OpenRouter 是完全 OpenAI 兼容的聚合 API,统一接入 300+ 模型. // 优势:单一 API Key 访问所有 provider;统一的 reasoning 参数支持思考模式; // // 有 live /api/v1/models 端点,模型列表始终最新. // // 升华改进(ELEVATED): OpenRouter 提供统一的 reasoning 参数, // 无论底层是 Anthropic thinking,OpenAI o1 还是 DeepSeek R1, // 调用方只需设置 Config.DefaultThinking=true,引擎自动注入 reasoning 参数. // 替代方案:<各 provider 各自实现 thinking 参数> - 否决: // OpenRouter 场景下底层 provider 随时切换,各自实现无法统一. package openrouter import ( "context" "fmt" "net/http" "time" "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" ) const defaultBaseURL = "https://openrouter.ai" // Config 是 OpenRouter provider 的配置. type Config struct { // APIKey 是 OpenRouter API 密钥(从 openrouter.ai 获取). APIKey string // BaseURL 覆盖默认的 OpenRouter API 地址(默认 https://openrouter.ai). // 用于自部署 OpenRouter 兼容代理,企业内网中转,单元测试 mock server. // // 升华改进(ELEVATED): 早期方案只能写死走 openrouter.ai-- // 私有化场景(合规要求出口流量必须过自家代理)和单元测试均无法注入. // 与 ollama/lmstudio/anthropic 等其他 provider 的 BaseURL 字段保持一致. // 替代方案:<不暴露 BaseURL,私有部署用 HTTP_PROXY 环境变量> - 否决: // 环境变量是进程级,无法在同一进程内同时使用多个 openrouter 实例(CLI/SDK 多场景). BaseURL string // SiteURL 用于 OpenRouter 排行榜统计(HTTP-Referer header). // 建议填写你的应用 URL,如 "https://yourapp.com". SiteURL string // AppName 用于 OpenRouter 排行榜展示(X-Title header). AppName string // DefaultThinking 为所有请求启用思考模式(reasoning.enabled=true). // 适用于需要推理能力但不关心底层模型是否原生支持思考的场景. DefaultThinking bool // DefaultThinkingTokens 设置默认思考预算(仅当 DefaultThinking=true 时有效). // 0 = 使用 OpenRouter 默认值. DefaultThinkingTokens int // EnableCaching 为系统消息添加 cache_control: ephemeral 标记. // 用于 OpenRouter → Anthropic 路径的 prompt caching(OpenRouter 透传 cache_control 给 Anthropic). // 注意:仅对 Anthropic 系列模型(如 anthropic/claude-sonnet-4.6)有效. // 系统提示 token 数需满足 Anthropic 最低阈值(Sonnet ≥1024,Haiku ≥2048),否则不会建立缓存. EnableCaching bool // HTTPClient 注入自定义 HTTP 客户端. // nil = 使用默认 http.Client (带 Timeout 字段配置的 ResponseHeaderTimeout). // 非 nil 时 consumer 完全接管超时责任, 下面的 Timeout 字段被忽略. HTTPClient *http.Client // Timeout 限制"从请求发出到收到响应首字节"的时间. // // 通过 http.Transport.ResponseHeaderTimeout 实现, **不影响** SSE 流式 body 读取. // 精妙之处(CLEVER): 不要误用 http.Client.Timeout - 会砍死 SSE 流. // 0 = 使用 defaultTimeout (60s).HTTPClient 非 nil 时忽略此字段. // // OpenRouter 特殊情况: 聚合网关会根据底层 provider 在请求间切换, // 某些冷启动的底层 (DeepSeek / Qwen) 响应首字节可能稍慢, 必要时调到 90-120s. Timeout time.Duration } // defaultTimeout 是 Config.Timeout == 0 且 Config.HTTPClient == nil 时的兜底值. // 60s 对 OpenRouter 聚合网关合理: 绝大多数底层 provider 10s 内开始响应. // 冷启动慢的底层 (少数 open-weights 模型) 消费者应显式设置 Timeout=90s 或更多. const defaultTimeout = 60 * time.Second // GoString 实现 fmt.GoStringer,防止 %#v 打印时泄露 APIKey. // 升华改进(ELEVATED): 参见 anthropic.Config.GoString 的说明,同理. func (c Config) GoString() string { return shared.GoStringWithMaskedKey("Config", c.APIKey) } // Provider 是 OpenRouter ModelProvider 实现. type Provider struct { cfg Config client *wire.OpenAICompatClient } // New 创建 OpenRouter ModelProvider. func New(cfg Config) *Provider { if cfg.BaseURL == "" { cfg.BaseURL = defaultBaseURL } var opts []wire.OpenAICompatOption // 超时配置: HTTPClient 和 Timeout 二选一 (见 Config.Timeout 注释). if cfg.HTTPClient != nil { opts = append(opts, wire.WithHTTPClient(cfg.HTTPClient)) } else { timeout := cfg.Timeout if timeout == 0 { timeout = defaultTimeout } opts = append(opts, wire.WithResponseHeaderTimeout(timeout)) } // OpenRouter 推荐的可选 header,用于排行榜和统计 if cfg.SiteURL != "" { opts = append(opts, wire.WithExtraHeader("HTTP-Referer", cfg.SiteURL)) } if cfg.AppName != "" { opts = append(opts, wire.WithExtraHeader("X-Title", cfg.AppName)) } // OpenRouter chat completions 端点 opts = append(opts, wire.WithChatPath("/api/v1/chat/completions")) return &Provider{ cfg: cfg, client: wire.NewOpenAICompatClient(cfg.APIKey, cfg.BaseURL, opts...), } } var _ flyto.ModelProvider = (*Provider)(nil) func (p *Provider) Name() string { return "openrouter" } // openrouterMaxTools 是 OpenRouter 工具数量上限的**兜底安全值**(0 = 跳过检查). // // 升华改进(ELEVATED): data-driven-capabilities RFC PR2.5 - 主决策来自 // req.Capabilities.MaxTools.OpenRouter 透传底层 provider,自身无统一上限: // 底层为 OpenAI 时 128,底层为 Anthropic strict 时 20.兜底 0 跳过检查让底层 API 决定. const openrouterMaxTools = 0 // resolveMaxTools 返回当前请求的工具上限 + 是否为硬性上限(Exhaustive). // registry 优先 + 包内 openrouterMaxTools 兜底. // // 零回归:nil Capabilities → (0, false) → Stream 不进入 check 分支,等同 PR2.5 之前. func resolveMaxTools(req *flyto.Request) (int, bool) { if req.Capabilities != nil && req.Capabilities.MaxTools > 0 { return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive } return openrouterMaxTools, false } // resolveThinkingSupport 返回当前请求模型是否支持 thinking.registry 优先 + 兜底 **true**. // // 操蛋之处(LEGACY)/语义反转:与 ollama/gemini 的兜底 false 不同,openrouter 兜底 **true**. // 原因:openrouter 没有静态模型表(完全依赖 live API 获取),nil Capabilities 时无法查询 // 模型实际能力.假设支持 = 维持早期方案"无脑发送 reasoning 参数"行为,让底层 OpenRouter 决定 // 是否支持.兜底 false 会对所有未注入 Capabilities 的请求 silent disable thinking, // 属于破坏性变更,违反零回归原则. // // 替代方案: - 否决:Stream 热路径不应做网络调用, // registry 的设计就是把 probe 数据前置到内存.没有 probe 数据时假设支持是合理默认. func resolveThinkingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsThinking } return true } // resolveCachingSupport 返回当前请求模型是否支持 caching.registry 优先 + 兜底 **true**. // // 兜底 true 的理由同 resolveThinkingSupport:openrouter 无静态表,零回归要求维持 // 早期方案"无条件注入 EnableSystemCaching"的行为.registry 显式 SupportsCaching=false 时才 // 视为 can=false 触发 warning. // // 注意:OpenRouter 的 caching 路径只对底层为 Anthropic 系列的模型有效 // (cache_control 透传给 Anthropic).这个语义原本就是 "opt-in 且仅部分模型生效"的, // 兜底 true 不引入新的误导. func resolveCachingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsCaching } return true } // detectFeatureWarnings 检测 want × can 不一致的场景,返回 WarningEvent 列表. // // 升华改进(ELEVATED): data-driven-capabilities RFC §4.4 双开关协议. // openrouter 三路径全覆盖(max_tools 不检测,max_tools 错误由 API 400 报); // thinking 和 caching 两路径都检测. // // 零回归设计:兜底路径 can 永远为 true,不会触发 warning;只有 registry 显式声明 // can=false 时才发 WarningEvent.这保证了未接入 registry 的老代码行为完全不变. func (p *Provider) detectFeatureWarnings(req *flyto.Request) []*flyto.WarningEvent { var warnings []*flyto.WarningEvent // thinking want × can 检测 wantsThinking := p.cfg.DefaultThinking || req.NeedsThinking if wantsThinking && !resolveThinkingSupport(req) { warnings = append(warnings, &flyto.WarningEvent{ Code: "feature_unsupported", Message: "Config.DefaultThinking/NeedsThinking 已设置但模型 " + req.Model + " 不支持 thinking,reasoning 参数可能被底层忽略", Detail: "model=" + req.Model + " feature=thinking", }) } // caching want × can 检测 if p.cfg.EnableCaching && !resolveCachingSupport(req) { warnings = append(warnings, &flyto.WarningEvent{ Code: "feature_unsupported", Message: "Config.EnableCaching=true 但模型 " + req.Model + " 不支持 caching,cache_control 可能被底层忽略", Detail: "model=" + req.Model + " feature=caching", }) } return warnings } // prependWarnings 在下游 channel 前面 prepend 一组 WarningEvent. // 参见 anthropic/ollama/openai/gemini 同款实现;RFC §3 决策不抽共享包. 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 } func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) { // Vision guard: openrouter 透传多 provider, 但 flyto 的 BlockImage → wire // 路径尚未接入. 显式报错让调用方切 anthropic. // // openrouter proxies many providers but BlockImage → wire is not yet // wired; explicit error directs callers to the anthropic provider. if err := shared.CheckNoImageBlocks(req.Messages, "openrouter"); err != nil { return nil, err } // CAP-7: Tool 数量上限检查 - data-driven-capabilities RFC PR2.5. // 兜底 openrouterMaxTools=0 表示跳过检查(openrouter 透传,无统一上限); // registry 可注入实测上限.仅 Exhaustive=true 时客户端硬拒. maxTools, exhaustive := resolveMaxTools(req) if maxTools > 0 && exhaustive && len(req.Tools) > maxTools { if err := wire.CheckToolCount(req.Tools, maxTools); err != nil { return nil, err } } // 双开关协议(want × can)检测 - silent disable 转 loud warning. // openrouter 三路径中 thinking 和 caching 两个都检测;max_tools 超限错误由客户端硬拒路径处理. // **注意**:零回归原则下,reasoning 和 EnableSystemCaching 字段**仍然无条件注入**下游 // (即使 can=false),只通过 WarningEvent 告知消费者.不清零字段是为了保留 openrouter 底层 // 可能存在的 fallback 行为. warnings := p.detectFeatureWarnings(req) // CAP-5: $ref 展开--OpenRouter→Gemini 有 $ref 双重序列化 bug. // 错误时 silently skip(保留原始 schema):理由同 minimax provider. tools := make([]flyto.Tool, len(req.Tools)) copy(tools, req.Tools) for i, t := range tools { expanded, err := wire.DereferenceSchema(t.InputSchema) if err == nil { tools[i].InputSchema = expanded } // OpenRouter 聚合多个后端,用 "openai" 而非 "openai-strict"(后端不一定支持 strict) tools[i].InputSchema = wire.AdaptSchema(tools[i].InputSchema, "openai") } wireReq := &wire.StreamRequest{ Model: req.Model, Messages: req.Messages, System: req.System, MaxTokens: req.MaxTokens, Tools: tools, ResponseFormat: req.ResponseFormat, EnableSystemCaching: p.cfg.EnableCaching, Temperature: req.Temperature, TopP: req.TopP, } // 升华改进(ELEVATED): 早期方案只能 Config 级别全局开启 thinking(DefaultThinking=true)-- // NeedsThinking 支持 per-request 动态开启,且 MaxTokens=0 时让 OpenRouter 决定预算. // 替代方案:<为每次 thinking 请求创建新 openrouter.Provider 实例> - // 否决:语义混乱,同一任务内有些 turn 需要 thinking,有些不需要时,多实例管理复杂. if p.cfg.DefaultThinking || req.NeedsThinking { wireReq.Reasoning = &wire.Reasoning{ Enabled: true, MaxTokens: p.cfg.DefaultThinkingTokens, } } ch, err := p.client.Stream(ctx, wireReq) if err != nil { return nil, err } if len(warnings) > 0 { return prependWarnings(ch, warnings), nil } return ch, nil } // Models 从 OpenRouter live API 获取模型列表. // // 精妙之处(CLEVER): OpenRouter 是少数提供完整 live 模型列表的聚合网关-- // 包含定价,上下文窗口,能力标志.我们每次 Models() 调用都实时获取, // 确保模型列表始终反映 OpenRouter 当前支持的最新状态,无需人工维护静态表. // 代价:每次调用有一次 HTTP 请求.可在消费层缓存(引擎不负责缓存). func (p *Provider) Models(ctx context.Context) ([]flyto.ModelInfo, error) { models, err := p.client.FetchOpenRouterModels(ctx) if err != nil { return nil, fmt.Errorf("openrouter: fetch models: %w", err) } return models, nil }