Skip to content

Commit 46d0eb5

Browse files
authored
Merge branch 'headroomlabs-ai:main' into main
2 parents 9598252 + a5d7e12 commit 46d0eb5

64 files changed

Lines changed: 3346 additions & 264 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -608,18 +608,30 @@ jobs:
608608
fi
609609
elif command -v apt-get >/dev/null 2>&1; then
610610
export DEBIAN_FRONTEND=noninteractive
611-
apt-get update -qq
611+
apt_retry() {
612+
apt_get_args="$*"
613+
for attempt in 1 2 3; do
614+
if apt-get "$@"; then
615+
return 0
616+
fi
617+
echo "apt-get ${apt_get_args} failed on attempt ${attempt}; refreshing package lists" >&2
618+
apt-get update -qq || true
619+
sleep $((attempt * 5))
620+
done
621+
apt-get "$@"
622+
}
623+
apt_retry update -qq
612624
# ubuntu:20.04s default repo only ships 3.8; 3.10 lives
613625
# in deadsnakes. Add it on demand. ubuntu:22.04 has
614626
# 3.10/3.11 in main and 3.12 via deadsnakes.
615-
apt-get install -y -qq --no-install-recommends ca-certificates software-properties-common >/dev/null
627+
apt_retry install -y -qq --fix-missing --no-install-recommends ca-certificates software-properties-common >/dev/null
616628
add-apt-repository -y ppa:deadsnakes/ppa >/dev/null 2>&1 || true
617-
apt-get update -qq
618-
apt-get install -y -qq --no-install-recommends \
629+
apt_retry update -qq
630+
apt_retry install -y -qq --fix-missing --no-install-recommends \
619631
"python$PYTHON_VERSION" \
620632
"python$PYTHON_VERSION-venv" \
621633
"python$PYTHON_VERSION-distutils" >/dev/null 2>&1 \
622-
|| apt-get install -y -qq --no-install-recommends \
634+
|| apt_retry install -y -qq --fix-missing --no-install-recommends \
623635
"python$PYTHON_VERSION" \
624636
"python$PYTHON_VERSION-venv" >/dev/null
625637
python_bin="python$PYTHON_VERSION"

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Large diffs are not rendered by default.

crates/headroom-proxy/src/proxy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode, Uri};
1010
use axum::response::IntoResponse;
1111
use axum::routing::{any, get, post};
1212
use axum::Router;
13-
use futures_util::{StreamExt as _, TryStreamExt};
1413
#[cfg(test)]
1514
use bytes::Bytes;
15+
use futures_util::{StreamExt as _, TryStreamExt};
1616
#[cfg(test)]
1717
use http_body_util::BodyExt;
1818

docs/content/docs/ccr.mdx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,16 @@ When the LLM calls `headroom_retrieve`:
7171
3. The result is added to the conversation
7272
4. The API call continues automatically
7373

