Skip to content

[Infra] Promote interal staging to main - #26298

Merged
yuneng-berri merged 60 commits into
mainfrom
litellm_internal_staging
Apr 23, 2026
Merged

[Infra] Promote interal staging to main#26298
yuneng-berri merged 60 commits into
mainfrom
litellm_internal_staging

Conversation

@yuneng-berri

Copy link
Copy Markdown
Collaborator

Relevant issues

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Screenshots / Proof of Fix

Type

🚄 Infrastructure

Changes

Sameerlite and others added 30 commits April 15, 2026 17:48
Use wildcard-aware deployment lookup when building order-based fallback levels so requests like openai/gpt-4.1-mini can advance from order=1 to order=2, and add a regression test for wildcard routing.

Made-with: Cursor
allow model routing to improve based on conversation signals

ensures router is picking best model for task
Remove _experimental/out/ changes from this PR — these are auto-generated Next.js build outputs, not part of the adaptive router feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unrelated timestamp and version drift was showing in the PR diff. This PR adds no new deps — keep uv.lock identical to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Router coverage check flagged this method as untested. Adds two cases:
- initializes AdaptiveRouter from model_list and is idempotent on re-entry
- no-op when no adaptive deployments are configured

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Use LoggingCallbackManager.add_litellm_callback instead of
  litellm.callbacks.append (required by callback_manager_test)
- init_adaptive_router_deployment now uses model_name_to_deployment_indices
  for O(k) lookup instead of scanning model_list
- Rephrase comment in set_model_list to avoid the 'in self.model_list'
  substring that the linear-scan test greps for
- Whitelist _finalize_adaptive_router_if_configured in
  test_no_linear_scans_in_router — prefix match on 'auto_router/adaptive_router'
  has no supporting index; runs once at init

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Use 'auto_router/adaptive_router' prefix in example yaml, docs, and
  README — the old 'adaptive_router/...' and 'openai/gpt-4o-mini' values
  silently skipped adaptive-router init because detection requires the
  'auto_router/adaptive_router' prefix.

- Read x-litellm-min-quality-tier from request headers (and the
  'min_quality_tier' metadata key as fallback) in async_pre_routing_hook.
  Previously the documented header was defined but never extracted, so
  the quality-floor feature was inert.

- Evict expired entries from _session_states. The cache grew without
  bound — added a parallel expiry map (same TTL as _owner_cache) and an
  opportunistic bulk sweep when the cache crosses a size threshold.

- Align adaptive-router migration SQL with Prisma schema: all count
  columns and the 'clean_credit_awarded' / 'last_processed_turn' fields
  are NOT NULL in the data model, so the migration now declares them
  NOT NULL. Fixes test_aaaasschema_migration_check.

Tests: 8 new covering header/metadata/precedence/invalid-value paths for
min_quality_tier and TTL-based eviction of _session_states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ires

The post-call hook was hardcoding tool_results=[] on every Turn, so the
failure detector never saw tool errors and the bandit only learned from
satisfaction — never from negative tool outcomes.

Added _recent_tool_results(messages): walks the request messages from the
tail and collects the contiguous run of role=='tool' entries — those are
the results from the most recent assistant tool_calls round. Normalizes
each to {content, is_error}, the only fields signals._detect_failure /
_detect_exhaustion read.

Tests: 6 new covering empty input, trailing-run extraction, is_error
propagation, boundary at first non-tool message, no-trailing-tool case,
and the end-to-end path from hook -> Turn.tool_results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests in test_hooks.py were written against an older API and had been
failing in CI. Updated:

- test_resolve_session_key_* (4 tests): _resolve_session_key now requires
  at least SIGNAL_GATE_MIN_MESSAGES messages before deriving a hash (it
  returns None on shorter convos to match the signal-processing gate).
  Switched the tests to use _long_messages() so they hit the hash path.

- test_post_call_success_hook_* (2 tests): the hook was migrated from
  async_post_call_success_hook (mutates response._hidden_params) to
  async_post_call_response_headers_hook (returns a headers dict) because
  the former fires too late for streaming responses. Rewrote the tests
  against the new API; added a metadata-not-dict noop case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…empty tool output

