The Prisma query engine is a separate Rust process whose resident memory is
a high-water mark: it grows with the payload of the largest single statement
it executes and glibc never returns that memory to the OS, so a pod's memory
floor ratchets up to its worst-ever write and stays there for the life of
the worker. Memory-based autoscaling then reads a number that reflects the
largest write the pod has ever done rather than what it is doing now.
The spend-log flush handed Prisma a fixed 1000 rows per create_many. With
store_prompts_in_spend_logs enabled a single row carries the full prompt and
response, so one statement can be tens of megabytes and permanently costs
hundreds of megabytes of RSS. Row counts cannot express that budget: the
same 1000 rows range from well under a megabyte to tens of megabytes.
Split each flush into statements bounded by encoded payload size
(SPEND_LOG_WRITE_BATCH_MAX_BYTES, default 2MB) on top of the existing
1000-row cap. What is measured is the encoded statement, so the budget
counts what actually goes on the wire: the JSON escaping of quotes and
newlines, multibyte characters at their encoded width, the field names and
separators a 25-column row carries, and the brackets and row separators the
rows carry as one collection. Deployments that do not store prompts keep one
statement per 1000 rows and are unaffected; prompt-carrying flushes get
several small statements instead of one huge one. A row larger than the
budget is still written on its own rather than dropped, and a row the
serializer refuses counts as zero rather than raising out of the flush and
dropping every row queued behind it.
Splitting a flush must not multiply what a poison-row flood costs, so the
poison-isolation allowance is threaded through every statement of a 1000-row
group instead of being handed out fresh per statement. That is only safe
because the allowance now counts failed inserts rather than every insert:
the one insert a statement needs when nothing is poisoned is not charged, so
a healthy flush never runs the allowance down however many statements it
splits into, and a statement reached after the allowance is spent is still
attempted so clean rows behind a flood still persist. Failed inserts for a
group are bounded by the allowance plus one baseline insert per statement,
which restores the constant-per-group ceiling the single-statement path had.
Resolves LIT-4765
A failing deployment stamps its own litellm_params.num_retries onto the raised
exception, and async_function_with_retries adopted that value unconditionally. So a
model_list num_retries outranked both the x-litellm-num-retries header and the request
body, inverting the documented precedence to model_list > header > body >
litellm_settings.
The router could not tell a request-level value from its own default because the entry
points filled num_retries in with self.num_retries whenever the caller omitted it,
collapsing "the request asked for N" and "nobody asked". Drop that pre-fill from
_update_kwargs_before_fallbacks and from the six entry points that also did it a line
above their own call to it (image generation sync and async, adapter completion, file
create, batch create, batch cancel), all of which reach async_function_with_retries,
where the router/global default is already resolved. Leaving them would have made the
request value never None on those routes and permanently suppressed a deployment
num_retries there.
The sync text_completion pre-fill stays. That path resolves a deployment and calls
litellm.text_completion directly, never entering the retry loop, so no request-versus-
deployment ranking happens there and there is nothing to fix; removing the line would
only change which value is forwarded to litellm.text_completion, a behaviour change this
bug does not call for.
async_function_with_retries then adopts the deployment's value only when the request
carried none. Precedence is now header > body > model_list > litellm_settings, with the
deployment value still beating litellm_settings when the request is silent, on every
entry point that retries.
Resolves LIT-4772
The litellm-helm proxy Deployment renders a pod-level securityContext from
.Values.podSecurityContext, but the Prisma migration Job rendered only the
container-level securityContext from .Values.securityContext. Clusters that
enforce pod-level admission policies (OPA Gatekeeper K8sPSPAllowedUsers, or a
PSP-style fsGroup MustRunAs rule) therefore admitted the Deployment and denied
the Job, which blocks install and upgrade because the Job runs as an ArgoCD
PreSync or Helm pre-install/pre-upgrade hook.
The Job now renders the same pod-level securityContext the Deployment does.
Charts that leave podSecurityContext unset render an empty securityContext,
matching what the Deployment already emitted, so default installs are unchanged.
Resolves LIT-4928
The public A2A guide tells users to declare agents under a top-level
`agents:` key, but the proxy only ever read `agent_list:`, so the
documented config was silently ignored and GET /v1/agents returned an
empty list. Accept `agents` as the documented spelling and keep
`agent_list` working for anyone who found it by reading the source.
Selection is by key presence, so an explicitly empty `agents: []` is not
overridden by leftover legacy entries.
Config-defined agents were also dropped on any database-backed gateway:
the periodic reload rebuilt the registry from the DB rows plus a module
global that was declared and never assigned. The registry now remembers
the agents it loaded from config.yaml and replays them on every rebuild.
A database row wins a name collision, mirroring how config-declared MCP
servers are unioned under the database registry, so name lookups and
deregistration keep addressing exactly one agent.
Resolves LIT-4978
OpenAI cut Terra 20% and Luna 80% on 2026-07-30; openai and bedrock_mantle
entries already match. Azure global and us/eu data-zone terra/luna rows still
used the pre-cut rates, so spend tracking over-billed those Azure deployments.
Sol is unchanged. Cache-read, priority, and long-context fields scale with the
same multipliers already used for azure gpt-5.6.
The unit workflows trigger on pull_request only, so a commit that
actually lands on a gated branch ends up with no unit-test check runs at
all. The commit status API reports success for those commits, which a
release gate reads as "nothing failed" rather than "never tested". PR
checks also only ever ran against the merge preview, not the commit that
landed, so two branches that are each green can still land broken
together
Add a push trigger on the two gated branches to the twelve unit
workflows and to the code-quality workflow, so every landed commit gets
check runs addressable by its SHA
test-linting.yml is deliberately left on pull_request only; six of its
steps gate on a diff against github.event.pull_request.base.sha, which
is empty outside a pull request, and a "what did this branch add" check
has no meaning on a merge commit
Also key the concurrency group on github.sha and restrict
cancel-in-progress to pull_request. The previous group was stable across
pushes to a branch, so consecutive merges would cancel the in-flight run
for the earlier commit and leave that SHA without a result, which is the
same blind spot this change is meant to close
Aligns the fix with the constraints in LIT-4800. A zero-increment
counter now blocks at current >= limit, matching RPM's semantics; the
previous current > limit let a pool sitting exactly at its reservation
admit one extra request. reserve_tpm_tokens rebuilds its descriptors
with only tokens_per_unit so the requests dimension stays out of the
reservation pass, which deliberately leaves RPM to the separate
should_rate_limit check.
* feat(prometheus): add global exclude_metrics and exclude_labels options
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(prometheus): apply global exclude_labels to hard-coded metric labels
Metrics built with hard-coded labelnames lists (guardrail, provider budget,
callback, managed file/batch, batch cost) bypassed prometheus_exclude_labels
because only labels resolved via get_labels_for_metric were filtered. Route
every metric through a factory that strips excluded labels at construction and
proxies labels() so excluded labels are dropped at emission too. Add the
non-enum hard-coded labels (guardrail_name, status, error_type, hook_type,
purpose, file_type, result) to exclude-config validation so they are accepted
instead of raising ValueError at logger init.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(prometheus): simplify exclude-label factory to the kwargs labelnames path
All metric definitions pass labelnames as a keyword argument, and the only
metrics that pass it positionally resolve their labels through
get_labels_for_metric, which already drops excluded labels, so they never carry
an excluded label into the factory. Drop the unreachable positional
reconstruction branch.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: re-trigger CI (flaky unrelated bedrock agentcore test)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(prometheus): use immutable constructions to satisfy LIT002 budget
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: shivam <shivam@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Keys created with key_type=llm_api get allowed_routes=["llm_api_routes"],
which covered /v1/models but not /v1/model/info, so a client could list model
names but not read pricing, mode, or max_tokens without a second key.
Adds both /model/info and /v1/model/info (same handler) to llm_api_routes only.
Membership there is not the same as RouteChecks.is_llm_api_route(), which is
what gates DISABLE_LLM_API_ENDPOINTS, global/virtual-key budget enforcement,
enforce_user_param and JWT team attachment; /guardrails/apply_guardrail already
sits in the group the same way. /v2/model/info stays out: it is the paginated
Admin UI listing, not model metadata a caller needs at request time.
public_routes moves from set([...]) to a frozenset literal to keep the LIT002
and ruff-strict ceilings from rising; both budgets ratchet down by one.
A chat tool-call dict was classified custom-vs-function with four different
spellings: the non-streaming parser required type == "custom", the streaming
Delta coercion also accepted a custom payload without type, and the stream
assembler required a type that later chunks never carry. The same payload could
be a custom tool call mid-stream, a TypeError on the completed message, and
silently dropped from the assembled message. is_custom_tool_call_dict() is now
the single discriminator (explicit custom type, or a custom payload present)
used by both parsers, and the assembler classifies from the accumulated custom
payload, matching how the deltas it consumes were classified.
The tool envelope converter picked one exclusive payload source: the nested
dict when present, else the top level. An empty nested envelope therefore
shadowed top-level fields and the normalized tool lost its name. Payload
extraction is now total over both locations, nested first, and an envelope
with no name anywhere passes through unchanged instead of being emitted
stripped.
The gpt-5.4+ responses-bridge gate classified the endpoint from the call-level
api_base alone, while the OpenAI chat handler resolves arg > global > env >
default. A custom base configured via litellm.api_base or OPENAI_BASE_URL/
OPENAI_API_BASE was therefore invisible to the gate: it read blank as the
default OpenAI endpoint and bridged a request the custom backend has no
/responses route for.
Extract that resolution into one _resolve_openai_api_base() and have both the
gate and _complete_custom_openai() call it, so the gate can never classify an
endpoint the request won't hit. The gate compares the resolved base against the
default (import litellm seeds OPENAI_BASE_URL to the default, so "override is
non-None" is not a safe custom-endpoint signal); whitespace collapses to the
default as before. reasoning_effort="none" remains the escape hatch.
Chat Completions nests a named tool_choice under its tool type while the
Responses API keeps it flat; ChatCompletionNamedToolChoiceParam and
ChatCompletionNamedToolChoiceCustomParam both mark the nested key required. The
messages arm normalized tool definitions but forwarded tool_choice at whatever
level Cursor sent it, so a flat {"type": "custom", "name": "ApplyPatch"} reached
OpenAI unchanged and was rejected while the tool defs beside it nested correctly
A tool definition and a named tool_choice carry the same envelope, so both now
convert through a single _convert_tool_envelope, and _normalize_tool_dialect
moves tools and tool_choice together on each arm. That covers all four cells of
{tool def, tool_choice} x {to chat, to responses} and removes the shape where
one field can be converted while the other is missed, replacing three helpers
with two and cutting 24 lines
Also restores the end-to-end assertion that a flat tool_choice reaches
chat_completion nested, which had been flipped to pin the passthrough behavior
- share one _CustomToolCallAccess mixin across the 4 new custom-tool
classes instead of hand-rolling dict access on each
- inline the single-use _nest_flat_chat_tools / _flatten_chat_tools_for_responses
list wrappers at their call sites
- drop _nest_flat_chat_tool_choice: it rewrote object-form chat tool_choice
into {type,custom:{name}}, a shape OpenAI rejects; real Cursor never sends
tool_choice on the messages arm, so pass it through unchanged
A blank api_base (empty or whitespace) resolves to the default OpenAI
base downstream but is not None, so the constraint-enforcing-endpoint
check misclassified it as a custom backend and skipped the unset-effort
auto-bridge, leaving gpt-5.4+ function-tool requests to 400 at OpenAI.
The check now treats None, empty, and whitespace api_base alike; a real
custom base still opts out. Verified with get_llm_provider, which passes
a blank api_base through while resolving the provider to openai
Chat-only OpenAI-compatible backends registered under the openai
provider with custom api_base and gpt-5.4+ model names served
tools-without-reasoning fine and have no /responses route, so the
unset-effort arm added for real OpenAI would have silently rerouted
previously working deployments. The arm now fires only when api_base is
unset (default OpenAI endpoint) or the provider is azure; an explicit
reasoning_effort keeps its pre-existing bridging behavior on any
api_base. Flagged lines also modernized to PEP 604
An import probe proves nothing about the real SDK: it may be absent (it
lives in the proxy-runtime extra) and the tests/test_litellm/llms/anthropic
test package can shadow it once collection puts that path on sys.path,
which made the test order-sensitive across collection sets
Three gaps from the bridge becoming a mainstream path for chat traffic.
The chat to responses message converter only mapped function tool_calls,
so history carrying the native custom tool calls this PR introduced
raised "tool call not supported" on follow-up turns; custom entries now
map to custom_tool_call items and their results to
custom_tool_call_output. The stream translator returned an empty delta
for output_item.done on tool items, which left the responses guardrail
handler's tool extraction permanently empty (dead on staging too, where
the built chunk was discarded); stateless callers now receive the
complete tool call while per-stream callers keep the suppressed delta
that prevents client-side duplication. Cursor routing keyed on the
presence of a messages key, so a null or empty stub next to a real
agent-mode input array picked the chat arm; routing now keys on
messages content
The bridge gate compared reasoning_effort against the string "none", so
litellm's dict form ({"effort": "none"}) wrongly bridged; the gate now
reads the effort value from either form and treats a summary inside the
dict as Responses-only regardless of effort. Helicone and lunary
previously skipped custom tool calls entirely; both now serialize them
(helicone as a tool_use block from the custom payload, lunary with the
custom name and input in its function fields, keeping type custom), with
new mapped tests for both integrations
OpenAI's chat completions rejection applies to function tools only;
custom (grammar) tools are served natively with reasoning on, live-proven
by a 200 on a custom-only gpt-5.6 chat request. Gating on any truthy
tools needlessly bridged custom-only requests, and the bridge maps custom
tool calls back function-shaped, so the native chat custom tool_call
surface added earlier in this PR was bypassed exactly where chat serves
it natively. The gate now checks for a function-type tool in either the
nested chat or flat Responses def shape; the same coarseness existed on
the explicit-effort arm before this PR and is fixed by the shared leg
OpenAI enables reasoning by default for gpt-5.4+ (unset reasoning_effort
means medium server-side) and Chat Completions rejects function tools
whenever reasoning is on, so a tools request without an explicit
reasoning_effort 400d instead of auto-bridging to the Responses API; the
bridge heuristic now treats unset effort as reasoning-active and honors
the documented escape hatch by keeping explicit "none" on chat
completions. The cursor input arm also gains the mirror of the
messages-arm normalization: chat-nested tool envelopes, grammar formats,
and object tool_choice flatten to the Responses dialect before dispatch
Live Cursor Ask-mode captures show the shape dialects mix PER LEVEL: the
tool envelope arrives chat-nested while the grammar format inside it is
still Responses-flat, so a normalizer that pattern-matches whole-tool
templates misses every hybrid. The cursor arm now normalizes the envelope
level and the format level independently and idempotently, making it
total over the envelope x format matrix; a parametrized 8-cell test pins
every combination. The reference BYOK bridge was checked and forwards
chat bodies verbatim, so there is no prior art for these hybrids
Cursor's ApplyPatch is a grammar-constrained custom tool; the Responses
surface carries the grammar flat while chat completions wraps the same
fields in a grammar object, so the nested envelope from the previous
commit still 400d at OpenAI (tools[N].custom.format.grammar). Adds a
shared flat to nested format helper pair in prompt_templates/common_utils
used by the cursor messages arm and the chat-to-responses bridge, nests
flat Responses-style tool_choice objects on the cursor arm, flattens chat
custom tool_choice on the chat-to-responses bridge, and maps custom
tool_choice to function tool_choice on the responses-to-chat bridge to
match that bridge's custom-to-function tool downgrade
Cursor Ask mode sends chat bodies whose tools array mixes nested function
tools with flat Responses-style custom tools; the /cursor messages arm now
nests those before delegating, published via the request parsed-body cache.
Core chat parsing gains first-class custom tool call types mirroring the
openai SDK union: a single dict dispatch feeds the provider-dict sinks,
Delta dispatch stops both stream re-parse sites from silently swallowing
custom deltas, the chunk builder accumulates custom input for spend logs,
function-assuming consumers (json-mode gate, multi_tool_use repair,
helicone, lunary) skip custom entries, and the chat-to-responses bridge
flattens nested custom tools to the Responses flat shape
- delegate messages-shaped bodies to the standard chat completions handler
- strip chat-only stream_options before the Responses pipeline
- fix cursor_data_generator signature (request kwarg) and duck-type the
stream gate so router-wrapped streams convert instead of leaking raw
Responses events
- convert custom_tool_call items and events to chat tool_calls in the
streaming and non-streaming paths; remap streamed tool_call indices to
0-based sequential; accumulate raw and pydantic tool calls into one choice
- normalize generic pydantic output items through the raw-dict handler
Both fields select from a server-side search over existing accounts, so a
typed-in address or id never becomes a value. Say so up front rather than
letting the form look like it accepts a new user and fail on submit.
Applies to the organization member modal too, which shares this component.
Replace Any-typed seams with real types in the files carrying the highest
reportAny/reportExplicitAny density. The dominant source was the repository
layer: BaseRepository.table is declared Any, so every repository read poisoned
its rows and every downstream call. Typed pass-through accessors under a
_PrismaTableActions Protocol pay that crossing once per table, and TypedDicts
and Protocols replace the remaining Any-typed request, row, and tool payloads
across the team, key, SCIM, spend, MCP, guardrail, video, and websearch
surfaces
No casts, no type: ignore, no noqa, no new Any annotations, no behavior
changes. Whole-tree basedpyright: reportAny 20,840 -> 19,397,
reportExplicitAny 7,253 -> 6,518, all rules 151,424 -> 149,066, with no rule
increased in any file. Budgets ratcheted: basedpyright -2,358, ruff-strict
-300, type-discipline -68
add_deployment already reapplies DB router settings through _update_llm_router,
so gating router_settings out of the pub/sub publish set left the push path
covering less than the resync actually applies
Resolve the requested member user_ids with a single find_many instead of one
lookup per member, so a large member list no longer turns into that many
round-trips before the permission check runs. Write the member-add audit
entries concurrently rather than one after another, and list at most a few
ids in the rejection message instead of echoing the whole request back.
Update the team-admin member-add case that covered adding a user_id with no
user row, which the endpoint now leaves to proxy admins.
Caps fleet-wide reload rate at one resync per 10s per pod so a burst of
authenticated writes cannot amplify into continuous cross-pod reloads, and
skips publishing config params (environment_variables, router_settings) that
no resync callback applies outside proxy startup