mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Closes #1133 When `CLAUDE_CODE_USE_FOUNDRY=1` is set, Claude Code routes all API traffic to an Azure AI Foundry endpoint (`https://{resource}.services.ai.azure.com/anthropic`) rather than `api.anthropic.com`. The proxy never sees this traffic, so compression is silently skipped. `wrap.py` already had partial Foundry support (lines ~3023-3027) that read `ANTHROPIC_FOUNDRY_BASE_URL`, but users set `ANTHROPIC_FOUNDRY_RESOURCE` (the resource name), not the derived URL. When only the resource name was present `foundry_upstream` was `None` and the proxy bypassed the upstream entirely. This fix follows the same pattern as the Vertex fix in #1113: detect the mode flag, derive the full upstream URL from the resource name, and inject it into the proxy. Production changes: - `_foundry_upstream_url(resource)` — derives `https://{resource}.services.ai.azure.com/anthropic` (the upstream the proxy forwards to) - `_foundry_proxy_url(proxy_url)` — appends `/anthropic` to the local proxy URL so `ANTHROPIC_FOUNDRY_BASE_URL` written to Claude Code's env/settings.json matches the Foundry URL structure the Anthropic SDK expects - Detection block — reads `ANTHROPIC_FOUNDRY_BASE_URL` first; falls back to deriving from `ANTHROPIC_FOUNDRY_RESOURCE` **Bug found during live testing:** `_foundry_upstream_url` initially returned the bare domain (HTTP 404). Live testing confirmed the correct path is `.../anthropic`. Fixed before review. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/wrap.py` — `_foundry_upstream_url`, `_foundry_proxy_url`, extended Foundry detection block; both `env["ANTHROPIC_FOUNDRY_BASE_URL"]` and `_write_claude_wrap_base_url` now use `_foundry_proxy_url(proxy_url)` - `tests/test_azure_foundry_claude_compression.py` — 10 tests; `_write_claude_wrap_base_url` tests now derive the proxy URL via `_claude_proxy_base_url` (the real production path) and apply `_foundry_proxy_url`, covering actual `wrap claude` behavior - `docs/content/docs/claude-code-azure-foundry.mdx` — new user guide ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text --- ruff check --- All checks passed! --- ruff format check --- 2 files already formatted --- mypy --- Success: no issues found in 1 source file --- pytest --- tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_builds_services_endpoint PASSED [ 10%] tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_strips_whitespace PASSED [ 20%] tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_preserves_hyphens_and_digits PASSED [ 30%] tests/test_azure_foundry_claude_compression.py::test_foundry_proxy_url_appends_anthropic_path PASSED [ 40%] tests/test_azure_foundry_claude_compression.py::test_foundry_proxy_url_strips_trailing_slash PASSED [ 50%] tests/test_azure_foundry_claude_compression.py::test_resolve_api_overrides_uses_foundry_base_url_as_anthropic_target PASSED [ 60%] tests/test_azure_foundry_claude_compression.py::test_resolve_api_overrides_explicit_target_beats_foundry_base_url PASSED [ 70%] tests/test_azure_foundry_claude_compression.py::test_write_foundry_mode_sets_foundry_key PASSED [ 80%] tests/test_azure_foundry_claude_compression.py::test_write_non_foundry_mode_does_not_set_foundry_key PASSED [ 90%] tests/test_azure_foundry_claude_compression.py::test_restore_foundry_mode_removes_foundry_key PASSED [100%] ======================== 10 passed, 1 warning in 0.80s ========================= Environment: Docker python:3.12-slim, headroom-ai[proxy] from PyPI + patched wrap.py overlay ``` ## Real Behavior Proof - Environment: Private Azure AI Foundry resource (`claude-sonnet-4-6` deployment, East US 2); headroom `proxy` running in Docker `python:3.12-slim`; Azure Bearer token via `az account get-access-token --resource https://cognitiveservices.azure.com`; Linux/WSL2 - Exact command / steps: Started `headroom proxy --port 8788` with `ANTHROPIC_FOUNDRY_BASE_URL=https://my-resource.services.ai.azure.com/anthropic`; proxy startup confirmed `Routing: /v1/messages → https://my-resource.services.ai.azure.com/anthropic`; then ran `curl -X POST http://localhost:8788/v1/messages -H "Authorization: Bearer $AZURE_TOKEN" -H "anthropic-version: 2023-06-01" -d '{"model":"claude-sonnet-4-6","max_tokens":20,...}'` - Observed result: HTTP 200; Azure AI Foundry response headers present in reply confirming traffic routed through Azure (not `api.anthropic.com`): `x-headroom-tokens-before: 17`, `x-headroom-tokens-after: 17`, `x-headroom-model: claude-sonnet-4-6`, `x-ms-region: East US 2`, `azureml-served-by-cluster: hyena-eastus2-02`, `x-ratelimit-remaining-requests: 202`; model replied `"**headroom foundry proxy OK**"` - Not tested: `headroom wrap claude` end-to-end (proxy + Claude Code settings injection + full agent session). The proxy routes correctly to Foundry and returns real responses; `wrap` plumbing (`_foundry_proxy_url` + `_write_claude_wrap_base_url`) is unit-tested against the real `_claude_proxy_base_url` production path. ## 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 - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — no UI changes. ## Additional Notes **CHANGELOG.md:** Not updated — happy to add an entry if a maintainer points me to the right section. **Issue #1133 prerequisite:** CONTRIBUTING.md asks for a maintainer 👍 before implementing. Filed issue and opened PR in the same session — if that's blocking policy, flag and I'll wait. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
5.5 KiB
Python
137 lines
5.5 KiB
Python
"""Azure AI Foundry + Claude Code compression wiring.
|
|
|
|
Covers the gap fixed by feat(azure-foundry): when only ANTHROPIC_FOUNDRY_RESOURCE
|
|
is set (no explicit ANTHROPIC_FOUNDRY_BASE_URL), wrap claude must derive the
|
|
upstream URL and route Claude Code's Foundry requests through the proxy.
|
|
|
|
No real Azure endpoint is contacted — helpers are unit-tested directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from headroom.cli import wrap as wrap_cli
|
|
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
|
|
from headroom.providers.registry import resolve_api_overrides
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Upstream URL derivation from ANTHROPIC_FOUNDRY_RESOURCE
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_foundry_upstream_url_builds_services_endpoint() -> None:
|
|
assert (
|
|
wrap_cli._foundry_upstream_url("my-org-claude")
|
|
== "https://my-org-claude.services.ai.azure.com/anthropic"
|
|
)
|
|
|
|
|
|
def test_foundry_upstream_url_strips_whitespace() -> None:
|
|
assert (
|
|
wrap_cli._foundry_upstream_url(" my-resource ")
|
|
== "https://my-resource.services.ai.azure.com/anthropic"
|
|
)
|
|
|
|
|
|
def test_foundry_upstream_url_preserves_hyphens_and_digits() -> None:
|
|
assert (
|
|
wrap_cli._foundry_upstream_url("avanade-claude-42")
|
|
== "https://avanade-claude-42.services.ai.azure.com/anthropic"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Local proxy URL for Foundry mode includes /anthropic path component
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_foundry_proxy_url_appends_anthropic_path() -> None:
|
|
proxy_url = _claude_proxy_base_url(8787) # http://127.0.0.1:8787
|
|
assert wrap_cli._foundry_proxy_url(proxy_url) == "http://127.0.0.1:8787/anthropic"
|
|
|
|
|
|
def test_foundry_proxy_url_strips_trailing_slash() -> None:
|
|
assert (
|
|
wrap_cli._foundry_proxy_url("http://127.0.0.1:8787/") == "http://127.0.0.1:8787/anthropic"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# resolve_api_overrides picks up ANTHROPIC_FOUNDRY_BASE_URL as anthropic target
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_api_overrides_uses_foundry_base_url_as_anthropic_target() -> None:
|
|
overrides = resolve_api_overrides(
|
|
anthropic_api_url=None,
|
|
openai_api_url=None,
|
|
gemini_api_url=None,
|
|
cloudcode_api_url=None,
|
|
environ={
|
|
"ANTHROPIC_FOUNDRY_BASE_URL": "https://my-resource.services.ai.azure.com/anthropic"
|
|
},
|
|
)
|
|
assert overrides.anthropic == "https://my-resource.services.ai.azure.com/anthropic"
|
|
|
|
|
|
def test_resolve_api_overrides_explicit_target_beats_foundry_base_url() -> None:
|
|
# ANTHROPIC_TARGET_API_URL takes precedence; FOUNDRY_BASE_URL is the fallback.
|
|
overrides = resolve_api_overrides(
|
|
anthropic_api_url=None,
|
|
openai_api_url=None,
|
|
gemini_api_url=None,
|
|
cloudcode_api_url=None,
|
|
environ={
|
|
"ANTHROPIC_TARGET_API_URL": "https://explicit-override.example.com",
|
|
"ANTHROPIC_FOUNDRY_BASE_URL": "https://my-resource.services.ai.azure.com/anthropic",
|
|
},
|
|
)
|
|
assert overrides.anthropic == "https://explicit-override.example.com"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# settings.json written with ANTHROPIC_FOUNDRY_BASE_URL in Foundry mode
|
|
# Uses _claude_proxy_base_url + _foundry_proxy_url to cover the real wrap path
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _settings(tmp_path: Path) -> Path:
|
|
return tmp_path / ".claude" / "settings.json"
|
|
|
|
|
|
def test_write_foundry_mode_sets_foundry_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
# Mirror the real production path: derive proxy_url then apply _foundry_proxy_url
|
|
proxy_url = _claude_proxy_base_url(8787)
|
|
foundry_url = wrap_cli._foundry_proxy_url(proxy_url)
|
|
wrap_cli._write_claude_wrap_base_url(foundry_url, foundry_mode=True, settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_FOUNDRY_BASE_URL"] == "http://127.0.0.1:8787/anthropic"
|
|
assert "ANTHROPIC_BASE_URL" not in payload["env"]
|
|
|
|
|
|
def test_write_non_foundry_mode_does_not_set_foundry_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
proxy_url = _claude_proxy_base_url(8787)
|
|
wrap_cli._write_claude_wrap_base_url(proxy_url, settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
assert "ANTHROPIC_FOUNDRY_BASE_URL" not in payload["env"]
|
|
|
|
|
|
def test_restore_foundry_mode_removes_foundry_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_FOUNDRY_BASE_URL": "http://127.0.0.1:8787/anthropic"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url(None, foundry_mode=True, settings_path=path)
|
|
# The restore may delete the file entirely when the env dict becomes empty,
|
|
# or leave a file with the key absent — both indicate correct removal.
|
|
if path.exists():
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert "ANTHROPIC_FOUNDRY_BASE_URL" not in payload.get("env", {})
|
|
# else: file deleted — key is gone, which is also correct
|