Skip to content

fix: strip thinking blocks with invalid signatures before sending to Anthropic API - #72

Open
Michael J. Jabbour (michaeljabbour) wants to merge 1 commit into
mainfrom
fix/strip-invalid-thinking-signatures
Open

fix: strip thinking blocks with invalid signatures before sending to Anthropic API#72
Michael J. Jabbour (michaeljabbour) wants to merge 1 commit into
mainfrom
fix/strip-invalid-thinking-signatures

Conversation

@michaeljabbour

Copy link
Copy Markdown

Fixes microsoft-amplifier/amplifier-support#207

Root cause

Sessions that switch providers mid-conversation (e.g. some turns handled by
provider-chat-completions or provider-openai, interleaved with anthropic
turns) can persist assistant thinking content blocks whose signature is
null, absent entirely, or otherwise not something Anthropic minted — the
signing/verification scheme is provider-specific and cannot be retrofitted
onto a block another provider produced.

Anthropic strict-validates thinking.signature as a non-empty string on
every replay request. A single malformed block anywhere in history is
enough for the entire request to 400, e.g.:

messages.51.content[0].thinking.signature.str: Input should be a valid string

...which bricks the session on every subsequent resume attempt, even though
only one message out of many is at fault.

What this PR does

Adds AnthropicProvider._sanitize_thinking_blocks(messages, model), a
defensive chokepoint pass run once in _complete_chat_request on the fully
assembled all_messages list, immediately after message conversion/combination
and before cache-control is applied. Both the streaming transport
(client.messages.stream) and the non-streaming transport
(client.messages.with_raw_response.create) consume the same params dict
built there — as does the refusal-fallback retry path — so one sanitize
pass covers all three call sites.

The sanitizer:

  • Strips thinking/redacted_thinking blocks whose signature is not a
    valid non-empty string, covering both malformed shapes seen in the wild:
    • {"type": "thinking", "thinking": "...", "signature": null}
      round-tripped through provider-chat-completions' history format.
    • {"type": "thinking", "content": ["<encrypted>", "rs_..."]} with no
      signature key at all — provider-openai's Responses API persists
      encrypted reasoning content + a reasoning-item id instead of a signature.
    • Both shapes reduce to the same check once read via dict.get (a missing
      key and an explicit None are indistinguishable to .get()).
  • Tolerates non-dict content-array entries (e.g. a raw string surviving a
    corrupted/partial transcript) instead of crashing.
  • Inserts a minimal placeholder text block if stripping would leave an
    assistant message with an empty content array — Anthropic rejects empty
    content arrays just as strictly as it rejects unsigned thinking blocks.
  • Logs one aggregate logger.warning (stripped count + model) and emits a
    provider:thinking_signature_stripped hook event per request when
    anything was actually stripped — matching the existing
    provider:tool_sequence_repaired observability pattern already used
    elsewhere in this module. No log, no event, and no behavior change at
    all for clean histories.
  • Leaves valid signed thinking blocks, and all other block types, untouched.

Tests

