fix(codex): match the User-Agents Codex actually sends

CLIENT_UA_MAP carries one OpenAI needle, "codex-cli/", which no shipped
Codex build sends. Measured across two proxy.log rotations:

    codex-tui/           3,167
    codex-browser-use/   1,338
    Codex Desktop/         368   (note the space; matching is lowercased)
    codex_cli_rs/           13
    codex-computer-use/      1

4,887 Codex requests, zero matches. Every one classified as None, so the
Codex fail-open in decide_compression_failure_action() - which keys on
client == "codex" - never fired: a compression timeout returned 413, and
Codex treats 413 as fatal, so sessions died at startup rather than
degrading to an uncompressed forward.

Needles must be lowercase, since classify_client() lowercases the UA before
matching - a copy-pasted "Codex Desktop/" needle would never fire while
still looking correct in review. "Codex Desktop/" is also the only client UA
containing a space.

Left "codex-cli/" in place in case some build does send it.

Tests use the User-Agent strings verbatim from the log, with negative
controls so the broadened needles cannot swallow claude-code, Mozilla or
curl. One real UA embeds both harnesses -
"codex_cli_rs/... (claude-code; 2.1.233)" - and must still resolve to codex:
the Anthropic needles are checked first but require "claude-code/" with a
slash, and Codex is the process that dies on the 413. Map order decides
that, so it is asserted rather than assumed.
This commit is contained in:
Aaron 2026-08-15 16:48:47 -07:00
parent a29d2015e5
commit d285b4981a
3 changed files with 100 additions and 0 deletions

View file

