diff --git a/ccproxy/plugins/claude_api/adapter.py b/ccproxy/plugins/claude_api/adapter.py index 88f20882..c056b198 100644 --- a/ccproxy/plugins/claude_api/adapter.py +++ b/ccproxy/plugins/claude_api/adapter.py @@ -88,9 +88,9 @@ async def prepare_provider_request( # Minimal beta tags required for OAuth-based Claude Code auth filtered_headers["anthropic-version"] = "2023-06-01" - filtered_headers["anthropic-beta"] = self._merge_anthropic_beta( - headers.get("anthropic-beta") or headers.get("Anthropic-Beta") - ) + client_beta = headers.get("anthropic-beta") or headers.get("Anthropic-Beta") + filtered_headers["anthropic-beta"] = self._merge_anthropic_beta(client_beta) + client_provided_beta = bool(client_beta) # Add CLI headers if available, but never allow overriding auth cli_headers = self._collect_cli_headers() @@ -106,6 +106,14 @@ async def prepare_provider_request( ) continue if lk == "anthropic-beta": + # Client is authoritative for its own beta features. + # Only fall back to CLI-detected betas when the client + # sent none — otherwise CLI defaults like + # context-1m-2025-08-07 leak into client requests that + # never asked for them and break model/account combos + # that don't support those betas. + if client_provided_beta: + continue filtered_headers[lk] = self._merge_anthropic_beta( value, base=filtered_headers.get("anthropic-beta") ) diff --git a/tests/plugins/claude_api/unit/test_adapter.py b/tests/plugins/claude_api/unit/test_adapter.py index 00183d1c..c28d8848 100644 --- a/tests/plugins/claude_api/unit/test_adapter.py +++ b/tests/plugins/claude_api/unit/test_adapter.py @@ -140,13 +140,13 @@ async def test_prepare_provider_request_merges_client_beta( assert "custom-tag" in beta_tags @pytest.mark.asyncio - async def test_prepare_provider_request_merges_cli_detected_beta( + async def test_prepare_provider_request_cli_beta_used_when_client_omits_header( self, mock_detection_service: ClaudeAPIDetectionService, mock_auth_manager: Mock, mock_http_pool_manager: Mock, ) -> None: - """CLI-detected beta tags from detection cache must flow through to upstream request.""" + """CLI-detected beta tags are used as a fallback when the client sends no anthropic-beta.""" mock_detection_service.get_detected_headers = Mock( # type: ignore[method-assign] return_value=DetectedHeaders( { @@ -170,9 +170,57 @@ async def test_prepare_provider_request_merges_cli_detected_beta( "max_tokens": 100, } ).encode() + headers = {"content-type": "application/json"} + + _, result_headers = await adapter.prepare_provider_request( + body, headers, "/v1/messages" + ) + + beta_tags = set(result_headers["anthropic-beta"].split(",")) + assert "claude-code-20250219" in beta_tags + assert "oauth-2025-04-20" in beta_tags + assert "context-1m-2025-08-07" in beta_tags + assert "interleaved-thinking-2025-05-14" in beta_tags + + @pytest.mark.asyncio + async def test_prepare_provider_request_cli_beta_skipped_when_client_sends_header( + self, + mock_detection_service: ClaudeAPIDetectionService, + mock_auth_manager: Mock, + mock_http_pool_manager: Mock, + ) -> None: + """When the client sends its own anthropic-beta the CLI-detected betas must not leak in. + + Otherwise CLI defaults like ``context-1m-2025-08-07`` get injected into + every request and break model/account combos that don't support them + (e.g. haiku, or accounts without the long-context beta). + """ + mock_detection_service.get_detected_headers = Mock( # type: ignore[method-assign] + return_value=DetectedHeaders( + { + "anthropic-beta": "claude-code-20250219,context-1m-2025-08-07,advisor-tool-2026-03-01", + } + ) + ) + from ccproxy.plugins.claude_api.config import ClaudeAPISettings + + adapter = ClaudeAPIAdapter( + detection_service=mock_detection_service, + config=ClaudeAPISettings(), + auth_manager=mock_auth_manager, + http_pool_manager=mock_http_pool_manager, + ) + + body = json.dumps( + { + "model": "claude-haiku-4-5-20251001", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + } + ).encode() headers = { "content-type": "application/json", - "anthropic-beta": "client-only-tag", + "anthropic-beta": "oauth-2025-04-20,prompt-caching-scope-2026-01-05", } _, result_headers = await adapter.prepare_provider_request( @@ -180,11 +228,13 @@ async def test_prepare_provider_request_merges_cli_detected_beta( ) beta_tags = set(result_headers["anthropic-beta"].split(",")) + # Required OAuth tags + the client's own tags are present. assert "claude-code-20250219" in beta_tags assert "oauth-2025-04-20" in beta_tags - assert "context-1m-2025-08-07" in beta_tags - assert "interleaved-thinking-2025-05-14" in beta_tags - assert "client-only-tag" in beta_tags + assert "prompt-caching-scope-2026-01-05" in beta_tags + # CLI-detected betas the client did not request must NOT leak in. + assert "context-1m-2025-08-07" not in beta_tags + assert "advisor-tool-2026-03-01" not in beta_tags def test_merge_anthropic_beta_helper(self) -> None: """_merge_anthropic_beta deduplicates and always includes required tags."""