AKS workload identity injects AZURE_CLIENT_ID, AZURE_TENANT_ID, and
AZURE_FEDERATED_TOKEN_FILE into the pod, and never a client secret.
Reading that bare client id as a managed identity sent the pod to IMDS,
which has no identity attached to it, so the token request failed and the
federated token was never exchanged.
AZURE_FEDERATED_TOKEN_FILE now wins over the bare client id and infers
DefaultAzureCredential, whose chain reaches WorkloadIdentityCredential
before ManagedIdentityCredential. DefaultAzureCredential passes
AZURE_CLIENT_ID to both legs, so a plain user-assigned managed identity
still reaches the same identity it does today.
This is the credential path Azure recommends for passwordless Postgres on
AKS, and it also fixes the Azure OpenAI token provider, which infers its
credential the same way.
ruff.toml excludes tests/* from `ruff check`, so nothing has ever checked the
test tree for names that do not exist. That matters more in tests than in
product code: a NameError inside a test whose body is wrapped in
`except Exception: pass` is swallowed, and the test reports green forever.
Adds ruff-tests.toml selecting F821 alone, wired into the lint workflow and
`make lint-ruff`, and clears every existing violation:
- 4 tests interpolated an unbound `e` into a `pytest.fail` message reached only
on the failure path, so the NameError, not the assertion, is what ran.
test_llm_guard_error_raising is the worst: it passes today with content
safety disabled entirely. It now asserts the 400 and its detail body.
- 5 sites construct BaseExceptionGroup, a 3.11 builtin, in a tree that still
supports 3.10. Guarded behind the exceptiongroup backport that anyio already
pulls in below 3.11.
- 9 missing imports (json, openai, Any, Final, HTTPException), including one in
a helper that catches HTTPException by a name it never imported, so the
challenge path it exists to detect raises NameError instead.
- 5 annotations naming types imported inside the function body, hoisted to
module scope or TYPE_CHECKING.
- 2 blocks of dead code: everything after a pytest.fail in
test_claude_agent_sdk, and an unused helper in test_end_users calling a
function defined in a different module.
- 1 error-path f-string in the router-settings doc test that masked the real
FileNotFoundError behind a NameError.
Only F821 for now. Widening the select list means ratcheting thousands of
pre-existing findings, so rules go in one at a time with their violations
already fixed.
The Experimental badge sat in the top bar next to the icon, which read as if the
whole theme control were experimental and cost toolbar width for a caveat that
only applies once. It moves into the menu as a Beta tag on the Dark entry, where
it labels exactly the choice it is about and is visible before the choice is made
rather than only after.
* feat(proxy): redact or drop individual batch records instead of rejecting the file
A single record tripping a guardrail rejected the whole upload, which is unusable for a file
holding thousands of rows. A record a guardrail rewrites is now submitted in its rewritten
form, a record it blocks is left out, and the create response reports every changed record by
both custom_id and line so a caller can reconcile against the file it sent. The same outcome
is written to the proxy log and to request metadata, so it is not visible only to the caller.
A rewritten record goes straight to a spool and only its offset is carried, so a masking
guardrail touching most rows of a large upload does not build a second copy of the file on the
heap, and the rewrite runs off the event loop the way the sibling full-file validation does.
Both proxy-injected metadata keys are captured from the record and restored exactly, including
an explicit null, so a masked row keeps the tags that decide how it is attributed.
A record is dropped only when a guardrail judged its content. `GuardrailRaisedException` now
carries `blocked_content` for that, because half its raise sites in the repo signal an
unreachable or unparseable backend under a fail-closed policy, and treating those as blocks
would turn "refuse this request" into "drop this record and submit the rest". The default is
off, so a raise that does not say what it means aborts the upload instead of silently
shrinking the file.
* fix(proxy): only drop a batch record on a verdict the guardrail actually reached
A guardrail that reports a technical failure as an HTTPException carrying a block status was
read as a content block, so an unreachable backend under a fail-closed policy quietly shrank the
file instead of failing the upload. Two in-tree integrations do exactly that, and one of them
defaults to fail-closed, so the broken configuration was the default one. Such an exception is
raised `from` the underlying error, which is a deliberate statement that something else caused
it, and no content verdict in the repo is raised that way, so the chain now settles it. Implicit
context is left alone, since a block raised inside an unrelated `except` would read as a failure.
Two annotation errors in the same family: the one GuardrailRaisedException subclass in tree never
opted into blocked_content, so a real block took the whole upload down with it, and straiker's
block helper is reached both from its verdict and from its fail-closed handler, so it claimed a
verdict for an outage. The helper now takes the flag from its caller.
A record could also opt itself out of the chain. Guardrail selection reads a body-level
`guardrails` key ahead of the proxy-injected list, and online that key can only add to the key
and team selection, never replace it, so a batch record naming an empty list skipped every
guardrail that was not default_on and was still reported as scanned. Every injected key is now
stripped before dispatch and restored afterwards.
A guardrail that reroutes a record to another model is honoured on the online path by rewriting
the model, which the scan read as a rewrite and submitted in the same file, sending content to
the provider the reroute existed to avoid. Every record of a batch file goes to one provider, so
the upload is refused instead, naming the line.
The scan spool is closed on the paths that never read it back.
* fix(proxy): give the scan the metadata bag guardrails actually read, and close its spools
The narrowed request metadata was installed under `litellm_metadata` only, but a record is
scanned as the chat request it describes, and the guardrails that pick a policy from a request
header read `metadata` instead. Noma choosing an application and Aim choosing a user both look
there, so the header allowlist added for them did not reach either one and a batch record was
still evaluated under the fallback policy. The scan metadata now goes into both bags, which are
both stripped and restored, so neither survives into the record that ships.
The scan spool was closed on the paths that abort, which are exactly the paths where it is
empty, and left open on the one path where it holds the rewritten records. Nothing closed the
rewrite output either, where before this feature the uploaded handle belonged to Starlette. The
upload now owns both and closes them however it exits.
* fix(proxy): register the scan spool before the rewrite can fail
The scan spool was added to the request's cleanup list only after the rewrite returned, so a
rewrite that raised, which for a spilled file can be as ordinary as the disk filling up, jumped
to the handler with the list still empty and left the scan's own handle open. The rewrite also
left its half-written output behind on that path, since nothing owns that handle until it is
returned. Both now close.
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>
* feat(ui): add a light/dark/system theme toggle
The dashboard already carried a full `.dark` palette, dark-aware surfaces and a
dark logo variant, but nothing ever put the `dark` class on the document, so
none of it could be reached. next-themes now owns that class: it reads the
stored choice, falls back to the OS preference, and stamps the class from an
inline script before first paint so there is no light flash on load.
The toggle is a three-way System / Light / Dark control in the account menu,
in both the sidebar menu and the older navbar dropdown, so it is reachable from
the gateway dashboard, chat and the model hub alike.
useIsDarkMode watched the root element with a MutationObserver purely to answer
a question next-themes now answers directly, so it goes, and useSyntaxTheme
reads resolvedTheme instead. The toaster follows the resolved theme too.
* feat(ui): move the theme control to the top bar and default to light
The toggle now lives in the header toolbar of both shells, the gateway
dashboard's DashboardHeader and the older full-width Navbar, where it replaces
the placeholder comment that had been holding its spot. It reads better there
as a single icon button with a System / Light / Dark menu than as a segmented
row buried in the account popover, so the account menus lose their theme row.
Dark mode is still being rolled out, so an install that has never touched the
control now stays light instead of following the OS. System is still a choice,
just no longer the default. While dark is active the toolbar carries a small
Experimental badge, so nobody mistakes an unstyled surface for a bug.
* fix(ui): serve the dark logo in the legacy navbar too
The sidebar already paired its logo with a dark variant, but the full-width
navbar kept a single light-only image. That did not matter while dark mode was
unreachable; now that the toggle sits in that shell's own top bar, the white
JPEG slab lands on a dark bar. It gets the same two-image swap the sidebar uses,
and a test that pins the pairing so the two shells cannot drift apart again.
* 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>
* 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.
* 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
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>
* test: replace blind sleeps with deadline waits in callback and caching tests
tests/local_testing/test_custom_callback_input.py slept a fixed 1-3s after
every call and then asserted the callback handler recorded no errors. Because
the handler only appends to `states` when a callback actually fires, an assert
of `len(errors) == 0` passes just as happily when nothing fired at all, so the
sleep was buying flakiness in exchange for a vacuous check. The async tests
were worse: `time.sleep` blocks the event loop, so the success/failure tasks
scheduled on it could not run before the assertion.
Adds tests/_wait_helpers.py with `wait_until` / `await_until`, which poll a
predicate against a deadline, and converts all 17 sites to wait on the thing
the test actually cares about (the terminal state landing in `states`, or the
patched log hook being called). The waits assert the callback fired, so these
tests now fail on a dropped callback instead of passing silently.
The three sleeps in test_caching_handler.py sat between `sync_set_cache` and
`_sync_get_cache`, both fully synchronous against a local in-memory cache, so
they are just deleted.
* fix(test): wait on the priming call's own logging in the cache-hit test
The 3s sleep in test_logging_async_cache_hit_sync_call was not waiting for the
cache write, which lands before the stream iterator is exhausted. It was
waiting for the priming call's success callback to drain, so the handler
installed right after it only ever sees the second, cache-hit call. Waiting on
a populated cache_dict let the priming call's still-pending log_success_event
reach the new mock, and the test then read cache_hit off the wrong payload.
Waits on the priming handler's own sync_success state instead.
`lite login --pkce` mints a refresh token that buys a fresh key from the
proxy on demand, so it is the credential just as much as the key is. Moving
the key into the keychain left it behind in ~/.litellm/token.json, where any
process running as the user can read it and renew the login for itself.
It now travels with the key: `save_cli_token` writes both into the keychain
entry, the token file keeps only metadata, and `lite logout` takes it out of
the file whether or not the keychain answers.
Upgrading finds one sign-in split across the two stores, the key already in
the keychain and the refresh token still on disk. That case rejoins the two
halves into a single entry before scrubbing the file, so the write never
replaces a live key with nothing, and a machine that refuses the scrub keeps
what it has rather than having the key rolled back out from under it.
* 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
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.
* feat(complexity_router): add business classification rubric preset
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore(ui): regenerate api schema for business rubric
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore(ui): suppress preexisting antd import violations in touched files
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: tin <tin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* 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.
* fix(ui): make hardcoded palette surfaces theme-aware
Twenty-one dashboard files painted fills from the raw Tailwind palette with no
dark counterpart, so in dark mode they rendered as near-white islands carrying
dark text: unreadable. The route sweep caught them on teams, access-groups,
policies, users, skills, guardrails-monitor, logs, compliance, playground,
fallbacks and the AI hub.
Where the hue already had a semantic token, the surface moves onto it. Every one
of these lines had a token on its border and a palette class on its fill, so
this finishes a migration that had stalled halfway: bg-blue-50 next to
border-info/20 becomes bg-info, bg-gray-50 becomes bg-muted, DocLink's bg-white
becomes bg-card, and the Alert error variant drops text-red-800 and text-red-600
for the destructive token its sibling variants already use.
Purple, violet and indigo have no token in the system, which is exactly why the
maps in PluginTableColumns, GuardrailsOverview, AccessGroupsTableColumns and
teamTableColumns had migrated every other entry and left those behind. Rather
than mint a brand token here, they take the dark palette step, matching what
TeamGuardrailsTab, add_agent_form, MCPToolsetsTab and mcp_connect already do.
Gradient stops get the same treatment since bg-linear stops have no token form.
Light mode is unchanged apart from the four surfaces that moved onto a token,
and those stay inside the same colour family.
The #1e1e1e code slabs in guardrail_info and CustomCodeModal are deliberately
left alone: they are intentionally dark editors in both themes, and their
gray-200 text stays legible either way.
* fix(ui): give dark surfaces a readable foreground step
The dark fills added for the purple and indigo surfaces left three nested
foregrounds on their original light-palette step, so the text and icon sitting
on those new fills dropped below readable contrast in dark mode.
text-purple-800 on purple-950 measured 1.72:1, text-indigo-600 on indigo-950
2.54:1, and text-purple-600 on the blue-950 gradient stop 2.73:1. Each now
takes the purple-300 / indigo-300 step this PR already uses elsewhere, which
lands them at 8.48:1, 8.02:1 and 8.31:1.
The pricing calculator renders the same cost expression twice, so both copies
move together rather than leaving one half-migrated.
* fix(ui): keep the guardrail chip remove button visible on hover
The chip itself moved to the dark indigo fill, but its remove button still
darkened to indigo-900 on hover, which against indigo-950 measures 1.40:1 and
makes the X vanish under the cursor in dark mode.
Dark mode now brightens to indigo-100 on hover instead, mirroring the light
theme where hover darkens away from the resting colour.
* 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
* feat(ci): catch files a -k expression deselects from every job
The coverage census asks whether some job names a file. It cannot ask what that
job's -k then does with it, and the gap is not hypothetical: tests/local_testing
is globbed by five jobs, two of which carry
-k "... and not router and not assistants and not langfuse and not caching and not cache"
while the other three keep one keyword each. Any file whose path holds an
excluded term is dropped by the first two and matched by none of the rest, so it
runs nowhere while the census counts it as covered. 118 tests across eight
caching files sit in exactly that hole today.
The new mode reads the same CircleCI jobs the census already parses and asks
whether each globbed file survives its job's selector. Two facts about -k make
that decidable without running pytest: it matches an item's own name and its
parents', so a term appearing in the module path deselects the whole file; and
the names it can match are otherwise the classes and functions in the file,
which ast reads. A positive term is therefore satisfied by the path or by a name
inside, which is what keeps a langfuse-named test inside test_logging.py from
being reported.
Where the parser is unsure it stays quiet. An expression with or, parentheses,
or a negated group is left unmodelled and its job is treated as claiming
everything it globs, so an unparsed selector can never raise a false alarm.
Glob translation learned character classes, without which
tests/local_testing/**/test_[a-mA-M]*.py matches nothing and the guard would
report that whole directory. The census and shard counts are unchanged by it,
2423 files and 327 shard children before and after.
Validated against the real thing: collecting tests/local_testing under each
job's own selector leaves 175 of 1577 tests unselected, in exactly the ten files
this check derives statically, no more and no fewer. Two of the ten are named
outright by other jobs, which the check credits, leaving the eight now recorded
in the allowlist as a decision rather than an accident.
Verified red-first: dropping one of those eight from the allowlist reports it,
and adding 'and not embedding' to the two part jobs reports test_embedding.py
and test_get_optional_params_embeddings.py.
* fix(ci): keep the slice guard from pairing one command's -k with another's glob
Two accuracy notes from review, both about the parser's model rather than its
current verdicts.
A job that runs several pytest commands offers no way to tell which glob a -k
belongs to, since both are read out of the same flattened job text. Combining
them could pair one command's exclusion with another command's glob and report a
file that in fact runs. Such a job is now left unmodelled, which means it claims
everything it globs, matching how the parser already treats an expression it
cannot read. Only one job in the config has two globs today and it carries no
-k at all, so no verdict changes.
The second is a deliberate limit, now stated where it lives: an excluded term is
only honoured when it sits in the module path, because that is the case that
takes the whole file with it. A term matching one function inside drops that
test and leaves the file running, and reporting it would be a false alarm.
Answering per-test instead would need a baseline of test ids that churns on
every rename, for a smaller failure than a file going dark.
Both are pinned by tests.
* feat(ci): ratchet tests that skip themselves when a credential is absent
* docs(ci): name the new rule where the gate's rules are listed
* fix(ci): require the condition to test for absence before TQ006 fires
* test: settle three allowlist entries that were open questions
The allowlist is meant to hold decisions, not deferrals, so an entry reading
'needs moving' or 'referenced by no job' is a gap wearing an exemption. These
three each get an answer.
The two prompt-factory tests move into the mirror, which is what their own entry
said they needed. Both were passing the whole time, so the 23 tests they hold
start running and the entry goes away rather than getting reworded.
test_aio_http_image_conversion.py is not a test. It fetches live image URLs,
times aiohttp against httpx, prints the ratio, and asserts nothing, and pytest
cannot collect it because its functions take arguments rather than fixtures.
Running it beside its siblings would buy CI a network dependency and a number
nothing reads, so it stays exempt with that written down.
test_litellm_proxy_extras_utils.py stays exempt with a measured reason. 24 of
its 28 tests pass; the 4 in TestMigrationSQLIdempotency fail because nine
migrations from 2026-04 onward use bare CREATE TABLE, ADD COLUMN and CREATE
INDEX where that file requires guarded forms. The convention eroded quietly
precisely because the test enforcing it has never run. Wiring it up is blocked
on what to do about those migrations, and editing them is not the answer, since
Prisma checksums an applied migration and a changed one breaks migrate deploy
for existing installs.
Allowlist entries 10 -> 9, paths 88 -> 86.
* docs(ci): correct the migration count in the proxy-extras allowlist reason
* fix(ui): move the policy flow builder onto theme tokens
The flow builder carried its own private palette: 126 raw literals across a
1644-line file, hardcoded into React inline style objects and SVG presentation
attributes. Inline styles beat every class, so the whole page, its version
sidebar, its step cards and its test panel stayed light no matter what the
theme said.
Each literal now resolves through the token it was already imitating. The greys
map onto card, muted, border, muted-foreground and foreground; the indigo and
blue accents onto info; the pass, fail and API-failure accents onto success,
destructive and warning; and the pale status washes become a color-mix of the
same token so they track it in both themes. Six icons carried their colour as
an SVG presentation attribute, where custom properties do not substitute, so
those switch to currentColor with the token set alongside.
Light mode is not byte-identical, and that is the point: the file stops keeping
a second palette. Of the mappings, card, muted and border land on the exact same
rgb they had, covering most of the file. The rest snap to the dashboard's
canonical shade, which mostly means slightly darker text and deeper status
colours: the gray-400 labels pick up real contrast, the soft red on the fail
icon becomes the destructive red every other failure indicator uses, and the
indigo accent becomes the blue that info resolves to.
Verified in a browser on both themes. In dark mode nothing on the page paints a
light background any more; the six that still do are shadcn's inverted primary
buttons and badges, which are meant to.
* fix(ui): token the flow builder test textarea fill
The quick-chat textarea is the one bare form control left in the file, so the
@tailwindcss/forms base layer still paints it `background-color: #fff`. The
inline style overrode the plugin's border but not its fill, which left a white
box inside the now-dark test panel, and its text inherits the near-white
foreground, so the typed message was invisible in dark mode.
Pin both halves of the pair on the element the plugin styles: the card token it
sits on, and the foreground token it was already inheriting.
* fix(ui): make inline styles and code blocks follow the theme
Two families of colour that a stylesheet never gets to see, so dark mode could
not reach them.
The log details drawer paints most of its chrome through React inline style
objects holding raw hex: #f0f0f0 borders, #fafafa panels, #262626 body text,
the antd-era role accents on message cards, and a green/red guardrail summary
pill. Inline styles win over any class, so the drawer stayed light on a dark
page. Every one of those literals becomes the var(--color-*) it was already
imitating, which costs nothing in light mode and now tracks the theme. The
guardrail pill keeps its layout inline and moves its three colours onto the
success and destructive tokens the rest of the dashboard uses.
The eleven code blocks pass a prism stylesheet as a prop, so the theme has to be
picked in JavaScript. There is no dark-mode toggle in the app yet, only the
`dark` class the design system keys off, so useIsDarkMode subscribes to that
class through useSyncExternalStore and useSyntaxTheme swaps in oneDark when it
is set. Each call site keeps the light stylesheet it already had, including the
two that were relying on the prism default and now name it, so light mode is
unchanged everywhere.
Six of those call sites were casting the stylesheet to `any` or re-declaring its
type to get past the prop signature; the hook returns the right type, so the
casts are gone.
* fix(ui): let the markdown code renderer keep its own syntax theme
The three ReactMarkdown code renderers spread the remaining code element
props after style, so the incoming style attribute widened the prop type
and next build's type check rejected the hook's return value. The old
`coy as any` cast hid the same conflict. Spreading first lets the
explicit props win, which is what every one of these call sites meant.
* test(ui): cover the dark-mode hooks that pick a syntax stylesheet
useIsDarkMode carries the only real logic in this change: an external
store over the root element's class list. Cover the three things that can
regress, the class already being present at mount, the class being
toggled later, and the observer being disconnected on unmount, then cover
useSyntaxTheme handing back the caller's own stylesheet in light mode and
oneDark in dark. The assertions are on which stylesheet object comes
back, by identity, not on any colour it holds.
* refactor(ui): drop the last stylesheet cast in the chat code renderer
This was the one markdown code renderer still spreading the code element
props over its style, so an incoming style attribute would have won over
the theme, and the cast on the spread was what kept that compiling.
Spreading first lets the theme win and the cast go.
* fix(ui): give status colours a readable foreground and drop the muted 70% step
The four status tokens are lightened for dark mode, which is correct when they are used as text
and wrong for the 27 places that use them as a background under `text-white`. Every one of those
passes in light and fails in dark: success 1.78:1, warning 1.72:1, info 2.64:1, destructive
2.89:1. The cause is not 27 authoring mistakes, it is that no `--success-foreground` and no
sibling ever existed, so `text-white` was the only thing available to write. Adding the four
companions and registering them in `@theme` makes the correct pairing expressible, and the call
sites then read `text-success-foreground` instead of a hardcoded colour. Dark lands at 9.98, 10.31,
6.72 and 6.15.
Light is deliberately pure white rather than the near-white the other `-foreground` tokens use, so
the four ratios stay at exactly the 4.95, 5.03, 5.25 and 4.77 they are today instead of drifting
down to 4.73, 4.81, 5.02 and 4.56.
Separately `text-muted-foreground/70` measures 2.75:1 on a light page and 4.31:1 on a dark one,
so the same 183 occurrences fail AA in light and sit under it in dark. Dropping the opacity step
takes them to 4.84:1 and 7.34:1. The identical step on the placeholder base rule goes with them,
which is what put every input's placeholder at 2.75:1 in light.
Residual, not addressed here: `text-muted-foreground` over `bg-muted` reaches 4.39:1 in light,
still short of 4.5. Closing that needs `--muted-foreground` itself to move, which changes every
secondary label in the product and is a design call rather than a defect fix.
* fix(ui): finish the status-foreground swap and repoint no-op muted hovers
Four sites still forced text-white on a status fill because the class sat on
a child element rather than on the filled container, so the earlier sweep did
not reach them. The compliance quick-test bubble was worse: it paired bg-info
with text-success-foreground and its paragraph kept text-white on top, so the
dark-theme contrast the PR set out to fix was still reachable there
Dropping the /70 step also turned 21 existing "text-muted-foreground/70
hover:text-muted-foreground" pairs into hovers that change nothing, which
local/no-noop-hover-variant flags as an error. The affordance was "brighten on
hover", so these now hover to text-foreground, matching the 74 places that
already spell it that way
The remaining churn is prettier reflowing the handful of lines whose length
changed, since the token names are longer than text-white
* fix(ui): let the approve/reject confirm button pick the token its fill uses
Both submission review dialogs put text-success-foreground on the shared
button class while the fill below it swings between bg-success for Approve and
bg-destructive for Reject, so Reject drew a success token over a destructive
fill. The two tokens resolve to the same value today, so nothing looks wrong,
but the pairing only holds by coincidence and would break the moment either
token moves. Moving the token into the branch makes it track the fill
* fix(ui): drop the last 70% placeholders, still live on the legacy utility
Four inputs spell their placeholder colour with Tailwind's older
placeholder-<colour> utility rather than placeholder:text-<colour>, so the
sweep that dropped the 70% step passed over them. Tailwind 4.3 still emits
that utility, and utilities sit after base in the layer order, so those four
kept overriding the new input::placeholder rule and kept rendering at 70% in
dark mode, which is the contrast failure this PR set out to close
They now spell it the same way as the three placeholders the PR already
converted, which both removes the step and settles on one spelling
* fix(ui): make dark-mode form controls visible
Two dark-mode defects left form controls without any visual boundary or fill.
`--input` and `--border` share one value in `.dark`, oklch(0.309), which resolves to
rgb(48,48,48). Against `--background` (33) that is a 15-step stroke, and against `--popover` (42)
it collapses to 6 steps out of 255, so a control inside any dialog is effectively undrawn. The
controls also use `bg-transparent`, so there is no fill cue either and only the placeholder text
renders. Measured 1.09:1 against the dialog surface where WCAG 1.4.11 asks for 3.0:1 on the
boundary of a user interface component. Splitting `--input` off at oklch(0.56) restores 3.07:1
without touching `--border`, which stays where it is because it draws decorative separators rather
than control boundaries. 91 controls across 19 routes were measured at the collapsed value, every
one with an identical stroke and surface, so a single token covers all of them.
Separately, `@tailwindcss/forms` paints a white fill on every bare control. The block above
already neutralises that for `combobox-chip-input`, but its audit covered `components/ui` only,
and hand-rolled controls elsewhere still render white on a dark page: typed text lands at 1.11:1
and native selects at 2.19:1 on `/model-hub-table`, `/playground`, `/guardrails`, `/mcp-servers`
and `/models-and-endpoints`. Tracking `--background` fixes those at 14.51:1 and 7.34:1.
Light mode is unchanged by both. The token edit is scoped to `.dark`, and `--background` in
`:root` is the same white the plugin was already painting, verified control-by-control on a dev
server: backgrounds stay rgb(255,255,255) and ratios stay 20.13:1 and 4.84:1.
* fix(ui): keep the combobox chip input transparent under the bare-control fill
The new base rule matched at (0,2,1) while the combobox chip-input override
sits at (0,1,0), so ComboboxChipsInput lost its transparent background and
painted an opaque page-colored rectangle inside the chips container, which
carries its own bg-transparent / dark:bg-input/30 fill.
Folding the exclusions into one :not() list adds the chip input and drops the
selector to (0,1,1). Every @tailwindcss/forms base selector is wrapped in
:where(), so it lands at (0,0,1); (0,1,1) still outweighs it and bare inputs,
textareas and selects keep the fill this PR gives them.
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.
Mistral's live /v1/models reports max_context_length 1048576 and capabilities.reasoning
true for zai-glm-5-2, and its docs price cached input at $0.14/M. Without
cache_read_input_token_cost LiteLLM billed every cached prompt token at $0, so a repeat
request against a 21k-token cached prefix logged $0.0000135 instead of its real cost.
Mistral also serves the model under the short glm-5-2 name, which had no cost map entry
at all and therefore no pricing, so add it alongside.
Stacked on the multi-key shadow eval backend. The key picker becomes a
paginated multi-select with chips, built on the base-ui combobox chips
primitives, with the pagination and debounced-search logic extracted into a
shared usePaginatedCombobox hook that PaginatedSearchSelect now also uses.
The detail view gains a per key table showing each key's own status, judged
turns against its budget, and win rates from the by_key slice, and the job
headline pluralises to "N keys" for multi-key jobs
tests/proxy_unit_tests had a 30-line YAML parser inlined in its workflow that
failed the run when a test file there belonged to no shard. tests/test_litellm
is sharded the same way, with no catch-all bucket, and had no such guard: a new
directory under it (or under its proxy subtree) is collected by nothing and runs
nowhere, and the coverage census cannot see it because a token like
tests/test_litellm/test_*.py already answers 'yes, that tree runs'.
The two questions differ. The census asks whether a file runs at all, so an
ancestor path standing in for everything beneath it is a fine answer. Shard
assignment asks which shard owns a child, and there that same ancestor path is
precisely the bug. _token_covers keeps the first meaning; _token_names adds the
second, and the guard now walks a list of sharded trees rather than one hardcoded
directory. Both read the same test-path keys, so there is one workflow parser.
A directory needs a shard when it holds a test file, not when it is named test_*.
That drops the hardcoded test_configs exception and keeps fixture directories
like expected_fine_tuning_api out on their own merits.
The job keeps its name and its workflow, since assert-shard-coverage is a
required status check on litellm_internal_staging.
Verified red-first: a planted directory under tests/test_litellm, a planted
directory under tests/test_litellm/proxy, and a planted file under
tests/proxy_unit_tests each fail the guard, while a fixture-only directory does
not. 327 children across the three trees are assigned today.
Python binds a name once per scope, so when a module or class defines the same
test twice only the last one exists. The earlier definitions are unreachable:
pytest never collects them, and nothing that references them can fail.
A sweep in August cleared nine of these. Five have appeared since, which is the
argument for a rule rather than another sweep.
Each survivor is the better version, so nothing is lost. The two SQS logger
twins additionally stub `asyncio.create_task`, which the shadowed copies did
not. The cost-calculator duplicate is a two-line stub that also takes a
`model_item` parameter no fixture supplies, so it could not have run even
unshadowed. The two `test_prompt_caching` bodies are both `pass`.
Collecting the four files reports 416 tests before and after.
`tests/proxy_unit_tests/conftest copy.py` goes with them. pytest only loads a
file named exactly `conftest.py`, nothing imports this one, and the space in the
name says what it was.
* feat(ci): ratchet the test suite's zero-assert, mock-echo and global-state debt
The suite's dominant failure mode is tests that cannot fail for the reason anyone
would want them to. The testing-strategy audit measured five shapes of it, and
nothing mechanical stops any of them from reproducing, so they keep reproducing.
`scripts/check_test_quality.py` is an AST checker for those five, emitting the
same `path:line: CODE message` contract as `scripts/check_type_discipline.py`:
TQ001 a collectible test with no assertion of any kind
TQ002 mock-echo, where every assertion only inspects the mock that was patched
TQ003 sys.path.insert inside the test tree
TQ004 raw `os.environ[...] =`, which leaks into whatever runs next
TQ005 `litellm.<attr> =`, the process-wide leak the 491-line conftest undoes
`scripts/test_quality_gate.py` caps each rule against test-quality-budget.json,
seeded at exactly today's count, and fails only when a rule is both over its
limit and higher than the base being merged into, so a change is blamed for what
it adds and never for drift already in the base. `--update` lowers a limit by
what a branch cleared, so the ceilings only ever fall. It runs in the existing
required lint job, which means it enforces without a ruleset change.
TQ001 follows assertions into helpers defined in the same module, transitively.
Without that it flagged 111 tests in tests/e2e, the harness this program holds up
as the reference, because that suite factors its assertions into shared helpers
(`assert_auth_denied(result, ...)`). Following them leaves 25, all of which reach
their assertions across a module boundary; those are grandfathered and documented
rather than papered over.
The seeded counts land within about 10% of the audit's independent numbers for
every rule measured on the same subtree, which is the cross-check that the
definitions here match the ones the audit pinned.
* fix(ci): resolve test helpers per scope, not by bare name
The helper walk keyed every function in a module by its bare name, so two
same-named helpers in different classes collided and the last one parsed won.
A test calling `self._check()` could be cleared by a `_check` belonging to a
different class, or flagged because of one.
Resolution is now scoped: a bare name looks up the module-level functions, and
`self.<name>` looks up the enclosing class's own methods and no other class's.
Recursion is tracked by function identity rather than by name, so the cycle
guard cannot be confused by the same collision.
This surfaced one real zero-assert test that a same-named helper elsewhere had
been clearing, so TQ001 seeds at 750 rather than 749.
The test module has to register itself in sys.modules before exec_module:
`@dataclass(slots=True)` rebuilds its class through `sys.modules[__module__]`,
and Scope fails to construct without it. Recorded at the call site, since it
reads like avoidable global mutation otherwise.
* fix: register test-quality-budget.json with the ratchet alarm
The repo keeps one census over its budget files: every *-budget.json on disk
must appear in DEFAULT_BUDGETS, or its ceilings can be raised with no signal.
tests/test_litellm/test_budget_ratchet_check.py asserts that set equality and
caught the new budget on the way in.
Registering it also turns the alarm on for TQ001-TQ005, so a later PR cannot
quietly raise a test-quality ceiling. The file already uses the {limit: N}
schema the ratchet reads, so no other change was needed.
* test: retire tests/old_proxy_tests, which holds no tests
Twenty files named test_*.py, and pytest collects nothing from any of them:
uv run pytest tests/old_proxy_tests --collect-only -q
no tests collected, 16 errors in 114.17s
They are manual snippets against a running proxy, written at module level with
no test function, no assertion and no entry point, so the only thing the name
buys them is a place on the coverage allowlist. Sixteen of the twenty cannot
even be imported in this environment, wanting langchain, llama_index or
google.api_core, and ten still point at 0.0.0.0:8000, which stopped being the
proxy's default port some time ago.
Nothing outside the directory refers to it apart from the allowlist entry, which
goes with it. The other loose contents go too: five load_test_*.py scripts, a
bursty variant, two committed log files, an essay fixture and a stray .js
snippet.
Allowlist paths 88 -> 68, test files 2422 -> 2402, and no job loses anything it
was running. Recoverable from history if a snippet turns out to be someone's
habit.
* test: drop the retired old_proxy_tests paths from the coverage allowlist
The agent job's CircleCI glob collected `tests/agent_tests/**/test_*.py` and then
piped it through `grep -v` to drop `local_only_agent_tests/`. `assert_ci_coverage.py`
reads the glob but not the pipeline, so those two files looked covered and were
invisible to the census. The glob now excludes them structurally and they carry an
allowlist entry instead, which is a decision on the record rather than a hidden
filter. The collected file set is unchanged: `tests/agent_tests/` holds exactly one
CI-runnable test at the top level.
`tests/scim_tests/` held a single JSON fixture and no tests, referenced from nowhere.
`.github/workflows/` is for workflows. Both stray scripts move to `.github/scripts/`
with their callers updated: the price-file updater is invoked by
`auto_update_price_and_context_window.yml`, and the translation-report runner by
`make test-llm-translation`. The audit listed the latter as orphaned, but Makefile
line 317 still runs it, so it moves rather than being deleted.
The rollout heads-up workflow was a deliberate one-shot for the agent-shin rollout.
That rollout is done, the triage and auto-close workflows have been running daily
since June, so the pre-flip warning window is long past. Its script and dedicated
test go with it, and the sibling workflow-invariant test drops its entry.
Nine workflow files existed only to make a single call to _test-unit-base.yml
with a different test-path. Adding a shard meant adding a file; changing
anything shared meant editing nine. One matrix caller replaces them, so a shard
is now one entry.
Check names are unchanged, which is the whole constraint. A reusable-workflow
job reports as "<job name> / <inner job name>", so setting `name` to the shard id
alone reproduces today's context strings exactly: the eleven the matrix produces
are eleven of the twenty-three "/ Run tests" contexts the branch ruleset
requires, matched string for string. No ruleset edit is needed and none should
be made for this.
Every matrix entry states its timeouts even where they equal the base defaults.
An absent matrix key renders as an empty string rather than falling back, and an
empty string is not a number, so a partially-specified entry would fail the call.
tests/proxy_unit_tests keeps test-unit-proxy-db.yml. It is already a matrix and
its shard-coverage guard reads that file by name, so folding it in belongs with
generalising that guard into assert_ci_coverage.py rather than here. Its twelve
shards are the remaining required contexts.
test-unit-documentation.yml stays too: it does not call the base workflow.
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
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