Files
AutoZero/llm/openai.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
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。
2026-10-26 00:54:46 +08:00

375 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}