// Package lmstudio 实现 LM Studio 本地模型服务的 ModelProvider. // // LM Studio 提供 GUI 界面管理本地 LLM,并内置 OpenAI 兼容 API 服务器. // 与 Ollama 不同,LM Studio 使用标准的 OpenAI /v1/models 端点, // 同时也支持 /v1/chat/completions 流式请求. // // 两者对比: // - Ollama:命令行管理,/api/tags 端点,更适合服务器场景 // - LM Studio:GUI 管理,/v1/models 端点,更适合桌面开发场景 package lmstudio 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:1234" // lmstudioMaxTools 是 LM Studio 工具数量上限的**兜底安全值**(0 = 本地模型无硬限制). // // 升华改进(ELEVATED): data-driven-capabilities RFC PR2.2 - 主决策来自 req.Capabilities.MaxTools // (由 engine 从 ModelRegistry 注入),本常量仅在 Capabilities == nil 时兜底. // 本地模型通常无严格上限,未来某本地模型实测有上限时通过 capability-probe 写入 registry 即可. const lmstudioMaxTools = 0 // resolveMaxTools 返回当前请求的工具上限 + 是否为硬性上限(Exhaustive). // registry 优先 + 包内 lmstudioMaxTools 兜底. // // 返回值 (maxTools, exhaustive) 语义: // - exhaustive=true → registry 数据是确认上限,客户端硬拒超限请求 // - exhaustive=false → 已知下界或兜底值,软处理(不在客户端拒,让 API 自行处理) func resolveMaxTools(req *flyto.Request) (int, bool) { if req.Capabilities != nil && req.Capabilities.MaxTools > 0 { return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive } return lmstudioMaxTools, false } // resolveThinkingSupport 返回当前请求模型是否支持 thinking.registry 优先 + 兜底 false. // // 兜底 false 的理由:LM Studio 装载的本地开源模型绝大多数不支持结构化 thinking. // 若 registry 数据说某本地模型支持(未来 DeepSeek-R1,QwQ 等推理模型),相信 registry. 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 双开关协议. // lmstudio 当前仅检测 thinking;Config 无 EnableCaching 字段,所以不检测 caching // (want 永远为 false,want×can 矩阵永远不触发). // // 操蛋之处(LEGACY): lmstudio Stream 当前完全不转发 req.NeedsThinking 到 wire 层. // 即使 registry 说模型 can=true 且用户 want=true,lmstudio 仍会 silent disable. // 此为 lmstudio 已有债务,PR2.2 是"推广抽象"而非"加新功能",债务待后续消除. 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. // // 替代方案:<从 anthropic 包 import 共享实现> - 否决:RFC §3 决策不抽共享 helper, // 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 是 LM Studio provider 的配置. type Config struct { // BaseURL 是 LM Studio 本地 API 服务器地址(默认 http://localhost:1234). 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 一致: 本地模型 load + warm-up 可能耗时 30-60s. // 仅当 HTTPClient 为 nil 时生效; 提供自定义 HTTPClient 时此字段被忽略. Timeout time.Duration } // defaultTimeout 是 Config.Timeout == 0 且 Config.HTTPClient == nil 时的兜底值. // 300s (5 分钟) 与 ollama 对称: 本地推理冷启动经验值, 详见 ollama/provider.go 的论证. const defaultTimeout = 300 * time.Second // GoString 实现 fmt.GoStringer,防止 %#v 打印时意外暴露配置详情. // 升华改进(ELEVATED): LM Studio 本地部署无 APIKey,BaseURL 暴露内网地址也应避免. func (c Config) GoString() string { return fmt.Sprintf("Config{BaseURL: %q, ...}", c.BaseURL) } // Provider 是 LM Studio ModelProvider 实现. type Provider struct { cfg Config client *wire.OpenAICompatClient } // New 创建 LM Studio 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)) } return &Provider{ cfg: cfg, client: wire.NewOpenAICompatClient("", cfg.BaseURL, opts...), } } var _ flyto.ModelProvider = (*Provider)(nil) func (p *Provider) Name() string { return "lmstudio" } func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) { // Vision guard: lmstudio 本地模型 vision 支持差异大, BlockImage → wire // 尚未接入. 和 ollama 同构报错, 让调用方明白切 anthropic. // // lmstudio local models have varied vision support; surface an error // analogous to the ollama guard, pointing callers to anthropic. if err := shared.CheckNoImageBlocks(req.Messages, "lmstudio"); err != nil { return nil, err } // CAP-7: Tool 数量上限检查 - data-driven-capabilities RFC PR2.2. // 兜底 lmstudioMaxTools=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. warnings := p.detectFeatureWarnings(req) // CAP-5: Schema 处理--$ref 展开 + 适配 // 注意:本地模型用 "openai",不用 "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 } // err != nil: silently skip 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 从 LM Studio /v1/models 端点获取当前加载的模型列表. func (p *Provider) Models(ctx context.Context) ([]flyto.ModelInfo, error) { models, err := p.client.FetchOpenAIModels(ctx) if err != nil { return nil, fmt.Errorf("lmstudio: fetch models: %w", err) } // LM Studio 返回的 owned_by 为空,补充 provider 标识 for i := range models { models[i].Provider = "lmstudio" } return models, nil }