Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

2895 lines
111 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Top-level helper functions and constants for the Headroom proxy.
Contains lazy loaders, file logging setup, request body decompression,
and safety-limit constants.
Extracted from server.py for maintainability.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import random
import re
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from headroom import paths as _paths
from headroom._subprocess import run
from headroom.proxy import (
diagnostic_decode_policy,
memory_injection_mode_policy,
query_log_policy,
request_limit_policy,
sse_byte_buffer_policy,
wire_debug_format_policy,
wire_debug_redaction_policy,
)
from headroom.proxy.beta_header_policy import (
BETA_HEADER_STICKY_DEFAULT,
BETA_HEADER_STICKY_ENV,
BETA_TRACKER_MAX_SESSIONS_DEFAULT,
BETA_TRACKER_MAX_SESSIONS_ENV,
BetaHeaderStickyMode,
resolve_beta_header_sticky_mode,
resolve_beta_tracker_max_sessions,
)
from headroom.proxy.body_forwarding import (
BodyMutationTracker as BodyMutationTracker, # noqa: F401 - compatibility export
)
from headroom.proxy.body_forwarding import (
PythonForwarderMode as PythonForwarderMode, # noqa: F401 - compatibility export
)
from headroom.proxy.body_forwarding import (
get_python_forwarder_mode as get_python_forwarder_mode, # noqa: F401 - compatibility export
)
from headroom.proxy.body_forwarding import (
prepare_outbound_body_bytes as prepare_outbound_body_bytes, # noqa: F401 - compatibility export
)
from headroom.proxy.body_forwarding import serialize_body_canonical
from headroom.proxy.ccr_golden_policy import (
create_fresh_ccr_tool_definition,
replay_golden_ccr_tool_definition,
)
from headroom.proxy.ccr_marker_policy import (
has_new_ccr_markers as _has_new_ccr_markers,
)
from headroom.proxy.ccr_marker_policy import (
should_inject_ccr_tool as _should_inject_ccr_tool,
)
from headroom.proxy.ccr_session_tracker import SessionCcrTracker as _SessionCcrTracker
from headroom.proxy.internal_header_policy import (
INTERNAL_HEADER_PREFIX,
STRIP_INTERNAL_HEADERS_DEFAULT,
STRIP_INTERNAL_HEADERS_ENV,
StripInternalHeadersMode,
resolve_strip_internal_headers_mode,
strip_internal_headers,
)
from headroom.proxy.memory_golden_policy import (
replay_golden_memory_tool_definition,
serialize_memory_tool_definition_canonical,
)
from headroom.proxy.tool_injection_config import (
ToolInjectionStickyMode,
)
from headroom.proxy.tool_injection_config import (
get_tool_injection_sticky_mode as _get_tool_injection_sticky_mode,
)
from headroom.proxy.tool_injection_config import (
get_tool_tracker_max_sessions as _get_tool_tracker_max_sessions,
)
from headroom.proxy.tool_injection_logging import (
ToolInjectionDecision,
)
from headroom.proxy.tool_injection_logging import (
log_tool_injection_decision as _log_tool_injection_decision,
)
from headroom.proxy.tool_injection_tracker import SessionToolTracker as _SessionToolTracker
from headroom.proxy.tool_name_policy import extract_tool_name
if TYPE_CHECKING:
import httpx
from fastapi import Request
logger = logging.getLogger("headroom.proxy")
_CODEX_WIRE_DEBUG_ENV = "HEADROOM_CODEX_WIRE_DEBUG"
_CODEX_WIRE_DEBUG_DIR_ENV = "HEADROOM_CODEX_WIRE_DEBUG_DIR"
_CODEX_WIRE_REDACTED = wire_debug_redaction_policy.WIRE_DEBUG_REDACTED
_CODEX_WIRE_SECRET_KEYS = wire_debug_redaction_policy.WIRE_DEBUG_SECRET_KEYS
def codex_wire_debug_enabled() -> bool:
"""Return whether opt-in Codex wire capture is enabled."""
return os.environ.get(_CODEX_WIRE_DEBUG_ENV, "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
def _codex_wire_debug_dir() -> Path:
explicit = os.environ.get(_CODEX_WIRE_DEBUG_DIR_ENV, "").strip()
if explicit:
return Path(explicit).expanduser()
return _paths.codex_wire_debug_dir()
def _should_redact_key(key: str) -> bool:
return wire_debug_redaction_policy.should_redact_key(key)
def _redact_value(value: Any) -> Any:
return wire_debug_redaction_policy.redact_for_wire_debug(value)
def redact_for_wire_debug(value: Any) -> Any:
"""Redact obvious secrets while preserving request/response shape."""
return wire_debug_redaction_policy.redact_for_wire_debug(value)
def _safe_event_name(event: str) -> str:
return wire_debug_format_policy.safe_wire_debug_name(event)
def _wire_debug_preview(value: Any, *, max_chars: int | None = None) -> str:
"""Return the redacted wire payload for proxy.log.
This is intentionally not truncated. During Codex WS debugging we need the
proxy log itself to show the complete frame so we can decide later where a
deliberate trim boundary belongs.
"""
return wire_debug_format_policy.wire_debug_preview(value, max_chars=max_chars)
def capture_codex_wire_debug(
event: str,
*,
request_id: str | None = None,
session_id: str | None = None,
transport: str,
direction: str,
method: str | None = None,
url: str | None = None,
headers: dict[str, Any] | None = None,
body: Any = None,
raw_text: str | None = None,
status_code: int | None = None,
metadata: dict[str, Any] | None = None,
) -> Path | None:
"""Write an opt-in redacted Codex wire snapshot to disk.
This is intentionally file-based rather than log-based: real Codex
requests can be large, and operators need the exact envelope shape without
mixing it into normal proxy logs. Header/body secret-looking keys are
redacted, but request content is otherwise preserved because this mode is
explicitly for local debugging.
"""
if not codex_wire_debug_enabled():
return None
try:
out_dir = _codex_wire_debug_dir()
out_dir.mkdir(parents=True, exist_ok=True)
ts_ns = time.time_ns()
req = request_id or "no_request"
safe_req = _safe_event_name(req)
safe_event = _safe_event_name(event)
path = out_dir / f"{ts_ns}_{safe_req}_{safe_event}.json"
payload = {
"event": event,
"timestamp_ns": ts_ns,
"request_id": request_id,
"session_id": session_id,
"transport": transport,
"direction": direction,
"method": method,
"url": url,
"status_code": status_code,
"headers": redact_for_wire_debug(headers or {}),
"body": redact_for_wire_debug(body),
"raw_text": raw_text,
"metadata": redact_for_wire_debug(metadata or {}),
}
path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False, default=str), encoding="utf-8"
)
logger.info(
"event=codex_wire_debug_capture path=%s request_id=%s wire_event=%s",
path,
request_id or "",
event,
)
preview_source = redact_for_wire_debug(body) if body is not None else raw_text
preview = _wire_debug_preview(preview_source)
meta_keys = ",".join(sorted((metadata or {}).keys()))
logger.info(
"event=codex_wire_debug_frame request_id=%s session_id=%s wire_event=%s "
"transport=%s direction=%s status_code=%s meta_keys=%s preview=%s",
request_id or "",
session_id or "",
event,
transport,
direction,
status_code if status_code is not None else "",
meta_keys,
preview,
)
return path
except Exception as exc: # pragma: no cover - debug path must never break traffic
logger.warning("event=codex_wire_debug_capture_failed error=%s", exc)
return None
# Memory injection mode (P0-1 fix in PR-A2).
#
# Values:
# - "live_zone_tail" (default): Memory context appends to the first text block
# of the latest non-frozen user message. Cache hot zone (system + frozen
# prefix) is never mutated.
# - "disabled": Memory context lookup is skipped entirely; the request
# forwards untouched.
#
# Configurable via HEADROOM_MEMORY_INJECTION_MODE env var. There is no
# "system_prompt" option — that path is permanently retired by I2 (cache hot
# zone never modified). See REALIGNMENT/02-architecture.md §2.2.
_MEMORY_INJECTION_MODE_ENV = memory_injection_mode_policy.MEMORY_INJECTION_MODE_ENV
_MEMORY_INJECTION_MODE_DEFAULT = memory_injection_mode_policy.MEMORY_INJECTION_MODE_DEFAULT
MemoryInjectionMode = memory_injection_mode_policy.MemoryInjectionMode
def get_memory_injection_mode() -> MemoryInjectionMode:
"""Return the active memory-injection routing mode.
Read at request time so the env var can be flipped without restart for
smoke tests. Unknown values are rejected loudly (no silent fallback).
"""
return memory_injection_mode_policy.resolve_memory_injection_mode(
os.environ.get(_MEMORY_INJECTION_MODE_ENV)
)
def hash_query_for_log(query: str) -> str:
"""Stable short hash of a memory-context query, safe to log.
Uses BLAKE2b truncated to 16 hex chars. Never logs the raw query content.
"""
return query_log_policy.hash_query_for_log(query)
def extract_tags(headers: Any) -> dict[str, str]:
"""Extract ``x-headroom-*`` tags from inbound headers.
Pure function (no I/O, no state). Used by every handler at request
entry to capture operator slicing tags into the per-request
``RequestOutcome.tags``. Free function rather than a mixin method so
handler mixins instantiated in isolation (tests using
``object.__new__(OpenAIHandlerMixin)``) don't need a shim
implementation.
Header name match is case-insensitive; the returned key has the
``x-headroom-`` prefix stripped.
"""
return {
k.lower().replace("x-headroom-", ""): v
for k, v in headers.items()
if k.lower().startswith("x-headroom-")
}
def _headroom_bypass_enabled(headers: Any) -> bool:
"""Return True when inbound headers request full Headroom passthrough.
This is transport-neutral policy: HTTP and WebSocket handlers both call
it on original inbound headers before request-body mutation.
"""
try:
bypass = str(headers.get("x-headroom-bypass", "")).strip().lower() == "true"
passthrough = str(headers.get("x-headroom-mode", "")).strip().lower() == "passthrough"
except AttributeError:
return False
return bypass or passthrough
def log_outbound_request(
*,
forwarder: str,
method: str,
path: str,
body_bytes_count: int,
body_mutated: bool,
mutation_reasons: list[str],
request_id: str | None,
source: str,
) -> None:
"""Structured log line for every outbound forwarder call.
Per realignment build constraints: every cache-affecting decision is
logged. Never includes ``Authorization``/``x-api-key`` content or full
body bytes.
"""
logger.info(
"event=outbound_request forwarder=%s method=%s path=%s body_bytes=%d "
"body_mutated=%s mutation_reasons=%s source=%s request_id=%s",
forwarder,
method,
path,
body_bytes_count,
"true" if body_mutated else "false",
",".join(mutation_reasons) if mutation_reasons else "",
source,
request_id or "",
)
def log_memory_injection(
*,
request_id: str,
session_id: str | None,
decision: str,
bytes_injected: int,
query: str | None = None,
) -> None:
"""Emit a structured log line for every memory-context routing decision.
Per realignment build constraints: log every cache-affecting decision.
Never log raw query content or Authorization header — only a stable
hash of the query.
"""
query_hash = hash_query_for_log(query) if query else ""
logger.info(
"event=memory_injection request_id=%s session_id=%s decision=%s "
"bytes_injected=%d query_hash=%s",
request_id,
session_id or "",
decision,
bytes_injected,
query_hash,
)
def append_text_to_latest_user_chat_message(
messages: list[dict[str, Any]],
context_text: str,
) -> tuple[list[dict[str, Any]], int]:
"""Append context text to the first text block of the latest user chat message.
OpenAI Chat Completions ``body["messages"]`` shape: each message is
``{"role": ..., "content": str | list[{"type": "text"|"input_text", "text": ...}]}``.
This is the OpenAI Chat Completions analog of
``_append_context_to_latest_non_frozen_user_turn`` (Anthropic) and
``append_text_to_latest_user_input_item`` (OpenAI Responses). Used by
PR-A3 to retire the legacy system-prepend memory-injection path
(P0-equivalent for /v1/chat/completions).
Returns ``(new_messages, bytes_appended)``. ``bytes_appended == 0``
when no eligible user message was found (no mutation occurred).
"""
if not messages or not context_text:
return messages, 0
new_messages = list(messages)
for idx in range(len(new_messages) - 1, -1, -1):
msg = new_messages[idx]
if not isinstance(msg, dict):
continue
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
updated_msg = {**msg, "content": content + "\n\n" + context_text}
new_messages[idx] = updated_msg
return new_messages, len(context_text)
if isinstance(content, list) and content:
new_content: list[dict[str, Any]] = []
appended = False
for part in content:
if (
not appended
and isinstance(part, dict)
and part.get("type") in ("text", "input_text")
):
existing_text = part.get("text", "")
new_part = {**part, "text": existing_text + "\n\n" + context_text}
new_content.append(new_part)
appended = True
else:
new_content.append(part)
if appended:
updated_msg = {**msg, "content": new_content}
new_messages[idx] = updated_msg
return new_messages, len(context_text)
# User message but no eligible text block — leave untouched and stop.
return messages, 0
return messages, 0
def append_text_to_latest_user_input_item(
body_input: list[dict[str, Any]],
context_text: str,
) -> tuple[list[dict[str, Any]], int]:
"""Append context text to the first text block of the latest user input item.
Mirrors ``_append_context_to_latest_non_frozen_user_turn`` but for the
OpenAI Responses API ``body["input"]`` shape, which uses a flat item list
where each user item's content is a list like
``[{"type": "input_text", "text": "..."}]``.
Returns a tuple ``(new_input, bytes_appended)`` where ``bytes_appended``
is 0 when the item list was unchanged (no eligible user item).
"""
if not body_input or not context_text:
return body_input, 0
new_input = list(body_input)
for idx in range(len(new_input) - 1, -1, -1):
item = new_input[idx]
if not isinstance(item, dict):
continue
if item.get("role") != "user":
continue
content = item.get("content")
if isinstance(content, str):
updated_item = {**item, "content": content + "\n\n" + context_text}
new_input[idx] = updated_item
return new_input, len(context_text)
if isinstance(content, list) and content:
new_content: list[dict[str, Any]] = []
appended = False
for part in content:
if (
not appended
and isinstance(part, dict)
and part.get("type") in ("input_text", "text")
):
existing_text = part.get("text", "")
new_part = {**part, "text": existing_text + "\n\n" + context_text}
new_content.append(new_part)
appended = True
else:
new_content.append(part)
if appended:
updated_item = {**item, "content": new_content}
new_input[idx] = updated_item
return new_input, len(context_text)
# User item but no eligible text block — leave untouched and stop.
return body_input, 0
return body_input, 0
_CONTEXT_TOOL_ENV = "HEADROOM_CONTEXT_TOOL"
_CONTEXT_TOOL_RTK = "rtk"
_CONTEXT_TOOL_LEAN_CTX = "lean-ctx"
_RTK_GAIN_SCOPE_ENV = "HEADROOM_RTK_GAIN_SCOPE"
_RTK_GAIN_SCOPE_GLOBAL = "global"
_RTK_GAIN_SCOPE_PROJECT = "project"
_RTK_GAIN_SCOPES = {_RTK_GAIN_SCOPE_GLOBAL, _RTK_GAIN_SCOPE_PROJECT}
RTK_STATS_CACHE_TTL_SECONDS = float(os.environ.get("HEADROOM_CONTEXT_TOOL_STATS_TTL_SECONDS", "60"))
CONTEXT_TOOL_STATS_CACHE_TTL_SECONDS = RTK_STATS_CACHE_TTL_SECONDS
_context_tool_stats_cache_lock = threading.Lock()
_context_tool_stats_cache: dict[str, Any] = {
"expires_at": 0.0,
"has_value": False,
"tool": None,
"value": None,
}
_context_tool_session_baseline: dict[str, Any] = {
"initialized": False,
"tool": None,
"total_commands": 0,
"input_tokens": 0,
"output_tokens": 0,
"tokens_saved": 0,
"total_time_ms": 0,
"captured_at": 0.0,
}
_rtk_stats_cache_lock = _context_tool_stats_cache_lock
_rtk_stats_cache = _context_tool_stats_cache
_rtk_session_baseline = _context_tool_session_baseline
# Maximum request body size (100MB - increased to support image-heavy requests)
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
# Maximum SSE buffer size (10MB - prevents memory exhaustion from malformed streams)
MAX_SSE_BUFFER_SIZE = 10 * 1024 * 1024
# Per-event SSE size cap (PR-A8 / P1-8). Configurable via
# HEADROOM_SSE_BUFFER_MAX_BYTES. Guards against pathological huge events
# (a single event > 1 MB by default is treated as an upstream protocol bug
# and surfaces loudly rather than silently growing the buffer).
_SSE_EVENT_MAX_BYTES_ENV = request_limit_policy.SSE_EVENT_MAX_BYTES_ENV
_SSE_EVENT_MAX_BYTES_DEFAULT = request_limit_policy.SSE_EVENT_MAX_BYTES_DEFAULT
def get_sse_event_max_bytes() -> int:
"""Return the per-event SSE size cap.
Read at request time so operators can flip the env var without a
restart. Negative values are rejected loudly (no silent fallback).
"""
return request_limit_policy.resolve_sse_event_max_bytes(
os.environ.get(_SSE_EVENT_MAX_BYTES_ENV)
)
# Body-too-large status code (PR-A8 / P5-59). Default 413 (RFC 7231 §6.5.11).
# Configurable via HEADROOM_PROXY_BODY_TOO_LARGE_STATUS for operators who need
# to override (no expected production use; documentation knob).
_BODY_TOO_LARGE_STATUS_ENV = request_limit_policy.BODY_TOO_LARGE_STATUS_ENV
_BODY_TOO_LARGE_STATUS_DEFAULT = request_limit_policy.BODY_TOO_LARGE_STATUS_DEFAULT
def get_body_too_large_status() -> int:
"""Return the HTTP status code for body-too-large rejections."""
return request_limit_policy.resolve_body_too_large_status(
os.environ.get(_BODY_TOO_LARGE_STATUS_ENV)
)
_SSE_EVENT_TERMINATORS = sse_byte_buffer_policy.SSE_EVENT_TERMINATORS
def _find_sse_event_terminator(buf: bytearray) -> tuple[int, int] | None:
"""Return the earliest complete SSE event terminator in ``buf``."""
return sse_byte_buffer_policy.find_sse_event_terminator(buf)
_SSE_EVENT_LINE_PREFIX = b"event:"
_SSE_DATA_LINE_PREFIX = b"data:"
def safe_decode_for_logging(raw: bytes, *, max_bytes: int | None = None) -> str:
"""Decode bytes to a string for **log/diagnostic display only**.
PR-A8 / P1-8: the SSE wire path forbids ``errors="ignore"`` /
``errors="replace"`` because corrupting bytes silently busts cache
safety. Diagnostic logs (e.g. error response bodies) are fine to
show with a replacement character because the bytes are already
discarded; this helper centralizes that single legitimate use of
the lossy decoder so a project-wide grep stays clean.
Use ``parse_sse_events_from_byte_buffer`` for SSE parsing instead.
"""
return diagnostic_decode_policy.safe_decode_for_logging(raw, max_bytes=max_bytes)
def parse_sse_events_from_byte_buffer(
buf: bytearray,
) -> list[tuple[str | None, str]]:
"""Drain complete ``event:`` + ``data:`` events from a bytes buffer.
Returns list of ``(event_name, data_str)`` tuples for complete events.
Mutates ``buf`` in-place to leave only partial-event tail bytes.
Operates on bytes; only decodes complete events as UTF-8 (raises if a
*complete* event has invalid UTF-8 — that's an upstream protocol bug
we want loud, not silent).
Per PR-A8 / P1-8: this is the canonical SSE event splitter. NEVER use
``decode("utf-8", errors="ignore")`` on a partial buffer; UTF-8
multi-byte characters split across TCP reads will corrupt content.
"""
return sse_byte_buffer_policy.parse_sse_events_from_byte_buffer(buf)
# Maximum message array length (prevents DoS from deeply nested payloads)
MAX_MESSAGE_ARRAY_LENGTH = 10000
# Compression pipeline timeout in seconds. Override via the
# HEADROOM_COMPRESSION_TIMEOUT_SECONDS env var for slow CPUs or long Claude Code
# conversations (GH #946). Falls back to 30 on an unparseable value.
try:
COMPRESSION_TIMEOUT_SECONDS = float(
os.environ.get("HEADROOM_COMPRESSION_TIMEOUT_SECONDS", "30")
)
except ValueError:
COMPRESSION_TIMEOUT_SECONDS = 30.0
# Eager startup preload timeout in seconds. The preload (compressor/parser models,
# cache-only, allow_download=False) runs off the event loop during startup; this
# bound only fires on a true hang or an uncatchable native stall so the proxy still
# binds its port instead of never opening (GH #790). Override via
# HEADROOM_EAGER_PRELOAD_TIMEOUT_SECONDS. Falls back to 120 on an unparseable value.
try:
EAGER_PRELOAD_TIMEOUT_SECONDS = float(
os.environ.get("HEADROOM_EAGER_PRELOAD_TIMEOUT_SECONDS", "120")
)
except ValueError:
EAGER_PRELOAD_TIMEOUT_SECONDS = 120.0
# Maximum compression cache sessions (prevents unbounded memory growth)
MAX_COMPRESSION_CACHE_SESSIONS = 500
# ---------------------------------------------------------------------------
# Compression-failure escape hatch
# ---------------------------------------------------------------------------
# When the proxy's compression stage fails (timeout, exception) on a frame
# Headroom thought was large enough to compress, the legacy behaviour was to
# fall through and forward the *original* uncompressed frame to the upstream.
# That fail-open turned a recoverable timeout into a context-window overflow
# downstream: Codex's auto-compaction reads ``total_usage_tokens`` from
# upstream (which Headroom's earlier successful compressions shrunk), then
# the un-compressed retry overflows the model context and the client
# locks up.
#
# Default behaviour is now fail-CLOSED: refuse to forward, close the client
# WS with code 1009 (or return HTTP 413) so the client knows to compact and
# retry. Operators who want the old behaviour can set
# ``HEADROOM_WS_FAIL_OPEN_ON_COMPRESSION_FAILURE=1``. The oversize threshold
# below which transient errors still fall through to passthrough is
# configurable via ``HEADROOM_WS_COMPRESSION_FAIL_THRESHOLD_BYTES``
# (default 256 KiB ≈ 64K tokens).
WS_COMPRESSION_FAIL_OPEN_ENV = "HEADROOM_WS_FAIL_OPEN_ON_COMPRESSION_FAILURE"
WS_COMPRESSION_OVERSIZE_BYTES_ENV = "HEADROOM_WS_COMPRESSION_FAIL_THRESHOLD_BYTES"
WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT = 256 * 1024
@dataclass(frozen=True)
class CompressionFailureAction:
"""Decision returned by :func:`decide_compression_failure_action`."""
refuse: bool
"""If True, the caller MUST NOT forward the original frame. Close the
client connection with a clear error code instead."""
reason: str
"""Short machine-readable label for telemetry. One of:
``timeout``, ``oversize:bytes=<n>>threshold=<m>``,
``small_frame_transient``, ``client_override:codex``, or
``env_override:fail_open``."""
frame_bytes: int
"""Original frame size in bytes (for logging / metrics)."""
def decide_compression_failure_action(
exception: BaseException,
frame_bytes: int,
*,
client: str | None = None,
) -> CompressionFailureAction:
"""Decide whether to refuse-and-close vs forward-original after the
proxy's compression pipeline fails on a Realtime WebSocket frame
(or analogous HTTP body).
Decision matrix:
* env :data:`WS_COMPRESSION_FAIL_OPEN_ENV` truthy → forward (legacy
behaviour, opt-in for debugging or strict compatibility).
* Codex client compression timeout → forward. Codex currently treats
the proxy's 1009/413 refusal path as a hard connection failure, so
fail-open is safer for Codex sessions even when the proxy is run
standalone rather than through ``headroom wrap codex``.
* exception is :class:`asyncio.TimeoutError` → refuse (the compression
stage hit its own timeout, which only fires on frames Headroom
thought were big enough to need compression in the first place).
* ``frame_bytes`` > :data:`WS_COMPRESSION_OVERSIZE_BYTES_ENV`
(default 256 KiB) → refuse (large + any compression failure is a
strong signal the upstream will reject the original).
* otherwise → forward (a transient pipeline error on a small frame
shouldn't break the request).
"""
fail_open = os.environ.get(WS_COMPRESSION_FAIL_OPEN_ENV, "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
if fail_open:
return CompressionFailureAction(
refuse=False,
reason="env_override:fail_open",
frame_bytes=frame_bytes,
)
if (client or "").strip().lower() == "codex" and isinstance(exception, asyncio.TimeoutError):
return CompressionFailureAction(
refuse=False,
reason="client_override:codex",
frame_bytes=frame_bytes,
)
threshold = WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT
raw_threshold = os.environ.get(WS_COMPRESSION_OVERSIZE_BYTES_ENV, "").strip()
if raw_threshold:
try:
parsed = int(raw_threshold)
if parsed > 0:
threshold = parsed
except ValueError:
# Operator typo'd the env value — keep the default rather than
# raise on every WS frame. Loud warning instead.
logger.warning(
"Ignoring non-integer %s=%r; using default %d",
WS_COMPRESSION_OVERSIZE_BYTES_ENV,
raw_threshold,
WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT,
)
if isinstance(exception, asyncio.TimeoutError):
return CompressionFailureAction(refuse=True, reason="timeout", frame_bytes=frame_bytes)
if frame_bytes > threshold:
return CompressionFailureAction(
refuse=True,
reason=f"oversize:bytes={frame_bytes}>threshold={threshold}",
frame_bytes=frame_bytes,
)
return CompressionFailureAction(
refuse=False, reason="small_frame_transient", frame_bytes=frame_bytes
)
def jitter_delay_ms(base_ms: int, max_ms: int, attempt: int) -> float:
"""Exponential backoff with 50-150% jitter.
Returns ``min(base_ms * 2**attempt, max_ms) * (0.5 + random())`` — the
canonical formula used across proxy retry loops. Extracted so every
retry site shares one implementation.
"""
capped: float = min(base_ms * (2**attempt), max_ms)
return capped * (0.5 + random.random())
def retry_after_ms(response: httpx.Response, max_ms: int) -> float | None:
"""Parse an HTTP ``Retry-After`` header into a millisecond delay, capped at ``max_ms``.
Returns the delay in ms for a numeric ``seconds`` value or an HTTP-date, or
``None`` when the header is absent or unparseable so the caller falls back to
exponential backoff. Anthropic sends integer seconds; the HTTP-date branch
covers other upstreams. Fails open on any parse error.
"""
value = response.headers.get("retry-after")
if not value:
return None
try:
seconds = float(value)
except ValueError:
try:
from datetime import datetime
from email.utils import parsedate_to_datetime
retry_at = parsedate_to_datetime(value)
seconds = (retry_at - datetime.now(retry_at.tzinfo)).total_seconds()
except (TypeError, ValueError):
return None
return min(max(seconds, 0.0) * 1000.0, float(max_ms))
# Transient upstream statuses worth retrying with backoff: 429 (rate limit) and
# 529 (Anthropic ``overloaded_error``). Both mean "the server is temporarily
# limiting/overloaded — try again shortly", unlike other 4xx which signal a
# problem with the request itself. Single source of truth so the streaming and
# non-streaming forwarders agree on what is retriable.
RETRYABLE_OVERLOAD_STATUSES: frozenset[int] = frozenset({429, 529})
async def request_with_transient_retry(
client: httpx.AsyncClient,
*,
request_id: str | None = None,
max_retries: int = 1,
**request_kwargs: Any,
) -> httpx.Response:
"""Issue a buffered httpx request, retrying once on a transient close.
``httpx.RemoteProtocolError`` ("peer closed connection without sending
complete message body (incomplete chunked read)") is raised when an
upstream closes a pooled keep-alive connection that httpx then reuses for
the next request. A direct ``curl`` never hits this because it opens a
fresh connection per call; Headroom reuses pooled connections, so the
first request issued on a stale connection fails even though the upstream
is healthy (it answers a fresh connection with 200). Retrying opens a new
connection and succeeds, mirroring curl's behaviour. See GH #1112.
Only ``httpx.RemoteProtocolError`` is retried — the specific stale
keep-alive symptom; every other exception (``ConnectError``, timeouts,
HTTP status errors) propagates immediately so existing handling is
unchanged. Use this for buffered (non-streaming) requests only: a streamed
response cannot be safely replayed once bytes have reached the client.
"""
import httpx
attempt = 0
while True:
try:
return await client.request(**request_kwargs)
except httpx.RemoteProtocolError as exc:
if attempt >= max_retries:
raise
attempt += 1
logger.warning(
"Upstream closed connection mid-response (%s); retrying on a "
"fresh connection (attempt %d/%d)%s",
exc,
attempt,
max_retries,
f" [{request_id}]" if request_id else "",
)
# Image compression availability (do not retain a global compressor instance)
_image_compressor_available: bool | None = None
def _get_image_compressor():
"""Create a short-lived image compressor on demand."""
global _image_compressor_available
if _image_compressor_available is False:
return None
try:
from headroom.image import ImageCompressor
# Callers own closing the compressor; this helper only memoizes whether
# the optional image stack is importable.
compressor = ImageCompressor()
if _image_compressor_available is None:
logger.info("Image compression enabled (model: chopratejas/technique-router)")
_image_compressor_available = True
return compressor
except ImportError as e:
if _image_compressor_available is not False:
logger.warning(f"Image compression not available: {e}")
_image_compressor_available = False
return None
# Always-on file logging to the workspace logs directory for `headroom perf` analysis.
# Resolved lazily so HEADROOM_WORKSPACE_DIR env-var changes are honored.
def _headroom_log_dir() -> Path:
return _paths.log_dir()
def _setup_file_logging() -> None:
"""Add a RotatingFileHandler to the headroom root logger.
Writes to ~/.headroom/logs/proxy.log with automatic rotation:
- Rotates at 10 MB
- Keeps 5 backups (~50 MB max)
"""
from logging.handlers import RotatingFileHandler
try:
log_dir = _headroom_log_dir()
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "proxy.log"
handler = RotatingFileHandler(
log_path,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
handler.setLevel(logging.INFO)
handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
# Attach to the headroom root logger so all sub-loggers are captured.
# Disable propagation to root to avoid duplicate writes when
# wrap.py redirects stderr to the same log file.
headroom_logger = logging.getLogger("headroom")
headroom_logger.setLevel(logging.INFO)
if not any(isinstance(h, RotatingFileHandler) for h in headroom_logger.handlers):
headroom_logger.addHandler(handler)
headroom_logger.propagate = False
except OSError:
# Non-fatal: can't write logs (read-only fs, permissions, etc.)
pass
def _selected_context_tool() -> str:
raw = os.environ.get(_CONTEXT_TOOL_ENV, _CONTEXT_TOOL_RTK).strip().lower()
normalized = raw.replace("_", "-")
if normalized in ("leanctx", _CONTEXT_TOOL_LEAN_CTX):
return _CONTEXT_TOOL_LEAN_CTX
return _CONTEXT_TOOL_RTK
def _context_tool_label(tool: str) -> str:
if tool == _CONTEXT_TOOL_LEAN_CTX:
return "lean-ctx"
return "RTK"
def _context_tool_default_scope(tool: str) -> str:
if tool == _CONTEXT_TOOL_LEAN_CTX:
return "local"
return _RTK_GAIN_SCOPE_GLOBAL
def _rtk_gain_scope() -> str:
raw = os.environ.get(_RTK_GAIN_SCOPE_ENV, "").strip().lower()
if not raw:
return _RTK_GAIN_SCOPE_GLOBAL
if raw in _RTK_GAIN_SCOPES:
return raw
logger.warning(
"event=rtk_gain_scope_invalid env=%s value=%r default=%s",
_RTK_GAIN_SCOPE_ENV,
raw,
_RTK_GAIN_SCOPE_GLOBAL,
)
return _RTK_GAIN_SCOPE_GLOBAL
def _rtk_gain_command(rtk_path: Any, scope: str) -> list[str]:
command = [str(rtk_path), "gain"]
if scope == _RTK_GAIN_SCOPE_PROJECT:
command.append("--project")
command.extend(["--format", "json"])
return command
def _coerce_int(value: Any, default: int = 0) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return default
def _coerce_float(value: Any, default: float = 0.0) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return default
def _first_value(mapping: dict[str, Any], keys: tuple[str, ...], default: Any = 0) -> Any:
for key in keys:
if key in mapping and mapping[key] is not None:
return mapping[key]
return default
def _context_tool_summary_payload(
*,
tool: str,
installed: bool,
scope: str | None = None,
summary: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Normalize RTK/lean-ctx lifetime gain output into one schema.
Both tools expose cumulative counters, but field names vary slightly.
Headroom computes session values by subtracting a startup baseline, so
keeping raw input/output counters is necessary for a truthful session
savings percentage.
"""
summary = summary or {}
input_tokens = _coerce_int(
_first_value(
summary,
(
"total_input",
"total_input_tokens",
"input_tokens",
"tokens_input",
"totalBefore",
),
)
)
output_tokens = _coerce_int(
_first_value(
summary,
(
"total_output",
"total_output_tokens",
"output_tokens",
"tokens_output",
"totalAfter",
),
)
)
tokens_saved = _coerce_int(
_first_value(
summary,
(
"total_saved",
"tokens_saved",
"total_tokens_saved",
"saved_tokens",
"totalSaved",
),
)
)
if tokens_saved <= 0 and input_tokens > 0 and output_tokens >= 0:
tokens_saved = max(input_tokens - output_tokens, 0)
if input_tokens <= 0 and tokens_saved > 0 and output_tokens >= 0:
input_tokens = tokens_saved + output_tokens
lifetime_savings_pct = _coerce_float(
_first_value(
summary,
(
"avg_savings_pct",
"average_savings_pct",
"savings_pct",
"savings_percent",
"avgSavingsPct",
),
0.0,
)
)
if lifetime_savings_pct <= 0 and input_tokens > 0:
lifetime_savings_pct = (tokens_saved / input_tokens) * 100.0
return {
"tool": tool,
"label": _context_tool_label(tool),
"installed": installed,
"scope": scope or _context_tool_default_scope(tool),
"total_commands": _coerce_int(
_first_value(
summary,
(
"total_commands",
"commands",
"command_count",
"totalCommandCount",
),
)
),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"tokens_saved": tokens_saved,
# Backward-compatible name. See `lifetime_avg_savings_pct` and
# `session_savings_pct` below for explicit scopes.
"avg_savings_pct": lifetime_savings_pct,
"lifetime_avg_savings_pct": lifetime_savings_pct,
"total_time_ms": _coerce_int(
_first_value(summary, ("total_time_ms", "time_ms", "totalTimeMs"))
),
}
def _context_tool_zero_payload(
*,
tool: str,
installed: bool,
scope: str | None = None,
) -> dict[str, Any]:
return _context_tool_summary_payload(
tool=tool,
installed=installed,
scope=scope,
summary={},
)
def _read_rtk_lifetime_stats() -> dict[str, Any] | None:
"""Read rtk's lifetime stats using the configured gain scope."""
from headroom.rtk import get_rtk_path
scope = _rtk_gain_scope()
rtk_path = get_rtk_path()
if not rtk_path:
return _context_tool_zero_payload(
tool=_CONTEXT_TOOL_RTK,
installed=False,
scope=scope,
)
try:
result = run(
_rtk_gain_command(rtk_path, scope),
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout)
summary = data.get("summary", {})
payload = _context_tool_summary_payload(
tool=_CONTEXT_TOOL_RTK,
installed=True,
scope=scope,
summary=summary if isinstance(summary, dict) else {},
)
else:
# A failed read is "no data", never a zero counter — a synthetic
# zero here re-pins the session baseline and inflates session
# savings by the tool's whole lifetime on recovery.
stderr_excerpt = (result.stderr or "")[:200]
logger.warning(
"event=rtk_stats_subprocess_failed reason=non_zero_exit rc=%s stderr=%r",
result.returncode,
stderr_excerpt,
)
return None
except Exception as exc:
# Reason is the exception class name (without payload — RTK
# exceptions can carry filesystem paths).
logger.warning(
"event=rtk_stats_subprocess_failed reason=%s error=%s",
type(exc).__name__,
exc,
)
return None
return payload
def _read_lean_ctx_lifetime_stats() -> dict[str, Any] | None:
"""Read lean-ctx's current project-level lifetime stats."""
from headroom.lean_ctx import get_lean_ctx_path
lean_ctx_path = get_lean_ctx_path()
if not lean_ctx_path:
return _context_tool_zero_payload(tool=_CONTEXT_TOOL_LEAN_CTX, installed=False)
try:
result = run(
[str(lean_ctx_path), "gain", "--json"],
capture_output=True,
text=True,
timeout=5,
)
# Failed reads return None ("no data") — mirrors the rtk reader so
# the baseline logic never sees synthetic zeros from either tool.
if result.returncode != 0 or not result.stdout.strip():
logger.warning(
"event=lean_ctx_stats_subprocess_failed reason=non_zero_exit rc=%s",
result.returncode,
)
return None
data = json.loads(result.stdout)
summary = data.get("summary", data) if isinstance(data, dict) else {}
if not isinstance(summary, dict):
logger.warning("event=lean_ctx_stats_subprocess_failed reason=bad_payload")
return None
return _context_tool_summary_payload(
tool=_CONTEXT_TOOL_LEAN_CTX,
installed=True,
summary=summary,
)
except Exception as exc:
logger.warning(
"event=lean_ctx_stats_subprocess_failed reason=%s",
type(exc).__name__,
)
return None
def _read_context_tool_lifetime_stats(tool: str) -> dict[str, Any] | None:
if tool == _CONTEXT_TOOL_LEAN_CTX:
return _read_lean_ctx_lifetime_stats()
return _read_rtk_lifetime_stats()
async def initialize_context_tool_session_baseline() -> None:
"""Pin the current context-tool counters as the proxy-session baseline."""
tool = _selected_context_tool()
payload = await asyncio.to_thread(_read_context_tool_lifetime_stats, tool)
with _context_tool_stats_cache_lock:
if payload is None or not payload.get("installed", False):
# Failed or tool-absent read: defer the pin to the first
# successful read (guarded lazy-init) — pinning zeros here would
# inflate session savings by the tool's whole lifetime once it
# recovers or gets installed.
_context_tool_session_baseline.update(
{
"initialized": False,
"tool": tool,
"total_commands": 0,
"input_tokens": 0,
"output_tokens": 0,
"tokens_saved": 0,
"total_time_ms": 0,
"captured_at": time.time(),
}
)
else:
_context_tool_session_baseline.update(
{
"initialized": True,
"tool": tool,
"total_commands": int(payload.get("total_commands", 0) or 0),
"input_tokens": int(payload.get("input_tokens", 0) or 0),
"output_tokens": int(payload.get("output_tokens", 0) or 0),
"tokens_saved": int(payload.get("tokens_saved", 0) or 0),
"total_time_ms": int(payload.get("total_time_ms", 0) or 0),
"captured_at": time.time(),
}
)
_context_tool_stats_cache.update(
{
"expires_at": 0.0,
"has_value": False,
"tool": None,
"value": None,
}
)
async def initialize_rtk_session_baseline() -> None:
"""Backward-compatible alias for initialize_context_tool_session_baseline."""
await initialize_context_tool_session_baseline()
def _get_context_tool_stats() -> dict[str, Any] | None:
"""Get context-tool savings for the current Headroom proxy session.
RTK and lean-ctx persist project-level lifetime counters. Dashboard stats
should be session-local, so we subtract the counter snapshot captured at
proxy startup instead of resetting the tool's own history.
"""
tool = _selected_context_tool()
now = time.monotonic()
with _context_tool_stats_cache_lock:
cached_value = cast(dict[str, Any] | None, _context_tool_stats_cache["value"])
if (
_context_tool_stats_cache["has_value"]
and now < float(_context_tool_stats_cache["expires_at"])
and _context_tool_stats_cache.get("tool") == tool
):
return cached_value
payload = _read_context_tool_lifetime_stats(tool)
with _context_tool_stats_cache_lock:
# Baseline mutations only happen on successful reads from an
# installed tool — a failed read (None) or a tool-absent zero payload
# must never pin or re-pin, or session deltas inflate by the whole
# lifetime when the tool comes back.
tool_installed = payload is not None and bool(payload.get("installed", False))
if (
payload is not None
and tool_installed
and (
not _context_tool_session_baseline["initialized"]
or _context_tool_session_baseline.get("tool") != tool
)
):
_context_tool_session_baseline.update(
{
"initialized": True,
"tool": tool,
"total_commands": int(payload.get("total_commands", 0) or 0),
"input_tokens": int(payload.get("input_tokens", 0) or 0),
"output_tokens": int(payload.get("output_tokens", 0) or 0),
"tokens_saved": int(payload.get("tokens_saved", 0) or 0),
"total_time_ms": int(payload.get("total_time_ms", 0) or 0),
"captured_at": time.time(),
}
)
if payload is not None:
lifetime_total_commands = int(payload.get("total_commands", 0) or 0)
lifetime_input_tokens = int(payload.get("input_tokens", 0) or 0)
lifetime_output_tokens = int(payload.get("output_tokens", 0) or 0)
lifetime_tokens_saved = int(payload.get("tokens_saved", 0) or 0)
lifetime_total_time_ms = int(payload.get("total_time_ms", 0) or 0)
baseline_total_commands = int(_context_tool_session_baseline["total_commands"])
baseline_input_tokens = int(_context_tool_session_baseline["input_tokens"])
baseline_output_tokens = int(_context_tool_session_baseline["output_tokens"])
baseline_tokens_saved = int(_context_tool_session_baseline["tokens_saved"])
baseline_total_time_ms = int(_context_tool_session_baseline["total_time_ms"])
# A tool-absent payload carries zero counters that are not a
# genuine external reset — only successful installed reads may
# re-pin the baseline.
counter_reset_detected = tool_installed and (
lifetime_total_commands < baseline_total_commands
or lifetime_input_tokens < baseline_input_tokens
or lifetime_output_tokens < baseline_output_tokens
or lifetime_tokens_saved < baseline_tokens_saved
or lifetime_total_time_ms < baseline_total_time_ms
)
if counter_reset_detected:
baseline_total_commands = lifetime_total_commands
baseline_input_tokens = lifetime_input_tokens
baseline_output_tokens = lifetime_output_tokens
baseline_tokens_saved = lifetime_tokens_saved
baseline_total_time_ms = lifetime_total_time_ms
_context_tool_session_baseline.update(
{
"total_commands": baseline_total_commands,
"input_tokens": baseline_input_tokens,
"output_tokens": baseline_output_tokens,
"tokens_saved": baseline_tokens_saved,
"total_time_ms": baseline_total_time_ms,
"captured_at": time.time(),
}
)
session_total_commands = max(lifetime_total_commands - baseline_total_commands, 0)
session_input_tokens = max(lifetime_input_tokens - baseline_input_tokens, 0)
session_output_tokens = max(lifetime_output_tokens - baseline_output_tokens, 0)
session_tokens_saved = max(lifetime_tokens_saved - baseline_tokens_saved, 0)
session_total_time_ms = max(lifetime_total_time_ms - baseline_total_time_ms, 0)
session_savings_pct = (
round(session_tokens_saved / session_input_tokens * 100.0, 4)
if session_input_tokens > 0
else None
)
session_avg_time_ms = (
round(session_total_time_ms / session_total_commands, 2)
if session_total_commands > 0 and session_total_time_ms > 0
else None
)
lifetime_savings_pct = float(payload.get("lifetime_avg_savings_pct", 0.0) or 0.0)
payload = {
**payload,
"tool": tool,
"label": _context_tool_label(tool),
# Backward-compatible session-delta fields.
"total_commands": session_total_commands,
"input_tokens": session_input_tokens,
"output_tokens": session_output_tokens,
"tokens_saved": session_tokens_saved,
"total_time_ms": session_total_time_ms,
"session_savings_pct": session_savings_pct,
"session_avg_time_ms": session_avg_time_ms,
# Keep old field for compatibility, but declare its scope.
"avg_savings_pct": lifetime_savings_pct,
"avg_savings_pct_scope": "lifetime",
"lifetime_avg_savings_pct": lifetime_savings_pct,
"lifetime_total_commands": lifetime_total_commands,
"lifetime_input_tokens": lifetime_input_tokens,
"lifetime_output_tokens": lifetime_output_tokens,
"lifetime_tokens_saved": lifetime_tokens_saved,
"lifetime_total_time_ms": lifetime_total_time_ms,
"session_baseline_total_commands": baseline_total_commands,
"session_baseline_input_tokens": baseline_input_tokens,
"session_baseline_output_tokens": baseline_output_tokens,
"session_baseline_tokens_saved": baseline_tokens_saved,
"session_baseline_total_time_ms": baseline_total_time_ms,
"session_baseline_captured_at": _context_tool_session_baseline.get(
"captured_at", 0.0
),
"session": {
"commands": session_total_commands,
"input_tokens": session_input_tokens,
"output_tokens": session_output_tokens,
"tokens_saved": session_tokens_saved,
"savings_pct": session_savings_pct,
"total_time_ms": session_total_time_ms,
"avg_time_ms": session_avg_time_ms,
},
"lifetime": {
"commands": lifetime_total_commands,
"input_tokens": lifetime_input_tokens,
"output_tokens": lifetime_output_tokens,
"tokens_saved": lifetime_tokens_saved,
"savings_pct": lifetime_savings_pct,
"total_time_ms": lifetime_total_time_ms,
},
"baseline": {
"commands": baseline_total_commands,
"input_tokens": baseline_input_tokens,
"output_tokens": baseline_output_tokens,
"tokens_saved": baseline_tokens_saved,
"total_time_ms": baseline_total_time_ms,
"captured_at": _context_tool_session_baseline.get("captured_at", 0.0),
},
"sampled_at": time.time(),
"sample_ttl_seconds": CONTEXT_TOOL_STATS_CACHE_TTL_SECONDS,
"refresh_interval_seconds": CONTEXT_TOOL_STATS_CACHE_TTL_SECONDS,
"counter_reset_detected": counter_reset_detected,
}
_context_tool_stats_cache.update(
{
"expires_at": time.monotonic() + CONTEXT_TOOL_STATS_CACHE_TTL_SECONDS,
"has_value": True,
"tool": tool,
"value": payload,
}
)
return payload
def _get_rtk_stats() -> dict[str, Any] | None:
"""Backward-compatible alias for selected context-tool stats."""
return _get_context_tool_stats()
def is_anthropic_auth(headers: dict[str, str]) -> bool:
"""Detect Anthropic auth signals in request headers."""
if headers.get("x-api-key") or headers.get("anthropic-version"):
return True
auth = headers.get("authorization", "")
if auth.startswith("Bearer sk-ant-"):
return True
return False
# ---------------------------------------------------------------------------
# Internal-header stripping (PR-A5 — fixes P5-49).
# ---------------------------------------------------------------------------
#
# `x-headroom-*` request headers (e.g. ``x-headroom-bypass``,
# ``x-headroom-mode``, ``x-headroom-user-id``, ``x-headroom-stack``,
# ``x-headroom-base-url``) are internal control flags consumed by the
# proxy itself. They MUST NOT leak upstream — leaking them would (a)
# fingerprint the proxy to subscription enforcers and (b) expose the
# user-id/stack/base-url internals to whichever vendor terminates the
# request.
#
# Inbound read paths (bypass gating, ``_extract_tags`` reading
# ``x-headroom-*``, memory ``x-headroom-user-id`` lookup) keep using
# the original dict / ``request.headers``. The stripped copy is what
# every upstream-bound forwarder receives.
#
# Note: response-side ``X-Headroom-*`` injection (e.g.
# ``x-headroom-tokens-saved``) is unrelated — the proxy is allowed to
# tell its client about its own work. This helper only filters
# request-side headers.
_INTERNAL_HEADER_PREFIX = INTERNAL_HEADER_PREFIX
_STRIP_INTERNAL_HEADERS_ENV = STRIP_INTERNAL_HEADERS_ENV
_STRIP_INTERNAL_HEADERS_DEFAULT = STRIP_INTERNAL_HEADERS_DEFAULT
def get_strip_internal_headers_mode() -> StripInternalHeadersMode:
"""Return the active internal-header strip mode.
Read at request time so operators can flip behaviour without a
restart. Unknown values raise loudly per the no-silent-fallback
build constraint.
"""
return resolve_strip_internal_headers_mode(os.environ.get(_STRIP_INTERNAL_HEADERS_ENV))
def _strip_internal_headers(headers: dict[str, str]) -> dict[str, str]:
"""Return a copy of ``headers`` with internal ``x-headroom-*`` keys stripped.
Used at every upstream call site to prevent fingerprinting / leakage of
internal flags like ``x-headroom-bypass``, ``x-headroom-mode``,
``x-headroom-user-id``, ``x-headroom-stack``, ``x-headroom-base-url``.
Case-insensitive on the prefix. Returns a NEW dict; never mutates the
caller's mapping. Pure function. No regex.
When the operator opt-in ``HEADROOM_STRIP_INTERNAL_HEADERS=disabled``
is set, returns a shallow copy unchanged. That mode is for diagnostic
shadow tracing only and is documented as a per-deploy choice.
"""
return strip_internal_headers(headers, mode=get_strip_internal_headers_mode())
def log_outbound_headers(
*,
forwarder: str,
stripped_count: int,
request_id: str | None,
) -> None:
"""Structured log line for every upstream forwarder header strip.
Emitted once per outbound request (paired with ``log_outbound_request``).
Per realignment build constraint #8 we log every cache-affecting
decision; per #8/#11 we never log header values, only the count of
stripped internal headers.
"""
logger.info(
"event=outbound_headers forwarder=%s stripped_count=%d request_id=%s",
forwarder,
stripped_count,
request_id or "",
)
# ---------------------------------------------------------------------------
# Beta-header merge + per-session stickiness (PR-A6 — fixes P5-50; preps P0-6).
# ---------------------------------------------------------------------------
#
# Anthropic's `anthropic-beta` and OpenAI's `OpenAI-Beta` request headers
# carry a comma-separated list of opt-in beta tokens. Two cache-killer
# patterns motivated PR-A6:
#
# 1. Mid-session mutation: when memory is enabled the proxy historically
# did an ad-hoc concat of `context-management-2025-06-27` onto the
# client value (anthropic.py:1244-1248) — every variant produced a
# different byte sequence and the order was undefined when the same
# client value already contained a Headroom-required token.
#
# 2. Token drop-out across turns: clients (Claude Code, Codex CLI) MAY
# drop a beta token between turn N and turn N+1 even when the proxy
# mutated turn N to add it. The cache hot zone is positional, so the
# next turn's prefix bytes hash differently and the prefix-cache
# read misses.
#
# PR-A6 introduces:
# * `merge_anthropic_beta` / `merge_openai_beta`: deterministic, pure,
# order-preserving merge. Client tokens first (in their original order),
# then Headroom-required tokens (in the order passed). Dedupe is
# case-insensitive but preserves original casing of first occurrence.
# Per Anthropic guide §6.3 #6: sticky-on means we add but never reorder.
#
# * `SessionBetaTracker`: bounded LRU cache keyed by `(provider,
# session_id)` tracking every beta token observed for that session.
# On every request we union the client value with previously-seen
# tokens and update the seen set — so a beta seen in turn N is
# present in turn N+1 even if the client drops it. LRU bound (default
# 1000 sessions) prevents unbounded growth. Reentrant lock so future
# callers from inside another locked method don't self-deadlock.
#
# Operator opt-in `HEADROOM_BETA_HEADER_STICKY=disabled` short-circuits
# the tracker (returns the client value verbatim). That mode is loud and
# explicit per realignment build constraint #4 — NOT a silent fallback.
_BETA_HEADER_STICKY_ENV = BETA_HEADER_STICKY_ENV
_BETA_HEADER_STICKY_DEFAULT = BETA_HEADER_STICKY_DEFAULT
_BETA_TRACKER_MAX_SESSIONS_ENV = BETA_TRACKER_MAX_SESSIONS_ENV
_BETA_TRACKER_MAX_SESSIONS_DEFAULT = BETA_TRACKER_MAX_SESSIONS_DEFAULT
def get_beta_header_sticky_mode() -> BetaHeaderStickyMode:
"""Return the active beta-header stickiness mode.
Read at request time so operators can flip behaviour without a
restart. Unknown values raise loudly per the no-silent-fallback
build constraint.
"""
return resolve_beta_header_sticky_mode(os.environ.get(_BETA_HEADER_STICKY_ENV))
def get_beta_tracker_max_sessions() -> int:
"""Return the LRU bound for `SessionBetaTracker` (sessions cap)."""
return resolve_beta_tracker_max_sessions(os.environ.get(_BETA_TRACKER_MAX_SESSIONS_ENV))
def _split_beta_tokens(value: str | None) -> list[str]:
"""Split a comma-separated beta-header value into trimmed tokens.
Empty/whitespace-only entries are dropped. Pure function, no regex.
"""
if not value:
return []
out: list[str] = []
for raw in value.split(","):
token = raw.strip()
if token:
out.append(token)
return out
def _merge_beta_tokens(client_value: str | None, headroom_required: list[str]) -> str:
"""Shared deterministic merge for `anthropic-beta` / `OpenAI-Beta` tokens.
Rules (per Anthropic guide §6.3 #6 "sticky-on; add but never reorder"):
* Client tokens come first, in their original order.
* Headroom-required tokens append in the order given, skipping any
token already present (case-insensitive).
* Dedupe is case-insensitive but the FIRST occurrence's casing wins
(prevents drift when client uses one casing across turns).
* Returns ``""`` when both inputs are empty.
Pure function. No regex. No global state.
"""
seen_lower: set[str] = set()
out: list[str] = []
for token in _split_beta_tokens(client_value):
lower = token.lower()
if lower in seen_lower:
continue
seen_lower.add(lower)
out.append(token)
for token in headroom_required:
if not token:
continue
token = token.strip()
if not token:
continue
lower = token.lower()
if lower in seen_lower:
continue
seen_lower.add(lower)
out.append(token)
return ",".join(out)
def merge_anthropic_beta(client_value: str | None, headroom_required: list[str]) -> str:
"""Merge client `anthropic-beta` value with Headroom-required tokens.
See `_merge_beta_tokens` for full semantics. Order is deterministic:
client tokens first (in their original order), then headroom tokens
(in the order passed). No sorting — sticky-on per Anthropic guide
§6.3 #6 means we add but never reorder. Dedupe is case-insensitive
but preserves the original casing of the first occurrence.
Returns ``""`` when both inputs are empty.
"""
return _merge_beta_tokens(client_value, headroom_required)
def merge_openai_beta(client_value: str | None, headroom_required: list[str]) -> str:
"""Merge client `OpenAI-Beta` value with Headroom-required tokens.
Mirror of `merge_anthropic_beta`. Same semantics — the OpenAI header
follows the same comma-separated convention and the same cache-stable
rules apply.
"""
return _merge_beta_tokens(client_value, headroom_required)
class SessionBetaTracker:
"""Bounded LRU tracker of beta-header tokens observed per (provider, session).
On every request:
* Read the client's beta-header value.
* Union with previously-seen tokens for this session (sticky-on).
* Update the session's seen set.
* Return the union (preserving first-seen order).
Bounded by `max_sessions` (default 1000) via `OrderedDict` LRU
eviction: hits move-to-end; overflow pops oldest. Reentrant lock so
future callers from inside another locked method don't self-deadlock
(mirrors `CompressionCache` pattern).
The tracker is provider-aware: the same `session_id` for Anthropic
and OpenAI keeps independent token sets (clients/upstreams differ on
which tokens are valid).
"""
def __init__(self, max_sessions: int | None = None) -> None:
if max_sessions is None:
max_sessions = get_beta_tracker_max_sessions()
if max_sessions <= 0:
raise ValueError("max_sessions must be > 0")
self._max_sessions: int = max_sessions
# OrderedDict per `compression_cache.py` LRU pattern. Entries
# store the per-session ordered token list (preserving first-seen
# order). RLock allows future callers from inside another locked
# method to enter without self-deadlock.
self._lock = threading.RLock()
self._sessions: OrderedDict[tuple[str, str], list[str]] = OrderedDict()
@property
def active_sessions(self) -> int:
with self._lock:
return len(self._sessions)
def _key(self, provider: str, session_id: str) -> tuple[str, str]:
return (provider, session_id)
def record_and_get_sticky_betas(
self,
provider: str,
session_id: str,
client_value: str | None,
) -> str:
"""Union client tokens with session-seen tokens; update; return.
``provider`` is the upstream identifier (``anthropic`` /
``openai``). ``session_id`` is the proxy's per-conversation ID
(e.g. `SessionTrackerStore.compute_session_id` output for the
HTTP path; the WS handler's per-connection UUID for the WS
path — note WS sessions are short-lived and won't accumulate
cross-turn).
When `HEADROOM_BETA_HEADER_STICKY=disabled` returns the client
value verbatim (operator diagnostic opt-in; documented as a
per-deploy choice, NOT a silent fallback).
Returns the merged comma-separated value (possibly empty).
"""
if not provider:
raise ValueError("provider must be non-empty")
if not session_id:
raise ValueError("session_id must be non-empty")
if get_beta_header_sticky_mode() == "disabled":
# Diagnostic mode — return the client value verbatim, do not
# touch tracker state. This is loud (operators read the env
# var) and per-deploy.
return (client_value or "").strip()
client_tokens = _split_beta_tokens(client_value)
key = self._key(provider, session_id)
with self._lock:
previous = self._sessions.get(key)
if previous is None:
merged_list: list[str] = []
seen_lower: set[str] = set()
else:
# Move-to-end on hit (LRU touch).
self._sessions.move_to_end(key)
merged_list = list(previous)
seen_lower = {t.lower() for t in merged_list}
# Append client tokens preserving order; first-seen casing wins.
for token in client_tokens:
lower = token.lower()
if lower in seen_lower:
continue
seen_lower.add(lower)
merged_list.append(token)
self._sessions[key] = merged_list
self._sessions.move_to_end(key)
# Bound: evict oldest until at-or-below cap.
while len(self._sessions) > self._max_sessions:
self._sessions.popitem(last=False)
return ",".join(merged_list)
def reset(self) -> None:
"""Clear all session state (test helper)."""
with self._lock:
self._sessions.clear()
# Process-wide singleton. Lazily replaced by tests via `reset` /
# `_reset_session_beta_tracker_for_test`. One tracker for both providers
# — the (provider, session_id) key keeps namespaces independent.
_session_beta_tracker_lock = threading.Lock()
_session_beta_tracker: SessionBetaTracker | None = None
def get_session_beta_tracker() -> SessionBetaTracker:
"""Return the process-wide `SessionBetaTracker` singleton.
Lazily constructed so the env-var bound (`HEADROOM_BETA_TRACKER_MAX_SESSIONS`)
is honored at first use. Tests use `_reset_session_beta_tracker_for_test`.
"""
global _session_beta_tracker
with _session_beta_tracker_lock:
if _session_beta_tracker is None:
_session_beta_tracker = SessionBetaTracker()
return _session_beta_tracker
def _reset_session_beta_tracker_for_test() -> None:
"""Clear the process-wide tracker (test-only)."""
global _session_beta_tracker
with _session_beta_tracker_lock:
_session_beta_tracker = None
def log_beta_header_merge(
*,
provider: str,
session_id: str | None,
client_betas_count: int,
sticky_betas_count: int,
headroom_added: list[str],
request_id: str | None,
) -> None:
"""Structured log for every cache-affecting beta-header merge.
`headroom_added` is a list of public, documented beta tokens
(e.g. ``context-management-2025-06-27``,
``responses_websockets=2026-02-06``) — safe to log. We intentionally
do NOT log the raw client value because beta tokens, while public,
can carry experiment IDs the user has not opted to share with
Headroom logs. Emitting counts only makes the decision auditable.
"""
logger.info(
"event=beta_header_merge provider=%s session_id=%s "
"client_betas=%d sticky_betas=%d headroom_added=%s request_id=%s",
provider,
session_id or "",
client_betas_count,
sticky_betas_count,
",".join(headroom_added) if headroom_added else "",
request_id or "",
)
# ---------------------------------------------------------------------------
# Memory-tool injection session-stickiness (PR-A7 — closes P0-6).
# ---------------------------------------------------------------------------
#
# Memory adds `memory_save` / `memory_search` tool definitions to
# `body["tools"]` when memory is enabled for a request. The cache-killer
# pattern motivated by guide §6.3 #2 ("tool list change → cache bust"):
#
# * Mid-session toggle: memory is enabled in turn N (tool definitions
# injected) and disabled in turn N+1 (tool list shrinks). The next
# turn's prefix bytes hash differently, prefix-cache misses, and the
# full prompt re-runs at provider cost.
#
# * Tool definition drift: memory adds the SAME logical tool but the
# bytes differ across turns (insertion order, dict key order, schema
# drift between deploys, etc.). Even with the tool list intact the
# prefix bytes change.
#
# PR-A7 introduces:
#
# * `SessionToolTracker`: bounded LRU keyed by (provider, session_id)
# storing the GOLDEN tool-definition bytes injected on the first
# turn. Subsequent turns of that session always inject the same
# bytes — even if memory is disabled mid-session (sticky-on per
# guide §6.3 #2). Provider-aware so the same `session_id` under
# two providers keeps independent state.
#
# The golden bytes are produced by `serialize_body_canonical` of the
# tool definition object so they are deterministic across deploys
# regardless of dict insertion ordering quirks.
#
# Operator opt-in `HEADROOM_TOOL_INJECTION_STICKY=disabled` short-
# circuits the tracker; per-turn decision flows through unchanged. That
# mode is loud and explicit per realignment build constraint #4 — NOT a
# silent fallback. It exists for diagnostic shadow tracing / emergency
# rollback only.
def get_tool_injection_sticky_mode() -> ToolInjectionStickyMode:
"""Return the active memory-tool stickiness mode.
Read at request time so operators can flip behaviour without a
restart. Unknown values raise loudly per the no-silent-fallback
build constraint.
"""
return _get_tool_injection_sticky_mode()
def get_tool_tracker_max_sessions() -> int:
"""Return the LRU bound for `SessionToolTracker` (sessions cap)."""
return _get_tool_tracker_max_sessions()
def serialize_tool_definition_canonical(tool_definition: dict[str, Any]) -> bytes:
"""Deterministic byte serialization of a single memory tool definition.
Uses ``serialize_body_canonical`` semantics (compact separators, UTF-8,
no ASCII escaping). Python 3.7+ dict insertion order is preserved by
``json.dumps`` so callers must construct the tool definition with a
stable key order — which the static schemas in
``headroom/proxy/memory_handler.py`` and
``headroom/proxy/memory_tool_adapter.py`` already do.
Returned bytes pin the golden tool definition for a session: every
follow-up turn must inject byte-equal output to keep the prefix
cache hot.
"""
return serialize_body_canonical(tool_definition)
class SessionToolTracker(_SessionToolTracker):
"""Env-aware compatibility wrapper for the pure session tool tracker."""
def __init__(self, max_sessions: int | None = None) -> None:
if max_sessions is None:
max_sessions = get_tool_tracker_max_sessions()
super().__init__(max_sessions=max_sessions)
# Process-wide singleton. Lazily replaced by tests via
# `_reset_session_tool_tracker_for_test`.
_session_tool_tracker_lock = threading.Lock()
_session_tool_tracker: SessionToolTracker | None = None
def get_session_tool_tracker() -> SessionToolTracker:
"""Return the process-wide `SessionToolTracker` singleton.
Lazily constructed so the env-var bound
(`HEADROOM_TOOL_TRACKER_MAX_SESSIONS`) is honored at first use.
Tests use ``_reset_session_tool_tracker_for_test``.
"""
global _session_tool_tracker
with _session_tool_tracker_lock:
if _session_tool_tracker is None:
_session_tool_tracker = SessionToolTracker()
return _session_tool_tracker
def _reset_session_tool_tracker_for_test() -> None:
"""Clear the process-wide tracker (test-only)."""
global _session_tool_tracker
with _session_tool_tracker_lock:
_session_tool_tracker = None
def log_tool_injection_decision(
*,
provider: str,
session_id: str | None,
decision: ToolInjectionDecision,
tool_definition_bytes_count: int,
request_id: str | None,
) -> None:
"""Structured log for every cache-affecting tool-injection decision.
Per realignment build constraint #8 we log every cache-affecting
decision. ``tool_definition_bytes_count`` is the per-tool byte count
summed across all memory tools injected this turn. We do NOT log the
tool definition contents (might contain user-specific schemas) per
constraint #11.
"""
_log_tool_injection_decision(
logger=logger,
provider=provider,
session_id=session_id,
decision=decision,
tool_definition_bytes_count=tool_definition_bytes_count,
request_id=request_id,
)
def _extract_tool_name(tool_definition: dict[str, Any]) -> str | None:
"""Extract a stable tool name from a memory tool definition.
Handles three formats:
* Anthropic custom: ``{"name": "memory_save", ...}``
* Anthropic native: ``{"type": "memory_20250818", "name": "memory"}``
* OpenAI function: ``{"type": "function", "function": {"name": "memory_save", ...}}``
"""
return extract_tool_name(tool_definition)
def apply_session_sticky_memory_tools(
*,
provider: Literal["anthropic", "openai"],
session_id: str | None,
request_id: str | None,
existing_tools: list[dict[str, Any]] | None,
memory_tools_to_inject: list[dict[str, Any]],
inject_this_turn: bool,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply sticky-on memory tool injection per `SessionToolTracker`.
The single coordination point for all memory-tool injection sites
(Anthropic custom tools, Anthropic native tool, OpenAI function tools).
Logic (guide §6.3 #2):
* If ``HEADROOM_TOOL_INJECTION_STICKY=disabled``: bypass tracker,
inject only when ``inject_this_turn`` is True. Diagnostic mode.
* If session previously injected and tracker has golden bytes:
ALWAYS inject the golden bytes verbatim (sticky-on). Memory-this-
turn flag is irrelevant — once injected, always injected.
* If session has NOT previously injected:
- ``inject_this_turn=True``: serialize ``memory_tools_to_inject``,
record golden bytes, append to tools list.
- ``inject_this_turn=False``: skip; no future replay obligation.
Memory tools whose names already appear in ``existing_tools`` are
NOT re-appended (the client owns the canonical definition then).
``session_id`` may be ``None`` (e.g. WS path with no per-turn
session); in that case the tracker is bypassed and the caller's
``inject_this_turn`` flag drives the decision verbatim. We log the
bypass once so operators can see it.
Returns ``(updated_tools, was_injected)``. The returned list is a
fresh list (caller-safe). ``was_injected`` is True iff at least one
memory tool was added to the list.
"""
if provider not in ("anthropic", "openai"):
raise ValueError(f"unsupported provider: {provider!r}")
tools_out: list[dict[str, Any]] = list(existing_tools) if existing_tools else []
existing_names: set[str] = set()
for t in tools_out:
n = _extract_tool_name(t)
if n:
existing_names.add(n)
# Diagnostic / rollback path.
if get_tool_injection_sticky_mode() == "disabled":
if not inject_this_turn:
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="skip_disabled_via_env",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
# Disabled mode + inject_this_turn=True: append the definitions
# verbatim without recording golden bytes (per-turn decision
# passes through as the broken behavior — explicit operator
# opt-in only). Skip names already in the list.
added_bytes = 0
for tool_def in memory_tools_to_inject:
tn = _extract_tool_name(tool_def)
if tn is None or tn in existing_names:
continue
tools_out.append(tool_def)
existing_names.add(tn)
added_bytes += len(serialize_memory_tool_definition_canonical(tool_def))
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="skip_disabled_via_env",
tool_definition_bytes_count=added_bytes,
request_id=request_id,
)
return tools_out, added_bytes > 0
# Sticky path requires a session_id. None means we cannot track —
# fall back to the caller's per-turn decision (loud, single log line)
# so WS handlers / pre-session paths remain functional.
if not session_id:
if not inject_this_turn:
log_tool_injection_decision(
provider=provider,
session_id=None,
decision="skip",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
added_bytes = 0
for tool_def in memory_tools_to_inject:
tn = _extract_tool_name(tool_def)
if tn is None or tn in existing_names:
continue
tools_out.append(tool_def)
existing_names.add(tn)
added_bytes += len(serialize_memory_tool_definition_canonical(tool_def))
log_tool_injection_decision(
provider=provider,
session_id=None,
decision="inject_first_time",
tool_definition_bytes_count=added_bytes,
request_id=request_id,
)
return tools_out, added_bytes > 0
tracker = get_session_tool_tracker()
previously_injected = tracker.should_inject(provider, session_id)
if previously_injected:
# Sticky replay: always inject the golden bytes. inject_this_turn
# flag is intentionally ignored (memory may be disabled this turn
# but the cache prefix demands the same tool list as before).
golden = tracker.get_golden_definitions(provider, session_id) or []
replay_bytes = 0
for tool_name, golden_bytes in golden:
if tool_name in existing_names:
# Client also has a tool by this name — don't double up.
# Their bytes win (the client's choice, not ours to gate).
continue
try:
replay = replay_golden_memory_tool_definition(
tool_name=tool_name,
golden_tool_bytes=golden_bytes,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
logger.error(
"corrupt golden tool bytes for session %s tool %s: %s — skipping tool injection",
session_id,
tool_name,
exc,
exc_info=True,
)
continue
tools_out.append(replay.tool_definition)
existing_names.add(replay.tool_name)
replay_bytes += len(replay.canonical_bytes)
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="inject_sticky_replay",
tool_definition_bytes_count=replay_bytes,
request_id=request_id,
)
return tools_out, replay_bytes > 0
# Fresh session.
if not inject_this_turn:
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="skip",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
# First-time inject: serialize, record, append.
added_bytes = 0
for tool_def in memory_tools_to_inject:
tn = _extract_tool_name(tool_def)
if tn is None or tn in existing_names:
continue
golden_bytes = serialize_memory_tool_definition_canonical(tool_def)
tracker.record_injection(
provider=provider,
session_id=session_id,
tool_name=tn,
tool_definition_bytes=golden_bytes,
)
tools_out.append(tool_def)
existing_names.add(tn)
added_bytes += len(golden_bytes)
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="inject_first_time",
tool_definition_bytes_count=added_bytes,
request_id=request_id,
)
return tools_out, added_bytes > 0
# ─── Session-sticky CCR tool injection (PR-B7) ─────────────────────────
#
# Per realignment plan PR-B7 (`REALIGNMENT/04-phase-B-live-zone.md`):
# once a session has performed any CCR compression, the
# `headroom_retrieve` tool stays registered in `body["tools"]` for every
# subsequent request in that session — never toggled off.
#
# The legacy `CCRToolInjector.has_compressed_content` flips on/off based
# on whether the *latest request* contained compression markers, which
# bust the prompt cache every time the flag flips. Sticky-on means the
# tool list bytes stay byte-stable across turns once injected.
class SessionCcrTracker(_SessionCcrTracker):
"""Env-aware compatibility wrapper for the pure CCR session tracker."""
def __init__(self, max_sessions: int | None = None) -> None:
if max_sessions is None:
max_sessions = get_tool_tracker_max_sessions()
super().__init__(max_sessions=max_sessions)
# Process-wide singleton.
_session_ccr_tracker_lock = threading.Lock()
_session_ccr_tracker: SessionCcrTracker | None = None
def get_session_ccr_tracker() -> SessionCcrTracker:
"""Return the process-wide :class:`SessionCcrTracker` singleton."""
global _session_ccr_tracker
with _session_ccr_tracker_lock:
if _session_ccr_tracker is None:
_session_ccr_tracker = SessionCcrTracker()
return _session_ccr_tracker
def _reset_session_ccr_tracker_for_test() -> None:
"""Clear the process-wide CCR tracker (test-only)."""
global _session_ccr_tracker
with _session_ccr_tracker_lock:
_session_ccr_tracker = None
def has_new_ccr_markers(
*,
current_detected_hashes: list[str],
previous_forwarded_messages: list[dict[str, Any]] | None,
provider: Literal["anthropic", "openai", "google"],
) -> bool:
"""Whether the about-to-forward content carries CCR markers NOT already forwarded.
``overlay_cached_prefix`` (#1850) replays the previously-forwarded (compressed)
prefix byte-identical to keep the prompt cache warm — which reintroduces the
``hash=…`` markers that prefix already carried. Those markers are *historical*:
the agent saw them last turn and the retrieve-tool state was already settled
for them. Only markers that are genuinely NEW this turn justify overriding the
tool-injection deferral (#1006); counting the replayed ones would re-inject the
tool on every frozen turn and bust the *tools* cache segment (undoing the very
cache-safety the overlay provides).
Returns True iff ``current_detected_hashes`` contains a hash that is not present
in ``previous_forwarded_messages``.
"""
return _has_new_ccr_markers(
current_detected_hashes=current_detected_hashes,
previous_forwarded_messages=previous_forwarded_messages,
provider=provider,
)
def should_inject_ccr_tool(
*,
configured_inject_tool: bool,
frozen_message_count: int,
has_compressed_content: bool,
) -> tuple[bool, bool]:
"""Decide whether the ``headroom_retrieve`` tool must be injected this turn.
This is the decision the Anthropic handler used to inline. It is extracted
so the #1006 regression can be pinned at the decision point itself.
Tool injection is normally deferred when there is a frozen message prefix
(``frozen_message_count > 0``) to preserve the prompt cache. But if
compression emitted fresh markers this turn, deferring would hand the agent
a ``<<ccr:hash>>`` marker with no tool to redeem it — silent data loss. In
that case we override the deferral and inject anyway (one cache miss is
cheaper than dropped content).
Returns ``(should_inject, is_marker_override)``. ``is_marker_override`` is
True only when injection happens *because* of new markers despite a deferral,
so the caller can log the override distinctly.
"""
return _should_inject_ccr_tool(
configured_inject_tool=configured_inject_tool,
frozen_message_count=frozen_message_count,
has_compressed_content=has_compressed_content,
)
def apply_session_sticky_ccr_tool(
*,
provider: Literal["anthropic", "openai", "google"],
session_id: str | None,
request_id: str | None,
existing_tools: list[dict[str, Any]] | None,
has_compressed_content_this_turn: bool,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply sticky-on CCR retrieval-tool injection per :class:`SessionCcrTracker`.
Coordination point for both Anthropic and OpenAI handlers — replaces
the legacy ``CCRToolInjector.inject_tool_definition`` "flip on, flip
off" behaviour.
Logic:
* If ``session_id`` is None: tracker is bypassed and the per-turn
``has_compressed_content_this_turn`` flag drives the decision
verbatim (matching legacy behaviour for WS / pre-session paths).
* If the session has previously done CCR (``has_done_ccr``):
ALWAYS inject the recorded golden bytes — even if this turn has
no fresh compression. That is the load-bearing PR-B7 fix.
* Otherwise, inject only when this turn produced compressed content.
The first injection records the golden bytes for future turns.
Tools whose name already equals ``CCR_TOOL_NAME`` (e.g. the client
pre-registered it via MCP) are not re-appended; the client's bytes
win.
Returns ``(updated_tools, was_injected)``. ``updated_tools`` is a
fresh list (caller-safe).
"""
from headroom.ccr.tool_injection import CCR_TOOL_NAME
if provider not in ("anthropic", "openai", "google"):
raise ValueError(f"unsupported provider: {provider!r}")
tools_out: list[dict[str, Any]] = list(existing_tools) if existing_tools else []
existing_names: set[str] = set()
for t in tools_out:
n = _extract_tool_name(t)
if n:
existing_names.add(n)
# Client (or MCP) already provided a tool by this name — don't double up.
if CCR_TOOL_NAME in existing_names:
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="skip",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
# No session_id (e.g. WS path): per-turn decision drives directly.
if not session_id:
if not has_compressed_content_this_turn:
log_tool_injection_decision(
provider=provider,
session_id=None,
decision="skip",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
replay = create_fresh_ccr_tool_definition(provider)
tools_out.append(replay.tool_definition)
log_tool_injection_decision(
provider=provider,
session_id=None,
decision="inject_first_time",
tool_definition_bytes_count=len(replay.canonical_bytes),
request_id=request_id,
)
return tools_out, True
tracker = get_session_ccr_tracker()
previously_done = tracker.has_done_ccr(provider, session_id)
if previously_done:
# Sticky replay path. Always inject — even if this turn had no
# fresh CCR compression. Prefer the recorded golden bytes; fall
# back to a freshly serialized definition if (somehow) the
# tracker lost them. Loud per build constraint #4: we log the
# path taken either way.
golden = tracker.get_golden_tool_bytes(provider, session_id)
if golden is not None:
try:
replay = replay_golden_ccr_tool_definition(golden)
tools_out.append(replay.tool_definition)
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="inject_sticky_replay",
tool_definition_bytes_count=len(replay.canonical_bytes),
request_id=request_id,
)
return tools_out, True
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
logger.error(
"corrupt golden CCR tool bytes for session %s: %s — regenerating fresh definition",
session_id,
exc,
exc_info=True,
)
# Fall through to fresh creation below
# Tracker says "done CCR" but has no golden bytes (or they were corrupt). Pin
# them now so future turns are stable.
replay = create_fresh_ccr_tool_definition(provider)
tracker.record_ccr_done(provider, session_id, replay.canonical_bytes)
tools_out.append(replay.tool_definition)
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="inject_sticky_replay",
tool_definition_bytes_count=len(replay.canonical_bytes),
request_id=request_id,
)
return tools_out, True
# Fresh session — only inject when this turn produced compressed content.
if not has_compressed_content_this_turn:
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="skip",
tool_definition_bytes_count=0,
request_id=request_id,
)
return tools_out, False
replay = create_fresh_ccr_tool_definition(provider)
tracker.record_ccr_done(provider, session_id, replay.canonical_bytes)
tools_out.append(replay.tool_definition)
log_tool_injection_decision(
provider=provider,
session_id=session_id,
decision="inject_first_time",
tool_definition_bytes_count=len(replay.canonical_bytes),
request_id=request_id,
)
return tools_out, True
async def _read_request_body_bytes(request: Request) -> bytes:
"""Read and (if needed) decompress the request body, returning raw UTF-8 bytes.
Mirrors ``_read_request_json`` but returns the bytes pre-parse so
forwarders can implement byte-faithful passthrough (PR-A3, fixes P0-2).
Raises ``ValueError`` on any decompression failure.
"""
encoding = (request.headers.get("content-encoding") or "").lower().strip()
raw = await request.body()
if encoding in ("zstd", "zstandard"):
try:
import zstandard
dctx = zstandard.ZstdDecompressor()
reader = dctx.stream_reader(raw)
raw = reader.read()
reader.close()
except ImportError:
raise ValueError(
"Request body is zstd-compressed but the 'zstandard' package is not installed. "
"Install it with: pip install zstandard"
) from None
except Exception as exc:
raise ValueError(f"Failed to decompress zstd request body: {exc}") from exc
elif encoding == "gzip":
import gzip as _gzip
try:
raw = _gzip.decompress(raw)
except Exception as exc:
raise ValueError(f"Failed to decompress gzip request body: {exc}") from exc
elif encoding == "deflate":
import zlib
try:
raw = zlib.decompress(raw)
except Exception as exc:
raise ValueError(f"Failed to decompress deflate request body: {exc}") from exc
elif encoding == "br":
try:
import brotli
raw = brotli.decompress(raw)
except ImportError:
raise ValueError(
"Request body is brotli-compressed but the 'brotli' package is not installed."
) from None
except Exception as exc:
raise ValueError(f"Failed to decompress brotli request body: {exc}") from exc
elif encoding and encoding != "identity":
raise ValueError(f"Unsupported Content-Encoding: {encoding}")
return cast(bytes, raw)
async def _read_request_json(request: Request) -> dict[str, Any]:
"""Read and parse JSON from a request, handling compressed bodies.
Clients like OpenAI Codex may send zstd, gzip, or deflate-compressed
request bodies. Starlette's ``request.json()`` does not decompress
automatically, causing a UnicodeDecodeError on compressed bytes.
This helper inspects ``Content-Encoding``, decompresses if needed,
then JSON-decodes the result. It raises ``ValueError`` on any
decompression or parse failure so callers can return a clean 400.
"""
raw = await _read_request_body_bytes(request)
# Decode and parse JSON
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc
result = json.loads(text)
if not isinstance(result, dict):
raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
return result
async def read_request_json_with_bytes(request: Request) -> tuple[dict[str, Any], bytes]:
"""Read JSON body AND return the original (decompressed) bytes.
Returned bytes are post-content-decoding (zstd/gzip/deflate/br are
decompressed) so they represent the body as the upstream API will
receive it. Forwarders pair this with a ``BodyMutationTracker`` to
decide between passthrough and canonical re-serialization.
"""
raw = await _read_request_body_bytes(request)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc
result = json.loads(text)
if not isinstance(result, dict):
raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
return result, raw
def _strip_per_call_annotations(obj: Any) -> Any:
"""Remove annotations that clients mutate between calls in one agent loop.
``cache_control`` is the main offender: clients (notably Claude Code)
move the cache breakpoint to the newest message on each call, which
means the exact same user-text message carries ``cache_control`` on
call 1 and not on call 2. Hashing the raw message dicts therefore
produces a different turn_id for every iteration of a single agent
loop, collapsing ``turn_id`` to effectively ``request_id`` and
breaking prompt-level aggregation downstream.
"""
if isinstance(obj, dict):
return {k: _strip_per_call_annotations(v) for k, v in obj.items() if k != "cache_control"}
if isinstance(obj, list):
return [_strip_per_call_annotations(item) for item in obj]
return obj
def compute_turn_id(
model: str,
system: Any,
messages: list[dict[str, Any]] | None,
) -> str | None:
"""Group all agent-loop API calls triggered by a single user prompt.
A turn spans the user's text prompt plus every assistant tool-use and
user tool-result message the agent appends while executing that prompt.
Hashing the prefix up to and including the last user *text* message yields
an id that is stable across the turn but rolls over when the user sends a
new prompt.
Returns None when no user-text message is present (nothing to identify).
"""
if not messages:
return None
last_text_user_idx: int | None = None
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str) and content:
last_text_user_idx = i
break
if isinstance(content, list):
has_text = any(
isinstance(block, dict) and block.get("type") == "text" for block in content
)
has_tool_result = any(
isinstance(block, dict) and block.get("type") == "tool_result" for block in content
)
# An agent-loop continuation carries tool_result blocks; only a
# fresh user turn is text-only.
if has_text and not has_tool_result:
last_text_user_idx = i
break
if last_text_user_idx is None:
return None
prefix = _strip_per_call_annotations(messages[: last_text_user_idx + 1])
try:
prefix_json = json.dumps(prefix, sort_keys=True, default=str)
except (TypeError, ValueError):
return None
h = hashlib.sha256()
h.update(model.encode("utf-8", errors="replace"))
h.update(b"\0")
if isinstance(system, str):
h.update(system.encode("utf-8", errors="replace"))
elif system is not None:
try:
normalized_system = _strip_per_call_annotations(system)
h.update(json.dumps(normalized_system, sort_keys=True, default=str).encode("utf-8"))
except (TypeError, ValueError):
pass
h.update(b"\0")
h.update(prefix_json.encode("utf-8", errors="replace"))
return h.hexdigest()[:16]
# ---------------------------------------------------------------------------
# Issue #746: Claude Code on-demand tool loading (deferral) detection
#
# When Claude Code points at a custom ``ANTHROPIC_BASE_URL`` (the proxy) with
# ``ENABLE_TOOL_SEARCH`` unset, it stops deferring MCP/system tool schemas
# behind the server-side Tool Search Tool and materializes them all into its
# local context window — tens of K tokens. That decision is made client-side
# before the request reaches us, so the proxy cannot reverse it; the only
# remedy is the ``ENABLE_TOOL_SEARCH`` env var (set automatically by
# ``headroom wrap claude``). For users who run ``claude`` manually we cannot
# touch their environment, so the proxy emits a single actionable hint.
# ---------------------------------------------------------------------------
_TOOL_SEARCH_TOOL_TYPE_PREFIX = "tool_search_tool_"
# Substrings of the ``anthropic-beta`` tokens that gate tool search:
# ``advanced-tool-use-2025-11-20`` (firstParty/foundry) and
# ``tool-search-tool-2025-10-19`` (vertex/bedrock/mantle/gateway).
_TOOL_SEARCH_BETA_MARKERS = ("advanced-tool-use", "tool-search-tool")
_tool_search_hint_lock = threading.Lock()
_tool_search_hint_emitted = False
def claude_code_tool_search_inactive(
*,
client: str | None,
tools: Any,
anthropic_beta: str | None,
) -> bool:
"""Return ``True`` when a Claude Code request is *not* deferring tools.
Detected from request shape alone — no token thresholds, so it scales to
any tool surface:
* the request is from Claude Code (``client == "claude-code"``),
* it carries one or more tool definitions, yet
* it includes neither a ``tool_search_tool_*`` tool nor a tool-search
``anthropic-beta`` token.
In that combination Claude Code has eagerly materialized every tool schema
into its local context window (issue #746).
"""
if client != "claude-code":
return False
if not isinstance(tools, list) or not tools:
return False
for tool in tools:
if isinstance(tool, dict) and str(tool.get("type", "")).startswith(
_TOOL_SEARCH_TOOL_TYPE_PREFIX
):
return False
beta = (anthropic_beta or "").lower()
return not any(marker in beta for marker in _TOOL_SEARCH_BETA_MARKERS)
def format_tool_search_disabled_hint(tools: list[Any]) -> str:
"""Build the one-time, actionable hint for issue #746.
Reports factual, directional numbers (tool count and serialized schema
size) rather than a derived token estimate, which avoids implying a
precision the proxy cannot measure for the client's tokenizer.
"""
try:
schema_kb = len(json.dumps(tools, separators=(",", ":"), default=str)) / 1024
except (TypeError, ValueError):
schema_kb = 0.0
return (
f"Claude Code is sending all {len(tools)} tool definitions eagerly "
f"(~{schema_kb:.0f} KB of tool schema in local context) because "
"ENABLE_TOOL_SEARCH is unset with a custom ANTHROPIC_BASE_URL. Set "
"ENABLE_TOOL_SEARCH=true (or auto) to keep on-demand tool loading active, "
"or launch via `headroom wrap claude` (which sets it automatically). "
"See https://github.com/chopratejas/headroom/issues/746"
)
def tool_search_hint_pending() -> bool:
"""Cheap, lock-free check of whether the one-time hint may still fire.
Lets the request hot path skip the (O(number-of-tools)) detection scan on
every request once the hint has already been emitted. A benign race here
only costs one extra detection scan, never a duplicate warning — the
actual one-shot guarantee lives in :func:`take_tool_search_hint_slot`.
"""
return not _tool_search_hint_emitted
def take_tool_search_hint_slot() -> bool:
"""Return ``True`` exactly once per process, gating the one-time hint.
Thread-safe so concurrent requests cannot each emit the warning.
"""
global _tool_search_hint_emitted
if _tool_search_hint_emitted:
return False
with _tool_search_hint_lock:
if _tool_search_hint_emitted:
return False
_tool_search_hint_emitted = True
return True
def reset_tool_search_hint_state() -> None:
"""Reset the one-time hint guard. Test helper only."""
global _tool_search_hint_emitted
with _tool_search_hint_lock:
_tool_search_hint_emitted = False
# ---------------------------------------------------------------------------
# Server-side Tool Search injection (opencode / non-Claude-Code clients).
#
# Clients that eagerly materialize every tool schema (opencode ships ~135 tool
# defs ≈ 28k tokens on EVERY request) never opt into Anthropic's Tool Search
# Tool themselves. Unlike the Claude Code case above — where the schemas are
# already in the client's own context and the proxy can't reverse it — a plain
# API client's tools live only in the request body, so the proxy CAN defer them:
# mark the non-core tools ``defer_loading: true`` and inject a tool_search tool.
# Anthropic then excludes deferred tools from the context window (they stop
# counting as input tokens until the model searches for one), while every tool
# stays callable. Deterministic output → the tools prefix still prompt-caches.
# ---------------------------------------------------------------------------
# Core coding tools kept non-deferred so routine edit/read/run loops never pay a
# search round-trip. Everything else (Slack/Linear/Sentry/Notion/Snowflake/…) is
# deferred and loaded on demand. Anthropic recommends keeping the 35 (here a few
# more) most frequent tools resident.
_TOOL_SEARCH_CORE_TOOLS = frozenset(
{
"bash",
"bash_background",
"bash_background_output",
"bash_background_wait",
"bash_background_kill",
"read",
"write",
"edit",
"multiedit",
"apply_patch",
"glob",
"grep",
"task",
"todowrite",
"todoread",
"webfetch",
"question",
"skill",
}
)
_TOOL_SEARCH_DEFAULT_TYPE = "tool_search_tool_regex_20251119"
_TOOL_SEARCH_DEFAULT_NAME = "tool_search_tool_regex"
# Below this many tools the ~search round-trip isn't worth it (Anthropic's own
# guidance: standard calling is better under ~10 tools).
_TOOL_SEARCH_MIN_TOOLS = 12
def inject_tool_search_deferral(
tools: Any,
*,
core_tools: frozenset[str] = _TOOL_SEARCH_CORE_TOOLS,
search_type: str = _TOOL_SEARCH_DEFAULT_TYPE,
search_name: str = _TOOL_SEARCH_DEFAULT_NAME,
) -> Any:
"""Return a new ``tools`` list with non-core tools deferred + a search tool
injected, or the original list unchanged when injection doesn't apply.
No-op when: not a list, fewer than ``_TOOL_SEARCH_MIN_TOOLS``, a tool_search
tool is already present (client already defers), or nothing would be deferred.
Invariants enforced (else Anthropic 400s): the search tool is never deferred;
at least one tool stays non-deferred; a deferred tool never carries
``cache_control`` — if the client's tools cache breakpoint sat on a now-deferred
tool, it is moved to the last non-deferred real tool so the (smaller) tools
prefix still caches.
"""
if not isinstance(tools, list) or len(tools) < _TOOL_SEARCH_MIN_TOOLS:
return tools
for tool in tools:
if isinstance(tool, dict) and str(tool.get("type", "")).startswith(
_TOOL_SEARCH_TOOL_TYPE_PREFIX
):
return tools # client already uses tool search — leave it alone
search_tool = {"type": search_type, "name": search_name}
out: list[Any] = [search_tool]
deferred = 0
dropped_cache_control = False
last_resident_real: dict[str, Any] | None = None
resident_has_cache_control = False
for tool in tools:
if not isinstance(tool, dict) or tool.get("type") or tool.get("name") in core_tools:
# Non-dict, server/typed tools (web_search, computer, …), and core
# tools stay resident and unchanged.
out.append(tool)
if isinstance(tool, dict) and not tool.get("type"):
last_resident_real = tool
resident_has_cache_control = resident_has_cache_control or bool(
tool.get("cache_control")
)
continue
new_tool = dict(tool)
new_tool["defer_loading"] = True
if new_tool.pop("cache_control", None) is not None:
dropped_cache_control = True
out.append(new_tool)
deferred += 1
if deferred == 0:
return tools # nothing to defer → don't perturb the cache prefix
# Preserve a tools cache breakpoint: if we stripped cache_control off a
# deferred tool and no resident tool carries one, move it to the last
# resident real tool (never the search tool, to keep its shape canonical).
if dropped_cache_control and not resident_has_cache_control and last_resident_real is not None:
last_resident_real["cache_control"] = {"type": "ephemeral"}
return out
# ---------------------------------------------------------------------------
# Server-side Tool Search injection — OpenAI Responses API (gpt-5.4+).
#
# The OpenAI-side analogue of inject_tool_search_deferral above. OpenAI shipped
# the same idea for the Responses API on gpt-5.4+: mark a function/MCP tool
# ``defer_loading: true`` and add a ``{"type": "tool_search"}`` tool, and OpenAI
# keeps the deferred tools' heavy parameter schemas OUT of the model's context
# (only name+description remain) until the model searches for one — while every
# tool stays callable and the prompt cache is preserved. Same win as Anthropic
# (~15-25k tool-schema tokens -> ~200) for clients that ship a big tool surface
# and never opt into tool search themselves (opencode, plain API clients).
#
# Differences from the Anthropic path that require a separate function:
# * Responses function tools carry ``type: "function"`` (Anthropic real tools
# have no ``type``), so the resident/defer test is inverted — we defer
# ``function`` (non-core) and ``mcp`` tools and keep OTHER typed/hosted tools
# (web_search, file_search, code_interpreter, computer, image_generation, and
# the search tool itself) resident.
# * Model-gated: only gpt-5.4+ support it; older models 400 on the fields.
# * No ``cache_control`` (OpenAI caches automatically), so no breakpoint move.
# ---------------------------------------------------------------------------
_OPENAI_TOOL_SEARCH_TYPE = "tool_search"
_OPENAI_TOOL_SEARCH_MIN_TOOLS = 12
_OPENAI_TOOL_SEARCH_RESIDENT_NAMES = frozenset({"terminal"})
# gpt-5.4 is the first model with Responses tool_search (OpenAI docs). Version-
# gated by default; overridable per deployment via a regex in
# HEADROOM_OPENAI_TOOL_SEARCH_MODELS (matched against the model name) so new
# model families can be enabled without a code edit + release.
_OPENAI_TOOL_SEARCH_MIN_VERSION = (5, 4)
def _model_supports_openai_tool_search(model: str | None) -> bool:
"""True when an OpenAI model supports the Responses ``tool_search`` feature.
Default gate: ``gpt-<major>.<minor>`` >= 5.4. A regex in
``HEADROOM_OPENAI_TOOL_SEARCH_MODELS`` (matched against the model name) wins
when set; a malformed pattern falls back to the version gate rather than
crashing.
"""
if not model:
return False
override = os.environ.get("HEADROOM_OPENAI_TOOL_SEARCH_MODELS", "").strip()
if override:
try:
return re.search(override, model) is not None
except re.error:
pass # malformed override → fall back to the version gate
match = re.match(r"gpt-(\d+)(?:\.(\d+))?", model.strip().lower())
if not match:
return False
major, minor = int(match.group(1)), int(match.group(2) or 0)
return (major, minor) >= _OPENAI_TOOL_SEARCH_MIN_VERSION
def inject_tool_search_deferral_openai(
tools: Any,
model: str | None,
*,
core_tools: frozenset[str] = _TOOL_SEARCH_CORE_TOOLS,
) -> Any:
"""Return a new Responses ``tools`` list with non-core function/MCP tools
deferred + a ``{"type": "tool_search"}`` tool injected, or the original list
unchanged when injection doesn't apply.
No-op when: the model doesn't support tool search (gpt-5.4+ only), ``tools``
is not a list, there are fewer than ``_OPENAI_TOOL_SEARCH_MIN_TOOLS``, a
tool_search tool is already present (client already defers), or nothing would
be deferred. Core coding tools and hosted/typed tools (web_search,
file_search, code_interpreter, computer, …) stay resident and unchanged, so
routine edit/read/run loops never pay a search round-trip and the request
stays valid; the injected search tool is itself resident.
"""
if not _model_supports_openai_tool_search(model):
return tools
if not isinstance(tools, list) or len(tools) < _OPENAI_TOOL_SEARCH_MIN_TOOLS:
return tools
for tool in tools:
if isinstance(tool, dict) and tool.get("type") == _OPENAI_TOOL_SEARCH_TYPE:
return tools # client already uses tool search — leave it alone
out: list[Any] = [{"type": _OPENAI_TOOL_SEARCH_TYPE}]
deferred = 0
for tool in tools:
if not isinstance(tool, dict):
out.append(tool)
continue
ttype = tool.get("type")
# Deferrable: a non-core function, or an MCP server (OpenAI models are
# trained to search namespaces / MCP servers). Everything else — core
# coding tools and other hosted tools — stays resident.
deferrable = (
ttype == "function"
and tool.get("name") not in core_tools
and tool.get("name") not in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES
) or ttype == "mcp"
if deferrable and not tool.get("defer_loading"):
new_tool = dict(tool)
new_tool["defer_loading"] = True
out.append(new_tool)
deferred += 1
else:
out.append(tool)
if deferred == 0:
return tools # nothing to defer → don't perturb the request / cache prefix
return out