From 3e976712e717a53ab6aea73120ae6ffacea74250 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 22 Jul 2026 18:46:20 +0530 Subject: [PATCH] fix(proxy/output-shaping): tolerate a non-string system block text in steering (#2435) ## Description `apply_verbosity_steering` (the Anthropic output-shaping path) scans the `system` block list to find and update an existing steering block: ```python if isinstance(system, list): for block in system: if isinstance(block, dict) and block.get("text", "").startswith(_STEERING_SENTINEL): ``` `.get("text", "")` only substitutes the default when the key is **absent**. A malformed client block with a null text (`{"type": "text", "text": null}`) returns `None`, so `None.startswith(...)` raises `AttributeError`. In the output-shaping treatment arm that call runs inside `shape_request`, which is not individually guarded, so the exception propagates and 502s the request. The OpenAI chat sibling in the same module already defends against this exact case (`isinstance(part.get("text"), str)`), so the Anthropic path is the inconsistent one. ## Fix Guard that the block text is a string before `startswith`, mirroring the OpenAI sibling. Well-formed bodies are unchanged: the steering block is still replaced idempotently when a level changes, or appended when absent. The malformed block is left untouched. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/output_steering.py`: string-guard the system block text before `startswith` in `apply_verbosity_steering`. - `tests/test_output_steering.py`: regression asserting a `system` list containing a `{"text": null}` block does not crash and still appends the steering block. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_output_steering.py -q 9 passed # with the fix reverted, the new test fails (AttributeError on None.startswith): $ git stash push -- headroom/proxy/output_steering.py $ python -m pytest "tests/test_output_steering.py::test_anthropic_steering_tolerates_non_string_system_block_text" -q 1 failed $ uvx ruff@0.15.17 check headroom/proxy/output_steering.py tests/test_output_steering.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/output_steering.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: called the real `apply_verbosity_steering` with `system=[{"type":"text","text":None},{"type":"text","text":"Real system prompt."}]`; also confirmed the OpenAI sibling `apply_openai_chat_verbosity_steering` handles the same shape. - Observed result: pre-fix the Anthropic call raised `AttributeError: 'NoneType' object has no attribute 'startswith'` while the OpenAI sibling returned True; post-fix the Anthropic call returns True, leaves the malformed block as-is, appends the steering block, and stays idempotent on a repeat. Ran against the actual module. - Not tested: a live client that sends a null system block text end to end. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable --- headroom/proxy/output_steering.py | 9 +++++++-- tests/test_output_steering.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/headroom/proxy/output_steering.py b/headroom/proxy/output_steering.py index 2d86a86bc..7a39dae09 100644 --- a/headroom/proxy/output_steering.py +++ b/headroom/proxy/output_steering.py @@ -36,8 +36,13 @@ def apply_verbosity_steering(body: dict[str, Any], level: int) -> bool: return True if isinstance(system, list): for block in system: - if isinstance(block, dict) and block.get("text", "").startswith(_STEERING_SENTINEL): - if block["text"] == text: + # Guard the text is a string before ``startswith``: a malformed + # client block (``{"type": "text", "text": null}``) would otherwise + # raise ``AttributeError`` here and 500 the request. The OpenAI chat + # sibling below already guards this exact case. + block_text = block.get("text") if isinstance(block, dict) else None + if isinstance(block_text, str) and block_text.startswith(_STEERING_SENTINEL): + if block_text == text: return False block["text"] = text return True diff --git a/tests/test_output_steering.py b/tests/test_output_steering.py index 8b94f66c8..39a88a6b8 100644 --- a/tests/test_output_steering.py +++ b/tests/test_output_steering.py @@ -35,6 +35,23 @@ def test_anthropic_steering_preserves_cached_prefix_block() -> None: assert body["system"][1] == {"type": "text", "text": steering_text(2)} +def test_anthropic_steering_tolerates_non_string_system_block_text() -> None: + # A malformed client block ({"type": "text", "text": null}) must not crash + # `.startswith` and 500 the request; steering is still appended. The OpenAI + # chat sibling already guards this exact case. + body = { + "system": [ + {"type": "text", "text": None}, + {"type": "text", "text": "Real system prompt."}, + ] + } + + assert apply_verbosity_steering(body, 2) is True + # The malformed block is left as-is and a steering block is appended. + assert body["system"][0] == {"type": "text", "text": None} + assert body["system"][-1] == {"type": "text", "text": steering_text(2)} + + def test_openai_responses_steering_is_idempotent() -> None: body = {"instructions": "System."}