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。
250 lines
9.2 KiB
Go
250 lines
9.2 KiB
Go
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"])
|
|
}
|
|
}
|