fix(proxy/auth): match real Anthropic OAuth token prefix (sk-ant-oat) (#1672)

## Description

`classify_auth_mode` (in `headroom/proxy/auth_mode.py`) checks for
Anthropic
OAuth tokens with:

```python
if token.startswith("sk-ant-oat-"):
    return AuthMode.OAUTH
if token.startswith("sk-ant-api") or token.startswith("sk-"):
    return AuthMode.PAYG
```

But real Anthropic OAuth access tokens are **`sk-ant-oat01-...`** — a
version
number right after `oat`, **no dash**. So the `sk-ant-oat-` check never
matches a
real token; it falls through to the broad `sk-` rule and gets classified
**`PAYG`**.

That's exactly the misclassification the module is built to prevent: a
subscription/OAuth-bound request tagged `PAYG` gets the
aggressive-compression
policy — lossy compression, auto `cache_control`, `prompt_cache_key`
injection —
instead of the passthrough-prefer path OAuth is meant to get.

The existing tests didn't catch it because they use a synthetic
`sk-ant-oat-01-`
fixture (dashed) that happens to match the buggy prefix. Corroboration
that the
real shape is dash-less:
- `.gitguardian.yaml` fixture: `sk-ant-oat01-oauth-fixture`
- `tests/test_oauth_bearer_routing.py`: `sk-ant-oat01-xxx`
- the sibling helper `headroom/proxy/helpers.py` matches on `sk-ant-`
(no `oat-`)

## Fix

Match the dash-less `sk-ant-oat` prefix. It still matches the legacy
dashed
shape, and ordering relative to `sk-ant-api` / `sk-` is unchanged (OAuth
is
still checked first).

```python
if token.startswith("sk-ant-oat"):
    return AuthMode.OAUTH
```

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/proxy/auth_mode.py`: match OAuth tokens on the dash-less
`sk-ant-oat` prefix.
- `tests/test_auth_mode.py`: add a regression test using the real
`sk-ant-oat01-...` format (the existing test keeps the legacy dashed
fixture, which still classifies correctly).
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

## Testing

- [x] New regression test added (`tests/test_auth_mode.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/proxy/auth_mode.py tests/test_auth_mode.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom from this branch.
Importing `headroom` loads the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the classification
logic with a dependency-free script and left the full pytest to CI.
- Exact command / steps: replicated the Bearer-token branch of
`classify_auth_mode` in a standalone script (only stdlib, no `headroom`
import) and ran the real and legacy token shapes plus PAYG keys through
it.
- Observed result: the real `sk-ant-oat01-...` now classifies OAUTH (was
PAYG before the change); the legacy dashed fixture still classifies
OAUTH; `sk-ant-api*` / `sk-*` keys still classify PAYG:

```text
OK: sk-ant-oat01-... -> OAUTH (was PAYG before fix)
OK: sk-ant-oat-01-... -> OAUTH (legacy fixture still matches)
OK: sk-ant-api* / sk-* -> PAYG (unchanged)
AUTH LOGIC VERIFIED
```

- Not tested: a live proxied Anthropic OAuth request end-to-end (needs a
real subscription token); the classification is pure and covered by the
regression test. Full local `pytest` deferred to CI (OOM, per above).

## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- `crates/headroom-core/src/auth_mode.rs` carries the identical dashed
prefix (its Rust test matrix uses the same synthetic dashed fixture). I
scoped this PR to the Python runtime classifier since that's the
request-time path; happy to mirror the one-line fix in Rust in the same
PR or a follow-up — I just couldn't `cargo build` locally to verify, so
I left it out rather than push an unverified Rust edit.
- @JerrettDavis tagging you since you've been triaging these — small,
contained fix with a regression test if you have a moment.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
Abhay Singh 2026-07-03 02:56:19 +05:30 committed by GitHub
parent 2fe19c39e4
commit 8cddf9b58e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 35 additions and 12 deletions

View file

@ -72,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **proxy/auth:** classify real Anthropic OAuth tokens correctly. `classify_auth_mode` matched OAuth on the `sk-ant-oat-` prefix, but real access tokens are `sk-ant-oat01-...` (a version number, no dash after `oat`), so every real subscription/OAuth token fell through to the `sk-` branch and was tagged `PAYG` — enabling aggressive lossy compression, auto `cache_control`, and `prompt_cache_key` injection on subscription-bound requests the classifier is meant to route to the passthrough-prefer path. The prefix is now the dash-less `sk-ant-oat` (still matches the legacy dashed shape). The existing parity tests only passed because they used a synthetic `sk-ant-oat-01-` fixture; a regression test now covers the real `sk-ant-oat01-` format.
* **install:** stop leaking a file descriptor on every `headroom install start`. `start_detached_agent()` opened the agent log file and handed it to `subprocess.Popen` but never closed the parent's copy, so each call leaked one fd (and pinned the log file open against rotation). The parent now closes its copy in a `try/finally` once the child has inherited it — the close also runs if `Popen` raises ([#1554](https://github.com/headroomlabs-ai/headroom/issues/1554)).
* **proxy:** include the system prompt, tools, and the response-shaping request fields in the SemanticCache key. `_compute_key` hashed only `{model, messages}`, so two non-streaming requests with identical messages but a different top-level `system` prompt, tool set, sampling config, or output-shaping field collided on one key and the second caller was served the first's cached response — generated under different request semantics, in the default config (`cache_enabled` defaults on). The key now folds the request fields that shape generation — `temperature`/`top_p`/`top_k`/`max_tokens`/`stop`, plus OpenAI `tool_choice`/`response_format`/`parallel_tool_calls`/`seed`/`presence_penalty`/`frequency_penalty`/`logit_bias`/`n`/`logprobs`/`top_logprobs`/`reasoning_effort`/`verbosity`/`modalities` and Anthropic `thinking`/`tool_choice`/`output_config` — canonicalizing `system`/`tools` so a moved `cache_control` breakpoint does not fragment it, and the handlers snapshot the fields once at the cache read and reuse them at write so a body mutated by the pipeline cannot diverge the key. Non-streaming path only.
* **learn (verbosity):** `--verbosity --apply --all` now aggregates the savings baseline across every project instead of overwriting it per project (last-project-wins), which previously left the output shaper with a tiny, unrepresentative baseline. The applied verbosity level comes from the project with the most samples ([#1288](https://github.com/headroomlabs-ai/headroom/pull/1288)).

View file

@ -93,10 +93,10 @@ const SUBSCRIPTION_UA_PREFIXES: &[&str] = &[
/// 1. **Subscription UA prefix** → [`AuthMode::Subscription`].
/// The CLI's own auth-mode wins over the bearer token shape it
/// happens to be carrying — a Claude Code session uses a
/// `sk-ant-oat-*` token but is a subscription client, not OAuth.
/// 2. **`Authorization: Bearer sk-ant-oat-*`** → [`AuthMode::OAuth`]
/// `sk-ant-oat*` token but is a subscription client, not OAuth.
/// 2. **`Authorization: Bearer sk-ant-oat*`** → [`AuthMode::OAuth`]
/// (Claude Pro / Max OAuth). Checked before the broader `sk-` PAYG
/// rule because `sk-ant-oat-` shares the `sk-` prefix.
/// rule because `sk-ant-oat` shares the `sk-` prefix.
/// 3. **`Authorization: Bearer sk-ant-api*` or `Bearer sk-*`** →
/// [`AuthMode::Payg`] (Anthropic / OpenAI API key).
/// 4. **`Authorization: Bearer <jwt>`** (3 dot-separated segments) →
@ -162,10 +162,11 @@ pub fn classify(headers: &HeaderMap) -> AuthMode {
};
if let Some(token) = auth.strip_prefix("Bearer ") {
// Order matters: the OAuth shape `sk-ant-oat-*` shares a
// Order matters: the OAuth shape `sk-ant-oat*` shares a
// prefix with `sk-ant-api*` only at `sk-ant-`, so we check
// the OAuth shape FIRST. Then the broad PAYG shapes.
if token.starts_with("sk-ant-oat-") {
// the OAuth shape FIRST. Real OAuth access tokens are
// `sk-ant-oat01-...` (version number, no dash after `oat`).
if token.starts_with("sk-ant-oat") {
return AuthMode::OAuth;
}
if token.starts_with("sk-ant-api") || token.starts_with("sk-") {

View file

@ -43,11 +43,19 @@ fn oauth_jwt_classified_oauth() {
#[test]
fn oauth_sk_ant_oat_classified_oauth() {
// Claude Pro / Max OAuth: `Bearer sk-ant-oat-...`.
// Legacy/synthetic Claude Pro / Max OAuth fixture.
let h = headers(&[("authorization", "Bearer sk-ant-oat-01-abc123def456")]);
assert_eq!(classify(&h), AuthMode::OAuth);
}
#[test]
fn oauth_real_sk_ant_oat01_classified_oauth() {
// Real Anthropic OAuth access tokens are `sk-ant-oat01-...`:
// a version number, no dash after `oat`.
let h = headers(&[("authorization", "Bearer sk-ant-oat01-abc123def456")]);
assert_eq!(classify(&h), AuthMode::OAuth);
}
#[test]
fn claude_code_ua_classified_subscription() {
// Claude Code CLI: `User-Agent: claude-code/1.2.3 ...`.

View file

@ -116,10 +116,10 @@ def classify_auth_mode(headers: Mapping[str, Any] | Any) -> AuthMode:
1. **Subscription UA prefix** :data:`AuthMode.SUBSCRIPTION`.
The CLI's own auth-mode wins over the bearer token shape it
happens to be carrying a Claude Code session uses a
``sk-ant-oat-*`` token but is a subscription client, not OAuth.
2. **``Authorization: Bearer sk-ant-oat-*``** :data:`AuthMode.OAUTH`
``sk-ant-oat*`` token but is a subscription client, not OAuth.
2. **``Authorization: Bearer sk-ant-oat*``** :data:`AuthMode.OAUTH`
(Claude Pro / Max OAuth). Checked before the broader ``sk-``
PAYG rule because ``sk-ant-oat-`` shares the ``sk-`` prefix.
PAYG rule because ``sk-ant-oat`` shares the ``sk-`` prefix.
3. **``Authorization: Bearer sk-ant-api*`` or ``Bearer sk-*``**
:data:`AuthMode.PAYG` (Anthropic / OpenAI API key).
4. **``Authorization: Bearer <jwt>``** (3 dot-separated segments)
@ -151,9 +151,13 @@ def classify_auth_mode(headers: Mapping[str, Any] | Any) -> AuthMode:
if auth.startswith("Bearer "):
token = auth[len("Bearer ") :]
# Order matters: `sk-ant-oat-*` shares a prefix with
# Order matters: `sk-ant-oat*` shares a prefix with
# `sk-ant-api*` only at `sk-ant-`, so check OAuth first.
if token.startswith("sk-ant-oat-"):
# Real Anthropic OAuth access tokens are `sk-ant-oat01-...`
# (a version number, no dash after `oat`), so match on the
# dash-less `sk-ant-oat` prefix — matching on `sk-ant-oat-`
# missed every real token and let it fall through to PAYG.
if token.startswith("sk-ant-oat"):
return AuthMode.OAUTH
if token.startswith("sk-ant-api") or token.startswith("sk-"):
return AuthMode.PAYG

View file

@ -47,6 +47,15 @@ def test_oauth_sk_ant_oat_classified_oauth() -> None:
assert classify_auth_mode(headers) is AuthMode.OAUTH
def test_oauth_real_sk_ant_oat01_classified_oauth() -> None:
"""Real Anthropic OAuth access tokens are ``sk-ant-oat01-...`` (a version
number, no dash after ``oat``). These must classify as OAUTH matching on
``sk-ant-oat-`` missed them and let them fall through to PAYG, enabling
aggressive lossy compression on subscription-bound requests."""
headers = {"authorization": "Bearer sk-ant-oat01-abc123def456"}
assert classify_auth_mode(headers) is AuthMode.OAUTH
def test_claude_code_ua_classified_subscription() -> None:
"""Claude Code CLI: ``User-Agent: claude-code/1.2.3 ...``."""
headers = {"user-agent": "claude-code/1.2.3 (darwin; arm64)"}