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..3aeb0f4 100644 --- a/app/test_explain.py +++ b/app/test_explain.py @@ -264,6 +264,59 @@ 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 + # 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): """A Claude call that overruns the wall-clock budget must return a