Commit graph

55316 commits

Author SHA1 Message Date
Mateo Wang
94e4ebd63d
test(proxy): pin the max_parallel_requests slot release when a streamed /v1/messages MCP auto-execute ends (#47788)
* test(proxy): cover the max_parallel_requests slot a streamed /v1/messages MCP auto-execute keeps

A streamed /v1/messages request that auto-executes MCP tools replays its answer
as a stream after its model rounds already logged success. The stream path frees
the request's max_parallel_requests slot only on a client disconnect, so after
message_stop the slot stays taken and the key's next request answers 429

The test streams such a replay to the end and expects the slot to be free. It
fails until the fix lands

* fix(proxy): free the max_parallel_requests slot when a replayed /v1/messages MCP stream completes

A streamed /v1/messages request that auto-executes MCP tools runs its model
rounds without streaming and replays the final answer as a stream. The first
round claims the request's only success log, and its limiter callback keeps the
slot because the tool call reused it afterwards. The stream path released the
slot only on a client disconnect, so after message_stop the key stayed at its
max_parallel_requests limit and its next request answered 429

Stream cleanup now releases the stashed slot when the stream completed and its
logging object already claimed the success log, which only happens when the
stream replays a response that was logged before it started. A real stream
still releases through its own success callback

* test(mcp): free the key's only parallel slot after a streamed auto-execute ends

Run a streamed MCP auto-execute on a key with max_parallel_requests 1 on
every LLM surface, then send the next request on that key and expect 200.
On main both /v1/messages surfaces answer 429 here, while /v1/responses and
/v1/chat/completions already pass

* test(proxy): keep only the MCP stream regression tests, since #48628 already frees the slot at stream end

* test(proxy): require the MCP stream regression test's response to stream and reach its end event
2026-10-28 02:47:27 -07:00
zoveki
d8a43a7cd3
test(gemini): audit #47542 (#48758)
* test(gemini): audit #47542

Proxy integration cells for the Gemini and Vertex default output budget: the env budget reaches Google on chat and responses, turns on long decode, yields to the caller's own limit, caps per deployment, skips resumes, and a camelCase two-candidate request is never continued

* test(gemini): annotate the budget wire module's pytestmark as Final

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 02:42:45 -07:00
rj-cairn
000d4f6bb3
test(sagemaker): audit #47495 (#48767)
Checks that a SageMaker stream failing after its first chunks reaches /v1/chat/completions, /v1/messages and /v1/responses callers as an error with no success end event, and that each writes a failed spend log row

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 02:40:45 -07:00
norkelbyte
1176e608b1
test(harness): audit #47530 (#48751)
Check that cancelling a LocalSandbox run stops the grandchild process it started, using a real child process

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 01:57:41 -07:00
zoveki
220a70653e
docs(readme): link the LiteLLM OSS Champions program from Contributing (#48734)
* docs(readme): link the LiteLLM OSS Champions program from Contributing

* docs(readme): frame the champions perks the way the program page does

* docs(readme): drop the champions swag claim the page gates on merged PRs

* docs(readme): word the champions line as the invitation the page makes

* docs(readme): invite readers to the champions page without promising its perks

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 01:38:07 -07:00
Shivam Rawat
f1795e6d44
fix(responses): keep every MCP tool round in streaming follow-up input (#47698)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
2026-10-28 00:51:10 -07:00
Mateo Wang
6fe0263761
fix(ui): prefill the key edit Organization picker from org_id (#39535)
* fix(ui): prefill the key edit Organization picker from org_id

The Virtual Keys table hands KeyEditView rows from /key/list, which
serializes the organization as org_id, while /key/info calls the same
field organization_id. The edit form read only organization_id, so on a
key opened from the list the Organization picker fell back to its "All
Organizations" placeholder even though the key belongs to an org.

Read both field names through one keyOrganizationId helper, used by the
form value mapper, the edit view's selected organization state, and the
two places in key_info_view that already hand-rolled the same fallback.

* fix(ui): keep the key edit team filter in sync with the organization field

* fix(ui): drop the leftover organization setter calls in the key edit team handler

The previous commit derived the organization from the form and removed the
useState behind it, but handleTeamChange still called the deleted setter, so
the dashboard build failed to type check and picking a team with an
organization threw at runtime.

The form.setValue calls next to them already carry the organization, so the
setter calls only had to go. Adds a test that picks a team with an
organization and asserts the form adopts and submits it.

* fix(ui): drop the list row's stale org_id when a key save returns its organization

* fix(ui): send the key's organization only when the edit form changes it

An untouched save from the key edit form now leaves organization_id out
of the /key/update body, the same way the form already drops unchanged
gated fields. This stops a save from writing a team's fallback
organization onto a key that has none, and keeps the audit log free of
no-op organization updates. The organization tests now use typed inputs

* fix(ui): open team page keys with their own organization in the edit form

* fix(ui): keep every team in the key form's team list for users who cannot change the organization

---------

Co-authored-by: mateo-berri <edgetonmas@gmail.com>
2026-10-28 00:26:56 -07:00
ryan-crabbe-berri
e32d8a4d03
fix(responses): reject or drop Responses params the chat completions bridge cannot translate (#48655)
* fix(responses): reject or drop Responses params the chat completions bridge cannot translate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep additional_drop_params working for Responses fields the bridge cannot translate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Update litellm/responses/main.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* style(responses): drop the blank line ruff format rejects after the dataclass header

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-28 07:11:55 +00:00
ryan-crabbe-berri
df9091deb6
fix(responses): skip failed spend-log turns when rebuilding a session from previous_response_id (#48647)
* fix(responses): skip failed spend-log turns when rebuilding a session from previous_response_id

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): guard the failed-turn status read and annotate the regression test locals

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-28 06:47:42 +00:00
tin-berri
c7c895db45
fix(proxy)!: paginate customer lists and usage filters (#48700)
Some checks are pending
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
2026-10-27 19:46:06 -07:00
Kerry Lu
7acdf9f00d
fix(anthropic): count search results and reject unsupported token-count blocks (#48699)
* fix(anthropic): count search results and reject unsupported token-count blocks

Co-authored-by: Prasanna Jain <prassu017@gmail.com>

* fix(anthropic): avoid a Final assignment inside the token-count loop

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Prasanna Jain <prassu017@gmail.com>
2026-10-28 02:33:38 +00:00
Praising Harris Ratnam
e4832c5d06
fix(cohere): map string stop parameter to list of strings for stop_sequences (#47091)
Fixes #46977: Cohere chat API reference expects stop_sequences to be a list of strings. Convert string stop parameter to a single-element list for CohereChatConfig and CohereV2ChatConfig while preserving lists.

Co-authored-by: Praising Harris Ratnam <praisingharris@gmail.com>
2026-10-27 19:01:54 -07:00
devin-ai-integration[bot]
1171273511
fix(langfuse otel): emit request metadata under langfuse.observation.metadata and langfuse.trace.metadata on the OTEL v2 Langfuse mapper (#42697)
* fix(langfuse_otel): emit request metadata under langfuse.observation.metadata and langfuse.trace.metadata keys

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): name trace metadata identity keys after their request metadata fields

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): type metadata helper and drop null fields so they cannot clobber caller trace metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): annotate new request metadata tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): build rig config and wire batches immutably

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): skip empty trace identity values so teamless requests cannot erase attribution

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): drain wire batches per poll instead of accumulating

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): cover null stripping and empty trace identities

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): set redaction flag directly instead of monkeypatching

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): add integration audit matrix for langfuse metadata keys

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): tighten audit chaos and unauthenticated cells

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): restore contracts.json formatting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): close SDK clients, pin worker identity, and normalize response id matching

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): gate the slow sink reply instead of sleeping

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel v2): emit request metadata and identity under langfuse.observation.metadata and langfuse.trace.metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel v2): satisfy type discipline gate for metadata export

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: drop prohibited covers markers from langfuse otel integration tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): put the mutable-ok suppression on the dict construction line the type-discipline gate reports

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse otel): share request metadata export across callbacks