@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Fixed
- **Codex: `CLIENT_UA_MAP` matched a User-Agent no shipped Codex build sends.**
The only OpenAI needle was `codex-cli/`; measured across two `proxy.log`
rotations the Codex User-Agents actually on the wire were `codex-tui/` (3,167
requests), `codex-browser-use/` (1,338), `Codex Desktop/` (368),
`codex_cli_rs/` (13) and `codex-computer-use/` (1) — 4,887 requests, zero
matches. Every Codex request therefore classified as `None`, so the Codex
fail-open in `decide_compression_failure_action()`, which keys on
`client == "codex"`, never fired and a compression timeout returned 413.
### Features
- **proxy:** opt-in cost-aware model routing ([#1706](https://github.com/headroomlabs-ai/headroom/issues/1706)). Set `HEADROOM_MODEL_ROUTER_ENABLED=1` and `HEADROOM_MODEL_ROUTES` (a JSON array of ordered rules) to rewrite the upstream model based on estimated input size and tool presence, complementary to content compression, e.g. send small, tool-free requests to a cheaper model. First matching rule wins, and each decision is logged with a reason so routing stays observable. Malformed rules fail open (the rule is skipped, never silently widened). Disabled by default so behavior is unchanged, skipped under `x-headroom-bypass`/passthrough, and currently applied on the Anthropic `/v1/messages` path.
- **install:** `headroom install apply` now accepts `--code-aware/--no-code-aware`, `--intercept-tool-results`, `--protect-tool-results`, and `--bedrock-profile`, mirroring the equivalent flags already on `headroom proxy`. Previously the only way to run a persistent deployment with these settings was to hand-edit `manifest.json` after the fact, which silently reverts on the next `install apply`.

View file

@ -33,6 +33,11 @@ CLIENT_UA_MAP: tuple[tuple[str, str], ...] = (
("claude-vscode/", "claude-vscode"),
("anthropic-cli/", "anthropic-cli"),
("codex-cli/", "codex"),
("codex-tui/", "codex"),
("codex_cli_rs/", "codex"),
("codex-browser-use/", "codex"),
("codex-computer-use/", "codex"),
("codex desktop/", "codex"),
("cursor/", "cursor"),
("grok/", "grok_build"),
("zed/", "zed"),

View file

@ -0,0 +1,84 @@
"""Codex User-Agent classification regression tests (AQ-0610).
``CLIENT_UA_MAP`` carried a single OpenAI needle, ``codex-cli/``, which no
shipped Codex build actually sends. Every Codex request therefore classified as
``None``, so the Codex-specific fail-open in ``headroom/proxy/helpers.py``
which keys on ``client == "codex"`` never fired and the proxy answered a
compression timeout with 413. Codex treats 413 as fatal, so sessions died at
startup.
The User-Agent strings below are verbatim from ~/.headroom/logs/proxy.log, not
invented. Counts across two rotations at the time of the fix:
codex-tui/ 3,167
codex-browser-use/ 1,338
Codex Desktop/ 368
codex_cli_rs/ 13
codex-computer-use/ 1
"""
from __future__ import annotations
import pytest
from headroom.proxy.auth_mode import classify_client
# Verbatim User-Agents observed on the wire.
OBSERVED_CODEX_USER_AGENTS = [
"codex-tui/0.147.0 (Mac OS 26.5.1; arm64) Apple_Terminal/470.2 (codex-tui; 0.147.0)",
"codex-tui/0.148.0-alpha.9 (Mac OS 26.5.1; arm64) xterm-256color",
"codex_cli_rs/0.147.0 (Mac OS 26.5.1; arm64) Apple_Terminal/470.2",
"codex_cli_rs/0.148.0-alpha.9 (Mac OS 26.5.1; arm64) unknown",
"codex-browser-use/0.147.0-alpha.1.2 (Mac OS 26.5.1; arm64) xterm-256color"
" (codex-browser-use; 0.1.0)",
"Codex Desktop/0.147.0-alpha.6.5 (Mac OS 26.5.1; arm64) unknown",
"Codex Desktop/0.148.0-alpha.9 (Mac OS 26.5.1; arm64) unknown (Codex Desktop; 26.810.41047)",
"codex-computer-use/0.147.0-alpha.1.2 (Mac OS 26.5.1; arm64) unknown",
]
@pytest.mark.parametrize("user_agent", OBSERVED_CODEX_USER_AGENTS)
def test_observed_codex_user_agents_classify_as_codex(user_agent: str) -> None:
"""Every Codex build seen in production must resolve to ``codex``.
This is what gates the compression-timeout fail-open. A ``None`` here means
a real Codex session gets a fatal 413 instead of an uncompressed forward.
"""
assert classify_client({"user-agent": user_agent}) == "codex"
def test_codex_desktop_matches_despite_capitals_and_space() -> None:
"""``Codex Desktop/`` is the only client whose UA contains a space.
Matching lowercases the UA, so the needle must be lowercase too a
``"Codex Desktop/"`` needle would silently never match.
"""
assert classify_client({"user-agent": "Codex Desktop/0.148.0-alpha.9 (Mac OS)"}) == "codex"
def test_codex_ua_carrying_a_claude_code_originator_still_classifies_as_codex() -> None:
"""Real UA that embeds both harness names.
``codex_cli_rs/... (claude-code; 2.1.233)`` is the Codex binary reporting
Claude Code as its originator. The Anthropic needles are checked first and
require ``claude-code/`` with a slash, so the semicolon form must not steal
this match the process talking to us is Codex, and Codex is the one that
dies on a 413.
"""
ua = "codex_cli_rs/0.147.0 (Mac OS 26.5.1; arm64) Apple_Terminal/470.2 (claude-code; 2.1.233)"
assert classify_client({"user-agent": ua}) == "codex"
def test_claude_code_user_agents_are_unaffected() -> None:
"""Negative control: the Anthropic needles must keep winning their own UAs."""
assert classify_client({"user-agent": "claude-cli/2.1.233 (external, cli)"}) == "claude-code"
assert (
classify_client({"user-agent": "claude-cli/2.1.233 (external, sdk-py, agent-sdk/0.2.139)"})
== "claude-code"
)
def test_unrelated_user_agent_still_returns_none() -> None:
"""Negative control: broadening the Codex needles must not swallow everything."""
assert classify_client({"user-agent": "Mozilla/5.0 (Macintosh)"}) is None
assert classify_client({"user-agent": "curl/8.7.1"}) is None