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:
Aashish Tamsya 2026-07-04 23:28:56 +09:00
parent 4633cf94ed
commit 9368c413ab
6 changed files with 191 additions and 5 deletions

View file

@ -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)).

View 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.

View file

@ -35,6 +35,7 @@
"claude-code-vertex",
"claude-code-azure-foundry",
"opencode",
"grok-build",
"mcp",
"---Configuration---",
"configuration",

View file

@ -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

View file

@ -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

View 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)