* fix(langfuse otel): drop prompt variables, MCP tool I/O and vector store queries from exported request metadata

* fix(langfuse_otel): keep span export alive when nested request metadata has non-string keys

The content strip validated prompt_management_metadata, mcp_tool_call_metadata
and vector_store_request_metadata items as str-keyed mappings, so an SDK
caller passing one with a non-string key raised inside
exported_request_metadata: OTEL v2 dropped the LLM span for every vendor and
v1 langfuse_otel lost the request metadata and every attribute after it.
Validate those nested mappings with object keys instead. Also hoists the
langfuse_otel imports.

* fix(langfuse_otel): send the request metadata blob to Langfuse destinations only

- A requester_metadata that is not a mapping no longer raises. The v2 LLM span
  (every vendor) and the v1 Langfuse attributes survive it again.
- Prompt-quoting routing_decision fields stay out of langfuse.observation.metadata.
- An exporter or key/team destination for any other backend drops
  langfuse.observation.metadata and langfuse.trace.metadata.user_api_key_*, as on
  main. This covers a collector folded into the Langfuse logger.
- The v2 Langfuse key-set pin in the arize family test gains the two new keys.

* fix(langfuse_otel): export only caller identity under the Langfuse metadata keys

- The Langfuse metadata keys carried the whole request metadata. On OTel v2 that
  sent request headers, caller metadata, user email, user agent and auth metadata
  to a Langfuse destination that never received them on main; on v1 it doubled
  each span's metadata payload, so big caller metadata doubled the export size
- Both paths now export only the key alias, user, end user, team, team alias and
  org. On v2 that is the team plus the identity among the operator's
  `baggage_metadata_keys`, which v2 spans already carry as `litellm.metadata.*`.
  Everything else stays in the bare `metadata` attribute, as on main
- `output_messages` returns a list again, and the arize v2 b3 key pin is back to
  main's set

* fix(langfuse_otel): drop the caller identity first when a span hits its attribute limit

v1 wrote the identity attributes after the OpenInference ones, so a span near the
128-attribute limit (about 33+ tools) evicted openinference.span.kind, metadata
and llm.model_name instead. The identity now goes first on a fresh span, so the
SDK evicts it before anything else, and a caller's active span that already
carries attributes does not get it.

v2 sheds the Langfuse identity keys before any indexed message attribute when a
span is over budget, so a span keeps every attribute it carried without them.

* fix(langfuse_otel): keep other spans and dropped counts as main exports them

