headroom/tests/test_codex_rate_limits.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

422 lines
15 KiB
Python
Raw Normal View History

feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
"""Unit tests for headroom.subscription.codex_rate_limits."""
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
from __future__ import annotations
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
import asyncio
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
import time
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
import headroom.subscription.codex_rate_limits as crl
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
from headroom.subscription.codex_rate_limits import (
CodexRateLimitState,
CodexRateLimitWindow,
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
_build_usage_headers,
maybe_schedule_usage_poll,
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
parse_codex_rate_limits,
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
parse_codex_usage_payload,
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
)
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
# A faithful GET /wham/usage body (shape captured from a live Plus account).
USAGE_PAYLOAD = {
"plan_type": "plus",
"rate_limit": {
"allowed": True,
"limit_reached": False,
"primary_window": {
"used_percent": 23,
"limit_window_seconds": 18000,
"reset_after_seconds": 12266,
"reset_at": 1781276043,
},
"secondary_window": {
"used_percent": 6,
"limit_window_seconds": 604800,
"reset_after_seconds": 359170,
"reset_at": 1781622947,
},
},
"additional_rate_limits": None,
"credits": {
"has_credits": False,
"unlimited": False,
"balance": "0",
},
"rate_limit_reached_type": None,
"promo": None,
}
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
# ---------------------------------------------------------------------------
# CodexRateLimitWindow helpers
# ---------------------------------------------------------------------------
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
class TestCodexRateLimitWindow:
def test_window_label_minutes(self):
w = CodexRateLimitWindow(used_percent=10.0, window_minutes=45)
assert w.window_label == "45m"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_window_label_hours(self):
w = CodexRateLimitWindow(used_percent=10.0, window_minutes=60)
assert w.window_label == "1h"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_window_label_hours_with_minutes(self):
w = CodexRateLimitWindow(used_percent=10.0, window_minutes=90)
assert w.window_label == "1h30m"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_window_label_unknown(self):
w = CodexRateLimitWindow(used_percent=10.0, window_minutes=None)
assert w.window_label == "unknown"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_seconds_until_reset_future(self):
future = int(time.time()) + 3600
w = CodexRateLimitWindow(used_percent=10.0, resets_at=future)
secs = w.seconds_until_reset
assert secs is not None
assert 3590 <= secs <= 3600
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_seconds_until_reset_past(self):
past = int(time.time()) - 100
w = CodexRateLimitWindow(used_percent=10.0, resets_at=past)
assert w.seconds_until_reset == 0
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_seconds_until_reset_none(self):
w = CodexRateLimitWindow(used_percent=10.0, resets_at=None)
assert w.seconds_until_reset is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_to_dict_keys(self):
w = CodexRateLimitWindow(used_percent=42.5, window_minutes=60, resets_at=9999999)
d = w.to_dict()
assert set(d.keys()) == {
"used_percent",
"window_minutes",
"window_label",
"resets_at",
"seconds_until_reset",
}
assert d["used_percent"] == 42.5
assert d["window_label"] == "1h"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
# ---------------------------------------------------------------------------
# parse_codex_rate_limits
# ---------------------------------------------------------------------------
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
class TestParseCodexRateLimits:
def test_returns_none_for_empty_headers(self):
assert parse_codex_rate_limits({}) is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_returns_none_for_non_codex_headers(self):
headers = {"content-type": "application/json", "x-request-id": "abc"}
assert parse_codex_rate_limits(headers) is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_primary_window(self):
headers = {
"x-codex-primary-used-percent": "35.5",
"x-codex-primary-window-minutes": "60",
"x-codex-primary-reset-at": "1704069000",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.limit_id == "codex"
assert snap.primary is not None
assert snap.primary.used_percent == 35.5
assert snap.primary.window_minutes == 60
assert snap.primary.resets_at == 1704069000
assert snap.secondary is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_secondary_window(self):
headers = {
"x-codex-primary-used-percent": "10.0",
"x-codex-secondary-used-percent": "80.0",
"x-codex-secondary-window-minutes": "1440",
"x-codex-secondary-reset-at": "1704100000",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.secondary is not None
assert snap.secondary.used_percent == 80.0
assert snap.secondary.window_minutes == 1440
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_credits(self):
headers = {
"x-codex-primary-used-percent": "5.0",
"x-codex-credits-has-credits": "true",
"x-codex-credits-unlimited": "false",
"x-codex-credits-balance": "$12.50",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.credits is not None
assert snap.credits.has_credits is True
assert snap.credits.unlimited is False
assert snap.credits.balance == "$12.50"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_unlimited_credits(self):
headers = {
"x-codex-primary-used-percent": "0.0",
"x-codex-credits-has-credits": "true",
"x-codex-credits-unlimited": "true",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.credits is not None
assert snap.credits.unlimited is True
assert snap.credits.balance is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_limit_name(self):
headers = {
"x-codex-primary-used-percent": "20.0",
"x-codex-limit-name": "gpt-5.2-codex-sonic",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.limit_name == "gpt-5.2-codex-sonic"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_parses_promo_message(self):
headers = {
"x-codex-primary-used-percent": "50.0",
"x-codex-promo-message": "Try our new model!",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.promo_message == "Try our new model!"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_only_credits_header_triggers_parse(self):
headers = {
"x-codex-credits-has-credits": "true",
"x-codex-credits-unlimited": "false",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
assert snap.primary is None
assert snap.credits is not None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_invalid_float_ignored(self):
headers = {"x-codex-primary-used-percent": "not_a_number"}
assert parse_codex_rate_limits(headers) is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_to_dict_structure(self):
headers = {
"x-codex-primary-used-percent": "42.0",
"x-codex-primary-window-minutes": "60",
}
snap = parse_codex_rate_limits(headers)
assert snap is not None
d = snap.to_dict()
assert "limit_id" in d
assert "primary" in d
assert "secondary" in d
assert "credits" in d
assert "captured_at" in d
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
# ---------------------------------------------------------------------------
# CodexRateLimitState
# ---------------------------------------------------------------------------
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
class TestCodexRateLimitState:
def test_initial_state_is_none(self):
state = CodexRateLimitState()
assert state.latest is None
assert state.get_stats() is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_update_from_headers_stores_snapshot(self):
state = CodexRateLimitState()
headers = {
"x-codex-primary-used-percent": "55.0",
"x-codex-primary-window-minutes": "60",
}
state.update_from_headers(headers)
snap = state.latest
assert snap is not None
assert snap.primary is not None
assert snap.primary.used_percent == 55.0
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_update_from_empty_headers_is_noop(self):
state = CodexRateLimitState()
state.update_from_headers({})
assert state.latest is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_update_from_non_codex_headers_is_noop(self):
state = CodexRateLimitState()
state.update_from_headers({"content-type": "application/json"})
assert state.latest is None
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_get_stats_returns_dict_when_data_present(self):
state = CodexRateLimitState()
state.update_from_headers({"x-codex-primary-used-percent": "10.0"})
stats = state.get_stats()
assert stats is not None
assert isinstance(stats, dict)
assert stats["limit_id"] == "codex"
feat: add OpenAI Codex rate-limit window tracking Passively capture x-codex-* response headers from proxied Codex API calls and surface them in /stats and the dashboard. Unlike Anthropic subscription window tracking (which polls a dedicated OAuth endpoint), Codex embeds rate-limit data directly in every API response header — no polling, no new credentials needed. Changes: - headroom/subscription/codex_rate_limits.py: new module with CodexRateLimitWindow, CodexCreditsSnapshot, CodexRateLimitSnapshot data models and a thread-safe CodexRateLimitState singleton; parse_codex_rate_limits() parses x-codex-primary/secondary-used-percent, window-minutes, reset-at, credits, limit-name, and promo-message headers - headroom/subscription/__init__.py: re-export new public symbols - headroom/proxy/handlers/openai.py: call get_codex_rate_limit_state().update_from_headers() after each proxied /v1/chat/completions and /v1/responses response - headroom/proxy/server.py: add codex_rate_limits key to /stats via _get_codex_rate_limit_stats() helper - headroom/dashboard/templates/dashboard.html: new OpenAI Codex Rate-Limit Window panel (primary + secondary progress bars, credits balance, limit name, reset countdown); hidden when no data - tests/test_codex_rate_limits.py: 25 unit tests covering parsing, window labels, reset time, credits, state updates - docs/screenshots/subscription_window_active.png: updated screenshot showing both Anthropic and Codex panels in the real dashboard Header schema (from codex-rs/codex-api/src/rate_limits.rs): x-codex-primary-used-percent / x-codex-primary-window-minutes / x-codex-primary-reset-at (and secondary- variants) x-codex-credits-has-credits / -unlimited / -balance x-codex-limit-name / x-codex-promo-message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 23:34:19 -05:00
def test_update_overwrites_previous_snapshot(self):
state = CodexRateLimitState()
state.update_from_headers({"x-codex-primary-used-percent": "10.0"})
state.update_from_headers({"x-codex-primary-used-percent": "90.0"})
snap = state.latest
assert snap is not None
assert snap.primary is not None
assert snap.primary.used_percent == 90.0
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
# ---------------------------------------------------------------------------
# parse_codex_usage_payload (GET /wham/usage)
# ---------------------------------------------------------------------------
class TestParseCodexUsagePayload:
def test_parses_full_payload(self):
snap = parse_codex_usage_payload(USAGE_PAYLOAD)
assert snap is not None
assert snap.primary is not None
assert snap.primary.used_percent == 23.0
assert snap.primary.window_minutes == 300 # 18000s rounded up
assert snap.primary.resets_at == 1781276043
assert snap.secondary is not None
assert snap.secondary.used_percent == 6.0
assert snap.secondary.window_minutes == 10080 # 604800s
def test_window_minutes_rounds_up(self):
snap = parse_codex_usage_payload(
{"rate_limit": {"primary_window": {"used_percent": 1, "limit_window_seconds": 61}}}
)
assert snap is not None
assert snap.primary is not None
assert snap.primary.window_minutes == 2
def test_no_credits_balance_suppressed(self):
# has_credits False -> balance must not surface as "0".
snap = parse_codex_usage_payload(USAGE_PAYLOAD)
assert snap is not None
assert snap.credits is not None
assert snap.credits.has_credits is False
assert snap.credits.balance is None
def test_credits_balance_kept_when_has_credits(self):
payload = {
"rate_limit": {"primary_window": {"used_percent": 5}},
"credits": {"has_credits": True, "unlimited": False, "balance": "$5.00"},
}
snap = parse_codex_usage_payload(payload)
assert snap is not None
assert snap.credits is not None
assert snap.credits.balance == "$5.00"
def test_promo_object_message(self):
payload = {
"rate_limit": {"primary_window": {"used_percent": 5}},
"promo": {"message": "Hello"},
}
snap = parse_codex_usage_payload(payload)
assert snap is not None
assert snap.promo_message == "Hello"
def test_returns_none_for_empty(self):
assert parse_codex_usage_payload({}) is None
assert parse_codex_usage_payload(None) is None
assert parse_codex_usage_payload({"rate_limit": {}}) is None
def test_missing_used_percent_window_skipped(self):
snap = parse_codex_usage_payload(
{"rate_limit": {"primary_window": {"limit_window_seconds": 60}}}
)
assert snap is None
def test_update_from_usage_payload_stores(self):
state = CodexRateLimitState()
assert state.update_from_usage_payload(USAGE_PAYLOAD) is True
snap = state.latest
assert snap is not None
assert snap.primary is not None
assert snap.primary.used_percent == 23.0
def test_update_from_usage_payload_noop_returns_false(self):
state = CodexRateLimitState()
assert state.update_from_usage_payload({}) is False
assert state.latest is None
# ---------------------------------------------------------------------------
# Usage poll: header gating + throttle
# ---------------------------------------------------------------------------
class TestUsagePollGating:
def test_build_headers_requires_account_id(self):
assert _build_usage_headers({"authorization": "Bearer abc.def.ghi"}) is None
def test_build_headers_requires_bearer(self):
assert _build_usage_headers({"chatgpt-account-id": "acct"}) is None
assert (
_build_usage_headers({"authorization": "sk-live", "chatgpt-account-id": "acct"}) is None
)
def test_build_headers_happy_path(self):
headers = _build_usage_headers(
{
"Authorization": "Bearer abc.def.ghi",
"ChatGPT-Account-Id": "acct-1",
"User-Agent": "codex_exec/0.139.0",
"originator": "codex_exec",
}
)
assert headers is not None
assert headers["Authorization"] == "Bearer abc.def.ghi"
assert headers["ChatGPT-Account-Id"] == "acct-1"
assert headers["User-Agent"] == "codex_exec/0.139.0"
assert headers["originator"] == "codex_exec"
def test_try_begin_poll_throttles(self):
state = CodexRateLimitState()
assert state._try_begin_poll(60.0) is True
# Second immediate attempt is throttled (within interval).
assert state._try_begin_poll(60.0) is False
state._end_poll()
# Still throttled by time even after the in-flight flag clears.
assert state._try_begin_poll(60.0) is False
# A zero interval always allows once the in-flight flag is clear.
assert state._try_begin_poll(0.0) is True
def test_maybe_schedule_returns_false_without_loop(self):
# No running event loop -> cannot schedule.
assert (
maybe_schedule_usage_poll(
{"authorization": "Bearer a.b.c", "chatgpt-account-id": "acct"}
)
is False
)
def test_maybe_schedule_skips_non_codex(self):
async def run():
return maybe_schedule_usage_poll({"authorization": "Bearer a.b.c"})
assert asyncio.run(run()) is False
def test_maybe_schedule_creates_task_and_throttles(self, monkeypatch):
# Replace the network fetch with a fast no-op coroutine.
calls: list[str] = []
async def fake_fetch(url, headers): # noqa: ANN001
calls.append(url)
crl.get_codex_rate_limit_state()._end_poll()
monkeypatch.setattr(crl, "_fetch_and_store_usage", fake_fetch)
# Reset the singleton's throttle so this test is deterministic.
monkeypatch.setattr(crl, "_state", None)
monkeypatch.setattr(crl, "_state_lock", crl.Lock())
async def run():
req = {"authorization": "Bearer a.b.c", "chatgpt-account-id": "acct"}
first = maybe_schedule_usage_poll(req, min_interval_s=60.0)
second = maybe_schedule_usage_poll(req, min_interval_s=60.0)
# Let the scheduled task run.
await asyncio.sleep(0)
return first, second
first, second = asyncio.run(run())
assert first is True
assert second is False # throttled
assert calls == [crl.CODEX_USAGE_URL]