eleven / messenger Public

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>
Paul Campbell 8652526e87452fc9d2469629a9ebea87081d65c3 parent 0d62141
3 files changed, +80 −5
  • bot_run_test.go +45 −0
  • internal/bot/brain.go +20 −5
  • internal/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))
+ }
+ }
+}