package llm import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" ) // imageCache 是全局图片缓存实例。 var imageCache ImageCache // ImageCache 是图片缓存接口。 type ImageCache interface { LoadWithMIME(filename string) (string, string, error) } // SetImageCache 设置全局图片缓存。 func SetImageCache(cache ImageCache) { imageCache = cache } // OpenAIProvider 走 OpenAI 兼容的 /chat/completions 协议(含大多数国产网关)。 type OpenAIProvider struct { BaseURL string APIKey string Model string HTTP *http.Client } type oaFunction struct { Name string `json:"name"` Arguments string `json:"arguments"` } // modelListResponse 是 OpenAI 兼容 GET /models 的响应。 type modelListResponse struct { Data []struct { ID string `json:"id"` } `json:"data"` } // SetModel 运行时切换模型(ModelSwitcher)。 func (p *OpenAIProvider) SetModel(model string) { p.Model = model } // CurrentModel 报告当前模型(CurrentModeler)。 func (p *OpenAIProvider) CurrentModel() string { return p.Model } // Identity 自报协议与当前模型(Identifier)。 func (p *OpenAIProvider) Identity() (string, string) { return FormatChatCompletions, p.Model } // ListModels 枚举网关可用模型(ModelLister)。 func (p *OpenAIProvider) ListModels(ctx context.Context) ([]string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(p.BaseURL, "/")+"/models", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.APIKey) resp, err := p.http().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { var buf bytes.Buffer _, _ = buf.ReadFrom(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("openai: ListModels: HTTP %d: %s", resp.StatusCode, buf.String()) } var out modelListResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } models := make([]string, 0, len(out.Data)) for _, d := range out.Data { if d.ID != "" { models = append(models, d.ID) } } return models, nil } type oaChunk struct { Choices []struct { Delta struct { Content string `json:"content"` // 思考内容:网关字段名不统一(DeepSeek 等用 reasoning_content, // 部分 OpenAI 兼容网关用 reasoning),两者都收。 ReasoningContent string `json:"reasoning_content"` Reasoning string `json:"reasoning"` ToolCalls []struct { Index int `json:"index"` ID string `json:"id"` Function oaFunction `json:"function"` } `json:"tool_calls"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage *struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` // 明细:Chat Completions 的 prompt_tokens 已含缓存命中部分,这里只取 breakdown。 // 兼容网关不一定返回,缺省为 0。 PromptDetails struct { Cached int `json:"cached_tokens"` } `json:"prompt_tokens_details"` CompletionDetails struct { Reasoning int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` } `json:"usage"` } func (p *OpenAIProvider) Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) { body := map[string]any{ "model": p.Model, "messages": oaBuildMessages(joinSystem(system, opts.SystemDynamic), msgs), "stream": true, "stream_options": map[string]any{"include_usage": true}, } if len(tools) > 0 { body["tools"] = oaBuildTools(tools) // 只禁用调用、不撤定义:工具数组保持不变,prompt cache 前缀才能命中。 if opts.DisableTools { body["tool_choice"] = "none" } } // 思考级别:仅开启时发送,默认关闭保持请求体不变。 if opts.Thinking.Enabled() { body["reasoning_effort"] = string(opts.Thinking) } payload, err := json.Marshal(body) if err != nil { return Message{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload)) if err != nil { return Message{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.APIKey) resp, err := doWithRetry(ctx, p.http(), req, payload) if err != nil { return Message{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { var buf bytes.Buffer _, _ = buf.ReadFrom(io.LimitReader(resp.Body, 4096)) return Message{}, fmt.Errorf("openai: HTTP %d: %s", resp.StatusCode, buf.String()) } final := Message{Role: RoleAssistant} var callsByIndex = map[int]*ToolCall{} maxIndex := -1 emitted := map[int]bool{} // emitReady 把 index < upTo 的工具调用视为参数完整并提前通知。 // OpenAI 的增量协议没有显式"调用结束"事件:下一个 index 出现、或 // finish_reason 到达,前一个调用的参数就完整了。 emitReady := func(upTo int) { if onDelta == nil { return } for i := 0; i < upTo; i++ { if emitted[i] { continue } c := callsByIndex[i] if c == nil || c.Name == "" { continue } emitted[i] = true tc := *c onDelta(StreamDelta{ToolCall: &tc}) } } sg := guardStream(resp.Body, streamIdleTimeout) defer sg.Stop() sc := bufio.NewScanner(sg) sc.Buffer(make([]byte, 1<<20), 1<<20) var st streamState for sc.Scan() { line := sc.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { st.doneSeen = true break } var ck oaChunk if err := json.Unmarshal([]byte(data), &ck); err != nil { continue } if ck.Usage != nil { st.usageSeen = true final.Usage = &Usage{ InputTokens: ck.Usage.PromptTokens, OutputTokens: ck.Usage.CompletionTokens, CacheReadTokens: ck.Usage.PromptDetails.Cached, ReasoningTokens: ck.Usage.CompletionDetails.Reasoning, } } for _, ch := range ck.Choices { if ch.FinishReason != "" { st.doneSeen = true // finish_reason 也是终止证据 if marksOutputLimit(ch.FinishReason) { final.OutputLimited = true } } reasoning := ch.Delta.ReasoningContent if reasoning == "" { reasoning = ch.Delta.Reasoning } if reasoning != "" { final.Reasoning += reasoning if onDelta != nil { onDelta(StreamDelta{Reasoning: reasoning}) } } if ch.Delta.Content != "" { final.Content += ch.Delta.Content if onDelta != nil { onDelta(StreamDelta{Text: ch.Delta.Content}) } } for _, tc := range ch.Delta.ToolCalls { cur := callsByIndex[tc.Index] if cur == nil { cur = &ToolCall{} callsByIndex[tc.Index] = cur } if tc.Index > maxIndex { maxIndex = tc.Index } if tc.ID != "" { cur.ID = tc.ID } if tc.Function.Name != "" { cur.Name = tc.Function.Name } if tc.Function.Arguments != "" { cur.Input += tc.Function.Arguments } emitReady(tc.Index) } if ch.FinishReason != "" { emitReady(maxIndex + 1) } } } if err := sc.Err(); err != nil { // 被静默看门狗掐断时,说真实原因:那句 "read on closed response body" // 对排查毫无帮助,看起来像我们自己的连接坏了。 if stall := sg.Err(); stall != nil { return Message{}, fmt.Errorf("openai: %w", stall) } return Message{}, fmt.Errorf("openai: stream: %w", err) } // 没等到 [DONE] 就结束,且是看门狗掐断的:不能把半截回复当完整结果返回。 if stall := sg.Err(); stall != nil { return Message{}, fmt.Errorf("openai: %w", stall) } // 流结束还没发完的通知(例如只有一个 tool call、没有下一个 index)在这里补齐。 emitReady(maxIndex + 1) for i := 0; i <= maxIndex; i++ { if c, ok := callsByIndex[i]; ok { final.ToolCalls = append(final.ToolCalls, *c) } } return completion("openai", final, st) } // joinSystem 把动态 system 追加到稳定 system 尾部。只要动态段在整条消息的末尾, // 稳定前缀仍然逐 token 相同,OpenAI 的自动前缀缓存就能继续命中;用一条消息也 // 避免部分兼容网关不接受多条 system 消息。 func joinSystem(system, dynamic string) string { if dynamic == "" { return system } if system == "" { return dynamic } return system + "\n\n" + dynamic } func oaBuildMessages(system string, msgs []Message) []map[string]any { out := []map[string]any{} if system != "" { out = append(out, map[string]any{"role": "system", "content": system}) } for _, m := range msgs { switch { case m.Role == RoleTool: out = append(out, map[string]any{"role": "tool", "content": m.Content, "tool_call_id": m.ToolCallID}) // OpenAI tool 消息不支持图片:以 user 消息附图(紧随 tool result)。 if len(m.Images) > 0 { out = append(out, map[string]any{ "role": "user", "content": oaContentParts("[image(s) attached from tool result]", m.Images), }) } case len(m.ToolCalls) > 0: tcs := []map[string]any{} for _, tc := range m.ToolCalls { tcs = append(tcs, map[string]any{ "id": tc.ID, "type": "function", "function": map[string]any{"name": tc.Name, "arguments": tc.Input}, }) } out = append(out, map[string]any{"role": "assistant", "content": m.Content, "tool_calls": tcs}) case len(m.Images) > 0: out = append(out, map[string]any{"role": string(m.Role), "content": oaContentParts(m.Content, m.Images)}) default: out = append(out, map[string]any{"role": string(m.Role), "content": m.Content}) } } return out } // oaContentParts 构造 OpenAI 多模态 content 数组;读取失败的图降级为文本说明。 // 支持两种图片引用方式: // 1. Filename: 从缓存目录加载(优先) // 2. Path: 直接文件路径(兼容旧格式) func oaContentParts(text string, imgs []Image) []map[string]any { parts := []map[string]any{} if text != "" { parts = append(parts, map[string]any{"type": "text", "text": text}) } for _, img := range imgs { b64, mt, err := loadImageRef(img) if err != nil { parts = append(parts, map[string]any{"type": "text", "text": imageUnavailable(img, err)}) continue } parts = append(parts, map[string]any{ "type": "image_url", "image_url": map[string]any{"url": "data:" + mt + ";base64," + b64}, }) } return parts } func oaBuildTools(tools []ToolDef) []map[string]any { out := make([]map[string]any, 0, len(tools)) for _, t := range tools { out = append(out, map[string]any{ "type": "function", "function": map[string]any{ "name": t.Name, "description": t.Description, "parameters": t.Parameters, }, }) } return out } func (p *OpenAIProvider) http() *http.Client { if p.HTTP != nil { return p.HTTP } return defaultHTTPClient }