- v1 writes the caller identity only into the room the span's attribute
  limit leaves next to the error attributes a failed call adds, so the
  exported dropped-attribute count matches main
- v2 strips the Langfuse request metadata only from LiteLLM's own spans;
  application, instrumentation and server spans keep every key they set
- a stripped v2 span keeps the dropped attribute, event and link counts
- a config that lists the Langfuse mapper without the langfuse_otel
  exporter also keeps the request metadata away from other exporters

* fix(langfuse_otel): keep the caller identity off the caller's active span

v1 writes LiteLLM's attributes onto the caller's active span when one is
current instead of starting its own span. That span goes to the caller's
own exporter, so the identity keys reached a non-Langfuse collector and
could take attribute slots the caller fills later. The identity keys now
go only on the span LiteLLM starts, whatever the caller's span carries.

* fix(langfuse otel): keep the identity keys on the OTEL v2 Langfuse mapper only

The v1 langfuse_otel callback goes back to main: it sends no
langfuse.observation.metadata or langfuse.trace.metadata.user_api_key_*
keys. The OTEL v2 Langfuse mapper keeps them. The integration cells now
run every endpoint, client, redaction, failure and chaos case on v2.

* test(langfuse otel): count only accepted batches in the sink outage cell

The wire also records the batches the sink answered 503, so an exporter
retry made a span look exported twice. The chaos cells now also check the
spans that landed carry the caller identity.

* test(langfuse otel): split stacked span comprehensions and drop constant comments

* test(langfuse otel): split the metadata integration module into focused files

* fix(langfuse otel): send the org id in trace metadata too

