mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
* feat(lite): offer each model group's reasoning efforts in pi, OpenCode and Codex lite pi, lite opencode and lite codex now read supported_reasoning_efforts from /model_group/info and write them into each client's model config. pi gets reasoning and a thinkingLevelMap, OpenCode gets reasoning_options plus one variant per effort, and Codex fallback entries get supported_reasoning_levels with a default level. The proxy-served Codex catalog on /v1/models?client_version= offers the same levels Codex before 0.138.0 fails the whole catalog on an effort it cannot name, so those releases, and an unreadable version, get only the six efforts every release parses. A group with unknown efforts keeps today's output, and a group with none is written as non-reasoning The strength order and nearest_declared_reasoning_effort move to an SDK-free module so the lite CLI, which loads without initializing the SDK, can import the Codex catalog * fix(lite): describe team models by their public name and keep Codex's none default /model_group/info now looks up a team's own deployments when the caller's team serves the requested name, so a team model reports its reasoning levels. Codex fallback entries default to none when the group lists it, and the proxy builds the Codex catalog in a worker thread * fix(lite): round Codex's default level down and describe a team's shared-name model from its own deployments Move the reasoning strength helper to litellm_core_utils so lite and the Codex catalog import it without the SDK or the router, and let make lint-format-check-changed diff the working tree so a staged rename passes. * fix(lite): keep pi's system role on reasoning model groups pi switches its system prompt to the developer role for any model marked reasoning, so an operator's cache_control_injection_points entry for role system stopped matching on Claude groups. lite pi now sets compat.supportsDeveloperRole false on those entries, so pi sends system as it did before. * fix(codex): treat a client_version too long to parse as an old Codex and keep undescribed stock levels A client_version past Python's integer digit limit made the Codex catalog answer 500. It now gets the levels every Codex parses. A stock Codex level with no description validates again, as on base, and lite codex treats undecodable codex --version output as an unknown version * fix(lite): keep REASONING_EFFORT_STRENGTH_ORDER importable from router_utils and skip the Codex sync on undecodable debug models output --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2737 lines
111 KiB
Python
2737 lines
111 KiB
Python
import functools
|
|
import inspect
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import Final
|
|
from unittest.mock import patch
|
|
|
|
import click
|
|
import pytest
|
|
import requests
|
|
from click.testing import CliRunner
|
|
|
|
from litellm.proxy.client.cli.commands.agents import (
|
|
AgentRunError,
|
|
ModelSyncArgs,
|
|
ModelSyncSkipped,
|
|
_hand_off,
|
|
_replace_process,
|
|
_spawn_and_wait,
|
|
agent_commands,
|
|
agent_launch_args,
|
|
agent_model_sync_env,
|
|
agent_profile,
|
|
build_agent_env,
|
|
codex_model_sync_args,
|
|
opencode_model_sync_env,
|
|
run_agent,
|
|
verify_proxy_key,
|
|
verify_subscription_key,
|
|
)
|
|
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
|
|
from litellm.proxy.client.cli.commands.config import save_config
|
|
from litellm.proxy.client.cli.main import cli as lite
|
|
|
|
if sys.version_info >= (3, 11):
|
|
import tomllib
|
|
else:
|
|
import tomli as tomllib
|
|
|
|
AGENTS_MODULE = "litellm.proxy.client.cli.commands.agents"
|
|
CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings"
|
|
|
|
|
|
def _agent_command(name):
|
|
return next(c for c in agent_commands() if c.name == name)
|
|
|
|
|
|
def _default_of(func, param):
|
|
return inspect.signature(func).parameters[param].default
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code, body=None):
|
|
self.status_code = status_code
|
|
self.content = json.dumps(body).encode() if body is not None else b""
|
|
|
|
def json(self):
|
|
return json.loads(self.content)
|
|
|
|
|
|
class _Recorder:
|
|
def __init__(self, returns=None):
|
|
self.returns = returns
|
|
self.calls = []
|
|
|
|
def __call__(self, *args):
|
|
self.calls.append(args)
|
|
return self.returns
|
|
|
|
|
|
_STOCK_REASONING_LEVELS = [
|
|
{"effort": "low", "description": "Fast responses with lighter reasoning"},
|
|
{"effort": "medium", "description": "Balances speed and reasoning depth for everyday tasks"},
|
|
{"effort": "high", "description": "Greater reasoning depth for complex problems"},
|
|
]
|
|
|
|
_STOCK_MODELS = {
|
|
"gpt-5.6-terra": {
|
|
"slug": "gpt-5.6-terra",
|
|
"display_name": "GPT-5.6 Terra",
|
|
"description": "Balanced agentic coding model for everyday work.",
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": _STOCK_REASONING_LEVELS,
|
|
"shell_type": "unified_exec",
|
|
"visibility": "list",
|
|
"supported_in_api": True,
|
|
"priority": 7,
|
|
"availability_nux": None,
|
|
"upgrade": None,
|
|
"base_instructions": "You are Codex, a coding agent based on GPT-5.6.",
|
|
"apply_patch_tool_type": "freeform",
|
|
"supports_parallel_tool_calls": True,
|
|
"supports_search_tool": True,
|
|
"tool_mode": "code_mode_only",
|
|
"context_window": 272000,
|
|
"comp_hash": "terra-hash",
|
|
},
|
|
"gpt-5.5": {
|
|
"slug": "gpt-5.5",
|
|
"display_name": "GPT-5.5",
|
|
"description": "Frontier model for complex coding, research, and real-world work.",
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": _STOCK_REASONING_LEVELS,
|
|
"shell_type": "unified_exec",
|
|
"visibility": "list",
|
|
"supported_in_api": True,
|
|
"priority": 12,
|
|
"availability_nux": None,
|
|
"upgrade": None,
|
|
"base_instructions": "You are Codex, a coding agent based on GPT-5.",
|
|
"apply_patch_tool_type": "freeform",
|
|
"supports_parallel_tool_calls": True,
|
|
"context_window": 272000,
|
|
"comp_hash": "gpt-5.5-hash",
|
|
},
|
|
"gpt-5.4": {
|
|
"slug": "gpt-5.4",
|
|
"display_name": "GPT-5.4",
|
|
"description": "Strong model for everyday coding.",
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": _STOCK_REASONING_LEVELS,
|
|
"shell_type": "unified_exec",
|
|
"visibility": "hide",
|
|
"supported_in_api": True,
|
|
"priority": 16,
|
|
"availability_nux": None,
|
|
"upgrade": {
|
|
"model": "gpt-5.6-terra",
|
|
"migration_markdown": "GPT-5.4 is no longer available. Switch to GPT-5.6 Terra to continue.",
|
|
"retirement_at": "2026-08-31T19:00:00Z",
|
|
},
|
|
"base_instructions": "You are Codex, a coding agent based on GPT-5.",
|
|
"apply_patch_tool_type": "freeform",
|
|
"supports_parallel_tool_calls": True,
|
|
"context_window": 272000,
|
|
"comp_hash": "gpt-5.4-hash",
|
|
},
|
|
"codex-auto-review": {
|
|
"slug": "codex-auto-review",
|
|
"display_name": "Codex Auto Review",
|
|
"description": None,
|
|
"supported_reasoning_levels": [],
|
|
"shell_type": "unified_exec",
|
|
"visibility": "hide",
|
|
"supported_in_api": False,
|
|
"priority": 43,
|
|
"availability_nux": None,
|
|
"upgrade": None,
|
|
"base_instructions": "You are Codex, reviewing a change.",
|
|
"apply_patch_tool_type": None,
|
|
"supports_parallel_tool_calls": True,
|
|
"context_window": 272000,
|
|
"comp_hash": "review-hash",
|
|
},
|
|
}
|
|
|
|
_STOCK_CATALOG = json.dumps({"models": list(_STOCK_MODELS.values())})
|
|
|
|
|
|
class _FakeRun:
|
|
"""A `codex` that prints `stock` from a bare `debug models`, `version` from `--version`, and answers a
|
|
catalog override with `returncode`.
|
|
|
|
`stock=None` is a Codex with no `debug models` at all: every call answers with `returncode` and `stderr`.
|
|
`version=None` is a `--version` that fails, an exception one that cannot run.
|
|
"""
|
|
|
|
def __init__(self, returncode=0, stderr="", stock=_STOCK_CATALOG, version="codex-cli 0.165.0"):
|
|
self.returncode = returncode
|
|
self.stderr = stderr
|
|
self.stock = stock
|
|
self.version = version
|
|
self.calls = []
|
|
|
|
def __call__(self, args, **kwargs):
|
|
self.calls.append((args, kwargs))
|
|
if self.stock is not None and "--version" in str(args):
|
|
if isinstance(self.version, Exception):
|
|
raise self.version
|
|
if self.version is None:
|
|
return subprocess.CompletedProcess(args, 2, "", "error: unexpected argument '--version'")
|
|
return subprocess.CompletedProcess(args, 0, f"{self.version}\n", "")
|
|
if self.stock is not None and "model_catalog_json=" not in str(args):
|
|
return subprocess.CompletedProcess(args, 0, self.stock, "")
|
|
return subprocess.CompletedProcess(args, self.returncode, "", self.stderr)
|
|
|
|
|
|
def _routed_get(captured, listing, groups, model_info=None):
|
|
"""A proxy answering /model_group/info with `groups` (or `groups` as the status it fails with),
|
|
/v1/model/info with `model_info` (raised when it is an exception) and any other URL with `listing`."""
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured.setdefault("urls", []).append(url)
|
|
captured["headers"] = headers
|
|
if url.endswith("/v1/model/info"):
|
|
if isinstance(model_info, Exception):
|
|
raise model_info
|
|
return model_info if model_info is not None else _FakeResponse(200, {"data": []})
|
|
if not url.endswith("/model_group/info"):
|
|
return _FakeResponse(200, listing)
|
|
return _FakeResponse(groups) if isinstance(groups, int) else _FakeResponse(200, {"data": list(groups)})
|
|
|
|
return fake_get
|
|
|
|
|
|
class _FakeJsonResponse:
|
|
def __init__(self, status_code, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
def _overlay_writer(tmp_path):
|
|
def write(text, environ):
|
|
target = tmp_path / "overlay.json"
|
|
target.write_text(text)
|
|
return target
|
|
|
|
return write
|
|
|
|
|
|
def _overlay(args):
|
|
return json.loads(Path(args[2]).read_text())
|
|
|
|
|
|
def _codex_config(args):
|
|
assert set(args[0::2]) == {"-c"}
|
|
return tomllib.loads("\n".join(args[1::2]))
|
|
|
|
|
|
def _set_codex_key(config: Mapping[str, object], keys: Sequence[str], value: object) -> Mapping[str, object]:
|
|
head, *rest = keys
|
|
if not rest:
|
|
return {**config, head: value}
|
|
nested: Final = config.get(head, {})
|
|
assert isinstance(nested, Mapping)
|
|
return {**config, head: _set_codex_key(nested, rest, value)}
|
|
|
|
|
|
def _with_codex_override(config: Mapping[str, object], override: str) -> Mapping[str, object]:
|
|
path, value = override.split("=", 1)
|
|
return _set_codex_key(config, path.split("."), tomllib.loads(f"value = {value}")["value"])
|
|
|
|
|
|
def _codex_config_over(user_config_toml: str, args: Sequence[str]) -> Mapping[str, object]:
|
|
assert set(args[0::2]) == {"-c"}
|
|
return functools.reduce(_with_codex_override, args[1::2], tomllib.loads(user_config_toml))
|
|
|
|
|
|
class TestAgentProfile:
|
|
def test_claude_is_anthropic(self):
|
|
name, profiles = agent_profile("claude")
|
|
assert name == "Claude Code"
|
|
assert profiles == frozenset({"anthropic"})
|
|
|
|
def test_claude_full_path_uses_basename(self):
|
|
name, profiles = agent_profile("/usr/local/bin/claude")
|
|
assert name == "Claude Code"
|
|
assert profiles == frozenset({"anthropic"})
|
|
|
|
def test_codex_and_opencode_are_openai(self):
|
|
assert agent_profile("codex") == ("Codex", frozenset({"openai"}))
|
|
assert agent_profile("opencode") == ("OpenCode", frozenset({"openai"}))
|
|
|
|
def test_pi_is_litellm(self):
|
|
assert agent_profile("pi") == ("pi", frozenset({"litellm"}))
|
|
|
|
def test_unknown_command_gets_both_profiles(self):
|
|
name, profiles = agent_profile("mytool")
|
|
assert name == "mytool"
|
|
assert profiles == frozenset({"anthropic", "openai"})
|
|
|
|
|
|
class TestBuildAgentEnv:
|
|
def test_anthropic_profile_uses_bare_root_and_bearer(self):
|
|
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"anthropic"}))
|
|
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key"
|
|
assert env["ENABLE_TOOL_SEARCH"] == "true"
|
|
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
|
assert "OPENAI_BASE_URL" not in env
|
|
assert "OPENAI_API_KEY" not in env
|
|
|
|
def test_anthropic_profile_preserves_existing_gateway_model_discovery(self):
|
|
env = build_agent_env(
|
|
{"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "0"},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
frozenset({"anthropic"}),
|
|
)
|
|
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "0"
|
|
|
|
def test_anthropic_profile_preserves_existing_tool_search(self):
|
|
env = build_agent_env(
|
|
{"ENABLE_TOOL_SEARCH": "false"},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
frozenset({"anthropic"}),
|
|
)
|
|
assert env["ENABLE_TOOL_SEARCH"] == "false"
|
|
|
|
def test_anthropic_profile_drops_existing_api_key(self):
|
|
env = build_agent_env(
|
|
{"ANTHROPIC_API_KEY": "real-key"},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
frozenset({"anthropic"}),
|
|
)
|
|
assert "ANTHROPIC_API_KEY" not in env
|
|
|
|
def test_openai_profile_appends_v1(self):
|
|
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"openai"}))
|
|
assert env["OPENAI_BASE_URL"] == "http://localhost:4000/v1"
|
|
assert env["OPENAI_API_KEY"] == "sk-key"
|
|
assert "LITELLM_PROXY_API_KEY" not in env
|
|
assert "ANTHROPIC_BASE_URL" not in env
|
|
assert "ENABLE_TOOL_SEARCH" not in env
|
|
assert "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY" not in env
|
|
|
|
def test_both_profiles_set_everything(self):
|
|
env = build_agent_env({}, "http://localhost:4000", "sk-key", frozenset({"anthropic", "openai"}))
|
|
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
|
assert env["OPENAI_BASE_URL"] == "http://localhost:4000/v1"
|
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key"
|
|
assert env["OPENAI_API_KEY"] == "sk-key"
|
|
assert env["ENABLE_TOOL_SEARCH"] == "true"
|
|
|
|
def test_litellm_profile_exports_only_the_proxy_key(self):
|
|
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"litellm"}))
|
|
assert env["LITELLM_PROXY_API_KEY"] == "sk-key"
|
|
assert "ANTHROPIC_BASE_URL" not in env
|
|
assert "OPENAI_BASE_URL" not in env
|
|
assert "OPENAI_API_KEY" not in env
|
|
|
|
def test_preserves_unrelated_env_and_does_not_mutate_input(self):
|
|
base = {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
|
|
env = build_agent_env(base, "http://localhost:4000", "sk-key", frozenset({"anthropic"}))
|
|
assert env["PATH"] == "/usr/bin"
|
|
assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
|
|
|
|
def test_subscription_sends_the_key_in_a_header_and_sets_no_credential(self):
|
|
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"anthropic"}), subscription=True)
|
|
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
|
assert env["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
|
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
|
assert "ANTHROPIC_API_KEY" not in env
|
|
|
|
def test_subscription_clears_inherited_credentials(self):
|
|
base = {"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"}
|
|
env = build_agent_env(base, "http://localhost:4000", "sk-key", frozenset({"anthropic"}), subscription=True)
|
|
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
|
assert "ANTHROPIC_API_KEY" not in env
|
|
assert base == {"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"}
|
|
|
|
def test_subscription_keeps_the_users_other_custom_headers(self):
|
|
env = build_agent_env(
|
|
{"ANTHROPIC_CUSTOM_HEADERS": "x-team: blue\n\nx-trace: 1\n"},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
frozenset({"anthropic"}),
|
|
subscription=True,
|
|
)
|
|
assert env["ANTHROPIC_CUSTOM_HEADERS"].splitlines() == [
|
|
"x-team: blue",
|
|
"x-trace: 1",
|
|
"x-litellm-api-key: Bearer sk-key",
|
|
]
|
|
|
|
def test_subscription_replaces_a_stale_litellm_key_header_whatever_its_case(self):
|
|
env = build_agent_env(
|
|
{"ANTHROPIC_CUSTOM_HEADERS": "X-LiteLLM-Api-Key: Bearer old\nx-team: blue"},
|
|
"http://localhost:4000",
|
|
"sk-new",
|
|
frozenset({"anthropic"}),
|
|
subscription=True,
|
|
)
|
|
assert env["ANTHROPIC_CUSTOM_HEADERS"].splitlines() == ["x-team: blue", "x-litellm-api-key: Bearer sk-new"]
|
|
|
|
def test_subscription_routes_openai_agents_without_offering_the_key_as_an_openai_credential(self):
|
|
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"openai"}), subscription=True)
|
|
assert "OPENAI_BASE_URL" not in env
|
|
assert env["LITELLM_PROXY_API_KEY"] == "sk-key"
|
|
assert "OPENAI_API_KEY" not in env
|
|
assert "ANTHROPIC_CUSTOM_HEADERS" not in env
|
|
|
|
def test_subscription_leaves_the_users_own_openai_key_and_base_url_as_they_were(self):
|
|
base = {"OPENAI_API_KEY": "sk-users-openai", "OPENAI_BASE_URL": "https://api.openai.com/v1"}
|
|
env = build_agent_env(base, "http://localhost:4000", "sk-key", frozenset({"openai"}), subscription=True)
|
|
assert env["OPENAI_API_KEY"] == "sk-users-openai"
|
|
assert env["OPENAI_BASE_URL"] == "https://api.openai.com/v1"
|
|
assert env["LITELLM_PROXY_API_KEY"] == "sk-key"
|
|
assert base == {"OPENAI_API_KEY": "sk-users-openai", "OPENAI_BASE_URL": "https://api.openai.com/v1"}
|
|
|
|
def test_default_mode_does_not_touch_custom_headers(self):
|
|
env = build_agent_env(
|
|
{"ANTHROPIC_CUSTOM_HEADERS": "x-team: blue"}, "http://localhost:4000", "sk-key", frozenset({"anthropic"})
|
|
)
|
|
assert env["ANTHROPIC_CUSTOM_HEADERS"] == "x-team: blue"
|
|
|
|
|
|
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 "model_providers.litellm.requires_openai_auth=false" in args
|
|
assert "model_providers.litellm.http_headers={}" in args
|
|
assert joined.count("-c") == 8
|
|
|
|
def test_codex_uses_basename(self):
|
|
assert agent_launch_args("/usr/local/bin/codex", "http://localhost:4000") == (
|
|
agent_launch_args("codex", "http://localhost:4000")
|
|
)
|
|
|
|
def test_codex_default_provider_reads_the_key_from_openai_api_key(self):
|
|
assert _codex_config(agent_launch_args("codex", "http://localhost:4000/")) == {
|
|
"model_provider": "litellm",
|
|
"model_providers": {
|
|
"litellm": {
|
|
"name": "LiteLLM proxy",
|
|
"base_url": "http://localhost:4000/v1",
|
|
"wire_api": "responses",
|
|
"supports_websockets": False,
|
|
"requires_openai_auth": False,
|
|
"env_key": "OPENAI_API_KEY",
|
|
"http_headers": {},
|
|
}
|
|
},
|
|
}
|
|
|
|
def test_codex_subscription_provider_keeps_the_chatgpt_login_and_sends_the_key_in_the_litellm_header(self):
|
|
assert _codex_config(
|
|
agent_launch_args("/usr/local/bin/codex", "http://localhost:4000/", subscription=True)
|
|
) == {
|
|
"model_provider": "lite_subscription",
|
|
"model_providers": {
|
|
"lite_subscription": {
|
|
"name": "LiteLLM proxy",
|
|
"base_url": "http://localhost:4000/v1",
|
|
"wire_api": "responses",
|
|
"supports_websockets": False,
|
|
"requires_openai_auth": True,
|
|
"env_http_headers": {"x-litellm-api-key": "LITELLM_PROXY_API_KEY"},
|
|
"http_headers": {},
|
|
}
|
|
},
|
|
}
|
|
|
|
def test_codex_subscription_leaves_the_cookbook_provider_table_out_of_the_launch(self):
|
|
user_config = """
|
|
model_provider = "litellm_subscription"
|
|
|
|
[model_providers.litellm_subscription]
|
|
name = "LiteLLM subscription"
|
|
base_url = "https://litellm.example.com/v1"
|
|
wire_api = "responses"
|
|
requires_openai_auth = true
|
|
env_key = "OPENAI_API_KEY"
|
|
http_headers = { "x-team" = "qa" }
|
|
"""
|
|
config = _codex_config_over(
|
|
user_config, agent_launch_args("codex", "http://localhost:4000/", subscription=True)
|
|
)
|
|
launched = config["model_providers"][config["model_provider"]]
|
|
assert "env_key" not in launched
|
|
assert launched["requires_openai_auth"] is True
|
|
assert launched["base_url"] == "http://localhost:4000/v1"
|
|
assert config["model_providers"]["litellm_subscription"] == tomllib.loads(user_config)["model_providers"][
|
|
"litellm_subscription"
|
|
]
|
|
|
|
@pytest.mark.parametrize("agent", ["claude", "opencode", "pi", "mytool"])
|
|
def test_subscription_adds_no_args_for_agents_without_a_proxy_provider(self, agent):
|
|
assert agent_launch_args(agent, "http://localhost:4000", subscription=True) == []
|
|
|
|
def test_pi_gets_no_static_args(self):
|
|
assert agent_launch_args("pi", "http://localhost:4000") == []
|
|
|
|
|
|
class TestVerifyProxyKey:
|
|
def test_ok_status_passes_and_uses_models_endpoint(self):
|
|
captured = {}
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
return _FakeResponse(200)
|
|
|
|
verify_proxy_key("http://localhost:4000/", "sk-key", get=fake_get)
|
|
|
|
assert captured["url"] == "http://localhost:4000/v1/models"
|
|
assert captured["headers"] == {"Authorization": "Bearer sk-key"}
|
|
|
|
@pytest.mark.parametrize("status", [401, 403])
|
|
def test_rejected_key_raises(self, status):
|
|
with pytest.raises(AgentRunError, match="rejected your key"):
|
|
verify_proxy_key(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(status),
|
|
)
|
|
|
|
def test_unreachable_proxy_raises(self):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
with pytest.raises(AgentRunError, match="Could not reach"):
|
|
verify_proxy_key("http://localhost:4000", "sk-key", get=boom)
|
|
|
|
def test_other_non_2xx_is_tolerated(self):
|
|
verify_proxy_key(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(500),
|
|
)
|
|
|
|
def test_key_header_moves_the_bearer_off_authorization(self):
|
|
captured = {}
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured["headers"] = headers
|
|
return _FakeResponse(200)
|
|
|
|
verify_proxy_key("http://localhost:4000", "sk-key", get=fake_get, key_header="x-litellm-api-key")
|
|
|
|
assert captured["headers"] == {"x-litellm-api-key": "Bearer sk-key"}
|
|
|
|
def test_subscription_probe_sends_the_key_only_in_the_litellm_header(self):
|
|
captured = {}
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured["headers"] = headers
|
|
return _FakeResponse(200)
|
|
|
|
verify_subscription_key("http://localhost:4000", "sk-key", get=fake_get)
|
|
|
|
assert captured["headers"] == {"x-litellm-api-key": "Bearer sk-key"}
|
|
|
|
def test_subscription_probe_reports_a_rejected_key(self):
|
|
with pytest.raises(AgentRunError, match="rejected your key"):
|
|
verify_subscription_key("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(401))
|
|
|
|
|
|
class TestOpencodeModelSync:
|
|
@staticmethod
|
|
def _listing(*models):
|
|
return {"object": "list", "data": list(models)}
|
|
|
|
def _sync(self, listing, base_env=None, base_url="http://localhost:4000/", groups=()):
|
|
captured = {}
|
|
env = opencode_model_sync_env(base_env or {}, base_url, "sk-key", get=_routed_get(captured, listing, groups))
|
|
return captured, env
|
|
|
|
def test_declares_proxy_as_litellm_provider_with_listed_models(self):
|
|
listing = self._listing(
|
|
{"id": "gpt-5.5", "object": "model", "created": 1, "owned_by": "openai", "mode": "chat"},
|
|
{"id": "claude-opus-4-7", "object": "model", "created": 1, "owned_by": "openai"},
|
|
)
|
|
captured, env = self._sync(listing, groups=({"model_group": "gpt-5.5", "supported_reasoning_efforts": None},))
|
|
|
|
assert captured["urls"] == ["http://localhost:4000/v1/models", "http://localhost:4000/model_group/info"]
|
|
assert captured["headers"] == {"Authorization": "Bearer sk-key"}
|
|
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
|
provider = config["provider"]["litellm"]
|
|
assert provider["npm"] == "@ai-sdk/openai-compatible"
|
|
assert provider["name"] == "LiteLLM"
|
|
assert provider["options"] == {
|
|
"baseURL": "http://localhost:4000/v1",
|
|
"apiKey": "{env:OPENAI_API_KEY}",
|
|
}
|
|
assert provider["models"] == {
|
|
"gpt-5.5": {"name": "gpt-5.5"},
|
|
"claude-opus-4-7": {"name": "claude-opus-4-7"},
|
|
}
|
|
assert "sk-key" not in env["OPENCODE_CONFIG_CONTENT"]
|
|
|
|
def test_token_limits_become_opencode_limits(self):
|
|
listing = self._listing(
|
|
{
|
|
"id": "gpt-5.5",
|
|
"object": "model",
|
|
"created": 1,
|
|
"owned_by": "openai",
|
|
"max_input_tokens": 400000,
|
|
"max_output_tokens": 128000,
|
|
},
|
|
{"id": "half", "object": "model", "created": 1, "owned_by": "openai", "max_input_tokens": 8192},
|
|
)
|
|
_, env = self._sync(listing)
|
|
models = json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"]
|
|
assert models["gpt-5.5"]["limit"] == {"context": 400000, "output": 128000}
|
|
assert "limit" not in models["half"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("efforts", "reasoning"),
|
|
[
|
|
(
|
|
["low", "high", "max"],
|
|
{
|
|
"reasoning": True,
|
|
"reasoning_options": [{"type": "effort", "values": ["low", "high", "max"]}],
|
|
"variants": {
|
|
"low": {"reasoningEffort": "low"},
|
|
"high": {"reasoningEffort": "high"},
|
|
"max": {"reasoningEffort": "max"},
|
|
},
|
|
},
|
|
),
|
|
(
|
|
["none", "turbo"],
|
|
{
|
|
"reasoning": True,
|
|
"reasoning_options": [{"type": "effort", "values": ["none", "turbo"]}],
|
|
"variants": {"none": {"reasoningEffort": "none"}, "turbo": {"reasoningEffort": "turbo"}},
|
|
},
|
|
),
|
|
([], {"variants": {}}),
|
|
(None, {}),
|
|
],
|
|
ids=["declared", "unknown-names", "non-reasoning", "unknown"],
|
|
)
|
|
def test_model_group_reasoning_efforts_become_exactly_the_opencode_variants(self, efforts, reasoning):
|
|
listing = self._listing(
|
|
{
|
|
"id": "m",
|
|
"object": "model",
|
|
"created": 1,
|
|
"owned_by": "openai",
|
|
"max_input_tokens": 9,
|
|
"max_output_tokens": 3,
|
|
}
|
|
)
|
|
_, env = self._sync(listing, groups=({"model_group": "m", "supported_reasoning_efforts": efforts},))
|
|
models = json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"]
|
|
assert models == {"m": {"name": "m", "limit": {"context": 9, "output": 3}, **reasoning}}
|
|
|
|
def test_an_unavailable_model_group_info_keeps_the_listing_only_config(self):
|
|
listing = self._listing({"id": "m", "object": "model", "created": 1, "owned_by": "openai"})
|
|
_, failed = self._sync(listing, groups=500)
|
|
_, unknown = self._sync(listing, groups=({"model_group": "m", "supported_reasoning_efforts": None},))
|
|
assert failed == unknown
|
|
assert json.loads(failed["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"] == {"m": {"name": "m"}}
|
|
|
|
def test_non_chat_models_are_left_out(self):
|
|
listing = self._listing(
|
|
{"id": "chat", "object": "model", "created": 1, "owned_by": "openai", "mode": "chat"},
|
|
{"id": "resp", "object": "model", "created": 1, "owned_by": "openai", "mode": "responses"},
|
|
{"id": "embed", "object": "model", "created": 1, "owned_by": "openai", "mode": "embedding"},
|
|
{"id": "img", "object": "model", "created": 1, "owned_by": "openai", "mode": "image_generation"},
|
|
)
|
|
_, env = self._sync(listing)
|
|
models = json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"]
|
|
assert set(models) == {"chat", "resp"}
|
|
|
|
def test_existing_config_content_is_left_alone(self):
|
|
calls = []
|
|
|
|
def fake_get(*a, **k):
|
|
calls.append(a)
|
|
return _FakeResponse(200, self._listing())
|
|
|
|
result = opencode_model_sync_env(
|
|
{"OPENCODE_CONFIG_CONTENT": "{}"}, "http://localhost:4000", "sk-key", get=fake_get
|
|
)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "OPENCODE_CONFIG_CONTENT" in result.reason
|
|
assert calls == []
|
|
|
|
def test_unreachable_proxy_is_reported_not_raised(self):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
result = opencode_model_sync_env({}, "http://localhost:4000", "sk-key", get=boom)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "refused" in result.reason
|
|
|
|
def test_non_200_is_reported(self):
|
|
result = opencode_model_sync_env({}, "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500))
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "HTTP 500" in result.reason
|
|
|
|
def test_unexpected_body_is_reported(self):
|
|
result = opencode_model_sync_env(
|
|
{}, "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": "nope"})
|
|
)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "unexpected body" in result.reason
|
|
|
|
@pytest.mark.parametrize("command", ["claude", "pi", "/usr/bin/claude"])
|
|
def test_only_opencode_and_codex_sync(self, command):
|
|
def boom(*a, **k):
|
|
raise AssertionError("no agent other than opencode or codex should call the proxy")
|
|
|
|
assert agent_model_sync_env(command, {}, "http://localhost:4000", "sk-key", False, get=boom) == {}
|
|
|
|
def test_skip_verify_keeps_the_launch_offline(self):
|
|
def boom(*a, **k):
|
|
raise AssertionError("--skip-verify must not touch the proxy")
|
|
|
|
result = agent_model_sync_env("opencode", {}, "http://localhost:4000", "sk-key", True, get=boom)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "--skip-verify" in result.reason
|
|
|
|
def test_full_path_opencode_syncs(self):
|
|
listing = self._listing({"id": "m", "object": "model", "created": 1, "owned_by": "x"})
|
|
env = agent_model_sync_env(
|
|
"/opt/bin/opencode",
|
|
{},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
False,
|
|
get=lambda *a, **k: _FakeResponse(200, listing),
|
|
)
|
|
assert "m" in json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"]
|
|
|
|
def test_default_http_client_is_requests_get(self):
|
|
assert _default_of(agent_model_sync_env, "get") is requests.get
|
|
assert _default_of(opencode_model_sync_env, "get") is requests.get
|
|
|
|
|
|
class TestCodexModelSync:
|
|
@staticmethod
|
|
def _listing(*models):
|
|
return {"object": "list", "data": list(models)}
|
|
|
|
@staticmethod
|
|
def _row(model_id, **extra):
|
|
return {"id": model_id, "object": "model", "created": 1, "owned_by": "openai", **extra}
|
|
|
|
def _sync(self, listing, codex_home, base_url="http://localhost:4000/", run=None, groups=(), model_info=None):
|
|
captured = {}
|
|
result = codex_model_sync_args(
|
|
{"CODEX_HOME": str(codex_home)},
|
|
base_url,
|
|
"sk-key",
|
|
get=_routed_get(captured, listing, groups, model_info),
|
|
run=_FakeRun() if run is None else run,
|
|
)
|
|
return captured, result
|
|
|
|
@staticmethod
|
|
def _catalog_path(result):
|
|
assert isinstance(result, ModelSyncArgs)
|
|
flag, override = result.args
|
|
assert flag == "-c"
|
|
key, _, value = override.partition("=")
|
|
assert key == "model_catalog_json"
|
|
return json.loads(value)
|
|
|
|
def test_writes_catalog_under_codex_home_and_points_codex_at_it(self, tmp_path):
|
|
listing = self._listing(self._row("gpt-5.5", mode="chat"), self._row("claude-opus-4-7"))
|
|
captured, result = self._sync(listing, tmp_path / "codex")
|
|
|
|
assert captured["urls"] == [
|
|
"http://localhost:4000/v1/models",
|
|
"http://localhost:4000/v1/model/info",
|
|
"http://localhost:4000/model_group/info",
|
|
]
|
|
assert captured["headers"] == {"Authorization": "Bearer sk-key"}
|
|
path = self._catalog_path(result)
|
|
assert path == str(tmp_path / "codex" / "litellm-models.json")
|
|
text = (tmp_path / "codex" / "litellm-models.json").read_text()
|
|
assert "sk-key" not in text
|
|
catalog = json.loads(text)
|
|
assert [m["slug"] for m in catalog["models"]] == ["gpt-5.5", "claude-opus-4-7"]
|
|
assert [m["display_name"] for m in catalog["models"]] == ["GPT-5.5", "claude-opus-4-7"]
|
|
assert [m["priority"] for m in catalog["models"]] == [0, 1]
|
|
|
|
def _entries(self, codex_home):
|
|
return {m["slug"]: m for m in json.loads((codex_home / "litellm-models.json").read_text())["models"]}
|
|
|
|
def test_known_model_keeps_the_installed_codex_entry(self, tmp_path):
|
|
self._sync(self._listing(self._row("gpt-5.5", mode="chat")), tmp_path)
|
|
assert self._entries(tmp_path)["gpt-5.5"] == {**_STOCK_MODELS["gpt-5.5"], "priority": 0}
|
|
|
|
def test_hidden_stock_model_is_listed_when_the_proxy_serves_it(self, tmp_path):
|
|
self._sync(self._listing(self._row("gpt-5.4")), tmp_path)
|
|
entry = self._entries(tmp_path)["gpt-5.4"]
|
|
assert entry["visibility"] == "list"
|
|
assert entry["upgrade"] is None
|
|
assert entry["supported_reasoning_levels"] == _STOCK_REASONING_LEVELS
|
|
|
|
def test_api_disabled_stock_model_is_selectable_when_the_proxy_serves_it(self, tmp_path):
|
|
self._sync(self._listing(self._row("codex-auto-review")), tmp_path)
|
|
entry = self._entries(tmp_path)["codex-auto-review"]
|
|
assert entry["supported_in_api"] is True
|
|
assert entry["visibility"] == "list"
|
|
assert entry["base_instructions"] == _STOCK_MODELS["codex-auto-review"]["base_instructions"]
|
|
|
|
def test_stock_upgrade_nudge_survives_when_its_target_is_listed(self, tmp_path):
|
|
self._sync(self._listing(self._row("gpt-5.4"), self._row("gpt-5.6-terra")), tmp_path)
|
|
entries = self._entries(tmp_path)
|
|
assert entries["gpt-5.4"]["upgrade"] == _STOCK_MODELS["gpt-5.4"]["upgrade"]
|
|
assert [entries["gpt-5.4"]["priority"], entries["gpt-5.6-terra"]["priority"]] == [0, 1]
|
|
|
|
@pytest.mark.parametrize(
|
|
"deployment",
|
|
[
|
|
{"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}},
|
|
{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}, "model_info": None},
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra"},
|
|
"model_info": {"id": "d1", "team_public_model_name": ""},
|
|
},
|
|
{
|
|
"model_name": "model_name_team-1_d1",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "https://api.openai.com/v1"},
|
|
"model_info": {"id": "d1", "team_public_model_name": "api-terra"},
|
|
},
|
|
],
|
|
ids=["provider_prefixed", "bare_without_model_info", "empty_team_public_name", "team_public_model_name"],
|
|
)
|
|
def test_alias_of_a_codex_model_gets_that_models_entry_under_its_own_name(self, tmp_path, deployment):
|
|
listing = self._listing(self._row("api-terra"), self._row("m"))
|
|
_, result = self._sync(listing, tmp_path, model_info=_FakeResponse(200, {"data": [deployment]}))
|
|
|
|
assert isinstance(result, ModelSyncArgs)
|
|
entries = self._entries(tmp_path)
|
|
assert entries["api-terra"] == {
|
|
**_STOCK_MODELS["gpt-5.6-terra"],
|
|
"slug": "api-terra",
|
|
"display_name": "api-terra",
|
|
"priority": 0,
|
|
"service_tiers": [],
|
|
"default_service_tier": None,
|
|
"default_verbosity": None,
|
|
"availability_nux": None,
|
|
"default_reasoning_level": None,
|
|
}
|
|
assert entries["m"]["supports_search_tool"] is False
|
|
|
|
def test_alias_gets_no_upgrade_nudge_away_from_its_own_name(self, tmp_path):
|
|
listing = self._listing(self._row("gpt-5.6-terra"), self._row("api-54"))
|
|
deployment = {"model_name": "api-54", "litellm_params": {"model": "openai/gpt-5.4"}}
|
|
self._sync(listing, tmp_path, model_info=_FakeResponse(200, {"data": [deployment]}))
|
|
|
|
entry = self._entries(tmp_path)["api-54"]
|
|
assert (entry["comp_hash"], entry["upgrade"]) == (_STOCK_MODELS["gpt-5.4"]["comp_hash"], None)
|
|
|
|
@pytest.mark.parametrize(
|
|
("pins", "expected"),
|
|
[
|
|
(("high",), "high"),
|
|
(("high", "high"), "high"),
|
|
(("high", None), None),
|
|
(("high", "low"), None),
|
|
(({"effort": "high"},), None),
|
|
],
|
|
ids=["one_deployment", "every_deployment_agrees", "one_unpinned", "pins_differ", "non_string_pin"],
|
|
)
|
|
def test_alias_starts_at_the_reasoning_effort_every_deployment_pins(self, tmp_path, pins, expected):
|
|
deployments = [
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra", **({"reasoning_effort": p} if p else {})},
|
|
}
|
|
for p in pins
|
|
]
|
|
self._sync(
|
|
self._listing(self._row("api-terra")), tmp_path, model_info=_FakeResponse(200, {"data": deployments})
|
|
)
|
|
|
|
entry = self._entries(tmp_path)["api-terra"]
|
|
assert (entry["comp_hash"], entry["default_reasoning_level"]) == ("terra-hash", expected)
|
|
|
|
@pytest.mark.parametrize(
|
|
"deployments",
|
|
[
|
|
[
|
|
{"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}},
|
|
{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}},
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "gpt-5.6-terra", "custom_llm_provider": "openai"},
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.5"},
|
|
"model_info": {"id": "paused", "blocked": True},
|
|
},
|
|
{"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}},
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "https://eu.api.openai.com/v1"},
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {
|
|
"model": "openai/gpt-5.6-terra",
|
|
"api_base": "https://api.openai.com/v1",
|
|
"litellm_credential_name": "openai-key",
|
|
},
|
|
}
|
|
],
|
|
],
|
|
ids=[
|
|
"same_model_with_and_without_prefix",
|
|
"openai_custom_llm_provider",
|
|
"paused_deployment_ignored",
|
|
"openai_regional_host",
|
|
"credential_under_an_openai_host",
|
|
],
|
|
)
|
|
def test_alias_whose_deployments_all_route_to_one_openai_model_gets_its_entry(self, tmp_path, deployments):
|
|
self._sync(
|
|
self._listing(self._row("api-terra")), tmp_path, model_info=_FakeResponse(200, {"data": deployments})
|
|
)
|
|
|
|
assert self._entries(tmp_path)["api-terra"]["comp_hash"] == _STOCK_MODELS["gpt-5.6-terra"]["comp_hash"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"deployments",
|
|
[
|
|
[
|
|
{"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.5"}},
|
|
{
|
|
"model_name": "model_name_team-1_d1",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra"},
|
|
"model_info": {"id": "d1", "team_public_model_name": "api-terra"},
|
|
},
|
|
],
|
|
[
|
|
{"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}},
|
|
{"model_name": "api-terra", "litellm_params": {"model": "anthropic/claude-opus-4-7"}},
|
|
],
|
|
[{"model_name": "api-terra", "litellm_params": {"model": "azure/gpt-5.6-terra"}}],
|
|
[{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra", "custom_llm_provider": "azure"}}],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "http://vllm.example.invalid/v1"},
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "gpt-5.6-terra", "api_base": "https://api.openai.com.example.invalid"},
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"model_name": "api-terra",
|
|
"litellm_params": {"model": "openai/gpt-5.6-terra", "litellm_credential_name": "gateway"},
|
|
}
|
|
],
|
|
],
|
|
ids=[
|
|
"team_row_and_global_row_differ",
|
|
"load_balanced_across_providers",
|
|
"azure_prefix",
|
|
"azure_provider",
|
|
"openai_prefix_on_another_host",
|
|
"openai_lookalike_host",
|
|
"host_only_a_credential_knows",
|
|
],
|
|
)
|
|
def test_alias_without_one_shared_openai_upstream_keeps_the_fallback_entry(self, tmp_path, deployments):
|
|
listing = self._listing(self._row("api-terra"))
|
|
self._sync(listing, tmp_path / "with", model_info=_FakeResponse(200, {"data": deployments}))
|
|
self._sync(listing, tmp_path / "without")
|
|
|
|
assert self._entries(tmp_path / "with")["api-terra"] == self._entries(tmp_path / "without")["api-terra"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_info",
|
|
[
|
|
requests.ConnectionError("refused"),
|
|
_FakeResponse(500, {"data": [{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}}]}),
|
|
_FakeResponse(200, {"data": "nope"}),
|
|
_FakeResponse(200, {"data": [{"model_name": "api-terra"}]}),
|
|
],
|
|
ids=["unreachable", "http_500", "unexpected_body", "deployment_without_litellm_params"],
|
|
)
|
|
def test_unavailable_model_info_keeps_the_alias_on_the_fallback_entry(self, tmp_path, model_info):
|
|
_, result = self._sync(self._listing(self._row("api-terra")), tmp_path, model_info=model_info)
|
|
|
|
catalog = tmp_path / "litellm-models.json"
|
|
assert result == ModelSyncArgs(("-c", f"model_catalog_json={json.dumps(str(catalog))}"))
|
|
entry = self._entries(tmp_path)["api-terra"]
|
|
assert entry["slug"] == "api-terra"
|
|
assert entry["supports_search_tool"] is False
|
|
|
|
def test_stock_catalog_is_decoded_as_utf8_regardless_of_locale(self, tmp_path):
|
|
description = "Modelo equilibrado para el trabajo diario, con acentos y ñ."
|
|
catalog = {"models": [{**_STOCK_MODELS["gpt-5.5"], "description": description}]}
|
|
stock = json.dumps(catalog, ensure_ascii=False).encode("utf-8")
|
|
|
|
def locale_bound_run(args, **kwargs):
|
|
if "model_catalog_json=" in str(args):
|
|
return subprocess.CompletedProcess(args, 0, "", "")
|
|
return subprocess.CompletedProcess(args, 0, stock.decode(kwargs.get("encoding") or "ascii"), "")
|
|
|
|
_, result = self._sync(self._listing(self._row("gpt-5.5")), tmp_path, run=locale_bound_run)
|
|
|
|
assert isinstance(result, ModelSyncArgs)
|
|
written = json.loads((tmp_path / "litellm-models.json").read_text(encoding="utf-8"))["models"]
|
|
assert [m["description"] for m in written] == [description]
|
|
|
|
def test_unparseable_stock_catalog_is_reported(self, tmp_path):
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=_FakeRun(stock="not json"))
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert result.reason.startswith("`codex debug models` printed no model catalog: ")
|
|
assert not (tmp_path / "litellm-models.json").exists()
|
|
|
|
def test_unknown_model_gets_the_fields_codex_requires(self, tmp_path):
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path)
|
|
entry = json.loads((tmp_path / "litellm-models.json").read_text())["models"][0]
|
|
|
|
assert entry["visibility"] == "list"
|
|
assert entry["supported_in_api"] is True
|
|
assert entry["shell_type"] == "unified_exec"
|
|
assert entry["supported_reasoning_levels"] == []
|
|
assert entry["truncation_policy"] == {"mode": "bytes", "limit": 10000}
|
|
assert entry["experimental_supported_tools"] == []
|
|
assert entry["support_verbosity"] is False
|
|
assert entry["supports_reasoning_summaries"] is False
|
|
assert entry["supports_parallel_tool_calls"] is False
|
|
for nullable in ("description", "availability_nux", "upgrade", "default_verbosity", "apply_patch_tool_type"):
|
|
assert nullable in entry and entry[nullable] is None
|
|
assert entry["base_instructions"].startswith("You are a coding agent running in the Codex CLI")
|
|
|
|
@pytest.mark.parametrize(
|
|
("version", "levels"),
|
|
[
|
|
("codex-cli 0.165.0", ["low", "high", "max"]),
|
|
("codex-cli 0.138.0", ["low", "high", "max"]),
|
|
("codex-cli 0.137.0", ["low", "high"]),
|
|
("codex-cli 0.138.0-alpha.3", ["low", "high"]),
|
|
("codex-cli", ["low", "high"]),
|
|
(None, ["low", "high"]),
|
|
(OSError("codex vanished"), ["low", "high"]),
|
|
(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), ["low", "high"]),
|
|
],
|
|
ids=["current", "first-custom", "pre-custom", "pre-release", "no-number", "failed", "unrunnable", "undecodable"],
|
|
)
|
|
def test_unknown_model_offers_the_group_efforts_the_installed_codex_parses(self, tmp_path, version, levels):
|
|
_, result = self._sync(
|
|
self._listing(self._row("m")),
|
|
tmp_path,
|
|
run=_FakeRun(version=version),
|
|
groups=({"model_group": "m", "supported_reasoning_efforts": ["low", "high", "max"]},),
|
|
)
|
|
|
|
assert isinstance(result, ModelSyncArgs)
|
|
entry = self._entries(tmp_path)["m"]
|
|
assert [level["effort"] for level in entry["supported_reasoning_levels"]] == levels
|
|
assert entry["supported_reasoning_levels"][0] == {
|
|
"effort": "low",
|
|
"description": "Sends reasoning_effort=low upstream",
|
|
}
|
|
assert entry["default_reasoning_level"] == "low"
|
|
|
|
def test_known_models_and_unknown_or_empty_groups_keep_their_entries(self, tmp_path):
|
|
listing = self._listing(self._row("gpt-5.5"), self._row("unknown"), self._row("empty"))
|
|
groups = (
|
|
{"model_group": "gpt-5.5", "supported_reasoning_efforts": ["max"]},
|
|
{"model_group": "unknown", "supported_reasoning_efforts": None},
|
|
{"model_group": "empty", "supported_reasoning_efforts": []},
|
|
)
|
|
self._sync(listing, tmp_path, groups=groups)
|
|
with_groups = self._entries(tmp_path)
|
|
self._sync(listing, tmp_path, groups=500)
|
|
|
|
assert with_groups == self._entries(tmp_path)
|
|
assert with_groups["gpt-5.5"] == {**_STOCK_MODELS["gpt-5.5"], "priority": 0}
|
|
assert [with_groups[slug]["supported_reasoning_levels"] for slug in ("unknown", "empty")] == [[], []]
|
|
assert all("default_reasoning_level" not in with_groups[slug] for slug in ("unknown", "empty"))
|
|
|
|
def test_context_window_comes_from_max_input_tokens_for_unknown_models_only(self, tmp_path):
|
|
listing = self._listing(
|
|
self._row("big", max_input_tokens=400000),
|
|
self._row("unknown"),
|
|
self._row("gpt-5.5", max_input_tokens=400000),
|
|
)
|
|
self._sync(listing, tmp_path)
|
|
models = self._entries(tmp_path)
|
|
assert models["big"]["context_window"] == 400000
|
|
assert models["unknown"]["context_window"] is None
|
|
assert models["gpt-5.5"]["context_window"] == 272000
|
|
|
|
def test_non_chat_models_are_left_out(self, tmp_path):
|
|
listing = self._listing(
|
|
self._row("chat", mode="chat"),
|
|
self._row("resp", mode="responses"),
|
|
self._row("embed", mode="embedding"),
|
|
self._row("img", mode="image_generation"),
|
|
)
|
|
self._sync(listing, tmp_path)
|
|
slugs = {m["slug"] for m in json.loads((tmp_path / "litellm-models.json").read_text())["models"]}
|
|
assert slugs == {"chat", "resp"}
|
|
|
|
def test_listing_without_chat_models_is_skipped_and_writes_nothing(self, tmp_path):
|
|
_, result = self._sync(self._listing(self._row("embed", mode="embedding")), tmp_path)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "no chat models" in result.reason
|
|
assert not (tmp_path / "litellm-models.json").exists()
|
|
|
|
def test_catalog_is_rewritten_on_every_launch(self, tmp_path):
|
|
self._sync(self._listing(self._row("old")), tmp_path)
|
|
self._sync(self._listing(self._row("new")), tmp_path)
|
|
slugs = [m["slug"] for m in json.loads((tmp_path / "litellm-models.json").read_text())["models"]]
|
|
assert slugs == ["new"]
|
|
|
|
def test_catalog_is_replaced_whole_and_leaves_no_temp_files(self, tmp_path):
|
|
self._sync(self._listing(*(self._row(f"m{i}") for i in range(50))), tmp_path)
|
|
self._sync(self._listing(self._row("new")), tmp_path)
|
|
assert [p.name for p in tmp_path.iterdir()] == ["litellm-models.json"]
|
|
assert json.loads((tmp_path / "litellm-models.json").read_text())["models"][0]["slug"] == "new"
|
|
|
|
def test_defaults_to_dot_codex_in_home(self, tmp_path):
|
|
result = codex_model_sync_args(
|
|
{},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(200, self._listing(self._row("m"))),
|
|
run=_FakeRun(),
|
|
home=lambda: tmp_path,
|
|
)
|
|
assert self._catalog_path(result) == str(tmp_path / ".codex" / "litellm-models.json")
|
|
|
|
def test_default_home_is_the_users(self):
|
|
assert _default_of(codex_model_sync_args, "home") == Path.home
|
|
|
|
def test_missing_base_instructions_is_reported_not_raised(self, tmp_path):
|
|
result = codex_model_sync_args(
|
|
{"CODEX_HOME": str(tmp_path)},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(200, self._listing(self._row("m"))),
|
|
instructions_path=tmp_path / "missing.md",
|
|
)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "could not read" in result.reason
|
|
assert not (tmp_path / "litellm-models.json").exists()
|
|
|
|
def test_unwritable_catalog_path_is_reported_not_raised(self, tmp_path):
|
|
blocker = tmp_path / "file"
|
|
blocker.write_text("")
|
|
_, result = self._sync(self._listing(self._row("m")), blocker / "codex")
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "could not write" in result.reason
|
|
|
|
def test_failed_replace_is_reported_and_leaves_no_temp_file(self, tmp_path):
|
|
(tmp_path / "litellm-models.json").mkdir()
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "could not write" in result.reason
|
|
assert [p.name for p in tmp_path.iterdir()] == ["litellm-models.json"]
|
|
|
|
def test_unreachable_proxy_is_reported_not_raised(self, tmp_path):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
result = codex_model_sync_args({"CODEX_HOME": str(tmp_path)}, "http://localhost:4000", "sk-key", get=boom)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "refused" in result.reason
|
|
assert not (tmp_path / "litellm-models.json").exists()
|
|
|
|
@pytest.mark.parametrize(
|
|
("response", "reason"),
|
|
[(_FakeResponse(500), "HTTP 500"), (_FakeResponse(200, {"data": "nope"}), "unexpected body")],
|
|
)
|
|
def test_bad_response_is_reported(self, tmp_path, response, reason):
|
|
result = codex_model_sync_args(
|
|
{"CODEX_HOME": str(tmp_path)}, "http://localhost:4000", "sk-key", get=lambda *a, **k: response
|
|
)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert reason in result.reason
|
|
|
|
@pytest.mark.parametrize("binary", ["codex", "/opt/bin/codex", "codex.cmd", "/c/npm/codex.CMD"])
|
|
def test_codex_syncs_through_the_agent_dispatch_with_the_binary_it_will_run(self, tmp_path, binary):
|
|
run = _FakeRun()
|
|
result = agent_model_sync_env(
|
|
binary,
|
|
{"CODEX_HOME": str(tmp_path)},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
False,
|
|
get=lambda *a, **k: _FakeResponse(200, self._listing(self._row("m"))),
|
|
run=run,
|
|
)
|
|
assert self._catalog_path(result) == str(tmp_path / "litellm-models.json")
|
|
assert len(run.calls) == 3
|
|
assert all(binary in command for command, _ in run.calls)
|
|
|
|
def test_opencode_dispatch_never_runs_codex(self):
|
|
def boom(*a, **k):
|
|
raise AssertionError("only the Codex sync reads its catalog back")
|
|
|
|
result = agent_model_sync_env(
|
|
"opencode",
|
|
{},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
False,
|
|
get=lambda *a, **k: _FakeResponse(200, self._listing(self._row("m"))),
|
|
run=boom,
|
|
)
|
|
assert "OPENCODE_CONFIG_CONTENT" in result
|
|
|
|
def test_codex_lists_its_own_models_then_reads_the_catalog_back_before_launch(self, tmp_path):
|
|
run = _FakeRun()
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=run)
|
|
path = self._catalog_path(result)
|
|
|
|
assert [command for command, _ in run.calls] == [
|
|
("codex", "debug", "models"),
|
|
("codex", "--version"),
|
|
("codex", "-c", f"model_catalog_json={json.dumps(path)}", "debug", "models"),
|
|
]
|
|
for _, options in run.calls:
|
|
assert options["env"] == {"CODEX_HOME": str(tmp_path)}
|
|
assert options["stdin"] is subprocess.DEVNULL
|
|
assert options["capture_output"] is True
|
|
assert options["encoding"] == "utf-8"
|
|
assert options["timeout"] == 10
|
|
|
|
def test_codex_rejecting_the_catalog_skips_the_sync_and_keeps_the_file(self, tmp_path):
|
|
stderr = (
|
|
"Error: failed to parse model_catalog_json path `/home/me/.codex/litellm-models.json` as JSON: "
|
|
"missing field `supports_parallel_tool_calls` at line 1 column 21648\n"
|
|
)
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=_FakeRun(1, stderr))
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert result.reason == (
|
|
"`codex debug models` exited 1: Error: failed to parse model_catalog_json path "
|
|
"`/home/me/.codex/litellm-models.json` as JSON: missing field `supports_parallel_tool_calls` "
|
|
"at line 1 column 21648"
|
|
)
|
|
assert (tmp_path / "litellm-models.json").exists()
|
|
|
|
def test_codex_without_debug_models_skips_the_sync(self, tmp_path):
|
|
stderr = "error: unrecognized subcommand 'models'\n\nUsage: codex debug [OPTIONS] <COMMAND>\n"
|
|
run = _FakeRun(2, stderr, stock=None)
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=run)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert result.reason == "`codex debug models` exited 2: error: unrecognized subcommand 'models'"
|
|
assert len(run.calls) == 1
|
|
assert not (tmp_path / "litellm-models.json").exists()
|
|
|
|
def test_codex_failing_silently_is_reported(self, tmp_path):
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=_FakeRun(1))
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert result.reason == "`codex debug models` exited 1: no output"
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
OSError("codex vanished"),
|
|
subprocess.TimeoutExpired("codex", 10),
|
|
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
|
|
],
|
|
ids=["oserror", "timeout", "undecodable"],
|
|
)
|
|
def test_unrunnable_preflight_is_reported_not_raised(self, tmp_path, error):
|
|
def failing_run(*a, **k):
|
|
raise error
|
|
|
|
_, result = self._sync(self._listing(self._row("m")), tmp_path, run=failing_run)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert result.reason.startswith("`codex debug models` failed: ")
|
|
assert str(error) in result.reason
|
|
|
|
def test_windows_shim_preflight_goes_through_cmd_exe(self, tmp_path):
|
|
shim = _WINDOWS_CLAUDE_CMD.replace("claude", "codex")
|
|
run = _FakeRun()
|
|
result = codex_model_sync_args(
|
|
{"CODEX_HOME": str(tmp_path)},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
binary=shim,
|
|
get=lambda *a, **k: _FakeResponse(200, self._listing(self._row("m"))),
|
|
run=run,
|
|
)
|
|
override = f"model_catalog_json={json.dumps(self._catalog_path(result))}"
|
|
doubled = override.replace('"', '""')
|
|
assert [command for command, _ in run.calls] == [
|
|
f'{_CMD_PREFIX}""{shim}" "debug" "models""',
|
|
f'{_CMD_PREFIX}""{shim}" "--version""',
|
|
f'{_CMD_PREFIX}""{shim}" "-c" "{doubled}" "debug" "models""',
|
|
]
|
|
|
|
def test_default_binary_is_codex_on_path(self):
|
|
assert _default_of(codex_model_sync_args, "binary") == "codex"
|
|
|
|
def test_default_runner_is_subprocess_run(self):
|
|
assert _default_of(codex_model_sync_args, "run") is subprocess.run
|
|
assert _default_of(agent_model_sync_env, "run") is subprocess.run
|
|
|
|
def test_skip_verify_keeps_the_launch_offline(self):
|
|
def boom(*a, **k):
|
|
raise AssertionError("--skip-verify must not touch the proxy")
|
|
|
|
result = agent_model_sync_env("codex", {}, "http://localhost:4000", "sk-key", True, get=boom)
|
|
assert isinstance(result, ModelSyncSkipped)
|
|
assert "--skip-verify" in result.reason
|
|
|
|
def test_default_http_client_is_requests_get(self):
|
|
assert _default_of(codex_model_sync_args, "get") is requests.get
|
|
|
|
|
|
class TestRunAgent:
|
|
def test_synced_args_precede_user_args_and_follow_provider_overrides(self):
|
|
calls = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex", "exec", "hi"],
|
|
base_env={},
|
|
sync_models=lambda *a: ModelSyncArgs(("-c", 'model_catalog_json="/tmp/c.json"')),
|
|
which=lambda name: "/usr/local/bin/codex",
|
|
verify=lambda *a: None,
|
|
launcher=lambda p, a, e: calls.update(args=tuple(a), env=dict(e)),
|
|
)
|
|
args = calls["args"]
|
|
assert args[-2:] == ("exec", "hi")
|
|
assert args[args.index('model_catalog_json="/tmp/c.json"') - 1] == "-c"
|
|
assert (
|
|
args.index('model_provider="litellm"') < args.index('model_catalog_json="/tmp/c.json"') < args.index("exec")
|
|
)
|
|
assert calls["env"]["OPENAI_API_KEY"] == "sk-key"
|
|
assert "model_catalog_json" not in json.dumps(calls["env"])
|
|
|
|
def test_synced_model_config_reaches_the_agent_alongside_profile_env(self):
|
|
calls = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["opencode"],
|
|
base_env={"HOME": "/home/me"},
|
|
sync_models=lambda *a: {"OPENCODE_CONFIG_CONTENT": '{"provider":{}}'},
|
|
which=lambda name: "/usr/local/bin/opencode",
|
|
verify=lambda *a: None,
|
|
launcher=lambda p, a, e: calls.update(env=dict(e)),
|
|
)
|
|
assert calls["env"]["OPENCODE_CONFIG_CONTENT"] == '{"provider":{}}'
|
|
assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1"
|
|
assert calls["env"]["OPENAI_API_KEY"] == "sk-key"
|
|
assert calls["env"]["HOME"] == "/home/me"
|
|
|
|
def test_sync_gets_the_launch_inputs_and_runs_after_verify(self):
|
|
order = []
|
|
calls = {}
|
|
|
|
def fake_sync(command, base_env, base_url, api_key, skip_verify):
|
|
order.append("sync")
|
|
calls["args"] = (command, dict(base_env), base_url, api_key, skip_verify)
|
|
return {"OPENCODE_CONFIG_CONTENT": '{"provider":{"litellm":{}}}'}
|
|
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["opencode"],
|
|
base_env={"HOME": "/home/me"},
|
|
sync_models=fake_sync,
|
|
which=lambda name: "/usr/local/bin/opencode",
|
|
verify=lambda *a: order.append("verify"),
|
|
launcher=lambda p, a, e: order.append("launch"),
|
|
)
|
|
assert order == ["verify", "sync", "launch"]
|
|
assert calls["args"] == (
|
|
"/usr/local/bin/opencode",
|
|
{"HOME": "/home/me"},
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
False,
|
|
)
|
|
|
|
def test_unreachable_proxy_is_not_asked_for_models(self):
|
|
def failing_verify(*a):
|
|
raise AgentRunError("Could not reach the LiteLLM proxy")
|
|
|
|
def boom(*a):
|
|
raise AssertionError("a failed key check must not be followed by a model fetch")
|
|
|
|
with pytest.raises(AgentRunError):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["opencode"],
|
|
base_env={},
|
|
sync_models=boom,
|
|
which=lambda name: "/usr/local/bin/opencode",
|
|
verify=failing_verify,
|
|
launcher=lambda *a: None,
|
|
)
|
|
|
|
def test_skip_verify_reaches_the_sync_which_reports_the_skip(self):
|
|
warnings = []
|
|
calls = {}
|
|
|
|
def fake_sync(command, base_env, base_url, api_key, skip_verify):
|
|
calls["skip_verify"] = skip_verify
|
|
return ModelSyncSkipped("offline")
|
|
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["opencode"],
|
|
skip_verify=True,
|
|
base_env={},
|
|
sync_models=fake_sync,
|
|
warn=warnings.append,
|
|
which=lambda name: "/usr/local/bin/opencode",
|
|
verify=lambda *a: pytest.fail("--skip-verify must not verify"),
|
|
launcher=lambda p, a, e: calls.update(env=dict(e)),
|
|
)
|
|
assert calls["skip_verify"] is True
|
|
assert "OPENCODE_CONFIG_CONTENT" not in calls["env"]
|
|
assert warnings == ["litellm: not syncing OpenCode models from the proxy: offline"]
|
|
|
|
def test_skipped_sync_still_launches_with_plain_openai_env(self):
|
|
calls = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["opencode"],
|
|
base_env={},
|
|
sync_models=lambda *a: ModelSyncSkipped("proxy said no"),
|
|
warn=lambda message: calls.setdefault("warned", message),
|
|
which=lambda name: "/usr/local/bin/opencode",
|
|
verify=lambda *a: None,
|
|
launcher=lambda p, a, e: calls.update(env=dict(e)),
|
|
)
|
|
assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1"
|
|
assert "OPENCODE_CONFIG_CONTENT" not in calls["env"]
|
|
assert "proxy said no" in calls["warned"]
|
|
|
|
def test_non_opencode_agent_is_not_warned_about_model_sync(self):
|
|
warnings = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
base_env={},
|
|
warn=warnings.append,
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
sync_models=agent_model_sync_env,
|
|
)
|
|
assert warnings == []
|
|
|
|
def test_default_sync_is_the_agent_model_sync(self):
|
|
assert _default_of(run_agent, "sync_models") is agent_model_sync_env
|
|
|
|
def test_wires_env_and_launches_resolved_binary(self):
|
|
calls = {}
|
|
|
|
def fake_launcher(path, args, env):
|
|
calls["path"] = path
|
|
calls["args"] = tuple(args)
|
|
calls["env"] = dict(env)
|
|
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--resume"],
|
|
base_env={"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "leaked"},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: None,
|
|
launcher=fake_launcher,
|
|
)
|
|
|
|
assert calls["path"] == "/usr/local/bin/claude"
|
|
assert calls["args"] == ("claude", "--resume")
|
|
env = calls["env"]
|
|
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key"
|
|
assert env["ENABLE_TOOL_SEARCH"] == "true"
|
|
assert "ANTHROPIC_API_KEY" not in env
|
|
assert "OPENAI_BASE_URL" not in env
|
|
|
|
def test_codex_gets_openai_env(self):
|
|
calls = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/codex",
|
|
verify=lambda *a: None,
|
|
launcher=lambda p, a, e: calls.update(env=dict(e)),
|
|
)
|
|
assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1"
|
|
assert calls["env"]["OPENAI_API_KEY"] == "sk-key"
|
|
assert "ANTHROPIC_BASE_URL" not in calls["env"]
|
|
assert "ENABLE_TOOL_SEARCH" 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_pi_preparer_runs_after_verify_and_before_launch(self):
|
|
order = []
|
|
captured = {}
|
|
|
|
def fake_prepare(base_url, api_key, base_env):
|
|
order.append("prepare")
|
|
captured["args"] = (base_url, api_key, dict(base_env))
|
|
return []
|
|
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["pi"],
|
|
base_env={"HOME": "/home/u"},
|
|
which=lambda name: "/usr/local/bin/pi",
|
|
verify=lambda *a: order.append("verify"),
|
|
launcher=lambda *a: order.append("launch"),
|
|
preparers={"pi": fake_prepare},
|
|
)
|
|
assert order == ["verify", "prepare", "launch"]
|
|
assert captured["args"] == (
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
{"HOME": "/home/u"},
|
|
)
|
|
|
|
def test_pi_prepared_args_precede_user_args_and_env_has_proxy_key(self):
|
|
calls = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["pi", "-p", "hello"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/pi",
|
|
verify=lambda *a: None,
|
|
launcher=lambda p, a, e: calls.update(args=tuple(a), env=dict(e)),
|
|
preparers={"pi": lambda *a: ["--model", "litellm/m-1"]},
|
|
)
|
|
assert calls["args"] == ("pi", "--model", "litellm/m-1", "-p", "hello")
|
|
assert calls["env"]["LITELLM_PROXY_API_KEY"] == "sk-key"
|
|
assert "OPENAI_API_KEY" not in calls["env"]
|
|
assert "ANTHROPIC_BASE_URL" not in calls["env"]
|
|
|
|
def test_failed_preparer_aborts_before_launch(self):
|
|
launched = []
|
|
|
|
def boom(*a):
|
|
raise AgentRunError("sync failed")
|
|
|
|
with pytest.raises(AgentRunError, match="sync failed"):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["pi"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/pi",
|
|
verify=lambda *a: None,
|
|
launcher=lambda *a: launched.append(a),
|
|
preparers={"pi": boom},
|
|
)
|
|
assert launched == []
|
|
|
|
def test_prepare_pi_syncs_models_json_and_pins_first_model(self, tmp_path):
|
|
from litellm.proxy.client.cli.commands.agents import prepare_pi
|
|
|
|
def fake_get(url, headers, timeout):
|
|
if url.endswith("/model_group/info"):
|
|
return _FakeJsonResponse(
|
|
200,
|
|
{"data": [{"model_group": "m-first", "max_input_tokens": 131072, "max_output_tokens": 8192}]},
|
|
)
|
|
return _FakeJsonResponse(200, {"data": [{"id": "m-first"}, {"id": "m-second"}]})
|
|
|
|
pin = prepare_pi(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
{"PI_CODING_AGENT_DIR": str(tmp_path)},
|
|
get=fake_get,
|
|
)
|
|
|
|
assert pin == ("--model", "litellm/m-first")
|
|
import json
|
|
|
|
written = json.loads((tmp_path / "models.json").read_text())
|
|
assert written["providers"]["litellm"]["apiKey"] == "$LITELLM_PROXY_API_KEY"
|
|
assert written["providers"]["litellm"]["models"] == [
|
|
{"id": "m-first", "contextWindow": 131072, "maxTokens": 8192},
|
|
{"id": "m-second"},
|
|
]
|
|
|
|
def test_prepare_pi_surfaces_fetch_failure_as_agent_error(self, tmp_path):
|
|
from litellm.proxy.client.cli.commands.agents import prepare_pi
|
|
|
|
with pytest.raises(AgentRunError, match="HTTP 500"):
|
|
prepare_pi(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
{"PI_CODING_AGENT_DIR": str(tmp_path)},
|
|
get=lambda *a, **k: _FakeJsonResponse(500),
|
|
)
|
|
|
|
def test_claude_has_no_preparer(self):
|
|
prepared = []
|
|
|
|
def fake_prepare(*a):
|
|
prepared.append(a)
|
|
return []
|
|
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
preparers={"pi": fake_prepare},
|
|
)
|
|
assert prepared == []
|
|
|
|
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=r"claude.*Install it first"):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
base_env={},
|
|
which=lambda name: None,
|
|
verify=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
)
|
|
|
|
def test_skip_verify_does_not_call_verify(self):
|
|
verified = []
|
|
launched = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
skip_verify=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: verified.append(a),
|
|
launcher=lambda *a: launched.append(a),
|
|
)
|
|
assert verified == []
|
|
assert len(launched) == 1
|
|
|
|
def test_verify_failure_aborts_before_launch(self):
|
|
launched = []
|
|
|
|
def boom(*a):
|
|
raise AgentRunError("rejected")
|
|
|
|
with pytest.raises(AgentRunError):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=boom,
|
|
launcher=lambda *a: launched.append(a),
|
|
)
|
|
assert launched == []
|
|
|
|
def test_empty_command_raises(self):
|
|
with pytest.raises(AgentRunError):
|
|
run_agent("http://localhost:4000", "sk-key", [])
|
|
|
|
@pytest.mark.parametrize("subscription", [True, False])
|
|
def test_codex_stop_hook_is_added_only_when_codex_sends_the_key_as_its_openai_key(self, subscription):
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex"],
|
|
subscription=subscription,
|
|
base_env={"OPENAI_API_KEY": "sk-users-own-openai-key"},
|
|
which=lambda name: "/usr/local/bin/codex",
|
|
verify=lambda *a: None,
|
|
verify_subscription=lambda *a: None,
|
|
sync_models=lambda *a: ModelSyncSkipped("not needed"),
|
|
warn=lambda message: None,
|
|
launcher=lambda p, a, e: launched.update(args=tuple(a)),
|
|
preparers={"codex": lambda *a: ("-c", "hooks.Stop=lite")},
|
|
)
|
|
assert ("hooks.Stop=lite" in launched["args"]) is not subscription
|
|
|
|
def test_subscription_launch_probes_with_the_header_and_exports_it(self):
|
|
probed = []
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
subscription=True,
|
|
base_env={"ANTHROPIC_AUTH_TOKEN": "stale"},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: probed.append(("bearer", a)),
|
|
verify_subscription=lambda *a: probed.append(("header", a)),
|
|
launcher=lambda p, a, e: launched.update(env=dict(e)),
|
|
read_settings=lambda path: {},
|
|
)
|
|
assert probed == [("header", ("http://localhost:4000", "sk-key"))]
|
|
assert launched["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
|
assert "ANTHROPIC_AUTH_TOKEN" not in launched["env"]
|
|
|
|
def test_subscription_launch_blanks_the_credentials_claude_code_would_load_from_settings_json(self, tmp_path):
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--resume"],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=tuple(a)),
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
assert launched["args"][:2] == ("claude", "--settings")
|
|
assert _overlay(launched["args"]) == {
|
|
"env": {
|
|
"ANTHROPIC_AUTH_TOKEN": "",
|
|
"ANTHROPIC_API_KEY": "",
|
|
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
|
}
|
|
}
|
|
assert launched["args"][3:] == ("--resume",)
|
|
|
|
def test_subscription_launch_keeps_the_key_out_of_argv_and_in_one_private_file_next_to_settings_json(
|
|
self, tmp_path
|
|
):
|
|
config_dir = tmp_path / "cfg"
|
|
config_dir.mkdir()
|
|
stale = config_dir / "litellm-subscription-overlay.json"
|
|
stale.write_text('{"env": {"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer old-key"}}')
|
|
stale.chmod(0o644)
|
|
launched = {}
|
|
for _ in range(2):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
subscription=True,
|
|
base_env={"CLAUDE_CONFIG_DIR": str(config_dir)},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a)),
|
|
read_settings=lambda path: {},
|
|
)
|
|
assert "sk-key" not in " ".join(launched["args"])
|
|
assert Path(launched["args"][2]) == stale
|
|
assert sorted(path.name for path in config_dir.iterdir()) == ["litellm-subscription-overlay.json"]
|
|
assert json.loads(stale.read_text())["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
|
if os.name == "posix":
|
|
assert stat.S_IMODE(stale.stat().st_mode) == 0o600
|
|
|
|
def test_subscription_launch_accepts_inline_settings_json_longer_than_a_file_name(self, tmp_path):
|
|
long_value = "a" * 400
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--settings", json.dumps({"env": {"FOO": long_value}})],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a)),
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
assert _overlay(launched["args"])["env"]["FOO"] == long_value
|
|
|
|
def test_subscription_launch_reports_a_missing_settings_file_as_a_clean_cli_error(self, tmp_path):
|
|
with pytest.raises(AgentRunError, match="file not found"):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--settings", str(tmp_path / "nope.json")],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
|
|
def test_subscription_launch_reads_the_settings_files_claude_code_merges_below_the_flag(self, tmp_path):
|
|
seen = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
subscription=True,
|
|
base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
read_settings=lambda path: seen.append(path) or {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
project_dir=tmp_path / "repo",
|
|
)
|
|
assert seen == [
|
|
tmp_path / "repo" / ".claude" / "settings.local.json",
|
|
tmp_path / "repo" / ".claude" / "settings.json",
|
|
tmp_path / "cfg" / "settings.json",
|
|
]
|
|
|
|
@pytest.mark.parametrize("separate_flag", [True, False])
|
|
def test_subscription_launch_merges_the_blanks_into_the_users_settings_flag(self, tmp_path, separate_flag):
|
|
settings_file = tmp_path / "mine.json"
|
|
settings_file.write_text('{"model": "opus", "env": {"ANTHROPIC_AUTH_TOKEN": "mine", "FOO": "bar"}}')
|
|
user_flag = ["--settings", str(settings_file)] if separate_flag else [f"--settings={settings_file}"]
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--resume", *user_flag, "-p", "hi"],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a)),
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
assert launched["args"][:2] == ["claude", "--settings"]
|
|
assert _overlay(launched["args"]) == {
|
|
"model": "opus",
|
|
"env": {
|
|
"ANTHROPIC_AUTH_TOKEN": "",
|
|
"FOO": "bar",
|
|
"ANTHROPIC_API_KEY": "",
|
|
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
|
},
|
|
}
|
|
assert launched["args"][3:] == ["--resume", "-p", "hi"]
|
|
|
|
def test_subscription_launch_merges_the_blanks_into_inline_settings_json(self, tmp_path):
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--settings", '{"env": {"ANTHROPIC_API_KEY": "mine"}, "theme": "dark"}'],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a)),
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
assert launched["args"][:2] == ["claude", "--settings"]
|
|
assert _overlay(launched["args"]) == {
|
|
"theme": "dark",
|
|
"env": {
|
|
"ANTHROPIC_API_KEY": "",
|
|
"ANTHROPIC_AUTH_TOKEN": "",
|
|
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
|
},
|
|
}
|
|
assert launched["args"][3:] == []
|
|
|
|
@pytest.mark.parametrize(
|
|
("flag_headers", "local_headers", "project_headers", "user_headers", "expected"),
|
|
[
|
|
("X-Flag: f", "X-Local: l", "X-Proj: p", "X-User: u", "X-Flag: f\nx-litellm-api-key: Bearer sk-key"),
|
|
(None, "X-Local: l", "X-Proj: p", "X-User: u", "X-Local: l\nx-litellm-api-key: Bearer sk-key"),
|
|
(
|
|
None,
|
|
None,
|
|
"X-Proj: p\nx-litellm-api-key: Bearer stale",
|
|
"X-User: u",
|
|
"X-Proj: p\nx-litellm-api-key: Bearer sk-key",
|
|
),
|
|
(None, None, None, "X-User: u", "X-User: u\nx-litellm-api-key: Bearer sk-key"),
|
|
(None, None, None, None, "X-Proc: h\nx-litellm-api-key: Bearer sk-key"),
|
|
],
|
|
)
|
|
def test_subscription_overlay_keeps_the_custom_headers_claude_code_would_apply_and_adds_the_key(
|
|
self, tmp_path, flag_headers, local_headers, project_headers, user_headers, expected
|
|
):
|
|
"""Claude Code applies settings `env` over the process env, in precedence order, so the overlay must too."""
|
|
user_flag = (
|
|
["--settings", json.dumps({"env": {"ANTHROPIC_CUSTOM_HEADERS": flag_headers}})] if flag_headers else []
|
|
)
|
|
files = {
|
|
tmp_path / "repo" / ".claude" / "settings.local.json": local_headers,
|
|
tmp_path / "repo" / ".claude" / "settings.json": project_headers,
|
|
tmp_path / "cfg" / "settings.json": user_headers,
|
|
}
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", *user_flag],
|
|
subscription=True,
|
|
base_env={"ANTHROPIC_CUSTOM_HEADERS": "X-Proc: h", "CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a), env=dict(e)),
|
|
read_settings=lambda path: {"env": {"ANTHROPIC_CUSTOM_HEADERS": files[path]}} if files[path] else {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
project_dir=tmp_path / "repo",
|
|
)
|
|
assert _overlay(launched["args"])["env"]["ANTHROPIC_CUSTOM_HEADERS"] == expected
|
|
assert launched["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "X-Proc: h\nx-litellm-api-key: Bearer sk-key"
|
|
|
|
def test_subscription_launch_strips_every_settings_flag_and_merges_the_last_one_claude_code_would_keep(
|
|
self, tmp_path
|
|
):
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
[
|
|
"claude",
|
|
"--settings",
|
|
'{"env": {"ANTHROPIC_AUTH_TOKEN": "first"}, "theme": "light"}',
|
|
"--resume",
|
|
'--settings={"theme": "dark"}',
|
|
],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda p, a, e: launched.update(args=list(a)),
|
|
read_settings=lambda path: {},
|
|
write_overlay=_overlay_writer(tmp_path),
|
|
)
|
|
assert launched["args"][:2] == ["claude", "--settings"]
|
|
assert _overlay(launched["args"]) == {
|
|
"theme": "dark",
|
|
"env": {
|
|
"ANTHROPIC_AUTH_TOKEN": "",
|
|
"ANTHROPIC_API_KEY": "",
|
|
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
|
},
|
|
}
|
|
assert launched["args"][3:] == ["--resume"]
|
|
|
|
def test_subscription_launch_reports_a_malformed_settings_file_as_a_clean_cli_error(self):
|
|
def broken(path):
|
|
raise ClaudeSettingsError(f"{path} contains invalid JSON")
|
|
|
|
with pytest.raises(AgentRunError, match="contains invalid JSON"):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
read_settings=broken,
|
|
)
|
|
|
|
def test_subscription_launch_rejects_a_settings_flag_that_is_not_a_json_object(self):
|
|
with pytest.raises(AgentRunError, match="is not a JSON object"):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude", "--settings", '{"env": 1, '],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=lambda *a: None,
|
|
launcher=lambda *a: None,
|
|
read_settings=lambda path: {},
|
|
)
|
|
|
|
def test_codex_subscription_launch_keeps_the_chatgpt_login_and_needs_no_settings_overlay(self):
|
|
probed = []
|
|
overlays = []
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex", "exec", "--settings", "kept"],
|
|
subscription=True,
|
|
base_env={"PATH": "/usr/bin"},
|
|
which=lambda name: "/usr/local/bin/codex",
|
|
verify=lambda *a: probed.append(("bearer", a)),
|
|
verify_subscription=lambda *a: probed.append(("header", a)),
|
|
sync_models=lambda *a: {},
|
|
preparers={},
|
|
write_overlay=lambda text, environ: overlays.append(text),
|
|
launcher=lambda p, a, e: launched.update(path=p, args=tuple(a), env=dict(e)),
|
|
)
|
|
assert probed == [("header", ("http://localhost:4000", "sk-key"))]
|
|
assert overlays == []
|
|
assert launched["path"] == "/usr/local/bin/codex"
|
|
assert launched["args"][0] == "codex"
|
|
assert launched["args"][-3:] == ("exec", "--settings", "kept")
|
|
assert _codex_config(launched["args"][1:-3])["model_provider"] == "lite_subscription"
|
|
assert "OPENAI_BASE_URL" not in launched["env"]
|
|
assert launched["env"]["LITELLM_PROXY_API_KEY"] == "sk-key"
|
|
assert "OPENAI_API_KEY" not in launched["env"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"sync_result",
|
|
[ModelSyncArgs(("-c", 'model_catalog_json="/tmp/c.json"')), ModelSyncSkipped("lists no chat models")],
|
|
)
|
|
def test_codex_subscription_launch_leaves_the_model_list_to_codex(self, sync_result):
|
|
warnings = []
|
|
launched = {}
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex"],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/codex",
|
|
verify_subscription=lambda *a: None,
|
|
sync_models=lambda *a: sync_result,
|
|
warn=warnings.append,
|
|
launcher=lambda p, a, e: launched.update(args=tuple(a)),
|
|
)
|
|
assert warnings == []
|
|
assert "model_catalog_json" not in json.dumps(launched["args"])
|
|
|
|
def test_default_launch_still_probes_with_the_bearer(self):
|
|
probed = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify=lambda *a: probed.append(("bearer", a)),
|
|
verify_subscription=lambda *a: probed.append(("header", a)),
|
|
launcher=lambda *a: None,
|
|
)
|
|
assert probed == [("bearer", ("http://localhost:4000", "sk-key"))]
|
|
|
|
def test_subscription_rejection_aborts_before_launch(self):
|
|
launched = []
|
|
|
|
def boom(*a):
|
|
raise AgentRunError("rejected")
|
|
|
|
with pytest.raises(AgentRunError):
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
subscription=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
verify_subscription=boom,
|
|
launcher=lambda *a: launched.append(a),
|
|
)
|
|
assert launched == []
|
|
|
|
def test_reattach_terminal_runs_just_before_launch(self):
|
|
order = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
skip_verify=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
launcher=lambda *a: order.append("launch"),
|
|
reattach_terminal=lambda: order.append("reattach"),
|
|
)
|
|
assert order == ["reattach", "launch"]
|
|
|
|
def test_no_reattach_terminal_by_default(self):
|
|
order = []
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["claude"],
|
|
skip_verify=True,
|
|
base_env={},
|
|
which=lambda name: "/usr/local/bin/claude",
|
|
launcher=lambda *a: order.append("launch"),
|
|
)
|
|
assert order == ["launch"]
|
|
|
|
|
|
_WINDOWS_CLAUDE_EXE = "C:\\Program Files\\Claude\\claude.exe"
|
|
_WINDOWS_CLAUDE_CMD = "C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd"
|
|
_AGENT_ENV = {"ANTHROPIC_BASE_URL": "http://localhost:4000"}
|
|
_CMD_PREFIX = "cmd.exe /d /e:on /v:off /s /c "
|
|
|
|
|
|
def _shim_command_line(*args):
|
|
spawn = _Recorder(returns=0)
|
|
with pytest.raises(SystemExit):
|
|
_hand_off(
|
|
_WINDOWS_CLAUDE_CMD,
|
|
["claude", *args],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=spawn,
|
|
)
|
|
return spawn.calls[0][0]
|
|
|
|
|
|
class TestHandOff:
|
|
def test_windows_spawns_child_instead_of_exec(self):
|
|
replace = _Recorder()
|
|
spawn = _Recorder(returns=0)
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_hand_off(
|
|
_WINDOWS_CLAUDE_EXE,
|
|
["claude", "--resume"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=replace,
|
|
spawn=spawn,
|
|
)
|
|
|
|
assert excinfo.value.code == 0
|
|
assert replace.calls == []
|
|
assert spawn.calls == [
|
|
((_WINDOWS_CLAUDE_EXE, "--resume"), _AGENT_ENV),
|
|
]
|
|
|
|
@pytest.mark.parametrize("code", [1, 42, 130])
|
|
def test_windows_propagates_child_exit_code(self, code):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_hand_off(
|
|
_WINDOWS_CLAUDE_EXE,
|
|
["claude"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=_Recorder(returns=code),
|
|
)
|
|
assert excinfo.value.code == code
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
_WINDOWS_CLAUDE_CMD,
|
|
"C:\\shims\\claude.CMD",
|
|
"C:\\shims\\claude.bat",
|
|
],
|
|
)
|
|
def test_windows_batch_shim_goes_through_cmd_exe(self, path):
|
|
spawn = _Recorder(returns=0)
|
|
|
|
with pytest.raises(SystemExit):
|
|
_hand_off(
|
|
path,
|
|
["claude", "--resume"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=spawn,
|
|
)
|
|
|
|
assert spawn.calls[0][0] == f'{_CMD_PREFIX}""{path}" "--resume""'
|
|
|
|
def test_windows_shim_quotes_a_path_containing_spaces(self):
|
|
spawn = _Recorder(returns=0)
|
|
path = "C:\\Program Files\\npm\\claude.cmd"
|
|
|
|
with pytest.raises(SystemExit):
|
|
_hand_off(
|
|
path,
|
|
["claude", "-p", "hello world"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=spawn,
|
|
)
|
|
|
|
expected = f'{_CMD_PREFIX}""C:\\Program Files\\npm\\claude.cmd" "-p" "hello world""'
|
|
assert spawn.calls[0][0] == expected
|
|
|
|
@pytest.mark.parametrize("payload", ["a&calc", "a|calc", "a>out", "a^b", "a&&calc"])
|
|
def test_windows_shim_never_leaves_a_metacharacter_unquoted(self, payload):
|
|
expected = f'{_CMD_PREFIX}""{_WINDOWS_CLAUDE_CMD}" "-p" "{payload}""'
|
|
assert _shim_command_line("-p", payload) == expected
|
|
|
|
def test_windows_shim_doubles_an_embedded_quote(self):
|
|
assert _shim_command_line("-p", 'say "hi"').endswith('"-p" "say ""hi""""')
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload, quoted",
|
|
[
|
|
("%PATH%", "%%cd:~,%PATH%%cd:~,%"),
|
|
("100%", "100%%cd:~,%"),
|
|
("%OS%%CD%", "%%cd:~,%OS%%cd:~,%%%cd:~,%CD%%cd:~,%"),
|
|
],
|
|
)
|
|
def test_windows_shim_stops_cmd_expanding_a_percent_variable(self, payload, quoted):
|
|
assert _shim_command_line("-p", payload).endswith(f'"-p" "{quoted}""')
|
|
|
|
def test_windows_shim_guards_a_percent_in_the_shim_path(self):
|
|
spawn = _Recorder(returns=0)
|
|
path = "C:\\dev%HOME%\\claude.cmd"
|
|
|
|
with pytest.raises(SystemExit):
|
|
_hand_off(
|
|
path,
|
|
["claude"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=spawn,
|
|
)
|
|
|
|
assert spawn.calls[0][0] == f'{_CMD_PREFIX}""C:\\dev%%cd:~,%HOME%%cd:~,%\\claude.cmd""'
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload, quoted",
|
|
[
|
|
("C:\\dir\\", "C:\\dir\\\\"),
|
|
('say \\"hi', 'say \\\\""hi'),
|
|
('a\\\\"b', 'a\\\\\\\\""b'),
|
|
],
|
|
)
|
|
def test_windows_shim_doubles_backslashes_that_precede_a_quote(self, payload, quoted):
|
|
assert _shim_command_line("-p", payload).endswith(f'"-p" "{quoted}""')
|
|
|
|
@pytest.mark.parametrize("payload", ["one\ntwo", "one\r\ntwo", "trailing\r"])
|
|
def test_windows_shim_refuses_an_argument_holding_a_line_break(self, payload):
|
|
with pytest.raises(AgentRunError, match="line break"):
|
|
_hand_off(
|
|
_WINDOWS_CLAUDE_CMD,
|
|
["claude", "-p", payload],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=_Recorder(returns=0),
|
|
)
|
|
|
|
def test_windows_shim_keeps_the_switches_the_quoting_depends_on(self):
|
|
command = _shim_command_line("-p", "hi")
|
|
assert command.startswith("cmd.exe ")
|
|
switches = command.split(" /c ")[0].split()[1:]
|
|
assert switches == ["/d", "/e:on", "/v:off", "/s"]
|
|
|
|
def test_windows_exe_is_not_wrapped_in_cmd_exe(self):
|
|
spawn = _Recorder(returns=0)
|
|
with pytest.raises(SystemExit):
|
|
_hand_off(
|
|
_WINDOWS_CLAUDE_EXE,
|
|
["claude"],
|
|
_AGENT_ENV,
|
|
platform="win32",
|
|
replace=_Recorder(),
|
|
spawn=spawn,
|
|
)
|
|
assert spawn.calls[0][0] == (_WINDOWS_CLAUDE_EXE,)
|
|
|
|
@pytest.mark.parametrize("platform", ["darwin", "linux", "freebsd8"])
|
|
def test_posix_still_replaces_the_process(self, platform):
|
|
replace = _Recorder()
|
|
spawn = _Recorder(returns=0)
|
|
|
|
_hand_off(
|
|
"/usr/local/bin/claude",
|
|
["claude", "--resume"],
|
|
_AGENT_ENV,
|
|
platform=platform,
|
|
replace=replace,
|
|
spawn=spawn,
|
|
)
|
|
|
|
assert spawn.calls == []
|
|
assert replace.calls == [
|
|
("/usr/local/bin/claude", ["claude", "--resume"], _AGENT_ENV),
|
|
]
|
|
path, args, env = replace.calls[0]
|
|
assert isinstance(args, list)
|
|
assert isinstance(env, dict)
|
|
|
|
def test_replace_process_calls_execvpe_with_argv_and_env(self):
|
|
execvpe = _Recorder()
|
|
|
|
_replace_process(
|
|
"/usr/local/bin/claude",
|
|
("claude", "--resume"),
|
|
_AGENT_ENV,
|
|
execvpe=execvpe,
|
|
)
|
|
|
|
assert execvpe.calls == [
|
|
("/usr/local/bin/claude", ["claude", "--resume"], _AGENT_ENV),
|
|
]
|
|
_path, argv, env = execvpe.calls[0]
|
|
assert isinstance(argv, list)
|
|
assert isinstance(env, dict)
|
|
|
|
def test_posix_default_replacement_is_execvpe(self):
|
|
assert _default_of(run_agent, "launcher") is _hand_off
|
|
assert _default_of(_hand_off, "replace") is _replace_process
|
|
assert _default_of(_replace_process, "execvpe") is os.execvpe
|
|
assert _default_of(_hand_off, "spawn") is _spawn_and_wait
|
|
assert _default_of(_hand_off, "platform") == sys.platform
|
|
|
|
def test_spawn_and_wait_blocks_until_the_child_is_done(self, tmp_path):
|
|
marker = tmp_path / "child-finished"
|
|
script = (
|
|
"import os, pathlib, time; time.sleep(0.5); "
|
|
"pathlib.Path(os.environ['MARKER']).write_text('done'); "
|
|
"raise SystemExit(int(os.environ['RC']))"
|
|
)
|
|
|
|
code = _spawn_and_wait(
|
|
[sys.executable, "-c", script],
|
|
{"RC": "7", "MARKER": str(marker), "PATH": os.environ.get("PATH", "")},
|
|
)
|
|
|
|
assert marker.read_text() == "done"
|
|
assert code == 7
|
|
|
|
def test_windows_run_agent_spawns_resolved_binary_with_proxy_args(self):
|
|
spawn = _Recorder(returns=3)
|
|
replace = _Recorder()
|
|
|
|
def launcher(path, args, env):
|
|
_hand_off(path, args, env, platform="win32", replace=replace, spawn=spawn)
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
run_agent(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
["codex", "exec", "do a thing"],
|
|
skip_verify=True,
|
|
base_env={},
|
|
which=lambda name: _WINDOWS_CLAUDE_CMD.replace("claude", "codex"),
|
|
launcher=launcher,
|
|
)
|
|
|
|
assert excinfo.value.code == 3
|
|
assert replace.calls == []
|
|
command, env = spawn.calls[0]
|
|
shim = _WINDOWS_CLAUDE_CMD.replace("claude", "codex")
|
|
assert command.startswith(f'{_CMD_PREFIX}""{shim}" ')
|
|
assert command.endswith('"exec" "do a thing""')
|
|
assert '"model_provider=""litellm"""' in command
|
|
assert env["OPENAI_API_KEY"] == "sk-key"
|
|
|
|
|
|
class TestAgentCommands:
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
|
|
def test_one_command_per_known_agent(self):
|
|
assert {c.name for c in agent_commands()} == {"claude", "codex", "opencode", "pi"}
|
|
|
|
@pytest.mark.parametrize("agent", [c.name for c in agent_commands()])
|
|
def test_lite_runs_every_known_agent_command(self, agent):
|
|
result = self.runner.invoke(lite, [agent, "--help"], prog_name="lite")
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert result.output.splitlines()[0].startswith(f"Usage: lite {agent} ")
|
|
|
|
def test_pi_is_hidden_from_help_but_still_registered(self):
|
|
hidden_by_name = {c.name: c.hidden for c in agent_commands()}
|
|
assert hidden_by_name == {"claude": False, "codex": False, "opencode": False, "pi": True}
|
|
|
|
def test_claude_launches_with_stored_key_and_forwards_args(self):
|
|
captured = {}
|
|
|
|
def fake_run_agent(base_url, api_key, command, **kwargs):
|
|
captured["base_url"] = base_url
|
|
captured["api_key"] = api_key
|
|
captured["command"] = list(command)
|
|
captured["skip_verify"] = kwargs.get("skip_verify")
|
|
|
|
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=fake_run_agent):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
["--resume", "-p", "hi"],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["api_key"] == "sk-key"
|
|
assert captured["command"] == ["claude", "--resume", "-p", "hi"]
|
|
assert captured["skip_verify"] is False
|
|
assert "routing Claude Code through proxy at http://localhost:4000" in result.output
|
|
|
|
def test_codex_shows_friendly_name(self):
|
|
captured = {}
|
|
with patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda b, k, c, **kw: captured.update(command=list(c)),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("codex"),
|
|
["exec", "do a thing"],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["command"] == ["codex", "exec", "do a thing"]
|
|
assert "routing Codex through proxy" in result.output
|
|
|
|
def test_opencode_launches_through_the_proxy(self):
|
|
captured = {}
|
|
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(command=list(c))):
|
|
result = self.runner.invoke(
|
|
_agent_command("opencode"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["command"] == ["opencode"]
|
|
assert "routing OpenCode through proxy at http://localhost:4000" in result.output
|
|
|
|
@pytest.mark.parametrize(
|
|
("agent", "user_args", "login"),
|
|
[("claude", ["--resume"], "claude.ai"), ("codex", ["exec", "hi"], "ChatGPT")],
|
|
)
|
|
def test_subscription_flag_reaches_run_agent_and_is_not_forwarded(self, agent, user_args, login):
|
|
captured = {}
|
|
|
|
def fake_run_agent(base_url, api_key, command, **kwargs):
|
|
captured["command"] = list(command)
|
|
captured["subscription"] = kwargs.get("subscription")
|
|
|
|
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=fake_run_agent):
|
|
result = self.runner.invoke(
|
|
_agent_command(agent),
|
|
["--subscription", *user_args],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["subscription"] is True
|
|
assert captured["command"] == [agent, *user_args]
|
|
assert f"keeping your {login} login" in result.output
|
|
|
|
@pytest.mark.parametrize(("agent", "login"), [("claude", "claude.ai"), ("codex", "ChatGPT")])
|
|
@pytest.mark.parametrize(
|
|
("saved", "args", "expected"),
|
|
[
|
|
(None, [], False),
|
|
("false", [], False),
|
|
("true", [], True),
|
|
("true", ["--no-subscription"], False),
|
|
("false", ["--subscription"], True),
|
|
],
|
|
)
|
|
def test_subscription_comes_from_the_flag_else_the_saved_setting(self, agent, login, saved, args, expected):
|
|
if saved is not None:
|
|
save_config({f"{agent}_subscription": saved})
|
|
captured = {}
|
|
with patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda b, k, c, **kw: captured.update(sub=kw.get("subscription"), command=list(c)),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command(agent), args, obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["sub"] is expected
|
|
assert captured["command"] == [agent]
|
|
assert (f"keeping your {login} login" in result.output) is expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("saved_key", "agent"),
|
|
[
|
|
("claude_subscription", "codex"),
|
|
("claude_subscription", "opencode"),
|
|
("codex_subscription", "claude"),
|
|
("codex_subscription", "opencode"),
|
|
],
|
|
)
|
|
def test_a_saved_subscription_setting_only_changes_its_own_agent(self, saved_key, agent):
|
|
save_config({saved_key: "true"})
|
|
captured = {}
|
|
with patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda b, k, c, **kw: captured.update(sub=kw.get("subscription")),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command(agent), [], obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["sub"] is False
|
|
assert "keeping your" not in result.output
|
|
|
|
def test_only_claude_and_codex_offer_the_subscription_flag(self):
|
|
offering = {
|
|
command.name
|
|
for command in agent_commands()
|
|
if "--subscription" in {opt for param in command.params for opt in param.opts}
|
|
}
|
|
assert offering == {"claude", "codex"}
|
|
|
|
@pytest.mark.parametrize(
|
|
("agent", "login", "own_credential"),
|
|
[("claude", "claude.ai", "Claude Code's"), ("codex", "ChatGPT", "Codex's")],
|
|
)
|
|
def test_subscription_help_names_the_agents_own_login_and_its_setting(self, agent, login, own_credential):
|
|
result = self.runner.invoke(lite, [agent, "--help"], prog_name="lite")
|
|
assert result.exit_code == 0, result.output
|
|
text = " ".join(result.output.split())
|
|
assert f"Keep using your {login} login" in text
|
|
assert f"instead of replacing {own_credential} own credential" in text
|
|
assert f"lite config set {agent}_subscription true" in text
|
|
|
|
def test_skip_verify_is_consumed_not_forwarded(self):
|
|
captured = {}
|
|
|
|
def fake_run_agent(base_url, api_key, command, **kwargs):
|
|
captured["command"] = list(command)
|
|
captured["skip_verify"] = kwargs.get("skip_verify")
|
|
|
|
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=fake_run_agent):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
["--skip-verify", "--resume"],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["skip_verify"] is True
|
|
assert captured["command"] == ["claude", "--resume"]
|
|
|
|
def test_non_interactive_without_key_errors_clearly(self):
|
|
with (
|
|
patch(f"{AGENTS_MODULE}._is_interactive", return_value=False),
|
|
patch(f"{AGENTS_MODULE}.run_agent") as mock_run,
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": None},
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "LITELLM_PROXY_API_KEY" in result.output
|
|
mock_run.assert_not_called()
|
|
|
|
def test_interactive_without_key_logs_in_then_launches(self, secret_vault_factory):
|
|
captured = {}
|
|
vault = secret_vault_factory()
|
|
|
|
@click.command()
|
|
def fake_login():
|
|
pass
|
|
|
|
with (
|
|
patch(f"{AGENTS_MODULE}._is_interactive", return_value=True),
|
|
patch(f"{AGENTS_MODULE}.login", fake_login),
|
|
patch(f"{AGENTS_MODULE}.get_stored_api_key", return_value="sk-after-login") as mock_get,
|
|
patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda base_url, api_key, command, **k: captured.update(api_key=api_key),
|
|
),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": None, "secret_vault": vault},
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["api_key"] == "sk-after-login"
|
|
mock_get.assert_called_once_with(expected_base_url="http://localhost:4000", vault=vault)
|
|
|
|
def test_child_exit_code_reaches_the_shell(self):
|
|
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=SystemExit(42)):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
assert result.exit_code == 42
|
|
|
|
def test_agent_run_error_becomes_click_error(self):
|
|
with patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=AgentRunError("could not reach proxy"),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "could not reach proxy" in result.output
|
|
|
|
def test_interactive_session_reattaches_terminal_before_handoff(self):
|
|
from litellm.proxy.client.cli.commands.agents import (
|
|
_restore_controlling_terminal,
|
|
)
|
|
|
|
captured = {}
|
|
with (
|
|
patch(f"{AGENTS_MODULE}._is_interactive", return_value=True),
|
|
patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda b, k, c, **kw: captured.update(kw),
|
|
),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["reattach_terminal"] is _restore_controlling_terminal
|
|
|
|
def test_non_interactive_agent_mode_leaves_stdin_alone(self):
|
|
captured = {}
|
|
with (
|
|
patch(f"{AGENTS_MODULE}._is_interactive", return_value=False),
|
|
patch(
|
|
f"{AGENTS_MODULE}.run_agent",
|
|
side_effect=lambda b, k, c, **kw: captured.update(kw),
|
|
),
|
|
):
|
|
result = self.runner.invoke(
|
|
_agent_command("claude"),
|
|
[],
|
|
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["reattach_terminal"] is None
|
|
|
|
|
|
class TestPrepareCodex:
|
|
def test_registers_the_installed_script_as_a_session_scoped_stop_hook(self):
|
|
from litellm.proxy.client.cli.commands.agents import prepare_codex
|
|
|
|
args = prepare_codex(
|
|
"http://localhost:4000", "sk-key", {}, install=lambda: "/py /home/me/.litellm/statusline.py"
|
|
)
|
|
assert args == (
|
|
"-c",
|
|
'hooks.Stop=[{hooks=[{type="command",command="/py /home/me/.litellm/statusline.py"}]}]',
|
|
)
|
|
|
|
def test_a_failed_install_is_an_agent_error_not_a_crash(self):
|
|
from litellm.proxy.client.cli.commands.agents import AgentRunError, prepare_codex
|
|
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
|
|
|
|
def boom():
|
|
raise ClaudeSettingsError("disk full")
|
|
|
|
with pytest.raises(AgentRunError, match="disk full"):
|
|
prepare_codex("http://localhost:4000", "sk-key", {}, install=boom)
|
|
|
|
def test_a_config_that_already_declares_hooks_keeps_them_and_skips_ours(self, tmp_path):
|
|
from litellm.proxy.client.cli.commands.agents import prepare_codex
|
|
|
|
warnings = []
|
|
env = {"CODEX_HOME": str(tmp_path)}
|
|
for body in (
|
|
'[[hooks.Stop]]\nhooks = [{ type = "command", command = "mine" }]\n',
|
|
"hooks.Stop = []\n",
|
|
"[hooks]\n",
|
|
):
|
|
(tmp_path / "config.toml").write_text(body)
|
|
assert (
|
|
prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append)
|
|
== ()
|
|
)
|
|
(tmp_path / "config.toml").write_text('model = "gpt-5.6-sol"\n[projects."/x"]\ntrust_level = "trusted"\n')
|
|
assert (
|
|
prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append) != ()
|
|
)
|
|
assert len(warnings) == 3 and "already declares hooks" in warnings[0]
|
|
|
|
def test_a_config_that_cannot_be_read_or_decoded_still_lets_codex_launch(self, tmp_path):
|
|
# A UTF-16 config.toml (a Windows Notepad save) is Codex's problem to report at launch, not a reason
|
|
# for the hook pre-check to abort `lite codex` with a traceback before Codex ever starts.
|
|
from litellm.proxy.client.cli.commands.agents import codex_declares_stop_hooks, prepare_codex
|
|
|
|
config = tmp_path / "config.toml"
|
|
config.write_bytes('[[hooks.Stop]]\nhooks = [{ type = "command", command = "mine" }]\n'.encode("utf-16"))
|
|
assert codex_declares_stop_hooks(config) is False
|
|
assert codex_declares_stop_hooks(tmp_path / "absent.toml") is False
|
|
args = prepare_codex("http://localhost:4000", "sk", {"CODEX_HOME": str(tmp_path)}, install=lambda: "/py /s.py")
|
|
assert args[0] == "-c" and "hooks.Stop=" in args[1]
|
|
|
|
def test_codex_is_wired_through_the_preparer_registry(self):
|
|
from litellm.proxy.client.cli.commands.agents import _PREPARERS, prepare_codex
|
|
|
|
assert _PREPARERS["codex"] is prepare_codex
|