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
This commit is contained in:
Abhay Singh 2026-07-22 18:46:20 +05:30 committed by GitHub
parent 77b26c093c
commit 3e976712e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 24 additions and 2 deletions

View file

@ -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

View file

@ -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."}