Skip to content

Handle safety-classifier refusals with a distinct error and metric - #28

Merged
mattgodbolt merged 2 commits into
mainfrom
molty/refusal-handling
Jul 29, 2026
Merged

Handle safety-classifier refusals with a distinct error and metric#28
mattgodbolt merged 2 commits into
mainfrom
molty/refusal-handling

Conversation

@mattgodbolt-molty

@mattgodbolt-molty mattgodbolt-molty commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Check-up step three.

Sonnet 5's safety classifiers can decline a request: a normal HTTP 200 whose stop_reason is "refusal", with empty content (or a partial that should be discarded). CE users compile arbitrary code, including exploit-adjacent material, so this will eventually fire in production. Today it falls into the generic empty-response path, where it's indistinguishable from thinking starving max_tokens on dashboards, and the user gets a confusing "no text content" error.

This PR handles it explicitly, before text extraction:

  • Clear user-facing message ("Claude declined to explain this code (safety filters)...") with a hint that trimming input may help.
  • Any mid-stream partial output is discarded rather than served as a complete explanation.
  • Dedicated ClaudeExplainRefusal metric, separate from ClaudeExplainEmptyResponse, with usage populated.
  • Error responses were already excluded from the cache, so retries hit the API.

Two new tests (pre-output refusal, mid-stream partial discard); CLAUDE.md gotchas updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY

Sonnet 5 ships cyber-safety classifiers that can decline a request:
HTTP 200 with stop_reason "refusal" and empty (or discarded partial)
content. CE users compile arbitrary code, so exploit-adjacent input can
plausibly trip this. Previously a refusal fell into the generic
empty-response path, indistinguishable from thinking starving max_tokens.

Now handled explicitly before text extraction: a clear user-facing
message (with a hint that trimming input may help), partial output
discarded rather than served, usage populated, and a dedicated
ClaudeExplainRefusal metric. Error responses are already not cached, so
retries hit the API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY
@mattgodbolt
mattgodbolt requested a review from Copilot July 29, 2026 23:45

Copilot AI 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.

Pull request overview

This PR updates the FastAPI “explain” pipeline to explicitly handle Anthropic safety-classifier refusals (stop_reason == "refusal") as a distinct, user-visible error path with dedicated metrics, so refusals don’t get conflated with generic empty-response/token-starvation failures.

Changes:

  • Add explicit refusal handling in app/explain.py, returning a dedicated error message, emitting ClaudeExplainRefusal, and discarding any partial text content.
  • Add tests covering (1) refusal with empty content and (2) refusal that includes partial text which must be discarded.
  • Update CLAUDE.md documentation to capture the refusal behavior and its observability implications.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
CLAUDE.md Documents the new refusal behavior and metric separation.
app/test_explain.py Adds tests for refusal handling and partial-output discard.
app/explain.py Implements refusal-specific error handling and metrics emission before generic empty-response logic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/test_explain.py
Comment on lines +290 to +311
@pytest.mark.asyncio
async def test_refusal_discards_partial_output(self, sample_request, noop_metrics):
"""A mid-stream refusal can carry partial text; it must be discarded
rather than served as if it were a complete explanation."""
partial = MagicMock()
partial.type = "text"
partial.text = "This function starts by..."

mock_message = MagicMock()
mock_message.content = [partial]
mock_message.usage = MagicMock(input_tokens=80, output_tokens=40)
mock_message.stop_reason = "refusal"

mock_client = MagicMock()
mock_client.messages.create = AsyncMock(return_value=mock_message)

test_prompt = Prompt(Path("app/prompt.yaml"))
response = await process_request(sample_request, mock_client, test_prompt, noop_metrics)

assert response.status == "error"
assert response.explanation is None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — done in ea7e065: the test now asserts the distinct declined message, the absence of both the generic no-text error and the partial output text, and populated usage.

Assert the distinct declined message, absence of the generic error text
and of the partial output, and populated usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY
@mattgodbolt
mattgodbolt merged commit 640e1c8 into main Jul 29, 2026
2 checks passed
@mattgodbolt
mattgodbolt deleted the molty/refusal-handling branch July 29, 2026 23:58
mattgodbolt-molty added a commit that referenced this pull request Jul 30, 2026
…tent (#29)

Final check-up documentation pass. **Stacked on #28** (both touch
CLAUDE.md); GitHub will retarget this to main when #28 merges — merge
#28 first.

- **Records the prompt-caching evaluation and rejection** with the data:
~104 fresh Claude calls/day against a 5-minute cache TTL and a prefix
fragmented by language/arch/audience/type; generous math says
~$0.40/fortnight saved of ~$22 spend, and the restructuring needed to
clear Sonnet 5's 1024-token minimum cacheable prefix isn't paid for. The
note includes the revisit threshold (~50x traffic, or sustained >3
same-combo req/hour for the 1-hour TTL) and the CloudWatch query to
rerun the analysis.
- **Condenses the whole document** (137 -> 103 lines, nothing
load-bearing lost): merges Project Structure and the workflow notes into
Overview/Development Commands, adds the prompt-test workflow as a
first-class step before prompt changes, drops historical narration (the
1536-token era), dedupes the thinking gotchas, and documents the
`build_api_payload` single-source-of-truth rule established in #27.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY

---------

Co-authored-by: mattgodbolt-molty <mattgodbolt-molty@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants