Skip to content

fix(llm-client): drop unsigned thinking blocks on the Anthropic leg - #285

Open
sabhatinas wants to merge 1 commit into
mainfrom
sabhatinas/strip-unsigned-thinking-blocks
Open

fix(llm-client): drop unsigned thinking blocks on the Anthropic leg#285
sabhatinas wants to merge 1 commit into
mainfrom
sabhatinas/strip-unsigned-thinking-blocks

Conversation

@sabhatinas

@sabhatinas sabhatinas commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

  • Drops replayed thinking blocks that have no signature, on the Anthropic leg of libsy-llm-client.
  • switchyard-components already does this; libsy-llm-client (what the Rust server uses) did not. Same parity gap as fix(llm-client): strip Anthropic-incompatible fields on the Anthropic leg #248, next item on the list.
  • If a message's only content was an unsigned thinking block, its content collapses to an empty string so the message stays valid.

Why

  • Anthropic requires signed thinking blocks on replay. A router can serve early turns from an OpenAI-format target whose thinking blocks are unsigned, then escalate to an Anthropic target and replay them.
  • Hit this with stage_router on TB 2.1: GLM 5.2 serves a few turns emitting {"type": "thinking", ..., "signature": ""}, and the first escalation to aws/anthropic/bedrock-claude-opus-4-8 fails with 403 The request signature we calculated does not match the signature you provided.
  • The AWS wording is misleading — nothing is wrong with credentials. Reproduced with plain curl through Inference Hub, no Switchyard involved:
azure/anthropic/claude-opus-4-8         + unsigned thinking block -> 200
aws/anthropic/bedrock-claude-opus-4-8   + unsigned thinking block -> 403

Same key, same body otherwise. Bedrock enforces the signed-thinking rule; Azure-hosted Anthropic currently does not, which is why only Bedrock routes broke.

  • Ruled out first: request size (195 KB body -> 200), forwarded Switchyard/Claude headers (200), output_config (200), and credentials (Bedrock passthrough with no weak tier scored reward 1.0).
  • Not stage_router-specific. Any route that switches wire formats mid-session can replay unsigned blocks onto an Anthropic target.

Testing

  • New test anthropic_requests_drop_unsigned_thinking_blocks: unsigned block dropped, signed block kept, no message left with an empty content array. Verified it fails without the strip and passes with it.
  • cargo test -p switchyard-llm-client (46) and -p switchyard-server (47) pass. fmt and clippy clean.
  • Built an image with this change and reran the stage_router smoke that previously failed (Bedrock Opus strong, Foundry GLM weak).

Summary by CodeRabbit

  • Bug Fixes
    • Improved Anthropic request handling by removing unsigned thinking content before requests are sent.
    • Preserved signed thinking content and ensured messages without remaining blocks use valid empty content.
    • Prevented requests from containing empty content arrays.

Signed-off-by: sabhatinas <sabhatinas@nvidia.com>
@sabhatinas
sabhatinas requested a review from a team as a code owner August 4, 2026 18:48
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Anthropic request preparation now removes unsigned thinking blocks before request enrichment. Signed blocks remain. Empty message content becomes an empty string. An integration test verifies the resulting request body.

Changes

Anthropic request cleanup

Layer / File(s) Summary
Clean Anthropic message content
crates/libsy-llm-client/src/client.rs
Anthropic request handling recursively removes unsigned thinking blocks, preserves signed blocks, and converts empty content to an empty string before applying extra body fields and prompt-caching behavior.
Validate cleaned requests
crates/libsy-llm-client/src/client.rs
An integration test verifies unsigned block removal, signed block preservation, and the absence of empty content arrays.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Poem

A rabbit trims the thinking blooms,
Keeps signed petals in their rooms.
Empty trays turn strings instead,
Clean requests hop straight ahead.
Tests thump paws: the path is clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: removing unsigned Anthropic thinking blocks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/libsy-llm-client/src/client.rs`:
- Around line 1315-1332: Update the matcher in the test around the blocks and
thinking checks to assert that the message whose content contained only the
unsigned block remains in messages with content exactly equal to an empty
string. Keep the existing signed-thinking preservation checks, and ensure the
assertion distinguishes this required replacement from dropping the message or
omitting its content.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f4c5cbcd-a954-4555-926e-efd06e46a38c

📥 Commits

Reviewing files that changed from the base of the PR and between a5fefa7 and a1c0681.

📒 Files selected for processing (1)
  • crates/libsy-llm-client/src/client.rs

Comment on lines +1315 to +1332
// The unsigned block is gone, the signed one survives, and the
// message whose only block was unsigned is not left with an empty
// content array.
let blocks: Vec<&Value> = messages
.iter()
.filter_map(|message| message.get("content"))
.filter_map(Value::as_array)
.flatten()
.collect();
let thinking: Vec<&&Value> = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("thinking"))
.collect();
thinking.len() == 1
&& thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
&& messages
.iter()
.all(|message| message.get("content") != Some(&json!([])))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the required empty-string replacement.

The matcher does not verify that the message with only an unsigned block remains in messages with content: "". It also passes if a regression drops that message or omits its content, which changes conversation history.

Proposed test assertion
                 thinking.len() == 1
                     && thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
+                    && messages.len() == 4
+                    && messages
+                        .get(1)
+                        .and_then(|message| message.get("content"))
+                        == Some(&json!(""))
                     && messages
                         .iter()
                         .all(|message| message.get("content") != Some(&json!([])))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The unsigned block is gone, the signed one survives, and the
// message whose only block was unsigned is not left with an empty
// content array.
let blocks: Vec<&Value> = messages
.iter()
.filter_map(|message| message.get("content"))
.filter_map(Value::as_array)
.flatten()
.collect();
let thinking: Vec<&&Value> = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("thinking"))
.collect();
thinking.len() == 1
&& thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
&& messages
.iter()
.all(|message| message.get("content") != Some(&json!([])))
// The unsigned block is gone, the signed one survives, and the
// message whose only block was unsigned is not left with an empty
// content array.
let blocks: Vec<&Value> = messages
.iter()
.filter_map(|message| message.get("content"))
.filter_map(Value::as_array)
.flatten()
.collect();
let thinking: Vec<&&Value> = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("thinking"))
.collect();
thinking.len() == 1
&& thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
&& messages.len() == 4
&& messages
.get(1)
.and_then(|message| message.get("content"))
== Some(&json!(""))
&& messages
.iter()
.all(|message| message.get("content") != Some(&json!([])))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/client.rs` around lines 1315 - 1332, Update the
matcher in the test around the blocks and thinking checks to assert that the
message whose content contained only the unsigned block remains in messages with
content exactly equal to an empty string. Keep the existing signed-thinking
preservation checks, and ensure the assertion distinguishes this required
replacement from dropping the message or omitting its content.

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