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。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// AnthropicProvider 走 Anthropic Messages API(SSE 流式 + 工具调用)。
type AnthropicProvider struct {
BaseURL string
APIKey string
Model string
MaxTokens int // 单次回复上限;0 = 8192
HTTP *http.Client
}
// SetModel 运行时切换模型(ModelSwitcher)。
func (p *AnthropicProvider) SetModel(model string) {
p.Model = model
}
// CurrentModel 报告当前模型(CurrentModeler)。
func (p *AnthropicProvider) CurrentModel() string { return p.Model }
// Identity 自报协议与当前模型(Identifier)。
func (p *AnthropicProvider) Identity() (string, string) { return FormatAnthropic, p.Model }
type anBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"` // tool_result 内容
// Signature / Data 用于 thinking 回传签名与 redacted_thinking 块。
Signature string `json:"signature,omitempty"`
Data string `json:"data,omitempty"`
}
type anStreamEvent struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"` // thinking_delta 的推理文本
Signature string `json:"signature"` // signature_delta:thinking 块签名
PartialJSON string `json:"partial_json"` // input_json_delta
// StopReason 只在 message_delta 里出现(end_turn / max_tokens / tool_use …)。
// max_tokens 是"模型想写却写不下",见 Message.OutputLimited。
StopReason string `json:"stop_reason"`
} `json:"delta"`
ContentBlock anBlock `json:"content_block"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// 缓存明细:Anthropic 把命中/写入单列,**不计入** input_tokens。
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
} `json:"usage"`
Message *struct {
Usage struct {
InputTokens int `json:"input_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
// anUsage 把 Anthropic 的分段计数归一成 Usage——口径见 llm.Usage 的注释:
// InputTokens 必须含缓存段。本文件确实给 system+tools 前缀打了 cache_control,
// 照抄 input_tokens 会把命中缓存的那一大段整个漏掉,终端计数与 stats 的
// TokensIn 因此低报一个数量级(压缩走字符估算,不受影响)。
func anUsage(input, cacheRead, cacheWrite, output int) *Usage {
return &Usage{
InputTokens: input + cacheRead + cacheWrite,
OutputTokens: output,
CacheReadTokens: cacheRead,
CacheCreationTokens: cacheWrite,
}
}
// thinkingBudget 返回 Anthropic 扩展思考的 token 预算;off/零值返回 ok=false。
func thinkingBudget(l ThinkingLevel) (int, bool) {
switch l {
case ThinkingLow:
return 2048, true
case ThinkingMedium:
return 8192, true
case ThinkingHigh:
return 16384, true
default:
return 0, false
}
}
func (p *AnthropicProvider) Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) {
blocks := anBuildMessages(msgs, opts.Thinking.Enabled())
maxTokens := p.MaxTokens
if maxTokens <= 0 {
maxTokens = 8192
}
// 扩展思考:Anthropic 要求 max_tokens > budget_tokens,故按预算抬高上限,
// 并额外预留 8192 作为正文回答预算。
budget, thinking := thinkingBudget(opts.Thinking)
if thinking && maxTokens < budget+8192 {
maxTokens = budget + 8192
}
body := map[string]any{
"model": p.Model,
"max_tokens": maxTokens,
"messages": blocks,
}
if thinking {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
if system != "" || opts.SystemDynamic != "" {
// 稳定 system 单独成块并标记 cache_control:工具表 + 稳定前缀在同一
// 会话内逐字节不变,可命中 Anthropic prompt cache。动态部分(计划/记忆)
// 放在断点之后的第二块,它的变化不会让缓存前缀失效。
blocks := []map[string]any{}
if system != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": system,
"cache_control": map[string]any{"type": "ephemeral"},
})
}
if opts.SystemDynamic != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": opts.SystemDynamic})
}
body["system"] = blocks
}
if len(tools) > 0 {
ts := make([]map[string]any, 0, len(tools))
for _, t := range tools {
ts = append(ts, map[string]any{
"name": t.Name,
"description": t.Description,
"input_schema": t.Parameters,
})
}
// 最后一个工具上标记 cache_control,覆盖整个 tools 前缀。
ts[len(ts)-1]["cache_control"] = map[string]any{"type": "ephemeral"}
body["tools"] = ts
}
if opts.DisableTools && len(tools) > 0 {
body["tool_choice"] = map[string]any{"type": "none"}
}
payload, err := json.Marshal(body)
if err != nil {
return Message{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(p.BaseURL, "/")+"/v1/messages", bytes.NewReader(payload))
if err != nil {
return Message{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
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("anthropic: HTTP %d: %s", resp.StatusCode, buf.String())
}
final := Message{Role: RoleAssistant}
var st streamState
var curTool *ToolCall
// 分段计数在事件之间是分散给的:message_start 给提示侧(含缓存明细),
// message_delta 给输出侧,且常常**不重复**缓存字段。所以要学下来存着,
// 谁非零谁覆盖,否则归一出来的总量会漏掉 message_start 那一份。
var baseIn, cachedRead, cachedWrite int
sg := guardStream(resp.Body, streamIdleTimeout)
defer sg.Stop()
sc := bufio.NewScanner(sg)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var ev anStreamEvent
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
continue
}
switch ev.Type {
case "message_start":
if ev.Message != nil {
u := ev.Message.Usage
if u.InputTokens > 0 {
baseIn = u.InputTokens
}
if u.CacheRead > 0 {
cachedRead = u.CacheRead
}
if u.CacheWrite > 0 {
cachedWrite = u.CacheWrite
}
final.Usage = anUsage(baseIn, cachedRead, cachedWrite, 0)
}
case "content_block_start":
b := ev.ContentBlock
if b.Type == "tool_use" {
curTool = &ToolCall{ID: b.ID, Name: b.Name}
}
// 安全策略触发时返回脱敏思考块,需原样回传。
if b.Type == "redacted_thinking" && b.Data != "" {
final.RedactedThinking = b.Data
}
case "content_block_delta":
// 扩展思考:thinking_delta 携带推理文本,signature_delta 携带回传签名。
if ev.Delta.Type == "signature_delta" {
final.ReasoningSignature = ev.Delta.Signature
} else if ev.Delta.Type == "thinking_delta" && ev.Delta.Thinking != "" {
final.Reasoning += ev.Delta.Thinking
if onDelta != nil {
onDelta(StreamDelta{Reasoning: ev.Delta.Thinking})
}
} else if ev.Delta.Text != "" {
final.Content += ev.Delta.Text
if onDelta != nil {
onDelta(StreamDelta{Text: ev.Delta.Text})
}
} else if curTool != nil && ev.Delta.Type == "input_json_delta" {
curTool.Input += ev.Delta.PartialJSON
}
case "content_block_stop":
if curTool != nil {
final.ToolCalls = append(final.ToolCalls, *curTool)
// 参数到 content_block_stop 才算完整:提前告诉调用方,只读工具
// 可以趁模型继续生成后面的块先跑。
if onDelta != nil {
tc := *curTool
onDelta(StreamDelta{ToolCall: &tc})
}
curTool = nil
}
case "message_delta":
if marksOutputLimit(ev.Delta.StopReason) {
final.OutputLimited = true
}
if ev.Usage != nil {
st.usageSeen = true
if ev.Usage.InputTokens > 0 {
baseIn = ev.Usage.InputTokens
}
if ev.Usage.CacheRead > 0 {
cachedRead = ev.Usage.CacheRead
}
if ev.Usage.CacheWrite > 0 {
cachedWrite = ev.Usage.CacheWrite
}
final.Usage = anUsage(baseIn, cachedRead, cachedWrite, ev.Usage.OutputTokens)
}
case "message_stop":
st.doneSeen = true
return completion("anthropic", final, st)
}
}
if err := sc.Err(); err != nil {
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("anthropic: %w", stall)
}
return Message{}, fmt.Errorf("anthropic: stream: %w", err)
}
// 没收到 message_stop 就结束,且是看门狗掐断的:绝不能把半截内容当成品返回。
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("anthropic: %w", stall)
}
return completion("anthropic", final, st)
}
// anThinkingBlocks 构造需要回传的 thinking / redacted_thinking 块。
// 只在本次请求开启思考时回传:thinking 关闭时必须剥离,否则 Anthropic 会拒绝。
// 工具调用场景下不回传 thinking 块同样会被拒(要求完整原文 + 签名)。
func anThinkingBlocks(m Message) []map[string]any {
var out []map[string]any
if m.RedactedThinking != "" {
out = append(out, map[string]any{"type": "redacted_thinking", "data": m.RedactedThinking})
}
if m.Reasoning != "" {
block := map[string]any{"type": "thinking", "thinking": m.Reasoning}
if m.ReasoningSignature != "" {
block["signature"] = m.ReasoningSignature
}
out = append(out, block)
}
return out
}
func anBuildMessages(msgs []Message, thinking bool) []map[string]any {
out := []map[string]any{}
for _, m := range msgs {
switch {
case m.Role == RoleTool:
tr := map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID}
if len(m.Images) == 0 {
tr["content"] = m.Content
} else {
// tool_result.content 支持块数组:文本 + 图片(Anthropic 原生支持)。
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
content = append(content, anImageBlocks(m.Images)...)
tr["content"] = content
}
out = append(out, map[string]any{"role": "user", "content": []any{tr}})
case len(m.ToolCalls) > 0:
blocks := []any{}
// thinking 块必须是 assistant content 的第一个块。
if thinking {
for _, b := range anThinkingBlocks(m) {
blocks = append(blocks, b)
}
}
if m.Content != "" {
blocks = append(blocks, anBlock{Type: "text", Text: m.Content})
}
for _, tc := range m.ToolCalls {
input := json.RawMessage(tc.Input)
if len(input) == 0 {
input = json.RawMessage("{}")
}
blocks = append(blocks, anBlock{Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: input})
}
out = append(out, map[string]any{"role": "assistant", "content": blocks})
case len(m.Images) > 0:
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
content = append(content, anImageBlocks(m.Images)...)
out = append(out, map[string]any{"role": string(m.Role), "content": content})
default:
// 纯文本 assistant 消息也可能带思考,同样需要回传(块数组形式)。
if thinking && (m.Reasoning != "" || m.RedactedThinking != "") {
content := []any{}
for _, b := range anThinkingBlocks(m) {
content = append(content, b)
}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
out = append(out, map[string]any{"role": string(m.Role), "content": content})
continue
}
out = append(out, map[string]any{"role": string(m.Role), "content": m.Content})
}
}
return out
}
// anImageBlocks 构造 Anthropic image 块;读取失败的图降级为文本说明。
// 支持两种图片引用方式:
// 1. Filename: 从缓存目录加载(优先)
// 2. Path: 直接文件路径(兼容旧格式)
func anImageBlocks(imgs []Image) []map[string]any {
blocks := []map[string]any{}
for _, img := range imgs {
b64, mt, err := loadImageRef(img)
if err != nil {
blocks = append(blocks, map[string]any{"type": "text", "text": imageUnavailable(img, err)})
continue
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{"type": "base64", "media_type": mt, "data": b64},
})
}
return blocks
}
func (p *AnthropicProvider) http() *http.Client {
if p.HTTP != nil {
return p.HTTP
}
return defaultHTTPClient
}
+114
View File
@@ -0,0 +1,114 @@
package llm
import (
"errors"
"fmt"
"strings"
)
// 空补全:一次模型调用回来,**正文、工具调用、思考内容三样全没有**。
//
// 为什么单独立一个错误:网关把 SSE 流"干净地关掉"(不发 [DONE]/message_stop,
// 也不触发静默看门狗)时,provider 会返回一条空 Message + nil error,而 agent
// 的收尾判定是"这条没有 tool_calls ⇒ 模型说完了"——于是回合被当成**成功结束**,
// 界面一个字都不出。用户看到的症状是"干不了几步就停,得手动按继续"。
// 真实取证:20261009-213751 一次 136 步的会话里有 18 个这样的空回合
// (全部 usage 缺失、中位 3.0s、12 个紧跟在工具结果之后),对应 23 次人工「继续」。
//
// 判定刻意**不依赖终止事件**:有些兼容网关确实不发 [DONE],拿"没收到终止事件"
// 当条件是会把能用的配置弄坏的。"整轮什么都没有"则是无歧义的——一个 agent 回合
// 里什么都没有,永远不是一次有用的最终回答。
// ErrEmptyCompletion 是"这一轮什么都没返回"。可被 errors.Is 识别,上层据此重试。
var ErrEmptyCompletion = errors.New("模型返回空回复(没有正文、没有工具调用、没有思考内容)")
// IsEmptyCompletion 判断一次补全是否什么都没有。usage 不参与判定——
// 观测到的坏流量 usage 也缺失,但"有 usage 却整轮空"同样不该被当成最终回答。
func IsEmptyCompletion(m Message) bool {
return strings.TrimSpace(m.Content) == "" &&
strings.TrimSpace(m.Reasoning) == "" &&
len(m.ToolCalls) == 0
}
// ErrTruncatedStream 是"流被掐断":既没收到任何终止事件,也没拿到 usage。
//
// 为什么两种信号都要缺才算:网关不发 [DONE] 的情况真实存在(拿"没收到终止事件"
// 单独当条件会把这些配置弄坏),而有些网关确实不回 usage。两个都没有才算证据——
// 一次正常的补全至少留下其中一个。
//
// 咬人的样子(会话 20261010-092121 实测):一次 62 秒的调用只吐出 "rawData" 一个词、
// usage 缺失、没有工具调用,而全库 179 条 assistant 只有这一条 usage 缺失。它被
// "没有 tool_calls ⇒ 模型说完了"当成正常收尾,用户只能打「继续」。
var ErrTruncatedStream = errors.New("模型回复被截断(流里既没有终止事件也没有 usage)")
// streamState 是一次流式调用的收尾证据,由各家 provider 在解析循环里填。
type streamState struct {
usageSeen bool // 收到过 usage 块
doneSeen bool // 收到 [DONE] / message_stop / response.completed / finish_reason
}
// outputLimitReasons 是三家里"输出被 token 上限切断"的终止原因。
// 各家叫法不同(Chat Completions 用 length,Anthropic 用 max_tokens,Responses 用
// max_output_tokens),归成一个信号给上层,别让 agent 认三家方言。
//
// 刻意**不含** model_context_window_exceeded:那是上下文满了,续写只会连着撞同一堵墙,
// 该走的是压缩(agent/context.go),不是"接着写"。
var outputLimitReasons = map[string]bool{
"length": true,
"max_tokens": true,
"max_output_tokens": true,
}
// IsTransientStream 报告一次失败属于"这条请求没走完 / 网关没答上"——重发同一个请求
// 就有机会拿到完整回复。与之相对的是**确定性**失败(HTTP 400 说请求本身不合法、鉴权
// 失败、上下文满),重发只会再撞同一堵墙,所以那些不该重试。
//
// 三类证据:整轮空、流被截断、流卡死到空闲看门狗上限;再加一组传输层措辞(连接被掐、
// 半路 EOF、网关 429/5xx)。它们都发生在流已开始之后,llm 那层的前置重试
// (doWithRetry 只覆盖"响应开始前")管不到,只能由上层按请求重发。
func IsTransientStream(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrEmptyCompletion) || errors.Is(err, ErrTruncatedStream) {
return true
}
var stalled *StalledError
if errors.As(err, &stalled) {
return true
}
msg := strings.ToLower(err.Error())
for _, mark := range transientErrorMarks {
if strings.Contains(msg, mark) {
return true
}
}
return false
}
// transientErrorMarks 是传输层失败的措辞。刻意不含 cancel:人被叫停时不该再发请求。
var transientErrorMarks = []string{
"connection reset", "connection refused", "broken pipe", "i/o timeout",
"eof", "too many requests", "http 429", "http 500", "http 502", "http 503", "http 504",
}
func marksOutputLimit(reason string) bool {
return outputLimitReasons[strings.ToLower(strings.TrimSpace(reason))]
}
// completion 是三家 provider 的统一出口:空回复与截断流都按失败返回。
// prefix 带上协议名,报错时能看出是哪一路 provider 撞的。
func completion(prefix string, m Message, st streamState) (Message, error) {
if IsEmptyCompletion(m) {
return Message{}, fmt.Errorf("%s: %w", prefix, ErrEmptyCompletion)
}
if !st.usageSeen && !st.doneSeen {
return Message{}, fmt.Errorf("%s: %w", prefix, ErrTruncatedStream)
}
// 带工具调用的响应不算"被切断的最终回答":那时 finish_reason 通常是 tool_calls,
// 而真是 length 也说明参数 JSON 已经残缺——续写救不回来,该重发这一步(另一件事)。
if len(m.ToolCalls) > 0 {
m.OutputLimited = false
}
return m, nil
}
+188
View File
@@ -0,0 +1,188 @@
package llm
import (
"context"
"errors"
"strings"
"testing"
)
// emptyStreamCases 是三家协议下"这一轮什么都没回来"的流形状。
//
// 关键不是"没收到终止事件"(有些兼容网关确实不发 [DONE],拿它当条件会把能用的
// 配置弄坏),而是**正文/工具调用/思考三样全空**。真实取证:一次 136 步的会话里
// 有 18 个这样的回合,全部 usage 缺失,每个都让用户多按一次「继续」。
func TestProvidersRejectEmptyStream(t *testing.T) {
cases := []struct {
name string
sse string
call func(url string) (Provider, error)
}{
{
name: "chat_completions:流直接关掉",
sse: "",
call: func(u string) (Provider, error) {
return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil
},
},
{
name: "chat_completions:只有 finish_reason 没有内容",
sse: "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n",
call: func(u string) (Provider, error) {
return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil
},
},
{
name: "anthropic:只有 message_stop",
sse: "data: {\"type\":\"message_stop\"}\n\n",
call: func(u string) (Provider, error) {
return &AnthropicProvider{BaseURL: u, APIKey: "k", Model: "m", MaxTokens: 128}, nil
},
},
{
name: "responses:completed 但 output 为空",
sse: "data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n",
call: func(u string) (Provider, error) {
return &ResponsesProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var body map[string]any
srv := captureServer(&body, c.sse)
defer srv.Close()
p, err := c.call(srv.URL)
if err != nil {
t.Fatal(err)
}
_, err = p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if !errors.Is(err, ErrEmptyCompletion) {
t.Fatalf("want ErrEmptyCompletion, got %v", err)
}
// 报错里要带协议名:三家都可能是那条坏流,排查时得知道是谁。
if !strings.Contains(err.Error(), "openai") && !strings.Contains(err.Error(), "anthropic") &&
!strings.Contains(err.Error(), "responses") {
t.Fatalf("错误里没有协议归因:%v", err)
}
})
}
}
// TestProvidersAcceptThinStreams:反过来也要钉住——只要有任何一样内容,就不算空。
// 尤其"只有思考没有正文"这一种,把模型的真实工作误判成失败会白白重试三次。
func TestProvidersAcceptThinStreams(t *testing.T) {
thin := []struct {
name string
sse string
call func(url string) Provider
}{
{"chat_completions 只回一句", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n",
func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} }},
{"chat_completions 只有工具调用", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{}\"}}]}}]}\n\ndata: [DONE]\n\n",
func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} }},
{"anthropic 只有 thinking", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"T\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n",
func(u string) Provider {
return &AnthropicProvider{BaseURL: u, APIKey: "k", Model: "m", MaxTokens: 128}
}},
}
for _, c := range thin {
t.Run(c.name, func(t *testing.T) {
var body map[string]any
srv := captureServer(&body, c.sse)
defer srv.Close()
if _, err := c.call(srv.URL).Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
t.Fatalf("不该判成空回复:%v", err)
}
})
}
}
func TestIsEmptyCompletion(t *testing.T) {
cases := []struct {
name string
m Message
want bool
}{
{"全空", Message{}, true},
{"只有空白正文", Message{Content: " \n\t "}, true},
{"有正文", Message{Content: "答"}, false},
{"只有思考", Message{Reasoning: "想"}, false},
{"只有工具调用", Message{ToolCalls: []ToolCall{{ID: "1", Name: "bash"}}}, false},
// usage 不参与判定:观测到的坏流 usage 也缺失,但"有 usage 却整轮空"
// 同样不是一次有用的最终回答。
{"有 usage 但什么都没有", Message{Usage: &Usage{OutputTokens: 1}}, true},
}
for _, c := range cases {
if got := IsEmptyCompletion(c.m); got != c.want {
t.Errorf("%s: IsEmptyCompletion = %v, want %v", c.name, got, c.want)
}
}
}
// TestProvidersDetectTruncatedStream:真实取证那一例——62 秒只回一个词、usage 缺失、
// 没有终止事件。判据要求**两个证据都缺**,所以另外两种"缺一个"的合法形状必须放过:
// 有的网关不发 [DONE],有的不回 usage(我们确实请求了 include_usage,但兼容层不一定给)。
func TestProvidersDetectTruncatedStream(t *testing.T) {
cases := []struct {
name string
sse string
wantErr bool
provider func(url string) Provider
}{
{
name: "chat_completions:有正文,流静默关掉(实报的形状)",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"rawData\"}}]}\n\n",
wantErr: true,
provider: func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} },
},
{
name: "chat_completions:有正文 + [DONE],没 usage(兼容网关常态)",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"答\"}}]}\n\ndata: [DONE]\n\n",
wantErr: false,
provider: func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} },
},
{
name: "chat_completions:有正文 + usage,没 [DONE]",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"答\"}}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2}}\n\n",
wantErr: false,
provider: func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} },
},
{
name: "chat_completions:只有 finish_reason 就断",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"答\"},\"finish_reason\":\"stop\"}]}\n\n",
wantErr: false, // finish_reason 本身就是终止证据
provider: func(u string) Provider { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"} },
},
{
name: "anthropic:text 块开了却没等到 message_stop",
sse: "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"role\":\"assistant\",\"content\":[],\"usage\":{\"input_tokens\":1}}}\n\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"半句\"}}\n\n",
wantErr: true,
provider: func(u string) Provider {
return &AnthropicProvider{BaseURL: u, APIKey: "k", Model: "m", MaxTokens: 128}
},
},
{
name: "responses:有增量却没有 response.completed",
sse: "data: {\"type\":\"response.output_text.delta\",\"delta\":\"半句\"}\n\n",
wantErr: true,
provider: func(u string) Provider { return &ResponsesProvider{BaseURL: u, APIKey: "k", Model: "m"} },
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var body map[string]any
srv := captureServer(&body, c.sse)
defer srv.Close()
_, err := c.provider(srv.URL).Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if got := err != nil; got != c.wantErr {
t.Fatalf("wantErr=%v,实际 err=%v", c.wantErr, err)
}
if c.wantErr && !errors.Is(err, ErrTruncatedStream) {
t.Fatalf("该报 ErrTruncatedStream,得到 %v", err)
}
})
}
}
+57
View File
@@ -0,0 +1,57 @@
package llm
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
)
var mediaTypes = map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp",
}
// maxImageBytes 限制单张图大小,避免一次请求爆掉 token/体积。
const maxImageBytes = 8 << 20
// LoadImage 读图并 base64 编码,返回 (b64, mediaType)。
func LoadImage(path string) (string, string, error) {
ext := strings.ToLower(filepath.Ext(path))
mt, ok := mediaTypes[ext]
if !ok {
return "", "", fmt.Errorf("unsupported image type %q", ext)
}
data, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
if len(data) > maxImageBytes {
return "", "", fmt.Errorf("image too large: %d bytes (limit %d)", len(data), maxImageBytes)
}
return base64.StdEncoding.EncodeToString(data), mt, nil
}
// ImageEstimateTokens 是单张图在 token 粗估中的保守常数(1080p 截图约 1.1-1.6k tokens)。
const ImageEstimateTokens = 1400
// loadImageRef 统一解析图片引用:Filename 走缓存(优先),Path 直读(兼容旧格式)。
func loadImageRef(img Image) (b64, mime string, err error) {
if img.Filename != "" && imageCache != nil {
return imageCache.LoadWithMIME(img.Filename)
}
if img.Path != "" {
return LoadImage(img.Path)
}
return "", "", fmt.Errorf("no image path or filename provided")
}
// imageUnavailable 文本占位(图片读取失败时各 provider 的统一降级文案)。
func imageUnavailable(img Image, err error) string {
ref := img.Path
if img.Filename != "" {
ref = img.Filename
}
return "[image unavailable: " + ref + ": " + err.Error() + "]"
}
+92
View File
@@ -0,0 +1,92 @@
package llm
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// 1x1 红色 PNG
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
func writeTinyPNG(t *testing.T) string {
t.Helper()
data, _ := base64.StdEncoding.DecodeString(tinyPNG)
path := filepath.Join(t.TempDir(), "shot.png")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadImage(t *testing.T) {
path := writeTinyPNG(t)
b64, mt, err := LoadImage(path)
if err != nil {
t.Fatal(err)
}
if mt != "image/png" || b64 != tinyPNG {
t.Fatalf("roundtrip failed: mt=%s len=%d", mt, len(b64))
}
if _, _, err := LoadImage("x.txt"); err == nil {
t.Fatal("expected unsupported type error")
}
}
func TestOpenAISerializationWithImages(t *testing.T) {
path := writeTinyPNG(t)
msgs := []Message{
{Role: RoleUser, Content: "look", Images: []Image{{Path: path}}},
{Role: RoleTool, ToolCallID: "t1", Content: "done", Images: []Image{{Path: path}}},
}
out := oaBuildMessages("sys", msgs)
// user 消息 → content parts 数组(text + image_url)
u := out[1]
if u["role"] != "user" {
t.Fatalf("role: %v", u["role"])
}
parts, _ := json.Marshal(u["content"])
if !strings.Contains(string(parts), `"image_url"`) || !strings.Contains(string(parts), "data:image/png;base64,") {
t.Fatalf("user image parts: %s", parts)
}
// tool result → tool 消息 + 紧随的 user 附图消息
if out[2]["role"] != "tool" || out[3]["role"] != "user" {
t.Fatalf("tool+image layout wrong: %v %v", out[2]["role"], out[3]["role"])
}
parts, _ = json.Marshal(out[3]["content"])
if !strings.Contains(string(parts), "data:image/png") {
t.Fatalf("tool image parts: %s", parts)
}
}
func TestAnthropicSerializationWithImages(t *testing.T) {
path := writeTinyPNG(t)
msgs := []Message{
{Role: RoleTool, ToolCallID: "t1", Content: "shot", Images: []Image{{Path: path}}},
{Role: RoleUser, Content: "look again", Images: []Image{{Path: path}}},
}
out := anBuildMessages(msgs, false)
body, _ := json.Marshal(out)
if !strings.Contains(string(body), `"type":"tool_result"`) ||
!strings.Contains(string(body), `"type":"image"`) ||
!strings.Contains(string(body), `"media_type":"image/png"`) {
t.Fatalf("anthropic serialization: %s", body)
}
}
func TestImageUnavailableDegradation(t *testing.T) {
msgs := []Message{{Role: RoleUser, Content: "look", Images: []Image{{Path: "/nonexistent/shot.png"}}}}
oa := oaBuildMessages("s", msgs)
body, _ := json.Marshal(oa[1]["content"])
if !strings.Contains(string(body), "[image unavailable") {
t.Fatalf("openai degradation missing: %s", body)
}
an := anBuildMessages(msgs, false)
body, _ = json.Marshal(an[0]["content"])
if !strings.Contains(string(body), "[image unavailable") {
t.Fatalf("anthropic degradation missing: %s", body)
}
}
+374
View File
@@ -0,0 +1,374 @@
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
}
+129
View File
@@ -0,0 +1,129 @@
package llm
import (
"context"
"strings"
"testing"
)
// 输出上限切断的识别:三家的叫法不同,归一成 Message.OutputLimited 一个信号。
// 每个 fixture 都带正文——"什么都没有"是空回复那条路(ErrEmptyCompletion),
// 这里要抓的是"合法、有内容、但没写完"。
func TestProvidersDetectOutputLimit(t *testing.T) {
cases := []struct {
name string
sse string
call func(url string) (Provider, error)
want bool
}{
{
name: "chat_completions:finish_reason=length",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"第一段写完了,第二段\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":7}}\n\n" +
"data: [DONE]\n\n",
call: func(u string) (Provider, error) { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil },
want: true,
},
{
name: "chat_completions:finish_reason=stop 是正常收尾",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"说完了\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":3}}\n\n" +
"data: [DONE]\n\n",
call: func(u string) (Provider, error) { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil },
want: false,
},
{
// 上下文满 ≠ 输出被切断:续写只会连着撞同一堵墙,该走压缩。
name: "chat_completions:context window 不算输出切断",
sse: "data: {\"choices\":[{\"delta\":{\"content\":\"半截\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"model_context_window_exceeded\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n" +
"data: [DONE]\n\n",
call: func(u string) (Provider, error) { return &OpenAIProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil },
want: false,
},
{
name: "anthropic:message_delta 的 stop_reason=max_tokens",
sse: "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"还在写\"}}\n\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"},\"usage\":{\"output_tokens\":64}}\n\n" +
"data: {\"type\":\"message_stop\"}\n\n",
call: func(u string) (Provider, error) {
return &AnthropicProvider{BaseURL: u, APIKey: "k", Model: "m", MaxTokens: 128}, nil
},
want: true,
},
{
name: "responses:incomplete + max_output_tokens",
sse: "data: {\"type\":\"response.output_text.delta\",\"delta\":\"写了一半\"}\n\n" +
"data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"output\":[],\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":4,\"output_tokens\":9}}}\n\n",
call: func(u string) (Provider, error) { return &ResponsesProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil },
want: true,
},
{
// 审核拦下的 incomplete 续写无意义(模型不是没写完,是不肯写完)。
name: "responses:incomplete 但原因是内容审核",
sse: "data: {\"type\":\"response.output_text.delta\",\"delta\":\"抱歉\"}\n\n" +
"data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"output\":[],\"incomplete_details\":{\"reason\":\"content_filter\"},\"usage\":{\"input_tokens\":4,\"output_tokens\":2}}}\n\n",
call: func(u string) (Provider, error) { return &ResponsesProvider{BaseURL: u, APIKey: "k", Model: "m"}, nil },
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var body map[string]any
srv := captureServer(&body, c.sse)
defer srv.Close()
p, err := c.call(srv.URL)
if err != nil {
t.Fatal(err)
}
m, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatalf("这一轮有正文,不该报错:%v", err)
}
if m.OutputLimited != c.want {
t.Errorf("OutputLimited = %v,want %v(正文 %q)", m.OutputLimited, c.want, m.Content)
}
if strings.TrimSpace(m.Content) == "" {
t.Error("fixture 没把正文收进来,用例本身失效")
}
})
}
}
// TestToolCallAtOutputLimitIsNotMarkedCut:带工具调用的响应不算"被切断的最终回答"。
// 真是 length 也说明参数 JSON 已经残缺,续写救不回来——标了反而让上层去续一个
// 不该续的东西。
func TestToolCallAtOutputLimitIsNotMarkedCut(t *testing.T) {
sse := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"type\":\"function\"," +
"\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}]}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":4}}\n\n" +
"data: [DONE]\n\n"
var body map[string]any
srv := captureServer(&body, sse)
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
m, err := p.Stream(context.Background(), "sys", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if len(m.ToolCalls) != 1 {
t.Fatalf("工具调用没收到:%+v", m.ToolCalls)
}
if m.OutputLimited {
t.Error("带 tool_calls 的响应被标成了输出切断")
}
}
func TestMarksOutputLimit(t *testing.T) {
for r, want := range map[string]bool{
"length": true, "LENGTH": true, " max_tokens": true, "max_output_tokens": true,
"stop": false, "tool_calls": false, "": false, "model_context_window_exceeded": false,
"content_filter": false,
} {
if got := marksOutputLimit(r); got != want {
t.Errorf("marksOutputLimit(%q) = %v,want %v", r, got, want)
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package llm
import (
"fmt"
"strings"
)
// APIFormat 是自定义供应商的 API 协议格式。
type APIFormat = string
const (
// FormatAnthropic 走 Anthropic Messages API(/v1/messages)。
FormatAnthropic = "anthropic"
// FormatChatCompletions 走 OpenAI Chat Completions(/chat/completions)。
FormatChatCompletions = "chat_completions"
// FormatResponses 走 OpenAI Responses API(/responses)。
FormatResponses = "responses"
)
// NormalizeAPIFormat 归一化用户输入的格式别名;未知格式返回空串。
func NormalizeAPIFormat(s string) APIFormat {
switch strings.ToLower(strings.TrimSpace(s)) {
case "anthropic", "anthropic_messages", "messages":
return FormatAnthropic
case "chat_completions", "chatcompletions", "openai", "chat":
return FormatChatCompletions
case "responses", "response":
return FormatResponses
}
return ""
}
// ProviderSpec 描述一个模型供应商的接入参数。
type ProviderSpec struct {
Name string // 展示名
BaseURL string
APIKey string
APIFormat APIFormat
Model string // 默认模型
AllowedModels []string // /models 白名单;空 = 不筛选
MaxTokens int // anthropic 单次回复上限
}
// NewProvider 按 spec 构造对应协议的 Provider。
func NewProvider(spec ProviderSpec) (Provider, error) {
format := spec.APIFormat
if format == "" {
format = guessFormat(spec.BaseURL)
}
switch format {
case FormatAnthropic:
return &AnthropicProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model, MaxTokens: spec.MaxTokens}, nil
case FormatResponses:
return &ResponsesProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model}, nil
case FormatChatCompletions:
return &OpenAIProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model}, nil
}
return nil, fmt.Errorf("llm: unknown api_format %q (want anthropic | chat_completions | responses)", spec.APIFormat)
}
// guessFormat 依据 BaseURL 猜协议:含 "anthropic" 视为 anthropic,其余默认 chat_completions。
func guessFormat(baseURL string) APIFormat {
if strings.Contains(strings.ToLower(baseURL), "anthropic") {
return FormatAnthropic
}
return FormatChatCompletions
}
// DedupeModels 去空白、大小写不敏感去重(保留首次出现的原始写法与顺序)。
func DedupeModels(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[strings.ToLower(s)] {
continue
}
seen[strings.ToLower(s)] = true
out = append(out, s)
}
if len(out) == 0 {
return nil
}
return out
}
// FilterAllowed 按白名单筛选模型列表;白名单为空返回原列表。
// 支持通配前缀("glm-*")与精确名;大小写不敏感。
func FilterAllowed(models []string, allowed []string) []string {
if len(allowed) == 0 {
return models
}
out := make([]string, 0, len(models))
for _, m := range models {
for _, a := range allowed {
if matchesAllowed(m, a) {
out = append(out, m)
break
}
}
}
return out
}
// matchesAllowed 报告单个模型是否匹配一条条目:精确名(EqualFold)或
// 前缀通配(尾部 *)。FilterAllowed(条目=白名单、模型=候选)与
// MissingRegisteredModels(条目=登记模型、模型=远端目录)共用同一匹配语义,
// 两处口径漂移会让"下架判定"和"白名单筛选"打架。
func matchesAllowed(model, pattern string) bool {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == "" {
return false
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(strings.ToLower(model), strings.TrimSuffix(pattern, "*"))
}
return strings.EqualFold(model, pattern)
}
// MissingRegisteredModels 返回登记模型里在 fetched(远端 /models 目录)中
// 已不存在的条目——「已下架」的判定源,TUI 多选表格与 WebUI 供应商页共用。
// 保序、保留原文写法、大小写不敏感去重;只做精确名比对(EqualFold),
// 通配条目(含 *)**不参与下架判定**:模式没法对具体 ID 目录作存在性结论,
// 按"目录里仍有前缀匹配"算存活会掩盖真下架,按字面比对又必然误报。
//
// fetched 为空(拉取失败 / anthropic 无枚举端点)返回 nil:没有目录就没有
// 资格谈下架,误标比漏标危害大。
func MissingRegisteredModels(registered, fetched []string) []string {
if len(registered) == 0 || len(fetched) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, r := range registered {
r = strings.TrimSpace(r)
if r == "" || seen[strings.ToLower(r)] || strings.HasSuffix(r, "*") {
continue // 通配条目跳过判定(见上),不代表存活也不标下架
}
seen[strings.ToLower(r)] = true
alive := false
for _, m := range fetched {
if strings.EqualFold(m, r) {
alive = true
break
}
}
if !alive {
out = append(out, r)
}
}
return out
}
+139
View File
@@ -0,0 +1,139 @@
package llm
import (
"reflect"
"testing"
)
func TestFilterAllowed(t *testing.T) {
models := []string{"glm-4.7", "glm-4.7-air", "glm-4.6", "deepseek-v4-flash", "gpt-5-mini"}
// 空白名单不筛选
if got := FilterAllowed(models, nil); !reflect.DeepEqual(got, models) {
t.Errorf("FilterAllowed(nil) = %v, want unchanged", got)
}
// 前缀通配
if got := FilterAllowed(models, []string{"glm-*"}); !reflect.DeepEqual(got, []string{"glm-4.7", "glm-4.7-air", "glm-4.6"}) {
t.Errorf("FilterAllowed(glm-*) = %v", got)
}
// 精确名大小写不敏感 + 通配混合
if got := FilterAllowed(models, []string{"GLM-4.7", "deepseek-*"}); !reflect.DeepEqual(got, []string{"glm-4.7", "deepseek-v4-flash"}) {
t.Errorf("FilterAllowed(mixed) = %v", got)
}
// 全不匹配
if got := FilterAllowed(models, []string{"claude-*"}); len(got) != 0 {
t.Errorf("FilterAllowed(claude-*) = %v, want empty", got)
}
}
func TestNormalizeAPIFormat(t *testing.T) {
cases := map[string]APIFormat{
"anthropic": FormatAnthropic,
"Anthropic_Messages": FormatAnthropic,
"openai": FormatChatCompletions,
"chat": FormatChatCompletions,
"chat_completions": FormatChatCompletions,
"Responses": FormatResponses,
"": "",
"graphql": "",
}
for in, want := range cases {
if got := NormalizeAPIFormat(in); got != want {
t.Errorf("NormalizeAPIFormat(%q) = %q, want %q", in, got, want)
}
}
}
func TestNewProviderFormats(t *testing.T) {
// 三种格式各自构造出对应 provider
p1, err := NewProvider(ProviderSpec{BaseURL: "https://x.com", APIFormat: FormatAnthropic})
if err != nil || p1 == nil {
t.Errorf("anthropic: %v", err)
}
p2, err := NewProvider(ProviderSpec{BaseURL: "https://x.com/v1", APIFormat: FormatChatCompletions})
if err != nil {
t.Errorf("chat: %v", err)
}
if _, ok := p2.(*OpenAIProvider); !ok {
t.Errorf("chat_completions should yield OpenAIProvider, got %T", p2)
}
p3, err := NewProvider(ProviderSpec{BaseURL: "https://x.com/v1", APIFormat: FormatResponses})
if err != nil {
t.Errorf("responses: %v", err)
}
if _, ok := p3.(*ResponsesProvider); !ok {
t.Errorf("responses should yield ResponsesProvider, got %T", p3)
}
// 未知格式报错
if _, err := NewProvider(ProviderSpec{BaseURL: "https://x.com", APIFormat: "graphql"}); err == nil {
t.Error("unknown format should error")
}
// BaseURL 含 anthropic 时自动推断
p4, _ := NewProvider(ProviderSpec{BaseURL: "https://api.anthropic.com"})
if _, ok := p4.(*AnthropicProvider); !ok {
t.Errorf("anthropic URL should infer AnthropicProvider, got %T", p4)
}
}
// TestMissingRegisteredModels:下架判定——大小写不敏感、通配条目不参与
// 判定(既不标下架也不算存活)、空目录不判定、原文保序去重。
func TestMissingRegisteredModels(t *testing.T) {
fetched := []string{"glm-4.7", "GLM-4.6", "cogview-4", "glm-4.5-air"}
cases := []struct {
name string
registered []string
fetched []string
want []string
}{
{
name: "精确匹配大小写不敏感",
registered: []string{"glm-4.7", "glm-4.6", "dead-model"},
fetched: fetched,
want: []string{"dead-model"},
},
{
name: "通配条目不参与判定",
registered: []string{"glm-*", "kling-*"},
fetched: fetched,
want: nil, // 模式无法对具体目录下结论:跳过,不标下架
},
{
name: "空目录不判定(拉取失败不误标)",
registered: []string{"glm-4.7"},
fetched: nil,
want: nil,
},
{
name: "保序去重、跳过空条目",
registered: []string{"b-dead", "", "a-dead", "B-DEAD"},
fetched: fetched,
want: []string{"b-dead", "a-dead"},
},
{
name: "全部存活返回 nil",
registered: []string{"GLM-4.7", "cogview-4"},
fetched: fetched,
want: nil,
},
}
for _, c := range cases {
got := MissingRegisteredModels(c.registered, c.fetched)
if len(got) != len(c.want) {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("%s: got[%d]=%q, want %q", c.name, i, got[i], c.want[i])
}
}
}
}
// TestFilterAllowed 与 MissingRegisteredModels 口径一致:同一个匹配函数。
func TestFilterAllowedWildcard(t *testing.T) {
got := FilterAllowed([]string{"glm-4.7", "cogview-4"}, []string{"glm-*"})
if len(got) != 1 || got[0] != "glm-4.7" {
t.Errorf("FilterAllowed = %v, want [glm-4.7]", got)
}
}
+354
View File
@@ -0,0 +1,354 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// ResponsesProvider 走 OpenAI Responses API(/responses,SSE 流式)。
// 与 Chat Completions 的差异:事件驱动流(response.output_text.delta 等)、
// 工具调用经 function_call/function_call_output 消息往返、图片用 input_image。
type ResponsesProvider struct {
BaseURL string
APIKey string
Model string
HTTP *http.Client
}
// SetModel 运行时切换模型(ModelSwitcher)。
func (p *ResponsesProvider) SetModel(model string) {
p.Model = model
}
// CurrentModel 报告当前模型(CurrentModeler)。
func (p *ResponsesProvider) CurrentModel() string { return p.Model }
// Identity 自报协议与当前模型(Identifier)。
func (p *ResponsesProvider) Identity() (string, string) { return FormatResponses, p.Model }
// ListModels 枚举网关可用模型(ModelLister),与 Chat Completions 同一 GET /models。
func (p *ResponsesProvider) ListModels(ctx context.Context) ([]string, error) {
op := &OpenAIProvider{BaseURL: p.BaseURL, APIKey: p.APIKey, HTTP: p.HTTP}
return op.ListModels(ctx)
}
// rsEvent 是 /responses SSE 事件包络(外层只取 type,其余按事件类型二次解码)。
type rsEvent struct {
Type string `json:"type"`
}
// rsItemEvent 载荷:response.output_item.added / .done 的 function_call 项。
type rsItemEvent struct {
Item *rsOutputItem `json:"item"`
}
// rsArgsDeltaEvent 载荷:response.function_call_arguments.delta。
type rsArgsDeltaEvent struct {
ItemID string `json:"item_id"`
CallID string `json:"call_id"`
Arguments string `json:"delta"`
}
// rsCompletedEvent 载荷:response.completed / response.incomplete。
type rsCompletedEvent struct {
Response struct {
Output []rsOutputItem `json:"output"`
Usage rsUsageWrap `json:"usage"`
// Status 是 completed / incomplete;incomplete 时必须看 IncompleteDetails
// 才知道是不是"输出上限截住"(另一种是内容审核拦下)。
Status string `json:"status"`
IncompleteDetails struct {
Reason string `json:"reason"`
} `json:"incomplete_details"`
} `json:"response"`
}
type rsOutputItem struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type rsUsageWrap struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// 明细:input_tokens 已含缓存命中部分(与 Chat Completions 同口径)。
InputDetails struct {
Cached int `json:"cached_tokens"`
} `json:"input_tokens_details"`
OutputDetails struct {
Reasoning int `json:"reasoning_tokens"`
} `json:"output_tokens_details"`
}
func (p *ResponsesProvider) Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) {
body := map[string]any{
"model": p.Model,
"input": rsBuildInput(joinSystem(system, opts.SystemDynamic), msgs),
"stream": true,
"store": false,
"max_output_tokens": 8192,
}
if len(tools) > 0 {
body["tools"] = rsBuildTools(tools)
if opts.DisableTools {
body["tool_choice"] = "none"
}
}
// 思考级别:仅开启时发送;推理 token 计入 max_output_tokens,故一并抬高上限防截断。
if opts.Thinking.Enabled() {
body["reasoning"] = map[string]any{"effort": string(opts.Thinking)}
body["max_output_tokens"] = 16384
}
payload, err := json.Marshal(body)
if err != nil {
return Message{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(p.BaseURL, "/")+"/responses", 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("responses: HTTP %d: %s", resp.StatusCode, buf.String())
}
final := Message{Role: RoleAssistant}
var st streamState
// callID -> 增量参数缓冲(function_call_arguments.delta 事件流)。
argBuf := map[string]*ToolCall{}
// itemID -> callID(function_call_arguments 事件只带 item_id/或 call_id,二者取其一)。
itemToCall := map[string]string{}
emitted := map[string]bool{}
emitCall := func(tc ToolCall) {
if onDelta == nil || tc.ID == "" || emitted[tc.ID] {
return
}
emitted[tc.ID] = true
c := tc
onDelta(StreamDelta{ToolCall: &c})
}
sg := guardStream(resp.Body, streamIdleTimeout)
defer sg.Stop()
sc := bufio.NewScanner(sg)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
raw := []byte(strings.TrimPrefix(line, "data: "))
var env rsEvent
if err := json.Unmarshal(raw, &env); err != nil {
continue
}
switch env.Type {
case "response.output_item.added", "response.output_item.done":
var ev rsItemEvent
if err := json.Unmarshal(raw, &ev); err != nil || ev.Item == nil || ev.Item.Type != "function_call" {
continue
}
// added 通常只带名字,done 带完整参数;参数为空时保留增量缓冲里已收到的那份。
tc := argBuf[ev.Item.CallID]
if tc == nil {
tc = &ToolCall{ID: ev.Item.CallID}
argBuf[ev.Item.CallID] = tc
}
if ev.Item.Name != "" {
tc.Name = ev.Item.Name
}
if ev.Item.Arguments != "" {
tc.Input = ev.Item.Arguments
}
if ev.Item.ID != "" {
itemToCall[ev.Item.ID] = ev.Item.CallID
}
// done 事件的 parameters 已完整:提前通知,只读工具可先跑。
if env.Type == "response.output_item.done" {
emitCall(*tc)
}
case "response.function_call_arguments.delta":
var ev rsArgsDeltaEvent
if err := json.Unmarshal(raw, &ev); err != nil {
continue
}
callID := ev.CallID
if callID == "" {
callID = itemToCall[ev.ItemID] // 某些网关只带 item_id
}
if tc := argBuf[callID]; tc != nil {
tc.Input += ev.Arguments
}
// 思考内容:summary(多网关可见)与完整推理文本都收。
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
var ev struct {
Delta string `json:"delta"`
}
if err := json.Unmarshal(raw, &ev); err != nil || ev.Delta == "" {
continue
}
final.Reasoning += ev.Delta
if onDelta != nil {
onDelta(StreamDelta{Reasoning: ev.Delta})
}
case "response.output_text.delta":
var ev struct {
Delta string `json:"delta"`
}
if err := json.Unmarshal(raw, &ev); err != nil || ev.Delta == "" {
continue
}
final.Content += ev.Delta
if onDelta != nil {
onDelta(StreamDelta{Text: ev.Delta})
}
case "response.completed", "response.incomplete":
var ev rsCompletedEvent
if err := json.Unmarshal(raw, &ev); err == nil {
final.Usage = &Usage{
InputTokens: ev.Response.Usage.InputTokens,
OutputTokens: ev.Response.Usage.OutputTokens,
CacheReadTokens: ev.Response.Usage.InputDetails.Cached,
ReasoningTokens: ev.Response.Usage.OutputDetails.Reasoning,
}
// 以 completed 事件的权威 output 为准重建 tool calls(增量网关兜底)。
final.ToolCalls = nil
for _, it := range ev.Response.Output {
if it.Type == "function_call" && it.Name != "" {
tc := ToolCall{ID: it.CallID, Name: it.Name, Input: it.Arguments}
final.ToolCalls = append(final.ToolCalls, tc)
emitCall(tc)
}
}
// incomplete 有两种:输出上限截住(可续写)与内容审核拦下(续写无意义)。
if ev.Response.Status == "incomplete" && marksOutputLimit(ev.Response.IncompleteDetails.Reason) {
final.OutputLimited = true
}
}
st.doneSeen = true
return completion("responses", final, st)
}
}
if err := sc.Err(); err != nil {
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("responses: %w", stall)
}
return Message{}, fmt.Errorf("responses: stream: %w", err)
}
// 看门狗掐断的流不走"尽力拼装":那会把半截输出当成一轮完整回复。
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("responses: %w", stall)
}
// 无 response.completed(连接截断):用增量缓冲尽力拼装。
for _, tc := range argBuf {
if tc.Name != "" {
final.ToolCalls = append(final.ToolCalls, *tc)
emitCall(*tc)
}
}
return completion("responses", final, st)
}
// rsBuildInput 构造 /responses 的 input 数组:system 作为首条 system 消息,
// tool 结果映射为 function_call_output,assistant 工具调用拆成 function_call 条目。
func rsBuildInput(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{
"type": "function_call_output",
"call_id": m.ToolCallID,
"output": m.Content,
})
if len(m.Images) > 0 {
// function_call_output 不收图片:附一条 user 消息带图。
out = append(out, map[string]any{
"role": "user",
"content": rsContentParts("[image(s) attached from tool result]", m.Images),
})
}
case len(m.ToolCalls) > 0:
// assistant 的 function_call 拆成独立条目;文本部分单独一条。
if m.Content != "" {
out = append(out, map[string]any{"role": "assistant", "content": m.Content})
}
for _, tc := range m.ToolCalls {
out = append(out, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": tc.Name,
"arguments": tc.Input,
})
}
case len(m.Images) > 0:
out = append(out, map[string]any{"role": string(m.Role), "content": rsContentParts(m.Content, m.Images)})
default:
out = append(out, map[string]any{"role": string(m.Role), "content": m.Content})
}
}
return out
}
// rsContentParts 构造多模态 content(text + input_image)。
func rsContentParts(text string, imgs []Image) []map[string]any {
parts := []map[string]any{}
if text != "" {
parts = append(parts, map[string]any{"type": "input_text", "text": text})
}
for _, img := range imgs {
b64, mt, err := loadImageRef(img)
if err != nil {
parts = append(parts, map[string]any{"type": "input_text", "text": imageUnavailable(img, err)})
continue
}
parts = append(parts, map[string]any{
"type": "input_image",
"image_url": "data:" + mt + ";base64," + b64,
})
}
return parts
}
func rsBuildTools(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",
"name": t.Name,
"description": t.Description,
"parameters": t.Parameters,
})
}
return out
}
func (p *ResponsesProvider) http() *http.Client {
if p.HTTP != nil {
return p.HTTP
}
return defaultHTTPClient
}
+156
View File
@@ -0,0 +1,156 @@
package llm
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
// RetryConfig 配置重试策略。
type RetryConfig struct {
MaxRetries int // 最大重试次数(默认 3)
InitialBackoff time.Duration // 初始退避时间(默认 1s)
MaxBackoff time.Duration // 最大退避时间(默认 30s)
RetryOn []int // 需要重试的 HTTP 状态码(默认 429, 500-509)
}
// DefaultRetryConfig 默认重试配置。
var DefaultRetryConfig = RetryConfig{
MaxRetries: 3,
InitialBackoff: 1 * time.Second,
MaxBackoff: 30 * time.Second,
RetryOn: []int{429, 500, 502, 503, 504},
}
// globalRetryConfig 全局重试配置。
var globalRetryConfig = DefaultRetryConfig
// SetRetryConfig 设置全局重试配置。
func SetRetryConfig(cfg RetryConfig) {
globalRetryConfig = cfg
}
// GetRetryConfig 获取当前重试配置。
func GetRetryConfig() RetryConfig {
return globalRetryConfig
}
// doWithRetry 发送请求并对瞬态错误做指数退避重试(尊重 Retry-After)。
// 只覆盖「响应开始前」的阶段:HTTP 状态检查在 SSE 流开始之前,一旦 200 并
// 开始读流,错误不再重试(增量已经发出去了)。
func doWithRetry(ctx context.Context, client *http.Client, req *http.Request, body []byte) (*http.Response, error) {
return doWithRetryConfig(ctx, client, req, body, globalRetryConfig)
}
// doWithRetryConfig 使用指定配置执行重试。
func doWithRetryConfig(ctx context.Context, client *http.Client, req *http.Request, body []byte, cfg RetryConfig) (*http.Response, error) {
if cfg.MaxRetries <= 0 {
cfg.MaxRetries = 1
}
if cfg.InitialBackoff <= 0 {
cfg.InitialBackoff = 1 * time.Second
}
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = 30 * time.Second
}
if len(cfg.RetryOn) == 0 {
cfg.RetryOn = DefaultRetryConfig.RetryOn
}
// 创建状态码查找表
retryOnMap := make(map[int]bool, len(cfg.RetryOn))
for _, code := range cfg.RetryOn {
retryOnMap[code] = true
}
var lastErr error
var lastRetryAfter time.Duration
for attempt := 0; attempt < cfg.MaxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<(attempt-1)) * cfg.InitialBackoff
if wait > cfg.MaxBackoff {
wait = cfg.MaxBackoff
}
if lastRetryAfter > 0 {
wait = lastRetryAfter
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
r := req.Clone(ctx)
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
resp, err := client.Do(r)
if err != nil {
lastErr = err
lastRetryAfter = 0
continue // 网络错误:可重试
}
// 检查是否需要重试
if resp.StatusCode == http.StatusTooManyRequests || retryOnMap[resp.StatusCode] {
lastRetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"))
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
return resp, nil // 2xx 与 4xx 一律交还调用方(4xx 不可重试)
}
return nil, fmt.Errorf("after %d attempts: %w", cfg.MaxRetries, lastErr)
}
// isRetryable 检查错误是否可重试。
func isRetryable(err error) bool {
if err == nil {
return false
}
// 网络错误可重试
if _, ok := err.(interface{ Timeout() bool }); ok {
return true
}
// HTTP 错误
errStr := err.Error()
return contains(errStr, "timeout") ||
contains(errStr, "connection refused") ||
contains(errStr, "connection reset") ||
contains(errStr, "EOF")
}
// contains 检查字符串是否包含子串。
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func parseRetryAfter(v string) time.Duration {
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 && secs <= 60 {
return time.Duration(secs) * time.Second
}
return 0
}
+104
View File
@@ -0,0 +1,104 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestRetryOn500:前两次 500,第三次成功——provider 应自动重试。
func TestRetryOn500(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls <= 2 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
final, err := p.Stream(context.Background(), "sys", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatalf("stream should succeed after retries: %v", err)
}
if final.Content != "ok" || calls != 3 {
t.Fatalf("content=%q calls=%d", final.Content, calls)
}
}
// TestNoRetryOn400:4xx 属不可重试错误,应立即返回。
func TestNoRetryOn400(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"bad request"}`))
}))
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
_, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil)
if err == nil || !strings.Contains(err.Error(), "400") {
t.Fatalf("expected 400 error, got %v", err)
}
if calls != 1 {
t.Fatalf("4xx must not retry, calls=%d", calls)
}
}
// TestAnthropicCacheControlAndMaxTokens:system/tools 标记 cache_control,
// max_tokens 来自 provider 配置。
func TestAnthropicCacheControlAndMaxTokens(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "text/event-stream")
// 带一句正文:只发 message_stop 的空流现在会被当成失败(见 ErrEmptyCompletion),
// 而这条用例断言的是**请求体**形状。
w.Write([]byte("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n"))
w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
}))
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 4096, HTTP: srv.Client()}
_, err := p.Stream(context.Background(), "system prompt",
[]Message{{Role: RoleUser, Content: "hi"}},
[]ToolDef{{Name: "read", Description: "d", Parameters: map[string]any{"type": "object"}}}, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if body["max_tokens"].(float64) != 4096 {
t.Fatalf("max_tokens: %v", body["max_tokens"])
}
sys := body["system"].([]any)
s0 := sys[0].(map[string]any)
if s0["cache_control"].(map[string]any)["type"] != "ephemeral" {
t.Fatalf("system cache_control missing: %v", s0)
}
tools := body["tools"].([]any)
last := tools[len(tools)-1].(map[string]any)
if last["cache_control"].(map[string]any)["type"] != "ephemeral" {
t.Fatalf("tools cache_control missing: %v", last)
}
}
func TestParseRetryAfter(t *testing.T) {
if got := parseRetryAfter("3"); got != 3*time.Second {
t.Fatalf("got %v", got)
}
if got := parseRetryAfter(""); got != 0 {
t.Fatalf("got %v", got)
}
if got := parseRetryAfter("99999"); got != 0 { // 上限保护
t.Fatalf("got %v", got)
}
}
+90
View File
@@ -0,0 +1,90 @@
package llm
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func sseProviderServer(t *testing.T, sse string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
}
func collectToolCallDeltas(t *testing.T, p Provider) []ToolCall {
t.Helper()
var got []ToolCall
_, err := p.Stream(context.Background(), "s", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{},
func(d StreamDelta) {
if d.ToolCall != nil {
got = append(got, *d.ToolCall)
}
})
if err != nil {
t.Fatal(err)
}
return got
}
// TestAnthropicEmitsCompletedToolCall:content_block_stop 一到就提前通知,
// 不等 message_stop。
func TestAnthropicEmitsCompletedToolCall(t *testing.T) {
srv := sseProviderServer(t, ""+
"data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n"+
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"a\\\"}\"}}\n\n"+
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 128, HTTP: srv.Client()}
got := collectToolCallDeltas(t, p)
if len(got) != 1 {
t.Fatalf("tool call deltas = %d, want 1", len(got))
}
if got[0].ID != "t1" || got[0].Name != "read" || got[0].Input != `{"path":"a"}` {
t.Fatalf("tool call = %+v", got[0])
}
}
// TestOpenAIEmitsCompletedToolCalls:下一个 index 出现时前一个调用完整;
// finish_reason 时最后一个完整。两者都通过 delta 通道提前通知。
func TestOpenAIEmitsCompletedToolCalls(t *testing.T) {
srv := sseProviderServer(t, ""+
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"t1\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]}}]}\n\n"+
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"a\\\"}\"}}]}}]}\n\n"+
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"t2\",\"function\":{\"name\":\"read\",\"arguments\":\"{}\"}}]}}]}\n\n"+
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n"+
"data: [DONE]\n\n")
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
got := collectToolCallDeltas(t, p)
if len(got) != 2 {
t.Fatalf("tool call deltas = %d, want 2: %+v", len(got), got)
}
if got[0].ID != "t1" || got[0].Input != `{"path":"a"}` {
t.Fatalf("first call = %+v", got[0])
}
if got[1].ID != "t2" || got[1].Input != "{}" {
t.Fatalf("second call = %+v", got[1])
}
}
// TestResponsesEmitsCompletedToolCall:output_item.done 的 parameters 已完整。
func TestResponsesEmitsCompletedToolCall(t *testing.T) {
srv := sseProviderServer(t, ""+
"data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"item_1\",\"call_id\":\"c1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
got := collectToolCallDeltas(t, p)
if len(got) != 1 {
t.Fatalf("tool call deltas = %d, want 1 (dedup by ID): %+v", len(got), got)
}
if got[0].ID != "c1" || got[0].Input != `{"path":"a"}` {
t.Fatalf("tool call = %+v", got[0])
}
}
+128
View File
@@ -0,0 +1,128 @@
package llm
import (
"io"
"net"
"net/http"
"sync"
"time"
)
// streamIdleTimeout 是流式响应允许的最长静默。
//
// 为什么需要它:三家 provider 都是 `bufio.Scanner` 死读 resp.Body,而 ctx 一路
// 传下去却**没有任何超时**(客户端也是无超时的 http.DefaultClient)。上游"连上了
// 但不吐字节"(网关半开连接、服务端卡在生成阶段)时,Read 永久阻塞——整个回合
// 就此静止,用户既等不到结果也看不出出了什么。这是第二类"卡死",且比 bash 那条
// 更容易在长任务里撞上(长任务 = 长思考 = 长静默)。
//
// 90 秒是权衡后的值:推理模型的首 token 延迟与中途长思考都会静默,几十秒属正常,
// 而真正卡死的连接通常连几十秒都不返回。宁可宽一点,也不要错杀慢但活的网关。
// var 仅供测试缩短。
var streamIdleTimeout = 90 * time.Second
// defaultHTTPClient 是 provider 没显式给 HTTP 客户端时的默认值。
//
// 不用 http.DefaultClient:① 它是进程级全局,改它会影响别人的代码,不改它就没有
// 任何超时;② 这里要的是"首字节前有限等待",而 Client.Timeout 覆盖**整个响应体**,
// 设了就会把正常的长流式掐死。所以只设 ResponseHeaderTimeout + dial/TLS 超时,
// 读流阶段的看门狗交给 idleGuard。
var defaultHTTPClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConnsPerHost: 8,
},
}
// idleGuard 是包在 SSE 响应体外面的静默看门狗:每次读到字节就重置计时,
// 静默超阈值就关掉连接,从而解阻塞正在 Read 的那个 goroutine。
type idleGuard struct {
body io.ReadCloser
idle time.Duration
mu sync.Mutex
last time.Time
once sync.Once
stalled bool
done chan struct{}
}
// guardStream 开始看护 body,返回可直接交给 bufio.Scanner 的 io.Reader。
// 调用方必须 defer Stop(),否则看护 goroutine 会活到进程结束。
func guardStream(body io.ReadCloser, idle time.Duration) *idleGuard {
g := &idleGuard{body: body, idle: idle, last: time.Now(), done: make(chan struct{})}
go g.watch()
return g
}
func (g *idleGuard) Read(p []byte) (int, error) {
n, err := g.body.Read(p)
if n > 0 {
g.mu.Lock()
g.last = time.Now()
g.mu.Unlock()
}
return n, err
}
func (g *idleGuard) Close() error { return g.body.Close() }
// watch 周期检查静默时长。阈值本身可能很长(90s),tick 取 idle/4:
// 既不会让正常流多出可观开销,也能在半分钟内发现"再也不会有数据"的连接。
func (g *idleGuard) watch() {
tick := g.idle / 4
if tick < 20*time.Millisecond {
tick = 20 * time.Millisecond // 测试里 idle 会被缩到毫秒级
}
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-g.done:
return
case <-t.C:
g.mu.Lock()
quiet := time.Since(g.last)
g.mu.Unlock()
if quiet > g.idle {
g.once.Do(func() {
g.mu.Lock()
g.stalled = true
g.mu.Unlock()
// 唯一的解阻塞手段:Read 卡在传输层时没有任何回调可指望。
_ = g.body.Close()
})
return
}
}
}
}
// Stop 结束看护(流正常读完后调用)。
func (g *idleGuard) Stop() { close(g.done) }
// Err 报告这次流是不是被看门狗掐断的。给上层的意义是"能说出真实原因":
// 否则调用方只会看到一句来历不明的 "read tcp: use of closed connection"。
func (g *idleGuard) Err() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.stalled {
return &StalledError{Idle: g.idle}
}
return nil
}
// StalledError 是"服务端长时间不吐字节"这一类失败。可被上层 errors.As 识别。
type StalledError struct{ Idle time.Duration }
func (e *StalledError) Error() string {
return "provider stream stalled: no data for " + e.Idle.String()
}
+174
View File
@@ -0,0 +1,174 @@
package llm
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// stallServer 吐一个增量之后彻底静默——正是"网关半开连接/服务端卡在生成阶段"的形态:
// TCP 不断、HTTP 头已回、之后再无字节。没有看门狗时 bufio.Scanner 会永久阻塞在 Read。
func stallServer(t *testing.T, hold chan struct{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
<-hold // 永不返回
}))
}
func TestStreamIdleWatchdogUnblocks(t *testing.T) {
old := streamIdleTimeout
streamIdleTimeout = 150 * time.Millisecond
defer func() { streamIdleTimeout = old }()
hold := make(chan struct{})
srv := stallServer(t, hold)
defer func() {
close(hold)
srv.Close()
}()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
done := make(chan error, 1)
go func() {
_, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
done <- err
}()
select {
case err := <-done:
var se *StalledError
if !errors.As(err, &se) {
t.Fatalf("要的是 StalledError,got %v", err)
}
if !strings.Contains(err.Error(), "stalled") {
t.Errorf("错误必须说清是静默掐断,got=%q", err)
}
case <-time.After(10 * time.Second):
t.Fatal("看门狗没生效:Stream 仍被静默的连接卡着")
}
}
// TestStreamIdleWatchdogSparesHealthyStream:不能错杀慢但活的流——
// 边睡边吐字节的流必须完整读完。
func TestStreamIdleWatchdogSparesHealthyStream(t *testing.T) {
old := streamIdleTimeout
streamIdleTimeout = 400 * time.Millisecond
defer func() { streamIdleTimeout = old }()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
f, _ := w.(http.Flusher)
// 三段,每段间隔 250ms:单段静默都低于 400ms 阈值。
for _, s := range []string{"he", "ll", "o"} {
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"" + s + "\"}}]}\n\n"))
if f != nil {
f.Flush()
}
time.Sleep(250 * time.Millisecond)
}
w.Write([]byte("data: [DONE]\n\n"))
if f != nil {
f.Flush()
}
}))
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatalf("健康流被误杀:%v", err)
}
if msg.Content != "hello" {
t.Errorf("Content = %q, want hello", msg.Content)
}
}
// trickle 是一个可控的"滴流"响应体:按给定节奏每次吐一个字节,
// 滴完之后彻底静默(阻塞在读上),用来精确复现"服务端连上不吐字节"。
// 和真响应体一样:Close 会让阻塞中的 Read 立刻返回错误。
type trickle struct {
gaps []time.Duration
i int
closed chan struct{}
}
func (r *trickle) Read(p []byte) (int, error) {
if r.i >= len(r.gaps) {
<-r.closed
return 0, io.ErrClosedPipe
}
time.Sleep(r.gaps[r.i])
r.i++
p[0] = 'x'
return 1, nil
}
func (r *trickle) Close() error { close(r.closed); return nil }
func (r *trickle) closedNow() bool {
select {
case <-r.closed:
return true
default:
return false
}
}
// TestIdleGuardMarksStall:看护的两条基本契约——有字节就重置计时,
// 真静默了才掐断,并且能把原因报出来。
func TestIdleGuardMarksStall(t *testing.T) {
const idle = 300 * time.Millisecond
src := &trickle{gaps: []time.Duration{60 * time.Millisecond, 60, 60, 60}, closed: make(chan struct{})}
g := guardStream(src, idle)
defer g.Stop()
buf := make([]byte, 1)
for i := 0; i < len(src.gaps); i++ {
if _, err := g.Read(buf); err != nil {
t.Fatalf("Read 透传失败:%v", err)
}
if err := g.Err(); err != nil {
t.Fatalf("活跃流被误判为静默:%v", err)
}
}
// 此后彻底没有数据:Read 阻塞在源上,只有看门狗关掉它能解阻塞。
readErr := make(chan error, 1)
go func() { _, err := g.Read(buf); readErr <- err }()
deadline := time.Now().Add(10 * time.Second)
for g.Err() == nil {
if time.Now().After(deadline) {
t.Fatal("静默没有被判定")
}
time.Sleep(20 * time.Millisecond)
}
if !strings.Contains(g.Err().Error(), idle.String()) {
t.Errorf("错误里应带阈值,got=%q", g.Err())
}
select {
case err := <-readErr:
if err == nil {
t.Error("body 被掐断后 Read 必须返回错误(否则调用方仍会阻塞)")
}
case <-time.After(2 * time.Second):
t.Fatal("掐断后 Read 仍卡着——调用方解不了阻塞,看门狗等于没做")
}
if !src.closedNow() {
t.Error("看门狗没有关闭响应体")
}
}
+107
View File
@@ -0,0 +1,107 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestSystemCacheSplit:稳定 system 与动态 system 必须分开下发,且收尾回合
// 只禁用工具调用、不撤掉工具定义——两者都是为了保住 prompt cache 前缀。
func TestSystemCacheSplit(t *testing.T) {
tools := []ToolDef{{Name: "read", Description: "d", Parameters: map[string]any{"type": "object"}}}
opts := StreamOptions{SystemDynamic: "DYNAMIC", DisableTools: true}
t.Run("anthropic:动态段是第二个 system 块且不带 cache_control", func(t *testing.T) {
var body map[string]any
srv := captureBodyServer(t, &body,
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 128, HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "STABLE",
[]Message{{Role: RoleUser, Content: "hi"}}, tools, opts, nil); err != nil {
t.Fatal(err)
}
blocks := body["system"].([]any)
if len(blocks) != 2 {
t.Fatalf("want 2 system blocks, got %d: %v", len(blocks), blocks)
}
first := blocks[0].(map[string]any)
second := blocks[1].(map[string]any)
if first["text"] != "STABLE" || first["cache_control"].(map[string]any)["type"] != "ephemeral" {
t.Fatalf("stable block must be cached: %v", first)
}
if second["text"] != "DYNAMIC" {
t.Fatalf("dynamic block text: %v", second)
}
if _, ok := second["cache_control"]; ok {
t.Fatalf("dynamic block must not carry cache_control: %v", second)
}
if body["tool_choice"].(map[string]any)["type"] != "none" {
t.Fatalf("tool_choice: %v", body["tool_choice"])
}
if len(body["tools"].([]any)) != 1 {
t.Fatalf("tools must stay in the request: %v", body["tools"])
}
})
t.Run("chat_completions:动态段是独立的第二条 system 消息", func(t *testing.T) {
var body map[string]any
srv := captureBodyServer(t, &body,
"data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "STABLE",
[]Message{{Role: RoleUser, Content: "hi"}}, tools, opts, nil); err != nil {
t.Fatal(err)
}
msgs := body["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("want one system message + user, got %v", msgs)
}
if msgs[0].(map[string]any)["content"] != "STABLE\n\nDYNAMIC" {
t.Fatalf("system must keep the stable prefix and append the dynamic tail: %v", msgs[0])
}
if body["tool_choice"] != "none" {
t.Fatalf("tool_choice: %v", body["tool_choice"])
}
if len(body["tools"].([]any)) != 1 {
t.Fatalf("tools must stay in the request: %v", body["tools"])
}
})
t.Run("responses:动态段是独立的第二条 system 消息", func(t *testing.T) {
var body map[string]any
srv := captureBodyServer(t, &body,
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "STABLE",
[]Message{{Role: RoleUser, Content: "hi"}}, tools, opts, nil); err != nil {
t.Fatal(err)
}
input := body["input"].([]any)
if len(input) != 2 {
t.Fatalf("want one system message + user, got %v", input)
}
if input[0].(map[string]any)["content"] != "STABLE\n\nDYNAMIC" {
t.Fatalf("system must keep the stable prefix and append the dynamic tail: %v", input[0])
}
if body["tool_choice"] != "none" {
t.Fatalf("tool_choice: %v", body["tool_choice"])
}
})
}
func captureBodyServer(t *testing.T, body *map[string]any, sse string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
}
+249
View File
@@ -0,0 +1,249 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestParseThinkingLevelAndEnabled(t *testing.T) {
cases := map[string]ThinkingLevel{
"": ThinkingOff,
"off": ThinkingOff,
"LOW": ThinkingLow,
" medium ": ThinkingMedium,
"High": ThinkingHigh,
"bogus": ThinkingOff,
}
for in, want := range cases {
if got := ParseThinkingLevel(in); got != want {
t.Fatalf("ParseThinkingLevel(%q) = %q, want %q", in, got, want)
}
}
// 零值 "" 必须视为关闭——Enabled 是唯一正确判据。
var zero ThinkingLevel
if zero.Enabled() {
t.Fatal("zero value must not be enabled")
}
if ThinkingOff.Enabled() || !ThinkingLow.Enabled() || !ThinkingMedium.Enabled() || !ThinkingHigh.Enabled() {
t.Fatal("Enabled() mapping wrong")
}
if IsThinkingLevel("bogus") || IsThinkingLevel("") || !IsThinkingLevel("off") || !IsThinkingLevel("HIGH") {
t.Fatal("IsThinkingLevel mapping wrong")
}
}
// captureServer 返回一个记录最后一次请求体的 SSE 服务器。
func captureServer(body *map[string]any, sse string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
}
func TestOpenAIReasoningEffort(t *testing.T) {
var body map[string]any
srv := captureServer(&body, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingMedium}, nil); err != nil {
t.Fatal(err)
}
if body["reasoning_effort"] != "medium" {
t.Fatalf("reasoning_effort = %v, want medium", body["reasoning_effort"])
}
body = nil
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
t.Fatal(err)
}
if _, ok := body["reasoning_effort"]; ok {
t.Fatalf("off must not send reasoning_effort: %v", body["reasoning_effort"])
}
}
func TestResponsesReasoning(t *testing.T) {
var body map[string]any
sse := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"
srv := captureServer(&body, sse)
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingHigh}, nil); err != nil {
t.Fatal(err)
}
r, ok := body["reasoning"].(map[string]any)
if !ok || r["effort"] != "high" {
t.Fatalf("reasoning = %v, want effort=high", body["reasoning"])
}
if body["max_output_tokens"].(float64) != 16384 {
t.Fatalf("max_output_tokens = %v, want 16384 when thinking on", body["max_output_tokens"])
}
body = nil
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
t.Fatal(err)
}
if _, ok := body["reasoning"]; ok {
t.Fatalf("off must not send reasoning: %v", body["reasoning"])
}
if body["max_output_tokens"].(float64) != 8192 {
t.Fatalf("max_output_tokens = %v, want 8192 when off", body["max_output_tokens"])
}
}
func TestOpenAIReasoningDelta(t *testing.T) {
srv := captureServer(&map[string]any{},
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"R1\"}}]}\n\n"+
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"R2\"}}]}\n\n"+
"data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n"+
"data: [DONE]\n\n")
defer srv.Close()
var got, reasoning string
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingLow},
func(d StreamDelta) {
got += d.Text
reasoning += d.Reasoning
})
if err != nil {
t.Fatal(err)
}
if final.Reasoning != "R1R2" || reasoning != "R1R2" {
t.Fatalf("reasoning = %q / %q, want R1R2", final.Reasoning, reasoning)
}
if final.Content != "answer" || got != "answer" {
t.Fatalf("content = %q / %q", final.Content, got)
}
}
func TestAnthropicThinkingDelta(t *testing.T) {
srv := captureServer(&map[string]any{},
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"T1\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"signature_delta\",\"signature\":\"SIG\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
defer srv.Close()
var reasoning string
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingMedium},
func(d StreamDelta) { reasoning += d.Reasoning })
if err != nil {
t.Fatal(err)
}
if final.Reasoning != "T1" || reasoning != "T1" {
t.Fatalf("reasoning = %q / %q", final.Reasoning, reasoning)
}
if final.Content != "hi" {
t.Fatalf("content = %q", final.Content)
}
// 签名必须留存,否则工具调用场景下无法回传 thinking 块。
if final.ReasoningSignature != "SIG" {
t.Fatalf("signature = %q, want SIG", final.ReasoningSignature)
}
}
// TestAnthropicThinkingRoundTrip:工具调用时,上一轮 assistant 的 thinking 块
// (原文 + 签名)必须原样回传且位于首位;关闭思考时必须剥离。
func TestAnthropicThinkingRoundTrip(t *testing.T) {
msgs := []Message{
{Role: RoleUser, Content: "hi"},
{
Role: RoleAssistant, Content: "let me check", Reasoning: "R", ReasoningSignature: "SIG",
ToolCalls: []ToolCall{{ID: "t1", Name: "read", Input: `{}`}},
},
{Role: RoleTool, ToolCallID: "t1", Content: "out"},
}
out := anBuildMessages(msgs, true)
content := out[1]["content"].([]any)
if len(content) < 3 {
t.Fatalf("expected thinking+text+tool_use, got %v", content)
}
think := content[0].(map[string]any)
if think["type"] != "thinking" || think["thinking"] != "R" || think["signature"] != "SIG" {
t.Fatalf("thinking block = %v", think)
}
out = anBuildMessages(msgs, false)
content = out[1]["content"].([]any)
for _, b := range content {
if m, ok := b.(map[string]any); ok && m["type"] == "thinking" {
t.Fatal("thinking must be stripped when thinking is off")
}
}
// redacted_thinking 同样回传。
out = anBuildMessages([]Message{{Role: RoleAssistant, Content: "x", RedactedThinking: "D"}}, true)
content = out[0]["content"].([]any)
red := content[0].(map[string]any)
if red["type"] != "redacted_thinking" || red["data"] != "D" {
t.Fatalf("redacted block = %v", red)
}
}
func TestResponsesReasoningDelta(t *testing.T) {
srv := captureServer(&map[string]any{},
"data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"S1\"}\n\n"+
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
defer srv.Close()
var reasoning string
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingHigh},
func(d StreamDelta) { reasoning += d.Reasoning })
if err != nil {
t.Fatal(err)
}
if final.Reasoning != "S1" || reasoning != "S1" {
t.Fatalf("reasoning = %q / %q", final.Reasoning, reasoning)
}
}
func TestAnthropicThinkingBudget(t *testing.T) {
wantBudget := map[ThinkingLevel]float64{
ThinkingLow: 2048,
ThinkingMedium: 8192,
ThinkingHigh: 16384,
}
for level, budget := range wantBudget {
var body map[string]any
srv := captureServer(&body, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n")
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 4096, HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: level}, nil); err != nil {
t.Fatalf("%s: %v", level, err)
}
srv.Close()
think, ok := body["thinking"].(map[string]any)
if !ok || think["type"] != "enabled" || think["budget_tokens"].(float64) != budget {
t.Fatalf("%s: thinking = %v, want budget %v", level, body["thinking"], budget)
}
mt := body["max_tokens"].(float64)
if mt <= budget {
t.Fatalf("%s: max_tokens %v must exceed budget %v", level, mt, budget)
}
}
// off:不发字段,max_tokens 保持配置值。
var body map[string]any
srv := captureServer(&body, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n")
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 4096, HTTP: srv.Client()}
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
t.Fatal(err)
}
if _, ok := body["thinking"]; ok {
t.Fatalf("off must not send thinking: %v", body["thinking"])
}
if body["max_tokens"].(float64) != 4096 {
t.Fatalf("max_tokens = %v, want 4096 when off", body["max_tokens"])
}
}
+47
View File
@@ -0,0 +1,47 @@
package llm
import (
"encoding/json"
"strings"
"testing"
)
// TestTraceFieldsNeverSentToAPI 锁死一条边界:Message 上的本地 trace 字段
// (started_at / duration_ms / step)绝不能出现在任何 provider 的请求体里。
// 三家 provider 都靠手工白名单拼 body,一旦有人改成 json.Marshal(Message)
// 或顺手把字段透传,这个测试会立刻失败。
func TestTraceFieldsNeverSentToAPI(t *testing.T) {
msgs := []Message{{
Role: RoleAssistant,
Content: "hi",
ToolCalls: []ToolCall{{ID: "t1", Name: "read", Input: "{}"}},
StartedAt: 1700000000000,
DurationMs: 42,
Step: 3,
}, {
Role: RoleTool,
Content: "result",
ToolCallID: "t1",
StartedAt: 1700000000001,
DurationMs: 7,
Step: 3,
}}
builders := map[string]any{
"openai": oaBuildMessages("sys", msgs),
"responses": rsBuildInput("sys", msgs),
"anthropic": anBuildMessages(msgs, true),
}
for name, body := range builders {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("%s: marshal: %v", name, err)
}
s := string(raw)
for _, key := range []string{`"started_at"`, `"duration_ms"`, `"step"`} {
if strings.Contains(s, key) {
t.Errorf("%s request body leaked trace field %s: %s", name, key, s)
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package llm
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
)
// TestIsTransientStream 钉住"该重发"与"重发也没用"的分界:前者是网关/传输层的偶发
// 坏流,后者是确定性失败——把后者卷进重试环就是白烧预算 + 把一次明确的错误拖成十分钟。
func TestIsTransientStream(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"整轮空补全", fmt.Errorf("openai: %w", ErrEmptyCompletion), true},
{"流被截断", fmt.Errorf("anthropic: %w", ErrTruncatedStream), true},
{"流卡死到看门狗上限", &StalledError{Idle: 90 * time.Second}, true},
{"包过一层的卡死", fmt.Errorf("stream: %w", &StalledError{Idle: time.Minute}), true},
{"连接被掐", errors.New("read tcp 10.0.0.5:52314->1.2.3.4:443: connection reset by peer"), true},
{"连接被拒", errors.New("dial tcp 1.2.3.4:443: connect: connection refused"), true},
{"读超时", errors.New("read tcp: i/o timeout"), true},
{"裸 EOF", io.EOF, true},
{"网关过载", errors.New("openai: HTTP 503: upstream busy"), true},
{"限流", errors.New("openai: HTTP 429 Too Many Requests"), true},
// 以下都是"重发只会再撞一次同一堵墙"。
{"请求不合法", errors.New(`openai: HTTP 400: {"error":{"message":"inference request is invalid","code":"400002"}}`), false},
{"上游拒了这条请求", errors.New(`openai: HTTP 400: {"error":{"code":"upstream_request_rejected","message":"Bad Request","type":"invalid_request_error"}}`), false},
{"没权限", errors.New("openai: HTTP 401: invalid api key"), false},
{"余额不足", errors.New("anthropic: credit balance is too low"), false},
{"上下文满", errors.New("openai: model_context_window_exceeded"), false},
{"用户取消", context.Canceled, false},
{"外部 deadline", context.DeadlineExceeded, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IsTransientStream(c.err); got != c.want {
t.Errorf("IsTransientStream(%v) = %v, want %v", c.err, got, c.want)
}
})
}
}
+216
View File
@@ -0,0 +1,216 @@
// Package llm 定义统一的 LLM Provider 抽象(借鉴 pigo internal/provider 的
// 统一层思路),当前支持 OpenAI 兼容与 Anthropic 两种协议,均为手写 SSE 流式。
package llm
import (
"context"
"strings"
)
// Role 取值:system / user / assistant / tool。
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleTool Role = "tool"
)
// ThinkingLevel 是每轮请求的思考/推理深度。注意零值是 ""(不是 ThinkingOff)。
type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
ThinkingHigh ThinkingLevel = "high"
)
// Enabled 仅对 low/medium/high 为真;off 与零值 "" 均为 false。
// 判定务必用本方法,不要写 `!= ThinkingOff`(零值不等于 off)。
func (l ThinkingLevel) Enabled() bool {
return l == ThinkingLow || l == ThinkingMedium || l == ThinkingHigh
}
// ParseThinkingLevel 归一化输入;空值与未知值一律回退 ThinkingOff。
func ParseThinkingLevel(s string) ThinkingLevel {
switch ThinkingLevel(strings.ToLower(strings.TrimSpace(s))) {
case ThinkingLow:
return ThinkingLow
case ThinkingMedium:
return ThinkingMedium
case ThinkingHigh:
return ThinkingHigh
default:
return ThinkingOff
}
}
// IsThinkingLevel 报告 s 是否为四个合法值之一(配置校验用)。
func IsThinkingLevel(s string) bool {
v := ThinkingLevel(strings.ToLower(strings.TrimSpace(s)))
return v == ThinkingOff || v.Enabled()
}
// StreamOptions 是单轮请求的可选参数。零值 = off:不发送任何新字段。
type StreamOptions struct {
Thinking ThinkingLevel
// SystemDynamic 是不该进入 prompt-cache 断点的 system 后缀:计划、记忆、
// 回退分支摘要这类每轮可能变化的内容。Anthropic 把它作为第二个 system 块
// (第一个块带 cache_control);OpenAI 兼容/Responses 追加在同一条 system
// 消息尾部——token 前缀缓存仍命中稳定段,同时避免部分网关不接受多条
// system 消息。
SystemDynamic string
// DisableTools 保留 tools 定义、但禁止模型调用(三家的 tool_choice=none)。
// 收尾回合用它而不是把 tools 传成 nil:换掉工具数组会打碎缓存前缀。
DisableTools bool
}
// ToolCall 是模型发起的一次工具调用请求。
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Input string `json:"input"` // 原始 JSON 参数字符串
}
// Image 是随消息发送的图片(按文件名引用,构建请求时从缓存读取并 base64 编码;
// 会话 JSONL 只存文件名,不存像素)。
type Image struct {
Filename string `json:"filename"` // 缓存中的文件名
Path string `json:"path,omitempty"` // 兼容旧格式:直接路径
}
// Message 是会话中的一条消息。ToolCallID 非空表示这是工具结果回填。
type Message struct {
Role Role `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Images []Image `json:"images,omitempty"` // 常见于截图类工具结果
Usage *Usage `json:"usage,omitempty"`
// Reasoning 是开启思考时的推理文本(展示用;Anthropic 场景还需回传,见下)。
Reasoning string `json:"reasoning,omitempty"`
// ReasoningSignature 是 Anthropic thinking 块的签名:工具调用场景下,
// 下一轮请求必须原样回传 thinking 块(含签名),否则 API 会拒绝。
ReasoningSignature string `json:"reasoning_signature,omitempty"`
// RedactedThinking 是 Anthropic 的 redacted_thinking 数据(安全策略触发时),
// 同样需要原样回传。
RedactedThinking string `json:"redacted_thinking,omitempty"`
// --- 本地 trace:仅为度量与展示,绝不进入任何 API 请求报文 ---
// 三家 provider 都手工白名单拼 body(oaBuildMessages / rsBuildInput /
// anBuildMessages),全仓不存在 json.Marshal(Message),故这些字段不会出网;
// 它们只随会话 JSONL 落盘。omitempty 保证旧消息与旧格式输出不变。
StartedAt int64 `json:"started_at,omitempty"` // 本步开始时刻(unix ms)
DurationMs int64 `json:"duration_ms,omitempty"` // 本步耗时:assistant = 模型调用,tool = 工具执行
Step int `json:"step,omitempty"` // 第 N 次模型调用(tool 消息沿用其所属轮次)
// Format/Model 是这一步的归因:协议格式 + 实际模型名。
// 没有它们就分不清"哪一步换了供应商/模型"——真实会话里出现过一条中途
// 切了 provider 的记录,事后只能靠 tool_call id 的前缀形态猜。
Format string `json:"format,omitempty"`
Model string `json:"model,omitempty"`
// Tool 是 tool 消息对应的工具名。此前只有 ToolCallID 落盘,工具名得从失败块
// 文本里 parse(retro.parseFriction 一直在这么干)。
Tool string `json:"tool,omitempty"`
// Fail 是归一出的失败类别(timeout / denied / self-kill / …),空 = 成功。
// 有了它,stats 与 retro 不必再靠「[TOOL FAILED] …(kind)」字符串前缀计数——
// 那条契约的原话是"内容前缀是唯一可靠信号",格式漂一处就静默失准。
Fail string `json:"fail,omitempty"`
// Exit 是命令类工具的退出码(取不到就不序列化)。
Exit int `json:"exit,omitempty"`
// Truncated 表示 Content 被长度上限截过:模型看到的不是全量,UI 该标出来,
// 统计也知道这条会话里有多少结果其实被削了。
Truncated bool `json:"truncated,omitempty"`
// OutputLimited 是"**模型想写却写不下**":这一轮的终止原因是输出 token 上限
// (OpenAI finish_reason=length / Anthropic stop_reason=max_tokens /
// Responses response.incomplete + max_output_tokens)。
//
// 与 Truncated 正相反:那是我们自己削工具结果,这是模型的笔停了。不接住它的后果
// 是"讲一半就停"——agent 的收尾判定是"没有 tool_calls ⇒ 说完了",于是半截答案
// 被当成最终回复,用户只能自己打「继续」。带 tool_calls 的响应一律不算(见 completion)。
OutputLimited bool `json:"output_limited,omitempty"`
// Interrupted 是"这条没写完就断了":模型调用出错或被停止时,已经流出来的半截
// 回复仍值得留下(见 agent.persistPartial),但它**不是**一个答案。
// 没有这个标记,会话末尾看起来就像已经答完了,重试入口只能回一句
// "没有可重试的消息"——而用户明明只看到一个断掉的半句。
Interrupted bool `json:"interrupted,omitempty"`
// Synthetic 标记这条 user 消息是系统生成的(后台作业完成通知等),不是人打的字。
// 只用于本地展示与统计:provider 的手工白名单拼报文不会带上它;统计、rewind
// 提问号与标题派生据此跳过,避免把通知算成一次人工干预。
Synthetic bool `json:"synthetic,omitempty"`
}
// ToolDef 向模型声明一个可调用工具。
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"` // JSON schema
}
// Usage 记录一次响应的 token 消耗。
//
// **口径约定:InputTokens 是本次请求在模型侧占位的提示 token 总量**,缓存命中
// 与缓存写入都算在内,Cache* 字段只是明细。必须这么归一:Anthropic 的
// `input_tokens` 只报**未命中缓存**的那一段(缓存部分单列在
// cache_read_input_tokens / cache_creation_input_tokens)。照抄的后果是终端计数、
// `stats` 的 TokensIn 与任何按 token 算的成本都被低报(95% 命中时 10 万的上下文
// 记成 5 千)。注意:当前自动压缩走的是**字符估算**(见 agent/context.go),
// 不依赖这几个数,所以这个口径修的是账目,不是压缩触发条件。
// OpenAI / Responses 的 prompt_tokens 本身已含缓存,取其明细即可。
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// CacheReadTokens 是命中提示缓存的 token 数(Anthropic cache_read /
// OpenAI prompt_tokens_details.cached_tokens)。
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
// CacheCreationTokens 是本次写入缓存的 token 数(Anthropic 独有,计费更贵)。
CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
// ReasoningTokens 是思考消耗(OpenAI/Responses 单列;Anthropic 混在
// output_tokens 里报不出来,留 0,不要瞎猜)。
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
}
// StreamDelta 是流式回调收到的一段增量内容。ToolCall 是一个工具调用的参数
// 已经完整的提前通知:调用方可以在消息结束前先执行只读工具;最终
// Message.ToolCalls 仍包含完整列表,按 ID 去重即可。
type StreamDelta struct {
Text string
Reasoning string
ToolCall *ToolCall
Usage *Usage
Err error
}
// Provider 是统一的 LLM 后端接口。
type Provider interface {
// Stream 发起一轮对话,增量回调 delta 流;返回最终的 assistant 完整消息。
// opts 为零值时不改变请求(思考级别默认关闭)。
Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error)
}
// ModelSwitcher 是支持运行时切换模型的 Provider。
type ModelSwitcher interface {
SetModel(model string)
}
// CurrentModeler 是能报告当前模型的 Provider。模型预设路由(含图走多模态、
// 失败回落备选)需要先知道现状才能决定切不切、以及回合后恢复到哪——
// 只读查询,与 SetModel 配对使用。
type CurrentModeler interface {
CurrentModel() string
}
// Identifier 是 provider 自报「协议格式 + 当前模型」的能力(三家都实现)。
// Agent 用它给每条 assistant 消息盖归因戳:会话中途切了供应商或模型时,
// 没有这两项就分不清哪一步是谁做的。
type Identifier interface {
Identity() (format, model string)
}
// ModelLister 是支持枚举可用模型的 Provider(GET /models)。
type ModelLister interface {
ListModels(ctx context.Context) ([]string, error)
}
+172
View File
@@ -0,0 +1,172 @@
package llm
import (
"context"
"encoding/json"
"strings"
"testing"
)
// 三家 provider 的 token 记账必须归一到同一口径(见 Usage 注释):
// InputTokens 含缓存段。Anthropic 尤其容易写错——它把命中/写入单列,
// 照抄 input_tokens 就会在缓存命中时把 10 万上下文记成 5 千。
func TestAnthropicUsageIncludesCache(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"usage":{"input_tokens":120,"cache_read_input_tokens":90000,"cache_creation_input_tokens":500}}}`,
"",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
"",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
"",
`data: {"type":"content_block_stop","index":0}`,
"",
// message_delta 常常不回缓存明细:必须沿用 message_start 学到的数
`data: {"type":"message_delta","usage":{"output_tokens":7}}`,
"",
`data: {"type":"message_stop"}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "claude-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 90620 {
t.Errorf("InputTokens = %d, want 90620(120 + cache_read 90000 + cache_write 500)", u.InputTokens)
}
if u.CacheReadTokens != 90000 || u.CacheCreationTokens != 500 {
t.Errorf("缓存明细 = %d/%d, want 90000/500", u.CacheReadTokens, u.CacheCreationTokens)
}
if u.OutputTokens != 7 {
t.Errorf("OutputTokens = %d, want 7", u.OutputTokens)
}
if msg.Content != "ok" {
t.Errorf("Content = %q", msg.Content)
}
}
func TestOpenAIUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
"",
`data: {"choices":[{"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":20,` +
`"prompt_tokens_details":{"cached_tokens":950},"completion_tokens_details":{"reasoning_tokens":8}}}`,
"",
`data: [DONE]`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "gpt-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
// prompt_tokens 本身已含缓存:总量不许二次相加。
if u.InputTokens != 1000 || u.CacheReadTokens != 950 || u.ReasoningTokens != 8 || u.OutputTokens != 20 {
t.Errorf("usage 归一错误:%+v", u)
}
}
func TestResponsesUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"response.output_text.delta","delta":"hi"}`,
"",
`data: {"type":"response.completed","response":{"status":"completed","usage":` +
`{"input_tokens":800,"output_tokens":15,"input_tokens_details":{"cached_tokens":700},` +
`"output_tokens_details":{"reasoning_tokens":5}}}}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "rsp-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 800 || u.CacheReadTokens != 700 || u.ReasoningTokens != 5 || u.OutputTokens != 15 {
t.Errorf("usage 归一错误:%+v", u)
}
}
// TestProviderIdentity:三家都自报协议与当前模型(会话落盘的归因戳)。
// 换了模型(SetModel)之后 Identity 必须跟着变,否则戳是假的。
func TestProviderIdentity(t *testing.T) {
var cases = []struct {
p Provider
wantFmt string
wantAttr string
}{
{&OpenAIProvider{Model: "gpt-a"}, FormatChatCompletions, "gpt-a"},
{&AnthropicProvider{Model: "claude-a"}, FormatAnthropic, "claude-a"},
{&ResponsesProvider{Model: "rsp-a"}, FormatResponses, "rsp-a"},
}
for _, c := range cases {
id, ok := c.p.(Identifier)
if !ok {
t.Fatalf("%T 没实现 Identifier", c.p)
}
f, m := id.Identity()
if f != c.wantFmt || m != c.wantAttr {
t.Errorf("%T Identity = %q/%q, want %q/%q", c.p, f, m, c.wantFmt, c.wantAttr)
}
if sw, ok := c.p.(ModelSwitcher); !ok {
t.Errorf("%T 应可切模型", c.p)
} else {
sw.SetModel("后到")
if _, m := id.Identity(); m != "后到" {
t.Errorf("SetModel 后 Identity 仍报 %q", m)
}
}
}
}
// TestUsageSerializesIntoTranscript:新增明细字段要能落进会话文件,
// 同时不给旧消息添噪声(omitempty)。
func TestUsageSerializesIntoTranscript(t *testing.T) {
old := Message{Role: RoleAssistant, Usage: &Usage{InputTokens: 5, OutputTokens: 2}}
b, err := json.Marshal(old)
if err != nil {
t.Fatal(err)
}
for _, noise := range []string{"cache_read_tokens", "reasoning_tokens", "format", "model"} {
if strings.Contains(string(b), noise) {
t.Errorf("零值不该序列化出 %q:%s", noise, b)
}
}
withCache := Message{Role: RoleAssistant, Format: FormatAnthropic, Model: "claude-x",
Usage: &Usage{InputTokens: 90620, OutputTokens: 7, CacheReadTokens: 90000}}
b2, err := json.Marshal(withCache)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"cache_read_tokens", "90000", `"format":"anthropic"`, `"model":"claude-x"`} {
if !strings.Contains(string(b2), want) {
t.Errorf("序列化缺 %q:%s", want, b2)
}
}
}