From be582ce2ed529a9c8f437336e5c3c9d947f4a89b Mon Sep 17 00:00:00 2001 From: mattgodbolt-molty Date: Wed, 29 Jul 2026 18:38:52 -0500 Subject: [PATCH 1/2] Handle safety-classifier refusals with a distinct error and metric 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 Claude-Session: https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY --- CLAUDE.md | 5 +++++ app/explain.py | 32 ++++++++++++++++++++++++++++++++ app/test_explain.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a2acb28..a5a0e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,11 @@ call → response with metrics. See `claude_explain.md` for detailed architectur `redacted_thinking` blocks (encrypted reasoning when safety filters trip); the same filter excludes them correctly, but be aware "no text block" can mean either max_tokens starvation *or* a redacted-thinking-only response — the error message is the same. +- **Safety refusals are handled before the empty-response path.** Claude 5-family classifiers can decline a + request (HTTP 200 with `stop_reason: "refusal"`, empty or partial content) — plausible here since CE users + compile arbitrary, sometimes exploit-adjacent code. `app/explain.py` returns a distinct user-facing message, + discards any partial output, and emits `ClaudeExplainRefusal` so it's separable from token starvation on + dashboards. - **Empty responses are not 500s.** When the model returns no text block, `app/explain.py` returns `ExplainResponse(status="error")` with `usage` populated and emits `ClaudeExplainEmptyResponse`. The cache layer skips storing error responses so retries hit the API. Don't change this to raise — the structured error diff --git a/app/explain.py b/app/explain.py index 0ab9bfc..86d27c4 100644 --- a/app/explain.py +++ b/app/explain.py @@ -162,6 +162,38 @@ async def _call_anthropic_api( output_tokens = message.usage.output_tokens total_tokens = input_tokens + output_tokens + if message.stop_reason == "refusal": + # Claude 5-family safety classifiers can decline a request: a normal + # HTTP 200 whose stop_reason is "refusal", with empty (or discarded + # partial) content. CE users compile arbitrary code, so exploit-adjacent + # input can occasionally trip this. Handle it before the generic + # empty-response path so it gets a clear user-facing message and its + # own metric rather than looking like token starvation on dashboards. + message_text = ( + "Claude declined to explain this code (safety filters). This can occasionally " + "trigger on benign security-related code; trimming the input to the relevant " + "part may help." + ) + LOGGER.warning("Refusal from model (in=%d, out=%d)", input_tokens, output_tokens) + metrics_provider.set_property("language", body.language) + metrics_provider.set_property("compiler", body.compiler) + metrics_provider.set_property("instructionSet", body.instructionSet or "unknown") + metrics_provider.set_property("cached", "false") + metrics_provider.put_metric("ClaudeExplainRequest", 1) + metrics_provider.put_metric("ClaudeExplainRefusal", 1) + metrics_provider.put_metric("ClaudeExplainInputTokens", input_tokens) + metrics_provider.put_metric("ClaudeExplainOutputTokens", output_tokens) + return ExplainResponse( + status="error", + message=message_text, + model=prompt_data["model"], + usage=TokenUsage( + inputTokens=input_tokens, + outputTokens=output_tokens, + totalTokens=total_tokens, + ), + ) + # Pick the last text block — when thinking is enabled the response # contains thinking blocks before the final text block. text_blocks = [c for c in message.content if getattr(c, "type", None) == "text"] diff --git a/app/test_explain.py b/app/test_explain.py index 2ed15ee..941a7c6 100644 --- a/app/test_explain.py +++ b/app/test_explain.py @@ -264,6 +264,51 @@ async def test_returns_error_when_no_text_block(self, sample_request, noop_metri assert response.usage.inputTokens == 100 assert response.usage.outputTokens == 50 + @pytest.mark.asyncio + async def test_refusal_returns_clear_error(self, sample_request, noop_metrics): + """A safety-classifier refusal (HTTP 200, stop_reason='refusal', empty + content) must return a user-facing message distinct from the generic + empty-response error, with usage populated.""" + mock_message = MagicMock() + mock_message.content = [] + mock_message.usage = MagicMock(input_tokens=80, output_tokens=0) + 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 + assert "declined" in response.message + assert "no text content" not in response.message + assert response.usage is not None + assert response.usage.inputTokens == 80 + + @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 + @pytest.mark.asyncio async def test_returns_error_when_call_exceeds_deadline(self, sample_request, noop_metrics): """A Claude call that overruns the wall-clock budget must return a From ea7e065c650290f4e9e722b7337ecf3ef5862993 Mon Sep 17 00:00:00 2001 From: mattgodbolt-molty Date: Wed, 29 Jul 2026 18:56:12 -0500 Subject: [PATCH 2/2] Strengthen partial-discard test per Copilot review 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 Claude-Session: https://claude.ai/code/session_012jJRqmqhE11VAc3biKbxUY --- app/test_explain.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/test_explain.py b/app/test_explain.py index 941a7c6..3aeb0f4 100644 --- a/app/test_explain.py +++ b/app/test_explain.py @@ -308,6 +308,14 @@ async def test_refusal_discards_partial_output(self, sample_request, noop_metric assert response.status == "error" assert response.explanation is None + # The refusal contract: the distinct declined message, not the generic + # empty-response error, and no trace of the partial output anywhere. + assert "declined" in response.message + assert "no text content" not in response.message + assert partial.text not in response.message + assert response.usage is not None + assert response.usage.inputTokens == 80 + assert response.usage.outputTokens == 40 @pytest.mark.asyncio async def test_returns_error_when_call_exceeds_deadline(self, sample_request, noop_metrics):