74-
The client never sees CCR tool calls -- they are handled transparently by Headroom.
74+
The client never sees CCR tool calls on the Anthropic and OpenAI proxy paths; Headroom resolves them transparently there.
75+
76+
<Callout type="warning" title="Current Gemini limitation">
77+
Native Gemini requests do not yet run the server-side CCR response handler, so
78+
`headroom_retrieve` is not resolved transparently on that path today. Google's
79+
OpenAI-compatible Gemini endpoint can also return
80+
`finish_reason=MALFORMED_FUNCTION_CALL` on large function-response continuations
81+
after CCR retrieval. If you need fully transparent CCR resolution today, use the
82+
Anthropic or OpenAI proxy paths. See [issue #2041](https://github.com/headroomlabs-ai/headroom/issues/2041).
83+
</Callout>
7584

7685
## Phase 4: Context Tracker
7786

@@ -94,7 +103,7 @@ Turn 5: User asks "What about the auth middleware?"
94103

95104
## Retrieving originals
96105

97-
CCR works automatically through the proxy, but you can also retrieve cached data programmatically:
106+
CCR works automatically on the Anthropic and OpenAI proxy paths, but you can also retrieve cached data programmatically:
98107

99108
<Tabs groupId="lang" items={['TypeScript', 'Python']}>
100109
<Tab value="TypeScript">

docs/content/docs/persistent-installs.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ The Python-native `headroom wrap ...` flow checks for a matching persistent depl
133133

134134
The Docker-native host wrapper does **not** yet reuse or recover persistent profiles automatically; it still starts a fresh proxy container unless you opt into `--no-proxy`.
135135

136+
## Claude Code VSCode extension caveat
137+
138+
Persistent Claude deployments default to `ENABLE_TOOL_SEARCH=true` because the
139+
standalone Claude CLI benefits from deferred tool schemas.
140+
141+
Anthropic's VSCode extension currently does not render those deferred-tool content
142+
blocks correctly through Headroom and can show `unsupported content type` in the
143+
webview. If your persistent install targets Claude Code inside VSCode, edit
144+
`~/.headroom/deploy/<profile>/manifest.json`, set
145+
`tool_envs.claude.ENABLE_TOOL_SEARCH` to `"false"`, then restart the deployment.
146+
147+
Keep `ENABLE_TOOL_SEARCH=true` for the standalone `claude` CLI unless you hit the
148+
same renderer limitation there.
149+
136150
## Docker-native relationship
137151

138152
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:

docs/content/docs/proxy.mdx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,64 @@ headroom proxy --codex-wire-debug
114114
The old LLMLingua proxy toggles are no longer part of the CLI. Headroom's proxy compression path uses ContentRouter plus the current built-in compressors, including Kompress where applicable.
115115
</Callout>
116116

117+
## Savings profiles
118+
119+
The proxy uses a **savings profile** to control compression behavior — which messages get compressed, how aggressively, and whether to prioritize provider prefix-cache stability or raw savings. Only the env var survives across related tools (`headroom wrap` passes it to the proxy it launches).
120+
121+
```bash
122+
# Switch to a different profile
123+
HEADROOM_SAVINGS_PROFILE=agent-90 headroom proxy
124+
```
125+
126+
### Built-in profiles
127+
128+
| Profile | Target savings | proxy_mode | force_kompress | Best for |
129+
|---------|---------------|------------|---------------|----------|
130+
| `coding` (default) | ~50% (emergent) | `cache` | No | Coding agents — preserves Anthropic prefix-cache stability |
131+
| `agent-90` | 90% | `token` | Yes | Non-coding, cost-sensitive, or high-volume workloads |
132+
| `balanced` | 70% | `token` | No | General-purpose moderate compression |
133+
| `general` | ~60% (emergent) | `token` | No | Non-coding chat, little code in context |
134+
135+
**`coding` (default)** — Optimizes for coding-agent workloads with Anthropic. Uses **cache mode** (`proxy_mode="cache"`): compresses only the newest delta in each turn so the provider's prefix-cache is never busted. User messages are compressed, system prompts preserved (hottest cache). Protects the 2 most recent turns verbatim. Lossless-first with lossy fallback; tool search and cross-turn dedup enabled. This is the profile that `headroom wrap` uses.
136+
137+
**`agent-90`** — Forces ML-based (Kompress) compression with a 10% keep-ratio, ignoring the lossless path. Compresses both user and system messages. Designed for non-coding or cost-sensitive workloads where maximum compression is the goal.
138+
139+
**`balanced`** — Token-mode compression with a 30% keep-ratio. Uses the standard lossless pipeline (does not force Kompress). Protects 4 recent turns. A safe general-purpose profile.
140+
141+
**`general`** — Token-mode compression for non-coding conversations. No turn protection (`protect_recent=0` — nothing code-positional to preserve) and does not compress user or system messages. Uses the standard lossless pipeline.
142+
143+
### Profiles override CLI flags
144+
145+
A profile's `proxy_mode` setting overrides the `--mode` flag. The `coding` profile sets `proxy_mode="cache"`, so `--mode token` has **no effect** when coding is active:
146+
147+
```bash
148+
# These are equivalent — coding's cache mode always wins
149+
headroom proxy
150+
headroom proxy --mode token # --mode token is silently overridden
151+
```
152+
153+
To run in token mode, switch to a profile that uses it:
154+
155+
```bash
156+
HEADROOM_SAVINGS_PROFILE=agent-90 headroom proxy --mode token
157+
```
158+
159+
### Extending a profile with env overrides
160+
161+
Profile defaults are applied only when the corresponding env var is not already set. You can start from a named profile and override individual settings:
162+
163+
```bash
164+
# Start from coding but force Kompress on
165+
HEADROOM_SAVINGS_PROFILE=coding HEADROOM_FORCE_KOMPRESS=1 headroom proxy
166+
167+
# Start from balanced but lower the keep-ratio
168+
HEADROOM_SAVINGS_PROFILE=balanced HEADROOM_TARGET_RATIO=0.15 headroom proxy
169+
```
170+
171+
### Custom profiles
172+
173+
For permanent custom profiles, see the profile definitions in `headroom/agent_savings.py`. Each profile is an `AgentSavingsProfile` dataclass with fields for compression mode, target ratio, turn protection, and pipeline toggles.
174+
117175
## API endpoints
118176

119177
### `GET /health`

docs/content/docs/troubleshooting.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,15 @@ When deferral is off, the proxy log also prints a one-time hint naming the fix.
183183

184184
See [issue #746](https://github.com/chopratejas/headroom/issues/746) for the full analysis.
185185

186+
<Callout type="warning" title="Claude Code VSCode extension caveat">
187+
Anthropic's VSCode extension webview does not currently render the deferred-tool
188+
content blocks that `ENABLE_TOOL_SEARCH=true` enables through Headroom. Tool
189+
results can show up as `unsupported content type` in the extension even though
190+
the standalone `claude` CLI works correctly. If you use Claude Code inside
191+
VSCode, set `ENABLE_TOOL_SEARCH=false` for that target and restart the Headroom
192+
deployment. See [issue #2028](https://github.com/headroomlabs-ai/headroom/issues/2028).
193+
</Callout>
194+
186195
## Remote Control unavailable through custom ANTHROPIC_BASE_URL
187196

188197
**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent.

headroom/backends/litellm.py

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@
2525

2626
logger = logging.getLogger(__name__)
2727

28+
_OPENAI_STANDARD_PARAMS = (
29+
"max_tokens",
30+
"temperature",
31+
"top_p",
32+
"stop",
33+
"tools",
34+
"tool_choice",
35+
"response_format",
36+
"seed",
37+
"n",
38+
)
39+
40+
_OPENAI_CONSUMED_BODY_KEYS = frozenset(
41+
{
42+
"model",
43+
"messages",
44+
"stream",
45+
"stream_options",
46+
*_OPENAI_STANDARD_PARAMS,
47+
}
48+
)
49+
2850
# litellm calls `dotenv.load_dotenv()` during its own import, which loads
2951
# the project `.env` into `os.environ`. We don't want that side effect —
3052
# importing a backend module should not silently leak API keys into the
@@ -139,6 +161,17 @@ def _build_bedrock_fallback_map(region: str) -> dict[str, str]:
139161
return {name: f"bedrock/{prefix}.{model_id}" for name, model_id in _CLAUDE_MODELS}
140162

141163

164+
def _build_openai_extra_body(body: dict[str, Any]) -> dict[str, Any]:
165+
"""Return unconsumed top-level OpenAI request fields for vendor passthrough."""
166+
return {
167+
key: value
168+
for key, value in body.items()
169+
if key not in _OPENAI_CONSUMED_BODY_KEYS
170+
and not key.startswith("x-headroom-")
171+
and not key.startswith("x_headroom_")
172+
}
173+
174+
142175
def _fetch_bedrock_inference_profiles(
143176
region: str | None, profile_name: str | None = None
144177
) -> dict[str, str]:
@@ -1177,20 +1210,14 @@ async def send_openai_message(
11771210
}
11781211

11791212
# Pass through OpenAI parameters
1180-
for param in [
1181-
"max_tokens",
1182-
"temperature",
1183-
"top_p",
1184-
"stop",
1185-
"tools",
1186-
"tool_choice",
1187-
"response_format",
1188-
"seed",
1189-
"n",
1190-
]:
1213+
for param in _OPENAI_STANDARD_PARAMS:
11911214
if param in body:
11921215
kwargs[param] = body[param]
11931216

1217+
extra_body = _build_openai_extra_body(body)
1218+
if extra_body:
1219+
kwargs["extra_body"] = extra_body
1220+
11941221
# Provider-specific region config
11951222
if self.region:
11961223
if self.provider == "bedrock":
@@ -1352,23 +1379,17 @@ async def stream_openai_message(
13521379
"stream": True,
13531380
}
13541381

1355-
for param in [
1356-
"max_tokens",
1357-
"temperature",
1358-
"top_p",
1359-
"stop",
1360-
"tools",
1361-
"tool_choice",
1362-
"response_format",
1363-
"seed",
1364-
"n",
1365-
]:
1382+
for param in _OPENAI_STANDARD_PARAMS:
13661383
if param in body:
13671384
kwargs[param] = body[param]
13681385

13691386
if "stream_options" in body:
13701387
kwargs["stream_options"] = body["stream_options"]
13711388

1389+
extra_body = _build_openai_extra_body(body)
1390+
if extra_body:
1391+
kwargs["extra_body"] = extra_body
1392+
13721393
# Provider-specific region config
13731394
if self.region:
13741395
if self.provider == "bedrock":

headroom/cache/dynamic_detector.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,10 +742,18 @@ def __init__(self, config: DetectorConfig):
742742
from headroom.models.ml_models import MLModelRegistry
743743

744744
self._model = MLModelRegistry.get_sentence_transformer(config.embedding_model)
745-
# Pre-compute exemplar embeddings
745+
# Pre-compute exemplar embeddings. normalize_embeddings=True is
746+
# required: detect() scores sentences with np.dot against these and
747+
# compares to semantic_threshold (a 0-1 cosine value). Without
748+
# normalization sentence_transformers returns raw vectors (norm
749+
# ~5-15), so the dot product is an unbounded inner product, not a
750+
# cosine similarity — nearly every sentence would clear the 0.7
751+
# threshold and be misflagged as dynamic. Matches the siblings in
752+
# prediction/feature_extractor.py and memory/adapters/embedders.py.
746753
self._exemplar_embeddings = self._model.encode(
747754
self.DYNAMIC_EXEMPLARS,
748755
convert_to_numpy=True,
756+
normalize_embeddings=True,
749757
)
750758
except ImportError:
751759
self._load_error = (
@@ -812,9 +820,13 @@ def detect(
812820
if self._exemplar_embeddings is None:
813821
return [], "exemplar embeddings not initialized"
814822

823+
# normalize_embeddings=True so np.dot below is a true cosine similarity
824+
# in [-1, 1], comparable to semantic_threshold; must match the exemplar
825+
# encoding above (both normalized or the dot product is meaningless).
815826
sentence_embeddings = self._model.encode(
816827
sentence_texts,
817828
convert_to_numpy=True,
829+
normalize_embeddings=True,
818830
)
819831

820832
similarities = np.dot(sentence_embeddings, self._exemplar_embeddings.T)

headroom/cli/init.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -322,11 +322,18 @@ def _ensure_codex_provider(path: Path, port: int) -> None:
322322
f"{_CODEX_PROVIDER_MARKER_END}"
323323
)
324324
content = path.read_text(encoding="utf-8") if path.exists() else ""
325-
# init owns model_provider/openai_base_url: drop any prior assignment (any
326-
# value, including one an older version mis-scoped under a table) so we
327-
# replace it instead of emitting a duplicate top-level key (#260).
328-
content = re.sub(r"(?m)^[ \t]*model_provider[ \t]*=.*\r?\n", "", content)
329-
content = re.sub(r"(?m)^[ \t]*openai_base_url[ \t]*=.*\r?\n", "", content)
325+
# init owns the ROOT-level model_provider/openai_base_url: drop any prior
326+
# root assignment so we replace it instead of emitting a duplicate top-level
327+
# key (#260). Scope the strip to the document root (everything before the
328+
# first table header) -- these keys also appear legitimately inside
329+
# [profiles.*] tables as per-profile overrides, and stripping them there
330+
# silently reroutes the user's profiles to the injected "headroom" default.
331+
_first_table = re.search(r"(?m)^[ \t]*\[", content)
332+
_split = _first_table.start() if _first_table else len(content)
333+
root, rest = content[:_split], content[_split:]
334+
root = re.sub(r"(?m)^[ \t]*model_provider[ \t]*=.*\r?\n", "", root)
335+
root = re.sub(r"(?m)^[ \t]*openai_base_url[ \t]*=.*\r?\n", "", root)
336+
content = root + rest
330337
# The provider block carries top-level keys (model_provider, openai_base_url),
331338
# so it must land at the document root rather than after a trailing table (#260).
332339
content = _replace_marker_block(
@@ -469,24 +476,43 @@ def _ensure_codex_feature_flag(path: Path) -> None:
469476
def _ensure_codex_hooks(path: Path, profile: str) -> None:
470477
logger.debug("ensure codex hooks: %s (profile=%s)", path, profile)
471478
command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}"
472-
payload = {
473-
"hooks": {
474-
"SessionStart": [
475-
{
476-
"matcher": "startup|resume",
477-
"hooks": [{"type": "command", "command": command, "timeout": 15}],
478-
}
479-
],
480-
"PreToolUse": [
481-
{
482-
"matcher": "Bash",
483-
"hooks": [{"type": "command", "command": command, "timeout": 15}],
484-
}
485-
],
486-
}
487-
}
488-
path.parent.mkdir(parents=True, exist_ok=True)
489-
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
479+
# Read-merge-write rather than overwrite: the previous version wrote a fresh
480+
# payload wholesale, destroying any user-managed hooks (and other top-level
481+
# keys) in codex hooks.json. Merge per event and dedup on the Headroom
482+
# marker, matching _ensure_claude_hooks / _ensure_copilot_hooks.
483+
payload = _json_file(path)
484+
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
485+
for event, matcher in (
486+
("SessionStart", "startup|resume"),
487+
("PreToolUse", "Bash"),
488+
):
489+
entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else []
490+
retained: list[dict[str, Any]] = []
491+
for entry in entries:
492+
if not isinstance(entry, dict):
493+
retained.append(entry)
494+
continue
495+
hook_items = entry.get("hooks")
496+
if not isinstance(hook_items, list):
497+
retained.append(entry)
498+
continue
499+
has_headroom = any(
500+
isinstance(item, dict)
501+
and item.get("command")
502+
and _CODEX_HOOK_MARKER in str(item.get("command"))
503+
for item in hook_items
504+
)
505+
if not has_headroom:
506+
retained.append(entry)
507+
retained.append(
508+
{
509+
"matcher": matcher,
510+
"hooks": [{"type": "command", "command": command, "timeout": 15}],
511+
}
512+
)
513+
hooks[event] = retained
514+
payload["hooks"] = hooks
515+
_write_json(path, payload)
490516

491517

492518
def _manifest_changed(

0 commit comments

Comments
 (0)