mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
e302063540
commit
4ef889077d
3 changed files with 105 additions and 2 deletions
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue