Files
AutoZero/llm/outputlimit_test.go
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

130 lines
5.7 KiB
Go
Raw Permalink 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"
"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)
}
}
}