// Package openai 实现 OpenAI Chat Completions API 的 ModelProvider. // // 支持所有 OpenAI 官方模型(GPT-4o,o1,o3 系列等), // 通过 Config.ModelOverrides 可注入厂商新发布但我们尚未维护的模型. // // 内部复用 internal/wire/openai_compat.go 处理 SSE 流式协议, // 本包只负责配置工厂和维护静态模型表. package openai import ( "context" "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" ) // Config 是 OpenAI provider 的配置. type Config struct { // APIKey 是 OpenAI API 密钥(sk-... 格式). APIKey string // BaseURL 覆盖 API 端点(默认 https://api.openai.com). // 用于 Azure OpenAI,兼容 OpenAI 的私有化端点等. BaseURL string // HTTPClient 注入自定义 HTTP 客户端. // nil = 使用默认 http.Client (带 Timeout 字段配置的 ResponseHeaderTimeout). // 非 nil 时 consumer 完全接管超时责任, 下面的 Timeout 字段被忽略. HTTPClient *http.Client // Timeout 限制"从请求发出到收到响应首字节"的时间. // // 通过 http.Transport.ResponseHeaderTimeout 实现, **不影响** SSE 流式响应的后续 // body 读取 - 长流式回复 (2-5 分钟) 可以正常读完.这是 LLM provider 的正确超时 // 语义: 捕捉服务端死等, 放行长流式输出. // // 精妙之处(CLEVER): 不要误用 http.Client.Timeout - 那会把 SSE 流砍死. // 详见 internal/wire/openai.go defaultResponseHeaderTimeout 注释. // // 0 = 使用 defaultTimeout (60s, 适合 OpenAI 云端). // 仅当 HTTPClient 为 nil 时生效; 提供自定义 HTTPClient 时此字段被忽略. Timeout time.Duration // ModelOverrides 覆盖或扩展静态模型表. // 厂商发布新模型时,无需等待我们更新代码即可使用. ModelOverrides []flyto.ModelInfo } // defaultTimeout 是 Config.Timeout == 0 且 Config.HTTPClient == nil 时的 ResponseHeaderTimeout 兜底值. // 60s 对云端 OpenAI 合理: GPT-4/o1/o3 通常在 < 10s 内开始响应, 60s 给网络抖动 / 排队 / // 冷启动充足余量.流式响应 body 读取阶段不受此值影响 (见 Config.Timeout 注释). 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 是 OpenAI ModelProvider 实现. type Provider struct { cfg Config client *wire.OpenAICompatClient } // New 创建 OpenAI ModelProvider. func New(cfg Config) *Provider { if cfg.BaseURL == "" { cfg.BaseURL = "https://api.openai.com" } var opts []wire.OpenAICompatOption // 超时配置: HTTPClient 和 Timeout 二选一, 不混用. // 精妙之处(CLEVER): 二选一比"两个字段叠加"简单 - 消费者注入 HTTPClient 就是 // 完全接管超时策略, 我们不去 mutate 他的 Transport (rude).消费者不注入, 我们用 // Timeout 字段 (或 defaultTimeout 兜底) 配置 ResponseHeaderTimeout. 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)) } return &Provider{ cfg: cfg, client: wire.NewOpenAICompatClient(cfg.APIKey, cfg.BaseURL, opts...), } } var _ flyto.ModelProvider = (*Provider)(nil) func (p *Provider) Name() string { return "openai" } func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) { // Vision guard: OpenAI provider 尚未接入 BlockImage wire, 图片路径走 // Anthropic provider. 遇到 image block 显式报错而不是静默丢弃, 让调用方 // 明白"这张图没送到模型", 不是静默失败. // // OpenAI provider does not yet wire BlockImage -- vision path routes // through Anthropic provider. Surface an error on image blocks rather // than silently dropping, so callers learn the image never reached the // model instead of getting a silent pass. if err := shared.CheckNoImageBlocks(req.Messages, "openai"); err != nil { return nil, err } // CAP-7: OpenAI Chat API 工具数量上限 - data-driven-capabilities RFC PR2.3. // OpenAI 的 max_tools 是 API 级硬上限,永远硬拒超限请求(不走软处理路径). // registry 可注入更新的上限覆盖包内 const 兜底(比如 OpenAI 未来放宽到 256). // 替代方案:<静默截断到上限> - 否决:截断会改变 agent 的能力语义. maxTools := resolveMaxTools(req) if err := wire.CheckToolCount(req.Tools, maxTools); err != nil { return nil, err } // 双开关协议(want × can)检测 - silent disable 转 loud warning. warnings := p.detectFeatureWarnings(req) // CAP-6: OpenAI strict 模式裁剪不支持的 JSON Schema 组合约束(allOf/not/if/then/else 等). // 普通 OpenAI 不裁剪(非 strict 模式支持完整 JSON Schema). // 当前引擎层无 strict 标志,统一应用 openai-strict 裁剪以求保守兼容. // 替代方案:<仅 strict 请求裁剪> - 否决:引擎层无法探知 request 是否 strict, // openai-strict 裁剪的字段(allOf/not 等)在 OpenAI 非 strict 模式下也较少用到, // 保守裁剪无实质副作用. tools := make([]flyto.Tool, len(req.Tools)) copy(tools, req.Tools) for i, t := range tools { if expanded, err := wire.DereferenceSchema(t.InputSchema); err == nil { tools[i].InputSchema = expanded } tools[i].InputSchema = wire.AdaptSchema(tools[i].InputSchema, "openai-strict") } ch, err := p.client.Stream(ctx, &wire.StreamRequest{ Model: req.Model, Messages: req.Messages, System: req.System, MaxTokens: req.MaxTokens, Tools: tools, ResponseFormat: req.ResponseFormat, Temperature: req.Temperature, TopP: req.TopP, }) if err != nil { return nil, err } // 零 warnings 时直接返回原 channel,跳过 prependWarnings 的 goroutine 开销. if len(warnings) > 0 { return prependWarnings(ch, warnings), nil } return ch, nil } // Models 返回 OpenAI 可用模型列表. // // 历史包袱(LEGACY): OpenAI 有 /v1/models 端点,但返回的模型列表包含 // embedding,audio,image 等非 chat 模型,且不包含定价和上下文窗口信息. // 我们维护静态表覆盖主要 chat 模型,通过 ModelOverrides 补充新模型. // 未来改进:调用 /v1/models 后与静态表合并,补充定价信息. func (p *Provider) Models(_ context.Context) ([]flyto.ModelInfo, error) { if len(p.cfg.ModelOverrides) > 0 { return p.cfg.ModelOverrides, nil } return openaiModels, nil } // openaiMaxTools 是 OpenAI Chat API 单次请求允许的最大工具数(2026-04 OpenAPI spec). // // 精妙之处(CLEVER): 静态常量而非 ModelInfo.MaxTools per-model-- // OpenAI 的工具上限是 **API 级别**(所有模型统一),不是 per-model 的. // 这是 openai 和其他 provider 的根本差异,它不存在 "probe 到下界但没触顶" 的情形. // // 升华改进(ELEVATED): data-driven-capabilities RFC PR2.3 - 本常量降级为兜底, // registry 可注入更新的上限(比如未来 OpenAI 把 128 放宽到 256). // 但 openai 的 resolveMaxTools **强制 Exhaustive=true**(无论 registry 字段如何), // 因为 OpenAI 的 max_tools 语义永远是硬性上限. const openaiMaxTools = 128 // resolveMaxTools 返回当前请求的 OpenAI 工具数量上限.registry 优先 + 包内 const 兜底. // // 单返回值设计(不返回 exhaustive): OpenAI API 级上限的语义永远是硬性的, // 不存在软处理空间(anthropic/ollama 的 probe 下界不触顶概念对 OpenAI 不适用). // 因此 openai 的 Stream 永远走硬拒路径,用 int 而非 (int, bool) 更诚实. // // 反向思维:为什么不复用 anthropic 同款 (int, bool) 签名? // 否决--强行对齐签名会让 openai 的"永远 exhaustive"变成 boolean flag 的噪音, // 且埋下一个陷阱:如果 probe 数据漏写 exhaustive 字段(registry 默认 false), // openai 的硬拒路径会 silent 失效,造成零回归违规. // 用单 int 签名明确传达"openai 无软处理",把不变量编码进类型而非注释. func resolveMaxTools(req *flyto.Request) int { if req.Capabilities != nil && req.Capabilities.MaxTools > 0 { return req.Capabilities.MaxTools } return openaiMaxTools } // modelSupportsThinkingFallback 扫静态表判断模型是否支持 thinking. // 当 req.Capabilities == nil 时使用,o1/o3/o3-mini 返回 true,其余返回 false. func modelSupportsThinkingFallback(model string) bool { for _, m := range openaiModels { if m.ID == model { return m.SupportsThinking } } return false } // resolveThinkingSupport 返回当前请求模型是否支持 thinking.registry 优先 + 静态表兜底. // // 注意:openai provider **不主动注入 thinking 参数**(o 系列由 API 自动决定是否推理). // 本 helper 的作用不是决定"是否发送 thinking 字段",而是供 detectFeatureWarnings 判断 // 用户 NeedsThinking=true 但模型不支持的 silent disable 场景. func resolveThinkingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsThinking } return modelSupportsThinkingFallback(req.Model) } // detectFeatureWarnings 检测 want × can 不一致的场景,返回 WarningEvent 列表. // // 升华改进(ELEVATED): data-driven-capabilities RFC §4.4 双开关协议. // openai 当前仅检测 thinking;Config 无 EnableCaching 字段(OpenAI 是 implicit caching // 自动缓存,用户无显式开关),所以不检测 caching. func (p *Provider) detectFeatureWarnings(req *flyto.Request) []*flyto.WarningEvent { var warnings []*flyto.WarningEvent if req.NeedsThinking && !resolveThinkingSupport(req) { warnings = append(warnings, &flyto.WarningEvent{ Code: "feature_unsupported", Message: "req.NeedsThinking 已设置但模型 " + req.Model + " 不支持 thinking,thinking 已被忽略", Detail: "model=" + req.Model + " feature=thinking", }) } return warnings } // prependWarnings 在下游 channel 前面 prepend 一组 WarningEvent. // 参见 anthropic/ollama 同款实现;RFC §3 决策不抽共享包,duplication 可接受. 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 } // openaiModels 是 OpenAI 主要 chat 模型静态表(2026-04). // 价格单位:USD / 1M tokens. var openaiModels = []flyto.ModelInfo{ { ID: "gpt-4o", DisplayName: "GPT-4o", Provider: "openai", ContextWindow: 128_000, MaxOutputTokens: 16_000, InputPricePer1M: 2.50, OutputPricePer1M: 10.0, SupportsCaching: true, // OpenAI 自动缓存(implicit) SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "gpt-4o-mini", DisplayName: "GPT-4o Mini", Provider: "openai", ContextWindow: 128_000, MaxOutputTokens: 16_000, InputPricePer1M: 0.15, OutputPricePer1M: 0.60, SupportsCaching: true, SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "gpt-4.1", DisplayName: "GPT-4.1", Provider: "openai", ContextWindow: 1_000_000, MaxOutputTokens: 32_000, InputPricePer1M: 2.0, OutputPricePer1M: 8.0, SupportsCaching: true, SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "gpt-5", DisplayName: "GPT-5", Provider: "openai", ContextWindow: 400_000, MaxOutputTokens: 32_000, InputPricePer1M: 1.25, OutputPricePer1M: 10.0, SupportsCaching: true, SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "o1", DisplayName: "OpenAI o1", Provider: "openai", ContextWindow: 200_000, MaxOutputTokens: 100_000, InputPricePer1M: 15.0, OutputPricePer1M: 60.0, SupportsCaching: true, SupportsThinking: true, SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "o3", DisplayName: "OpenAI o3", Provider: "openai", ContextWindow: 200_000, MaxOutputTokens: 100_000, InputPricePer1M: 10.0, OutputPricePer1M: 40.0, SupportsCaching: true, SupportsThinking: true, SupportsVision: true, MaxTools: openaiMaxTools, }, { ID: "o3-mini", DisplayName: "OpenAI o3-mini", Provider: "openai", ContextWindow: 200_000, MaxOutputTokens: 100_000, InputPricePer1M: 1.10, OutputPricePer1M: 4.40, SupportsCaching: true, SupportsThinking: true, SupportsVision: false, MaxTools: openaiMaxTools, }, }