server: don't send adaptive thinking to Haiku (it 400s) (#349)
The model reply path sent thinking:{type:adaptive} unconditionally. Sonnet and
Opus accept it; Haiku rejects it with 'adaptive thinking is not supported on
this model', so every Claude/Eleven reply 400'd and the bot went silent the
moment its model was switched to Haiku (live regression on team + paulca).
adaptiveThinking(model) returns the adaptive config for Sonnet/Opus and nil for
Haiku (nil omits the field via omitempty — the 'no thinking' request Haiku
accepts). The forced-tool retry path now omits thinking entirely rather than
sending type:disabled, so it can't 400 on Haiku either.
Tests: TestAdaptiveThinking (the gate) + TestBotEngineModelReplyHaikuNoThinking
(end-to-end: a Haiku bot's request omits thinking and still replies), plus the
existing Sonnet e2e now asserts thinking IS present.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+80
−5
bot_run_test.go+45 −0internal/bot/brain.go+20 −5internal/bot/brain_test.go+15 −0
diff --git a/bot_run_test.go b/bot_run_test.go| index 47fa8ab..4ca324c 100644 |
| --- a/bot_run_test.go |
| +++ b/bot_run_test.go |
| @@ -397,6 +397,51 @@ func TestBotEngineModelReply(t *testing.T) { |
| if _, ok := block["cache_control"]; !ok { |
| t.Error("system block not marked cacheable") |
| } |
| + // Default model is Sonnet, which supports adaptive thinking. |
| + if th, _ := fake.lastReq["thinking"].(map[string]any); th["type"] != "adaptive" { |
| + t.Errorf("Sonnet reply should send adaptive thinking, got %v", fake.lastReq["thinking"]) |
| + } |
| +} |
| + |
| +// TestBotEngineModelReplyHaikuNoThinking is the regression guard for the live |
| +// outage: Haiku 400s on adaptive thinking, so a Haiku bot must send NO thinking |
| +// field. Same flow as above with LCHAT_MODEL=haiku; asserts the request omits |
| +// thinking (and still produces a reply). |
| +func TestBotEngineModelReplyHaikuNoThinking(t *testing.T) { |
| + fake := newFakeAnthropic(t, "On it.", nil) |
| + t.Setenv("ANTHROPIC_BASE_URL", fake.srv.URL) |
| + t.Setenv("LCHAT_MODEL", "claude-haiku-4-5") |
| + |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven) |
| + |
| + dir := t.TempDir() |
| + sessionPath := dir + "/eleven.session.json" |
| + if err := eleven.Save(sessionPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + ready := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| + Recipe: bot.DefaultRecipe(), AIKey: "test-key", |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| + go eng.Run(ctx) |
| + awaitReady(t, ready) |
| + |
| + postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven hello") |
| + waitFor(t, "haiku reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 }) |
| + |
| + fake.mu.Lock() |
| + defer fake.mu.Unlock() |
| + if fake.lastReq["model"] != "claude-haiku-4-5" { |
| + t.Fatalf("expected haiku model, got %v", fake.lastReq["model"]) |
| + } |
| + if _, present := fake.lastReq["thinking"]; present { |
| + t.Fatalf("Haiku request must omit thinking, got %v", fake.lastReq["thinking"]) |
| + } |
| } |
| // TestBotEngineAnnouncerBadge pins the recipe-driven badge: an Announcer run |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index 9932ff1..3038bb6 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -22,6 +22,19 @@ const classifierModel = "claude-haiku-4-5" |
| const defaultPersona = "Keep replies short, warm, and easy to read." |
| +// adaptiveThinking returns the extended-thinking config for a model, or nil for |
| +// models that reject it. Haiku 400s on adaptive thinking ("adaptive thinking is |
| +// not supported on this model") where Sonnet/Opus accept it, so a Haiku bot must |
| +// send no thinking field at all (nil marshals away via omitempty). Gate by name |
| +// because the model id is all we have; the classifier call already runs on Haiku |
| +// with no thinking, so this only affects the main reply path. |
| +func adaptiveThinking(model string) map[string]any { |
| + if strings.Contains(strings.ToLower(model), "haiku") { |
| + return nil |
| + } |
| + return map[string]any{"type": "adaptive"} |
| +} |
| + |
| // safety is the non-overridable preamble; the console-set persona and docs |
| // layer under it (same text as the Node runtime's SAFETY). |
| func safety(name string) string { |
| @@ -190,7 +203,7 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| // truncates the tool_use off the end and the chip silently vanishes. |
| MaxTokens: 4096, |
| System: r.systemBlocks(), |
| - Thinking: map[string]any{"type": "adaptive"}, |
| + Thinking: adaptiveThinking(r.model), |
| Tools: chipTools(), |
| Messages: context, |
| }) |
| @@ -207,10 +220,12 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| if len(actions) == 0 && promisedButton.MatchString(text) { |
| r.logf("reply promised a button without a tool call — retrying forced") |
| resp2, err := r.ai.create(anthRequest{ |
| - Model: r.model, |
| - MaxTokens: 4096, |
| - System: r.systemBlocks(), |
| - Thinking: map[string]any{"type": "disabled"}, |
| + Model: r.model, |
| + MaxTokens: 4096, |
| + System: r.systemBlocks(), |
| + // Forced tool_choice requires thinking off; omitting the field is |
| + // "off" and every model accepts it (an explicit thinking type here |
| + // would 400 on Haiku, which is exactly the bug this path must avoid). |
| Tools: chipTools(), |
| ToolChoice: map[string]any{"type": "any"}, |
| Messages: context, |
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go| index b472517..2f79d51 100644 |
| --- a/internal/bot/brain_test.go |
| +++ b/internal/bot/brain_test.go |
| @@ -45,3 +45,18 @@ func TestActionsFromToolUse(t *testing.T) { |
| t.Fatalf("bad actions: %+v", got) |
| } |
| } |
| + |
| +func TestAdaptiveThinking(t *testing.T) { |
| + // Sonnet/Opus accept adaptive thinking. |
| + for _, m := range []string{"claude-sonnet-5", "claude-opus-4-8"} { |
| + if adaptiveThinking(m)["type"] != "adaptive" { |
| + t.Errorf("%s should use adaptive thinking", m) |
| + } |
| + } |
| + // Haiku rejects it → nil (omitted from the request). |
| + for _, m := range []string{"claude-haiku-4-5", "claude-haiku-4-5-20251001"} { |
| + if adaptiveThinking(m) != nil { |
| + t.Errorf("%s must send no thinking field, got %v", m, adaptiveThinking(m)) |
| + } |
| + } |
| +} |