diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 09c6642bb..585e633cc 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -136,7 +136,12 @@ from headroom.providers.copilot import ( validate_configuration as _validate_copilot_configuration, ) from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines -from headroom.providers.grok import build_launch_env as _build_grok_launch_env +from headroom.providers.grok import ( + DEFAULT_API_URL as _GROK_DEFAULT_API_URL, +) +from headroom.providers.grok import ( + build_launch_env as _build_grok_launch_env, +) from headroom.providers.grok_build import render_setup_lines as _render_grok_build_setup_lines from headroom.providers.grok_build.config import ( inject_grok_provider_config, @@ -6417,7 +6422,7 @@ def grok( backend=backend, anyllm_provider=anyllm_provider, region=region, - openai_api_url="https://api.x.ai", + openai_api_url=_GROK_DEFAULT_API_URL, ) @@ -6507,9 +6512,9 @@ def grok_build( \b Grok Build reads model endpoints from ``~/.grok/config.toml``. This - command starts the proxy, optionally sets up the selected CLI context - tool, injects a Headroom-managed ``[model.grok-build]`` override, and - prints next steps. + command starts the proxy (upstream ``https://api.x.ai``, same as + ``wrap grok``), injects a Headroom-managed ``[model.grok-build]`` + override, and prints next steps. \b Example: @@ -6536,6 +6541,8 @@ def grok_build( for line in _render_grok_build_setup_lines(actual_port, project=project): click.echo(line) + # Client hop is local proxy via config.toml; upstream must be xAI (not + # the OpenAI default). Omitting this caused 401s with Grok auth headers. _run_proxy_only_watcher( agent_label="grok-build", port=port, @@ -6544,6 +6551,7 @@ def grok_build( memory=memory, agent_type="grok_build", print_setup_lines=_print_grok_build_setup, + openai_api_url=_GROK_DEFAULT_API_URL, ) diff --git a/headroom/install/planner.py b/headroom/install/planner.py index 290cff063..518688abf 100644 --- a/headroom/install/planner.py +++ b/headroom/install/planner.py @@ -9,6 +9,7 @@ from collections.abc import Iterable import click from headroom import paths as _paths +from headroom.providers.grok.runtime import DEFAULT_API_URL as _GROK_DEFAULT_API_URL from headroom.providers.install_registry import build_install_target_envs from headroom.rollout import RolloutChannel @@ -177,6 +178,19 @@ def build_manifest( base_env["HEADROOM_TELEMETRY"] = "on" if telemetry_enabled else "off" if memory_enabled: base_env["HEADROOM_MEMORY_ENABLED"] = "1" + # Grok / Grok Build need proxy upstream = xAI. Only auto-set when no other + # OpenAI-compatible tools share this proxy (those may need api.openai.com / + # Copilot). Explicit OPENAI_TARGET_API_URL in extra_env still wins below. + _openai_native = { + ToolTarget.CODEX.value, + ToolTarget.COPILOT.value, + ToolTarget.AIDER.value, + ToolTarget.OPENCODE.value, + } + _grok_targets = {ToolTarget.GROK.value, ToolTarget.GROK_BUILD.value} + target_set = set(resolved_targets) + if target_set & _grok_targets and not (target_set & _openai_native): + base_env.setdefault("OPENAI_TARGET_API_URL", _GROK_DEFAULT_API_URL) # Applied last so explicit --env overrides win over the auto-derived # defaults above (e.g. a custom HEADROOM_WORKSPACE_DIR). if extra_env: @@ -241,6 +255,9 @@ def build_manifest( proxy_args.extend(["--protect-tool-results", protect_tool_results]) if bedrock_profile: proxy_args.extend(["--bedrock-profile", bedrock_profile]) + openai_target = base_env.get("OPENAI_TARGET_API_URL") + if openai_target: + proxy_args.extend(["--openai-api-url", openai_target]) container_name = f"headroom-{normalized_profile}" return DeploymentManifest( diff --git a/headroom/providers/grok_build/runtime.py b/headroom/providers/grok_build/runtime.py index f8bc7f123..26ca11e4e 100644 --- a/headroom/providers/grok_build/runtime.py +++ b/headroom/providers/grok_build/runtime.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass +from headroom.providers.grok.runtime import DEFAULT_API_URL from headroom.proxy.project_context import with_project_prefix @@ -41,6 +42,8 @@ def render_setup_lines(port: int, project: str | None = None) -> list[str]: " [model.grok-build]", f' base_url = "{target.base_url}"', "", + f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}", + "", " Start Grok Build in this project directory:", " grok", "", diff --git a/tests/test_cli/test_wrap_bridge.py b/tests/test_cli/test_wrap_bridge.py index c6c98172d..9f79dd8a6 100644 --- a/tests/test_cli/test_wrap_bridge.py +++ b/tests/test_cli/test_wrap_bridge.py @@ -74,6 +74,40 @@ def test_wrap_grok_build_uses_actual_proxy_port(monkeypatch, tmp_path: Path) -> assert "http://127.0.0.1:8787/" not in result.output +def test_wrap_grok_build_passes_xai_openai_api_url(monkeypatch, tmp_path: Path) -> None: + """Grok Build must set proxy upstream to xAI (same as wrap grok). + + Without openai_api_url, the proxy defaults to api.openai.com and Grok + session auth returns 401 on every chat completion. + """ + from headroom.providers.grok import DEFAULT_API_URL + + _set_test_home(monkeypatch, tmp_path) + runner = CliRunner() + captured: dict = {} + + def fake_watcher(**kwargs) -> None: + captured.update(kwargs) + kwargs["print_setup_lines"](kwargs["port"]) + + monkeypatch.setattr("headroom.cli.wrap._run_proxy_only_watcher", fake_watcher) + + result = runner.invoke(main, ["wrap", "grok-build", "--port", "8787"]) + + assert result.exit_code == 0, result.output + assert captured.get("openai_api_url") == DEFAULT_API_URL + # Equality on the constant (not substring containment) keeps CodeQL + # incomplete-url-substring-sanitization quiet while pinning the host. + assert DEFAULT_API_URL == "https://api.x.ai" + expected_upstream = f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}" + upstream_lines = [ + line + for line in result.output.splitlines() + if line.startswith(" Proxy upstream (OpenAI-compatible): ") + ] + assert upstream_lines == [expected_upstream] + + def test_wrap_rejects_retired_context_tool_flag(monkeypatch, tmp_path: Path) -> None: """A surviving --context-tool must fail loudly, not be silently ignored. diff --git a/tests/test_install/test_planner.py b/tests/test_install/test_planner.py index b4389e611..30ab8a070 100644 --- a/tests/test_install/test_planner.py +++ b/tests/test_install/test_planner.py @@ -330,3 +330,38 @@ def test_build_manifest_extra_env_overrides_derived_defaults() -> None: # telemetry_enabled=False in _base_manifest_kwargs would normally set "off"; # an explicit --env must win. assert manifest.base_env["HEADROOM_TELEMETRY"] == "on" + + +def test_build_manifest_grok_build_only_sets_xai_upstream() -> None: + """Persistent install for Grok Build alone must route proxy upstream to xAI.""" + from headroom.providers.grok import DEFAULT_API_URL + + manifest = build_manifest(**_base_manifest_kwargs(targets=["grok_build"], backend="openai")) + + assert manifest.base_env.get("OPENAI_TARGET_API_URL") == DEFAULT_API_URL + idx = manifest.proxy_args.index("--openai-api-url") + assert manifest.proxy_args[idx + 1] == DEFAULT_API_URL + + +def test_build_manifest_grok_with_codex_does_not_force_xai() -> None: + """Do not override OpenAI upstream when OpenAI-native tools share the proxy.""" + manifest = build_manifest( + **_base_manifest_kwargs(targets=["grok_build", "codex"], backend="openai") + ) + + assert "OPENAI_TARGET_API_URL" not in manifest.base_env + assert "--openai-api-url" not in manifest.proxy_args + + +def test_build_manifest_extra_env_wins_over_grok_xai_default() -> None: + manifest = build_manifest( + **_base_manifest_kwargs( + targets=["grok_build"], + backend="openai", + extra_env={"OPENAI_TARGET_API_URL": "https://gateway.example/v1"}, + ) + ) + + assert manifest.base_env["OPENAI_TARGET_API_URL"] == "https://gateway.example/v1" + idx = manifest.proxy_args.index("--openai-api-url") + assert manifest.proxy_args[idx + 1] == "https://gateway.example/v1" diff --git a/tests/test_provider_grok_build.py b/tests/test_provider_grok_build.py index 43ae61e7e..e0918f792 100644 --- a/tests/test_provider_grok_build.py +++ b/tests/test_provider_grok_build.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from headroom.providers.grok import DEFAULT_API_URL from headroom.providers.grok_build import build_proxy_targets, render_setup_lines from headroom.providers.grok_build.config import ( inject_grok_provider_config, @@ -46,6 +47,13 @@ def test_grok_build_setup_lines_include_proxy_url() -> None: assert "http://127.0.0.1:8787/v1" in joined assert "[model.grok-build]" in joined + # Exact labeled line equality (not bare host substring containment) so + # CodeQL does not flag incomplete URL substring sanitization. + expected_upstream = f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}" + upstream_lines = [ + line for line in lines if line.startswith(" Proxy upstream (OpenAI-compatible): ") + ] + assert upstream_lines == [expected_upstream] def test_grok_build_build_install_env_returns_proxy_url() -> None: