mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(cli): let the apiKeyHelper supply Claude Code's key under lite claude
`lite login --config-claude` writes an apiKeyHelper into ~/.claude/settings.json, and `lite claude` then also exported ANTHROPIC_AUTH_TOKEN, so Claude Code opened with its "Both ANTHROPIC_AUTH_TOKEN and apiKeyHelper set" banner and, because the env token wins, never ran the helper that was meant to refresh the key. When the launch key is the stored login key and settings.json carries exactly the helper lite wrote for this base URL, `lite claude` now leaves the token out of the env (dropping an inherited one) so Claude Code asks the helper. An explicit --api-key or LITELLM_PROXY_API_KEY, a helper for another proxy, a hand-written helper, or no settings file keep the previous behavior.
This commit is contained in:
parent
2c836f473c
commit
7e137958e9
4 changed files with 193 additions and 3 deletions
|
|
@ -12,6 +12,7 @@ import requests
|
|||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from .auth import CliContextObj, context_secret_vault, get_stored_api_key, login
|
||||
from .claude_settings import CLAUDE_SETTINGS_PATH, lite_api_key_helper_configured
|
||||
from .cmd_quoting import quote_for_cmd
|
||||
from .pi import (
|
||||
LITELLM_PROXY_API_KEY_ENV,
|
||||
|
|
@ -83,6 +84,8 @@ def build_agent_env(
|
|||
base_url: str,
|
||||
api_key: str,
|
||||
profiles: frozenset[str],
|
||||
*,
|
||||
export_anthropic_token: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""Return a copy of base_env wired to route the agent through the proxy.
|
||||
|
||||
|
|
@ -97,12 +100,19 @@ def build_agent_env(
|
|||
proxy's /v1/models; likewise left alone when already set.
|
||||
pi ignores both base URL variables and instead resolves $LITELLM_PROXY_API_KEY
|
||||
from its synced models.json provider entry.
|
||||
|
||||
With export_anthropic_token=False the bearer is left out (and any inherited
|
||||
one dropped) so Claude Code asks its configured apiKeyHelper instead; Claude
|
||||
Code prefers ANTHROPIC_AUTH_TOKEN over the helper and warns when both are set.
|
||||
"""
|
||||
env: Final = dict(base_env)
|
||||
root: Final = base_url.rstrip("/")
|
||||
if PROFILE_ANTHROPIC in profiles:
|
||||
env[ANTHROPIC_BASE_URL_ENV] = root
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
|
||||
if export_anthropic_token:
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
|
||||
else:
|
||||
env.pop(ANTHROPIC_AUTH_TOKEN_ENV, None)
|
||||
env.pop(ANTHROPIC_API_KEY_ENV, None)
|
||||
if ENABLE_TOOL_SEARCH_ENV not in env:
|
||||
env[ENABLE_TOOL_SEARCH_ENV] = ENABLE_TOOL_SEARCH_VALUE
|
||||
|
|
@ -460,6 +470,7 @@ def run_agent(
|
|||
launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _hand_off,
|
||||
reattach_terminal: Callable[[], None] | None = None,
|
||||
preparers: Mapping[str, _Preparer] = MappingProxyType(_PREPARERS),
|
||||
export_anthropic_token: bool = True,
|
||||
) -> None:
|
||||
"""Validate, wire the environment, and hand off to the agent.
|
||||
|
||||
|
|
@ -491,7 +502,9 @@ def run_agent(
|
|||
|
||||
env: Final = MappingProxyType(
|
||||
{
|
||||
**build_agent_env(env_before_sync, base_url, api_key, profiles),
|
||||
**build_agent_env(
|
||||
env_before_sync, base_url, api_key, profiles, export_anthropic_token=export_anthropic_token
|
||||
),
|
||||
**(_NO_EXTRA_ENV if isinstance(synced, ModelSyncSkipped) else synced),
|
||||
}
|
||||
)
|
||||
|
|
@ -529,14 +542,23 @@ def resolve_api_key(ctx: click.Context) -> str:
|
|||
_SKIP_VERIFY_HELP: Final = "Skip the pre-launch key check against the proxy."
|
||||
|
||||
|
||||
def _helper_supplies_token(ctx_obj: CliContextObj, base_url: str, profiles: frozenset[str]) -> bool:
|
||||
if PROFILE_ANTHROPIC not in profiles or not ctx_obj.get("api_key_from_token_file"):
|
||||
return False
|
||||
return lite_api_key_helper_configured(base_url, CLAUDE_SETTINGS_PATH)
|
||||
|
||||
|
||||
def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool) -> None:
|
||||
ctx_obj: Final[CliContextObj] = ctx.obj
|
||||
base_url: Final = ctx_obj["base_url"]
|
||||
started_interactive: Final = _is_interactive()
|
||||
api_key: Final = resolve_api_key(ctx)
|
||||
|
||||
display_name, _ = agent_profile(binary)
|
||||
display_name, profiles = agent_profile(binary)
|
||||
helper_supplies_token: Final = _helper_supplies_token(ctx_obj, base_url, profiles)
|
||||
click.echo(f"litellm: routing {display_name} through proxy at {base_url.rstrip('/')}")
|
||||
if helper_supplies_token:
|
||||
click.echo(f"litellm: {display_name} reads its key from the apiKeyHelper in {CLAUDE_SETTINGS_PATH}")
|
||||
|
||||
try:
|
||||
run_agent(
|
||||
|
|
@ -545,6 +567,7 @@ def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify
|
|||
[binary, *args],
|
||||
skip_verify=skip_verify,
|
||||
reattach_terminal=(_restore_controlling_terminal if started_interactive else None),
|
||||
export_anthropic_token=not helper_supplies_token,
|
||||
)
|
||||
except AgentRunError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
|
|
|||
|
|
@ -121,6 +121,19 @@ def resolve_api_key_helper(base_url: str, platform: str = sys.platform) -> str:
|
|||
return " ".join(quote(token) for token in (lite_path, "--base-url", base_url, "auth", "print-token"))
|
||||
|
||||
|
||||
def lite_api_key_helper_configured(base_url: str, settings_path: Path) -> bool:
|
||||
"""Whether settings_path already carries the apiKeyHelper `lite login --config-claude` writes for base_url.
|
||||
|
||||
Only an exact match counts: a helper for another proxy, a hand-written one, or
|
||||
settings that cannot be read leave the caller on the env-token path.
|
||||
"""
|
||||
try:
|
||||
configured_helper: Final = load_json_or_empty(settings_path).get(API_KEY_HELPER_KEY)
|
||||
return configured_helper == resolve_api_key_helper(base_url.rstrip("/"))
|
||||
except ClaudeSettingsError:
|
||||
return False
|
||||
|
||||
|
||||
def write_claude_settings(base_url: str, settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None:
|
||||
"""Persistently point Claude Code at base_url, preserving every unrelated setting.
|
||||
|
||||
|
|
@ -169,6 +182,7 @@ __all__ = (
|
|||
"SETTINGS_FILE_OWNERS",
|
||||
"ClaudeSettingsError",
|
||||
"SettingsFileOwner",
|
||||
"lite_api_key_helper_configured",
|
||||
"load_json_or_empty",
|
||||
"merge_claude_settings",
|
||||
"resolve_api_key_helper",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from litellm.proxy.client.cli.commands.agents import (
|
|||
)
|
||||
|
||||
AGENTS_MODULE = "litellm.proxy.client.cli.commands.agents"
|
||||
CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings"
|
||||
|
||||
|
||||
def _agent_command(name):
|
||||
|
|
@ -163,6 +164,27 @@ class TestBuildAgentEnv:
|
|||
assert env["PATH"] == "/usr/bin"
|
||||
assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
|
||||
|
||||
def test_anthropic_profile_leaves_the_bearer_to_the_api_key_helper(self):
|
||||
env = build_agent_env(
|
||||
{"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"},
|
||||
"http://localhost:4000/",
|
||||
"sk-key",
|
||||
frozenset({"anthropic"}),
|
||||
export_anthropic_token=False,
|
||||
)
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
||||
assert env["ENABLE_TOOL_SEARCH"] == "true"
|
||||
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
||||
|
||||
def test_helper_mode_still_exports_the_openai_key(self):
|
||||
env = build_agent_env(
|
||||
{}, "http://localhost:4000", "sk-key", frozenset({"anthropic", "openai"}), export_anthropic_token=False
|
||||
)
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert env["OPENAI_API_KEY"] == "sk-key"
|
||||
|
||||
|
||||
class TestAgentLaunchArgs:
|
||||
def test_claude_and_opencode_get_no_extra_args(self):
|
||||
|
|
@ -509,6 +531,25 @@ class TestRunAgent:
|
|||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert "OPENAI_BASE_URL" not in env
|
||||
|
||||
def test_helper_supplied_token_never_reaches_the_launch_env(self):
|
||||
calls = {}
|
||||
verified = []
|
||||
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
base_env={"PATH": "/usr/bin", "ANTHROPIC_AUTH_TOKEN": "stale-token"},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify=lambda base_url, api_key: verified.append(api_key),
|
||||
launcher=lambda p, a, e: calls.update(env=dict(e)),
|
||||
export_anthropic_token=False,
|
||||
)
|
||||
|
||||
assert verified == ["sk-key"]
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in calls["env"]
|
||||
assert calls["env"]["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
||||
|
||||
def test_codex_gets_openai_env(self):
|
||||
calls = {}
|
||||
run_agent(
|
||||
|
|
@ -1052,6 +1093,75 @@ class TestAgentCommands:
|
|||
in result.output
|
||||
)
|
||||
|
||||
def _invoke_claude_with_settings(self, tmp_path, settings, obj):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
if settings is not None:
|
||||
settings_path.write_text(json.dumps(settings))
|
||||
captured = {}
|
||||
with (
|
||||
patch(f"{AGENTS_MODULE}.CLAUDE_SETTINGS_PATH", settings_path),
|
||||
patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value="/usr/local/bin/lite"),
|
||||
patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(kw)),
|
||||
):
|
||||
result = self.runner.invoke(_agent_command("claude"), [], obj=obj)
|
||||
assert result.exit_code == 0, result.output
|
||||
return captured, result.output
|
||||
|
||||
def test_stored_login_with_a_matching_helper_leaves_the_token_to_the_helper(self, tmp_path):
|
||||
captured, output = self._invoke_claude_with_settings(
|
||||
tmp_path,
|
||||
{"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
|
||||
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
|
||||
)
|
||||
|
||||
assert captured["export_anthropic_token"] is False
|
||||
assert "reads its key from the apiKeyHelper" in output
|
||||
|
||||
def test_explicit_key_is_exported_even_when_a_helper_matches(self, tmp_path):
|
||||
captured, output = self._invoke_claude_with_settings(
|
||||
tmp_path,
|
||||
{"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
|
||||
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": False},
|
||||
)
|
||||
|
||||
assert captured["export_anthropic_token"] is True
|
||||
assert "apiKeyHelper" not in output
|
||||
|
||||
def test_helper_for_another_proxy_keeps_the_env_token(self, tmp_path):
|
||||
captured, _ = self._invoke_claude_with_settings(
|
||||
tmp_path,
|
||||
{"apiKeyHelper": "/usr/local/bin/lite --base-url https://other.example.com auth print-token"},
|
||||
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
|
||||
)
|
||||
|
||||
assert captured["export_anthropic_token"] is True
|
||||
|
||||
def test_no_claude_settings_keeps_the_env_token(self, tmp_path):
|
||||
captured, _ = self._invoke_claude_with_settings(
|
||||
tmp_path,
|
||||
None,
|
||||
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
|
||||
)
|
||||
|
||||
assert captured["export_anthropic_token"] is True
|
||||
|
||||
def test_codex_never_consults_claude_settings(self, tmp_path):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
captured = {}
|
||||
with (
|
||||
patch(f"{AGENTS_MODULE}.CLAUDE_SETTINGS_PATH", settings_path),
|
||||
patch(f"{AGENTS_MODULE}.lite_api_key_helper_configured", side_effect=AssertionError("consulted")),
|
||||
patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(kw)),
|
||||
):
|
||||
result = self.runner.invoke(
|
||||
_agent_command("codex"),
|
||||
[],
|
||||
obj={"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["export_anthropic_token"] is True
|
||||
|
||||
def test_codex_shows_friendly_name(self):
|
||||
captured = {}
|
||||
with patch(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from litellm.proxy.client.cli.commands.claude_settings import (
|
|||
SETTINGS_FILE_OWNERS,
|
||||
ClaudeSettingsError,
|
||||
SettingsFileOwner,
|
||||
lite_api_key_helper_configured,
|
||||
resolve_api_key_helper,
|
||||
write_claude_settings,
|
||||
)
|
||||
|
|
@ -357,3 +358,45 @@ class TestDoesNotDestroyUserOwnedStructure:
|
|||
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
|
||||
|
||||
assert json.loads(settings_path.read_text())["env"] == "not-an-object"
|
||||
|
||||
|
||||
class TestLiteApiKeyHelperConfigured:
|
||||
def _settings(self, tmp_path, payload):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
settings_path.write_text(payload)
|
||||
return settings_path
|
||||
|
||||
def test_recognises_the_helper_lite_login_wrote_for_this_proxy(self, tmp_path, lite_on_path):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
write_claude_settings("https://proxy.example.com/", settings_path, ())
|
||||
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com/", settings_path) is True
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is True
|
||||
|
||||
def test_a_helper_for_another_proxy_does_not_count(self, tmp_path, lite_on_path):
|
||||
settings_path = tmp_path / "settings.json"
|
||||
write_claude_settings("https://other.example.com", settings_path, ())
|
||||
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
|
||||
|
||||
def test_a_hand_written_helper_does_not_count(self, tmp_path, lite_on_path):
|
||||
settings_path = self._settings(tmp_path, json.dumps({"apiKeyHelper": "cat ~/.my-proxy-key"}))
|
||||
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
|
||||
|
||||
def test_missing_or_helperless_settings_do_not_count(self, tmp_path, lite_on_path):
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", tmp_path / "absent.json") is False
|
||||
helperless = json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://proxy.example.com"}})
|
||||
settings_path = self._settings(tmp_path, helperless)
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
|
||||
|
||||
def test_unreadable_settings_fall_back_to_false(self, tmp_path, lite_on_path):
|
||||
settings_path = self._settings(tmp_path, "{not json")
|
||||
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
|
||||
|
||||
def test_lite_missing_from_path_falls_back_to_false(self, tmp_path):
|
||||
helper = "/usr/local/bin/lite --base-url https://proxy.example.com auth print-token"
|
||||
settings_path = self._settings(tmp_path, json.dumps({"apiKeyHelper": helper}))
|
||||
with patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value=None):
|
||||
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue