Files
AutoZero/llm/completion_test.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

189 lines
7.6 KiB
Go
Raw Blame History

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