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