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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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() + "]"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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("看门狗没有关闭响应体")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user