fix(wrap/codex): export the detected custom upstream base URL (#2125)

## Description

`headroom wrap codex` detects a user's custom upstream gateway but never
tells Codex to use it, so the user's gateway key is sent to
`api.openai.com`.

`_inject_codex_provider_config` handles a Codex user who has an
OpenAI-compatible gateway declared in `~/.codex/config.toml`, e.g.

```toml
model_provider = "freemodel"
[model_providers.freemodel]
base_url = "https://api.freemodel.dev"
```

It injects the Headroom provider with `env_http_headers = { ...
"X-Headroom-Base-Url" = "HEADROOM_CODEX_UPSTREAM_BASE_URL" }` and
**returns the preserved upstream URL** so the caller can export it. Its
docstring even says: *"Callers that go on to launch Codex should export
this value into `HEADROOM_CODEX_UPSTREAM_BASE_URL`."*

But `_prepare_codex_wrap_state` called it as a bare statement and
discarded the return, and `_run_codex_wrap` / `_build_codex_launch_env`
only ever set `OPENAI_BASE_URL`. A repo-wide grep confirms
`HEADROOM_CODEX_UPSTREAM_BASE_URL` (`_UPSTREAM_BASE_URL_ENV_VAR`) is
never assigned into any process env — it appears only at its definition
and in that docstring. Since Codex only emits the `X-Headroom-Base-Url`
header when the env var exists, the header is omitted, the proxy's
OpenAI handler falls back to its hardcoded `https://api.openai.com`, and
the user's `freemodel.dev` key is sent to OpenAI, which rejects it.

This is a regression: the wiring existed in the original `#1614` fix
(`_codex_custom_upstream = _inject_codex_provider_config(...)` then
`env[_UPSTREAM_BASE_URL_ENV_VAR] = ...`) and was dropped by a later
refactor that extracted `_prepare_codex_wrap_state`.

## Fix

Restore the wiring: `_prepare_codex_wrap_state` now captures and returns
`_inject_codex_provider_config`'s value, and `_run_codex_wrap` exports
it into the launch env (`env[_UPSTREAM_BASE_URL_ENV_VAR] =
custom_upstream`) when it is non-None and not already set, so a
user-provided value still wins.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/wrap.py`: `_prepare_codex_wrap_state` returns the
detected custom upstream URL; `_run_codex_wrap` exports it into the
launch env (and its display list) when set.
- `tests/test_cli/test_wrap_codex.py`: add
`TestCodexLaunchExportsCustomUpstream` — drives `_run_codex_wrap` with
mocked prepare/launch and asserts the launch env carries
`HEADROOM_CODEX_UPSTREAM_BASE_URL` when a custom upstream is detected,
and does not when there isn't one.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [x] Unit tests pass (`uv run --extra dev pytest
tests/test_cli/test_wrap_codex.py::TestCodexLaunchExportsCustomUpstream
-q`)
- [x] Linting passes (`uvx ruff@0.15.17 check headroom/cli/wrap.py
tests/test_cli/test_wrap_codex.py headroom/memory/factory.py`)
- [x] Type checking passes (`uvx mypy==1.20.2
headroom/memory/factory.py`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py
All checks passed!
$ python -m py_compile headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and running the CLI
test locally OOM-kills this box, so I verified the wiring with a
dependency-free script that models prepare -> run -> the proxy's
upstream fallback, and left the full pytest (including the new CLI test)
to CI.
- Exact command / steps: modelled the old flow (inject return discarded)
and the new flow (return exported into the launch env), then applied the
proxy's rule that a missing `HEADROOM_CODEX_UPSTREAM_BASE_URL` falls
back to `api.openai.com`.
- Observed result: old effective upstream is `https://api.openai.com`
(the gateway key is misrouted); new effective upstream is
`https://api.freemodel.dev` (the user's gateway). The new CLI test
asserts the launch env carries the var when a custom upstream is present
and omits it otherwise.
- Not tested: a live Codex process reading the env and emitting the
header; full local `pytest` deferred to CI (OOM, per above).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Merged current `main` to pick up the repository-wide mypy cache-key
annotation fix, then verified the focused regression locally. the change
threads one return value through two functions and exports it, verified
by the wiring proof and a new CLI test that drives `_run_codex_wrap`
with the heavy prepare/launch steps mocked so only the env-export logic
is exercised.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
Abhay Singh 2026-07-14 21:32:24 +05:30 committed by GitHub
parent f8eaaeb26a
commit d236b27c60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 82 additions and 4 deletions

View file

@ -1953,3 +1953,63 @@ class TestCodexPortResolution:
assert call_kw.get("port") == 8787
assert call_kw.get("no_proxy") is False
assert call_kw.get("prepare_only") is False
class TestCodexLaunchExportsCustomUpstream:
"""`_run_codex_wrap` must export the detected custom upstream base URL into
the launch env so Codex emits the ``X-Headroom-Base-Url`` header. Otherwise
the proxy falls back to api.openai.com and the user's gateway key is sent to
the wrong host (regression of #1614)."""
def _launch_env(self, monkeypatch, tmp_path, *, custom_upstream):
from contextlib import contextmanager
captured: dict = {}
monkeypatch.setattr(wrap_mod.shutil, "which", lambda name: "/usr/bin/codex")
monkeypatch.setattr(wrap_mod, "_codex_home_dir", lambda: tmp_path)
@contextmanager
def _fake_overlay():
yield tmp_path / "session"
monkeypatch.setattr(wrap_mod, "_codex_session_home_overlay", _fake_overlay)
# Stand in for the heavy prepare step; only its return value matters here.
monkeypatch.setattr(wrap_mod, "_prepare_codex_wrap_state", lambda **kwargs: custom_upstream)
def _fake_launch(*, env, **kwargs):
captured["env"] = env
monkeypatch.setattr(wrap_mod, "_launch_tool", _fake_launch)
wrap_mod._run_codex_wrap(
port=8787,
no_rtk=True,
no_mcp=True,
no_tokensave=True,
serena=False,
no_serena=True,
code_graph=False,
no_proxy=True,
learn=False,
memory=False,
backend=None,
anyllm_provider=None,
region=None,
verbose=False,
prepare_only=False,
codex_args=(),
)
return captured["env"]
def test_custom_upstream_exported_into_launch_env(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
env = self._launch_env(monkeypatch, tmp_path, custom_upstream="https://api.freemodel.dev")
assert env[wrap_mod._UPSTREAM_BASE_URL_ENV_VAR] == "https://api.freemodel.dev"
def test_no_custom_upstream_leaves_env_var_unset(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
env = self._launch_env(monkeypatch, tmp_path, custom_upstream=None)
assert wrap_mod._UPSTREAM_BASE_URL_ENV_VAR not in env