Several dashboard pages wrap their content in a div styled w-full mx-4, so the
element's width is 100% of the scrollable main while mx-4 adds 16px of margin on
each side. That makes the margin-box 100% + 32px wide, which overflows main by
exactly 32px. Because main uses overflow-y-auto its overflow-x computes to auto,
so the overflow surfaces as a horizontal scrollbar along the bottom of the whole
content area under the pagination
The wrapped block is already full width without w-full, so removing that one
token keeps the layout and drops the overflow to 0. This is the same fix already
applied to the Virtual Keys page in #33112, extended to the remaining pages that
share the wrapper: Models + Endpoints, Tag Management, Organizations, Vector
Stores, AI Hub, and Logging & Alerts
* fix(openai/responses): clamp max_output_tokens below API minimum
Claude Code sends a max_tokens=1 warmup probe when running /model, which
the Anthropic Messages -> Responses adapter forwards as max_output_tokens=1.
OpenAI's Responses API rejects values below 16, so the probe failed with a
400. Clamp anything below the minimum up to 16 in map_openai_params so all
Responses API entrypoints (direct, chat->responses, anthropic->responses)
are covered.
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
* refactor(openai/responses): extract _enforce_min_max_output_tokens helper
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
* fix(ui): address Virtual Keys redesign review nits
Restore sorting by budget on the merged Spend / Budget column. The column now
uses a new DataTableMultiSortHeader whose chevron opens a menu offering Spend
and Budget in both directions plus Reset, so the progress-bar cell stays merged
while the sort field becomes an explicit choice. Sorting is server-side, so the
chosen field id (spend or max_budget, both accepted by /key/list) flows straight
through as sort_by
Fill the DataTable to its container width when column resizing is on. The table
width was pinned to the sum of column widths, so hiding columns left an empty
gutter on the right. It now keeps that width as a minimum and stretches to 100%
on underflow while still scrolling on overflow, which also covers the same gap
in TeamVirtualKeysTable since both share the component
Drop the dark background box behind the page-header icon so the Virtual Keys
header reads like the Teams header, and pull the 4-line inline filter lambda in
SearchSelect out into a named matchesQuery helper
Extends the DataTable and VirtualKeysTable tests to cover the new multi-field
sort menu (field id maps to sort_by, active indicator, reset) and the
fill-to-container width
* fix(ui): emphasize the active field in the Spend / Budget sort header
The merged Spend / Budget header always read "Spend / Budget" regardless of
which field drove the sort, so after picking Budget descending there was no way
to tell what was sorted without reopening the menu. The header now builds its
label from the sort fields and emphasizes whichever one is active (bold,
full-strength text) while muting the other, so the sorted column reads at a
glance alongside the direction chevron. Drops the now-redundant title prop since
the label is derived from the fields
* fix(ui): remove w-full so the keys page content stops overflowing by 32px
The virtual keys content wrapper used "w-full mx-4", which sets the width to
100% of the parent and then adds 16px of horizontal margin on each side, so its
margin-box came to 100% + 32px and overflowed the scrollable main region by
exactly 32px. That surfaced as a horizontal scrollbar along the bottom of the
whole content area, under the pagination. A block div is already full-width, so
dropping w-full lets mx-4 inset it correctly with no overflow
* fix(ui): darken the clickable Key cell on hover so it reads as clickable
The Key cell was the click target that opens the key detail, but hovering only
faded the chevron in with no change to the cell itself, so there was no cue that
the area was clickable. Give the cell a subtle muted background and a pointer
cursor on hover. The button spans the full cell (a negative inline margin plus a
matching width offset so the hover fill reaches both cell edges while the title
stays aligned with the other columns)
* fix response not being redacted for custom callbacks with streaming enabled
* reduce code duplication
* add unit test
* fix: resolve lint violations in adopted redaction fix
* fix: scope streaming response redaction to the opted-out custom logger
---------
Co-authored-by: Moritz Müller <moritz.mueller2@tu-dresden.de>
get_user_object catches every DB failure in a broad except and re-raises a bare ValueError (litellm/proxy/auth/auth_checks.py), so a real outage and a missing user look identical and the original error survives only as __context__. The dcr_bridge admission path keyed its 503-vs-401 decision on the exception type, so a transient outage during a user-subject reload surfaced as a 401 rather than a retryable 503, and the regression test injected a raw ConnectionError, a shape get_user_object never produces, so it passed on a fiction
Add PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain, which walks __cause__/__context__ (bounded and cycle-safe) the PEP 3134 way, and route _raise_503_if_db_unavailable through it. Move the user's object_permission resolution inside the single classified try so an outage there is a 503 too, never an opaque 500. Pin get_user_object's wrapping with a contract test that drives the real function, and drive the reload tests with that same faithful shape so a chain-blind regression fails them
The try/except around the registration post now relays every upstream 4xx/5xx for both arms, so the bridge_relay status_code check could never fire; removing it addresses the Greptile P2 dead-code finding
An upstream token endpoint rejection (e.g. Google requiring client_secret even for PKCE web clients) escaped exchange_token_with_server as a raw httpx.HTTPStatusError, which the global exception handler turned into an opaque 500 Internal server error in the create-flow UI. The RFC 6749 section 5.2 error body the IdP sent (error, error_description, error_uri) is now relayed with the upstream's own 400/401 status; rejections outside the section 5.2 contract map to 502 so a broken upstream is not misattributed to the caller. The same relay covers the non-bridge DCR registration arm, and a 200 token response without a usable access_token now answers 502 instead of a KeyError 500. The catch wraps the post call itself because litellm's AsyncHTTPHandler raises MaskedHTTPStatusError at call time, which also made the pre-existing bridge-relay status check unreachable in production. The dashboard's token exchange error message now composes error and error_description so the form shows the IdP's reason
* feat(ui): rebuild the Virtual Keys table on the shared DataTable
Replaces the hand-rolled Tremor table and bespoke toolbar/pagination on the admin
Virtual Keys page with the shared DataTable: server-side sort, paginate, and
filter, a sticky scrolling body, a search plus column-visibility plus filters
toolbar, a right-side filter drawer, and a rows-per-page footer. A page header
with the existing key icon carries the Create New Key action.
Adds reusable, shadcn-default building blocks for the tables migrating onto the
DataTable next: shared IdentityCell, ModelsCell, and SpendBudgetCell in
shared/table_cells, plus a shared PageHeader. The models cell reveals overflow in
a hover tooltip and the spend/budget cell uses the Meter primitive.
All data and domain logic is preserved, including the useKeys query, team and org
alias resolution, the user popover, and the KeyInfoView detail swap. The rich
async Team/Org/Alias filters move into the drawer, and the toolbar search maps to
the key-alias substring search. Status now also reflects key expiry alongside
blocked and SCIM-blocked.
The VirtualKeysTable tests are updated to the new markup and extended with focused
coverage for each new shared cell
* fix(ui): address Virtual Keys redesign review feedback
Fold the status badge into the clickable Key cell and drop the separate Status
column so a key's alias, secret, and status read as one unit. The Key cell is
now the single click target that opens the key detail; the whole-row click is
removed
Migrate the filter drawer off AntD to shadcn. A new Combobox composed from
Popover and Input backs the Team, Organization, and Key Alias filters, keeping
search and the alias infinite-scroll
Show $0.00 for zero spend instead of a hyphen, and extend the shared DataTable
with badge, chips, and meter skeleton shapes so the loading state matches the
loaded cells (status pill, model chips, spend meter) rather than uniform bars
Fix key sorting: the Key column sent its column id "key" as sort_by, which
/key/list rejects with 400. It now sorts by the backend field key_alias
* fix(ui): use the shadcn base combobox and refine the keys filters and skeletons
Replace the hand-rolled filter combobox with the supported shadcn Base UI combobox
(ui/combobox, added via the CLI and reused through a small SearchSelect wrapper).
Its vended input-group and textarea deps are written for React 19 (plain functions
with ref-as-prop); this app is on React 18, where those subcomponents drop the refs
Base UI passes for focus and anchoring, so InputGroupInput, InputGroupButton, and
ComboboxTrigger are adapted to forwardRef. Those ui/ files now diverge from the
registry, and a future shadcn add would overwrite the adaptation until the app moves
to React 19. Adds class-variance-authority, which input-group needs
Give loading skeletons a per-column renderSkeleton escape hatch on the shared
DataTable and mirror the Key cell exactly (alias line, secret, status pill), so
skeleton rows match the real rows instead of being shorter and simpler
Resolve the automated review: the toolbar search and the drawer Key Alias filter
both mapped to the key-alias query, so the search silently overrode the drawer value
while its chip stayed visible. Consolidate to a single alias search in the toolbar
(placeholder now "Search by key alias…") and drop the redundant drawer field. Re-add
coverage for the Created By column's alias-over-email display
Refine the Team and Organization filters: they match on name and id, so the labels
read "Team" and "Organization" rather than "... ID", each option shows the name with
the id on a muted second line instead of "name (id)", and the active-filter chip
shows the friendly name
* chore(ui): drop duplicate class-variance-authority, use the repo cva package in input-group
_reload_admitted_user returned a bare UserAPIKeyAuth(user_id=...), so the shared
get_allowed_mcp_servers found no key/team/object-permission grants and an interactive SSO client could
admit successfully yet see zero tools on a normal (allow_all_keys=False) server. The key path returns
the full key record whose object permission drives that computation; the user path dropped it.
Resolve the user's own MCP object permission and put it on the returned auth, so the same
get_allowed_mcp_servers the key path uses grants the user their litellm-granted servers and access
groups. This reuses get_object_permission (the id-to-grants resolver keys and teams already use) and
does not duplicate any permission logic; get_user_object does not load object_permission, so it is
resolved from the user's object_permission_id the same way the key and team paths do.
Only the user's own object permission is bound. A UserAPIKeyAuth carries a single team_id while a user
may belong to many teams, so team-inherited MCP grants for a user are a follow-up: they need a
many-teams union get_allowed_mcp_servers does not do off one auth object, and faking one here would be
the kind of half-measure that spawns more bugs. Tests cover the user's object permission riding onto the
admitted auth, and the existing admit/SCIM/missing-user/503 cases still hold.
_reload_admitted_user mirrored only part of _reload_admitted_key's error contract: it caught
ProxyException and HTTPException but had no arm for anything else, so a transient DB outage surfaced as
an opaque 500 instead of the retryable 503 the key path guarantees, and a missing user surfaced as a 500
too. The missing-user case is the subtle one: get_user_object raises a bare Exception for a deleted user
(not a ProxyException like get_key_object does for a missing key), so the ProxyException/HTTPException
clause never caught it and the user_object-is-None branch it was supposed to hit is unreachable on the
production path.
Add the same except-Exception arm the key path uses, with the one deliberate difference the differing
get_user_object contract requires: a database-service-unavailable error still raises the retryable 503,
while a missing user or any other non-outage resolution failure fails closed as a 401 rather than
propagating as a 500. The regression tests now drive the real behavior (get_user_object raising) rather
than a None return that never happens in production, and cover both the 503 outage and the 401
missing-user paths.
Completes the oauth_delegate bridge for real DCR clients (Claude Code, Claude
Desktop), which send no litellm key and cannot use the scripted two-header path.
On the short-circuit bridge arm the gateway now captures the SSO-authenticated
litellm user from the browser session at /authorize and seals it into the OAuth
state; at /callback it seals that user plus the upstream code into a gateway
authorization code the client echoes back; at /token it recovers the user,
exchanges the real upstream code, and mints a user-subject envelope. The user
identity captured in the browser thus rides to the back-channel token call with
nothing stored server-side, and admission opens the envelope under that user. The
scripted key_hash path is unchanged (raw upstream code, key from the request);
without a session the browser is sent through login first.
The scripted two-header client mints under a virtual key it presents at the token
endpoint (key_hash), but the interactive DCR client authenticates via SSO at the
bridged authorize, which yields a user, not a key. Make EnvelopeIdentity a
discriminated subject (subject_type key_hash | user_id) with key_hash_identity /
user_identity constructors, and dispatch admission on it: a key_hash reloads the
key, a user_id reloads the user and admits them as themselves (user-level budget
and SCIM enforced via the same centralized gate; no team bound, since a user
belongs to many teams or none). The interactive producer that mints a user_id
envelope lands in the follow-up commit.
Pass-through endpoints configured with auth=false reach the cost-tracking callback with no key/user/team/end-user, so _should_track_cost_callback returned False and the spend-log write was skipped, leaving the request out of request/usage logs. Track pass-through call types even when unauthenticated so the SpendLog row is still written.
Co-authored-by: Mubashir Osmani <mubashir@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(ui): rename OldTeams component file to Teams
* fix: show and allow editing team model aliases after team creation
* fix(ui): mark team model_aliases as nullable to match the prisma schema
* fix(guardrails): walk custom_tool_call_output items in _content_utils
* Change _OUTPUT_ITEM_TYPES to Frozenset type
* fix(guardrails): use builtin frozenset generic for _OUTPUT_ITEM_TYPES annotation
Frozenset is not a defined name (typing exports FrozenSet, the builtin is
frozenset), so module import raised NameError and broke every proxy test
suite. The builtin generic is valid on the supported python floor (3.10)
and keeps the UP006 ruff-strict budget at its ceiling, which the typing
alias would exceed
* feat(batches): track cost for unmanaged Bedrock batches, generalize the flag
CheckBatchCost skipped Bedrock batches whose unified_object_id is a raw
model-invocation-job ARN, the same root cause previously fixed for
unmanaged Vertex batches. Bedrock batches embed the model name in their
s3:// input file name instead (litellm-bedrock-files-{model}-{uuid}.jsonl),
so the same routing mechanism now derives the model from that layout and
matches it to a configured bedrock deployment.
track_unmanaged_vertex_batch_cost is renamed to track_unmanaged_batch_cost
since two providers now share this mechanism.
* fix(batches): parse Bedrock batch output and price with deployment model name
Bedrock model-invocation-job results use modelOutput/error rows and short
internal model ids that are not in the cost map, so unmanaged batch cost
tracking logged tokens but $0 spend. Use deployment model name for pricing
and add regression tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
When a /responses request uses a hosted MCP tool (server_url: litellm_proxy/<label>)
with store=true and the model calls a tool, the gateway auto-executes the tool and
streams one logical response stitched from several upstream responses: an interim
response whose only output is the function_call, then the post-tool answer
B1 (correctness): every streamed event was pinned to the first round's response id,
i.e. the interim response that carries the function_call but no tool output. The
client then continued the next turn from that dangling response and the provider
rejected it with "No tool output found for function call <id>", which on the
streaming path surfaced as a silent empty completion. The fix adopts each
auto-execute round's own response id (the cached id is reset when a follow-up round
starts) so the client continues from the final round, whose stored input chain
includes the function_call_output
B2 (robustness): initial and follow-up call failures were swallowed; the stream
emitted the mcp_list_tools discovery events and then closed with HTTP 200 and no
output and no error. The fix stashes the failure, makes the initial call eagerly in
aresponses_api_with_mcp so a pre-stream failure re-raises as a real 4xx before any
SSE bytes are written, and emits a terminal error event when a follow-up call fails
mid-stream
Adds regression tests covering continuation exposing the final round's response id
rather than the interim tool-call id, a follow-up failure emitting a terminal error
event, and an initial-call failure being stashed for eager re-raise
* feat(router): soft-floor adaptive mode for complexity router
Let complexity_router_config.adaptive=true Thompson-sample across the
union of tier pools with a tier-distance penalty, and wire the existing
adaptive post-call bandit so mis-tiered requests can still recover.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): reattach adaptive hooks for hybrid complexity
Finalize was wiping every AdaptiveRouterPostCallHook and only
re-registering standalone auto_router/adaptive_router deployments,
so complexity adaptive=true never received bandit updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(router): drop unnecessary hybrid docstrings
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): attribute adaptive feedback
Credit user reactions to the model that produced the previous response while keeping current-response signals on the serving model
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): tune hybrid cold defaults
Use the cost-weighted policy that beat equal-pool complexity in the full bakeoff, and make the committed harness compare identical tier pools
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): preserve hybrid cold quality floor
Sample only unobserved models in the classified tier until feedback exists, then apply adaptive scoring without mis-penalizing models shared across tiers
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): bound feedback context cache
Cap retained session feedback so unique session IDs cannot exhaust router memory
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): preserve exhaustion signals
Include tool-result exhaustion in adaptive feedback and clear strict lint regressions blocking CI
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(router): remove stale owner cache
Remove obsolete attribution state, tighten the embedded router type, and keep the test diff focused on adaptive behavior
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(router): centralize hook cleanup
Use the callback manager to discover and remove adaptive hooks across every registered callback list
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(router): add Router(plugins=[...]) routing-plugin pipeline
Runs a sequence of user-supplied plugins before the routing decision is
made. Each plugin reads/mutates a RoutingContext (messages, candidate
models, metadata, signals); the narrowed candidate list is enforced when
picking a deployment, raising rather than silently falling back if a
plugin narrows to zero candidates.
Prototype for the routing-plugin pipeline discussed in #32168.
* fix(router): use ruff-modern typing, add raw/structured messages to RoutingContext
- Use dict/list/X|None instead of Dict/List/Optional in new code, staying
within the ruff strict-rule budget ratchet
- Extract the guardrail-translation message normalization ComplexityRouter
already had into a shared resolve_structured_messages() helper
(litellm_core_utils/prompt_templates/factory.py), reused by
ComplexityRouter and the new routing-plugin pipeline instead of
duplicating it
- RoutingContext now exposes both raw_messages (as received) and
structured_messages (normalized across chat completions / Anthropic
messages / Responses API), mirroring CustomGuardrail.apply_guardrail's
pattern, per review feedback on #32972
- Add direct unit tests for _run_routing_plugins and
_filter_by_routing_plugin_candidates (router_code_coverage gate requires
every router.py function be called by name somewhere in tests/)
* fix(test): rename to test_router_routing_plugins.py
router_code_coverage.py's AST scanner only inspects test files whose
filename contains the substring "router" -- test_routing_plugins.py
doesn't match (routing != router), so it silently skipped this file
and flagged _run_routing_plugins/_filter_by_routing_plugin_candidates
as untested despite the direct unit tests added for them.
* fix(router): fail closed when plugins are configured but the resolved
routing path can't run them
Router.completion() (and other sync entry points) resolves deployments
via the synchronous get_available_deployment(), which never runs
async_pre_routing_hook and therefore never runs the routing-plugin
pipeline. async_get_available_deployment() itself falls back to that
same synchronous method for routing strategies without an async-native
selector (e.g. legacy "usage-based-routing" v1). Both paths would let a
policy plugin (e.g. a deny-all rule) be silently bypassed.
Raise instead of silently proceeding when self.routing_plugins is
configured and the sync path is reached, since applying the pipeline to
every selector path is a larger change out of scope for this PR.
Per review: https://github.com/BerriAI/litellm/pull/32972/changes/BASE..bdfb583c2c6f8df10004fb249e11629d41ce71fa#r3565373303
* feat(router): random-pick multi-model complexity tiers
Tier pools already make sense without adaptive; stop pinning lists to
index 0 and shuffle within the classified tier instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ci): format complexity router config
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ci): use PEP 585 types for tier pools
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Selecting the LLM classifier without picking a model only surfaced a
toast on submit; the classifier model select now gets the same red
outline and helper text as the tier and embedding selects once a submit
attempt has failed.
_classify_upstream_lifetime decided "expired" from int(float(expires_in)), which truncates toward
zero, so a positive fractional lifetime in (0, 1) became 0 and was misread as already elapsed. That
rejected the mint with 502 in _finish_bridge_mint after the single-use upstream code had already been
consumed, even though the upstream reported a positive remaining lifetime.
Decide expired on the parsed numeric value rather than its truncated int, so only a genuinely
non-positive value is expired. The envelope works in whole seconds and cannot represent a sub-second
lifetime, so a positive value that truncates to 0 clamps up to the 1s floor instead of being rejected.
Values >= 1 still truncate toward zero so the envelope never claims more life than the upstream stated,
and NaN / Infinity / oversized input still read as unparseable ("unspecified").
Regression covers the classifier (0.5 and 0.001 clamp to 1, 1.9 truncates to 1, -0.5 stays expired) and
the mint (a 0.5s upstream lifetime mints a 200 envelope rather than a 502); reverting to the
truncate-then-check reddens both.
Three findings landed together, all one defect: a resolution step crushed several distinct outcomes
into a single None or a silent default, so the mint's error mapper could not tell them apart and
assigned the wrong status. Identity resolution mapped a database outage to the same None as a missing
credential, which the mint reported as 400 invalid_request, blaming the caller for a gateway outage
while admission statuses the same outage 503/500. Lifetime coercion mapped an explicit non-positive
expires_in to the same None as an absent one, so an upstream token the IdP reports as already dead was
sealed into an hour-long envelope. And the refresh_token grant was run through the upstream exchange
(which can rotate the client's upstream refresh credential) and its result then discarded, even though
a bridge server seals no refresh_token and the client never holds one to present.
Rather than add a mapping branch per finding, the fix changes the return types so a wrong status is not
representable. Each resolution step now returns a precise tagged value instead of None: identity
resolution returns a _ResolvedKey or one of no_active_key / unavailable / unresolvable, classified the
same way admission's _reload_admitted_key classifies the same conditions; upstream-lifetime
classification returns a positive number of seconds, "unspecified" (absent or unparseable, which the
envelope caps), or "expired" (a parseable non-positive value, an already-dead token); and upstream-grant
validation returns a typed grant or one of no_access_token / expired_lifetime. Thin exhaustive mappers
(match plus assert_never) lift each vocabulary into one bridge-mint taxonomy of eight named failures,
and a single _bridge_mint_error_response gives each its truthful RFC 6749 §5.2 status: 400 for the
caller's missing credential or an unsupported grant, 503 for a transient auth-DB outage, 500 for a
gateway that cannot resolve identity or is not configured, and 502 for an upstream response with no
usable token, an already-expired lifetime, or a token too large to seal. Adding a failure mode now
requires a new literal and a match arm the type checker forces, so the class of wrong-status bug cannot
recur silently.
The refresh_token grant is rejected in _prepare_bridge_mint before the exchange with
unsupported_grant_type, so it can never rotate or consume the client's upstream refresh credential;
renewal is re-running authorization_code, as the sealed refresh_token=None already intends. An absent or
unparseable expires_in still mints a capped envelope (the by-design behaviour for an upstream that omits
the field); only an explicitly-dead lifetime is rejected.
Tests cover the resolver's three failure classes (including a real connection-error outage and a missing
prisma_client), the mint statuses for each (503 before the upstream exchange, 500, 502 on an expired
upstream lifetime, and a capped mint on an unknown one), and the refresh-grant rejection before any
exchange. The three findings are mutation-checked: reverting each fix turns its regression test red.
_finish_bridge_mint floored the reported expires_in at 1. Admission expires the
envelope against the JWT's second-truncated exp, so when the mint lands in the same
second that exp falls on (a sub-second upstream lifetime, for instance), the true
remaining life is 0 and reporting 1 tells the client the bearer lives one second past
the point admission already rejects it. Floor at 0 instead so the reported lifetime
never overstates the exp; the value still cannot go negative.
The regression pins the boundary directly: minting at now=100.25 with a 1s upstream
token seals exp=101, and the reported expires_in is max(0, 101 - ceil(100.25)) = 0.
Under the old floor of 1 it reads 1, so the test fails on that mutation.
Also drops the unused mcp_server parameter from _prepare_bridge_mint; identity and
key derivation there never referenced the server.
The dcr_bridge oauth_delegate token mint validated its preconditions in two
places: a pre-exchange guard inside exchange_token_with_server (master_key set,
resolvable litellm identity) and an authoritative re-check inside the post-exchange
_mint_bridge_delegate_token_response. Keeping the two in step by hand is what kept
producing the same class of finding: a precondition guarded on one grant branch but
not the other, master_key checked after the exchange on one path, identity resolved
twice, and each failure raising an ad-hoc HTTPException with its own status and body
shape.
Model the mint as three phases whose failures are values. _prepare_bridge_mint runs
before the exchange, checks every precondition once (master_key, then identity), and
returns either a frozen _BridgeMintReady carrying the resolved key hash and the
master-key-derived envelope keys, or a _BridgeMintError literal. Because every
precondition lives in prepare, and prepare runs before the upstream POST, no failure
can burn the single-use code or rotate a refresh token, for either grant type, by
construction rather than by a guard we have to remember to keep in sync.
_finish_bridge_mint runs after the exchange and has no preconditions left that can
fail; its only failure values are properties of the upstream response itself (no
usable access_token, or a token too large to seal). One mapper,
_bridge_mint_error_response, turns each _BridgeMintError into an RFC 6749 section
5.2-shaped body with a status truthful about where the failure is (400 for the
caller, 500 for gateway config, 502 for the upstream), with an exhaustive match plus
assert_never so a new failure mode cannot be added without a matching status.
Behavior is unchanged for the client. Every failure that previously raised now
returns the same status as an OAuth error body, which is the correct token-endpoint
contract; the three tests that asserted a raised HTTPException now assert the
returned response. _exchange_for_bridge_server additionally asserts the identity
resolver is awaited exactly once for a bridge server and never for a non-bridge one.
Follow-up to the pre-exchange identity gate, which I had only added to the
authorization_code branch and which left the master_key check inside the mint
(after the upstream exchange) - so the very burn-then-fail pattern it was meant to
prevent still applied to refresh_token grants and to a misconfigured gateway.
- Hoist a single pre-exchange gate above the upstream call that covers BOTH grant
types: it fails closed (invalid_request) on an unresolvable litellm identity and
500s on an unset master_key BEFORE the single-use code or refresh token is
exchanged/rotated, so a bad key or a misconfigured gateway never burns the
upstream credential.
- Report expires_in from the envelope JWT's own second-truncated exp (rounding the
elapsed portion up) instead of the raw expires_at - now delta, so the client is
never told the bearer is valid past the ~1s point admission already expires it.
Regression tests assert the upstream exchange is never called on the no-identity
refresh grant and the master_key-unset path, and that the reported expires_in does
not overstate the JWT exp.
Findings from a full adversarial review of the mint path across security,
correctness, error-handling, concurrency, and OAuth-protocol dimensions.
- expires_in coercion is now total: int(float(...)) can raise OverflowError on
Infinity / a giant numeric string, which escaped the ValueError/TypeError catch
and 500'd the token endpoint. Unified to catch OverflowError too.
- Resolve the litellm identity BEFORE exchanging the single-use upstream code, so
a missing or transiently-unresolvable identity fails closed with invalid_request
without burning the code (the mint re-resolves via a cache hit).
- The no-identity failure is now an RFC 6749 5.2-shaped invalid_request
(JSONResponse, top-level error, no-store) instead of a detail-wrapped
HTTPException, matching the BYOK OAuth endpoint.
- EnvelopeTooLarge (upstream token too big to seal) surfaces a 502, not a 500.
- The upstream refresh_token is no longer sealed into the envelope: the edge
never consumes it, so it was dead weight embedding a long-lived upstream
credential in the client bearer and enlarging the envelope; refresh is a
follow-up (a dedicated refresh-envelope).
Security review found no exploitable defect (forgery, cross-server/user replay,
leakage, confused-deputy all closed). Regression tests cover the OverflowError,
the code-not-burned path, the RFC-shaped error, the 502, and the dropped refresh.