- SessionState now carries clean_credit_awarded + last_processed_turn (matching
  the DB schema). Satisfaction only fires once per session AND only after
  MIN_TURNS_FOR_CLEAN_CREDIT turns of context — early "thanks" no longer
  inflates alpha.
- _detect_failure no longer treats empty content as failure. Many tools
  legitimately return empty output (zero-result searches, silent bash);
  penalizing those corrupted the bandit posterior. Only is_error fires now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…redact PII

- _owner_cache now opportunistically sweeps expired entries past
  _OWNER_CACHE_SWEEP_THRESHOLD live entries. Previously sessions that never
  came back piled up forever.
- flush_session_to_db strips session_id/router_name/model_name from the update
  payload. Prisma rejects writes to @@id fields.
- record_turn no longer persists last_user_content / last_assistant_content /
  tool_call_history / pending_tool_calls. Those are needed only in-memory for
  the next turn's signal detection; writing user prompts and tool payloads to
  the DB would store PII for every conversation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds total_spend column to LiteLLM_TeamMembership that accumulates
continuously and is not zeroed by the budget cycle reset job. This
enables UI surfaces to distinguish current-cycle spend (the existing
spend column, which resets) from lifetime spend per team member.

Also exposes budget_reset_at on LiteLLM_BudgetTable so /team/info
callers can see when a member's budget window next resets. The field
was already stored in the DB but stripped by the response Pydantic
model.

Includes regression tests that:
- Guard the reset job against ever writing total_spend: 0
- Verify the spend writer increments both spend and total_spend in
  one UPDATE statement.
The uv migration added PRISMA_BINARY_CACHE_DIR=/app/.cache/... and
XDG_CACHE_HOME=/app/.cache to the runtime stages of Dockerfile and
Dockerfile.database. BINARY_PATHS in the generated prisma client was
baked to point into /app/.cache, so any deployment that mounts a volume
there (common with securityContext.readOnlyRootFilesystem: true and an
emptyDir/tmpfs for a writable cache) wipes the pre-downloaded query
engine at pod startup, producing BinaryNotFoundError during connect().

