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>
This commit is contained in:
Shawn 2026-06-22 16:49:14 -04:00 committed by GitHub
parent 85786b33a3
commit e5031b0121
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 281 additions and 3 deletions

View file

@ -0,0 +1,102 @@
---
title: Claude Code on Azure AI Foundry
description: Run Claude Code against Claude models on Azure AI Foundry, with Headroom compressing your prompts — fewer input tokens, same answers, your own Azure credentials.
---
If your Claude models live on **Azure AI Foundry**, you can still get Headroom's
prompt compression. Headroom sits between Claude Code and Azure: it shrinks the
big stuff in each request (file reads, logs, tool output) and forwards the rest to
Azure using **your own Azure credentials**. You keep your Azure setup; Headroom
just makes each call cheaper.
## What you get
- **Fewer input tokens** on every Claude Code request to Azure AI Foundry (often
3060% on agent workloads), so you pay for less.
- **Same answers** — compression is reversible and content-aware.
- **No new secrets** — Headroom never holds your Azure credentials. Claude Code
keeps authenticating to Azure AI Foundry with its own `api-key` or Entra Bearer
token; Headroom passes it through.
## Before you start
You should already have Claude Code working against Azure AI Foundry **without**
Headroom. That means these are set in your shell (or your `~/.claude/settings.json`
`env` block):
```bash
export CLAUDE_CODE_USE_FOUNDRY=1
export ANTHROPIC_FOUNDRY_RESOURCE=<your-azure-resource-name>
# e.g. ANTHROPIC_FOUNDRY_RESOURCE=my-org-claude
```
No `ANTHROPIC_API_KEY` is needed — Foundry mode uses your Azure credentials.
## Run it (one command)
```bash
pip install headroom
headroom wrap claude
```
That's it. Because `CLAUDE_CODE_USE_FOUNDRY=1` is set, `headroom wrap claude`
automatically:
1. derives your Azure AI Foundry endpoint from `ANTHROPIC_FOUNDRY_RESOURCE`,
2. starts the Headroom proxy with that endpoint as the upstream,
3. points Claude Code's Foundry endpoint at the proxy (`ANTHROPIC_FOUNDRY_BASE_URL`),
4. leaves your Azure resource, model, and credentials untouched.
You'll see a line like:
```
Foundry mode: ANTHROPIC_FOUNDRY_BASE_URL=http://127.0.0.1:8787/anthropic
→ upstream https://my-org-claude.services.ai.azure.com/anthropic
```
Use Claude Code exactly as you normally would.
## How it works
```
Claude Code ──(Foundry request)──▶ Headroom ──(compressed)──▶ Azure AI Foundry (Claude)
in Foundry mode compresses your resource
(your api-key / Entra) ──────── passed through ───────────▶ authenticates you
```
Claude Code sends its Foundry request (Anthropic API format) to Headroom. Headroom
compresses the messages, then forwards to your Azure AI Foundry resource endpoint —
`https://{ANTHROPIC_FOUNDRY_RESOURCE}.services.ai.azure.com/anthropic` — with your
auth headers passed through unchanged.
## If you have ANTHROPIC_FOUNDRY_BASE_URL set explicitly
If your environment already has `ANTHROPIC_FOUNDRY_BASE_URL` set to the full Azure
endpoint URL, Headroom uses it directly and `ANTHROPIC_FOUNDRY_RESOURCE` is not
needed. Either configuration works.
## Check that compression is working
1. Open the dashboard: [http://localhost:8787/dashboard](http://localhost:8787/dashboard).
"Tokens saved" should climb as you use Claude Code.
2. Or check response headers: `x-headroom-tokens-before`, `x-headroom-tokens-after`,
`x-headroom-tokens-saved`.
## Troubleshooting
**Headroom says "ANTHROPIC_BASE_URL" instead of "Foundry mode"**
`CLAUDE_CODE_USE_FOUNDRY` is not set in the shell where you ran `headroom wrap
claude`. Make sure to export it before running, or set it in your shell profile.
**Claude Code fails with a 401 / auth error**
Your Azure credentials are not being forwarded correctly. Verify that Claude Code
works against Azure AI Foundry directly (without Headroom) before wrapping. If it
works direct but not through Headroom, open an issue with the proxy log.
**"tokens saved" is always 0**
Check the [dashboard](http://localhost:8787/dashboard) — if requests are flowing
but savings are 0, content may be below the compression threshold or the Rust
extension may not be installed (`pip install "headroom-ai[proxy]"` includes it).

View file

@ -625,6 +625,34 @@ def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool:
return True
def _foundry_upstream_url(resource: str) -> str:
"""Derive the Azure AI Foundry endpoint URL from a resource name.
When CLAUDE_CODE_USE_FOUNDRY=1 is set, Claude Code routes requests to the
Azure AI Services endpoint it constructs from ANTHROPIC_FOUNDRY_RESOURCE.
If ANTHROPIC_FOUNDRY_BASE_URL is not already set in the environment,
we derive it here so the proxy knows where to forward compressed requests.
Azure AI Foundry (AI Services) hosts the Anthropic-format Claude API at:
https://{resource}.services.ai.azure.com/anthropic
This matches the URL Claude Code constructs internally from ANTHROPIC_FOUNDRY_RESOURCE,
and what ANTHROPIC_FOUNDRY_BASE_URL must point to for the Anthropic SDK to reach Claude.
"""
return f"https://{resource.strip()}.services.ai.azure.com/anthropic"
def _foundry_proxy_url(proxy_url: str) -> str:
"""Return the local proxy URL that Claude Code should use in Foundry mode.
ANTHROPIC_FOUNDRY_BASE_URL is the full base URL the Anthropic SDK appends
/v1/messages to, so it must include the /anthropic path component to match
the Azure AI Foundry endpoint structure. _claude_proxy_base_url() returns
the bare http://127.0.0.1:<port> this helper appends /anthropic so the
proxy URL Claude Code receives mirrors the real Foundry URL shape.
"""
return proxy_url.rstrip("/") + "/anthropic"
def _write_claude_wrap_base_url(
proxy_url: str,
*,
@ -3110,9 +3138,16 @@ def claude(
# Detect Foundry mode: Claude Code uses ANTHROPIC_FOUNDRY_BASE_URL instead of
# ANTHROPIC_BASE_URL when CLAUDE_CODE_USE_FOUNDRY=1 is set.
# Users typically set ANTHROPIC_FOUNDRY_RESOURCE (the resource name) rather
# than the full ANTHROPIC_FOUNDRY_BASE_URL. When the URL is absent we derive
# it from the resource name so the proxy has an upstream to forward to.
foundry_upstream = None
if os.environ.get("CLAUDE_CODE_USE_FOUNDRY"):
foundry_upstream = os.environ.get("ANTHROPIC_FOUNDRY_BASE_URL")
if not foundry_upstream:
resource = os.environ.get("ANTHROPIC_FOUNDRY_RESOURCE", "").strip()
if resource:
foundry_upstream = _foundry_upstream_url(resource)
# Detect Vertex mode: with CLAUDE_CODE_USE_VERTEX=1, Claude Code IGNORES
# ANTHROPIC_BASE_URL and authenticates to Google Vertex with GCP ADC. The
@ -3176,7 +3211,7 @@ def claude(
)
elif foundry_upstream:
click.echo(
f" Foundry mode: ANTHROPIC_FOUNDRY_BASE_URL={proxy_url} → upstream {foundry_upstream}"
f" Foundry mode: ANTHROPIC_FOUNDRY_BASE_URL={_foundry_proxy_url(proxy_url)} → upstream {foundry_upstream}"
)
else:
click.echo(f" ANTHROPIC_BASE_URL={proxy_url}")
@ -3192,7 +3227,10 @@ def claude(
# we only redirect its Vertex endpoint to Headroom.
env["ANTHROPIC_VERTEX_BASE_URL"] = proxy_url
elif foundry_upstream:
env["ANTHROPIC_FOUNDRY_BASE_URL"] = proxy_url
# ANTHROPIC_FOUNDRY_BASE_URL is the base URL the Anthropic SDK
# appends /v1/messages to. The real Foundry URL includes /anthropic,
# so the proxy URL must mirror that structure.
env["ANTHROPIC_FOUNDRY_BASE_URL"] = _foundry_proxy_url(proxy_url)
else:
env["ANTHROPIC_BASE_URL"] = proxy_url
@ -3201,7 +3239,8 @@ def claude(
# daemon's environment) also route through Headroom.
_settings_foundry[0] = bool(foundry_upstream)
_saved_base_url[0] = _write_claude_wrap_base_url(
proxy_url, foundry_mode=_settings_foundry[0]
_foundry_proxy_url(proxy_url) if _settings_foundry[0] else proxy_url,
foundry_mode=_settings_foundry[0],
)
# Per-project savings attribution: tag every request with the launch

View file

@ -0,0 +1,137 @@
"""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