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 <B 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 <joshua@berri.ai>
Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
Co-authored-by: shrey <shrey@berri.ai>
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>
This commit is contained in:
norkelbyte 2026-11-13 20:04:38 -08:00 • committed by GitHub
parent 01de1a686d
commit dc378de207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 974 additions and 90 deletions

View file

@ -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-<UTF-8 hex of the group name>` 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=<path>` 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-<UTF-8 hex of the group name>` 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=<path>` 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/<id>`. 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/<other-id>` 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

View file

@ -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",

View file

@ -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)

View file

@ -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",

View file

@ -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.")

View file

@ -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.")

View file

@ -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

View file

@ -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(

View file

@ -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):

View file

@ -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

View file

@ -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()

View file

@ -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)