diff --git a/CHANGELOG.md b/CHANGELOG.md index 940aa6893..82d25a32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/crates/headroom-core/src/auth_mode.rs b/crates/headroom-core/src/auth_mode.rs index 4f0242d9e..0c61087b5 100644 --- a/crates/headroom-core/src/auth_mode.rs +++ b/crates/headroom-core/src/auth_mode.rs @@ -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 `** (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-") { diff --git a/crates/headroom-core/tests/auth_mode.rs b/crates/headroom-core/tests/auth_mode.rs index 461e41555..f0de13301 100644 --- a/crates/headroom-core/tests/auth_mode.rs +++ b/crates/headroom-core/tests/auth_mode.rs @@ -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 ...`. diff --git a/headroom/proxy/auth_mode.py b/headroom/proxy/auth_mode.py index 220ac4397..4999ac6f7 100644 --- a/headroom/proxy/auth_mode.py +++ b/headroom/proxy/auth_mode.py @@ -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 ``** (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 diff --git a/tests/test_auth_mode.py b/tests/test_auth_mode.py index b4627bb89..874a923b9 100644 --- a/tests/test_auth_mode.py +++ b/tests/test_auth_mode.py @@ -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)"}