From e22d7453d4c6fcf084135ad65c21dd4feb9927ad Mon Sep 17 00:00:00 2001 From: Vinay Gupta <58447456+aivinay@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:33:45 -0500 Subject: [PATCH] fix(proxy): strip 1m model suffix before upstream forwarding (#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Strips dangling terminal-style model suffixes like `[1m]` from Anthropic-compatible model ids before Headroom forwards `/v1/messages` upstream. Closes #1812 ## 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 - Generalized `sanitize_anthropic_model_id()` so the existing dangling ANSI-style suffix cleanup applies to Anthropic-compatible non-Claude models, including `glm-5.2[1m]`. - Added a provider-level regression for `glm-5.2[1m] -> glm-5.2`. - Added a `/v1/messages` handler regression that captures the upstream request body and verifies Headroom forwards `glm-5.2`, not `glm-5.2[1m]`. ## Testing - [x] Unit tests pass (`pytest`) — focused local tests and full CI test matrix passed - [x] Linting passes (`ruff check .`) — local Ruff and CI lint passed - [x] Type checking passes (`mypy headroom`) — local mypy and CI lint passed - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ rtk proxy env HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1812-1m-model-suffix /tmp/headroom-1812-testenv/bin/python -c '' ============================= test session starts ============================== platform darwin -- Python 3.13.11, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/headroom-1812-testenv/bin/python collected 17 items tests/test_providers/test_anthropic.py::TestAnthropicModelSanitization::test_sanitize_model_id_removes_ansi_escape_sequences PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelSanitization::test_sanitize_model_id_removes_displayed_style_suffix PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelSanitization::test_sanitize_model_metadata_cleans_nested_model_ids PASSED tests/test_providers/test_anthropic.py::TestAnthropicTokenCounting::test_count_text_fallback PASSED tests/test_providers/test_anthropic.py::TestAnthropicTokenCounting::test_count_messages_basic PASSED tests/test_providers/test_anthropic.py::TestAnthropicTokenCounting::test_count_text_allows_literal_special_tokens PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_get_context_limit_claude_sonnet PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_get_context_limit_claude_opus PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_get_context_limit_strips_ansi_model_suffix PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_get_context_limit_claude_5_family PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_supports_model_known PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_supports_model_prefix PASSED tests/test_providers/test_anthropic.py::TestAnthropicModelLimits::test_token_counter_cache_uses_sanitized_model_id PASSED tests/test_providers/test_anthropic.py::TestAnthropicCostEstimation::test_estimate_cost_basic PASSED tests/test_providers/test_anthropic.py::TestAnthropicCostEstimation::test_pricing_lookup_strips_ansi_model_suffix PASSED tests/test_providers/test_anthropic.py::TestAnthropicCostEstimation::test_pricing_claude_5_family PASSED tests/test_proxy_anthropic_model_sanitization.py::test_anthropic_messages_strips_local_1m_model_suffix_before_forwarding PASSED ======================== 17 passed, 3 warnings in 2.11s ======================== $ rtk uvx ruff check headroom/providers/anthropic.py tests/test_providers/test_anthropic.py tests/test_proxy_anthropic_model_sanitization.py All checks passed! $ rtk uvx ruff format --check headroom/providers/anthropic.py tests/test_providers/test_anthropic.py tests/test_proxy_anthropic_model_sanitization.py 3 files already formatted ``` The normal editable test command was attempted but did not reach test execution in this local checkout because the native extension build failed: ```text $ rtk uv run pytest tests/test_providers/test_anthropic.py tests/test_proxy_anthropic_model_sanitization.py × Failed to build `headroom-ai @ file:///Users/vinaygupta/Desktop/git/headroom-fix-1812-1m-model-suffix` warning: esaxx-rs@0.1.10: src/esaxx.cpp:620:10: fatal error: 'cstdint' file not found error: failed to run custom build command for `esaxx-rs v0.1.10` ``` ## Real Behavior Proof - Environment: local macOS worktree from current upstream `main`; Python 3.13.11 throwaway test environment; `HEADROOM_REQUIRE_RUST_CORE=false`; in-memory `headroom._core` stub used only to avoid the local missing native extension during Python-level tests. - Exact command / steps: POST a TestClient `/v1/messages` request with `{"model": "glm-5.2[1m]", ...}` and replace `_retry_request` with a test double that records the upstream body. - Observed result: the recorded upstream request body contains `{"model": "glm-5.2"}` and `mutation_reasons == ["sanitize_model_id"]`, so the mutated JSON body is serialized instead of forwarding the original bytes. - Not tested: live Z.AI credentials/provider call; full local pytest; local `mypy headroom`. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - All non-skipped GitHub Actions checks are green after the rebase onto `main`; skipped jobs are path-gated. - No code comments were added because the fix reuses the existing sanitizer and mutation-tracking path. - Documentation and CHANGELOG updates are N/A for this narrow proxy compatibility fix. - The local pytest warnings were from the throwaway environment/test tooling (`asyncio_mode`, Starlette TestClient deprecation, and the existing AnthropicProvider no-client warning), not from the changed code path. --- headroom/providers/anthropic.py | 4 +- tests/test_providers/test_anthropic.py | 1 + ...test_proxy_anthropic_model_sanitization.py | 77 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/test_proxy_anthropic_model_sanitization.py diff --git a/headroom/providers/anthropic.py b/headroom/providers/anthropic.py index 56a8d0790..107fc53c5 100644 --- a/headroom/providers/anthropic.py +++ b/headroom/providers/anthropic.py @@ -59,9 +59,7 @@ _DANGLING_ANSI_STYLE_SUFFIX_RE = re.compile(r"(?:\[[0-9;]*m\])+$") def sanitize_anthropic_model_id(model: str) -> str: """Return an Anthropic model id without terminal styling artifacts.""" cleaned = _ANSI_ESCAPE_RE.sub("", str(model)).strip() - if cleaned.startswith("claude-"): - cleaned = _DANGLING_ANSI_STYLE_SUFFIX_RE.sub("", cleaned) - return cleaned + return _DANGLING_ANSI_STYLE_SUFFIX_RE.sub("", cleaned) def sanitize_anthropic_model_metadata(value: Any) -> Any: diff --git a/tests/test_providers/test_anthropic.py b/tests/test_providers/test_anthropic.py index cbaf2accc..63fa1f73f 100644 --- a/tests/test_providers/test_anthropic.py +++ b/tests/test_providers/test_anthropic.py @@ -13,6 +13,7 @@ class TestAnthropicModelSanitization: from headroom.providers.anthropic import sanitize_anthropic_model_id assert sanitize_anthropic_model_id("claude-opus-4-8[1m]") == "claude-opus-4-8" + assert sanitize_anthropic_model_id("glm-5.2[1m]") == "glm-5.2" def test_sanitize_model_metadata_cleans_nested_model_ids(self): from headroom.providers.anthropic import sanitize_anthropic_model_metadata diff --git a/tests/test_proxy_anthropic_model_sanitization.py b/tests/test_proxy_anthropic_model_sanitization.py new file mode 100644 index 000000000..80880cab6 --- /dev/null +++ b/tests/test_proxy_anthropic_model_sanitization.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient + +from headroom.proxy.server import ProxyConfig, create_app + + +def test_anthropic_messages_strips_local_1m_model_suffix_before_forwarding() -> None: + config = ProxyConfig( + optimize=False, + cache_enabled=False, + rate_limit_enabled=False, + cost_tracking_enabled=False, + log_requests=False, + ccr_inject_tool=False, + ccr_handle_responses=False, + ccr_context_tracking=False, + image_optimize=False, + ) + app = create_app(config) + client = TestClient(app) + + captured: dict[str, Any] = {} + + async def _fake_retry( + method: str, # noqa: ARG001 + url: str, # noqa: ARG001 + headers: dict[str, str], # noqa: ARG001 + body: dict[str, Any], + body_mutated: bool, + mutation_reasons: list[str], + **kwargs: Any, + ) -> httpx.Response: + captured["body"] = dict(body) + captured["body_mutated"] = body_mutated + captured["mutation_reasons"] = list(mutation_reasons) + return httpx.Response( + 200, + json={ + "id": "msg_glm_1m", + "type": "message", + "role": "assistant", + "model": body["model"], + "content": [{"type": "text", "text": "ok"}], + "usage": { + "input_tokens": 10, + "output_tokens": 1, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + }, + }, + ) + + app.state.proxy._retry_request = _fake_retry # type: ignore[assignment] + + response = client.post( + "/v1/messages", + headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"}, + json={ + "model": "glm-5.2[1m]", + "max_tokens": 10, + "stream": False, + "messages": [{"role": "user", "content": "2+2"}], + }, + ) + + assert response.status_code == 200, response.text + assert captured["body"]["model"] == "glm-5.2" + assert captured["body_mutated"] is True + assert captured["mutation_reasons"] == ["sanitize_model_id"]