// Package ollama 实现 Ollama 本地模型服务的 ModelProvider. // // Ollama 在本地运行开源 LLM(Llama,DeepSeek,Qwen,Gemma 等), // 提供 OpenAI 兼容的 Chat Completions API. // // 特点: // - 无需 API Key(本地服务无鉴权) // - 通过 /api/tags 端点实时获取已安装的本地模型列表 // - 不支持 Thinking(本地模型通常不支持结构化推理输出) // - 不支持 Prompt Caching 和 Batch API package ollama 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 = "http://localhost:11434" // ollamaMaxTools 是 Ollama 工具数量上限的**兜底安全值**(0 = 本地模型无硬限制). // // 升华改进(ELEVATED): data-driven-capabilities RFC PR2.1 - 主决策来自 req.Capabilities.MaxTools // (由 engine 从 ModelRegistry 注入),本常量仅在 Capabilities == nil 时兜底. // 本地模型通常无严格上限,保留此常量是防御式默认--未来某本地模型实测有上限时, // 通过 capability-probe 写入 registry 即可,无需改本包代码. const ollamaMaxTools = 0 // resolveMaxTools 返回当前请求的工具上限 + 是否为硬性上限(Exhaustive). // registry 优先 + 包内 ollamaMaxTools 兜底. // // 返回值 (maxTools, exhaustive) 语义: // - exhaustive=true → registry 数据是确认上限,客户端硬拒超限请求 // - exhaustive=false → 已知下界或兜底值,软处理(不在客户端拒,让 API 自行处理) // // 反向思维:为什么 Exhaustive=false 时软处理而不硬拒? // 否决硬拒--会误伤 probe 测试覆盖不全的模型.Exhaustive=false 意味着"至少支持 N, // 真实上限可能更高",客户端硬拒比 API 更严格会丢失模型真实能力. func resolveMaxTools(req *flyto.Request) (int, bool) { if req.Capabilities != nil && req.Capabilities.MaxTools > 0 { return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive } return ollamaMaxTools, false } // resolveThinkingSupport 返回当前请求模型是否支持 thinking.registry 优先 + 兜底 false. // // 兜底 false 的理由:ollama 包注释明说"不支持 Thinking(本地模型通常不支持结构化推理输出)". // 若 registry 数据说某本地模型支持(未来 DeepSeek-R1 等推理模型),相信 registry. // // 替代方案:<兜底 true> - 否决:会让所有未探测的本地模型被误判为支持 thinking, // 与包注释直接矛盾,破坏零回归. func resolveThinkingSupport(req *flyto.Request) bool { if req.Capabilities != nil { return req.Capabilities.SupportsThinking } return false } // detectFeatureWarnings 检测 want × can 不一致的场景,返回 WarningEvent 列表. // // 升华改进(ELEVATED): data-driven-capabilities RFC §4.4 双开关协议. // 用户 opt-in 某能力但模型不支持时,之前是 silent disable(用户无感知), // 现在生成 WarningEvent 在流开头 emit,让消费者(engine/observer/TUI)看到降级. // // ollama 当前仅检测 thinking;ollama Config 无 EnableCaching 字段,所以不检测 caching // (want 永远为 false,want×can 矩阵永远不触发).未来若 ollama Config 加入 caching 相关字段, // 再在这里追加一个条件块. // // 操蛋之处(LEGACY): ollama Stream 当前完全不转发 req.NeedsThinking 到 wire 层. // 即使 registry 说模型 can=true 且用户 want=true,ollama 仍会 silent disable. // 此为 ollama 已有债务(PR2.1 是"推广抽象"而非"加新功能"),待 ollama 加 thinking 支持时 // 再一并消除.当前 detectFeatureWarnings 严格遵循 want×can 协议,不另发明新语义. 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. // // 精妙之处(CLEVER): 用 buffered channel(容量 = warnings 数)避免发送方阻塞, // 然后单 goroutine forward 下游事件.下游 close 时 out 也 close,保持 channel 语义一致. // // 性能:零 warnings 时调用方应直接返回原 channel,不调用本函数(避免无意义的 goroutine + buffer). // // 替代方案:<从 anthropic 包 import 共享实现> - 否决:anthropic 包内是小写私有, // 跨包共享需要抽到 providers/shared 包.data-driven-capabilities RFC §3 明确决策 // "不抽共享 helper"(不同 provider 的语义差异会让共享层错综交叉),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 } // Config 是 Ollama provider 的配置. type Config struct { // BaseURL 是 Ollama 服务地址(默认 http://localhost:11434). // 远程 Ollama 实例时填写对应地址,如 "http://192.168.1.100:11434". BaseURL string // 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 (300s, 适合本地推理冷启动). // Ollama 默认比云端长 5 倍: 本地模型 (尤其是第一次调用某个模型) 需要从磁盘加载 // 权重 + warm-up, 可能耗时 30-60s 才开始响应.云端 60s 默认值在 Ollama 会频繁超时. Timeout time.Duration } // defaultTimeout 是 Config.Timeout == 0 且 Config.HTTPClient == nil 时的兜底值. // // 300s (5 分钟) 是本地 Ollama 冷启动的经验值: llama 70B 类大模型首次 load 到 GPU 显存 // 可能需要 1-2 分钟, 再加上第一次 prompt 的 KV cache 填充, 300s 覆盖绝大多数冷启动. // 温启动 (模型已 load) 通常 < 5s 开始响应. // // 反向思维: 为什么不更大 (比如 600s)? - 再大会把"死等 ollama 服务挂掉"的故障模式 // 拖成用户觉得"程序冻住了".5 分钟是"容忍慢推理"和"尽早发现故障"的折中. const defaultTimeout = 300 * time.Second // GoString 实现 fmt.GoStringer,防止 %#v 打印时意外暴露配置详情. // 升华改进(ELEVATED): Ollama 本地部署无 APIKey,BaseURL 暴露内网拓扑也应避免. // 替代方案:<完整打印 BaseURL> - 本地部署 URL 通常不算敏感,但统一遮蔽更安全. func (c Config) GoString() string { return fmt.Sprintf("Config{BaseURL: %q, ...}", c.BaseURL) } // Provider 是 Ollama ModelProvider 实现. type Provider struct { cfg Config client *wire.OpenAICompatClient } // New 创建 Ollama 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)) } // 精妙之处(CLEVER): Ollama 无需 API Key(空字符串), // wire 客户端仍会发送 "Authorization: Bearer " header, // 但 Ollama 服务器忽略无效 Bearer token,不影响功能. // 替代方案:<给 wire 客户端加"跳过 auth header"选项> - // 否决:过度设计,为两行代码增加新配置项不值得. return &Provider{ cfg: cfg, client: wire.NewOpenAICompatClient("", cfg.BaseURL, opts...), } } var _ flyto.ModelProvider = (*Provider)(nil) func (p *Provider) Name() string { return "ollama" } func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) { // Vision guard: ollama 本地模型 vision 支持差异大, BlockImage → wire 尚未 // 统一接入. 先报错让调用方明白当前 vision 路径走 anthropic. // // ollama local models have varied vision support and BlockImage → wire // is not yet uniformly wired; surface an error pointing callers to the // anthropic vision path. if err := shared.CheckNoImageBlocks(req.Messages, "ollama"); err != nil { return nil, err } // CAP-7: Tool 数量上限检查 - data-driven-capabilities RFC PR2.1. // 兜底 ollamaMaxTools=0 表示本地模型无硬限制;registry 可注入实测上限. // 仅 Exhaustive=true 时客户端硬拒,避免误伤 probe 覆盖不全的模型. 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. // 在 client.Stream 之前计算,出错时无需发送 warnings(Stream 未建立). warnings := p.detectFeatureWarnings(req) // CAP-5: Schema 处理--$ref 展开 + 适配 // 注意:本地模型用 "openai",不用 "openai-strict"(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 } // err != nil: silently skip,保留原始 schema,让 provider 决定如何处理 tools[i].InputSchema = wire.AdaptSchema(tools[i].InputSchema, "openai") } 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, // CAP-6: 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 从 Ollama /api/tags 端点获取本地已安装的模型列表. // // Ollama 的模型列表完全由用户本地已拉取(ollama pull)的模型决定, // 因此必须实时获取,静态表没有意义. func (p *Provider) Models(ctx context.Context) ([]flyto.ModelInfo, error) { models, err := p.client.FetchOllamaModels(ctx) if err != nil { return nil, fmt.Errorf("ollama: fetch models: %w", err) } return models, nil }