Commit graph

4151 commits

Author SHA1 Message Date
mateo-berri
4eb7bf32c0 fix(proxy): keep token-auth URLs, toggles, and refresh sleeps safe
Three fixes on the Postgres token-auth path found by a live risk pass:

Pre-encoded connection components no longer double-escape. The user, database
name, and schema used to be interpolated raw, so encoding an already-encoded
DATABASE_USER like svc%40corp turned it into svc%2540corp and Postgres rejected
the login with P1010. Decoding before encoding is idempotent, so a pre-encoded
value comes out byte for byte as it went in while a raw UPN still gets encoded.

An unreadable IAM_TOKEN_DB_AUTH or AZURE_POSTGRESQL_AUTH now fails startup
naming the variable and the value. Reading a typo like "enabled" as off would
silently downgrade an operator from token auth to password auth, and the first
sign of it would be the server refusing the connection.

The proactive refresh loop floors its sleep at 30 seconds. azure-identity hands
back its cached token when a renewal fails inside its own window, so a token
whose expiry never advances used to compute a zero sleep and spin the loop,
re-minting and recreating the Prisma query engine every pass.

Co-authored-by: David Balatoni <balcsida@gmail.com>
2026-08-20 12:59:58 -07:00
yucheng-berri
3a31331435
fix(proxy): run pre-call guardrails on batch input file uploads (#37519)
* fix(proxy): run pre-call guardrails on batch input file uploads

POST /v1/files with purpose=batch was the only route in files_endpoints that
never reached pre_call_hook, so guardrails did not see batch content at all and
records reached the provider unscanned.

Stream the uploaded JSONL a record at a time and run each record's body through
the existing pre_call_hook dispatch under the call type its url maps to, so
guardrail resolution, key and team config, and the per-endpoint translations are
reused rather than reimplemented. The hook gains a guardrails_only mode for this,
since the same callback loop also drives rate limiters, budget hooks, prompt
templates and hanging-request alerting, none of which should fire once per record.

A guardrail that blocks raises its own exception, which propagates untouched so
its status code survives. A record a guardrail would rewrite, a record that
cannot be parsed, and a record whose url cannot be scanned all reject the upload,
since silently skipping any of them is the bypass this is meant to close.
Per-record redaction lands separately.

The scan only runs when a guardrail that actually runs pre_call, or a guardrail
pipeline, is configured, so deployments without one are byte for byte unchanged.

* fix(proxy): compare the dict a batch guardrail returns, not the one it was given

async_pre_call_hook may return a replacement dict instead of mutating its input, and
process_pre_call_hook_response then makes that replacement the request. The scan only
inspected the dict it passed in, so a guardrail that redacts by returning a copy was
treated as a no-op and its record uploaded unchanged.

* fix(proxy): treat a missing batch body key as different from a null one

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(proxy): document the guardrails_only mode on pre_call_hook

* fix(proxy): resolve a batch record's scan type from its body when the url is unfamiliar

The scanner only accepted five exact urls, but callers write that field by hand and the
provider transformers are far more permissive: bedrock treats any non-empty url as chat
and vertex strips query strings and trailing slashes. Uploads that work today would have
started failing the moment a pre-call guardrail was configured.

Normalize the url before lookup and fall back to the body shape when it is unfamiliar, so
a record we can still read is a record we still scan. Only a body with no messages, prompt
or input is now refused, and the error says so instead of listing urls that were never the
whole set.

Also pins the default side of the guardrails_only gate: the hanging-request alert and
prompt templating are asserted to still fire when the flag is absent.

* refactor(proxy): drop batch guardrail checks the upload validation already makes

check_batch_file_upload now runs first and rejects a line that does not parse, a line that
is not an object, and a line missing custom_id, method, url or body, so the guardrail scan
can rely on all four. Its own parse handling was unreachable through the endpoint and is
gone, along with the tests for it. What is left is the case that validation does not cover,
a body whose value is not an object, since it only checks that the key is present.

* fix(proxy): resolve a batch record's call type from the url path, not the whole url

A record naming its route in full, which is how callers actually write batch files, matched
no known route, so it fell through to the body shape. A Responses record carries `input`,
and that reads as an embedding, so the record was scanned as the wrong call type and any
guardrail scoped to chat or Responses skipped it while the upload was accepted. Chat records
survived only because their body shape happens to map back to the same call type. The url is
now reduced to its path before matching.

Guardrails that pick their policy from a request header, such as noma choosing an application
id, saw no headers at all during the scan and fell back to a default, so a batch record could
be evaluated under a different policy than the same content sent online. The sanitized headers
the proxy already stores in request metadata now travel with the scan.

Also drops the bare `dict` annotation, the unreachable non-dict branch on the guardrail chain's
own return, and the type alias that was missing its `TypeAlias`, which together were failing
the lint gate.

* fix(proxy): give each batch record its own copy of the scan metadata

The narrowed metadata was handed to every record as a shallow copy, so `headers` and `tags`
stayed shared with the upload request and with the other records in the same window. A guardrail
that writes into one of those in place, which several do to record their own bookkeeping, would
have its write show up in every record scanned after it and in the request itself. The narrowing
already removed the values that cannot be copied, so each record now gets a deep copy.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-20 12:51:15 -07:00
yuneng-jiang
122675c309
feat(ui): let admins supply a dark-mode variant of their custom logo (#37662)
* feat(ui): let admins supply a dark-mode variant of their custom logo

A deployment branded through UI_LOGO_PATH got its light artwork on the
dark sidebar, and there was nothing an admin could set to change that.

Adds UI_LOGO_PATH_DARK, exposed as the logo_url_dark theme setting and a
second field on the UI theme page. /get_image now walks an ordered list
of candidates for the requested theme and serves the first usable one:
the dark logo, then the light logo, then the bundled default.

Falling through rather than failing is the point. An admin who never
sets a dark logo keeps their own light one instead of reverting to
LiteLLM's, and a dark logo that goes missing later degrades to their
light logo rather than dropping their branding entirely.

* fix(ui): recover from a dark logo the browser cannot load

A dark logo given as an http(s) URL is loaded by the browser straight
from the sidebar, so it never passes through the proxy's fallback chain.
A URL that 404s left a broken image where the admin's light logo should
have been, while the same logo given as a local path fell back cleanly.

The sidebar now remembers the dark URL that failed and drops to the light
logo, matching how the proxy resolves an unusable dark logo and how the
provider Logo component already handles a broken image.
2026-08-20 12:48:55 -07:00
tin-berri
135f234e89
feat(proxy): add POST /auto_router/validate_complexity_router_config to dry-run the complexity-router write gate (#37409)
* feat(proxy): add POST /auto_router/validate_config to dry-run the complexity-router write gate

* refactor(proxy): scope the validation endpoint name to the complexity router

* fix(proxy): give the complexity-router validate route the same audience as /model/new

* feat(proxy): gate auto-router dry runs like the write they rehearse

* chore(proxy): dedupe the validate route's self_managed_routes entry

* test(proxy): fold the dry-run route reachability check into the model-new audience parity test

* fix(proxy): scope test_routing's configured check to models the caller can use

* chore(ui): regenerate schema.d.ts for the scoped configured-check description
2026-08-20 19:48:32 +00:00
mateo-berri
50bf99a909 Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_azure_postgres_entra_auth
# Conflicts:
#	type-discipline-budget.json
2026-08-20 11:58:52 -07:00
mateo-berri
5c213127e8 feat(proxy): authenticate to Azure Postgres with Microsoft Entra ID tokens
Azure Database for PostgreSQL Flexible Server takes a Microsoft Entra ID access
token as the connection password, and those tokens last about an hour, so a
proxy pointed at one dies shortly after boot unless something keeps minting
fresh ones

Set AZURE_POSTGRESQL_AUTH=True (or pass --azure_postgresql_auth) alongside
DATABASE_HOST, DATABASE_USER, and DATABASE_NAME, and the proxy mints a token at
startup, assembles the connection URL around it, and refreshes it in the
background for as long as the process runs. That is the same shape
IAM_TOKEN_DB_AUTH already had for AWS RDS, so the two now share one code path:
a tagged union picks the minting strategy once, and the wrapper, the read
replica, and the refresh loop all read the choice off it instead of each
guessing from the environment. Setting both toggles is a startup error, in the
chart as well as in Python

The helm chart gets database.writer.useAzureEntraAuth and the matching reader
knob next to the existing useIAMAuth

Fixes #29661

Co-authored-by: David Balatoni <balcsida@gmail.com>
2026-08-20 11:50:16 -07:00
mubashir1osmani
d542c82f0e
fix(otel): route Phoenix traces to per-key/team projects under otel v2 (#36706)
* feat(otel): route Phoenix traces to per-key/team projects under otel v2

The v2 arize_phoenix preset read PHOENIX_PROJECT_NAME once at startup into a
static resource attribute, silently dropping the per-key/team project routing
v1 supported. Route it via Phoenix's x-project-name OTLP/HTTP header instead:
the env var stays the global default, and a phoenix_project_name (or
phoenix_project_name_override) in key/team metadata sends that key's traces
to the named project.

The project comes only from user_api_key_auth_metadata (server-set at auth),
never from client request metadata or StandardCallbackDynamicParams, since
choosing the telemetry destination is a data-exfiltration primitive. The
header is appended to the exporter's static headers rather than replacing
them, so the preset's Authorization survives, and it is gated to OTLP/HTTP
exporters because Phoenix only reads it on /v1/traces.

Also unban the bare phoenix_project_name fields from the request-body gate:
the proxy integrations ignore them (only user_api_key_auth_metadata routes,
and that stays banned), so rejecting them just broke SDK-style callers.

* fix(otel): root project-routed Phoenix spans in their own trace

Phoenix assigns a whole trace to one project by whichever span arrives
first. The request's auth/db/root spans always export through the default
provider without the project header, so a project-routed LLM span parented
into that trace got dragged back into the default project and the header
did nothing (verified against a live Phoenix instance). Detach the routed
span into its own trace with a link back to the request trace, mirroring
how the v1 Phoenix logger exported each request under its own local parent.

* fix(otel): drain in-flight spans before shutting down evicted providers

LRU eviction shut a routed provider down immediately, but an LLM span
opened at pre_call stays open until the later success or failure callback;
with more than 256 overlapping credential/project routes that in-flight
span was silently dropped instead of exported. Refcount open spans per
provider (hold at span open, release when the carrier is removed on close,
carrier-map eviction, or MCP stray-carrier cleanup) and defer a retired
provider's shutdown until its last open span closes.

* fix(otel): take the provider hold inside route_for to close the eviction race

pre_call can run on thread-pool workers, so between route_for returning a
provider and the caller recording its open span, a concurrent request could
overflow the LRU and shut that provider down with a zero span count, dropping
the routed trace. route_for now increments the open-span count in the same
locked critical section as the cache update and hands back an already-held
provider; every caller releases it once its span has landed. The lock also
makes the cache mutations safe under that same thread-pool concurrency.

* fix(otel): skip tenant routing on deferred pre_call

route_for ran before the recordable-parent check, so a thread-pool
pre_call still built or LRU-touched a tenant provider and could evict
an idle one even though the hold was released immediately and close
re-routed. Only route when the span actually opens

* add somethign

* Revert "add somethign"

This reverts commit 2f2cf84c5a.

* fix(otel): cap retired tenant providers draining open spans

* docs(otel): justify the retired-provider cap
2026-08-20 11:37:09 -07:00
yuneng-jiang
edbb3429a3
feat(ui): serve a dark-mode variant of the LiteLLM logo (#37656)
The bundled logo is a JPEG, so it carries no alpha and its white
background renders as a bright slab against a dark sidebar. Making it
transparent alone would not be enough either: the wordmark is near-black
and would disappear on dark.

Adds logo_dark.png, derived from the light logo. The sky-blue disc and
train are kept as they are behind a circular alpha mask, and the
wordmark's antialiasing is un-flattened from white into straight alpha
and repainted in the dark theme's own foreground colour. Both files are
1000x257, so swapping between them cannot shift the sidebar header.

/get_image gains a theme query param. The default response is byte for
byte what it was, and a logo configured through UI_LOGO_PATH is served
unchanged in both themes, since custom logos have no dark variant yet.
2026-08-20 11:26:10 -07:00
tin-berri
c164944d40
fix(ui): draw one Per Day savings bar per date on Cost Optimization (#37643)
* fix(ui): draw one Per Day savings bar per date on Cost Optimization

The page paged /user/daily/activity over raw rows, so a date spanning
pages arrived N times with partial metrics and rendered as N thin bars.
Switch to the single-shot aggregated endpoint, thread
include_current_utc_day through it to keep the live-end extension from
PR #36051, and merge the paginated fallback by date.

* fix(ui): keep aggregated call at four params and mock it in view tests

Trailing userId and includeCurrentUtcDay ride a named rest tuple so the
eslint max-params baseline stays at 23, and the CostOptimizationView
suites mock the new networking export their render now reaches.
2026-08-20 11:14:04 -07:00
Mateo Wang
8672cd4df4
Merge pull request #37566 from BerriAI/litellm_cli_refresh_tokens
feat(cli): store the lite login credential in the OS keychain
2026-08-20 11:10:28 -07:00
yuneng-jiang
3357ec8d34
test: run the 30 test files stranded in the second mirror (#37595)
* test: run the 30 test files stranded in the second mirror

tests/litellm sat beside tests/test_litellm, which is the mirror the repo
convention names, and no job collected it. The allowlist called the directory
unresolved and assumed it was a duplicate. It is not: 30 of its 34 files have no
counterpart in the real mirror, so they are tests nobody has run since they were
written, not copies of tests that run elsewhere.

Moving them in is byte-identical, and it is what makes them run. Every one is
now claimed by a shard's test-path rather than by an allowlist entry, and the
216 tests they hold pass. Directories that needed to become packages did, since
several files are named test_transformation.py and pytest cannot import two of
those from non-package directories in one session.

Never running is why three assertions had drifted away from the code:

  * nvidia.nemotron-super-3-120b max_output_tokens, 32000 -> 32768
  * sambanova/MiniMax-M2.7 max_input_tokens, 204800 -> 196608
  * the Vertex text-to-speech handler moved from data= to json=, so the test
    reads the decoded body off the json kwarg instead of parsing the data one

The first two follow model_prices_and_context_window.json, which the catalog
sync keeps current; the third follows the handler. In all three the test was the
stale side.

The lint workflow ran test_no_hardcoded_secrets.py by path and now points at the
new one.

Four files stay behind. Each shares a filename with a live test whose contents
are disjoint from it, so landing those means merging test bodies, which is a
content review rather than a move. The allowlist entry now names those four and
records how many tests each would bring, in place of calling the whole
directory unresolved.

* fix(ci): keep the secret scan out of the mirror's conftest

The secret-scan job runs pytest under uv run --no-project, so its environment
holds pytest and nothing else. That worked while the file sat in tests/litellm,
which has no conftest, and broke the moment it moved into tests/test_litellm,
whose conftest imports litellm on collection: ModuleNotFoundError: No module
named 'dotenv', before a single test ran.

The file is a repo-wide static scan that imports only base64, os, re and pytest,
so it belongs with the other repo-wide checks in tests/code_coverage_tests,
which has no conftest, rather than in the package mirror. Installing the full
dependency set into a 15-second job to satisfy a conftest it does not use would
be the wrong trade.

Verified with the job's exact command:
  uv run --no-project --with 'pytest==9.0.2' pytest \
    tests/code_coverage_tests/test_no_hardcoded_secrets.py -q
  1 passed in 0.47s
2026-08-20 10:59:43 -07:00
mateo-berri
5f7c0e1e49 Merge origin/litellm_internal_staging into litellm_cli_refresh_tokens
Base landed the native CLI OAuth + PKCE login, which added its own token
storage and a silent refresh that wrote the key straight to token.json.
This branch had already moved that secret into the OS keychain, so the two
had to be joined rather than picked between.

auth.py now keeps one pair of record helpers, load_token and save_token,
that read and write through the vault and hand the PKCE layer the plain
mapping it works with. fresh_api_key and revoke_stored_credential get
vault-bound save and reload callables, so a renewed key is stored in the
keychain like any other and a sibling process's rotation is still seen.
login goes through _replace_stored_token on both paths, so the credential
it replaces is revoked on the proxy and the user is still told where the
new one landed. logout revokes first, then reports what the clear actually
managed to do.
2026-08-20 10:54:48 -07:00
Mateo Wang
0904d5836e
Merge pull request #37602 from BerriAI/litellm_vertex_live_db_credentials
fix(passthrough): resolve vertex live credentials from db model deployments
2026-08-20 10:24:50 -07:00
Mateo Wang
5bee64c870
Merge pull request #37604 from BerriAI/litellm_lit5867_realtime_silent_hang
fix(realtime): bound Vertex credential resolution and make realtime failures loud
2026-08-20 10:24:45 -07:00
mateo-berri
64e7ab1beb fix(cli): keep the login record when the proxy cannot record a logout's revocation
A 503 from POST /revoke means the proxy could not write the single-use record, so clearing the local record left a live refresh token nobody could revoke and a hint to retry with nothing left to retry. lite logout now keeps the record, exits 1, and asks to be run again shortly. A refused or unreachable revocation still clears the record and warns as before, and a re-login that replaces a record keeps its existing warning because the new record already stands
2026-08-20 08:16:30 -07:00
mateo-berri
238609ea5d fix(mcp): answer 503 when a refresh-token burn cannot be recorded
revoke_refresh_token discarded the single-use claim result, so a revocation that arrived while Redis was unreachable answered 200 and left the refresh token live. The token endpoint reported the same outage as invalid_grant "already used". The guard now reports first, replayed, or unavailable, and both endpoints answer 503 temporarily_unavailable for an outage (RFC 7009 section 2.2.1, RFC 6749 section 5.2), which the CLI surfaces as a one-line warning while keeping the key it has
2026-08-20 07:56:38 -07:00
mateo-berri
a20cfc5f0e fix(cli): let lite up trust the key the group already renewed
The lite group resolves the stored key once for every command and renews a --pkce key on the way in. lite up then asked the token file again, so every start sent a second refresh to the proxy, and once the refresh token was burned the refusal printed twice. _ensure_fresh_login now reuses the key the group resolved when the group read it from the token file, and only re-reads the file after the interactive login it starts itself. Also covers print-token through the group with a renewing session in the tests
2026-08-20 07:40:17 -07:00
mateo-berri
36e9e813a7 fix(cli): renew the key once per lite auth print-token run
The cli group already resolves the stored key for the server it was pointed
at, so print-token re-ran the renewal and, when the refresh token had been
revoked, posted to /token twice and printed the reason twice. print-token now
reuses the group's result whenever the stored record was issued for that
server and no --api-key or LITELLM_PROXY_API_KEY took precedence, and only
resolves the key itself when invoked bare for a different server.
2026-08-20 07:29:04 -07:00
mateo-berri
44d3fc7ec8 fix(cli): name lite login --pkce and the refresh failure when a key cannot be renewed
A PKCE credential whose renewal is refused (for example after lite logout ran
on another copy of it) used to fail lite auth print-token with the classic
'Token expired. Run lite login again' hint and no reason, while lite whoami
already named lite login --pkce. fresh_api_key now reports why a renewal
failed through a warn callback whenever no sibling rotation rescued it, the
CLI prints that reason on stderr, and the expiry hint names the command that
produced the credential. Both READMEs document the admin revocation semantics
and the Redis precondition for refresh single use on several workers.
2026-08-20 07:21:42 -07:00
mateo-berri
39bdacb3a6 fix(cli): revoke the previous login's refresh token when lite login replaces a stored record 2026-08-20 06:26:57 -07:00
mateo-berri
218a3ec370 fix(cli): never follow a redirect when posting to the proxy's OAuth endpoints
Discovery checks that every endpoint sits on the proxy origin, but the CLI's
requests.Session followed redirects, and requests replays a POST body on
307 and 308, so a token or revocation endpoint answering with one of those
would have sent the code and verifier, or the refresh token, wherever
Location pointed. Every POST now goes out with allow_redirects=False and a
3xx answer fails the command with a message naming where it pointed
2026-08-20 06:03:13 -07:00
mateo-berri
17455f4086 fix(proxy): refuse a teamless native-client grant for a user who has teams
The consent page offers the team picker, but a form posted without a team
sealed a teamless grant and the token endpoint minted an unscoped
credential for a team member, escaping the team attribution classic lite
login always applies. The minter now refuses such a grant on redemption
and refresh alike; memberships whose team rows are gone still count as no
team so they cannot lock a user out
2026-08-20 05:37:35 -07:00
mateo-berri
1f2baf509e test(cli): model keyring's null backend in the vault test double
FakeSecretVault could only stand in for a discarding backend by passing
KeyringDiscardsWrites as its `failure`, which also made read() and erase()
hand it back. Neither SecretRead nor SecretErase admits that outcome and the
real KeyringVault never produces it there, so the login path's match was
falling through on a value it can never see. Give the double a `discards`
flag that reports it from write() alone, which is what the null backend does.

Also widen lint-format-check-changed's pathspec. Git wildmatch runs without
FNM_PATHNAME here, so 'litellm/**/*.py' still requires an intermediate
directory and silently skipped all 21 top-level modules, litellm/__init__.py
and litellm/main.py among them. All 21 already pass ruff format.
2026-08-20 05:28:19 -07:00
mateo-berri
c011b4b56f fix(cli): tell whoami's expired PKCE key to sign in again instead of promising a renewal 2026-08-20 05:22:26 -07:00
mateo-berri
52c94327bd fix(cli): renew a --pkce key wherever lite checks freshness and show its expiry in whoami 2026-08-20 05:17:00 -07:00
mateo-berri
fb123b33de fix(cli): bind the sibling refresh fallback to the same user and team 2026-08-20 04:41:43 -07:00
mateo-berri
912206832c fix(cli): pin PKCE discovery to the typed proxy and ignore foreign sibling records
The discovery document is accepted only when its issuer is the --base-url the user
typed and every endpoint and the resource share that origin (RFC 8414 section 3.3),
so a tampered or redirected document can no longer point the code, verifier, or
refresh token at another host. After a failed refresh the re-read token record is
used only when it continues the same credential (same proxy, token endpoint, and
resource) and has not expired, so a concurrent login against a different proxy can
never hand this one its key
2026-08-20 04:29:01 -07:00
mateo-berri
2c691d3820 feat(proxy): native CLI login with OAuth authorization code + PKCE
The proxy's OAuth authorization server (dynamic registration, PKCE S256,
loopback redirects, single-use codes, refresh rotation) gains a proxy-API
audience: /authorize?resource=<proxy origin> renders a consent page with
team selection and /token mints the same per-user credential lite login
mints, so a native CLI can sign a user in through the system browser and
call /v1/* with user and team attribution. Adds GET /.well-known/litellm-cli-auth
as the versioned discovery contract for non-Python clients, POST /revoke
(RFC 7009) for logout, and lite login --pkce, lite logout, and
lite auth print-token on the CLI side. Proxy-API grants only ever redirect
to a loopback address and the server never picks a team on the user's behalf.

Fixes #37332
2026-08-20 04:10:10 -07:00
Mateo
a2928efc75 test(cli): cover the keyless token record and keep keyring to the cli extra
`lite up` treats a token record whose key the keychain would not hand over as no
login at all, and that clause had no test: every existing freshness test passed a
record carrying a real key, so deleting the clause left the whole suite green

The base install smoke check now also asserts keyring is absent, which is what
makes the lazy import in cli_keyring meaningful. keyring ships in the cli extra
only, so a plain `pip install litellm` must not be able to reach it
2026-08-20 03:45:00 -07:00
mateo-berri
4f04e59ca0 fix: harden vertex live passthrough against client model forms and dict credentials
- accept the Live SDK's models/<id> and LiteLLM's vertex_ai/<id> when rewriting the setup model
- keep a dict service account intact instead of stringifying it
- treat same-target deployments holding different credentials as ambiguous
- guard both websocket states before every close so a second close cannot raise
- build the sendable close codes from the public CloseCode enum
2026-08-20 03:05:48 -07:00
mateo-berri
b142d1d765 fix(cli): stop whoami calling an unreadable credential authenticated
`lite whoami` led with "Authenticated" whenever a token file was on disk, even when the
keychain holding the credential would not give it up. The notice about that sat below the
account lines, so the session read as a working one and sent the user looking for the
problem anywhere but the keychain
2026-08-20 02:59:20 -07:00
mateo-berri
4ca1f3148a fix(cli): finish a refused token file rewrite in place
Taking the secret out of ~/.litellm/token.json stages a replacement and moves it into
place, which needs room for a second file and a directory that will accept a new entry.
A full disk refuses the first and a read-only ~/.litellm the second, and logout gave up
there: it removed the file when it could, dropping the record that the keychain had never
been confirmed clear, so the logout after it reported a clean keychain it never checked

Shortening the file already in place needs neither, so the logout scrub and the legacy
migration now fall back to overwriting it where it lies. On a read-only ~/.litellm the
logout the user asked for now happens, instead of coming back with instructions to delete
the file by hand
2026-08-20 02:58:53 -07:00
mateo-berri
ef104acdaf fix(cli): report a token file logout cannot remove instead of crashing
A ~/.litellm that has gone read-only, or one left root-owned by a sudo login,
refuses both the scrubbed rewrite and the removal. The removal was unguarded,
so 'lite logout' ended in a PermissionError traceback with the credential still
readable in the file. It now comes back as an outcome the command reports,
naming the file and what to do about it, and a file that holds no secret is
still not worth alarming anyone over.
2026-08-20 02:28:48 -07:00
mateo-berri
f86aeba1e7 fix(cli): stop a file-held secret from vouching for an unreadable keychain
A logout run from an install without the keyring package treated a token file
holding its own secret as proof that no keychain entry could exist. That only
holds for the login which wrote the file. A login before it may have had the
package and put its credential in the keychain, where it outlives both the
uninstall and the file that replaced it, so logout reported a clean sweep over
a live credential. Every keychain that cannot be reached is now treated the
same way, and the message says the keychain went unchecked rather than
asserting what is in it.
2026-08-20 02:21:21 -07:00
mateo-berri
d434787a20 fix: refuse to guess a vertex project when live passthrough has no model hint 2026-08-20 02:19:38 -07:00
mateo-berri
b9d977aeee fix: guard vertex live passthrough provider lookup and close-code relay 2026-08-20 02:15:17 -07:00
mateo-berri
b6fef179ff fix(cli): stop a repeat logout from retracting its own keychain warning
A logout that could not reach the keychain deleted the token file whenever it
still held its own secret, and the next logout read that missing file as proof
the keychain was clean. It answered the warning the first run had just issued
with "Logged out successfully" while the entry an earlier login left behind was
still live. The file is the only record that something may still be in there,
which is what `_nothing_left_behind` already says it relies on, so keep it and
take only the secret out.

A keychain that did answer is a different case. `SecretStranded` means the entry
is confirmed there and would not delete, and that needs no note in the file,
while keeping one lets every later command read the credential straight back out
of the keychain, which makes "Logged out locally" untrue. That one drops the
file, as it did before.

The secret still goes first either way: a copy that cannot be replaced with a
secret-free one is removed rather than kept.
2026-08-20 02:08:04 -07:00
Mateo
9271133beb fix(realtime): bound Vertex credential resolution and make realtime failures loud
A /v1/realtime connection to a Vertex AI Live model accepted the WebSocket
upgrade and then went silent: a stalled Google OAuth token fetch blocked the
handler before any session event, and the eventual failure closed the socket
with a bare 1011 and no error event, so callers saw an open socket, no frames,
and no reason.

Bound the pre-session token fetch with
REALTIME_CREDENTIAL_RESOLUTION_TIMEOUT_SECONDS (20s default) and, on any
realtime failure, send an OpenAI-style error event before closing with a reason
that names the failure. Close reasons are truncated by bytes, not characters,
since an over-long reason makes the close frame itself fail.
2026-08-20 02:02:00 -07:00
mateo-berri
021a09b156 fix(passthrough): resolve vertex live credentials from db model deployments
The /vertex_ai/live WebSocket passthrough only ever looked at
default_vertex_config and the DEFAULT_VERTEXAI_* env vars, so a proxy whose
Vertex credentials live in the DB as a model entry with use_in_pass_through
had nothing to authenticate with. The upgrade still succeeded and the socket
then closed with a bare 1000 on the first client frame, which gave the client
no way to tell a misconfiguration from a normal end of session.

Credentials now also resolve from the router deployments flagged
use_in_pass_through, preferring the one matching the requested model, and a
failure to mint an access token closes 1011 with a reason naming both ways to
configure it. Upstream closes other than a plain 1000 are relayed to the client
with their code and reason, so Google's own errors reach the caller. The setup
frame's model is rewritten to the full projects/.../publishers/google/models
resource path, which is what Vertex expects and what lets a bare model id or a
gateway alias work over this route.
2026-08-20 02:00:29 -07:00
mateo-berri
ba637553f8 fix(cli): keep lite login and logout honest when the keychain will not answer
Three ways the credential commands could mislead or hang.

`lite logout` on a machine that never logged in warned that a credential may
be stranded in a keychain it could not check, and told the user to install
keyring to go clear it. There was nothing there. A missing token file is now
read as the evidence it is, because logout keeps a secret-free file behind
whenever the keychain is left unconfirmed, so a later run can tell a machine
with a credential it cannot reach apart from one that never had a login. That
holds on the LITELLM_CLI_DISABLE_KEYRING path too.

`KeyringDiscardsWrites` was handled on the read and erase paths, which cannot
produce it: the null backend returns None from `get_password` rather than
raising, so only a write ever detects it. It now lives on `SecretWrite` alone
and the unreachable arms are gone.

`keyring.set_password` blocks forever under a HOME with no usable login
keychain, which is what containers, CI images, `sudo -H`, and service accounts
run with, and reads answer normally there so nothing cheaper tells them apart.
`lite login` never touched a keychain before this, so a sign-in that simply
never returns would be a new way for it to fail. Writes are pre-flighted with
a throwaway value on a bounded wait, and a keychain that stays silent falls
back to the token file. The real credential is never the thing handed to a
call that might land long after we stopped waiting.

Saving also stages the token file before the keychain is given anything, since
the file is the half a read-only or full directory refuses. A save that cannot
land now leaves both stores as it found them, which matters most when the
login it failed to replace still works.
2026-08-20 01:39:43 -07:00
Mateo Wang
47a7e1742e
Merge pull request #37539 from BerriAI/litellm_batch_enqueued_token_limit
feat(proxy): enqueued-token rate limiting for batches with refund on completion and cancellation
2026-08-19 22:22:05 -07:00
yucheng-berri
8cf0b50125
fix(ptu): hand the prune a plain delete filter the query builder can serialise (#37571)
* fix(ptu): hand the prune a plain delete filter the query builder can serialise

The bounded sweep built its predicate as a read-only mapping view, which the query
builder refuses to serialise, so the nightly job raised as soon as a config-declared
deployment was priced. The charges were already written by then, which is why the run
looked like it had produced its rows.

The in-memory table these tests run against accepts any mapping, so only a live run
caught it. A predicate builder now returns a plain dict and is asserted as one, and the
catch-up pass has a test covering a config-declared reservation.

* refactor(ptu): build the prune predicate in one shot

Both filter shapes are known upfront, so the bounded one is constructed
directly rather than by mutating a value already declared Final.

The catch-up test took two independent clock reads, which disagree across
UTC midnight; it now derives both the reservation start and the expected
last charged day from a single read, matching the three sibling tests.
2026-08-19 22:18:58 -07:00
mateo-berri
3c73a39877 fix(cli): verify every credential store transition before reporting it done
A keyring backend can accept a write and keep nothing. That is exactly what
`keyring --disable` and PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring
select, and it raises nothing to distinguish itself, so `lite login` was
handing the credential to a black hole, scrubbing its own copy from
token.json, and printing a success message over a login that no longer
worked. Reading the value back is the only way to tell that backend apart
from a keychain that really stored the secret.

The same rule closes the rest of the gaps. A credential the token file will
not record is taken back out of the keychain instead of being left live on a
machine with no record of it, and is reported rather than raised. The
migration stages its scrubbed file before the keychain is handed anything,
so a directory that will not accept the rewrite stops the move rather than
leaving the secret in two places. Logout no longer reads a key in the file
as proof that the keychain is clear, which was never sound across two
separate runs, and only draws that conclusion when the `keyring` package is
missing outright, where nothing could have reached a keychain at all.
2026-08-19 21:10:59 -07:00
mateo-berri
1750893a69 fix(cli): never report success while a credential is still readable
Migration moved the secret into the keychain and then suppressed any OSError from
rewriting token.json, so a file that could not be rewritten kept the credential in
cleartext while every command reported success. That file is now removed instead:
signing in again costs one command, a stranded live credential costs the credential

`lite logout` also reported a clean logout whenever the keyring package was missing,
on the reasoning that an install without it could never have stored anything. The
entry belongs to the OS, so a keychain-backed login survives a logout run from a venv
without the cli extra. erase() now reports which keychain state applies, and logout
warns with the advice that fixes each one, staying quiet for file-backed logins whose
token file still carries its own secret

Also pins the migration path's tightening of a world-readable legacy token.json, and
moves the logout tests off patch() onto the injected vault
2026-08-19 20:26:41 -07:00
mateo-berri
65d834f01d Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_cli_refresh_tokens
# Conflicts:
#	basedpyright-code-budget.json
2026-08-19 19:50:26 -07:00
mateo-berri
01add58298 fix(cli): name why a login fell back to the token file
lite ships with every install of litellm, but the keyring package it needs
for keychain storage only ships with the cli extra. Such a user on a Mac was
told 'No OS keychain available' about a machine that plainly has one, with
nothing pointing at the missing package.

The vault now reports which of the three unusable states it is in, so login
can point at the install, name the kill switch, or report a genuinely absent
keychain.
2026-08-19 19:45:38 -07:00
mateo-berri
d5ac49588a Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_batch_enqueued_token_limit 2026-08-19 19:31:46 -07:00
hiraku-miyoshi
2a771caf02 fix(proxy): clamp reservation record TTL so stale records never outlive their counters 2026-08-19 19:12:22 -07:00
yucheng-berri
c2b3c4b1e4
feat(ptu): accrue flat cost for PTU deployments declared in config.yaml (#37556)
* feat(ptu): accrue flat cost for PTU deployments declared in config.yaml

The flat-cost rollup reads deployments from LiteLLM_ProxyModelTable, and config.yaml
models never reach that table by design, so a PTU deployment declared there accrued no
flat cost at all while still billing its traffic per token. The provider bills the
reservation whichever file declared it.

The rollup now also reads the deployments the router holds that no database row owns,
identified by db_model, skipping the per-request credential clones that carry
original_model_id and reuse their source's PTU config under a fresh id. Registering such
a deployment zeroes its pricing, since reserved capacity already pays for the traffic it
serves, and leaving a rate unset falls back to the public cost map, which makes the double
charge the default rather than an opt-in.

The rules both halves apply now live in one module. The rollup's test for what it will
charge and the router's test for what to zero have to agree, or a deployment one accepts
and the other declines serves its traffic for free. That module also owns the fields the
write endpoints already zero, so the two paths cannot drift: tiered_pricing is emptied
rather than zeroed because its tiers outrank the rates beside them, the search context
table is written zeroed because an absent one means the provider default, and any further
rate the deployment itself declares is zeroed alongside the standing set.

The prune is bounded to the deployments a run scanned, but only for a run that priced a
config-declared deployment. Deciding a row is garbage on staleness alone stays correct
while every run derives its charges from the same table, so a database-only run sweeps
exactly as it did before; once one host's charges come from a file the others cannot read,
a row it never considered is not evidence of anything.

Behaviour change worth calling out: a zeroed deployment sorts ahead of an unpriced sibling
in QualityRouter's cost tiebreak, where an unset rate previously sorted last. Reserved
capacity really is the cheaper choice, but the ordering moves.

* refactor(ptu): drop a Final rebind and two redundant isinstance guards

The basedpyright budget rejected reassigning a Final in the datetime coercion and
two isinstance calls the router entry's own type already guarantees. Filtering the
built records rather than the raw entries removes both guards and leaves
_router_deployment as the single validator.
2026-08-19 19:08:14 -07:00
mateo-berri
0de829d3e4 feat(cli): store the lite login credential in the OS keychain
lite login used to write the minted cli-session key in cleartext to
~/.litellm/token.json. The secret material (key plus any JWT) now goes
to the OS keychain through the optional keyring package, with the 0600
file kept for non-secret metadata and as the fallback on headless boxes.
Legacy plaintext files keep authenticating and are migrated into the
keychain, then scrubbed, on first read. A secret still on disk always
outranks the keychain entry, so a failed keychain write can never
resurrect a stale key. LITELLM_PROXY_API_KEY and --api-key precedence
is unchanged, lite logout clears both stores and warns when the
keychain will not release the entry, and ~/.litellm is created 0700
(tightened from 0755 where an older CLI left it broader).
LITELLM_CLI_DISABLE_KEYRING=1 forces the file fallback.
2026-08-19 18:57:35 -07:00