diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index bb26d6b..5730ad1 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -20,7 +20,8 @@ const ( // anthropicDefaultMaxTokens is the default max_tokens sent when // Request.MaxTokens is zero. Anthropic requires max_tokens to be present and // positive, so a sensible default is applied rather than omitting it. - anthropicDefaultMaxTokens = 1024 + // max_tokens is shared by thinking + text, so it must leave room for both. + anthropicDefaultMaxTokens = 8192 // anthropicDataPrefix marks a payload embedded as a data: URI. anthropicDataPrefix = "data:" ) diff --git a/internal/runtime/model/anthropic_budget_test.go b/internal/runtime/model/anthropic_budget_test.go new file mode 100644 index 0000000..aacdbbf --- /dev/null +++ b/internal/runtime/model/anthropic_budget_test.go @@ -0,0 +1,69 @@ +package model + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking guards the fix +// for thinking models whose reasoning shares the max_tokens budget with the +// visible answer: the zero-value default must leave room for both. It also +// asserts an explicit Request.MaxTokens is never overridden by that default +// (the existing 64-token case in anthropic_test.go is the compatibility proof). +func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}) + + if _, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + if got := (<-ch).body["max_tokens"]; got != float64(anthropicDefaultMaxTokens) { + t.Errorf("default body.max_tokens = %v, want %d", got, anthropicDefaultMaxTokens) + } + if anthropicDefaultMaxTokens < 4096 { + t.Errorf("anthropicDefaultMaxTokens = %d, too small for a long thinking turn plus its answer", anthropicDefaultMaxTokens) + } +} + +// TestAnthropicChat_Chat_LongThinkingThenShortText asserts a response whose +// thinking block is far longer than its text block still surfaces the text and +// never leaks the thinking into Content (the thinking-is-not-the-answer red +// line). This is the shape a thinking model produces when the budget is large +// enough, versus the truncation the old 1024 cap caused. +func TestAnthropicChat_Chat_LongThinkingThenShortText(t *testing.T) { + t.Parallel() + thinking := strings.Repeat("推理", 1500) // 3000 runes, far longer than the answer + body := fmt.Sprintf(`{ + "id":"msg_think","model":"claude-test", + "content":[ + {"type":"thinking","thinking":%q}, + {"type":"text","text":"现在是下午三点。"} + ], + "usage":{"input_tokens":20,"output_tokens":900} + }`, thinking) + srv := newAnthropicServer(t, http.StatusOK, "application/json", body, nil) + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}) + + out, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "几点了?")}}, Options{}) + if err != nil { + t.Fatalf("Chat error: %v", err) + } + if out.Content != "现在是下午三点。" { + t.Errorf("Content = %q, want the text block", out.Content) + } + if strings.Contains(out.Content, "推理") { + t.Errorf("Content leaked thinking: %q", out.Content) + } + if out.Message.Role != RoleAssistant || len(out.Message.Content) != 1 || out.Message.Content[0].Text != "现在是下午三点。" { + t.Errorf("Message = %+v, want a single assistant text part", out.Message) + } +}