Before the uv migration, prisma-python defaulted to $HOME/.cache =
/root/.cache (runtime stage runs as root), which was unaffected by any
/app/* volume mounts. Restore that behaviour: drop the env vars from
the runtime stage, re-run prisma generate there so the query engine
AND the baked BINARY_PATHS both land in /root/.cache, and remove the
stale builder-stage /app/.cache (~800 MB).

Dockerfile.non_root is intentionally left alone — its /app/.cache
location is by design for the hardened offline-install flow.
LiteLLM_BudgetTable is documented as "user-controllable params" and its
model_fields.keys() is used as the allowlist for extracting budget fields
from incoming API request bodies (management_helpers/utils.py:88,
organization_endpoints.py:112/255/537/549, project_endpoints.py:197/245/632,
customer_endpoints.py:598). Request models like NewOrganizationRequest
inherit from LiteLLM_BudgetTable, so anything on the base class becomes
user-settable — a caller could set budget_reset_at far in the future and
evade budget cycling.

Move budget_reset_at from the base class to LiteLLM_BudgetTableFull so it
appears on API responses without becoming writable, and type
LiteLLM_TeamMembership.litellm_budget_table as Union[Full, Base] so
Pydantic picks Full when the data has server-managed fields (/team/info
reads Prisma rows that include budget_reset_at and created_at) and Base
when callers construct with only user-settable fields (existing auth
tests and caches).
Follow-up on review feedback: the previous commit had the builder
download the query engine into /app/.cache, then threw it away in
the runtime stage and re-downloaded into /root/.cache. That doubled
the build-time network fetch.

Remove PRISMA_BINARY_CACHE_DIR and XDG_CACHE_HOME from the builder
stage as well, so its prisma generate lands in /root/.cache with the
correct path layout on its own. Drop the runtime-stage prisma generate
and instead COPY --from=builder /root/.cache /root/.cache. Single
download, smaller image.
- Mark last_updated_at (AdaptiveRouterState) and last_activity_at
  (AdaptiveRouterSession) with @updatedat so Prisma refreshes the
  timestamps on every write. Without this the fields stayed frozen at
  INSERT time and the last_activity_at index was misleading for any
  future TTL/eviction logic. Applied to all three schema.prisma copies;
  no migration SQL change needed (Prisma @updatedat is a client-side
  annotation that doesn't touch DDL).

- get_state_snapshot: report cell.total_samples instead of alpha+beta
  for the 'samples' field. The previous value inflated every cell by
  the COLD_START_MASS prior (e.g. showed 10.0 before any real traffic
  arrived), which confused operators reading /adaptive_router/.../state.
  Updated docs + the snapshot test to match.

Also fixes two pre-existing merge-break syntax errors in router.py
(missing ')' on the AdaptiveRouter TYPE_CHECKING import; truncated
async_pre_routing_hook dispatch call for the adaptive router branch)
that were masking the rest of the file from the interpreter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P1: start the adaptive-router flusher loop unconditionally at proxy boot
instead of gating on 'adaptive_routers is non-empty'. Adaptive routers
added via /config/reload after boot now have their queues drained.
State is lazy-loaded per router on first flush tick (new _state_loaded
flag on AdaptiveRouter) so hot-reloaded routers still get their
persisted priors.

P2: _finalize_adaptive_router_if_configured now prunes stale
AdaptiveRouterPostCallHook callbacks from every litellm callback list
before registering new ones. Without this, every Router replacement
left the old hooks wired up in litellm.callbacks and double-fired
signal recording for every request. Uses
logging_callback_manager.remove_callbacks_by_type (same pattern as the
semantic tool filter).

CI fixes:
- black --check failure: reformatted litellm/router.py
- schema migration diff: aligned @@index with the explicit index name
  ('idx_adaptive_router_session_activity') from the original migration
  by adding 'map:' to all three schema.prisma copies. No new migration
  needed.

Tests: 1 new covering the prune-on-hot-reload path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prevent_key_leaks_in_exceptions CI check forbids '{args}' in
f-strings because it's a common shape for accidental API key leaks
in exception messages. _signature() uses an entirely local variable
named 'args' for tool-call arguments (loop-detection signatures, no
exception path), but the grep is substring-based. Rename to 'call_args'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
milan-berri and others added 18 commits April 22, 2026 23:22
- Dedupe names in add_guardrail_to_applied_guardrails_header (matches policies).
- Inline unified during_call condition so mypy narrows UserAPIKeyAuth.
- Extend bedrock guardrails test mock for logging_event_type.

Made-with: Cursor
Principle: GHA handles work that doesn't need external API keys; CCI
stays for integration tests that hit real API endpoints.

Four CCI jobs moved to new or extended GHA workflows:

1. check_code_and_doc_quality (was 25 runs: ruff + import-safety +
   21 code_coverage_tests + 3 documentation_tests + circular-imports).
   - The 21 tests/code_coverage_tests/*.py scripts and the 3
     tests/documentation_tests/*.py scripts run in the new
     .github/workflows/test-code-quality.yml workflow.
   - ruff, import-safety, and circular-imports were already run by
     .github/workflows/test-linting.yml — no new migration needed.
   - The 3 documentation_tests scripts read
     docs/my-website/docs/proxy/config_settings.md. Since docs have
     moved to BerriAI/litellm-docs, the GHA workflow checks out that
     repo and symlinks docs/my-website -> the checkout so the
     existing hardcoded paths resolve without touching the scripts.
     The stale local docs/my-website/ copy in this repo will be
     removed in a separate PR.

2. semgrep (custom-rule SAST against .semgrep/rules).
   - New .github/workflows/test-semgrep.yml.

3. installing_litellm_on_python + installing_litellm_on_python_3_13
   (pip install compat checks on Python 3.12 and 3.13).
   - New .github/workflows/test-install-litellm.yml as a matrix job.
   - 3.12 run also verifies litellm_enterprise import; 3.13 run
     skips that check (matches previous CCI behavior).
   - installing_litellm_on_python_v2_migration_resolver stays in CCI
     because it requires a postgres service.

CCI .circleci/config.yml: -112 lines, 4 jobs and their workflow refs
removed.
Replace the calibration step (one request + 10-minute poll) with an
independent ground truth computed from response usage via
litellm.cost_per_token. All N requests are made up front, so a single
dropped Redis write no longer kills the test.

Add /health/readiness checks at test start and on poll timeout so the
failure message surfaces proxy state (db, cache) instead of "calibration
timed out".

Set PROXY_BATCH_WRITE_AT=2 in the spend tracking CI job to shorten the
scheduler flush window.
store_in_memory_spend_updates_in_redis drained the in-memory queues
into local variables before the rpush pipeline. If rpush raised (cloud
Redis hiccup, timeout, connection blip), those already-drained
transactions were garbage-collected with the scheduler job, silently
losing all spend aggregated during that tick.

Wrap the rpush in try/except. On failure, re-enqueue the aggregated
transactions into their respective in-memory queues so the next
scheduler tick retries.

Add a unit test that seeds real queues, simulates an rpush failure,
and asserts the transactions land back in-memory.
Replace recursive `_walk` helper with a stack-based traversal so the
recursive_detector CI check passes without adding to the ignore list,
and avoid Python recursion limits on deeply nested payloads.

Made-with: Cursor
The daily queue parameter types on _restore_spend_updates_to_in_memory_queues
were narrowed to specific subtypes (DailyUserSpendTransaction, etc), but
the caller passes Dict[str, BaseDailySpendTransaction] — the return type
of flush_and_get_aggregated_daily_spend_update_transactions. Widen the
parameters to the base type.

Also replace dynamic TypedDict key lookup (which returned object) with
explicit literal-keyed get() calls so mypy can type-narrow each field.
…d_logging_reapply

fix(proxy): Bedrock guardrail spend logs - hook mode, match redaction, streaming request_data
[Infra] Migrate more CI jobs from CircleCI to GitHub Actions
[Fix] Stabilize flaky spend accuracy tests + patch Redis buffer data-loss path
fix(router): wildcard order fallback to higher-order deployments
… errors

Two changes, both test-only:

- Configure the aiohttp session with TCPConnector(force_close=True) and an
  explicit ClientTimeout(total=30, connect=10). Prevents reuse of idle TCP
  connections that the proxy/kernel may have closed during the long window
  between setup POSTs and the later poll loop, and surfaces a blocked proxy
  event loop quickly instead of hanging on aiohttp's 5-minute default.

- In poll_key_spend_until, catch aiohttp.ClientError and asyncio.TimeoutError
  around the single /key/info call. A transient transport hiccup now logs and
  retries on the next tick instead of failing the entire polling loop.

Addresses the ConnectionTimeoutError observed on the first /key/info call
after the 20 chat completions.
Track per-member total spend on team memberships
[Fix] Stabilize spend accuracy test transport flakes
@CLAassistant

CLAassistant commented Apr 23, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
6 out of 9 committers have signed the CLA.

✅ Sameerlite
✅ shivamrawat1
✅ yuneng-berri
✅ ryan-crabbe-berri
✅ michelligabriele
✅ milan-berri
❌ ishaan-berri
❌ krrish-berri-2
❌ shin-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed-hq

codspeed-hq Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing litellm_internal_staging (6a25866) with main (09cd7e3)

Open in CodSpeed

@yuneng-berri
yuneng-berri enabled auto-merge April 23, 2026 02:12

// ---- session ---------------------------------------------------------
function newSession() {
STATE.sessionId = "chat-" + Math.random().toString(36).slice(2, 10);

// ---- persistence -----------------------------------------------------
function ssGet(k) { try { return sessionStorage.getItem(k) || ""; } catch { return ""; } }
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch {} }
return d.toTimeString().slice(0, 8);
}
function ssGet(k) { try { return sessionStorage.getItem(k) || ""; } catch { return ""; } }
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch {} }
renderInfo();
renderGateStatus();

const thinkingBubble = appendThinking();
@yuneng-berri
yuneng-berri merged commit e9e86ed into main Apr 23, 2026
115 of 118 checks passed
@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR promotes the internal staging branch to main, landing a substantial set of changes: a new Adaptive Router strategy (Thompson-sampling bandit with per-session signal detection, DB persistence, and an admin introspection endpoint), a total_spend column on LiteLLM_TeamMembership, Bedrock guardrail fixes for correct pre/during/post-call spend attribution, Redis spend-tracking retry-on-failure recovery, and a number of infrastructure/CI improvements.

The new feature is well-tested with comprehensive mocked unit and e2e tests, and the DB migrations are additive. All remaining findings are P2 quality/correctness suggestions.

Confidence Score: 5/5

Safe to merge; all findings are P2 quality/hardening suggestions that do not block correctness on the happy path.

No P0/P1 issues found. The adaptive router, bandit logic, DB migrations, and guardrail refactoring are well-scoped. The three P2 findings (overly broad redaction in base guardrail class, _state_loaded prematurely set on DB unavailability at startup, unreferenced asyncio task) are edge-case degradations rather than bugs on the primary request path.

litellm/integrations/custom_guardrail.py (broad redact scope), litellm/proxy/proxy_server.py (_state_loaded + task reference), litellm/router_strategy/adaptive_router/adaptive_router.py (min_quality_tier ValueError)

Important Files Changed

Filename Overview
litellm/router_strategy/adaptive_router/adaptive_router.py New AdaptiveRouter class: Thompson-sampling bandit with owner-cache and session-state management. Well-structured, asyncio-safe (no yields between cell read+write), comprehensive tests. Minor: no graceful fallback when min_quality_tier eliminates all models.
litellm/router_strategy/adaptive_router/bandit.py New Thompson-sampling bandit utilities. pick_best correctly builds costs from the same eligible set as cells. apply_delta respects SAMPLE_CAP. Normalization handles zero-spread edge case.
litellm/router_strategy/adaptive_router/hooks.py New post-call hook. Session key derivation, tool-result extraction, and logical-model lookup from metadata are clean. Exception swallowing prevents signal failures from breaking requests.
litellm/router_strategy/adaptive_router/update_queue.py In-memory aggregator queue for state and session upserts. Lock usage is correct; last-write-wins session semantics properly handle concurrent flushers via atomic upsert.
litellm/proxy/proxy_server.py Adds adaptive-router flusher loop and /adaptive_router/state admin endpoint. Two issues: _state_loaded set True even when prisma_client is None, and asyncio.create_task result not stored.
litellm/integrations/custom_guardrail.py Adds use_native_during_call_hook ClassVar and applies redact_nested_match_and_regex_keys globally to all guardrail log payloads. Overly broad redaction could affect non-Bedrock guardrails.
litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py Refactors _redact_pii_matches to delegate to shared utility, adds logging_event_type parameter to fix pre/during/post_call attribution in spend logs, and sets use_native_during_call_hook=True on BedrockGuardrail.
litellm/proxy/db/db_transaction_queue/redis_update_buffer.py Adds Redis rpush failure recovery by restoring drained transactions back to in-memory queues. Correctly handles both DBSpendUpdateTransactions entities and daily spend transaction pairs.
litellm-proxy-extras/litellm_proxy_extras/migrations/20260418000000_add_adaptive_router_tables/migration.sql New migration adds LiteLLM_AdaptiveRouterState and LiteLLM_AdaptiveRouterSession tables with appropriate indexes. Schema is additive, correctly documented.
litellm/router.py Integrates adaptive router into Router lifecycle: deferred finalization after all deployments are registered, hook cleanup on reload, and pre-routing dispatch. Wildcard-aware deployment lookup fix included.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant PS as ProxyServer
    participant R as Router
    participant AR as AdaptiveRouter
    participant BN as Bandit
    participant H as PostCallHook
    participant Q as UpdateQueue
    participant FL as FlusherLoop
    participant DB as Postgres

    PS->>PS: startup: load_state_from_db
    DB-->>AR: persisted BanditCells
    PS->>PS: create_task(flusher_loop)

    C->>PS: POST /chat/completions
    PS->>R: async_pre_routing_hook(model)
    R->>AR: async_pre_routing_hook
    AR->>BN: pick_best(cells, costs)
    BN-->>AR: chosen_model
    AR-->>R: PreRoutingHookResponse(model=chosen)
    R-->>PS: route to chosen_model

    PS-->>C: response + x-litellm-adaptive-router-model header

    note over H: async_log_success_event
    H->>H: _resolve_session_key
    H->>AR: claim_or_check_owner
    AR-->>H: True/False
    H->>AR: record_turn(session_id, model, rt, turn)
    AR->>AR: apply_turn(session_state)
    AR->>Q: add_session_state(snapshot)
    AR->>AR: update _cells[cell_key]
    AR->>Q: add_state_delta(delta)

    loop every 10s
        FL->>Q: flush_state_to_db(prisma)
        Q->>DB: upsert LiteLLM_AdaptiveRouterState
        FL->>Q: flush_session_to_db(prisma)
        Q->>DB: upsert LiteLLM_AdaptiveRouterSession
    end
Loading

Reviews (1): Last reviewed commit: "Merge pull request #26295 from BerriAI/y..." | Re-trigger Greptile

Comment on lines +645 to +649
# Default-safe behavior: never persist raw matched spans in standard
# guardrail logging payloads (single shared implementation; Bedrock hooks pass
# raw provider JSON so redaction is not duplicated upstream).
clean_guardrail_response = redact_nested_match_and_regex_keys(
clean_guardrail_response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Overly broad redaction may affect non-Bedrock guardrail logs

redact_nested_match_and_regex_keys is now applied to clean_guardrail_response in the shared base class path, meaning it silently replaces any key named match or regex (at any nesting depth) across ALL guardrail provider responses — not just Bedrock PII assessments. If a custom guardrail returns a response dict where match or regex is a semantically meaningful, non-PII field (e.g., a regex pattern for an allow-list, a match count from a semantic similarity check), that data is permanently lost from the standard logging payload without warning. The original _redact_pii_matches was intentionally scoped to the specific Bedrock sensitiveInformationPolicy / wordPolicy paths for this reason.

Comment on lines +956 to +962
# may be added later via `/config/reload`, and the flusher is a no-op when
# `llm_router.adaptive_routers` is empty. Per-router DB state is loaded
# lazily by the flusher on first tick (see `_state_loaded` flag) so
# hot-reloaded routers also get their persisted priors.
if llm_router is not None and getattr(llm_router, "adaptive_routers", None):
for _ar in llm_router.adaptive_routers.values():
await _ar.load_state_from_db(prisma_client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _state_loaded set to True even when prisma_client is None

load_state_from_db silently returns early when prisma_client is None, but _ar._state_loaded = True is unconditionally set immediately after. If the DB connection is not yet established at startup time, the adaptive router starts with cold-start priors and the flusher's lazy-load path will never retry:

if not getattr(ar, "_state_loaded", False):   # already True → skipped
    await ar.load_state_from_db(prisma_client)

Any persisted bandit priors from a previous deployment are permanently ignored. Consider only setting _state_loaded = True when prisma_client is not None, so the flusher's lazy-load path can still hydrate state once the DB becomes available.

if llm_router is not None and getattr(llm_router, "adaptive_routers", None):
for _ar in llm_router.adaptive_routers.values():
await _ar.load_state_from_db(prisma_client)
_ar._state_loaded = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Background task reference not retained

asyncio.create_task(...) returns a Task object that should be stored to prevent accidental garbage collection. While CPython's event loop holds a strong reference to all running tasks, the reference should still be stored (PEP guidance + avoids subtle bugs on other implementations and during shutdown):

_adaptive_router_flusher_task = asyncio.create_task(_adaptive_router_flusher_loop())

The other background health-check uses asyncio.ensure_future(...) without storing the result as well, but adding a reference here avoids the antipattern for the new code.

Comment on lines +157 to +164

Classifies the last user message, picks a logical model via the bandit,
and stashes the chosen model on `request_kwargs["metadata"]` so the
post-call hook can surface it as a response header.

Routing is stateless per-turn: every call Thompson-samples fresh,
regardless of any prior pick for the same session. Cross-turn
attribution is enforced post-call via the owner cache (see

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 pick_model raises unguarded ValueError on misconfigured min_quality_tier

If a caller passes a min_quality_tier value that is higher than every model's tier (e.g., min_quality_tier=3 when all models are tier 1/2), _eligible_models returns [] and pick_model raises:

ValueError: AdaptiveRouter[…]: no models meet min_quality_tier=…

This exception would propagate up through async_pre_routing_hook and surface as a 500 error to the end user. Consider falling back to the highest-tier eligible model (or the full model set) with a warning log rather than raising, so a misconfigured header doesn't take down traffic.

fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.