headroom/tests/test_azure_foundry_claude_compression.py
Shawn e5031b0121
feat(azure-foundry): derive upstream URL from ANTHROPIC_FOUNDRY_RESOURCE (#1138)
## 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>
2026-06-22 15:49:14 -05:00

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