From 9368c413ab5c93082c8a95902522e32a9ae91f07 Mon Sep 17 00:00:00 2001 From: Aashish Tamsya Date: Sat, 4 Jul 2026 23:28:56 +0900 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + docs/content/docs/grok-build.mdx | 62 ++++++++++++++++++++++ docs/content/docs/meta.json | 1 + docs/content/docs/proxy.mdx | 7 +++ headroom/providers/grok_build/config.py | 57 +++++++++++++++++++-- tests/test_provider_grok_build.py | 68 ++++++++++++++++++++++++- 6 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 docs/content/docs/grok-build.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d681c572..b09a20643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=[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)). diff --git a/docs/content/docs/grok-build.mdx b/docs/content/docs/grok-build.mdx new file mode 100644 index 000000000..e11b658c0 --- /dev/null +++ b/docs/content/docs/grok-build.mdx @@ -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:/v1` (with optional `/p/` 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. \ No newline at end of file diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 210e37d92..982b64e50 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -35,6 +35,7 @@ "claude-code-vertex", "claude-code-azure-foundry", "opencode", + "grok-build", "mcp", "---Configuration---", "configuration", diff --git a/docs/content/docs/proxy.mdx b/docs/content/docs/proxy.mdx index 0eb856f24..5d939b118 100644 --- a/docs/content/docs/proxy.mdx +++ b/docs/content/docs/proxy.mdx @@ -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 diff --git a/headroom/providers/grok_build/config.py b/headroom/providers/grok_build/config.py index 829d5abf6..e0f427150 100644 --- a/headroom/providers/grok_build/config.py +++ b/headroom/providers/grok_build/config.py @@ -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[ \t]*)base_url[ \t]*=[ \t]*"(?P[^"\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 diff --git a/tests/test_provider_grok_build.py b/tests/test_provider_grok_build.py index 7e0173e68..0c8405ad3 100644 --- a/tests/test_provider_grok_build.py +++ b/tests/test_provider_grok_build.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file