fix(cli): route litellm-proxy codex through the proxy via a custom provider

Codex ignores OPENAI_BASE_URL (it always dials api.openai.com over the
Responses WebSocket transport), so the OpenAI env profile alone left
`litellm-proxy codex` talking to OpenAI directly instead of the proxy. Point
Codex at the proxy with a custom provider passed as `-c` config overrides, and
force the HTTP/SSE Responses transport with supports_websockets=false since the
proxy does not speak the Responses WebSocket protocol. The provider reads its
key from OPENAI_API_KEY, which the agent env already exports.

The overrides are injected ahead of the user's args so they precede Codex's
subcommand. Claude Code and OpenCode are unaffected; they honor the exported
env vars. Adds regression tests for the per-agent launch args and the
injection ordering.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-06 21:32:23 +00:00
parent e302063540
commit 4ef889077d
No known key found for this signature in database
3 changed files with 105 additions and 2 deletions

View file

@ -457,7 +457,7 @@ litellm-proxy codex exec "summarize the repo"
Each command resolves your LiteLLM key (logging in via SSO when none is stored and you are at a terminal; otherwise it expects `LITELLM_PROXY_API_KEY` or `--api-key`), checks the key against the proxy so bad credentials fail immediately instead of deep inside the agent, exports the environment variables the agent reads, then replaces itself with the agent process.
The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`.
The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol).
Options (these belong to the wrapper, so put them before the agent's own flags):

View file

@ -29,6 +29,8 @@ _INSTALL_DOCS: Dict[str, str] = {
"opencode": "https://opencode.ai/docs",
}
CODEX_PROXY_PROVIDER = "litellm"
class AgentRunError(Exception):
"""Raised for any user-actionable failure while preparing to run an agent."""
@ -71,6 +73,48 @@ def build_agent_env(
return env
def _codex_proxy_args(base_url: str) -> List[str]:
"""Codex `-c` overrides that point it at the proxy.
Codex ignores OPENAI_BASE_URL (it always dials api.openai.com), so the env
profile alone cannot route it. It does honor a custom provider, so define one
inline; supports_websockets=false forces the HTTP/SSE Responses transport
because the proxy does not speak the Responses WebSocket protocol. The key is
read from OPENAI_API_KEY, which build_agent_env already exports.
"""
root = base_url.rstrip("/") + "/v1"
provider = f"model_providers.{CODEX_PROXY_PROVIDER}"
return [
"-c",
f'model_provider="{CODEX_PROXY_PROVIDER}"',
"-c",
f'{provider}.name="LiteLLM proxy"',
"-c",
f'{provider}.base_url="{root}"',
"-c",
f'{provider}.env_key="{OPENAI_API_KEY_ENV}"',
"-c",
f'{provider}.wire_api="responses"',
"-c",
f"{provider}.supports_websockets=false",
]
_PROXY_ARGS: Dict[str, Callable[[str], List[str]]] = {
"codex": _codex_proxy_args,
}
def agent_launch_args(command: str, base_url: str) -> List[str]:
"""Extra CLI args an agent needs to actually honor the proxy.
Claude Code and OpenCode respect the exported env vars, so they get nothing
here; Codex needs its provider pointed via config overrides.
"""
builder = _PROXY_ARGS.get(os.path.basename(command))
return builder(base_url) if builder else []
def verify_proxy_key(
base_url: str,
api_key: str,
@ -136,7 +180,8 @@ def run_agent(
api_key,
profiles,
)
launcher(binary, command, env)
extra_args = agent_launch_args(command[0], base_url)
launcher(binary, [command[0], *extra_args, *command[1:]], env)
def _is_interactive() -> bool:
@ -218,6 +263,7 @@ __all__ = [
"agent_commands",
"run_agent",
"build_agent_env",
"agent_launch_args",
"verify_proxy_key",
"agent_profile",
"AgentRunError",

View file

@ -15,6 +15,7 @@ sys.path.insert(
from litellm.proxy.client.cli.commands.agents import (
AgentRunError,
agent_commands,
agent_launch_args,
agent_profile,
build_agent_env,
run_agent,
@ -99,6 +100,30 @@ class TestBuildAgentEnv:
assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
class TestAgentLaunchArgs:
def test_claude_and_opencode_get_no_extra_args(self):
assert agent_launch_args("claude", "http://localhost:4000") == []
assert agent_launch_args("opencode", "http://localhost:4000") == []
def test_unknown_agent_gets_no_extra_args(self):
assert agent_launch_args("mytool", "http://localhost:4000") == []
def test_codex_points_provider_at_proxy_over_http(self):
args = agent_launch_args("codex", "http://localhost:4000/")
joined = " ".join(args)
assert 'model_provider="litellm"' in args
assert 'model_providers.litellm.base_url="http://localhost:4000/v1"' in args
assert 'model_providers.litellm.env_key="OPENAI_API_KEY"' in args
assert 'model_providers.litellm.wire_api="responses"' in args
assert "model_providers.litellm.supports_websockets=false" in args
assert joined.count("-c") == 6
def test_codex_uses_basename(self):
assert agent_launch_args("/usr/local/bin/codex", "http://localhost:4000") == (
agent_launch_args("codex", "http://localhost:4000")
)
class TestVerifyProxyKey:
def test_ok_status_passes_and_uses_models_endpoint(self):
captured = {}
@ -179,6 +204,38 @@ class TestRunAgent:
assert calls["env"]["OPENAI_API_KEY"] == "sk-key"
assert "ANTHROPIC_BASE_URL" not in calls["env"]
def test_codex_injects_proxy_provider_args_before_user_args(self):
calls = {}
run_agent(
"http://localhost:4000",
"sk-key",
["codex", "exec", "do a thing"],
base_env={},
which=lambda name: "/usr/local/bin/codex",
verify=lambda *a: None,
launcher=lambda p, a, e: calls.update(args=tuple(a)),
)
args = calls["args"]
assert args[0] == "codex"
assert args[-2:] == ("exec", "do a thing")
assert 'model_provider="litellm"' in args
assert 'model_providers.litellm.base_url="http://localhost:4000/v1"' in args
# overrides must precede the codex subcommand so codex parses them
assert args.index('model_provider="litellm"') < args.index("exec")
def test_claude_launches_without_injected_args(self):
calls = {}
run_agent(
"http://localhost:4000",
"sk-key",
["claude", "--resume"],
base_env={},
which=lambda name: "/usr/local/bin/claude",
verify=lambda *a: None,
launcher=lambda p, a, e: calls.update(args=tuple(a)),
)
assert calls["args"] == ("claude", "--resume")
def test_missing_binary_raises_with_install_hint(self):
with pytest.raises(AgentRunError, match="claude.*Install it first"):
run_agent(