mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(grok-build): rewrite existing model table in place; add docs
When users already own [model.grok-build] in config.toml, update base_url in that table instead of appending a duplicate header (invalid TOML). Adds regression tests with tomllib validation, CHANGELOG entry, grok-build docs page, and proxy.mdx wrap coverage.
This commit is contained in:
parent
4633cf94ed
commit
9368c413ab
6 changed files with 191 additions and 5 deletions
|
|
@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Features
|
||||
|
||||
* **grok-build:** add first-class Grok Build support — `headroom wrap grok-build` / `headroom unwrap grok-build`, reversible `~/.grok/config.toml` injection (in-place `base_url` rewrite when `[model.grok-build]` already exists), `GrokRegistrar` MCP install, and install/telemetry wiring ([#1629](https://github.com/headroomlabs-ai/headroom/pull/1629)).
|
||||
* **wrap:** `headroom wrap claude --1m` preserves the 1M context window. Behind a custom `ANTHROPIC_BASE_URL` (the proxy) Claude Code drops the `context-1m` beta header and caps the window at 200k for entitled subscription users; the opt-in flag sets `ANTHROPIC_MODEL=<opus>[1m]` on the launched process so the 1M window activates through Headroom. A model already selected via `ANTHROPIC_MODEL` is preserved (only the `[1m]` suffix is appended) ([#1158](https://github.com/chopratejas/headroom/issues/1158)).
|
||||
* **learn:** weight loops in `headroom learn`. A new loop detector (`headroom/learn/loops.py`) recognizes repeated tool-call patterns — including RTK re-fetch loops, where RTK's output truncation makes the agent re-run larger-limit variants of a *successful* command — collapses output-limit variants to one signature, measures the wasted tokens, surfaces loops as a highest-priority digest section, and weights loop guardrails above one-off rules by their measured waste. Previously loops had no special weight and a no-failure re-fetch loop was skipped entirely. Adds an RTK-loop eval (`benchmarks/rtk_loop_learn_eval.py`) that reproduces a loop, runs it through Learn, and asserts the generated guardrail ranks first and prevents re-triggering.
|
||||
* **learn:** write per-project learnings to the personal, gitignored `CLAUDE.local.md` by default instead of the team-shared `CLAUDE.md`, matching Claude Code's memory convention so machine-specific paths and tool-discovery byproducts no longer pollute the shared file. Adds a `--target` flag to override the destination (e.g. `--target CLAUDE.md` to opt back into the shared file, or any custom path), and auto-migrates a stale learned-patterns block out of an existing `CLAUDE.md` into `CLAUDE.local.md` with a warning ([#1072](https://github.com/chopratejas/headroom/issues/1072)).
|
||||
|
|
|
|||
62
docs/content/docs/grok-build.mdx
Normal file
62
docs/content/docs/grok-build.mdx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
title: Grok Build Integration
|
||||
description: Route Grok Build traffic through Headroom for token compression and per-project savings attribution.
|
||||
---
|
||||
|
||||
Use `headroom wrap grok-build` to route Grok Build LLM traffic through the local Headroom proxy. The wrapper starts or reuses the proxy, injects a reversible `[model.grok-build]` override into `~/.grok/config.toml` (or `$GROK_HOME/config.toml`), optionally sets up RTK or `lean-ctx`, and prints next steps for launching `grok`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
headroom wrap grok-build
|
||||
```
|
||||
|
||||
In another terminal, from the same project directory:
|
||||
|
||||
```bash
|
||||
grok
|
||||
```
|
||||
|
||||
When you are done:
|
||||
|
||||
```bash
|
||||
headroom unwrap grok-build
|
||||
```
|
||||
|
||||
## What `wrap grok-build` Does
|
||||
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| Proxy | Starts the Headroom proxy unless `--no-proxy` is set |
|
||||
| Model config | Writes or updates `[model.grok-build] base_url` in Grok's `config.toml`, pointing at `http://127.0.0.1:<port>/v1` (with optional `/p/<project>` prefix for savings attribution) |
|
||||
| Existing config | If you already have a `[model.grok-build]` table, Headroom rewrites `base_url` in place instead of appending a duplicate table (invalid TOML) |
|
||||
| Context tool | Injects RTK or `lean-ctx` guidance into project `AGENTS.md` unless `--no-context-tool` is set |
|
||||
| MCP install | `headroom mcp install` can register Headroom MCP via `GrokRegistrar` |
|
||||
| Backup | Snapshots `config.toml` to `config.toml.headroom-backup` before the first injection |
|
||||
|
||||
## Options
|
||||
|
||||
```bash
|
||||
headroom wrap grok-build \
|
||||
--port 8787 \ # Proxy port (default: 8787)
|
||||
--no-context-tool \ # Skip RTK / lean-ctx setup
|
||||
--no-proxy \ # Use an existing proxy instead of starting one
|
||||
--learn \ # Enable live traffic learning
|
||||
--memory # Enable persistent memory
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GROK_HOME` | Override Grok config directory (default: `~/.grok`) |
|
||||
| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK |
|
||||
| `XAI_API_KEY` | Grok API key (also accepts `GROK_CODE_XAI_API_KEY`) |
|
||||
|
||||
## Persistent Install
|
||||
|
||||
`grok_build` is an install target for `headroom install apply --providers manual --target grok_build`. The install manifest records proxy env values for Grok Build alongside other wrapped agents.
|
||||
|
||||
## Unwrap
|
||||
|
||||
`headroom unwrap grok-build` restores the pre-wrap `config.toml` from backup when available, or strips Headroom marker blocks and in-place `base_url` rewrites when no backup exists.
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"claude-code-vertex",
|
||||
"claude-code-azure-foundry",
|
||||
"opencode",
|
||||
"grok-build",
|
||||
"mcp",
|
||||
"---Configuration---",
|
||||
"configuration",
|
||||
|
|
|
|||
|
|
@ -225,12 +225,19 @@ headroom wrap aider
|
|||
|
||||
# Cursor (starts the proxy and prints settings to paste into Cursor)
|
||||
headroom wrap cursor
|
||||
|
||||
# Grok Build (updates ~/.grok/config.toml and starts the proxy)
|
||||
headroom wrap grok-build
|
||||
```
|
||||
|
||||
Cursor reads model endpoints from its settings UI, so `headroom wrap cursor`
|
||||
does not rewrite Cursor configuration or launch the app. After it starts the
|
||||
proxy, copy the printed base URL into Cursor's model settings.
|
||||
|
||||
Grok Build reads model endpoints from `~/.grok/config.toml`. `headroom wrap grok-build`
|
||||
injects or updates `[model.grok-build] base_url` to point at the local proxy, then
|
||||
run `grok` from the same project directory. See [Grok Build Integration](/docs/grok-build).
|
||||
|
||||
For environment-driven clients, you can also set the base URL manually:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ _BLOCK_RE = re.compile(
|
|||
re.escape(_MARKER_START) + r".*?" + re.escape(_MARKER_END) + r"\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_GROK_BUILD_TABLE_RE = re.compile(r"(?m)^\[model\.grok-build\]\s*$")
|
||||
_NEXT_TABLE_RE = re.compile(r"(?m)^\[")
|
||||
_BASE_URL_LINE_RE = re.compile(
|
||||
r'(?m)^(?P<indent>[ \t]*)base_url[ \t]*=[ \t]*"(?P<value>[^"\n]*)".*$'
|
||||
)
|
||||
|
||||
|
||||
def grok_home_dir() -> Path:
|
||||
|
|
@ -57,6 +62,46 @@ def strip_grok_headroom_blocks(content: str) -> str:
|
|||
return content.strip()
|
||||
|
||||
|
||||
def has_user_grok_build_model_table(content: str) -> bool:
|
||||
"""Return True when ``content`` already declares ``[model.grok-build]``."""
|
||||
return _GROK_BUILD_TABLE_RE.search(content) is not None
|
||||
|
||||
|
||||
def redirect_existing_grok_build_base_url(content: str, base_url: str) -> tuple[str, bool]:
|
||||
"""Rewrite ``base_url`` inside an existing ``[model.grok-build]`` table.
|
||||
|
||||
TOML rejects duplicate table headers, so when the user already owns
|
||||
``[model.grok-build]`` we update that table in place instead of appending
|
||||
a second one. The previous ``base_url`` value is preserved in a trailing
|
||||
``# was: …`` comment for visibility; the pre-wrap snapshot still enables
|
||||
byte-for-byte restore on ``headroom unwrap grok-build``.
|
||||
"""
|
||||
match = _GROK_BUILD_TABLE_RE.search(content)
|
||||
if match is None:
|
||||
return content, False
|
||||
|
||||
section_start = match.end()
|
||||
next_table = _NEXT_TABLE_RE.search(content, section_start)
|
||||
section_end = next_table.start() if next_table else len(content)
|
||||
section = content[section_start:section_end]
|
||||
|
||||
if _BASE_URL_LINE_RE.search(section):
|
||||
|
||||
def _replace(match_obj: re.Match[str]) -> str:
|
||||
original_value = match_obj.group("value")
|
||||
if original_value == base_url:
|
||||
return match_obj.group(0)
|
||||
indent = match_obj.group("indent")
|
||||
return f'{indent}base_url = "{base_url}" # was: {original_value}'
|
||||
|
||||
section = _BASE_URL_LINE_RE.sub(_replace, section, count=1)
|
||||
else:
|
||||
section = f'\nbase_url = "{base_url}"' + section
|
||||
|
||||
updated = content[:section_start] + section + content[section_end:]
|
||||
return updated, updated != content
|
||||
|
||||
|
||||
def render_headroom_block(port: int, project: str | None = None) -> str:
|
||||
"""Render the Headroom-managed ``[model.grok-build]`` override block."""
|
||||
target = build_proxy_targets(port, project)
|
||||
|
|
@ -79,11 +124,15 @@ def inject_grok_provider_config(port: int, project: str | None = None) -> Path:
|
|||
else:
|
||||
content = ""
|
||||
|
||||
block = render_headroom_block(port, project)
|
||||
if content:
|
||||
content = content.rstrip() + "\n\n" + block
|
||||
target = build_proxy_targets(port, project)
|
||||
if has_user_grok_build_model_table(content):
|
||||
content, _ = redirect_existing_grok_build_base_url(content, target.base_url)
|
||||
else:
|
||||
content = block
|
||||
block = render_headroom_block(port, project)
|
||||
if content:
|
||||
content = content.rstrip() + "\n\n" + block
|
||||
else:
|
||||
content = block
|
||||
|
||||
fsutil.write_text(config_file, content)
|
||||
return config_file
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.providers.grok_build import build_proxy_targets, render_setup_lines
|
||||
from headroom.providers.grok_build.config import (
|
||||
inject_grok_provider_config,
|
||||
redirect_existing_grok_build_base_url,
|
||||
render_headroom_block,
|
||||
restore_grok_provider_config,
|
||||
strip_grok_headroom_blocks,
|
||||
)
|
||||
from headroom.providers.grok_build.install import build_install_env
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
|
||||
def _assert_valid_toml(content: str) -> None:
|
||||
payload = content.encode("utf-8")
|
||||
try:
|
||||
tomllib.loads(payload)
|
||||
except TypeError:
|
||||
# Some environments expose a str-accepting TOML parser shim.
|
||||
tomllib.loads(content) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _count_grok_build_tables(content: str) -> int:
|
||||
return content.count("[model.grok-build]")
|
||||
|
||||
|
||||
def test_grok_build_proxy_targets_use_local_headroom_proxy() -> None:
|
||||
target = build_proxy_targets(9999)
|
||||
|
|
@ -71,4 +93,48 @@ def test_grok_build_config_strip_preserves_user_content() -> None:
|
|||
cleaned = strip_grok_headroom_blocks(original)
|
||||
|
||||
assert "[models]" in cleaned
|
||||
assert "headroom:grok-build" not in cleaned
|
||||
assert "headroom:grok-build" not in cleaned
|
||||
|
||||
|
||||
def test_grok_build_inject_updates_existing_user_table_without_duplicate(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
grok_home = tmp_path / ".grok"
|
||||
grok_home.mkdir()
|
||||
monkeypatch.setenv("GROK_HOME", str(grok_home))
|
||||
|
||||
original = (
|
||||
"[models]\n"
|
||||
'default = "grok-build"\n\n'
|
||||
"[model.grok-build]\n"
|
||||
'model = "grok-build"\n'
|
||||
'base_url = "https://api.x.ai/v1"\n'
|
||||
"temperature = 0.5\n"
|
||||
)
|
||||
config_file = grok_home / "config.toml"
|
||||
config_file.write_text(original, encoding="utf-8")
|
||||
|
||||
inject_grok_provider_config(8787, project="demo")
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
|
||||
assert _count_grok_build_tables(content) == 1
|
||||
assert 'base_url = "http://127.0.0.1:8787/p/demo/v1" # was: https://api.x.ai/v1' in content
|
||||
assert "temperature = 0.5" in content
|
||||
assert "headroom:grok-build" not in content
|
||||
_assert_valid_toml(content)
|
||||
|
||||
|
||||
def test_grok_build_redirect_existing_base_url_is_idempotent() -> None:
|
||||
original = (
|
||||
"[model.grok-build]\n"
|
||||
'base_url = "http://127.0.0.1:8787/v1"\n'
|
||||
"temperature = 0.2\n"
|
||||
)
|
||||
|
||||
updated, changed = redirect_existing_grok_build_base_url(
|
||||
original, "http://127.0.0.1:8787/v1"
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
assert updated == original
|
||||
_assert_valid_toml(updated)
|
||||
Loading…
Add table
Add a link
Reference in a new issue