tests/test_thinking_sanitization.py (12 tests, TDD: written and confirmed
failing before the fix landed) drives the fix through the real complete()
pipeline and asserts on the captured params["messages"] — the same dict
handed to the (mocked) Anthropic SDK call, i.e. the actual outgoing payload:

  • Shape (a): signature: null thinking block → stripped from the payload.
  • Shape (b): thinking-typed block with no signature key, cross-provider
    content payload → stripped (both via the full pipeline and via a direct
    unit test of _sanitize_thinking_blocks with a hand-built dict that
    genuinely omits the signature key, proving .get()-based access rather
    than indexing).
  • Valid signed thinking block → preserved verbatim, order intact.
  • Assistant message whose only content is an invalid thinking block →
    placeholder inserted, content never empty.
  • Mixed message (thinking + text + tool call) → only the bad thinking block
    removed; other blocks intact and in original order.
  • redacted_thinking without a signature key → left untouched (Anthropic
    doesn't require one there).
  • Non-dict content block present → no crash.
  • Clean history with no thinking blocks → completely unaffected.
  • Warning logged with count + model; event emitted only when something was
    actually stripped (and not emitted on clean/valid histories).

Full suite after the fix: 538 passed (526 pre-existing + 12 new), with
the same 3 pre-existing, unrelated failures in test_tool_repair.py's
streaming fixtures (a MockStreamManager/async-iterator mismatch, confirmed
present on main before this change — not touched by this PR).

Part of a set

This PR is part of the cross-provider resume hardening set tracked on
microsoft-amplifier/amplifier-support#208. Sibling PRs: the
provider-chat-completions producer-side fix for
microsoft-amplifier/amplifier-support#206, and an effort-clamp fix for
microsoft-amplifier/amplifier-support#289.

Follow-up to verify

The separate amplifier-module-provider-anthropic-fable package is expected
to inherit this fix automatically via subclassing AnthropicProvider — but
that package should be checked to confirm it does not override
_complete_chat_request or _convert_messages in a way that bypasses the
new sanitize call site.

…Anthropic API

Fixes microsoft-amplifier/amplifier-support#207.

Sessions that switch providers mid-conversation (e.g. some turns on
provider-chat-completions or provider-openai, interleaved with anthropic
turns) can persist assistant thinking blocks whose signature is null,
absent, or otherwise not something Anthropic minted. Anthropic strict-
validates thinking.signature as a non-empty string on every replay
request, so a single bad block anywhere in history 400s the ENTIRE
request and bricks the session on every future resume attempt.

Adds AnthropicProvider._sanitize_thinking_blocks(messages, model), a
defensive chokepoint pass run in _complete_chat_request on the fully
assembled message list, before cache control is applied. Both the
streaming transport (client.messages.stream) and the non-streaming
transport (client.messages.with_raw_response.create) consume the same
params dict built there -- as does the refusal-fallback retry path --
so one sanitize pass covers all three call sites.

The sanitizer:
- Strips thinking/redacted_thinking blocks whose signature is not a
  valid non-empty string, covering both malformed shapes seen in the
  wild: {"signature": null} (provider-chat-completions) and a missing
  signature key entirely, with the reasoning payload carried under
  content instead (provider-openai's Responses API shape).
- Tolerates non-dict content-array entries instead of crashing.
- Inserts a minimal placeholder text block if stripping would leave an
  assistant message with an empty content array (Anthropic rejects
  those too).
- Logs one aggregate logger.warning (count + model) and emits a
  provider:thinking_signature_stripped hook event per request when
  anything was stripped, matching the existing
  provider:tool_sequence_repaired observability pattern. No log/event
  and no behavior change at all for clean histories.

Part of the cross-provider resume hardening set tracked on
microsoft-amplifier/amplifier-support#208, alongside the
provider-chat-completions producer-side fix for #206 and an
effort-clamp fix for #289.

Adds tests/test_thinking_sanitization.py (12 tests) driving the fix
through the real complete() pipeline and asserting on the captured
params["messages"] payload (the same dict the SDK call receives) --
covering both malformed shapes, valid-signature preservation, mixed
thinking+tool_call+text ordering, empty-content placeholder insertion,
non-dict block tolerance, and the warning/event observability surface.
Full suite: 538 passed (526 pre-existing + 12 new), same 3 pre-existing
unrelated failures in test_tool_repair.py's streaming fixtures (a
MockStreamManager/async-iterator mismatch predating this change,
confirmed present on main before this patch).
@michaeljabbour

Copy link
Copy Markdown
Author

Follow-up resolved: anthropic-fable is covered by this sanitization ✅

Verified the separate fable package against both the installed package (~/.amplifier/cache/amplifier-module-provider-anthropic-fable-*/amplifier_module_provider_anthropic_fable/__init__.py, 292 lines) and GitHub main (michaeljabbour/amplifier-module-provider-anthropic-fable — note there is no microsoft/-org repo for it). Method rosters are identical in both.

Findings:

  • class AnthropicFableProvider(AnthropicProvider) (__init__.py:126) overrides only: _get_capabilities (:151), _refusal_fallback_target (:158), _strip_thinking_blocks (:175, static), complete (:197), plus mount-scope helpers (_add_cost, cleanup).
  • It does NOT override _complete_chat_request, _convert_messages, or _clean_content_block — the request-serialization path this PR hardens.
  • Its complete() is a thin wrapper: response = await super().complete(request, **kwargs) for the primary attempt, and return await super().complete(fallback_request, **fallback_kwargs) for the refusal-fallback retry — both routes flow through the base _complete_chat_request, where _sanitize_thinking_blocks runs.

Conclusion: the sanitization in this PR automatically serves the fable variant on every path (streaming, non-streaming, refusal-fallback, and fable's own fallback retry). No follow-up work needed in the fable package.

@michaeljabbour

Copy link
Copy Markdown
Author

Cross-provider resume hardening set — complete PR index

This PR is Option B (consumer-side edge normalization) in the set tracked on microsoft-amplifier/amplifier-support#208. Full set for reviewers:

PR Repo Role
provider-anthropic#72 (this PR) provider-anthropic Consumer-side sanitization of invalid thinking blocks — Fixes support#207
provider-chat-completions#12 provider-chat-completions Producer fix — stop fabricating signature: null thinking blocks — Fixes support#206
provider-anthropic#71 provider-anthropic Effort clamp (max→highest supported tier) — Addresses support#289
amplifier-app-cli#232 amplifier-app-cli Option A — resume-time provider/model mismatch warning + confirm, plus provider persisted in session metadata — Addresses support#208

Design rationale (incl. why the planned amplifier-core PR was skipped) and deferred items: support#208 comment. The fable follow-up from this PR's body is resolved (see comment above).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant