mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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 '<inject local headroom._core test stub; pytest.main(["tests/test_providers/test_anthropic.py", "tests/test_proxy_anthropic_model_sanitization.py"])>' ============================= 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.
128 lines
5 KiB
Python
128 lines
5 KiB
Python
"""Tests for Anthropic provider."""
|
|
|
|
import pytest
|
|
|
|
|
|
class TestAnthropicModelSanitization:
|
|
def test_sanitize_model_id_removes_ansi_escape_sequences(self):
|
|
from headroom.providers.anthropic import sanitize_anthropic_model_id
|
|
|
|
assert sanitize_anthropic_model_id("claude-opus-4-8\x1b[1m") == "claude-opus-4-8"
|
|
|
|
def test_sanitize_model_id_removes_displayed_style_suffix(self):
|
|
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
|
|
|
|
payload = {
|
|
"data": [
|
|
{"id": "claude-opus-4-8\x1b[1m", "display_name": "Claude Opus 4.8"},
|
|
{"id": "claude-sonnet-4-5[1m]"},
|
|
],
|
|
"model": "claude-opus-4-8[1m]",
|
|
}
|
|
|
|
assert sanitize_anthropic_model_metadata(payload) == {
|
|
"data": [
|
|
{"id": "claude-opus-4-8", "display_name": "Claude Opus 4.8"},
|
|
{"id": "claude-sonnet-4-5"},
|
|
],
|
|
"model": "claude-opus-4-8",
|
|
}
|
|
|
|
|
|
class TestAnthropicTokenCounting:
|
|
@pytest.fixture
|
|
def anthropic_provider(self):
|
|
from headroom.providers.anthropic import AnthropicProvider
|
|
|
|
return AnthropicProvider()
|
|
|
|
def test_count_text_fallback(self, anthropic_provider):
|
|
# Without API client, should use tiktoken fallback
|
|
counter = anthropic_provider.get_token_counter("claude-3-5-sonnet-20241022")
|
|
count = counter.count_text("Hello world")
|
|
assert count > 0
|
|
|
|
def test_count_messages_basic(self, anthropic_provider):
|
|
counter = anthropic_provider.get_token_counter("claude-3-5-sonnet-20241022")
|
|
messages = [{"role": "user", "content": "Hello"}]
|
|
count = counter.count_messages(messages)
|
|
assert count > 0
|
|
|
|
def test_count_text_allows_literal_special_tokens(self, anthropic_provider):
|
|
counter = anthropic_provider.get_token_counter("claude-3-5-sonnet-20241022")
|
|
count = counter.count_text("prefix <|fim_suffix|> suffix")
|
|
assert count > 0
|
|
|
|
|
|
class TestAnthropicModelLimits:
|
|
@pytest.fixture
|
|
def anthropic_provider(self):
|
|
from headroom.providers.anthropic import AnthropicProvider
|
|
|
|
return AnthropicProvider()
|
|
|
|
def test_get_context_limit_claude_sonnet(self, anthropic_provider):
|
|
limit = anthropic_provider.get_context_limit("claude-3-5-sonnet-20241022")
|
|
assert limit == 200000
|
|
|
|
def test_get_context_limit_claude_opus(self, anthropic_provider):
|
|
limit = anthropic_provider.get_context_limit("claude-3-opus-20240229")
|
|
assert limit == 200000
|
|
|
|
def test_get_context_limit_strips_ansi_model_suffix(self, anthropic_provider):
|
|
assert anthropic_provider.get_context_limit("claude-opus-4-7[1m]") == 1000000
|
|
|
|
def test_get_context_limit_claude_5_family(self, anthropic_provider):
|
|
assert anthropic_provider.get_context_limit("claude-fable-5") == 1000000
|
|
assert anthropic_provider.get_context_limit("claude-opus-4-8") == 1000000
|
|
assert anthropic_provider.get_context_limit("claude-sonnet-5") == 1000000
|
|
|
|
def test_supports_model_known(self, anthropic_provider):
|
|
assert anthropic_provider.supports_model("claude-3-5-sonnet-20241022")
|
|
|
|
def test_supports_model_prefix(self, anthropic_provider):
|
|
assert anthropic_provider.supports_model("claude-3-5-sonnet-latest")
|
|
|
|
def test_token_counter_cache_uses_sanitized_model_id(self, anthropic_provider):
|
|
plain = anthropic_provider.get_token_counter("claude-opus-4-7")
|
|
styled = anthropic_provider.get_token_counter("claude-opus-4-7\x1b[1m")
|
|
|
|
assert styled is plain
|
|
|
|
|
|
class TestAnthropicCostEstimation:
|
|
@pytest.fixture
|
|
def anthropic_provider(self):
|
|
from headroom.providers.anthropic import AnthropicProvider
|
|
|
|
return AnthropicProvider()
|
|
|
|
def test_estimate_cost_basic(self, anthropic_provider):
|
|
cost = anthropic_provider.estimate_cost(
|
|
input_tokens=1000000,
|
|
output_tokens=0,
|
|
model="claude-3-5-sonnet-20241022",
|
|
)
|
|
# $3.00 per 1M input
|
|
assert cost == pytest.approx(3.00, rel=0.1)
|
|
|
|
def test_pricing_lookup_strips_ansi_model_suffix(self, anthropic_provider):
|
|
assert anthropic_provider._get_pricing("claude-opus-4-7[1m]") == (
|
|
anthropic_provider._get_pricing("claude-opus-4-7")
|
|
)
|
|
|
|
def test_pricing_claude_5_family(self, anthropic_provider):
|
|
fable = anthropic_provider._get_pricing("claude-fable-5")
|
|
assert fable == {"input": 10.00, "output": 50.00, "cached_input": 1.00}
|
|
|
|
opus = anthropic_provider._get_pricing("claude-opus-4-8")
|
|
assert opus == {"input": 5.00, "output": 25.00, "cached_input": 0.50}
|
|
|
|
sonnet = anthropic_provider._get_pricing("claude-sonnet-5")
|
|
assert sonnet == {"input": 3.00, "output": 15.00, "cached_input": 0.30}
|