Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions ccproxy/plugins/claude_api/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare_provider_request is documented/used with lowercase header keys (see BaseHTTPAdapter contract and extract_request_headers), so checking headers.get("Anthropic-Beta") is redundant and slightly confusing. Consider relying on headers.get("anthropic-beta") (or normalizing keys once if you want true case-insensitive lookup).

Suggested change
headers.get("anthropic-beta") or headers.get("Anthropic-Beta")
headers.get("anthropic-beta")

Copilot uses AI. Check for mistakes.
)

# 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:
Expand All @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
97 changes: 97 additions & 0 deletions tests/plugins/claude_api/unit/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading