Handle safety-classifier refusals with a distinct error and metric - #28
Merged
Conversation
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
approved these changes
Jul 29, 2026
There was a problem hiding this comment.
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, emittingClaudeExplainRefusal, 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.mddocumentation 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 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 | ||
|
|
Contributor
Author
There was a problem hiding this comment.
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-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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Check-up step three.
Sonnet 5's safety classifiers can decline a request: a normal HTTP 200 whose
stop_reasonis"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 starvingmax_tokenson dashboards, and the user gets a confusing "no text content" error.This PR handles it explicitly, before text extraction:
ClaudeExplainRefusalmetric, separate fromClaudeExplainEmptyResponse, with usage populated.Two new tests (pre-output refusal, mid-stream partial discard); CLAUDE.md gotchas updated.
🤖 Generated with Claude Code
https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY