From a1a37b8d18a67ccd5f40301af8d2a044557d051b Mon Sep 17 00:00:00 2001 From: Caddy Glow Date: Mon, 13 Apr 2026 11:33:00 +0200 Subject: [PATCH] fix: merge client anthropic-beta tags with required OAuth tags (#54) The hardcoded anthropic-beta header in ClaudeAPIAdapter prevented Max Plan subscribers from enabling the 1M context window (context-1m-2025-08-07) because client-supplied beta tags were dropped and anthropic-beta was in blocked_overrides for CLI header merging. Required OAuth tags (claude-code-20250219, oauth-2025-04-20) are now always preserved, while client-provided and CLI-detected beta tags are merged in so any Claude CLI beta (context-1m, interleaved-thinking, etc.) flows through to the upstream API. --- ccproxy/plugins/claude_api/adapter.py | 33 ++++++- tests/plugins/claude_api/unit/test_adapter.py | 97 +++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/ccproxy/plugins/claude_api/adapter.py b/ccproxy/plugins/claude_api/adapter.py index 3b5cd023..88f20882 100644 --- a/ccproxy/plugins/claude_api/adapter.py +++ b/ccproxy/plugins/claude_api/adapter.py @@ -88,12 +88,14 @@ 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"] = "claude-code-20250219,oauth-2025-04-20" + filtered_headers["anthropic-beta"] = self._merge_anthropic_beta( + headers.get("anthropic-beta") or headers.get("Anthropic-Beta") + ) - # Add CLI headers if available, but never allow overriding auth or beta + # Add CLI headers if available, but never allow overriding auth cli_headers = self._collect_cli_headers() if cli_headers: - blocked_overrides = {"authorization", "x-api-key", "anthropic-beta"} + blocked_overrides = {"authorization", "x-api-key"} for key, value in cli_headers.items(): lk = key.lower() if lk in blocked_overrides: @@ -103,6 +105,11 @@ async def prepare_provider_request( reason="preserve_oauth_auth_header", ) continue + if lk == "anthropic-beta": + filtered_headers[lk] = self._merge_anthropic_beta( + value, base=filtered_headers.get("anthropic-beta") + ) + continue filtered_headers[lk] = value return json.dumps(body_data).encode(), filtered_headers @@ -272,6 +279,26 @@ def _resolve_system_prompt_value(self, mode: str) -> Any: return None + @staticmethod + def _merge_anthropic_beta(client_value: str | None, base: str | None = None) -> str: + """Merge required OAuth beta tags with client-provided beta tags. + + Required tags are always present; additional client tags (e.g. + context-1m-2025-08-07 for 1M context) are preserved. + """ + required = ["claude-code-20250219", "oauth-2025-04-20"] + tags: list[str] = [] + seen: set[str] = set() + for source in (base, client_value, ",".join(required)): + if not source: + continue + for tag in source.split(","): + tag = tag.strip() + if tag and tag not in seen: + seen.add(tag) + tags.append(tag) + return ",".join(tags) + def _collect_cli_headers(self) -> dict[str, str]: """Collect safe CLI headers from detection cache for request forwarding.""" diff --git a/tests/plugins/claude_api/unit/test_adapter.py b/tests/plugins/claude_api/unit/test_adapter.py index 78efabab..00183d1c 100644 --- a/tests/plugins/claude_api/unit/test_adapter.py +++ b/tests/plugins/claude_api/unit/test_adapter.py @@ -107,6 +107,103 @@ async def test_prepare_provider_request_basic( # Headers should be filtered and enhanced assert result_headers["content-type"] == "application/json" assert result_headers["authorization"] == "Bearer test-token" + # Required OAuth beta tags must always be present + beta_tags = set(result_headers["anthropic-beta"].split(",")) + assert "claude-code-20250219" in beta_tags + assert "oauth-2025-04-20" in beta_tags + + @pytest.mark.asyncio + async def test_prepare_provider_request_merges_client_beta( + self, adapter: ClaudeAPIAdapter + ) -> None: + """Client-provided anthropic-beta tags must be preserved alongside required tags.""" + body = json.dumps( + { + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + } + ).encode() + headers = { + "content-type": "application/json", + "anthropic-beta": "context-1m-2025-08-07,custom-tag", + } + + _, 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 "custom-tag" in beta_tags + + @pytest.mark.asyncio + async def test_prepare_provider_request_merges_cli_detected_beta( + 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.""" + mock_detection_service.get_detected_headers = Mock( # type: ignore[method-assign] + return_value=DetectedHeaders( + { + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14", + } + ) + ) + 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-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + } + ).encode() + headers = { + "content-type": "application/json", + "anthropic-beta": "client-only-tag", + } + + _, 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 + assert "client-only-tag" in beta_tags + + def test_merge_anthropic_beta_helper(self) -> None: + """_merge_anthropic_beta deduplicates and always includes required tags.""" + result = ClaudeAPIAdapter._merge_anthropic_beta(None) + assert set(result.split(",")) == { + "claude-code-20250219", + "oauth-2025-04-20", + } + + result = ClaudeAPIAdapter._merge_anthropic_beta( + "context-1m-2025-08-07, claude-code-20250219" + ) + tags = result.split(",") + assert len(tags) == len(set(tags)) + assert set(tags) == { + "claude-code-20250219", + "oauth-2025-04-20", + "context-1m-2025-08-07", + } @pytest.mark.asyncio async def test_prepare_provider_request_with_system_prompt(