fix(grok): route CLI traffic to api.x.ai on shared proxies - #2693
fix(grok): route CLI traffic to api.x.ai on shared proxies#2693p-clements wants to merge 8 commits into
Conversation
When Claude/Codex already own the default Headroom proxy, wrap grok reuses it without restarting, so process OPENAI_API_URL stays api.openai.com. Grok CLI cannot stamp x-headroom-base-url; recognize official wire signals (x-xai-token-auth, grok-shell/grok-pager UA) and send chat/models/responses to api.x.ai so session tokens are not rejected by OpenAI.
PR governanceThis PR follows the template and is marked ready for human review. |
Document the mid-handler rebind that previously discarded Grok→xAI routing, assert the broken formula still fails, and require the full resolver for the final base. Soften UA detector docstring (markers, not prefixes).
Three review findings on the shared-proxy Grok routing: 1. Lock the call sites for real. The previous regression tests computed the broken `custom_only or OPENAI_API_URL` formula in their own body and compared it to the resolver, so both chat and responses call sites could be reverted with the suite still green. Replaced with tests that drive `handle_openai_chat` / `handle_openai_responses` against the existing `_DummyOpenAIHandler` harness and assert on the outbound URL. 2. Never bypass a configured gateway. Grok wire signals previously outranked an operator's `--openai-api-url`, so any Grok-branded client skipped a corporate LiteLLM/Azure endpoint and carried its `OPENAI_TARGET_API_HEADERS` to xAI. Routing now applies only while the OpenAI target is still the default, via a shared `route_grok_to_xai()` predicate used by both detection sites. 3. Narrow UA detection. The bare `grok/` marker was unverified and matched any substring, so `litellm-grok/1.0` would have had its OpenAI credentials redirected. Matching is now prefix-on-whitespace-split-token against the observed `grok-pager/` and `grok-shell/` products. Also renames the shadowed `upstream_base_url` in `handle_openai_chat` to `custom_upstream_base_url`, matching `handle_openai_responses`, so the footgun is removed rather than worked around at the call site.
|
One possible follow-up: if Headroom ends up supporting more black-box CLIs that cannot send x-headroom-base-url, this pattern could be generalised into a small client-fingerprint registry. I kept this PR Grok-specific to avoid scope creep and to make the fix easier to review/revert. The important constraint for any future generalisation is that black-box clients should never need to know about or send x-headroom-base-url. |
JerrettDavis
left a comment
There was a problem hiding this comment.
The per-request routing fix is well targeted, but it currently crosses an operator credential boundary and needs one change before merge.
Both HTTP handlers build the outbound header set and call merge_extra_headers(headers, self.config.openai_extra_headers) before _resolve_openai_upstream() redirects a Grok-fingerprinted request to https://api.x.ai (chat around 2811-2812/4018; Responses around 4634-4635/4907). Those extras are explicitly OpenAI-target/gateway headers and can include Authorization or other secrets. Because the Grok signals are ordinary client-controlled headers/UA tokens, a request that selects xAI can cause operator-owned OpenAI credentials to be sent to another vendor. This also contradicts the PR's stated invariant that target headers must not cross vendors.
Please make header selection destination-aware: do not merge openai_extra_headers when the resolved destination is xAI (or introduce an xAI-specific header source), for chat, Responses, models, and passthrough as applicable. Add call-site regressions that configure a sentinel OpenAI extra header, send the Grok fingerprint, and prove the outbound xAI request does not contain it while ordinary OpenAI/default and operator-gateway routing still do.
The routing priority/detection behavior otherwise checks out. I ran the 48 focused tests listed in the PR plus Ruff successfully on 13610788; the missing case is credential isolation, not routing functionality. The branch merges cleanly with current main; CI has not been authorized for this fork and I am not asking the contributor to trigger it.
Grok-fingerprinted HTTP requests can resolve to api.x.ai after openai_extra_headers have been configured for OpenAI or a gateway. Because configured extras override client headers, Authorization could both cross the vendor boundary and replace the client's xAI token. Select extras from the resolved OpenAI-compatible upstream in the direct chat and Responses handlers. Suppress them for the api.x.ai hostname, including custom base paths, ports, and trailing-dot variants; preserve ordinary OpenAI, configured gateway, and configured backend behavior. Reuse the resolved candidate for direct URL construction so routing and header policy stay aligned. Keep WebSocket behavior unchanged: it does not use the new Grok fingerprint resolver, and its broader pre-existing API-key/header policy belongs in a separate fix. Add call-site regressions covering negative and positive behavior for both HTTP handlers, custom bases, configured xAI, mixed ChatGPT/Grok signals, and configured backends.
|
@p-clements is attempting to deploy a commit to the Headroom Labs Team on Vercel. A member of the Team first needs to authorize it. |
|
Thanks — you're right. I missed that configured OpenAI extras were merged before destination resolution, so a Grok-fingerprinted request could send On the direct HTTP path, header selection now uses the resolved OpenAI-compatible upstream host: Added call-site regressions with sentinel extras for both HTTP handlers, covering xAI isolation, ordinary OpenAI, operator gateways, client-selected xAI, and ChatGPT priority. Ready for re-review. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Re-reviewed current head b234e24 after the credential-isolation changes. Direct chat and Responses paths now resolve the destination before selecting operator extras, withhold OpenAI-target headers for the official xAI hostname (including ports, paths, and FQDN dots), preserve the client xAI credential, and retain extras for ordinary OpenAI, configured gateways, and configured backend transports. Routing and header policy reuse the same resolved value, eliminating the previous cross-vendor leak. The focused matrix also covers ChatGPT-auth priority and custom bases. CI has not been authorized beyond policy checks; per project policy I am not asking the contributor to trigger it. No remaining source blocker.
|
Anything else I can offer with on this? |
# Conflicts: # tests/test_openai_codex_routing.py
chopratejas
left a comment
There was a problem hiding this comment.
Approved, mergeable, and no failing checks — but CI on this branch only recorded a handful of check runs, well short of the ~35 a full run produces on current main. That usually means the run predates the current workflow set or was path-gated, so "no failures" here isn't the same claim as "green".
I've held it out of today's merge batch for that reason alone, not on content. Please rebase on main (ten PRs landed today) and push so a complete run reports, and I'll merge on the strength of that.
|
Updated — branch is now current with One note on how: I merged Locally on the merged tree: 124 passed across the focused set ( On the check count, though — I think the diagnosis is slightly off, and it's not something a push can fix. The runs aren't missing or path-gated; they're queued and waiting on approval. Every The only workflow that executes is So the branch is current and the head is fresh, but a complete run won't report until someone approves the queued workflows. Happy to push again afterwards if anything needs it. |
Resolves a conflict in headroom/providers/proxy_targets.py between this branch's Grok->xAI passthrough routing and headroomlabs-ai#3077's Copilot inline-completions routing. Both intercept the same OpenAI fall-through in select_passthrough_base_url, so the resolution keeps both: - select_passthrough_base_url takes headroomlabs-ai#3077's `path` parameter. - The Copilot branch runs first. It keys on a path only Copilot emits, so it is the narrower claim on this fall-through; the Grok branch keys on wire signals and only while the OpenAI target is still the default. - route_grok_to_xai and openai_compatible_base_url are unchanged, so Grok policy stays defined in one place. 154 passed across both sides' focused suites (including headroomlabs-ai#3077's 30 Copilot tests); 667 passed across the routing/passthrough sweep. Ruff and mypy clean.
|
You're right — that appeared after my last push. Both changes intercept the same OpenAI fall-through in
The Verification on the merged tree: 154 passed across both sides' focused suites, including all 30 tests from #3077; 667 passed across the routing, passthrough, Copilot and Grok sweep; Ruff and mypy clean. I also checked the Grok branch isn't passing vacuously — deleting it fails Worth noting this file is on a busy path — that conflict opened within a day of the previous merge, so the branch will likely need this again on that cadence. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Re-reviewed the exact current head after the two main merges. The #3077 conflict resolution preserves the narrower Copilot path claim before Grok fingerprint routing, while explicit x-headroom-base-url/operator targets still outrank Grok and destination-aware header selection still prevents OpenAI/gateway extras from crossing to api.x.ai. The latest merge is otherwise source-neutral for this feature. I also added the missing factual Runtime Rollout Safety section to the PR body. No remaining code blocker; external-fork workflows remain authorization-gated rather than failing.
Description
headroom wrap grokpoints the Grok CLI at the local proxy viaGROK_MODELS_BASE_URLand, when it starts the proxy, setsopenai_api_url=https://api.x.ai. When Claude/Codex already own port 8787, wrap reuses that process without restart (other wrappers attached), so process-wideOPENAI_API_URLstayshttps://api.openai.com.Grok CLI cannot stamp
x-headroom-base-url(no custom attribution headers). Inference then goes to OpenAI with xAI session tokens and returns 401. Observed on the wire:x-xai-token-auth: xai-grok-cliuser-agent: grok-pager/... grok-shell/...POST https://api.openai.com/v1/chat/completionsstatus 401This PR adds per-request Grok CLI detection and routes chat / models / responses to
https://api.x.aiwhen those signals are present and the OpenAI target is still its default, without restarting the shared proxy or changing Claude/Codex paths. An operator-configured gateway is never bypassed.Not covered by #2626 (attribution / UA map only when custom base is already
api.x.ai).Type of Change
Changes Made
is_grok_cli_request()inproviders/grok/runtime.pyusing narrow wire signals: exactx-xai-token-auth: xai-grok-cliand thegrok-pager//grok-shell/UA product tokens, compared per whitespace-split token so wrappers likelitellm-grok/1.0cannot matchx-headroom-base-urlthen Grok CLI then processOPENAI_API_URLGET /v1/modelsand OpenAI-classified passthrough through the same Grok to xAI basetests/test_grok_shared_proxy_call_sites.py, which driveshandle_openai_chatandhandle_openai_responsesagainst a stubbed transport and asserts the outbound URL — reverting either call site to the oldcustom_only or OPENAI_API_URLformula turns these redroute_grok_to_xai()predicate used by both the chat/responses resolver and the models/passthrough path, so an operator-configured gateway is never bypassedTesting
pytest)ruff check/ruff format)Test Output
Real Behavior Proof
fix/grok-shared-proxy-xai-routing(0.33.1-dev) running as the only proxy on 8787 withopenai_api_url=nulland--backend anthropic(same shared-proxy shape as Claude/Codex-started 8787). Stock Headroom 0.33.0 was first used to capture the before failure.headroom wrap grok(or attach viaGROK_MODELS_BASE_URL) and inspect~/.headroom/logs/proxy.log. 2) After: replace the process with this branch (uv run python -m headroom.cli proxy --host 127.0.0.1 --port 8787 --backend anthropic, no OpenAI target override). 3)GROK_MODELS_BASE_URL=http://127.0.0.1:8787/v1 grok -p "Reply with exactly one word: pong" --always-approve. 4) Confirm health still showsopenai_api_url: nulland inspect proxy log outbound lines.x-xai-token-auth: xai-grok-cliforwarded tohttps://api.openai.com/v1/chat/completions→ 401. After (this branch): same Grok CLI on the same port;/v1/modelsforuser-agent: grok-shell/0.2.117returned 200; chat outbound wasPOST path=https://api.x.ai/v1/chat/completionswith status 200; CLI printedpongand exited 0. Codex models list against the same proxy still returned 200.Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR titleScreenshots (if applicable)
N/A — evidence is CLI output and proxy logs (redacted auth).
Additional Notes
provider=xaiwithout custom base (see fix(grok): attribute api.x.ai chat outcomes as provider xai, recognize grok-shell UA #2626), multi-provider matrix for Kimi/Mistral, hard-coded per-agent ports./v1/responsesbefore this chain runs.x-headroom-base-url> Grok CLI signals (only whileOPENAI_API_URLis still the default) > process OpenAI default. The gate matters because an operator who points the proxy at a gateway (LiteLLM, Azure, self-hosted vLLM) chose it for every OpenAI-compatible client: a client User-Agent must not silently bypass it, nor carry that gateway'sOPENAI_TARGET_API_HEADERSto a different vendor. The shared-proxy case this PR fixes runs withopenai_api_url: null, so it is unaffected.action_requireduntil a maintainer approves first-time external CI runs.Runtime Rollout Safety