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。
189 lines
7.6 KiB
Go
189 lines
7.6 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|