Config now includes both env_key=OPENAI_API_KEY (for API key users)
and requires_openai_auth=true (for codex login / Plus plan users).
Tested and confirmed working with:
- Rust Codex v0.118.0 + OPENAI_API_KEY → wss://api.openai.com
- Rust Codex v0.118.0 + codex login (Plus) → wss://chatgpt.com
WS handler only logged at DEBUG level, making it invisible in production
logs. Added INFO logs for connection start and completion with request ID
and tokens_saved, matching the HTTP handler's PERF log pattern.
headroom wrap codex now writes a [model_providers.headroom] section
into ~/.codex/config.toml with supports_websockets=true. Without this,
Codex routes WebSocket traffic directly to OpenAI, bypassing the proxy.
Safe to call multiple times — replaces existing section if port changes.
Requires OPENAI_API_KEY (ChatGPT OAuth cannot auth through a proxy).
reasoningContent: exact counting via count_text() — pure text, no estimation
image: decode with Pillow for (w*h)/750 formula, fallback by byte size
document: ~1500 tokens/page heuristic (3KB/page of PDF)
video: ~1000 tokens/frame heuristic (30KB/frame)
Text content (reasoning, text, toolResult) uses exact tokenization.
Binary content (image, document, video) uses provider formula or
size-based estimates — accurate counting requires content extraction
that only the provider can do.
14 tests covering all Strands content block types.
ChatGPT session auth tokens are only valid at chatgpt.com, not
api.openai.com. The catch-all route was sending all unknown paths
to api.openai.com, causing 401 for /v1/responses/compact when
Codex sub-agents use codex login auth.
Build URL directly instead of using handle_passthrough because
ChatGPT backend uses /responses/... path (no /v1/ prefix).
Codex sub-agents call /v1/responses/compact for context compaction.
Without a route, the proxy returned 404/405, causing 401 errors when
the client retried against OpenAI with stale/wrong auth.
Added catch-all route for /v1/responses/{sub_path} that forwards
to the correct upstream (chatgpt.com for session auth, api.openai.com
for API key auth).
Claude Code prefixes all MCP tool names with `mcp__<server>__`, so
headroom_retrieve is only callable as `mcp__headroom__headroom_retrieve`.
The strings that tell Claude how to call the tool were using the short
name, causing "No such tool available: headroom_retrieve" errors.
Update the 4 runtime-visible strings in mcp_server.py to use the full
namespaced name so Claude calls the tool correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SiglipTokenizer from transformers hard-requires sentencepiece at runtime.
Without it, the image router silently falls back to PRESERVE (no compression).
Users on pip install headroom-ai[image] or [all] were missing this dependency.
- Increase headroom wrap startup timeout from 15s to 45s (#114)
ML components (Kompress, Magika, Tree-sitter) need 20-30s on slower machines
- Detect Strands SDK toolUse/toolResult blocks in find_tool_units (#116)
Strands wraps tools as {"toolUse": {"toolUseId": ...}} not {"type": "tool_use"}
Without this, tool pairs aren't grouped and dropping one breaks Anthropic validation
- Match CCR marker content format to conversation style (#117)
Strands expects list-of-blocks content, not plain strings
Detect format from existing messages and match it
Resolve the PR merge conflict by carrying forward the secure TLS fail-closed rtk download behavior, bring in the latest upstream proxy-handler updates, and fix the OpenClaw linked-install fallback so it copies the required hook-shim directory with regression coverage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Format the OpenAI proxy handler and proxy helpers with the same Ruff version used in GitHub Actions so the Python 3.12 lint step stops failing on CI-only formatter output.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the Docker wrap e2e harness validate live proxy env wiring for Codex and Aider, start a real OpenClaw gateway in-container, and clear the repo-wide Ruff issues that were keeping the Python 3.12 CI job red.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cache mode assembles prefix+compressed-delta which can legitimately
have different token counts than the original messages. The inflation
guard only applies to optimize/token modes.
Duplicate logging: wrap.py redirects stderr to proxy.log while
_setup_file_logging also writes there via RotatingFileHandler. Set
propagate=False on the headroom logger and guard against adding
duplicate handlers.
Token inflation: 5.8% of requests had optimized_tokens > original_tokens
due to tokenizer counting mismatches between handler and pipeline. Added
guards in all handlers (anthropic, openai, gemini, batch) to revert to
original messages when optimization inflates tokens.
Add a Docker-based end-to-end harness that validates Headroom's Codex, Aider, Cursor, and OpenClaw wrap flows without calling real model providers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove bandit_result.txt, pip_audit_result.txt, reqs.txt, ruff_result.txt
from repo (security risk: bandit output lists exact vuln locations) + gitignore
- Fix unbound original_tokens in batch handler except block (crash on first
batch request failure)
- Downgrade temporary cache debug log from INFO to DEBUG (fires on every
streaming request, polluting production logs)
- Remove duplicate _extract_anthropic_cache_ttl_metrics from AnthropicHandlerMixin
(StreamingMixin version wins via MRO, duplicate was dead code)
parsed_response was only assigned inside the memory_enabled block but
referenced unconditionally by CCR feedback and prefix cache tracker in
the finally block, causing every non-memory streaming request to crash
with UnboundLocalError and abort the connection.
Root cause: CompressionCache.compute_frozen_count() stopped at the first
tool_result not in its cache, capping frozen_message_count at 2. Tool
results excluded by content_router (Read/Glob) or skipped (ratio too
high) never entered the cache, so every subsequent message was eligible
for recompression — causing 192 cache busts per session.
Four fixes:
1. Add _stable_hashes set to CompressionCache so excluded/skipped
tool_results don't block the frozen count walk
2. Fix _estimate_message_tokens to count tool_result content and
tool_use input fields (were counted as 0 tokens in Anthropic format)
3. Fix streaming handler to include assistant response and
original_messages in prefix tracker updates (parity with non-streaming)
4. TTL-aware batch recompression: defer first-time compressions within
the 5-min cache TTL window, batching them at the boundary to trade
many small busts for one
The 0.5.18 refactor added api_key forwarding from request headers to
LiteLLM kwargs in all 4 handler methods. This breaks Bedrock (AWS SigV4)
and Vertex AI (Google ADC) which authenticate via env vars, not API keys.
Forwarding a dummy key like sk-ant-dummy overrides AWS credentials.
Fix: skip api_key forwarding for bedrock, vertex_ai, vertex_ai_beta,
and sagemaker providers. Applied to all 4 occurrences.
Strands SDK sends content blocks without a 'type' field:
{"text": "..."} instead of {"type": "text", "text": "..."}
{"toolUse": {...}} instead of {"type": "tool_use", ...}
{"toolResult": {...}} instead of {"type": "tool_result", ...}
The tokenizer's _count_content_parts() only matched on type field,
causing Strands blocks to fall through to json.dumps estimation.
Now explicitly handles Strands text, toolUse, and toolResult formats
with proper recursive counting for nested toolResult content.
8 new tests covering Strands text blocks, tool blocks, and mixed formats.
Magika detects diffs with score=1.0 (label='diff') but the label
was unmapped, falling through to TEXT. This sent diffs through
Kompress token pruning which destroys diff structure (+/- markers,
hunk headers, indentation).
Fix: add 'diff' to both the Magika ContentType enum and the
ContentRouter type_map so diffs route to DiffCompressor which
preserves all change lines and hunk structure.
Annotate the cache stats containers so the observed TTL mix fields do not confuse mypy in the Linux CI job.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sync the Anthropic cache stability test double with the prefix tracker contract used by the handler.
Format the benchmark scripts that were failing ruff format --check in CI.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cache stats:
- hit_rate is now token-level (cache_read / total_input) not request-level
- Track uncached_input_tokens per provider in metrics
- Preserve request_hit_rate as secondary metric
Compression-vs-cache:
- Detect when compression busts the prefix cache (expected_cached - actual_read)
- Two simple session-level numbers: tokens_saved vs cache_bust_tokens
- Log CACHE-BUST per request, aggregate in /stats and telemetry beacon
- Single new column in proxy_telemetry_v2: cache_bust_tokens
Dashboard infra:
- SQL for dashboard_summary table + pg_cron hourly refresh
- Hourly + daily aggregation from proxy_telemetry_v2
- Upgrade scripts for adding hourly_stats and cache bust columns
Root causes:
- ChatGPT session auth tokens sent to api.openai.com instead of chatgpt.com
- Fallback beta header was responses-api=v1 instead of responses_websockets=2026-02-06
Fixes:
- Detect ChatGPT-Account-ID header and route WS/HTTP to chatgpt.com/backend-api/codex/responses
- Update beta header fallback to match what Codex actually sends
- Add HTTP POST streaming fallback when upstream WS fails (relay SSE over client WS)
- Unwrap response.create envelope in HTTP fallback for correct POST body
- Initialize body before JSON parse to prevent NameError in fallback path
- Fix async test failures: convert asyncio.get_event_loop() to asyncio.run()