The trace metadata keys now reuse IDENTITY_METADATA_FIELDS, so a key in an org gets langfuse.trace.metadata.user_api_key_org_id next to the generation's org id.

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:54:10 -07:00
tin-berri
bfec81d411
fix(ui): keep guardrail wizard navigation within the dialog (#48688) 2026-10-28 01:53:43 +00:00
mrinal-berri
86dd7187fa
fix(tavily): return raw_content, score, favicon and the response envelope from Tavily search (#48684)
* fix(tavily): return raw_content, score, favicon and the response envelope from Tavily search

Tavily search dropped every field except title, url and content, so
include_raw_content, include_answer, include_images and include_favicon
reached Tavily but their output never reached the caller. Each result now
carries raw_content, score and favicon when Tavily returned them, and the
top-level fields (answer, images, request_id, usage, ...) come back as
provider_metadata, same as the Exa fix in #47423

* test(tavily): move the Tavily search wire test's sample data to module constants, like the Exa test
2026-10-27 18:52:06 -07:00
Kerry Lu
017e08aba3
fix(databricks): price the openjev decision model at its published 0.6 DBU per 1M input tokens (#48694)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:49:16 -07:00
tin-berri
b9e3587721
fix(embeddings): forward Gemini and Vertex request timeouts (#48690)
Co-authored-by: DDT <141296842+Hugo-DDT@users.noreply.github.com>
2026-10-27 18:34:11 -07:00
tin-berri
cd01c73dd9
fix(responses): preserve typed parts when logprobs is omitted (#48686) 2026-10-27 18:33:39 -07:00
yuneng-jiang
4bdeb1378b
chore: bump litellm-enterprise 0.1.79 -> 0.1.80, litellm 1.108.0 -> 1.109.0 (#48673)
* bump: litellm 1.108.0 -> 1.109.0

* bump: litellm-enterprise 0.1.79 -> 0.1.80
2026-10-27 18:29:35 -07:00
mrinal-berri
b5fb898d03
feat(passthrough): let upstreams report the model name via x-litellm-upstream-model (#48275)
* feat(passthrough): let upstreams report the model name via x-litellm-upstream-model

A pass-through upstream that prices its own requests already reports cost and
tokens in x-litellm-response-cost / x-litellm-total-tokens. Product endpoints
that take no `model` in the body still landed under `unknown`, so their spend
could not be broken down.

When the upstream sends x-litellm-upstream-model, record its value as the
request's model on success and failure rows, overriding any body `model`. The
name is a label only: it never marks the request as upstream-priced and is not
used to look up a price. A blank or missing header changes nothing.

Refs LIT-9910

* fix(passthrough): keep the upstream-reported model name bounded

The upstream model name becomes a spend row, a daily rollup key and a metric
label. Use only the first element of a repeated header (RFC 9110 field
combining, which is how httpx joins it) and cut the name to
MAX_SPEND_LOG_MODEL_NAME_LENGTH, the limit the spend log already applies to
model names.

Refs LIT-9910

* test(passthrough): cover x-litellm-upstream-model through spend rows and rollups

Drives a config-defined pass-through against a scripted upstream and reads
back the spend row, the daily team rollup and /team/daily/activity. Covers the
header winning over a body model on buffered and streamed responses, failure
rows, a model-only header keeping cost_per_request, the no-header and blank
header cases, and repeated or long header values.

Refs LIT-9910

* test(passthrough): annotate new test locals with Final

Refs LIT-9910
2026-10-27 18:28:54 -07:00
yuneng-jiang
8d30009b78
chore: update Next.js build artifacts (2026-10-28 01:11 UTC, node v24.19.0) (#48692) 2026-10-28 01:25:36 +00:00
tin-berri
8ced2cf433
fix(router): keep session tier pins coherent across workers (#48645) 2026-10-27 18:22:39 -07:00
tin-berri
e101449816
feat(helm): route extra ingress paths to custom services (#48674) 2026-10-27 18:19:09 -07:00
yuneng-jiang
21ab2cb87b
test: update stale integration and guardrail e2e tests to intended behavior (#48665)
* test(integration): update stale expectations to intended provider behavior

Each expectation moved because a merged PR changed behavior on purpose.
- chatgpt string input is wrapped as one user message (#41522)
- Gemini 2.5 tool media sits beside function_response (#48428)
- sampling knobs move to Gemini 3.5 Flash, which keeps them (#47645)
- a body base_model is stripped without allow_client_pricing_override (#47639)
- ai_decide answers without usage return usage null (#48261)
- azure_ai forwards mapped client anthropic-beta values (#48033)
- the OpenCode scripted upstream mints a fresh id per request

* test(e2e-ui): type the guardrail name before deleting it

GuardrailDeleteModal requires the exact name before Delete enables since
#45893. All three guardrail delete flows now go through one helper.

* test(observability): accept any sampled traceresponse flags and expect a 400 for a non-object body

OpenTelemetry 1.45 (#48055) sets the W3C random trace id flag on new traces,
so the gateway's traceresponse now ends in -03. The producer copies the server
span's own trace flags, so the tests now accept any two-digit flags with the
sampled bit set instead of pinning -01

#48438 made a non-object JSON body a 400 "Invalid JSON payload: expected an
object" on purpose, so the [1] body case is renamed non_object_body and
expects that 400

* test(observability): stop waiting on OpenTelemetry's export log line in the langtrace outage test

The outage test waited for "Exception while exporting Span batch" in the proxy
log, which is OpenTelemetry's wording and no longer appears with 1.45. The test
keeps asserting what the gateway guarantees, callers unaffected during the
outage, a live gateway, and every span after recovery delivered once with no
outage span duplicated

* test(guardrails): count the Responses disconnect spend rows #47611 added

Since #47611 a streamed /v1/responses disconnect writes its own spend row, so
the concurrent disconnect test saw 20 rows where it counted 10 by model group.
It now reads each row's call type and expects ten acompletion rows with the
outage verdict and ten aresponses rows with success. /v1/messages disconnects
still write no row on main, so they are left out of the count rather than
pinned as absent

The responses-httpx case of the single disconnect test no longer needs its
LIT-8603 skip and passes

* test(observability): size K4's timeout from the harness boot budget

K4 boots three two-worker proxies in a row. On CircleCI each boot takes 43 to
58 seconds, so a passing run took 179.97s against its fixed 180s cap and 6 of
30 runs hit pytest-timeout. The cap and the restart gate wait now come from
readiness_seconds and graceful_stop_seconds, like other multi-boot tests
2026-10-28 01:11:25 +00:00
yucheng-berri
15eca24fc2
fix(guardrails)!: redact secrets in tool traffic and system prompts (#47122)
* fix(guardrails): redact secrets in tool traffic and Anthropic system

* fix(guardrails): bound tool payload scans and stop nested tool_result recursion

Scan each tool value beside its key instead of an indented dump, read
tool_result content one level deep, keep non-finite tool arguments as
raw text, and give read-only guardrails one fragment per tool payload

* test(guardrails): cover read-only guardrail consumers of tool and system text

* fix(guardrails): keep tool argument parsing, scanning and tests within bounds

Treat tool arguments that decode to a lone surrogate as raw text, decode
JSON-escaped detections back to the leaf value, quote unkeyed leaves
without repeating a parent key per list item, drop cycle tracking that
parsed JSON cannot reach, and use the CI license instead of a fake
license server

* fix(guardrails): scan list items beside a capped parent key

List items keep their parent key in the scanned JSON line, capped to its
last 64 characters, so a credential key over a list is still detected
while a long key over many items no longer multiplies the scan text

* fix(guardrails): cap the scanned key after JSON escaping

A key of control characters escaped to six times its length after the
cap, so every list item's scan line grew with it

* fix(guardrails): collapse whitespace in the scanned key context

Space-heavy keys made every inherited list item scan several times slower
than a short key

* fix(guardrails): build each key's scan context once per payload

Repeating the collapse, escape and cut for every list item made a long key
over many items cost key length times item count

* fix(guardrails): cover Responses tool call items and instructions

Responses carries the same tool call arguments and system prompt as chat
and messages under function_call.arguments, custom_tool_call.input and
instructions, so secrets there reached the provider unscanned

* perf(guardrails): skip rescanning text hide-secrets already found clean

Agents resend their whole conversation on every turn, so each turn
rescanned every earlier tool result. Each guardrail now keeps a bounded
set of digests of scan inputs that held no secret and skips those; text
with a secret is always rescanned and no secret material is kept

* fix(guardrails): size the clean-scan cache for many sessions and replace longest secrets first

4096 digests held about 20 long agent sessions per worker, so interleaved
sessions evicted each other and every resend rescanned its history;
65536 digests (about 10 MB) hold a few hundred. Tool payload secrets are
now replaced longest first after JSON unescaping, so a shorter overlapping
match cannot leave part of a secret behind. The key-context test checks
the work it bounds instead of wall-clock time, which CI could not meet

* fix(guardrails): keep unmasked tool argument text and redact keyword matches cut inside an escape

* fix(guardrails): write a tool visitor's whole payload when it changes more than string values

* fix(guardrails): read message text before tool and system text so a vendor error on the new text cannot skip it

* fix(guardrails): moderate tool and system text in its own call so a long payload cannot hide a flagged message

* fix(guardrails): skip the empty moderation call when only tool or system text is present

* fix(guardrails): scan long multi-line tool strings as their own lines so the scan stays linear

* fix(guardrails): keep the key beside every line of a long multi-line tool string

* fix(guardrails): confine tool-traffic coverage to hide-secrets

Restore iter_message_text to its main behavior, so banned keywords, Azure
Content Safety, IBM detectors and the OpenAI and Google moderation hooks
read exactly what they read on main. Only walk_user_text, which
hide-secrets alone calls, still reaches system, instructions and tool
payloads.

* test(guardrails): stop the tool-traffic integration tests from flaking on a busy host

Use the shared per-case timeout for the rig client, and open a new
connection per call in the worker-kill burst so a pooled keep-alive
socket to the killed worker is never reused.

* test(guardrails): drop the wall-clock check from the deep tool arguments unit test

The integration test test_deeply_nested_tool_arguments_do_not_stall_the_worker
already covers the timing; the unit test keeps the redaction check.

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-27 18:11:03 -07:00
tin-berri
b9e4298506
fix: preserve media across Responses and Gemini conversions (#48484) 2026-10-27 18:07:10 -07:00
moe-berri
696ff615d4
feat(morph): support open models and cached input pricing (#48676)
* feat(morph): support open-weight chat models and cached input pricing

* chore(morph): preserve existing catalog number formatting

* test(morph): include provider regressions in CI shard

* fix(morph): satisfy catalog and test package checks

* fix(morph): declare abliterated model output limit
2026-10-27 18:06:14 -07:00
Eduardo Pessin
e5fe716d69
test(cost): cover unpriced provider slugs and explicit zero rates (#42467)
* fix(cost): a provider slug the price map lacks no longer bills the turn at zero

Anthropic names the dated build in `message_start`, so a streamed turn carries
`provider_response_model="claude-opus-5-20250930"` while the price map holds
`claude-opus-5`. `_select_model_name_for_cost_calc` prefers the reported name
over `response.model`, and `get_model_info` answers a model it has never heard
of with zero rates rather than raising -- so the turn is billed 0.0 with nothing
in the logs to explain it.

Same usage, only that field varying, on main:

    provider_response_model absent                  ->  0.00079
    provider_response_model=claude-opus-5           ->  0.00079
    provider_response_model=claude-opus-5-20250930  ->  0.0

Missing is harmless; the damage is a name that is present and unpriced.

The reported name still wins whenever it resolves to a rate -- it is the most
specific truth about what served the turn. It is only passed over when charging
by it would produce a silent zero, and then the response's own model is used:
the name the deployment resolved to, and the one an unstreamed turn is already
priced by.

This is not specific to Anthropic or to streaming. Any provider reporting a more
specific slug than the map carries -- dated, regional, build-suffixed -- is
affected, and only streamed turns carry the field, which is why it reads as a
streaming bug from the outside.

Reported in #42161.

* address review: drop explanatory comments, stop pinning vendor slugs in tests

Three points from the review, two of which were right.

The helper docstrings explained behaviour the code already shows, which AGENTS.md
rules out. Removed.

The tests asserted that `claude-opus-5` is in the price map and
`claude-opus-5-20250930` is not. Both are facts we do not own: if Anthropic registers
the dated build, the test breaks without any litellm change. They now use a name that
is ours, and assert the invariant instead, that what the provider reports cannot change
the price of a turn the map cannot quote it by.

Picking that name needed care. An arbitrary string makes `get_model_info` raise, which
a caller upstream already handles, so the test passed with or without the fix and
defended nothing. A `claude-` prefixed name reaches the zero-rate fallback that this
bug rides on, so `claude-test-42161` reproduces it and stays ours.

The third point, that lines 801 and 828 exceed 120 characters, does not hold: they are
98 and 39.

* Pass completion_response_model as str or None to _priced_provider_response_model

basedpyright counted the bare variable as partially unknown (reportUnknownArgumentType +1 over the budget).

* test(cost): isolate runtime registration replay cases

---------

Co-authored-by: Eduardo Pessin <eduardopessin@users.noreply.github.com>
2026-10-27 18:00:40 -07:00
ishaan-j-berri
7a23e497bd
fix: honor empty mock completion responses (#48162) (#48266) 2026-10-28 00:56:45 +00:00
devin-ai-integration[bot]
f277f1e260
fix(bedrock): add token_count_model to three Mantle-native Claude rows (#48682)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:52:31 -07:00
Kerry Lu
41336b09e2
ci: run spend-logs-guard and cost-map-guard without installing the litellm project (#48683)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-28 00:34:01 +00:00
Niraj Kumar
6e09664118
fix(decisions): catch ValidationError from validate_json in transform_decisions_response (#48660) (#48662) 2026-10-28 00:32:05 +00:00
ishaan-j-berri
4ad5a003b1
feat(search): add native xAI X Search support (#38505) (#48677)
* feat(search): add native xAI X Search support (#38505)

* fix(search): support xAI search on Python 3.10 (#38505)
2026-10-28 00:28:51 +00:00
tin-berri
c17faa1a27
fix(mcp): refuse calls to guardrail-hidden tools (#48669) 2026-10-27 17:13:40 -07:00
moe-berri
73cd296418
fix(chatgpt): send system input items to Codex as developer (#48668)
* fix(chatgpt): send system input items to Codex as developer

The Codex backend rejects {"type": "message", "role": "system"} input
items with 400 "System messages are not allowed", while it accepts the
same item with role=developer. The chat completions bridge emits that
shape for every non-leading system message, and typed Responses API
clients send it directly, so both paths failed. Rewrite system items to
developer in place, which keeps their position and content.

Fixes #48562

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chatgpt): annotate system-item rewrite with read-only types

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chatgpt): rewrite system items on the typed input before the base transform

Keeps the rewrite on known types so basedpyright sees no new unknowns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:03:28 -07:00
moe-berri
1b932332c1
fix(ui): omit blank expiry from key edit and regenerate payloads (#48634)
* fix(ui): omit blank expiry from key edit and regenerate payloads

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: credit #45048 author

Co-authored-by: Thomas Scholtz <thomas@quantum-sicarius.za.net>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format regenerate key payload builder

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): compare regenerate payloads by fields, not JSON order

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Thomas Scholtz <thomas@quantum-sicarius.za.net>
2026-10-27 17:03:12 -07:00
Kerry Lu
59b8c73930
fix(proxy): abort the request with a 499 when the client disconnects during the body upload (#48640)
* fix(proxy): abort the request with a 499 when the client disconnects during the body upload

Co-authored-by: Sinfony8838 <Sinfony8838@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): half-uploaded body on chat and vector store routes never reaches the provider

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): close the auth-time OTel server span before aborting a half-uploaded body

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Sinfony8838 <Sinfony8838@users.noreply.github.com>
2026-10-27 16:49:56 -07:00
yuneng-jiang
cedae29cc7
fix(ui): keep vendor names and agent instructions in AI wording under SI terminology (#48670)
XecGuard AI and Airia AI join the third-party names the SI swap leaves alone, so
their Guardrail Garden cards stop reading "XecGuard SI" and "Airia SI"

The copyable agent instructions go to Claude Code and Codex, so they keep AI the
same way the compliance prompts do
2026-10-27 16:26:01 -07:00
yuneng-jiang
969161501a
feat(ui): add LITELLM_UI_USE_SI_TERMINOLOGY to show SI instead of AI in the Admin UI (#47440)
* feat(ui): add LITELLM_UI_USE_SI_TERMINOLOGY to show SI instead of AI in the Admin UI

Setting LITELLM_UI_USE_SI_TERMINOLOGY=true on the proxy makes the Admin UI
say "SI" where its own copy says "AI" and "Super Intelligence" where it says
"Artificial Intelligence". This covers the nav, page headings, settings text,
guardrail garden blurbs, the compliance playground prompts and the playground
sample prompts. Third-party product, provider and law names keep their
spelling (Vertex AI, Cisco AI Defense, EU AI Act, ...), and user data such as
guardrail names, gateway URLs and keys is never rewritten.

The proxy exposes the flag on /.well-known/litellm-ui-config, a
TerminologyProvider at the app root reads it, and components call
useTerminology() on their own strings. The context defaults to a pass-through,
so nothing changes when the flag is unset.

* fix(ui): apply SI terminology to the command palette, page visibility groups and guardrail garden search

* test(ui): cover the flag-off wording on the palette, page visibility and garden search tests

* fix(ui): apply SI terminology to the Aria usage assistant copy

* test(ui): keep the Aria ui-config stub within the inline object lint budget

* fix(ui): keep AI wording in prompts sent to models
2026-10-27 22:54:39 +00:00
tin-berri
c4c2fe6ba1
docs(router): clarify heuristic v2 keyword settings (#48666) 2026-10-27 22:38:07 +00:00
yuneng-jiang
9b2fc9db40
test: remove checks that pin implementation details and add a shadcn testing rule (#48663)
* test: remove e2e checks and a CI line count that pin implementation details

Deletes the Auto Router template select anchoring spec, the 390px logs pagination variant, and the call id tooltip and clipboard step in the logs spec. Each one tests library or test helper behavior rather than what users see, and each has been red or flaky on main without a product regression

Also drops the exact line count on the fan-in requirements export in the integration coverage job. It went from 6 to 10 when uv.lock gained a Python 3.15 fork, while the install was still correct. A package the filter drops still fails the job, either at the hash-checked install or at the next step's import

* docs(agents): do not test what shadcn and Base UI already guarantee

Adds the boundary to the dashboard AGENTS.md next to the test tiers, and points the Playwright suite's entry in tests/e2e/AGENTS.md at it
2026-10-27 22:26:15 +00:00
Ambuj Upadhyay
cb70bc560e
fix(streaming): dict-usage arm drops prompt_tokens_details/completion_tokens_details its sibling arms preserve (#36678)
* fix(streaming): dict-usage arm no longer drops token detail fields

In _dispatch_provider_chunk's openai/azure else-branch, a plain-dict
usage was rebuilt into litellm.Usage from only prompt_tokens /
completion_tokens / total_tokens. prompt_tokens_details (cached_tokens),
completion_tokens_details (reasoning_tokens) and cache token counts were
silently dropped — while both sibling arms (Usage passthrough, BaseModel
via Usage(**model_dump())) preserve them.

Every in-repo chunk producer currently normalizes usage to
Usage/BaseModel before this point, so the dict arm is reachable today
only via chunks litellm did not construct (e.g. a custom
streaming_decoder feeding CustomStreamWrapper). When it does fire on a
usage-only final chunk (stream_options.include_usage), the stripped
Usage is exactly what the stream consumer receives — the early-return
path has no later aggregation to repair it.

Fix: pass the full dict through Usage(**usage_dict). Usage.__init__
coerces nested detail dicts into their wrapper types and tolerates
extra keys. Side effect: a legitimate 0 count is no longer coerced to
None by the old 'or None' expressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: drop explanatory source comment per repo comment policy

Rationale lives in the PR body and the regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(streaming): type the dict-usage unpack for the basedpyright gate

Usage(**response_obj["usage"]) unpacks a dict whose value type pyright
cannot see, and the delta gate reports that once per typed constructor
parameter: five, against the three .get() calls the line replaced. Moving
the unpack behind _usage_from_payload keeps the same Usage(**payload)
call and is reported once, at the call site, so the rule count ends below
its merge base

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 15:21:11 -07:00
Kerry Lu
8f31c7171d
fix(router): keep a lone deployment warm under router allowed_fails unless the policy names the exception (#48644)
* fix(router): keep a lone deployment warm under router allowed_fails unless the policy names the exception

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(router): give cooldown fixtures a sibling deployment so allowed_fails=0 still forces a cooldown

* fix(router): keep benching a lone deployment that has a fallback group or an empty deployment policy

* fix(router): accept dictionary fallback targets in the lone deployment check

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:12:53 -07:00
yucheng-berri
a53ea5bd77
fix(proxy): keep every startup line JSON when json_logs is on (#47035)
* fix(proxy): keep every startup line JSON when json_logs is on

Boot-time messages the proxy printed straight to stdout (banner, feedback
box, model list, cache and callback notices, policy table, migration
notices, CLI launch notes) and the first records of uvicorn- or
granian-spawned workers came out as plain text while json_logs was on,
breaking log pipelines that parse every line as JSON.

- print_startup_message() prints unchanged in plain mode and, with
  json_logs on, emits one INFO record on the proxy logger (ANSI
  stripped) that skips the logger and handler level checks like the
  print it replaces; show_banner() routes to it in JSON mode only
- the CLI exports JSON_LOGS=true when the config sets json_logs so
  spawned workers log JSON from their first record
- the --enforce_prisma_migration_check deprecation notice prints after
  the config is read so a config-only json_logs covers it

Resolves LIT-1624

* fix(proxy): keep show_banner() SDK-free, pick the JSON banner in initialize()

Since #46803 the `lite` shell calls show_banner() against an uninitialized
litellm package, and reading litellm.json_logs inside it initialized the whole
SDK at the banner (0.14 s to 0.83 s for a bare `lite` here, the seconds that PR
removed on small VMs). show_banner() is back to its main version; the proxy's
initialize() now emits the JSON banner record itself and calls show_banner()
only in plain mode. The banner unit tests move to the initialize() call site.

* fix(proxy): keep the JSON banner when an operator --log_config names the proxy logger

uvicorn applies --log_config after proxy_cli has switched JSON logging on and before
initialize() switches it on again through load_config(). A logging config that names
"LiteLLM Proxy" removes that logger's handlers in the window between the two, which is where
initialize() emits the banner and the feedback box, so at one worker both went nowhere;
two-worker children import litellm after uvicorn's logging setup and were not affected.
print_startup_message() now falls back to a one-off JSON handler, on the stream the JSON
handler uses, when the proxy logger has no handler of its own.

Live, json_logs on plus a log_config naming the proxy logger, one worker: main prints the
banner and the feedback box as plaintext, e2303a3925 prints neither, this head prints both as
one JSON record each. New unit test fails on e2303a3925; new integration cell
test_json_logs_with_operator_log_config_keeps_every_line_json passes at one and two workers.

* fix(proxy): keep a long startup listing whole in its JSON record

print_startup_message ran its record through the proxy logger's filters, so
in JSON mode the required-migration SQL list and the policy list were
middle-truncated at MAX_STRING_LENGTH_STDOUT_LOG (4096 by default) with the
litellm_truncated marker, where the prints they replace showed them whole.
That cap bounds echoed request payloads; a startup message is operator text,
so the record now gets the uncapped processing of a DEBUG line: secret
redaction and base64 collapse still apply, the text cap does not.

Red on 41f92b745a, green here:
- tests/unit/test_logging.py: a listing longer than the cap comes out whole;
  a credential in a startup message is still redacted
- tests/integration/configuration/test_json_logs_startup.py: a 60-policy
  config boots with json_logs on and the policy listing record (9762 chars)
  carries every policy with no truncation marker (4096 chars, marker, on
  the unfixed tree)

* fix(proxy): keep the --enforce_prisma_migration_check notice on --health and --test

This PR moved the deprecation notice after the config is read, so that a
json_logs set only in the config applies to it; --health and --test return
before that point and stopped printing it, a visible change main did not
have. The notice is now also emitted before those two returns, through the
same helper, so a deployment running `litellm --health
--enforce_prisma_migration_check` still learns the flag is dead, as on main.
A normal server start emits it once, after the config, as in the previous
commit.

Red on 2d7a2d6844, green here: tests/unit/proxy/test_proxy_cli.py asserts
the notice on --health and on --test.

* fix(proxy): keep the JSON startup records on stdout under an operator handler on the proxy logger

An operator --log_config that gives "LiteLLM Proxy" a handler of its own, a NullHandler
that silences it or a plain WARNING-level handler on stderr, is in force in the window
where the banner, the feedback box and the cache notice are emitted: uvicorn applies it
after the CLI process switched JSON on and before initialize() re-installs the JSON
handler through load_config(). 41f92b745a sent the startup records through the proxy
logger's handlers and fell back to a JSON stdout handler only when there were none, so
under such a config the records landed in the operator's handler, as plaintext on stderr,
or vanished, where the prints they replace reached stdout whatever the logging config
said.

print_startup_message() now writes every startup record through the proxy's own JSON
handler on stdout, filters included, and never through the proxy logger's handlers; the
operator's handler gets nothing from a startup message, as it got nothing from the print.
The normal JSON handler is the same class, formatter and filters, so the records a
pipeline reads are unchanged when no operator config is in play.

Tests: tests/unit/test_logging.py gets the two handler shapes (red on f6f5957d16: the
record went to stderr as plaintext or nowhere); tests/integration/configuration/
test_json_logs_startup.py gets a silencing operator --log_config cell at 1 and 2 workers
(red on f6f5957d16: banner, feedback box and cache notice missing; green here).

* fix(proxy): print the --enforce_prisma_migration_check notice when the config fails to load

f6f5957d16 printed the deprecation notice after the config is read, so a json_logs set
only in the config makes it a JSON record. A boot whose config file is missing or does
not parse then exited before that point and never printed it, where main printed it
before the error (10-20 gauntlet finding).

The notice is printed where main printed it, unless a config file can still switch
json_logs on: then it is held until the config is read and printed right after the
json_logs switch, and a click close callback prints it on the way out if the boot fails
first. functools.cache keeps it to one print either way.

Tests: tests/unit/proxy/test_proxy_cli.py covers a failed config read (red on
75043aed83), a plain config printing it once and a config-only json_logs making it one
JSON record; tests/integration/configuration/test_json_logs_startup.py adds the JSON cell
and a missing and an unparseable config cell (red on 75043aed83, green here), through
refused_boot_log, which now takes extra CLI arguments.

* fix(proxy): keep startup records as printed instead of running secret redaction

In JSON mode the startup messages ran secret redaction, which the prints they replace never
had. Its sk- key pattern has no left boundary, so a model named risk-assessment-gpt4o was
logged as riREDACTED in the model list (10-21 gauntlet finding). The records now skip
redaction like the prints did; the processed record still counts as scrubbed, so the
handler's secret filter and the formatter leave it alone. The stdout cap stays off and base64
collapse stays on, as before.

Tests: tests/unit/test_logging.py checks the model name comes out whole (red on 7d17f126e5),
replacing the test that asserted startup records were redacted;
tests/integration/configuration/test_json_logs_startup.py adds a boot with a
risk-assessment-<id> model whose model list record carries the name (red on 7d17f126e5).

* refactor(proxy): meet the AGENTS.md rules Greptile flagged on the startup-message change

The policy listing builds its rows as tuples in two helpers instead of appending to a list;
plain output is unchanged (same bytes as before across 3000 random policy and attachment
configs). The explanatory comments added with this change are gone, and the docstring of
print_startup_message now says the record is whole and unredacted.

Tests: the migration-diff JSON test no longer runs the fake prisma CLI; it checks the
skipped-diff notice in-process, and the fixture extension it needed is reverted. The
json_logs export and deprecation-notice CLI tests run the real proxy module and a real
config file instead of a mocked ProxyConfig and proxy module, and the --health/--test
notice tests no longer patch our helpers. Each still fails when the export, the hold or
call_on_close is removed. New test parameters are typed.

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-27 14:49:07 -07:00
tin-berri
2a9c630182
fix(proxy): release concurrency slots when synthetic streams complete (#48628) 2026-10-27 14:47:04 -07:00
tin-berri
ad4b35d791
fix(mcp): accept YAML OpenAPI documents (#48622) 2026-10-27 14:46:15 -07:00
tin-berri
c23cff7e45
fix(mcp): enforce deny-first tool policy consistently (#48625) 2026-10-27 14:30:48 -07:00
tin-berri
e0d1e64591
feat(router): respect caller output budgets in complexity routing (#48632)
Co-authored-by: stuti612 <stutiprasad63@gmail.com>
2026-10-27 14:04:30 -07:00
Kerry Lu
52562c47f8
refactor(proxy-extras): drop the spend-log api_key special case from the row check remedy (#48636)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 20:53:23 +00:00
moe-berri
34c10829b1
fix(ui): percent-encode request id in spend log detail URL (#48630)
* test: cover request ids with URL special characters in log detail fetches

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): percent-encode request id in spend log detail URL

Co-authored-by: Charlie Freeman <243496084+cf3901646@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Charlie Freeman <243496084+cf3901646@users.noreply.github.com>
2026-10-27 13:33:47 -07:00