test_google_login_only_threads_user_code_when_enabled cleared the whole
process environment for the duration of the call. mutmut's trampoline reads
os.environ['MUTANT_UNDER_TEST'] with a bare subscript, so the first
trampolined callee inside the block, _get_cli_sso_flow_or_raise, raised
KeyError. The bare `except Exception: pass` swallowed it and the assertion
then read call_args on a mock that was never called, which is where
"'NoneType' object has no attribute 'kwargs'" came from.
The test only needs the SSO provider variables unset, so it now preserves the
rest of the environment instead of clearing everything. google_login does not
raise here, so the try/except is gone and any future exception propagates; the
added assert turns a silent early return into a readable failure instead of an
AttributeError.
Root cause measured in a trampolined copy of the mutated folder with
MUTANT_UNDER_TEST=stats: the old test fails there with
KeyError: 'MUTANT_UNDER_TEST' inside _mutmut_trampoline, the new one passes.
That was the only test the mutation run could not execute, so the --deselect
comes back out and it counts toward the score again.
When a router-facing model_name alias contains a '/' whose leading segment
is not a registered provider (e.g. 'vertex/claude-opus-5' for deployment
'vertex_ai/claude-opus-5'), _select_model_name_for_cost_calc re-prefixed
it into a non-existent key ('vertex_ai/vertex/claude-opus-5'), so cost
lookup silently priced every streamed request at $0 - token counts were
recorded, no error raised, budgets never tripped.
After prefixing, walk the alias tail and return the first assembly that
exists in litellm.model_cost ('vertex_ai/claude-opus-5'). Provider/region
segments in the head are preserved, and an alias that resolves to no
known key keeps the previous behavior (no crash, legacy double-prefix).
Fixes#38069
With the coverage fix in place the run generates mutants, then dies before
testing any of them: "1 failed ... failed to collect stats. runner returned 1".
The offender is one test. google_login is called inside a bare
`except Exception: pass` and the assertion then reads the mock's call_args, so
an early raise inside mutmut's mutants/ sandbox surfaces as
"'NoneType' object has no attribute 'kwargs'" rather than as the real error.
Deselected rather than ignored, so the other 248 tests in test_ui_sso.py still
contribute to the score.
This is measured rather than guessed. mutmut's stats phase hardcodes -x, so a
failing run only ever names its first offender, which is why deselecting looked
like whack-a-mole before. pytest_add_cli_args is appended after -x, and a later
--maxfail wins, so overriding it once let the whole folder run inside the
sandbox: 1 failed, 2901 passed. That one test is the only one that cannot run
there.
What is still not known is why it raises early in the sandbox. It is not the
suite and not the copied tree: the same folder passes outside mutants/ on the
runner image (2930), passes on a copied tree put first on PYTHONPATH (2902),
and passes with and without the test_saml_sso.py ignore. What is left is
mutmut's trampolines.
The streamed-id regression test built a bare BaseLiteLLMOpenAIResponseObject with a
top-level id, hitting the wrong _encrypt_response_id branch. A real streamed create
emits ResponseCompletedEvent, whose client-visible id lives on event.response.id, so
the test now drives that production event shape and reads collected[0].response.id.
Mutating the alias route gate or disabling the .response.id encryption branch both
fail the test.
_get_tiered_base_costs documents that tiered pricing is all-or-nothing: a
tier is picked from the request's input tokens, and any rate that tier does
not declare falls back to the tier's own input rate so one request is never
priced from two tiers.
Nothing checked that. Every existing tiered test supplies a fully populated
tier, so the fallbacks were never reached: deleting them from the source
left the whole suite green. The fallbacks are not hypothetical either. Of
the 66 tiered rows shipped in model_prices_and_context_window.json, 54
declare no cache-creation rate and 44 declare no cache-read rate, so the
fallback is what prices their cached tokens today.
Adds three tests on the generic path:
- a tier with no cache rates bills cached and cache-creation tokens at
that tier's input rate, ignoring the model's top-level cache rates
- a tier with no above-1hr rate bills 1h cache writes at the tier's
cache-creation rate rather than zero
- a tier with no input rate is not a priced tier at all, so the model's
flat rates still apply instead of billing input at zero
Test-only change, no source touched.
The streaming security hook only encrypted response ids when request_route
matched "/v1/responses" exactly, so streamed creates on the /openai/v1/responses
and /responses aliases leaked the plain managed id. A second virtual key could
GET, continue, and DELETE another key's response. Normalize the route (strip the
provider prefix, accept the /responses alias) before gating, mirroring the
non-streaming hook which has no route gate.
Two of the new lines tripped the ratcheting gate.
TQ005 flagged restoring litellm.model_cost by assignment. Dropped the
save/restore pair for monkeypatch.setitem, which adds the one model the
test needs and takes it back out at teardown, so the module global is
never reassigned.
TQ008 flagged patching litellm.proxy.proxy_server.llm_router. The
endpoint imports the router from that module inside the function body,
so there is no seam to inject through without changing the endpoint.
Suppressed with the reason already used elsewhere in the suite for the
same module global, on the single helper the new tests share.
The endpoint already had tests for deployments that set both an input and
an output price, and for litellm_params winning over model_info. Nothing
covered a deployment that prices only one of the two sides, the daily and
monthly totals, or the price and provider read from the public cost map.
Found by changing one line of cost_tracking_settings.py at a time and
running the mapped test file against each change. Nine of eleven one-line
changes went unnoticed: dropping custom pricing entirely when only one
side is priced, billing the unpriced side at something other than zero,
skipping the model lookup so the reported price and provider go empty,
turning zero requests a day into a cost of zero rather than no estimate,
and scaling a period total by one request instead of the real count.
The seven tests added here kill all eleven. The cost math is real; only
the router is faked, matching the fixtures already in this file.
Adds `general_settings.model_list_healthy_only`, which makes `/models`,
`/v1/models/{id}` and `/model/info` hide models whose backing deployments are
all marked unhealthy by background health checks, for every caller, without
each client having to pass `healthy_only=true`. `/model/info` also gains the
per-request `healthy_only` parameter that `/v1/models` already had.
Everything here is opt-in. With the setting absent, the endpoints take the same
code path they do today and no health lookup runs at all.
The listing filter reads the deployment health cache, which until now was only
populated when `enable_health_check_routing` was on, so `healthy_only=true`
silently did nothing in a plain `background_health_checks` setup. The setting
now also keeps that cache filled. That is a pure write: every routing-time
reader is itself gated on `enable_health_check_routing`, and the cooldown and
failure bookkeeping stays behind that flag, so routing is untouched.
Filtering stays presentation-only and fails open. A hidden model is still
callable, and missing, stale or empty health state hides nothing.
Now that /v1/messages routes provider failures through exception_type, an
Anthropic permission_error fell through the anthropic branch to the generic
APIConnectionError and reached the client as a 500 where the raw exception
used to answer 403. Map 403 to PermissionDeniedError so the status survives
on every route.
Bugbot Autofix pushed e2e16d7e2d to split 403 out of the shared 401/403 branch in _map_openai_like_exception. That premise was the BaseLLMException fallback, which d2e4e74685 already removed, and remapping 403 for every openai-like provider is a separate contract change, so this merge resolves both files back to the base branch versions
The step-timeout comment claimed mutmut streams each mutant's result into
mutants/mutmut-stats.json. It does not. That file holds the pre-run test
timings and coverage map (tests_by_mangled_function_name, duration_by_test,
stats_time) written once by save_stats() before mutation starts.
Per-mutant results live in mutants/<source path>.meta. Verified against
mutmut 3.5.0: SourceFileMutationData.register_result() calls save() after
every single result, and export-cicd-stats walks those .meta files to build
mutmut-cicd-stats.json. So the reason the step deadline exists is still
right, an interrupted run keeps the mutants it already scored, but the
comment pointed at the wrong file.
Also upload the .meta files, since they are the partial results the comment
relies on and the artifact could not otherwise show them.
The fallback mapped every unbranched provider error by status code on every route, which changed the exception class and HTTP status for those providers and failed four provider test suites in CI. The /v1/messages handler change alone covers the ticket, since the anthropic branch already maps its errors
The fixture was matched by host alone, so it answered any method and path and
the tests would have stayed green if the request went somewhere else. It now
matches POST on the Foundry route, and asserts the route was called.
Worth pinning on its own: the real path carries a /models prefix, which the
first attempt at this got wrong, so the match now also holds the routing in
place rather than only the retry.
The source drops the rejected field in place, so a payload shared across
tests could in principle be consumed by whichever case ran first. It does
not happen today, because the request is copied before the transform runs,
and the cases pass in reverse and async-first order alike. Building the
payload per call costs nothing and keeps that true if the copy ever goes.
Azure AI is the only provider that retries a 422 inside the translation
layer: when the endpoint rejects a field, litellm drops that field and sends
the request again, up to twice. That is the difference between a customer's
tool call working and coming back as a hard 400, and none of it was covered.
The retry loop in llm_http_handler.py is 13,419 lines of source against a
0.20 test-to-source ratio, and nothing exercised this path at all.
Drives real litellm.completion and litellm.acompletion calls against a
recorded Azure AI endpoint, so the assertions read the bytes that actually
went over the wire rather than a mock's call list. Nothing internal is
patched: respx fakes the HTTP boundary and the provider config, retry loop
and serialization are all the real ones.
Pins:
- a tool field the endpoint rejects is dropped and the call retried, and the
caller gets a normal completion
- the retry changes only the field the provider named
- a provider that keeps rejecting stops after exactly two attempts
- a rejection the provider cannot fix is not retried at all
- an extra input outside a tool is retried only when drop_params was asked for
Mutating the source confirms these bite: raising the retry cap from 2 to 3,
and making the tool-level field check always return False, each turn the
suite red.
The async cases pin the transport to httpx, because the aiohttp default
carries its own transport that an httpx-level fake cannot intercept. Without
that the two async tests reached the real Azure endpoint and failed on a 401.
proxy/_types.py is 4,965 lines holding 202 request and auth models with 27
validators, and its mapped test file was 32 lines covering one of them. The
validators decide what a caller is allowed to send, so a silent change here
reaches customers as a request that should have been refused and wasn't, or
the reverse.
Pins the contracts that carry real consequence:
- the server-only MCP markers and via_virtual_key are stripped from any
caller-supplied input, so they cannot be forged through the constructor or
model_validate, while the server can still set them by assignment
- a virtual key is hashed out of the auth object, and Bearer-prefixed and
bare keys hash alike
- a JWT issuer must name an audience or opt out of one, never both and never
neither
- a boolean spend reset is refused rather than silently read as 1.0 or 0.0
- a key or user update must say which key or user it updates
- a key lookup naming nothing is refused rather than matching everything
- an organization member cannot be given a role that lives outside an
organization
- an audit log stores the key it recorded a change to only masked, and keeps
the non-secret fields intact
Every case asserts the observed value rather than that a call happened, and
nothing is patched. Verified by mutating the source: dropping the marker
strip, flipping the audience rule's and to or, letting booleans through the
spend reset, treating an empty key list as naming a key, and disabling the
role check each turn the suite red.
Moves the file to the path that mirrors litellm/proxy/_types.py, which the
old file's own first line already said it should have been at, and carries
its two tests over.
`_delete_cache_key_object` awaited the Redis delete unguarded, so any cache
backend error surfaced as a failure on an operation that had already been
committed. A Redis ACL that denies DEL on LiteLLM's unprefixed token-hash keys
turned a persisted `/key/update` into `400 Authentication Error, No permissions
to access a key`, and `/key/block` and `/key/regenerate` into 500s
Make the helper best-effort, the way `delete_cache_team_object` and
`delete_cache_key_objects` on either side of it already are: log the failure and
carry on. Nothing ends up staler for it, since the in-memory entry is dropped
before the Redis round trip and the write has already committed, so raising only
misreported a success
Virtual Keys, Budgets, Projects, Access Groups, Guardrails Monitor and Cost
Optimization all move onto the shared PageHeader, matching the Teams page.
That empties LegacyPageHeader, so it and its test are deleted.
Each page now uses its own sidebar icon, so the nav and the page agree:
Virtual Keys keeps KeyRound and Budgets keeps Wallet, Projects picks up
Folder and Access Groups picks up Boxes, and Guardrails Monitor swaps the
indigo Shield for the sidebar's HeartPulse. Cost Optimization keeps
PiggyBank but drops its hardcoded size and stroke, which PageHeader owns.
Control rows follow the spec instead of each page inventing one. Virtual
Keys had its create button rendered as a sibling below the header, Budgets
hand-rolled a row with a bottom border that closed the header off, and
Projects and Access Groups sat their button next to the title. All four now
pass primaryAction. Guardrails Monitor's date picker moves out of the parent
and joins Export Data in utilities. Cost Optimization's tabs move into the
tabs slot with the standard 22px spacing.
Page insets go to p-8 with a 24px gap to content, replacing p-6 px-12,
p-6, mx-4 and py-2.
Every page test now asserts its heading, subtext and sidebar icon. Swapping
any of the six icons fails its suite.
mutmut's gather_coverage() looks each source file's covered lines up by
absolute path, but [tool.coverage.run] sets relative_files = true, so every
lookup misses. With mutate_only_covered_lines = true that leaves no line
eligible for mutation, and the run ends on "Stopping early, because we could
not find any test case for any mutant" after spending 26 minutes collecting
coverage. The last four dispatches all died that way.
Point COVERAGE_RCFILE at a small rc file for mutation runs only, so the
coverage instance mutmut builds stores absolute paths. Scoped to one module
locally this takes the run from 0 mutants to 8 generated and 8 killed.
Also give the mutmut step a deadline inside the job's own. mutmut records
each mutant's verdict to mutants/mutmut-stats.json as it finishes, so a run
that outlasts its budget still scores what it got through, but a cancelled
job skips the report and upload steps and publishes nothing. That is how the
two runs before these four ended.
Ignore mutants/ and .venv-mutmut, which a local run leaves behind untracked.
* feat(prometheus): configure deployment caller identity
* test(prometheus): satisfy strict caller identity lint
* fix(prometheus): align caller identity on latency metrics
* fix(prometheus): validate caller identity mode before collectors register
Fail config load on an invalid prometheus_deployment_and_latency_caller_identity
value (including null) and on include_labels entries the selected mode removes
from a target metric, instead of booting green with an empty /metrics.
Validate the mode at the top of PrometheusLogger.__init__ so an invalid value
raises before any collector lands in the process-global registry, keeping
retries free of duplicated-timeseries errors. Label-validation errors now name
the mode setting alongside the rejected label.
---------
Co-authored-by: Mark Philipp <mphilipp622@gmail.com>
Co-authored-by: Yucheng Zhu <yucheng@berri.ai>