llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
375 lines
11 KiB
Go
375 lines
11 KiB
Go
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
|
||
}
|