From c40b46b66182d3c2901b1cae14afe64e99ba735f Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:55:49 +0200 Subject: [PATCH] fix(llm): send generous max_tokens on every request The copied OpenAI-compatible client sent no max_tokens. Thinking models (qwen3, deepseek-r1) spend their budget on the reasoning trace and return EMPTY content when max_tokens is unset, which the summarizer treats as an error. ADR-004 says change Tapir's copy rather than the hyperguild upstream, so set a generous default (8192) leaving room for both reasoning and output. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/llm/client.go | 12 ++++++++++++ internal/adapters/llm/client_test.go | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go index 0c84a36..8e40af7 100644 --- a/internal/adapters/llm/client.go +++ b/internal/adapters/llm/client.go @@ -17,11 +17,20 @@ import ( "time" ) +// defaultMaxTokens is sent on every request. Tapir CHANGES this from the +// hyperguild copy (ADR-004 says change the copy, not the upstream): thinking +// models (qwen3, deepseek-r1) spend their budget on reasoning and return EMPTY +// content when max_tokens is unset or too low. A generous ceiling leaves room +// for both the reasoning trace and the actual summary. See +// docs/homelab-integration.md. +const defaultMaxTokens = 8192 + // Client calls an OpenAI-compatible chat completions endpoint. type Client struct { baseURL string apiKey string model string + maxTokens int httpClient *http.Client } @@ -31,6 +40,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration) *Client { baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, model: model, + maxTokens: defaultMaxTokens, httpClient: &http.Client{Timeout: timeout}, } } @@ -39,6 +49,7 @@ type chatRequest struct { Model string `json:"model"` Messages []message `json:"messages"` Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens,omitempty"` } type message struct { @@ -62,6 +73,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err {Role: "user", Content: user}, }, Temperature: 0.2, + MaxTokens: c.maxTokens, } b, err := json.Marshal(body) if err != nil { diff --git a/internal/adapters/llm/client_test.go b/internal/adapters/llm/client_test.go index 742bc96..1f0dc91 100644 --- a/internal/adapters/llm/client_test.go +++ b/internal/adapters/llm/client_test.go @@ -43,6 +43,27 @@ func TestClient_Complete(t *testing.T) { } } +// TestClient_SendsMaxTokens guards Tapir's ADR-004 change to the copied client: +// it MUST send a positive max_tokens, or thinking models return empty content. +func TestClient_SendsMaxTokens(t *testing.T) { + var body chatRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": "ok"}}}, + }) + })) + defer srv.Close() + + c := New(srv.URL, "", "test-model", 10*time.Second) + if _, err := c.Complete(context.Background(), "sys", "user"); err != nil { + t.Fatalf("Complete: %v", err) + } + if body.MaxTokens <= 0 { + t.Errorf("max_tokens = %d, want > 0 (thinking models return empty content without it)", body.MaxTokens) + } +} + func TestClient_ReturnsErrorOnNon200(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "overloaded", http.StatusServiceUnavailable)