Files
coder/coderd/x/chatd/thinking_binding.go
T
Ethan 0e9337c215 fix: retry Anthropic thinking binding errors with drop_block (#29792)
Closes CODAGT-1141

Opus 5.5 and Fable 5.1 bind each signed thinking block to the exact
prefix it was created under. chatd rebuilds the system prompt and tools
from live state before every model call, so if that state changes
mid-turn (e.g. after `start_workspace`), the replayed block no longer
matches and Anthropic returns a 400, which we treated as non-retryable:

```
messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation...
```

To fix, we'll do what Anthropic recommends: on that 400, retry once with
`thinking.block_binding.prefix_mismatch_behavior: "drop_block"` and the
`thinking-binding-controls-2026-08-01` beta, then send both upfront for
that provider and model once the retry succeeds. That's only a hint,
since BYOK users on the same provider can have different keys, so if a
request with `drop_block` gets a 400 we retry once without it and clear
the hint. The retry wraps the model itself, so compaction and the
advisor get it too. The hint is in-memory, so a restart costs one extra
400 per model on each replica. Chats that never hit the error are
unchanged.

aibridge's InvokeModel path was stripping the beta and deleting
`block_binding`, so it now forwards both or neither, for any model. It
also renames the field to `mismatch_behavior`, since that's what Bedrock
calls it, and only some regions accept the Anthropic name.

Always sending `drop_block` would change bytes on working chats, and AWS
doesn't document Mantle support for the beta. Dropping stale thinking
client-side means guessing what the server will reject (the signature is
opaque, and enforcement depends on the model and account), so it would
still need the retry. The proper fix is an append-only prefix, but
that's a much bigger change, and it would also still need this retry.

Mantle does accept the beta, at least for Opus 5.5 on us-east-1 (see
testing below).

<details>
<summary>Testing</summary>

I tested this manually on `9c2b4d6` with `develop.sh`, using Opus 5.5 on
Bedrock Mantle us-east-1 via the dogfood AI Gateway, with a small
logging proxy in front to record the beta header and `block_binding` on
each request. The direct Anthropic API doesn't enforce the binding for
our account, so I couldn't reproduce it there.

To trigger it, I just asked the agent to reason about something, create
a workspace from the `docker` template and run a command in it, all in
one turn. On the merge base, the turn fails with the 400 above as soon
as the workspace is created. On this branch there's one 400, the
`drop_block` retry succeeds, the turn finishes with no error in the UI,
and every request after that sends `drop_block` upfront:

```
status=400 beta=""                                     block_binding=""
status=200 beta="thinking-binding-controls-2026-08-01" block_binding={"prefix_mismatch_behavior":"drop_block"}
status=200 beta="thinking-binding-controls-2026-08-01" block_binding={"prefix_mismatch_behavior":"drop_block"}
```

The chat that failed on the merge base also recovers once it's continued
on this branch, and a restart costs one extra 400 as expected. I also
had the proxy reject any request with `drop_block`, to mimic a BYOK key
without the beta, and the retry without it succeeded and cleared the
hint, so the next chat didn't send it.

I didn't test the InvokeModel path, since I don't have AWS credentials
for it.

</details>
2026-09-24 14:02:06 +10:00

206 lines
6.9 KiB
Go

package chatd
import (
"context"
"errors"
"maps"
"net/http"
"strings"
"sync"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/codersdk"
)
const (
thinkingBindingBeta = "thinking-binding-controls-2026-08-01"
thinkingBindingDropBlock = "drop_block"
thinkingBindingDropBlockPath = "thinking.block_binding.prefix_mismatch_behavior"
)
// isThinkingBindingError reports whether Anthropic rejected a replayed
// signed thinking block because the system prompt or tools changed since
// the block was produced.
func isThinkingBindingError(err error) bool {
var pe *fantasy.ProviderError
if !errors.As(err, &pe) || pe.StatusCode != http.StatusBadRequest {
return false
}
text := strings.ToLower(pe.Message + string(pe.ResponseBody))
return strings.Contains(text, "bound to a different conversation")
}
type thinkingDropBlockKey struct {
providerID uuid.UUID
model string
}
// withThinkingDropBlock wraps Anthropic models in a thinkingDropBlockModel.
func (p *Server) withThinkingDropBlock(
model chatprovider.Model,
providerID uuid.UUID,
callConfig codersdk.ChatModelCallConfig,
chatID uuid.UUID,
) chatprovider.Model {
if chatprovider.NormalizeProvider(model.Provider()) != fantasyanthropic.Name {
return model
}
// Call headers replace client headers of the same name, so the value
// must repeat the betas the client already sends.
betas := []string{thinkingBindingBeta}
if existing := chatprovider.BetaHeadersFromCallConfig(fantasyanthropic.Name, &callConfig)[chatprovider.HeaderAnthropicBeta]; existing != "" {
betas = []string{existing, thinkingBindingBeta}
}
return model.WithLanguageModel(&thinkingDropBlockModel{
LanguageModel: model.LanguageModel(),
logger: p.logger.With(slog.F("chat_id", chatID)),
hints: &p.thinkingDropBlock,
key: thinkingDropBlockKey{providerID: providerID, model: model.ModelID()},
betas: strings.Join(betas, ","),
})
}
// thinkingDropBlockModel makes Anthropic drop stale signed thinking blocks
// instead of rejecting the request. chatd rebuilds the system prompt and
// tools for every request, and some models bind signed thinking blocks to
// that prefix. A hint for each provider and model picks whether the first
// request sends the drop_block control. A 400 before any output retries
// once with the other choice, and a successful retry updates the hint. The
// hint can be wrong, for example when BYOK users of one provider send
// different keys, so it never removes the fallback.
type thinkingDropBlockModel struct {
fantasy.LanguageModel
logger slog.Logger
hints *sync.Map
key thinkingDropBlockKey
betas string
}
// Stream retries only when the rejection arrives before any output.
// fantasy reports the HTTP 400 as a stream part, not as the Stream error.
func (m *thinkingDropBlockModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
first, fallback := call, m.withDropBlock(call)
retryable, updateHint := isThinkingBindingError, func() { m.hints.Store(m.key, struct{}{}) }
_, hinted := m.hints.Load(m.key)
if hinted {
// The error a provider returns for an unsupported beta is
// unknown, so any 400 retries without drop_block.
first, fallback = fallback, first
retryable, updateHint = isBadRequest, func() { m.hints.Delete(m.key) }
}
stream, err := m.LanguageModel.Stream(ctx, first)
if err != nil {
return nil, err
}
return func(yield func(fantasy.StreamPart) bool) {
var rejection error
started := false
for part := range stream {
if !started && part.Type == fantasy.StreamPartTypeError && retryable(part.Error) {
rejection = part.Error
break
}
started = started || part.Type != fantasy.StreamPartTypeWarnings
if !yield(part) {
return
}
}
if rejection == nil {
return
}
m.logger.Warn(ctx, "retrying model call with thinking drop_block toggled",
slog.F("provider_id", m.key.providerID),
slog.F("model", m.key.model),
slog.F("drop_block", !hinted),
slogError(rejection),
)
m.retryStream(ctx, fallback, updateHint, yield)
}, nil
}
func isBadRequest(err error) bool {
var pe *fantasy.ProviderError
return errors.As(err, &pe) && pe.StatusCode == http.StatusBadRequest
}
// retryStream streams the fallback request and calls updateHint when the
// fallback starts without an error.
func (m *thinkingDropBlockModel) retryStream(ctx context.Context, fallback fantasy.Call, updateHint func(), yield func(fantasy.StreamPart) bool) {
stream, err := m.LanguageModel.Stream(ctx, fallback)
if err != nil {
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
return
}
decided := false
for part := range stream {
if !decided && part.Type != fantasy.StreamPartTypeWarnings {
decided = true
if part.Type != fantasy.StreamPartTypeError {
updateHint()
}
}
if !yield(part) {
return
}
}
}
// withDropBlock adds the drop_block body field and the beta header, which
// must travel together.
func (m *thinkingDropBlockModel) withDropBlock(call fantasy.Call) fantasy.Call {
options := maps.Clone(call.ProviderOptions)
if options == nil {
options = fantasy.ProviderOptions{}
}
anthropicOptions := &fantasyanthropic.ProviderOptions{}
if existing, ok := options[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions); ok && existing != nil {
copied := *existing
anthropicOptions = &copied
}
anthropicOptions.ExtraBody = maps.Clone(anthropicOptions.ExtraBody)
if anthropicOptions.ExtraBody == nil {
anthropicOptions.ExtraBody = map[string]any{}
}
if anthropicThinkingOmitted(anthropicOptions, m.Model()) {
// Claude 5+ models think by default when the request omits the
// thinking field, so an explicit adaptive config keeps behavior.
anthropicOptions.ExtraBody["thinking"] = map[string]any{
"type": "adaptive",
"block_binding": map[string]any{
"prefix_mismatch_behavior": thinkingBindingDropBlock,
},
}
} else {
anthropicOptions.ExtraBody[thinkingBindingDropBlockPath] = thinkingBindingDropBlock
}
options[fantasyanthropic.Name] = anthropicOptions
call.ProviderOptions = options
headers := maps.Clone(call.Headers)
if headers == nil {
headers = map[string]string{}
}
headers[chatprovider.HeaderAnthropicBeta] = m.betas
call.Headers = headers
return call
}
// anthropicThinkingOmitted mirrors when fantasy leaves the thinking field
// out of an Anthropic request for a model that thinks by default.
func anthropicThinkingOmitted(options *fantasyanthropic.ProviderOptions, modelID string) bool {
if options.Thinking != nil {
return false
}
model := strings.ToLower(modelID)
if options.Effort == nil {
return !strings.Contains(model, "claude-mythos-preview")
}
return *options.Effort == fantasyanthropic.EffortNone &&
(strings.Contains(model, "claude-mythos") || strings.Contains(model, "claude-fable"))
}