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) } }) } }