Skip to content

Commit 8cddf9b

Browse files
fix(proxy/auth): match real Anthropic OAuth token prefix (sk-ant-oat) (headroomlabs-ai#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>
1 parent 2fe19c3 commit 8cddf9b

5 files changed

Lines changed: 35 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7272

7373
### Bug Fixes
7474

75+
* **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.
7576
* **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)).
7677
* **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.
7778
* **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)).

crates/headroom-core/src/auth_mode.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ const SUBSCRIPTION_UA_PREFIXES: &[&str] = &[
9393
/// 1. **Subscription UA prefix** → [`AuthMode::Subscription`].
9494
/// The CLI's own auth-mode wins over the bearer token shape it
9595
/// happens to be carrying — a Claude Code session uses a
96-
/// `sk-ant-oat-*` token but is a subscription client, not OAuth.
97-
/// 2. **`Authorization: Bearer sk-ant-oat-*`** → [`AuthMode::OAuth`]
96+
/// `sk-ant-oat*` token but is a subscription client, not OAuth.
97+
/// 2. **`Authorization: Bearer sk-ant-oat*`** → [`AuthMode::OAuth`]
9898
/// (Claude Pro / Max OAuth). Checked before the broader `sk-` PAYG
99-
/// rule because `sk-ant-oat-` shares the `sk-` prefix.
99+
/// rule because `sk-ant-oat` shares the `sk-` prefix.
100100
/// 3. **`Authorization: Bearer sk-ant-api*` or `Bearer sk-*`** →
101101
/// [`AuthMode::Payg`] (Anthropic / OpenAI API key).
102102
/// 4. **`Authorization: Bearer <jwt>`** (3 dot-separated segments) →
@@ -162,10 +162,11 @@ pub fn classify(headers: &HeaderMap) -> AuthMode {
162162
};
163163

164164
if let Some(token) = auth.strip_prefix("Bearer ") {
165-
// Order matters: the OAuth shape `sk-ant-oat-*` shares a
165+
// Order matters: the OAuth shape `sk-ant-oat*` shares a
166166
// prefix with `sk-ant-api*` only at `sk-ant-`, so we check
167-
// the OAuth shape FIRST. Then the broad PAYG shapes.
168-
if token.starts_with("sk-ant-oat-") {
167+
// the OAuth shape FIRST. Real OAuth access tokens are
168+
// `sk-ant-oat01-...` (version number, no dash after `oat`).
169+
if token.starts_with("sk-ant-oat") {
169170
return AuthMode::OAuth;
170171
}
171172
if token.starts_with("sk-ant-api") || token.starts_with("sk-") {

crates/headroom-core/tests/auth_mode.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,19 @@ fn oauth_jwt_classified_oauth() {
4343

4444
#[test]
4545
fn oauth_sk_ant_oat_classified_oauth() {
46-
// Claude Pro / Max OAuth: `Bearer sk-ant-oat-...`.
46+
// Legacy/synthetic Claude Pro / Max OAuth fixture.
4747
let h = headers(&[("authorization", "Bearer sk-ant-oat-01-abc123def456")]);
4848
assert_eq!(classify(&h), AuthMode::OAuth);
4949
}
5050

51+
#[test]
52+
fn oauth_real_sk_ant_oat01_classified_oauth() {
53+
// Real Anthropic OAuth access tokens are `sk-ant-oat01-...`:
54+
// a version number, no dash after `oat`.
55+
let h = headers(&[("authorization", "Bearer sk-ant-oat01-abc123def456")]);
56+
assert_eq!(classify(&h), AuthMode::OAuth);
57+
}
58+
5159
#[test]
5260
fn claude_code_ua_classified_subscription() {
5361
// Claude Code CLI: `User-Agent: claude-code/1.2.3 ...`.

headroom/proxy/auth_mode.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,10 @@ def classify_auth_mode(headers: Mapping[str, Any] | Any) -> AuthMode:
116116
1. **Subscription UA prefix** → :data:`AuthMode.SUBSCRIPTION`.
117117
The CLI's own auth-mode wins over the bearer token shape it
118118
happens to be carrying — a Claude Code session uses a
119-
``sk-ant-oat-*`` token but is a subscription client, not OAuth.
120-
2. **``Authorization: Bearer sk-ant-oat-*``** → :data:`AuthMode.OAUTH`
119+
``sk-ant-oat*`` token but is a subscription client, not OAuth.
120+
2. **``Authorization: Bearer sk-ant-oat*``** → :data:`AuthMode.OAUTH`
121121
(Claude Pro / Max OAuth). Checked before the broader ``sk-``
122-
PAYG rule because ``sk-ant-oat-`` shares the ``sk-`` prefix.
122+
PAYG rule because ``sk-ant-oat`` shares the ``sk-`` prefix.
123123
3. **``Authorization: Bearer sk-ant-api*`` or ``Bearer sk-*``** →
124124
:data:`AuthMode.PAYG` (Anthropic / OpenAI API key).
125125
4. **``Authorization: Bearer <jwt>``** (3 dot-separated segments)
@@ -151,9 +151,13 @@ def classify_auth_mode(headers: Mapping[str, Any] | Any) -> AuthMode:
151151

152152
if auth.startswith("Bearer "):
153153
token = auth[len("Bearer ") :]
154-
# Order matters: `sk-ant-oat-*` shares a prefix with
154+
# Order matters: `sk-ant-oat*` shares a prefix with
155155
# `sk-ant-api*` only at `sk-ant-`, so check OAuth first.
156-
if token.startswith("sk-ant-oat-"):
156+
# Real Anthropic OAuth access tokens are `sk-ant-oat01-...`
157+
# (a version number, no dash after `oat`), so match on the
158+
# dash-less `sk-ant-oat` prefix — matching on `sk-ant-oat-`
159+
# missed every real token and let it fall through to PAYG.
160+
if token.startswith("sk-ant-oat"):
157161
return AuthMode.OAUTH
158162
if token.startswith("sk-ant-api") or token.startswith("sk-"):
159163
return AuthMode.PAYG

tests/test_auth_mode.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ def test_oauth_sk_ant_oat_classified_oauth() -> None:
4747
assert classify_auth_mode(headers) is AuthMode.OAUTH
4848

4949

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

0 commit comments

Comments
 (0)