From dc378de207f8e79860d1fd52df26fcce092777e1 Mon Sep 17 00:00:00 2001 From: norkelbyte <348963847+norkelbyte@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:04:38 -0800 Subject: [PATCH] fix(cli): let Claude Code renew a --pkce login's key through lite's apiKeyHelper (#51319) * fix(cli): let Claude Code renew a --pkce login's key through lite's apiKeyHelper * fix(cli): keep an explicit --api-key over lite's apiKeyHelper and persist its absolute path An explicit --api-key or LITELLM_PROXY_API_KEY is now exported to Claude Code even when lite's renewing helper is configured, since the helper prints the stored login's key instead. The helper command also uses the absolute path of the lite found on PATH, so a relative PATH entry cannot make Claude Code run another repository's bin/lite * fix(cli): keep an explicit key and --pkce when lite's renewing helper is set A --settings that blanks apiKeyHelper now keeps the exported key, and a key passed with --api-key turns lite's helper off for that launch, so Claude Code no longer warns about two credentials or sends the stored login's key beside it The helper names the lite installed beside the Python that ran the login before one found on PATH, so a pyenv shim no longer fails in projects pinned to another Python lite claude signs in again with --pkce when the stored login used it, and print-token's hints name the proxy and --pkce * fix(cli): keep a shell token from beating the renewing helper and reuse --pkce only for the same proxy The --pkce --config-claude login now writes an empty env.ANTHROPIC_AUTH_TOKEN beside the helper, since a token exported in the shell wins over apiKeyHelper lite claude sends any other key through a private settings file that blanks the helper and merges the user's own --settings A re-login keeps --pkce only when the stored login is for the same proxy, else the saved default decides * fix(cli): let any lite install's helper renew for its proxy and name the renewing login in print-token hints lite claude now treats a lite auth print-token helper for this proxy as renewing whichever lite path it names, since every lite reads the same token store Every print-token hint to sign in again, including an expired key and an unreadable keychain, names lite login --pkce with the proxy Shorter docstrings * fix(cli): send a launch key to the proxy it was issued for The private settings file lite claude writes for a key that lite's helper would not renew now also sets ANTHROPIC_BASE_URL. Without it, lite --base-url B --api-key claude after a --pkce --config-claude login to A sent the B key to A, since the login's settings point Claude Code at A * fix(proxy): preserve caller metadata on auth failures (#51745) * fix(proxy): keep x-litellm-spend-logs-metadata on auth-rejected failure spend logs Budget, invalid-key and other auth-stage rejections are raised before the request headers are merged into metadata, so the persisted failure row lost the caller's spend logs metadata header. The auth failure handler now parses it with the same pre-call helper and stamps it into the metadata bucket it already uses for the requester IP and user agent. * fix(proxy): type the spend logs metadata header parser honestly The parser takes the request headers as a str mapping and returns the parsed JSON as object, which is what successful requests already store. The auth failure path keeps only JSON objects. The precedence regression now also asserts the caller's request body is left untouched. * fix(proxy): stamp the auth failure spend logs metadata into the route's bucket A chat request whose body also carried litellm_metadata got the header in litellm_metadata, which the failure spend row never reads on chat routes. The header now goes into the bucket picked by metadata_variable_name_for_request for the route, and the failure row lifts spend_logs_metadata from that bucket the same way it already lifts the requester IP and user agent. The regression tests now assert on the built failure spend row. * fix(proxy): keep the auth failure spend logs metadata fix inside the auth handler Restores the failure spend row's lifted fields to main, so failures after auth on litellm_metadata routes keep their current logging. The auth handler now writes the parsed header into metadata, which the failure spend row reads on every route, and into the route's own bucket for callbacks on litellm_metadata routes. * fix(proxy): make the spend logs metadata header field read-only and test the real spend writer The header collector now sets spend_logs_metadata when it is built, so the field can be ReadOnly with the same stored value. The auth failure tests run the real DBSpendUpdateWriter and read the queued spend log row from a fake database client instead of an autospec writer. * test(proxy): read auth failure spend rows from a real unconnected PrismaClient Replaces the mocked database client and the class-level queue byte reset. The test now enqueues into a real PrismaClient's own queue and dequeues its row in cleanup, which keeps the shared byte counter balanced. --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> * fix(logging): keep a content-block system prompt in logged messages (#51699) * fix(logging): keep a content-block system prompt in logged messages A /v1/messages request whose top-level system prompt is a list of content blocks never reached the logged messages, because only a plain-text system prompt was prepended. Accept a non-empty list too, so every logger that reads messages sees it. Claude on Vertex also normalizes a plain-text system prompt into blocks, so it was dropped there as well. Fixes #36402 * fix(logging): type the logged system message * fix(logging): type the system message without a mutable annotation, close the test client * refactor(mcp): let the semantic filter take its MCP tool loader as a parameter (#50483) * test(mcp): check the tool dict conversion without patching the MCP handler * refactor(mcp): let the semantic filter take its MCP tool loader as a parameter * refactor(mcp): type the semantic filter's default tool loader Annotate the chosen loader as _OpenAIToolLoader and narrow its return to FunctionToolParam, so a default that returns raw MCP tools fails the type check. Drop the unused patch import. * fix(mcp): give the MCP client exactly the servers left on in the connect page (#50973) * fix(cli): send the launch key under a project apiKeyHelper and give each key its own settings file A `lite login --pkce --config-claude` leaves an empty ANTHROPIC_AUTH_TOKEN in Claude Code's user settings. When a repo's project settings set their own apiKeyHelper, `lite claude` skipped its private settings file, so the empty token hid the exported key and Claude Code sent the project helper's key. `lite claude` now also writes that file whenever the settings files leave ANTHROPIC_AUTH_TOKEN empty Every key launch also wrote the one file `--subscription` uses, so two launches started together could read each other's key and proxy. A key launch's file is now named after a hash of its contents, and every overlay is written to a temp file and renamed so a starting Claude Code never reads half of one * fix(proxy): keep carried end-user spend when a budget reset is retried (#51143) * fix(proxy): keep carried end-user spend when a budget reset is retried End-user pages commit before the budget window advance. When the advance failed, the tier stayed due and the next tick replayed every page, so a rollover-capped row already cut to its carryover (15 -> 5) matched the under-cap zero and lost it, and spend accrued since the first pass was zeroed again. Each end-user reset now stamps the tier window it closes in a new spend_reset_window column and skips rows already stamped with it. Fixes #50898 * fix(proxy): leave never-reset tiers unguarded and test each tier's own window A tier with no budget_reset_at falls back to the old unguarded reset instead of stamping created_at, which an admin clearing the reset time would reuse and silently skip end users. Tests now give tiers distinct windows and cover a second window closing. * test(proxy): give the end-user ledger its own fake prisma client * fix(proxy): pin a reset time on tiers without one before end-user pages commit * fix(proxy): read the reset-time pin from the writer and skip tiers another sweep advanced * fix(proxy): keep linked rows of a tier another sweep advanced out of the rollover reset * fix(proxy): order the end-user reset window migration after upstream's same-timestamp migration * fix(proxy): reset end users again when an admin moves a tier's reset time back to a closed window * fix(proxy): clear end-user reset stamps in the same transaction as the tier's reset time A failed /budget/update used to leave the new reset time written with the stamps still set, so repeating the request found the time unchanged and skipped the clear. The update and the clear now commit together, and a reset time in the future no longer scans the tier's end users * fix(proxy): name each end-user reset window by the tier's last write Stamp end-user resets with the tier's updated_at read on the writer instead of its reset time. A failed window advance leaves updated_at as it was, so the retry still skips the end users already reset, while the advance or any /budget/update moves it and opens a new window. That covers a forced reset on the default end-user tier and drops the stamp clear, so /budget/update is the one-row update it is on main. The writer read also leaves out a tier another sweep already advanced, which a lagging read replica still showed as due * fix(proxy): name end-user reset windows by the tier and its advance count Tiers advanced in one batch share updated_at, so a customer moved between them could skip the new tier's reset, and any unrelated tier edit during a failed advance reopened the window. A budget_reset_count that only the window advance bumps, prefixed with the tier id, names the window instead. * fix(proxy): keep carried spend for customer teams and members on a retried reset Customer team and member pages commit before the window advance like end-user pages, so they now stamp the window they close and a retry skips them too. A member runs on its unexpired override when that has its own duration, else on its template. The window name now joins the tier id, its creation time, its count of closed windows and its reset time, so a duration change or a tier recreated under the same id opens a new window even when the advance failed. * test(integration): list every budget column and relation in the budget update field check --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> * test(otel): keep proxy startup out of the per-test timeout in the excluded services matrix (#51759) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): skip the working directory when the login looks up lite for its helper On Windows, shutil.which searches the current directory before the directories it is given, so a lite.exe planted in the repo where someone ran `lite login --pkce --config-claude` could become their user-level apiKeyHelper The login now checks each directory by full path, this Python's scripts directory first and then each PATH entry, skipping empty entries * fix(bedrock): answer 400 for service tiers Bedrock cannot serve, forward reserved (#50516) * fix(bedrock): answer 400 for service tiers Bedrock cannot serve, forward reserved Converse used to drop any service_tier outside default, flex, and priority with no error, rewrite auto to default, and drop reserved. Now reserved and standard_only map to Bedrock tiers, auto sends no tier, and any other value answers 400 unless drop_params is on. Nova batch rows honor the row's drop_params. * fix(bedrock): keep a Responses batch row's drop_params and keep it out of passthrough rows * test(bedrock): annotate service tier test locals as Final and drop the row helper's kwargs * fix(bedrock): refuse a service tier on Nova batch rows unless drop_params, and honor the deployment's drop_params * fix(bedrock): map standard and any-case service tiers, and drop tiers from Claude batch rows under the deployment's drop_params * fix(bedrock): forward the tiers OpenAI-format batch rows take, accept auto on Claude rows, and label tiers in lowercase * fix(bedrock): mark the service tier block field read-only * fix(bedrock): read drop_params given as a string on batch rows, and cover the Nova row mapper's drop_params * test(prometheus): sort the service tier label test imports * fix(bedrock): send the Invoke route's service tier as a header and name the accepted tiers in the 400 * fix(bedrock): report and bill the tier Bedrock served on Invoke calls and streams, and send the tier header only to Nova * fix(bedrock): return copies when stamping the served tier, read its header without re-encoding the others, and pin it per stream chunk The Nova Invoke mapper and the event-stream decoder now return model_copy results instead of writing service_tier in place. One shared reader finds the X-Amzn-Bedrock-Service-Tier response header case-insensitively, so a non-ASCII request id no longer breaks the stream decoder, and the passthrough uses the same reader. Tests now vary the served tier in both directions, check every chunk, and cover a stream with no tier header or an empty one * test(bedrock): check the served tier on a stream whose headers come as a plain dict with an upper-case name The non-ASCII request id stream test now also carries X-AMZN-BEDROCK-SERVICE-TIER in a plain dict and asserts every chunk reports that tier, so a case-sensitive header reader or a lookup by the canonical name fails it * refactor(bedrock): drop the passthrough's own service tier header reader that main re-added The passthrough already reads the served tier through the shared case-insensitive reader in common_utils, so the httpx.Headers copy main brought back with the embeddings change has no callers and references a constant this branch removed --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> * fix(cli): delete the key files lite claude leaves on logout and unconfigure lite claude hands its process to Claude Code, so the --settings files holding its key stayed in the Claude config dir. lite logout and lite unconfigure claude now delete them, and Claude Code reads one only at startup, so open sessions keep working --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: joshua-berri Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri Co-authored-by: shrey Co-authored-by: Ankit Jha <45779665+PyExtreme@users.noreply.github.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: quietjules <349048734+quietjules@users.noreply.github.com> --- litellm/proxy/client/cli/README.md | 6 +- litellm/proxy/client/cli/commands/agents.py | 164 ++++++++-- litellm/proxy/client/cli/commands/auth.py | 78 +++-- .../client/cli/commands/claude_settings.py | 121 ++++++- .../proxy/client/cli/commands/configure.py | 3 + litellm/proxy/client/cli/commands/up.py | 17 +- tests/unit/proxy/client/cli/conftest.py | 31 ++ tests/unit/proxy/client/cli/test_agents.py | 301 +++++++++++++++++- .../proxy/client/cli/test_auth_commands.py | 136 +++++++- .../proxy/client/cli/test_claude_settings.py | 179 +++++++++++ .../client/cli/test_configure_commands.py | 15 + .../unit/proxy/client/cli/test_up_commands.py | 13 + 12 files changed, 974 insertions(+), 90 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 02bbeb75158..7ce0b05434e 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -502,7 +502,7 @@ lite codex exec "summarize the repo" Each command resolves your LiteLLM key (logging in via SSO when none is stored and you are at a terminal; otherwise it expects `LITELLM_PROXY_API_KEY` or `--api-key`), checks the key against the proxy so bad credentials fail immediately instead of deep inside the agent, exports the environment variables the agent reads, then replaces itself with the agent process. -The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins, and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, so the proxy lists every other group to Claude Code as `claude-router-` and marks a group whose input window reaches 1M with `[1m]`, and a request on such an id is served by the group. Older Claude Code versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). OpenCode additionally gets `OPENCODE_CONFIG_CONTENT` holding a generated `litellm` provider (`@ai-sdk/openai-compatible`, the proxy `/v1` URL, `{env:OPENAI_API_KEY}`) with one model entry per chat model your key can see on `/v1/models`, so its model picker mirrors the proxy without a hand-maintained `opencode.json`; OpenCode merges that over your own config files, and if you already export `OPENCODE_CONFIG_CONTENT` yours is left alone. When the list cannot be fetched, `lite opencode` says so on stderr and launches anyway. Codex gets the same list as a catalog file, `$CODEX_HOME/litellm-models.json` (default `~/.codex/`), passed as `-c model_catalog_json=` so `/model` lists exactly the proxy's chat models; a proxy model the installed Codex already knows (`gpt-5.5`, say) keeps that Codex's own entry, reasoning levels and prompt included, and only its place in the picker comes from the proxy, while a model Codex does not know gets the plain entry Codex uses for an unknown `-m` slug, plus whichever reasoning levels its model group lists on `/model_group/info` the installed Codex can parse. Before launching, `lite codex` asks the installed Codex for its own list and then has it read the written file back (both through `codex debug models`), and when the fetch, either of those or the write fails (Codex releases older than 0.130 have no such command) it says so on stderr and launches with Codex's built-in catalog, leaving a rejected file in place. +The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins (when the `apiKeyHelper` Claude Code would load is the one `lite login --pkce --config-claude` wrote for this proxy, `ANTHROPIC_AUTH_TOKEN` is left out instead, so that helper keeps renewing the key for as long as the session runs, and when that helper, or the empty `ANTHROPIC_AUTH_TOKEN` the login writes beside it, would keep the launch's key from Claude Code, such as a key from `--api-key` or a repo whose project settings set their own `apiKeyHelper`, the key goes to Claude Code through a private `--settings` file named after its contents, so parallel launches never share one, that also turns the helper off and points Claude Code at this proxy, merged with any `--settings` you passed, and that `lite logout` and `lite unconfigure claude` delete), and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, so the proxy lists every other group to Claude Code as `claude-router-` and marks a group whose input window reaches 1M with `[1m]`, and a request on such an id is served by the group. Older Claude Code versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). OpenCode additionally gets `OPENCODE_CONFIG_CONTENT` holding a generated `litellm` provider (`@ai-sdk/openai-compatible`, the proxy `/v1` URL, `{env:OPENAI_API_KEY}`) with one model entry per chat model your key can see on `/v1/models`, so its model picker mirrors the proxy without a hand-maintained `opencode.json`; OpenCode merges that over your own config files, and if you already export `OPENCODE_CONFIG_CONTENT` yours is left alone. When the list cannot be fetched, `lite opencode` says so on stderr and launches anyway. Codex gets the same list as a catalog file, `$CODEX_HOME/litellm-models.json` (default `~/.codex/`), passed as `-c model_catalog_json=` so `/model` lists exactly the proxy's chat models; a proxy model the installed Codex already knows (`gpt-5.5`, say) keeps that Codex's own entry, reasoning levels and prompt included, and only its place in the picker comes from the proxy, while a model Codex does not know gets the plain entry Codex uses for an unknown `-m` slug, plus whichever reasoning levels its model group lists on `/model_group/info` the installed Codex can parse. Before launching, `lite codex` asks the installed Codex for its own list and then has it read the written file back (both through `codex debug models`), and when the fetch, either of those or the write fails (Codex releases older than 0.130 have no such command) it says so on stderr and launches with Codex's built-in catalog, leaving a rejected file in place. pi ignores base-URL environment variables entirely, so `lite pi` (kept out of the `lite --help` command listing for now, but fully functional) wires it up differently: before handoff it fetches the models your key can use from the proxy's `/v1/models` (plus each model's context window and output cap from `/model_group/info`, when available) and syncs them into a `litellm` provider entry in pi's `~/.pi/agent/models.json` (honoring `PI_CODING_AGENT_DIR`), then starts pi on that provider's first model via an injected `--model litellm/`. Only that one provider entry is rewritten; the rest of the file, including any other custom providers, is left alone. The entry references the key as `$LITELLM_PROXY_API_KEY`, which the wrapper exports for the session, so the token itself never lands on disk and plain `pi` outside the wrapper simply shows the litellm models as unavailable. The entry also turns on pi's session header (`x-session-id`, sent by pi 0.80.7 and later), so every chat turn of one pi session shares one session id in the logs and sticks to one deployment when session affinity is on. A custom `pi --session-id` must be at least 8 letters, digits, `_`, or `-` for the proxy to read it. Your own flags come after the injected pin, so `lite pi --model litellm/` wins, and inside the TUI the `/model` picker lists every synced litellm model. @@ -516,7 +516,7 @@ To pin the model, pass the agent's own model flag (for example `lite claude --mo The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit. -The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it without asking the proxy for its first 24 hours, or `LITELLM_CLI_JWT_EXPIRATION_HOURS` if you set it on the client. After that, `print-token` and `lite whoami` ask the proxy whether it still accepts the token, and `print-token` fails once the proxy rejects it. There is no silent renewal, so a long-running session needs a fresh `lite login` each time the proxy's token lifetime runs out. If the proxy's tokens last less than 24 hours, set the same `LITELLM_CLI_JWT_EXPIRATION_HOURS` on the client so `print-token` stops printing the token once it runs out. `lite up` still starts a new login once the token is older than that same client window. It copies the token into Claude Code's settings as a static key that it never renews, so a fresh login gives Claude Code the proxy's full token lifetime instead of whatever an old token has left. `lite claude`, `lite codex`, `lite opencode`, and `lite pi` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. +The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it without asking the proxy for its first 24 hours, or `LITELLM_CLI_JWT_EXPIRATION_HOURS` if you set it on the client. After that, `print-token` and `lite whoami` ask the proxy whether it still accepts the token, and `print-token` fails once the proxy rejects it. There is no silent renewal, so a long-running session needs a fresh `lite login` each time the proxy's token lifetime runs out. If the proxy's tokens last less than 24 hours, set the same `LITELLM_CLI_JWT_EXPIRATION_HOURS` on the client so `print-token` stops printing the token once it runs out. `lite up` still starts a new login once the token is older than that same client window. It copies the token into Claude Code's settings as a static key that it never renews, so a fresh login gives Claude Code the proxy's full token lifetime instead of whatever an old token has left. `lite claude`, `lite codex`, `lite opencode`, and `lite pi` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires (with `--config-claude`, Claude Code runs `lite auth print-token` itself, so its key renews too), `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. ### Route Every Claude Code Session Through the Proxy @@ -546,7 +546,7 @@ lite --base-url https://your-proxy.example.com login --config-claude It writes the same settings `lite up` does, `env.ANTHROPIC_BASE_URL`, `env.ENABLE_TOOL_SEARCH`, `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, and the key this login minted as `env.ANTHROPIC_AUTH_TOKEN`, but persistently: no foreground process to keep alive, and `lite unconfigure claude` restores what it changed (see below). Every other key in `~/.claude/settings.json` is preserved, the file is created if it does not exist, and it is written atomically with owner-only permissions. Plain `lite login` is unchanged; nothing happens to your Claude Code config unless you pass the flag -The key in the file is the login's own, so it expires with it (24h by default): run `lite login --config-claude` again after that, which rewrites the key in place. Earlier versions wrote an `apiKeyHelper` that ran `lite auth print-token` instead, so a later login refreshed Claude Code by itself; that meant Claude Code spawning a full `lite` start, keychain check included, on every credential refresh, so the helper is no longer written and a stale one is stripped by the next `--config-claude` or `configure claude`. Like `lite up`, the flag refuses to run while a `lite up` session holds a backup, and tells you to run `lite down` first +After a plain `lite login`, the key in the file is the login's own, so it expires with it (24h by default): run `lite login --config-claude` again after that, which rewrites the key in place. A `lite login --pkce --config-claude` writes an `apiKeyHelper` that runs `lite auth print-token` instead of the key. Claude Code runs it at startup and again once the key it got is five minutes old, and `print-token` renews the key from the stored refresh token before it expires, so Claude Code keeps working across days and asks for a browser sign-in only once the refresh token goes unused for 14 days. Each run is a full `lite` start, which takes a few seconds. The helper names `lite` by its absolute path, the one installed beside the Python running the login first, else the one found on your `PATH`, so when neither exists, or the login could not be saved for later commands, the key is written as for a plain login and the command says why. It also sets `env.ANTHROPIC_AUTH_TOKEN` to an empty string, which Claude Code reads as unset, so a token exported in your shell cannot win over the helper. The status line cannot run the helper, so in this mode it shows the routed model from the transcript but not the session's cost bars. A later plain `--config-claude` or `configure claude` replaces the helper with a static key. Like `lite up`, the flag refuses to run while a `lite up` session holds a backup, and tells you to run `lite down` first #### Configuring Claude Code or Codex Once, With a Virtual Key diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index b70432d1e72..1b5171bdcea 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -1,3 +1,4 @@ +import hashlib import json import os import re @@ -27,16 +28,21 @@ from litellm.proxy.common_utils.codex_model_catalog import ( ) from litellm.types.llms.base_model import LiteLLMBaseModel -from .auth import cli_context_obj, context_secret_vault, get_stored_api_key, login +from .auth import cli_context_obj, context_secret_vault, get_stored_api_key, is_pkce_login, load_token, login from .claude_settings import ( + API_KEY_HELPER_KEY, ENV_KEY, + LAUNCH_KEY_OVERLAY_PREFIX, + OVERLAY_FILE_NAME, ClaudeSettingsError, claude_settings_path, install_statusline_script, + is_lite_api_key_helper, + is_lite_api_key_helper_for, load_json_or_empty, ) from .cmd_quoting import quote_for_cmd -from .config import subscription_default +from .config import pkce_default, subscription_default from .pi import ( LITELLM_PROXY_API_KEY_ENV, PI_PROVIDER_NAME, @@ -130,6 +136,7 @@ def build_agent_env( profiles: frozenset[str], *, subscription: bool = False, + helper_renews_key: bool = False, ) -> dict[str, str]: """Return a copy of base_env wired to route the agent through the proxy. @@ -148,6 +155,8 @@ def build_agent_env( travels in the x-litellm-api-key header through ANTHROPIC_CUSTOM_HEADERS, and no ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is left in the environment, since either one makes Claude Code stop using the subscription (and its connectors). + With helper_renews_key=True the Anthropic profile leaves ANTHROPIC_AUTH_TOKEN out, so Claude Code takes + its key from the renewing apiKeyHelper in its settings instead of a launch-time copy that expires. With subscription=True the OpenAI profile keeps Codex on its ChatGPT login: the key goes to LITELLM_PROXY_API_KEY, which Codex sends in the x-litellm-api-key header, and OPENAI_API_KEY and OPENAI_BASE_URL are left exactly as the caller had them, so the key is never offered to Codex as an @@ -162,6 +171,8 @@ def build_agent_env( env.get(ANTHROPIC_CUSTOM_HEADERS_ENV, ""), api_key ) env.pop(ANTHROPIC_AUTH_TOKEN_ENV, None) + elif helper_renews_key: + env.pop(ANTHROPIC_AUTH_TOKEN_ENV, None) else: env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key env.pop(ANTHROPIC_API_KEY_ENV, None) @@ -246,7 +257,7 @@ def _settings_json(base: Mapping[str, JsonValue], api_key: str, inherited_header own_headers if isinstance(own_headers, str) else inherited_headers, api_key ) overlay: Final = {**base_env, **_SUBSCRIPTION_SETTINGS_ENV, ANTHROPIC_CUSTOM_HEADERS_ENV: headers} - return json.dumps({**base, ENV_KEY: overlay}, separators=(",", ":")) + return json.dumps({**base, API_KEY_HELPER_KEY: "", ENV_KEY: overlay}, separators=(",", ":")) def _inherited_custom_headers(settings_files: Sequence[Mapping[str, JsonValue]], environ: Mapping[str, str]) -> str: @@ -274,21 +285,81 @@ def claude_settings_files( raise AgentRunError(str(e)) from e -OVERLAY_FILE_NAME: Final = "litellm-subscription-overlay.json" +def claude_api_key_helper( + args: Sequence[str], + read_settings: Callable[[Path], Mapping[str, JsonValue]], + environ: Mapping[str, str], + project_dir: Path, +) -> JsonValue: + """The first apiKeyHelper in Claude Code's settings precedence, the user's last `--settings` on top. One lite + cannot read counts as none, so the key stays exported as before.""" + override_value, _ = _split_settings_flags(args) + try: + override: Final = () if override_value is None else (_load_settings_override(override_value),) + except AgentRunError: + return None + settings_files: Final = (*override, *_readable_settings_files(read_settings, environ, project_dir)) + return next((settings[API_KEY_HELPER_KEY] for settings in settings_files if API_KEY_HELPER_KEY in settings), None) -def write_settings_overlay(text: str, environ: Mapping[str, str]) -> Path: +def _claude_settings_blank_auth_token( + read_settings: Callable[[Path], Mapping[str, JsonValue]], environ: Mapping[str, str], project_dir: Path +) -> bool: + """Whether the ANTHROPIC_AUTH_TOKEN Claude Code takes from its settings files is the blank one a + `lite login --pkce --config-claude` writes, which hides a key exported at launch.""" + tokens: Final = ( + _settings_env(settings).get(ANTHROPIC_AUTH_TOKEN_ENV) + for settings in _readable_settings_files(read_settings, environ, project_dir) + ) + return next((token for token in tokens if token is not None), None) == "" + + +def _launch_key_overlay_args( + command: Sequence[str], + base_url: str, + api_key: str, + settings_hide_key: bool, + environ: Mapping[str, str], + write_overlay: Callable[[str, str, Mapping[str, str]], Path], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """A `--settings` overlay carrying the key this launch resolved, and the proxy it was issued for, when Claude + Code's settings would hide it. It merges the user's last `--settings`, since Claude Code keeps only the last. + The file is named by its contents, so two launches share one only when they carry the same settings.""" + if not settings_hide_key: + return (), tuple(command[1:]) + user_value, rest = _split_settings_flags(tuple(command[1:])) + base: Final[Mapping[str, JsonValue]] = _load_settings_override(user_value) if user_value is not None else {} + overlay: Final = { + API_KEY_HELPER_KEY: "", + **base, + ENV_KEY: { + ANTHROPIC_AUTH_TOKEN_ENV: api_key, + ANTHROPIC_BASE_URL_ENV: base_url.rstrip("/"), + **_settings_env(base), + }, + } + text: Final = json.dumps(overlay, separators=(",", ":")) + name: Final = f"{LAUNCH_KEY_OVERLAY_PREFIX}{hashlib.sha256(text.encode()).hexdigest()[:16]}.json" + return (_SETTINGS_FLAG, str(write_overlay(name, text, environ))), rest + + +def _readable_settings_files( + read_settings: Callable[[Path], Mapping[str, JsonValue]], environ: Mapping[str, str], project_dir: Path +) -> tuple[Mapping[str, JsonValue], ...]: + try: + return claude_settings_files(read_settings, environ, project_dir) + except AgentRunError: + return () + + +def write_settings_overlay(name: str, text: str, environ: Mapping[str, str]) -> Path: """Write the overlay next to Claude Code's own settings.json (so under CLAUDE_CONFIG_DIR when set) and return - its path. One file per user, rewritten on every launch, mode 0600 whether or not it already existed. It carries - the LiteLLM key, which is why it is a private file and not an inline `--settings` argument that every local - user could read from the process list, and why it does not pile up in the temp dir.""" - path: Final = claude_settings_path(environ).parent / OVERLAY_FILE_NAME + its path. The file is replaced in one step with mode 0600, so a Claude Code starting at the same time never + reads half of it. It carries the LiteLLM key, which is why it is a private file and not an inline `--settings` + argument that every local user could read from the process list, and why it does not pile up in the temp dir.""" + path: Final = claude_settings_path(environ).parent / name path.parent.mkdir(parents=True, exist_ok=True) - fd: Final = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - if os.name == "posix": - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(text) + _replace_file(path, text) return path @@ -297,22 +368,23 @@ def subscription_launch_args( api_key: str, environ: Mapping[str, str], settings_files: Sequence[Mapping[str, JsonValue]], - write_overlay: Callable[[str, Mapping[str, str]], Path], + write_overlay: Callable[[str, str, Mapping[str, str]], Path], ) -> tuple[tuple[str, ...], tuple[str, ...]]: """The `--settings` that keeps Claude Code on its claude.ai login, plus the user's remaining args. Claude Code applies `env` from its settings files over the process environment, so a key that `lite up` or `lite configure claude` left in ANTHROPIC_AUTH_TOKEN would land back in `Authorization`, and an - ANTHROPIC_CUSTOM_HEADERS there would drop the x-litellm-api-key header. The overlay blanks both credential - keys (read as unset for this session only) and carries the custom headers with the LiteLLM key appended, - taking the headers from the user's `--settings`, else the settings files in Claude Code's precedence order, - else the process environment. Any `--settings` the user passed is stripped and the last one (inline JSON or a - file, the one Claude Code would keep) is merged in, since Claude Code keeps only the last `--settings`. + ANTHROPIC_CUSTOM_HEADERS there would drop the x-litellm-api-key header, and an apiKeyHelper outranks the + claude.ai login. The overlay blanks both credential keys and the helper (read as unset for this session only) + and carries the custom headers with the LiteLLM key appended, taking the headers from the user's + `--settings`, else the settings files in Claude Code's precedence order, else the process environment. Any + `--settings` the user passed is stripped and the last one (inline JSON or a file, the one Claude Code would + keep) is merged in, since Claude Code keeps only the last `--settings`. """ inherited: Final = _inherited_custom_headers(settings_files, environ) user_value, rest = _split_settings_flags(tuple(command[1:])) base: Final[Mapping[str, JsonValue]] = _load_settings_override(user_value) if user_value is not None else {} - overlay: Final = write_overlay(_settings_json(base, api_key, inherited), environ) + overlay: Final = write_overlay(OVERLAY_FILE_NAME, _settings_json(base, api_key, inherited), environ) return (_SETTINGS_FLAG, str(overlay)), rest @@ -1099,15 +1171,18 @@ def run_agent( reattach_terminal: Callable[[], None] | None = None, preparers: Mapping[str, _Preparer] = MappingProxyType(_PREPARERS), read_settings: Callable[[Path], Mapping[str, JsonValue]] = load_json_or_empty, - write_overlay: Callable[[str, Mapping[str, str]], Path] = write_settings_overlay, + write_overlay: Callable[[str, str, Mapping[str, str]], Path] = write_settings_overlay, project_dir: Path | None = None, + key_from_stored_login: bool = False, ) -> None: """Validate, wire the environment, and hand off to the agent. On success this replaces the current process and never returns. Raises AgentRunError for missing binaries, an unreachable proxy, a rejected key, or a failed pre-launch config sync (pi). reattach_terminal, when given, runs - just before handoff to restore stdin. + just before handoff to restore stdin. key_from_stored_login says api_key is the + stored `lite login` key, the only one lite's apiKeyHelper may stand in for, since + it prints that login's key and would replace a key passed with --api-key. """ if not command: raise AgentRunError("Nothing to run.") @@ -1123,6 +1198,23 @@ def run_agent( (verify_subscription if subscription else verify)(base_url, api_key) env_before_sync: Final = base_env if base_env is not None else os.environ + project: Final = project_dir if project_dir is not None else Path.cwd() + reads_helper: Final = not subscription and PROFILE_ANTHROPIC in profiles + configured_helper: Final = ( + claude_api_key_helper(command[1:], read_settings, env_before_sync, project) if reads_helper else None + ) + settings_helper: Final = ( + claude_api_key_helper((), read_settings, env_before_sync, project) if reads_helper else None + ) + helper_renews_key: Final = key_from_stored_login and is_lite_api_key_helper_for(configured_helper, base_url) + settings_hide_key: Final = ( + reads_helper + and not helper_renews_key + and ( + is_lite_api_key_helper(settings_helper) + or _claude_settings_blank_auth_token(read_settings, env_before_sync, project) + ) + ) synced: Final = ( _NO_EXTRA_ENV if subscription else sync_models(binary, env_before_sync, base_url, api_key, skip_verify) ) @@ -1134,7 +1226,14 @@ def run_agent( env: Final = MappingProxyType( { - **build_agent_env(env_before_sync, base_url, api_key, profiles, subscription=subscription), + **build_agent_env( + env_before_sync, + base_url, + api_key, + profiles, + subscription=subscription, + helper_renews_key=helper_renews_key, + ), **(synced if isinstance(synced, Mapping) else _NO_EXTRA_ENV), } ) @@ -1149,13 +1248,11 @@ def run_agent( command, api_key, env_before_sync, - claude_settings_files( - read_settings, env_before_sync, project_dir if project_dir is not None else Path.cwd() - ), + claude_settings_files(read_settings, env_before_sync, project), write_overlay, ) if subscription and os.path.basename(command[0]) == "claude" - else ((), tuple(command[1:])) + else _launch_key_overlay_args(command, base_url, api_key, settings_hide_key, env_before_sync, write_overlay) ) if reattach_terminal is not None: reattach_terminal() @@ -1173,15 +1270,17 @@ def resolve_api_key(ctx: click.Context) -> str: if api_key: return api_key + vault: Final = context_secret_vault(ctx) + pkce: Final = is_pkce_login(load_token(vault=vault), base_url) or pkce_default() if not _is_interactive(): raise click.ClickException( "No LiteLLM key found. Set LITELLM_PROXY_API_KEY (or pass --api-key) for " - "non-interactive use, or run `lite login` from a terminal." + f"non-interactive use, or run `{'lite login --pkce' if pkce else 'lite login'}` from a terminal." ) click.echo("No LiteLLM credentials found; starting login...") - ctx.invoke(login) - api_key = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx)) + ctx.invoke(login, pkce=pkce) + api_key = get_stored_api_key(expected_base_url=base_url, vault=vault) if not api_key: raise click.ClickException("Login did not produce an API key; cannot start the agent.") return api_key @@ -1210,6 +1309,7 @@ def _launch( ctx_obj: Final = cli_context_obj(ctx) base_url: Final = ctx_obj["base_url"] started_interactive: Final = _is_interactive() + key_from_stored_login: Final = ctx_obj.get("api_key_from_token_file", False) or not ctx_obj.get("api_key") api_key: Final = resolve_api_key(ctx) display_name, _profiles = agent_profile(binary) @@ -1225,6 +1325,7 @@ def _launch( skip_verify=skip_verify, subscription=use_subscription, reattach_terminal=(_restore_controlling_terminal if started_interactive else None), + key_from_stored_login=key_from_stored_login, ) except AgentRunError as e: raise click.ClickException(str(e)) @@ -1268,7 +1369,6 @@ def agent_commands() -> tuple[click.Command, ...]: __all__ = [ - "OVERLAY_FILE_NAME", "AgentRunError", "ListedModel", "ModelSyncSkipped", diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 7bd209140ac..e5539d6357a 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -44,13 +44,17 @@ from litellm.litellm_core_utils.cli_token_utils import ( from .claude_settings import ( STARTING_MODEL_ROLE, + ApiKeyHelper, + ClaudeCredential, ClaudeSettingsError, KeepModel, StaticToken, claude_settings_path, configure_claude_settings, configure_state_path, + lite_api_key_helper, refuse_while_owned, + remove_launch_settings_files, settings_file_owners, ) from .config import pkce_default @@ -236,6 +240,14 @@ def load_token(*, vault: SecretVault = SYSTEM_KEYRING) -> Mapping[str, object] | return None if record is None else record.model_dump(exclude_none=True) +def is_pkce_login(token_data: Mapping[str, object] | None, base_url: str) -> bool: + return ( + token_data is not None + and token_data.get("refresh_token") is not None + and token_data.get("base_url") == base_url.rstrip("/") + ) + + def _login_left_in_keychain(vault: SecretVault) -> Mapping[str, object] | None: """The login an earlier logout left in a keychain it could not reach""" left: Final = load_cli_token_left_in_keychain(vault=vault) @@ -833,16 +845,25 @@ def _render_and_prompt_for_team_selection(teams: list[CliTeam]) -> str | None: return None -def _configure_claude_code(base_url: str, api_key: str) -> None: - """Write the key this login just minted into Claude Code's settings.json as a static token, undoable with - `lite unconfigure claude`. The key expires with the login, so the flag is the re-wire step of each login - rather than a one-time setup: no apiKeyHelper is written, since Claude Code would spawn `lite` (and its - keychain probe) on every credential refresh to keep one fresh.""" +def _claude_credential(base_url: str, api_key: str, renews: bool, stored: SecretSave) -> ClaudeCredential: + """`print-token` renews from the stored record, so only a stored login with a refresh token gets the helper.""" + if not renews or isinstance(stored, (CredentialNotSaved, CredentialNotRecorded)): + return StaticToken(api_key) + helper: Final = lite_api_key_helper(base_url) + if helper is None: + click.echo("Could not find the `lite` command, so Claude Code cannot run it to renew this login's key.") + return StaticToken(api_key) + return helper + + +def _configure_claude_code(base_url: str, credential: ClaudeCredential, renews: bool) -> None: + """Undoable with `lite unconfigure claude`. A static key expires with the login, so the flag is then the + re-wire step of each login.""" settings_path: Final = claude_settings_path(os.environ) try: configure_claude_settings( base_url, - StaticToken(api_key), + credential, KeepModel(), settings_path, configure_state_path(settings_path), @@ -851,14 +872,27 @@ def _configure_claude_code(base_url: str, api_key: str) -> None: except ClaudeSettingsError as e: raise click.ClickException(f"Logged in, but could not configure Claude Code: {e}") click.echo(f"\nConfigured Claude Code: {settings_path} now routes through {base_url.rstrip('/')}.") + match credential: + case ApiKeyHelper(): + click.echo( + "Claude Code gets this login's key from `lite auth print-token`, which renews it before it expires." + ) + case StaticToken(): + click.echo( + f"This login's key is stored in the file, so run `{_login_command(renews)} --config-claude` again " + "after it expires." + ) + if not renews: + click.echo("`lite login --pkce --config-claude` instead lets Claude Code renew the key on its own.") + case _: + assert_never(credential) click.echo( - "This login's key is stored in the file, so run `lite login --config-claude` again after it expires. " "Your other Claude Code settings were left untouched. Restart Claude Code to pick this up. " f"Undo with `lite unconfigure claude`; `lite configure claude --model` sets {STARTING_MODEL_ROLE}." ) -def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: SecretSave) -> None: +def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: SecretSave, *, renews: bool) -> None: """Claude Code is configured from the key in hand, so it does not wait on the CLI's own store: a login whose token file or keychain refused it still has a usable key, and `--config-claude` asked for exactly that key to be written into settings.json.""" @@ -868,7 +902,7 @@ def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: Secr click.echo(f"JWT Token: {api_key[:20]}...") click.echo(storage_notice(stored)) if config_claude: - _configure_claude_code(base_url, api_key) + _configure_claude_code(base_url, _claude_credential(base_url, api_key, renews, stored), renews) if isinstance(stored, (CredentialNotSaved, CredentialNotRecorded)): if config_claude: click.echo("Claude Code was configured with this key even though the CLI itself could not keep it.") @@ -904,7 +938,7 @@ def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None: click.echo(f"Authentication failed: {credential.reason}") return stored: Final = _replace_stored_token(pkce_token_record(base_url, credential), http, vault) - _finish_login(base_url, credential.access_token, config_claude, stored) + _finish_login(base_url, credential.access_token, config_claude, stored, renews=True) @click.command(name="login") @@ -913,8 +947,9 @@ def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None: is_flag=True, default=False, help=( - "After logging in, write this login's key into ~/.claude/settings.json so Claude Code routes through " - "this proxy; run it again after the key expires. Unrelated settings are preserved." + "After logging in, wire ~/.claude/settings.json so Claude Code routes through this proxy. A --pkce login " + "gets an apiKeyHelper that renews the key; any other login writes the key itself, so run it again after " + "the key expires. Unrelated settings are preserved." ), ) @click.option( @@ -996,7 +1031,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool | None) -> None: context_secret_vault(ctx), ) - _finish_login(base_url, api_key, config_claude, stored) + _finish_login(base_url, api_key, config_claude, stored, renews=False) return else: click.echo("Authentication timed out. Please try again.") @@ -1083,6 +1118,13 @@ def logout(ctx: click.Context): case KeyringUnreachable(): click.echo(UNCHECKED_KEYCHAIN_MESSAGE) click.echo("Unlock your keychain and run 'lite logout' again to clear it.") + remove_launch_settings_files(claude_settings_path(os.environ).parent) + + +def _renewing_login_command(ctx_obj: CliContextObj | None) -> str: + if ctx_obj is None or not ctx_obj.get("base_url_explicit"): + return "lite login --pkce" + return f"lite --base-url {ctx_obj['base_url'].rstrip('/')} login --pkce" @click.command(name="print-token") @@ -1102,7 +1144,7 @@ def print_token(ctx: click.Context): vault: Final = context_secret_vault(ctx) token_data: Final = load_token(vault=vault) if not token_data: - click.echo("Not authenticated. Run 'lite login'.", err=True) + click.echo(f"Not authenticated. Run '{_renewing_login_command(_optional_cli_context_obj(ctx))}'.", err=True) sys.exit(1) # apiKeyHelper is invoked bare (no --base-url), so unless the caller @@ -1112,16 +1154,16 @@ def print_token(ctx: click.Context): ctx_obj: Final = cli_context_obj(ctx) issued_for_this_server: Final = token_data.get("base_url") == ctx_obj.get("base_url", "").rstrip("/") if ctx_obj.get("base_url_explicit") and not issued_for_this_server: - click.echo("Not authenticated for this server. Run 'lite login'.", err=True) + click.echo(f"Not authenticated for this server. Run '{_renewing_login_command(ctx_obj)}'.", err=True) sys.exit(1) renews: Final = "refresh_token" in token_data if not renews and token_expired(token_data): - click.echo("Token expired. Run 'lite login' again.", err=True) + click.echo(f"Token expired. Run '{_renewing_login_command(ctx_obj)}' again.", err=True) sys.exit(1) if token_data.get("key") is None: - click.echo(keychain_unreadable_notice(vault), err=True) + click.echo(keychain_unreadable_notice(vault, _renewing_login_command(ctx_obj)), err=True) sys.exit(1) http: Final = requests.Session() @@ -1137,7 +1179,7 @@ def print_token(ctx: click.Context): ) ) if not api_key: - click.echo(f"Key expired. Run '{_login_command(renews)}' again.", err=True) + click.echo(f"Key expired. Run '{_renewing_login_command(ctx_obj)}' again.", err=True) sys.exit(1) click.echo(api_key) diff --git a/litellm/proxy/client/cli/commands/claude_settings.py b/litellm/proxy/client/cli/commands/claude_settings.py index 60492a81cab..183a01b0e30 100644 --- a/litellm/proxy/client/cli/commands/claude_settings.py +++ b/litellm/proxy/client/cli/commands/claude_settings.py @@ -3,17 +3,23 @@ `lite up` and `lite autoroute start` patch this file temporarily and restore it on exit; `lite configure claude` patches it persistently and records how to undo it. All of them need the same merge, and `up` already imports from `auth`, so the -shared parts live here rather than in any one command module. The credential is -always a static token in `env.ANTHROPIC_AUTH_TOKEN`: Claude Code's `apiKeyHelper` -would spawn a `lite` process on every credential refresh, and that process touches -the keychain, so nothing here writes one; a helper left by an earlier version is -owned like any other key and stripped. +shared parts live here rather than in any one command module. The credential is a +static token in `env.ANTHROPIC_AUTH_TOKEN`, except for a login `lite` renews on its own, +which gets an `apiKeyHelper` running `lite auth print-token`: Claude Code reruns it +every few minutes, so the key it sends follows each renewal. A helper is owned like +any other key, so writing one credential strips the other, and it blanks +`env.ANTHROPIC_AUTH_TOKEN`, since a token exported in the shell would win over it. """ import hashlib import json +import ntpath +import os +import posixpath import shlex +import shutil import sys +import sysconfig from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from functools import reduce @@ -76,6 +82,9 @@ STARTING_MODEL_ROLE: Final = "the /model picker's default row, the model Claude CLAUDE_SETTINGS_PATH: Final = Path.home() / ".claude" / "settings.json" CLAUDE_CONFIG_DIR_ENV: Final = "CLAUDE_CONFIG_DIR" +OVERLAY_FILE_NAME: Final = "litellm-subscription-overlay.json" +LAUNCH_KEY_OVERLAY_PREFIX: Final = "litellm-launch-key-overlay-" +_LAUNCH_SETTINGS_FILE_PATTERNS: Final = (OVERLAY_FILE_NAME, f"{LAUNCH_KEY_OVERLAY_PREFIX}*.json") BACKUP_PATH: Final = Path.home() / ".litellm" / "claude_settings_backup.json" AUTOROUTE_BACKUP_PATH: Final = Path.home() / ".litellm" / "autorouter" / "claude_settings_backup.json" CONFIGURE_STATE_PATH: Final = Path.home() / ".litellm" / "claude_configure_state.json" @@ -112,6 +121,25 @@ def claude_settings_path(environ: Mapping[str, str]) -> Path: return Path(config_dir).expanduser() / "settings.json" +def _unlink(path: Path) -> OSError | None: + try: + path.unlink(missing_ok=True) + except OSError as error: + return error + return None + + +def remove_launch_settings_files(config_dir: Path) -> None: + """Delete the `--settings` files `lite claude` launched Claude Code with, since each holds a LiteLLM key. + Claude Code reads that file only as it starts, so a session already running keeps working.""" + found: Final = tuple(chain.from_iterable(config_dir.glob(pattern) for pattern in _LAUNCH_SETTINGS_FILE_PATTERNS)) + failures: Final = tuple((path, error) for path in found if (error := _unlink(path)) is not None) + if len(found) > len(failures): + click.echo(f"Removed the settings files holding a LiteLLM key that `lite claude` left in {config_dir}.") + for path, error in failures: + click.echo(f"Could not remove {path}, which holds a LiteLLM key: {error}. Delete it yourself.", err=True) + + def _is_default_settings_file(settings_path: Path) -> bool: return settings_path.resolve() == CLAUDE_SETTINGS_PATH.resolve() @@ -138,6 +166,16 @@ class StaticToken: token: str +@dataclass(frozen=True, slots=True) +class ApiKeyHelper: + """A `lite auth print-token` command Claude Code reruns for a fresh key, written into the top-level apiKeyHelper.""" + + command: str + + +ClaudeCredential: TypeAlias = StaticToken | ApiKeyHelper + + @dataclass(frozen=True, slots=True) class KeepModel: """Leave the top-level `model` as it is, the user's or an earlier configure's (a re-login).""" @@ -305,6 +343,56 @@ def _land( raise ClaudeSettingsError(f"Could not {'remove' if staged is None else 'write'} {path}: {e}") from e +def installed_executable( + name: str, + scripts_dir: str = sysconfig.get_path("scripts"), + environ: Mapping[str, str] = os.environ, + platform: str = sys.platform, +) -> str | None: + """This interpreter's own `name` first, since a version manager's shim earlier on PATH may run a Python + that lacks it. Each directory is checked by full path, since `shutil.which` on Windows searches the + current directory before the ones it is given, and a repo could plant its own `lite.exe` there.""" + file_name: Final = f"{name}.exe" if platform.startswith("win") else name + directories: Final = (scripts_dir, *environ.get("PATH", os.defpath).split(os.pathsep)) + candidates: Final = (shutil.which(os.path.join(directory, file_name)) for directory in directories if directory) + return next((found for found in candidates if found is not None), None) + + +_HELPER_SUBCOMMAND: Final = ("auth", "print-token") + + +def _helper_args(base_url: str) -> tuple[str, ...]: + return ("--base-url", base_url.rstrip("/"), *_HELPER_SUBCOMMAND) + + +def lite_api_key_helper( + base_url: str, platform: str = sys.platform, which: Callable[[str], str | None] = installed_executable +) -> ApiKeyHelper | None: + """Quoted for the shell Claude Code runs it in, `sh` on POSIX and cmd.exe on Windows, with an absolute path + so the directory Claude Code starts in cannot pick another `lite`.""" + lite_path: Final = which("lite") + if lite_path is None: + return None + windows: Final = platform.startswith("win") + quote: Final = quote_for_cmd if windows else shlex.quote + absolute_path: Final = (ntpath if windows else posixpath).abspath(lite_path) + return ApiKeyHelper(" ".join(quote(token) for token in (absolute_path, *_helper_args(base_url)))) + + +def _runs_lite_with(command: JsonValue, args: tuple[str, ...]) -> bool: + return isinstance(command, str) and any( + command.endswith(" " + " ".join(quote(token) for token in args)) for quote in (shlex.quote, quote_for_cmd) + ) + + +def is_lite_api_key_helper(command: JsonValue) -> bool: + return _runs_lite_with(command, _HELPER_SUBCOMMAND) + + +def is_lite_api_key_helper_for(command: JsonValue, base_url: str) -> bool: + return _runs_lite_with(command, _helper_args(base_url)) + + def statusline_command(script_path: Path, platform: str = sys.platform) -> str: """This interpreter, not a bare `python3`: it is the one the apiKeyHelper already depends on.""" quote: Final = quote_for_cmd if platform.startswith("win") else shlex.quote @@ -378,7 +466,7 @@ def with_status_line(settings: Mapping[str, JsonValue], command: str) -> Mapping def merge_claude_settings( settings: Mapping[str, JsonValue], base_url: str, - credential: StaticToken, + credential: ClaudeCredential, default_model: str | None = None, tier_model: str | None = None, *, @@ -386,8 +474,10 @@ def merge_claude_settings( ) -> Mapping[str, JsonValue]: """Return a new settings mapping wired to route Claude Code through the proxy. - The token lands in env.ANTHROPIC_AUTH_TOKEN; the other credential slots (a stray ANTHROPIC_API_KEY, - an apiKeyHelper) are removed, since Claude Code given two credentials may send the wrong one. + A StaticToken lands in env.ANTHROPIC_AUTH_TOKEN, an ApiKeyHelper in the top-level apiKeyHelper with + env.ANTHROPIC_AUTH_TOKEN blank, since Claude Code reads a blank one as unset and an ANTHROPIC_AUTH_TOKEN + exported in the shell would otherwise win over the helper. The other credential slots are removed either + way, since Claude Code given two credentials may send the wrong one. ENABLE_TOOL_SEARCH and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY get their defaults only when missing. `default_model` is the top-level `model` and env.ANTHROPIC_MODEL (see StartOn); `tier_model` is `lite autoroute start`'s knob that points every ANTHROPIC_DEFAULT_*_MODEL at one @@ -402,7 +492,8 @@ def merge_claude_settings( (ENABLE_GATEWAY_MODEL_DISCOVERY_KEY, ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE), ), ((key, value) for key, value in current_env.items() if key not in _CREDENTIAL_ENV_KEYS), - ((ANTHROPIC_BASE_URL_KEY, base_url.rstrip("/")), (ANTHROPIC_AUTH_TOKEN_KEY, credential.token)), + ((ANTHROPIC_BASE_URL_KEY, base_url.rstrip("/")),), + ((ANTHROPIC_AUTH_TOKEN_KEY, credential.token if isinstance(credential, StaticToken) else ""),), ((ANTHROPIC_MODEL_KEY, default_model),) if default_model is not None else (), ((key, tier_model) for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS if tier_model is not None), ) @@ -415,6 +506,7 @@ def merge_claude_settings( if key not in (API_KEY_HELPER_KEY, ENV_KEY) ), ((ENV_KEY, env),), + ((API_KEY_HELPER_KEY, credential.command),) if isinstance(credential, ApiKeyHelper) else (), ((MODEL_KEY, default_model),) if default_model is not None else (), ) ) @@ -520,7 +612,7 @@ def preflight_claude_settings(settings_path: Path) -> None: def configure_claude_settings( base_url: str, - credential: StaticToken, + credential: ClaudeCredential, model: ModelChoice, settings_path: Path, state_path: Path, @@ -663,7 +755,9 @@ __all__ = ( "ENABLE_TOOL_SEARCH_KEY", "ENABLE_TOOL_SEARCH_VALUE", "ENV_KEY", + "LAUNCH_KEY_OVERLAY_PREFIX", "MODEL_KEY", + "OVERLAY_FILE_NAME", "OWNED_ENV_KEYS", "OWNED_PATHS", "OWNED_TOP_LEVEL_KEYS", @@ -671,6 +765,8 @@ __all__ = ( "STARTING_MODEL_ROLE", "STATUSLINE_SCRIPT_PATH", "STATUS_LINE_KEY", + "ApiKeyHelper", + "ClaudeCredential", "ClaudeSettingsError", "ConfigureReceipt", "KeepModel", @@ -685,10 +781,15 @@ __all__ = ( "claude_settings_path", "configure_claude_settings", "configure_state_path", + "installed_executable", + "is_lite_api_key_helper", + "is_lite_api_key_helper_for", + "lite_api_key_helper", "load_json_or_empty", "merge_claude_settings", "read_configure_receipt", "refuse_while_owned", + "remove_launch_settings_files", "settings_file_owners", "unconfigure_claude_settings", "write_claude_settings", diff --git a/litellm/proxy/client/cli/commands/configure.py b/litellm/proxy/client/cli/commands/configure.py index 7903a978a4a..3435c168d4d 100644 --- a/litellm/proxy/client/cli/commands/configure.py +++ b/litellm/proxy/client/cli/commands/configure.py @@ -14,6 +14,7 @@ from .claude_settings import ( ClaudeSettingsError, UnconfigureOutcome, preflight_claude_settings, + remove_launch_settings_files, settings_file_owners, unconfigure_claude_settings, ) @@ -210,6 +211,8 @@ def _disconnect(target: Target, forget: bool) -> None: settings_path: Final = settings_path_for(target) state_path: Final = receipt_path_for(target, settings_path) profile: Final = setup_profile_path(target, settings_path) + if target == "claude": + remove_launch_settings_files(settings_path.parent) try: if not state_path.exists(): click.echo(f"No {target} undo receipt at {state_path}; nothing to undo. Agent settings were not changed.") diff --git a/litellm/proxy/client/cli/commands/up.py b/litellm/proxy/client/cli/commands/up.py index 4043761f255..b4592f8154c 100644 --- a/litellm/proxy/client/cli/commands/up.py +++ b/litellm/proxy/client/cli/commands/up.py @@ -19,7 +19,15 @@ from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh from litellm.litellm_core_utils.private_json import ensure_private_dir from .agents import AgentRunError, resolve_api_key, verify_proxy_key -from .auth import CliContextObj, cli_context_obj, context_secret_vault, get_stored_api_key, load_token, login +from .auth import ( + CliContextObj, + cli_context_obj, + context_secret_vault, + get_stored_api_key, + is_pkce_login, + load_token, + login, +) from .claude_settings import ( BACKUP_PATH, CLAUDE_SETTINGS_PATH, @@ -120,11 +128,6 @@ def _key_resolved_on_the_way_in(ctx_obj: CliContextObj, base_url: str, vault: Se return get_stored_api_key(expected_base_url=base_url, vault=vault) -def _stored_login_is_pkce(vault: SecretVault) -> bool: - token_data: Final = load_token(vault=vault) - return token_data is not None and token_data.get("refresh_token") is not None - - def ensure_fresh_login(ctx: click.Context) -> None: ctx_obj: Final = cli_context_obj(ctx) base_url: Final = ctx_obj["base_url"].rstrip("/") @@ -132,7 +135,7 @@ def ensure_fresh_login(ctx: click.Context) -> None: if _usable_login(_key_resolved_on_the_way_in(ctx_obj, base_url, vault), vault): return - pkce: Final = _stored_login_is_pkce(vault) or pkce_default() + pkce: Final = is_pkce_login(load_token(vault=vault), base_url) or pkce_default() login_command: Final = "lite login --pkce" if pkce else "lite login" if not sys.stdin.isatty(): raise UpError(f"No fresh LiteLLM login found for this proxy. Run `{login_command}` first.") diff --git a/tests/unit/proxy/client/cli/conftest.py b/tests/unit/proxy/client/cli/conftest.py index e54dbeef875..a695e9f08c4 100644 --- a/tests/unit/proxy/client/cli/conftest.py +++ b/tests/unit/proxy/client/cli/conftest.py @@ -7,8 +7,10 @@ from typing import Final import pytest from litellm.proxy.client.cli.commands import claude_settings +from litellm.proxy.client.cli.commands.agents import run_agent REAL_CLAUDE_SETTINGS: Final = Path(os.path.expanduser("~")) / ".claude" / "settings.json" +LAUNCH_PROXY: Final = "http://localhost:4000" def _current_bytes() -> bytes | None: @@ -78,3 +80,32 @@ def isolated_claude_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Ite f"this test wrote the developer's real {REAL_CLAUDE_SETTINGS}; the original bytes were restored. " "Resolve the Claude settings path at call time (never Path.home() at import) and point the test at tmp_path" ) + + +@pytest.fixture +def launch_claude(isolated_claude_home: Path) -> Callable[[bool], Path]: + """Launch `lite claude` over a `lite login --pkce --config-claude` setup and return the `--settings` file it + wrote, which holds the launch key. Claude Code's config dir is read at call time, so a test can move it first.""" + + def launch(subscription: bool) -> Path: + launched: Final[dict[str, tuple[str, ...]]] = {} + run_agent( + LAUNCH_PROXY, + "sk-launch", + ["claude"], + subscription=subscription, + base_env={"CLAUDE_CONFIG_DIR": os.environ["CLAUDE_CONFIG_DIR"]}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *args: None, + verify_subscription=lambda *args: None, + launcher=lambda path, args, env: launched.update(args=tuple(args)), + read_settings=lambda path: { + "apiKeyHelper": f"/usr/local/bin/lite --base-url {LAUNCH_PROXY} auth print-token", + "env": {"ANTHROPIC_AUTH_TOKEN": ""}, + }, + project_dir=isolated_claude_home / "repo", + ) + args: Final = launched["args"] + return Path(args[args.index("--settings") + 1]) + + return launch diff --git a/tests/unit/proxy/client/cli/test_agents.py b/tests/unit/proxy/client/cli/test_agents.py index a6de6a7ee50..2abe3544a23 100644 --- a/tests/unit/proxy/client/cli/test_agents.py +++ b/tests/unit/proxy/client/cli/test_agents.py @@ -33,6 +33,7 @@ from litellm.proxy.client.cli.commands.agents import ( verify_proxy_key, verify_subscription_key, ) +from litellm.proxy.client.cli.commands.auth import save_token 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 @@ -217,8 +218,13 @@ class _FakeJsonResponse: return self._payload +PROXY: Final = "http://localhost:4000" +OTHER_PROXY: Final = "https://other-proxy.example.com" +LITE_HELPER: Final = f"/usr/local/bin/lite --base-url {PROXY} auth print-token" + + def _overlay_writer(tmp_path): - def write(text, environ): + def write(name, text, environ): target = tmp_path / "overlay.json" target.write_text(text) return target @@ -1667,6 +1673,223 @@ class TestRunAgent: ) assert calls["args"] == ("claude", "--resume") + @pytest.mark.parametrize( + ("args", "project_local", "user", "key_from_stored_login", "exports_the_key"), + [ + ((), {}, {"apiKeyHelper": LITE_HELPER}, True, False), + ((), {}, {"apiKeyHelper": LITE_HELPER}, False, True), + ((), {}, {"apiKeyHelper": LITE_HELPER.replace("localhost:4000", "other-proxy:4000")}, True, True), + ((), {}, {"apiKeyHelper": "/opt/mine/helper"}, True, True), + ((), {"apiKeyHelper": "/opt/mine/helper"}, {"apiKeyHelper": LITE_HELPER}, True, True), + ((), {}, {}, True, True), + (("--settings", '{"apiKeyHelper":""}'), {}, {"apiKeyHelper": LITE_HELPER}, True, True), + (("--settings={}",), {}, {"apiKeyHelper": LITE_HELPER}, True, False), + ], + ids=[ + "lites-helper-for-this-proxy", + "key-passed-with-api-key", + "another-proxy", + "users-own-helper", + "project-helper-wins", + "no-helper", + "settings-flag-disables-the-helper", + "settings-flag-without-a-helper", + ], + ) + def test_claude_takes_the_key_from_lites_renewing_helper_only_when_it_is_the_one_claude_code_runs( + self, tmp_path, monkeypatch, args, project_local, user, key_from_stored_login, exports_the_key + ): + files = { + tmp_path / "repo" / ".claude" / "settings.local.json": project_local, + tmp_path / "cfg" / "settings.json": user, + } + monkeypatch.chdir(tmp_path) + launched = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["claude", *args], + base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg"), "ANTHROPIC_AUTH_TOKEN": "sk-stale"}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + launcher=lambda p, a, e: launched.update(env=dict(e)), + read_settings=lambda path: files.get(path, {}), + project_dir=tmp_path / "repo", + key_from_stored_login=key_from_stored_login, + ) + assert launched["env"]["ANTHROPIC_BASE_URL"] == "http://localhost:4000" + assert launched["env"].get("ANTHROPIC_AUTH_TOKEN") == ("sk-key" if exports_the_key else None) + + @pytest.mark.parametrize( + ("args", "user", "key_from_stored_login", "expected_overlay"), + [ + ((), {"apiKeyHelper": LITE_HELPER}, False, {"apiKeyHelper": "", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-key", "ANTHROPIC_BASE_URL": PROXY}}), + ((), {"apiKeyHelper": LITE_HELPER.replace("/usr/local/bin/lite", "/opt/other-venv/bin/lite")}, True, None), + ( + (), + { + "apiKeyHelper": LITE_HELPER.replace("http://localhost:4000", OTHER_PROXY), + "env": {"ANTHROPIC_BASE_URL": OTHER_PROXY, "ANTHROPIC_AUTH_TOKEN": ""}, + }, + True, + {"apiKeyHelper": "", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-key", "ANTHROPIC_BASE_URL": PROXY}}, + ), + ((), {"apiKeyHelper": LITE_HELPER}, True, None), + ((), {"apiKeyHelper": "/opt/mine/helper"}, False, None), + ( + ("--settings", '{"model":"opus"}'), + {"apiKeyHelper": LITE_HELPER}, + False, + {"apiKeyHelper": "", "model": "opus", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-key", "ANTHROPIC_BASE_URL": PROXY}}, + ), + ( + ("--settings", '{"apiKeyHelper":"","env":{"FOO":"1"}}'), + {"apiKeyHelper": LITE_HELPER}, + True, + {"apiKeyHelper": "", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-key", "ANTHROPIC_BASE_URL": PROXY, "FOO": "1"}}, + ), + ( + ("--settings", '{"apiKeyHelper":"/opt/mine/helper","env":{"ANTHROPIC_AUTH_TOKEN":"sk-mine"}}'), + {"apiKeyHelper": LITE_HELPER}, + False, + {"apiKeyHelper": "/opt/mine/helper", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-mine", "ANTHROPIC_BASE_URL": PROXY}}, + ), + ], + ids=[ + "key-passed-with-api-key", + "helper-from-another-lite-install-renews-the-same-login", + "helper-for-another-proxy", + "lites-helper-renews-the-stored-key", + "users-own-helper", + "users-settings-flag-is-merged", + "users-settings-flag-turns-the-helper-off", + "users-settings-flag-keeps-its-own-credentials", + ], + ) + def test_claude_sends_the_key_the_launch_resolved_when_lites_helper_would_not_renew_it( + self, tmp_path, args, user, key_from_stored_login, expected_overlay + ): + """`lite login --pkce --config-claude` blanks ANTHROPIC_AUTH_TOKEN beside its helper, so an exported key + would be dropped and only a `--settings` overlay can carry it.""" + launched = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["claude", *args], + base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + launcher=lambda p, a, e: launched.update(args=tuple(a)), + read_settings=lambda path: user if path == tmp_path / "cfg" / "settings.json" else {}, + write_overlay=_overlay_writer(tmp_path), + project_dir=tmp_path / "repo", + key_from_stored_login=key_from_stored_login, + ) + if expected_overlay is None: + assert launched["args"] == ("claude", *args) + else: + assert launched["args"][:2] == ("claude", "--settings") and len(launched["args"]) == 3 + assert _overlay(launched["args"]) == expected_overlay + + @pytest.mark.parametrize("project_helper", ["", "/opt/project/helper"], ids=["disabled", "its-own"]) + @pytest.mark.parametrize("key_from_stored_login", [True, False], ids=["stored-login", "api-key"]) + def test_claude_sends_the_launch_key_when_a_project_helper_sits_over_a_pkce_login( + self, tmp_path, project_helper, key_from_stored_login + ): + files = { + tmp_path / "repo" / ".claude" / "settings.json": {"apiKeyHelper": project_helper}, + tmp_path / "cfg" / "settings.json": {"apiKeyHelper": LITE_HELPER, "env": {"ANTHROPIC_AUTH_TOKEN": ""}}, + } + launched = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + launcher=lambda p, a, e: launched.update(args=tuple(a)), + read_settings=lambda path: files.get(path, {}), + write_overlay=_overlay_writer(tmp_path), + project_dir=tmp_path / "repo", + key_from_stored_login=key_from_stored_login, + ) + assert _overlay(launched["args"]) == { + "apiKeyHelper": "", + "env": {"ANTHROPIC_AUTH_TOKEN": "sk-key", "ANTHROPIC_BASE_URL": PROXY}, + } + + def test_concurrent_key_launches_each_read_their_own_settings_file(self, tmp_path): + config_dir = tmp_path / "cfg" + + def launch(base_url, api_key, *, subscription=False): + launched = {} + run_agent( + base_url, + api_key, + ["claude"], + subscription=subscription, + base_env={"CLAUDE_CONFIG_DIR": str(config_dir)}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + verify_subscription=lambda *a: None, + launcher=lambda p, a, e: launched.update(args=tuple(a)), + read_settings=lambda path: {"apiKeyHelper": LITE_HELPER, "env": {"ANTHROPIC_AUTH_TOKEN": ""}}, + project_dir=tmp_path / "repo", + ) + return Path(launched["args"][launched["args"].index("--settings") + 1]) + + first = launch(PROXY, "sk-a") + second = launch(OTHER_PROXY, "sk-b") + subscription = launch(PROXY, "sk-a", subscription=True) + assert len({first, second, subscription}) == 3 + assert json.loads(first.read_text())["env"] == {"ANTHROPIC_AUTH_TOKEN": "sk-a", "ANTHROPIC_BASE_URL": PROXY} + assert json.loads(second.read_text())["env"] == { + "ANTHROPIC_AUTH_TOKEN": "sk-b", + "ANTHROPIC_BASE_URL": OTHER_PROXY, + } + assert launch(PROXY, "sk-a") == first + assert sorted(path.name for path in config_dir.iterdir()) == sorted( + path.name for path in (first, second, subscription) + ) + if os.name == "posix": + assert {stat.S_IMODE(path.stat().st_mode) for path in (first, second, subscription)} == {0o600} + + def test_an_unreadable_settings_flag_beside_lites_helper_fails_before_launch(self, tmp_path): + launched = {} + with pytest.raises(AgentRunError, match="file not found"): + run_agent( + "http://localhost:4000", + "sk-key", + ["claude", "--settings", "missing.json"], + base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + launcher=lambda p, a, e: launched.update(args=tuple(a)), + read_settings=lambda path: {"apiKeyHelper": LITE_HELPER}, + write_overlay=_overlay_writer(tmp_path), + project_dir=tmp_path / "repo", + ) + assert launched == {} + + def test_a_malformed_settings_file_still_launches_claude_with_the_key(self, tmp_path): + def unreadable(path): + raise ClaudeSettingsError(f"{path} contains invalid JSON") + + launched = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")}, + which=lambda name: f"/usr/local/bin/{name}", + verify=lambda *a: None, + launcher=lambda p, a, e: launched.update(env=dict(e)), + read_settings=unreadable, + project_dir=tmp_path / "repo", + ) + assert launched["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-key" + def test_missing_binary_raises_with_install_hint(self): with pytest.raises(AgentRunError, match=r"claude.*Install it first"): run_agent( @@ -1755,7 +1978,9 @@ class TestRunAgent: 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): + def test_subscription_launch_blanks_the_credentials_and_helper_claude_code_would_load_from_settings( + self, tmp_path + ): launched = {} run_agent( "http://localhost:4000", @@ -1771,11 +1996,12 @@ class TestRunAgent: ) assert launched["args"][:2] == ("claude", "--settings") assert _overlay(launched["args"]) == { + "apiKeyHelper": "", "env": { "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_API_KEY": "", "ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key", - } + }, } assert launched["args"][3:] == ("--resume",) @@ -1881,6 +2107,7 @@ class TestRunAgent: assert launched["args"][:2] == ["claude", "--settings"] assert _overlay(launched["args"]) == { "model": "opus", + "apiKeyHelper": "", "env": { "ANTHROPIC_AUTH_TOKEN": "", "FOO": "bar", @@ -1907,6 +2134,7 @@ class TestRunAgent: assert launched["args"][:2] == ["claude", "--settings"] assert _overlay(launched["args"]) == { "theme": "dark", + "apiKeyHelper": "", "env": { "ANTHROPIC_API_KEY": "", "ANTHROPIC_AUTH_TOKEN": "", @@ -1985,6 +2213,7 @@ class TestRunAgent: assert launched["args"][:2] == ["claude", "--settings"] assert _overlay(launched["args"]) == { "theme": "dark", + "apiKeyHelper": "", "env": { "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_API_KEY": "", @@ -2039,7 +2268,7 @@ class TestRunAgent: verify_subscription=lambda *a: probed.append(("header", a)), sync_models=lambda *a: {}, preparers={}, - write_overlay=lambda text, environ: overlays.append(text), + write_overlay=lambda name, 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"))] @@ -2436,6 +2665,27 @@ class TestAgentCommands: assert captured["skip_verify"] is False assert "routing Claude Code through proxy at http://localhost:4000" in result.output + @pytest.mark.parametrize( + ("obj", "key_from_stored_login"), + [ + ({"api_key": "sk-stored", "api_key_from_token_file": True}, True), + ({"api_key": "sk-explicit", "api_key_from_token_file": False}, False), + ], + ids=["stored-login", "api-key-flag-or-env"], + ) + def test_only_a_stored_login_key_lets_lites_helper_stand_in_for_it(self, obj, key_from_stored_login): + captured = {} + with patch( + f"{AGENTS_MODULE}.run_agent", + side_effect=lambda b, k, c, **kw: captured.update(api_key=k, stored=kw.get("key_from_stored_login")), + ): + result = self.runner.invoke( + _agent_command("claude"), [], obj={"base_url": "http://localhost:4000", **obj} + ) + + assert result.exit_code == 0, result.output + assert captured == {"api_key": obj["api_key"], "stored": key_from_stored_login} + def test_codex_shows_friendly_name(self): captured = {} with patch( @@ -2594,7 +2844,8 @@ class TestAgentCommands: vault = secret_vault_factory() @click.command() - def fake_login(): + @click.option("--pkce", is_flag=True, default=None) + def fake_login(pkce): pass with ( @@ -2616,6 +2867,46 @@ class TestAgentCommands: assert captured["api_key"] == "sk-after-login" mock_get.assert_called_once_with(expected_base_url="http://localhost:4000", vault=vault) + @pytest.mark.parametrize( + ("stored", "expected_pkce"), + [ + ({"refresh_token": "llm_srefresh_old"}, True), + ({}, False), + ({"refresh_token": "llm_srefresh_old", "base_url": "http://other-proxy:4000"}, False), + ], + ids=["renewing-login-stored", "plain-login-stored", "renewing-login-for-another-proxy"], + ) + def test_the_login_lite_claude_starts_again_renews_like_the_stored_one( + self, tmp_path, monkeypatch, secret_vault_factory, stored, expected_pkce + ): + monkeypatch.setenv("HOME", str(tmp_path)) + vault = secret_vault_factory() + save_token( + {"base_url": "http://localhost:4000", "key": "sk-lapsed", "user_id": "u1", "timestamp": 1.0, **stored}, + vault=vault, + ) + logins = [] + + @click.command() + @click.option("--pkce", is_flag=True, default=None) + def fake_login(pkce): + logins.append(pkce) + + 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"), + patch(f"{AGENTS_MODULE}.run_agent"), + ): + 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 logins == [expected_pkce] + def test_child_exit_code_reaches_the_shell(self): with patch(f"{AGENTS_MODULE}.run_agent", side_effect=SystemExit(42)): result = self.runner.invoke( diff --git a/tests/unit/proxy/client/cli/test_auth_commands.py b/tests/unit/proxy/client/cli/test_auth_commands.py index 18c2c6ce8af..045a96436b1 100644 --- a/tests/unit/proxy/client/cli/test_auth_commands.py +++ b/tests/unit/proxy/client/cli/test_auth_commands.py @@ -1,4 +1,5 @@ import dataclasses +import functools import json import os import stat @@ -36,7 +37,7 @@ from litellm.proxy.client.cli.commands.auth import ( print_token, whoami, ) -from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner +from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner, installed_executable, lite_api_key_helper @pytest.fixture @@ -577,6 +578,19 @@ class TestLogoutCommand: assert vault.blob is None assert not (isolated_home / ".litellm" / "token.json").exists() + def test_logout_removes_the_key_files_lite_claude_launched_with( + self, isolated_home, secret_vault_factory, launch_claude + ): + vault = secret_vault_factory(blob=_secret_blob("https://test.example.com", "sk-stored")) + _write_token_file(isolated_home, key=None) + launched_with = (launch_claude(False), launch_claude(True)) + assert all(path.exists() for path in launched_with) + + result = self.runner.invoke(logout, obj={"secret_vault": vault}) + + assert result.exit_code == 0, result.output + assert not any(path.exists() for path in launched_with) + def test_logout_without_the_keyring_package_does_not_claim_the_keychain_is_clear( self, isolated_home, secret_vault_factory ): @@ -1075,8 +1089,8 @@ class TestPrintTokenCommand: ("proxy_answer", "expected_exit_code", "expected_stdout", "expected_stderr"), [ (200, 0, "sk-stale-key\n", ""), - (401, 1, "", "Token expired. Run 'lite login' again.\n"), - (403, 1, "", "Token expired. Run 'lite login' again.\n"), + (401, 1, "", "Token expired. Run 'lite login --pkce' again.\n"), + (403, 1, "", "Token expired. Run 'lite login --pkce' again.\n"), (404, 0, "sk-stale-key\n", ""), (500, 0, "sk-stale-key\n", ""), (requests.ConnectionError("proxy unreachable"), 0, "sk-stale-key\n", ""), @@ -1137,7 +1151,7 @@ class TestPrintTokenCommand: assert result.exit_code == 1 assert result.stdout == "" - assert result.stderr == "Token expired. Run 'lite login' again.\n" + assert result.stderr == "Token expired. Run 'lite login --pkce' again.\n" assert len(responses.calls) == 0 @@ -1360,23 +1374,31 @@ class TestKeychainBackedCommands: assert whoami_result.output == "Not authenticated. Run 'lite login' to authenticate.\n" assert print_result.exit_code == 1 - assert print_result.output == "Not authenticated. Run 'lite login'.\n" + assert print_result.output == "Not authenticated. Run 'lite login --pkce'.\n" + @pytest.mark.parametrize( + ("base_url_explicit", "login_command"), + [(False, "lite login --pkce"), (True, "lite --base-url https://test.example.com login --pkce")], + ids=["bare", "as-claude-codes-helper"], + ) def test_print_token_explains_a_locked_keychain_instead_of_printing_nothing( - self, isolated_home, secret_vault_factory + self, isolated_home, secret_vault_factory, base_url_explicit, login_command ): _write_home_json( isolated_home, "token.json", {"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()}, ) - obj = {"base_url": "https://test.example.com", "secret_vault": secret_vault_factory(available=False)} + obj = { + "base_url": "https://test.example.com", + "base_url_explicit": base_url_explicit, + "secret_vault": secret_vault_factory(available=False), + } result = self.runner.invoke(print_token, obj=obj) assert result.exit_code == 1 - assert "could not be read" in result.output - assert "lite login" in result.output + assert f"could not be read. Unlock it, or run '{login_command}' to start over." in result.output def test_whoami_does_not_call_a_credential_it_cannot_read_authenticated(self, isolated_home, secret_vault_factory): """A login whose secret is stuck in an unreachable keychain authenticates nothing. Leading @@ -1519,18 +1541,18 @@ class TestLoginConfigClaude: assert "Configured Claude Code" not in result.output def test_flag_writes_the_settings_file_and_reports_success(self, tmp_path, monkeypatch): + monkeypatch.setenv("PATH", str(_fake_lite(tmp_path).parent)) result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"]) assert result.exit_code == 0 written = json.loads(settings_path.read_text()) assert written["env"]["ANTHROPIC_BASE_URL"] == "https://test.example.com" assert written["env"]["ENABLE_TOOL_SEARCH"] == "true" - # The minted key goes in as a static token: an apiKeyHelper would make Claude Code spawn `lite` (and - # its keychain probe) on every credential refresh, which is what this flag used to write. assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt" assert "apiKeyHelper" not in written assert f"Configured Claude Code: {settings_path} now routes through https://test.example.com." in result.output assert "run `lite login --config-claude` again after it expires" in result.output + assert "`lite login --pkce --config-claude` instead lets Claude Code renew the key" in result.output assert "the model Claude Code starts and resumes on" in result.output def test_flag_preserves_unrelated_settings_on_an_existing_file(self, tmp_path, monkeypatch): @@ -1631,6 +1653,65 @@ class TestLoginConfigClaude: assert "even though the CLI itself could not keep it" in result.output assert "You can now use the CLI without specifying --api-key" not in result.output + def _run_pkce_login(self, tmp_path, monkeypatch, path, stored): + settings_path = tmp_path / "claude" / "settings.json" + self._isolate_default_settings(tmp_path, monkeypatch) + with ( + patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()), + patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=None), + patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=stored), + patch("litellm.proxy.client.cli.interface.show_commands"), + patch( + "litellm.proxy.client.cli.commands.auth.lite_api_key_helper", + functools.partial( + lite_api_key_helper, + which=lambda name: installed_executable(name, scripts_dir=str(tmp_path / "python-without-lite")), + ), + ), + ): + result = self.runner.invoke( + login, + ["--pkce", "--config-claude"], + obj={"base_url": PKCE_BASE_URL}, + env={"CLAUDE_CONFIG_DIR": str(settings_path.parent), "PATH": path}, + ) + return result, settings_path + + def test_a_pkce_login_gets_a_helper_that_renews_the_key_instead_of_a_copy_that_expires(self, tmp_path, monkeypatch): + lite = _fake_lite(tmp_path) + result, settings_path = self._run_pkce_login(tmp_path, monkeypatch, str(lite.parent), SecretStored()) + + assert result.exit_code == 0, result.output + written = json.loads(settings_path.read_text()) + assert written["env"]["ANTHROPIC_BASE_URL"] == PKCE_BASE_URL + assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "" + assert str(lite.parent) in written["apiKeyHelper"] + assert written["apiKeyHelper"].endswith(f"--base-url {PKCE_BASE_URL} auth print-token") + assert "sk-cli-fresh" not in settings_path.read_text() + assert "`lite auth print-token`, which renews it before it expires" in result.output + + @pytest.mark.parametrize( + ("lite_on_path", "stored", "reason"), + [ + (False, SecretStored(), "Could not find the `lite` command"), + (True, CredentialNotSaved("read-only ~/.litellm"), "even though the CLI itself could not keep it"), + (True, CredentialNotRecorded(), "even though the CLI itself could not keep it"), + ], + ids=["lite-not-on-path", "nothing-kept-it", "keychain-took-it-file-refused"], + ) + def test_a_pkce_login_lite_cannot_renew_for_claude_code_gets_the_static_key( + self, tmp_path, monkeypatch, lite_on_path, stored, reason + ): + path = str(_fake_lite(tmp_path).parent) if lite_on_path else str(tmp_path / "empty-path") + result, settings_path = self._run_pkce_login(tmp_path, monkeypatch, path, stored) + + assert result.exit_code == 0, result.output + written = json.loads(settings_path.read_text()) + assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-cli-fresh" + assert "apiKeyHelper" not in written + assert reason in result.output + assert "run `lite login --pkce --config-claude` again after it expires" in result.output + def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path, monkeypatch): settings_path = tmp_path / "claude" / "settings.json" settings_path.parent.mkdir(parents=True) @@ -1645,6 +1726,14 @@ class TestLoginConfigClaude: assert "Authentication failed" not in result.output +def _fake_lite(tmp_path): + lite = tmp_path / "bin" / ("lite.exe" if os.name == "nt" else "lite") + lite.parent.mkdir(exist_ok=True) + lite.write_text("") + lite.chmod(0o700) + return lite + + class _FakeHttpResponse: def __init__(self, status_code, payload): self.status_code = status_code @@ -2339,7 +2428,7 @@ class TestPkcePrintToken: "Could not renew the key: token request failed with 400: the refresh token was already used" in result.output ) - assert "Key expired. Run 'lite login --pkce' again." in result.output + assert "Key expired. Run 'lite --base-url https://llm.example.com login --pkce' again." in result.output save.assert_not_called() def test_print_token_through_the_cli_group_prints_the_key_the_group_renewed(self, monkeypatch): @@ -2405,7 +2494,7 @@ class TestPkcePrintToken: assert result.exit_code == 1 assert result.stdout == "" - assert result.stderr == "Token expired. Run 'lite login' again.\n" + assert result.stderr == "Token expired. Run 'lite login --pkce' again.\n" assert len(responses.calls) == 0 assert _FakeSession.instances == [] @@ -2493,7 +2582,7 @@ def test_login_shows_the_error_detail_only_when_the_proxy_answers_with_a_json_ob {"base_url": "https://other.example.com", "base_url_explicit": True}, 1, "", - "Not authenticated for this server. Run 'lite login'.\n", + "Not authenticated for this server. Run 'lite --base-url https://other.example.com login --pkce'.\n", ), ({"base_url": "https://other.example.com", "base_url_explicit": False}, 0, "sk-stored\n", ""), ( @@ -2533,7 +2622,24 @@ def test_print_token_without_a_context_object_reports_no_login_when_nothing_is_s assert result.exit_code == 1 assert result.stdout == "" - assert result.stderr == "Not authenticated. Run 'lite login'.\n" + assert result.stderr == "Not authenticated. Run 'lite login --pkce'.\n" + + +@pytest.mark.parametrize( + ("obj", "expected_command"), + [ + ({"base_url": "https://proxy.example.com/", "base_url_explicit": True}, "lite --base-url https://proxy.example.com login --pkce"), + ({"base_url": "https://proxy.example.com", "base_url_explicit": False}, "lite login --pkce"), + ], + ids=["helper-names-its-proxy", "default-proxy"], +) +def test_print_token_with_nothing_stored_names_the_login_that_renews_for_the_proxy_it_was_asked_about( + isolated_home, obj, expected_command +): + result = CliRunner().invoke(print_token, obj=obj) + + assert result.exit_code == 1 + assert result.stderr == f"Not authenticated. Run '{expected_command}'.\n" def test_print_token_without_a_context_object_fails_reading_it_once_a_login_is_stored(isolated_home): diff --git a/tests/unit/proxy/client/cli/test_claude_settings.py b/tests/unit/proxy/client/cli/test_claude_settings.py index 9451598aa40..80aa86c5058 100644 --- a/tests/unit/proxy/client/cli/test_claude_settings.py +++ b/tests/unit/proxy/client/cli/test_claude_settings.py @@ -3,6 +3,7 @@ import os import pathlib import shlex import stat +import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError @@ -22,9 +23,11 @@ from litellm.proxy.client.cli.commands.claude_settings import ( BACKUP_PATH, CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, + LAUNCH_KEY_OVERLAY_PREFIX, OWNED_ENV_KEYS, OWNED_TOP_LEVEL_KEYS, SETTINGS_FILE_OWNERS, + ApiKeyHelper, ClaudeSettingsError, KeepModel, SettingsFileOwner, @@ -35,7 +38,12 @@ from litellm.proxy.client.cli.commands.claude_settings import ( configure_claude_settings, install_statusline_script, configure_state_path, + installed_executable, + is_lite_api_key_helper, + is_lite_api_key_helper_for, + lite_api_key_helper, merge_claude_settings, + remove_launch_settings_files, statusline_command, unconfigure_claude_settings, with_status_line, @@ -165,6 +173,141 @@ class TestConfigureClaudeSettings: assert not settings_path.exists() +class TestLiteApiKeyHelper: + @pytest.mark.skipif(sys.platform.startswith("win"), reason="runs the command through sh, as Claude Code does on POSIX") + def test_sh_runs_the_lite_on_path_against_this_proxy(self, tmp_path): + lite = tmp_path / "my tools" / "lite" + lite.parent.mkdir() + lite.write_text('#!/bin/sh\nprintf "%s\\n" "$0" "$@"\n') + lite.chmod(0o700) + + helper = lite_api_key_helper("https://proxy.example.com/", platform="linux", which=lambda name: str(lite)) + + assert helper is not None + ran = subprocess.run(["sh", "-c", helper.command], capture_output=True, text=True, check=True) + assert ran.stdout.splitlines() == [str(lite), "--base-url", "https://proxy.example.com", "auth", "print-token"] + + @pytest.mark.skipif(sys.platform.startswith("win"), reason="runs the command through sh, as Claude Code does on POSIX") + def test_a_lite_found_through_a_relative_path_entry_keeps_running_from_any_directory(self, tmp_path, monkeypatch): + for repo, name in ((tmp_path / "login-dir", "mine"), (tmp_path / "other-repo", "planted")): + lite = repo / "bin" / "lite" + lite.parent.mkdir(parents=True) + lite.write_text(f"#!/bin/sh\necho {name}\n") + lite.chmod(0o700) + monkeypatch.chdir(tmp_path / "login-dir") + + helper = lite_api_key_helper("https://proxy.example.com", platform="linux", which=lambda name: "bin/lite") + + assert helper is not None + ran = subprocess.run( + ["sh", "-c", helper.command], cwd=tmp_path / "other-repo", capture_output=True, text=True, check=True + ) + assert ran.stdout == "mine\n" + + def test_quotes_every_token_for_cmd_on_windows(self): + helper = lite_api_key_helper( + "https://proxy.example.com", platform="win32", which=lambda name: r"C:\Program Files\lite\lite.exe" + ) + + assert helper is not None + assert helper.command == ( + r'"C:\Program Files\lite\lite.exe" "--base-url" "https://proxy.example.com" "auth" "print-token"' + ) + + def test_no_lite_on_path_means_no_helper(self): + assert lite_api_key_helper("https://proxy.example.com", which=lambda name: None) is None + + @pytest.mark.parametrize( + "command", + [ + "/usr/local/bin/lite --base-url https://proxy.example.com auth print-token", + "'/home/me/my tools/lite' --base-url https://other.example.com auth print-token", + r'"C:\Program Files\lite\lite.exe" "--base-url" "https://proxy.example.com" "auth" "print-token"', + ], + ids=["posix", "posix-quoted-path", "windows"], + ) + def test_recognizes_a_helper_any_lite_wrote_for_any_proxy(self, command): + assert is_lite_api_key_helper(command) + + @pytest.mark.parametrize( + "command", + ["/opt/mine/helper", "/usr/local/bin/lite auth print-token --verbose", "", None, ["lite", "auth", "print-token"]], + ids=["users-own-helper", "other-trailing-args", "disabled", "unset", "not-a-string"], + ) + def test_leaves_every_other_helper_alone(self, command): + assert not is_lite_api_key_helper(command) + + @pytest.mark.parametrize( + ("command", "matches"), + [ + ("/usr/local/bin/lite --base-url https://proxy.example.com auth print-token", True), + ("'/home/me/my tools/lite' --base-url https://proxy.example.com auth print-token", True), + (r'"C:\Program Files\lite\lite.exe" "--base-url" "https://proxy.example.com" "auth" "print-token"', True), + ("/usr/local/bin/lite --base-url https://other.example.com auth print-token", False), + ("/usr/local/bin/lite --base-url https://proxy.example.com/v2 auth print-token", False), + ("/opt/mine/helper", False), + (None, False), + ], + ids=["posix", "posix-quoted-path", "windows", "other-proxy", "other-path-on-the-proxy", "users-own", "unset"], + ) + def test_matches_a_helper_any_lite_wrote_for_this_proxy_only(self, command, matches): + assert is_lite_api_key_helper_for(command, "https://proxy.example.com/") is matches + + +@pytest.mark.skipif(sys.platform.startswith("win"), reason="runs the command through sh, as Claude Code does on POSIX") +class TestInstalledExecutable: + @staticmethod + def _script(path, body): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"#!/bin/sh\n{body}\n") + path.chmod(0o700) + + def test_the_helper_runs_the_lite_installed_beside_this_python_over_a_shim_earlier_on_path( + self, tmp_path, monkeypatch + ): + self._script(tmp_path / "shims" / "lite", "echo 'pyenv: lite: command not found' >&2; exit 127") + self._script(tmp_path / "venv" / "bin" / "lite", "echo real") + monkeypatch.setenv("PATH", f"{tmp_path / 'shims'}{os.pathsep}{os.environ['PATH']}") + + helper = lite_api_key_helper( + "https://proxy.example.com", + platform="linux", + which=lambda name: installed_executable(name, scripts_dir=str(tmp_path / "venv" / "bin")), + ) + + assert helper is not None + ran = subprocess.run(["sh", "-c", helper.command], capture_output=True, text=True) + assert (ran.returncode, ran.stdout) == (0, "real\n") + + def test_falls_back_to_path_when_this_python_has_no_lite(self, tmp_path, monkeypatch): + self._script(tmp_path / "pipx" / "lite", "echo pipx") + (tmp_path / "venv" / "bin").mkdir(parents=True) + monkeypatch.setenv("PATH", str(tmp_path / "pipx")) + + assert installed_executable("lite", scripts_dir=str(tmp_path / "venv" / "bin")) == str(tmp_path / "pipx" / "lite") + + def test_a_lite_planted_in_the_working_directory_never_becomes_the_helper(self, tmp_path, monkeypatch): + self._script(tmp_path / "repo" / "lite", "echo planted") + self._script(tmp_path / "pipx" / "lite", "echo pipx") + (tmp_path / "venv" / "bin").mkdir(parents=True) + monkeypatch.chdir(tmp_path / "repo") + + found = installed_executable( + "lite", scripts_dir=str(tmp_path / "venv" / "bin"), environ={"PATH": f"{os.pathsep}{tmp_path / 'pipx'}"} + ) + + assert found == str(tmp_path / "pipx" / "lite") + + def test_finds_the_lite_exe_launcher_a_windows_python_installs(self, tmp_path): + self._script(tmp_path / "venv" / "Scripts" / "lite.exe", "") + + found = installed_executable( + "lite", scripts_dir=str(tmp_path / "venv" / "Scripts"), environ={"PATH": ""}, platform="win32" + ) + + assert found == str(tmp_path / "venv" / "Scripts" / "lite.exe") + + class TestConflictingOwnersOfTheSettingsFile: """Both `lite up` and `lite autoroute start` restore a backup when they stop. @@ -313,6 +456,14 @@ class TestMergeClaudeSettings: assert "model" not in merged assert not any(key in merged["env"] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS) + def test_a_helper_lands_top_level_and_blanks_the_token_a_shell_export_would_win_with(self): + settings = {"env": {"ANTHROPIC_AUTH_TOKEN": "sk-expired-login", "ANTHROPIC_API_KEY": "leaked"}} + merged = merge_claude_settings(settings, "http://127.0.0.1:4000/", HELPER) + assert merged["apiKeyHelper"] == HELPER.command + assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000" + assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "" + assert "ANTHROPIC_API_KEY" not in merged["env"] + def test_keeps_existing_switch_values_and_unrelated_keys_without_mutating_the_input(self): settings = {"theme": "dark", "env": {"SOME_OTHER_VAR": "value", "ENABLE_TOOL_SEARCH": "false"}} merged = merge_claude_settings(settings, "http://127.0.0.1:4000", StaticToken("token-abc")) @@ -374,6 +525,7 @@ class TestMergeClaudeSettings: PROXY = "http://127.0.0.1:4000" ANTHROPIC = "https://api.anthropic.com" RELOGIN = StaticToken("sk-fresh-login") +HELPER = ApiKeyHelper("/usr/local/bin/lite --base-url http://127.0.0.1:4000 auth print-token") ORIGINAL = { "theme": "dark", "permissions": {"allow": ["Bash"]}, @@ -432,6 +584,15 @@ class _Rig: # to be one test each; the receipt's rules are what make them all come out right. UNDO_SCENARIOS = { "plain round trip": (ORIGINAL, [], ORIGINAL, {"kept": ()}), + "helper round trip": (ORIGINAL, [], ORIGINAL, {"kept": ()}, {"credential": HELPER, "model": KeepModel()}), + "helper with no file before": (None, [], None, {"file_removed": True}, {"credential": HELPER}), + "re-logins switching between the token and the helper keep the first snapshot": ( + ORIGINAL, + [{"credential": HELPER, "model": KeepModel()}, {"credential": RELOGIN, "model": KeepModel()}], + ORIGINAL, + {"kept": ()}, + {"credential": HELPER, "model": KeepModel()}, + ), "no file before": (None, [], None, {"file_removed": True}), "no env before": ({"theme": "dark"}, [], {"theme": "dark"}, {}), "null env before": ({"theme": "dark", "env": None}, [], {"theme": "dark", "env": None}, {}), @@ -973,3 +1134,21 @@ class TestStatusLine: assert "ANTHROPIC_AUTH_TOKEN" not in restored.get("env", {}) assert restored["statusLine"]["command"] == statusline_command(script) assert "statusLine" not in outcome.restored + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() == 0, reason="needs a directory its owner cannot write") +def test_a_key_file_that_cannot_be_removed_is_named_so_the_user_can_delete_it(tmp_path, capsys): + config_dir = tmp_path / "cfg" + config_dir.mkdir() + stuck = config_dir / f"{LAUNCH_KEY_OVERLAY_PREFIX}0123456789abcdef.json" + stuck.write_text("{}") + config_dir.chmod(0o500) + try: + remove_launch_settings_files(config_dir) + finally: + config_dir.chmod(0o700) + + output = capsys.readouterr() + assert stuck.exists() + assert str(stuck) in output.err + assert "Removed" not in output.out diff --git a/tests/unit/proxy/client/cli/test_configure_commands.py b/tests/unit/proxy/client/cli/test_configure_commands.py index d8be80ef560..8be99cb31ed 100644 --- a/tests/unit/proxy/client/cli/test_configure_commands.py +++ b/tests/unit/proxy/client/cli/test_configure_commands.py @@ -545,6 +545,21 @@ class TestConfigureAgents: class TestUnconfigureClaude: + def test_removes_the_key_files_lite_claude_launched_with_even_when_nothing_was_configured( + self, runner, paths, launch_claude + ): + settings_path, _ = paths + launched_with = (launch_claude(False), launch_claude(True)) + settings_path.write_text("{}") + assert {path.parent for path in launched_with} == {settings_path.parent} + assert all(path.exists() for path in launched_with) + + result = runner.invoke(cli, ["unconfigure", "claude"]) + assert result.exit_code == 0, result.output + assert not any(path.exists() for path in launched_with) + assert settings_path.exists() + assert "Removed the settings files holding a LiteLLM key" in result.output + @responses.activate def test_restores_the_original_file_and_removes_the_receipt(self, runner, paths): _mock_models() diff --git a/tests/unit/proxy/client/cli/test_up_commands.py b/tests/unit/proxy/client/cli/test_up_commands.py index 5c2e726315b..7732220b5d4 100644 --- a/tests/unit/proxy/client/cli/test_up_commands.py +++ b/tests/unit/proxy/client/cli/test_up_commands.py @@ -372,6 +372,19 @@ class TestEnsureFreshLogin: assert login_calls == [("http://proxy-a:4000", True)] + def test_a_stale_pkce_credential_for_another_proxy_leaves_the_login_mode_to_the_saved_default(self, monkeypatch): + monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: True) + store = _FakeTokenStore(monkeypatch, _pkce_record("http://proxy-a:4000", seconds_left=-10), {}) + monkeypatch.setattr(up_module, "pkce_default", lambda: False) + login_calls = _capture_login( + monkeypatch, + on_login=lambda: store.log_in(_pkce_record("http://proxy-b:4000", seconds_left=86_400), "sk-b"), + ) + + ensure_fresh_login(_make_ctx("http://proxy-b:4000")) + + assert login_calls == [("http://proxy-b:4000", False)] + def test_logs_in_with_pkce_when_the_saved_pkce_default_is_on(self, monkeypatch): """`lite config set pkce true` is honoured by the login `lite up` starts, not only by `lite login`.""" monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: True)