From 990dea27d5c87c7c48dbc286c2efa3c6a610cf54 Mon Sep 17 00:00:00 2001 From: Rad Wadud <104943953+rad-p44@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:52:41 -0500 Subject: [PATCH 001/196] fix(headroom): protect cache_control-marked rows anywhere in history get_protected_indices() only protected system rows, the last user row, and the last assistant row. A message carrying its own Anthropic cache_control breakpoint further back in history (e.g. a large cached tool result from a few turns ago) was not protected, so the Headroom guardrail would send it to /v1/compress and rewrite it. The row came back byte-different but kept its cache_control marker, so the provider's prompt cache treated the next request as a miss on that prefix: a cache read silently became a cache write. This reproduces the production cache-hit-rate collapse reported in #39519 (~65-70% down to ~40-50% within 48h of enabling the guardrail). get_protected_indices() now also protects any message whose content -- directly on the message, or on any part of a list-of-parts content -- carries a cache_control marker, regardless of its position in history. Both compress() and the Headroom guardrail already share this function as their compression-eligibility policy, so both get the fix. Adds test coverage for cache_control on the message dict itself, on a content part, mid-history, and de-duplicated against already-protected indices. Updates the Headroom guardrail's PARTS_MESSAGES fixture, which previously relied on this exact gap for its all-text merge/flatten test coverage, to use a separate un-marked row (the cache_control-marked-row merge behavior is covered directly by compresr's own test, since a marked row no longer reaches that merge path through Headroom). Fixes #39519 --- litellm/compression/compress.py | 35 +++++++++- .../test_litellm/compression/test_compress.py | 60 ++++++++++++++++ .../guardrail_hooks/test_headroom.py | 68 ++++++++++++++++--- 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/litellm/compression/compress.py b/litellm/compression/compress.py index c646baf9d9e..62b05a4938f 100644 --- a/litellm/compression/compress.py +++ b/litellm/compression/compress.py @@ -205,21 +205,54 @@ def _extract_anthropic_tool_exchange_spans( return spans, None +def _message_has_cache_control(message: Mapping[str, object]) -> bool: + """True if ``message`` carries an Anthropic ``cache_control`` breakpoint. + + A breakpoint can sit directly on the message dict, or on any part of a + list-of-parts ``content`` (the shape Anthropic's own messages use). Either + placement pins the provider's KV-cache prefix to this row's exact bytes, so + either placement must protect the row the same way. + """ + if message.get("cache_control") is not None: + return True + content: Final = message.get("content") + if isinstance(content, list): + return any(isinstance(part, Mapping) and part.get("cache_control") is not None for part in content) + return False + + def get_protected_indices(messages: Sequence[Mapping[str, object]]) -> tuple[int, ...]: """ Return indices of messages that must never be compressed: - All system messages - The last user message - The last assistant message + - Any message carrying an Anthropic cache_control breakpoint The last user message is what the model is being asked to act on right now, so compressing it replaces the live instruction with a marker. Compression guardrails share this policy; see the Headroom guardrail. + + A cache_control breakpoint pins the provider's prompt-cache prefix to that + row's exact bytes. Rewriting the row (even leaving the marker in place) + changes those bytes, so the next request misses the cache it thinks it is + reusing and silently pays a cache write instead of a cache read. This is + not limited to the last user/assistant row: a marker several turns back + (e.g. on a large cached tool result) needs the same protection. """ system_indices: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "system") last_user: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "user")[-1:] last_assistant = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "assistant")[-1:] - return system_indices + last_user + last_assistant + cache_control_indices: Final = tuple( + index for index, msg in enumerate(messages) if _message_has_cache_control(msg) + ) + seen: Final[set[int]] = set() + ordered: Final[list[int]] = [] + for index in system_indices + last_user + last_assistant + cache_control_indices: + if index not in seen: + seen.add(index) + ordered.append(index) + return tuple(ordered) def _combine_scores( diff --git a/tests/test_litellm/compression/test_compress.py b/tests/test_litellm/compression/test_compress.py index 6827c37dfd5..f9877ea2bc4 100644 --- a/tests/test_litellm/compression/test_compress.py +++ b/tests/test_litellm/compression/test_compress.py @@ -53,3 +53,63 @@ def test_every_system_row_is_protected(): def test_no_user_or_assistant_rows(): assert sorted(get_protected_indices([{"role": "system", "content": "sys"}])) == [0] assert get_protected_indices([]) == () + + +def test_mid_history_cache_control_part_is_protected(): + # A large cached tool result from a few turns back, not the last user or + # last assistant row -- exactly the row a provider prompt-cache pins to + # exact bytes. Rewriting it (even leaving the marker on) changes those + # bytes and turns the next request's cache read into a cache write. + messages = [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "a large cached tool result"}, + ], + }, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "live instruction"}, + ] + messages[2]["content"][0]["cache_control"] = {"type": "ephemeral"} + + # index 3 = last assistant, index 4 = last user (both protected by role + # regardless), index 2 = the cache_control-marked row itself. + assert sorted(get_protected_indices(messages)) == [2, 3, 4] + + +def test_cache_control_directly_on_message_is_protected(): + messages = [ + {"role": "user", "content": "old question", "cache_control": {"type": "ephemeral"}}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "live instruction"}, + ] + + assert sorted(get_protected_indices(messages)) == [0, 1, 2] + + +def test_cache_control_protection_does_not_duplicate_already_protected_rows(): + # The last user row is already protected by role; marking it too must not + # produce a duplicate index. + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "live", "cache_control": {"type": "ephemeral"}}, + ] + + protected = get_protected_indices(messages) + + assert sorted(protected) == [0, 1] + assert len(protected) == len(set(protected)) + + +def test_content_that_is_not_a_list_of_mappings_is_not_treated_as_cache_control(): + # Defensive: a plain string content, or a list of non-dict items, must not + # raise or be misread as carrying a breakpoint. + messages = [ + {"role": "assistant", "content": "plain string content"}, + {"role": "user", "content": ["not", "a", "dict", "list"]}, + {"role": "user", "content": "live instruction"}, + ] + + assert sorted(get_protected_indices(messages)) == [0, 2] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py index d8eeb8d2b8a..5cd42bd3f83 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -1795,14 +1795,18 @@ PARTS_MESSAGES = [ ], }, { + # No cache_control here on purpose: this row exercises the general + # multi-part flatten/merge mechanics (shared with compresr). A row + # carrying its own cache_control is a different, dedicated case -- + # see test_mid_history_cache_control_row_is_never_sent_for_compression + # (#39519): get_protected_indices withholds it from /v1/compress + # entirely rather than letting it be rewritten and re-merged, because + # rewriting the bytes under a live breakpoint busts the cache the + # marker is supposed to preserve. "role": "user", "content": [ - {"type": "text", "text": "Earlier turn.", "cache_control": {"type": "ephemeral"}}, - { - "type": "text", - "text": "Second block. " + "B" * 5000, - "cache_control": {"type": "ephemeral", "ttl": "1h"}, - }, + {"type": "text", "text": "Earlier turn."}, + {"type": "text", "text": "Second block. " + "B" * 5000}, ], }, { @@ -1891,14 +1895,17 @@ async def test_apply_guardrail_restores_rewritten_all_text_row( messages = result["structured_messages"] history_content = messages[1]["content"] - # Rewritten all-text row collapses to one part carrying the LAST declared - # breakpoint: an Anthropic breakpoint caches the prefix ending at its - # part, so after the merge the last one (and its TTL) still describes the - # row. + # Rewritten all-text row collapses to one part carrying the rewritten text. + # This fixture row carries no cache_control (see PARTS_MESSAGES): the + # last-declared-breakpoint-survives-the-merge behavior is a property of + # merge_rewritten_text_parts and is covered directly by compresr's + # test_all_text_row_merges_and_keeps_last_cache_control, since a + # cache_control-marked row never reaches this merge path through Headroom + # at all -- get_protected_indices withholds it before compression runs + # (see test_mid_history_cache_control_row_is_never_sent_for_compression). assert isinstance(history_content, list) assert len(history_content) == 1 assert history_content[0]["text"] == "compressed history. Retrieve more: hash=b573993006976af767214fac" - assert history_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} # Mixed row passes through byte-identical. assert messages[2]["content"] == PARTS_MESSAGES[2]["content"] # The service-declared hash still drives retrieve-tool injection on a restored row. @@ -2523,6 +2530,45 @@ async def test_history_is_still_compressed(guardrail: HeadroomGuardrail): assert messages[3] == compressed_history[1] +# --------------------------------------------------------------------------- +# #39519: a mid-history row carrying its own Anthropic cache_control marker +# (e.g. a large tool result the client already cached several turns back) was +# still sent to /v1/compress and rewritten. It came back byte-different but +# kept its marker, so the next request's cache read silently became a cache +# write. get_protected_indices() now protects any cache_control-marked row, +# not just system/last-user/last-assistant, so it must never reach the wire. +# --------------------------------------------------------------------------- + +CACHE_MARKED_HISTORY_MESSAGES = [ + {"role": "system", "content": "You are Claude Code. " + "S" * 5000}, + {"role": "user", "content": "old question " + "Q" * 5000}, + { + "role": "assistant", + "content": "Reading the file now.", + "tool_calls": [{"id": "old_1", "type": "function", "function": {"name": "Read", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "old_1", + "content": [{"type": "text", "text": "large cached file body " + "F" * 5000}], + "cache_control": {"type": "ephemeral"}, + }, + {"role": "assistant", "content": "Summarized the file for you."}, + {"role": "user", "content": "live instruction"}, +] + + +@pytest.mark.asyncio +async def test_mid_history_cache_control_row_is_never_sent_for_compression(guardrail: HeadroomGuardrail): + wire, result = await _wire_and_result(guardrail, CACHE_MARKED_HISTORY_MESSAGES) + + cached_row = CACHE_MARKED_HISTORY_MESSAGES[3] + assert cached_row not in wire + assert not any(row.get("tool_call_id") == "old_1" for row in wire) + # Byte-identical, marker intact -- the next request's cache read survives. + assert result["structured_messages"][3] == cached_row + + # --------------------------------------------------------------------------- # #38558: a client that runs its own tool loop (e.g. Claude Code via the MCP # gateway) executes headroom_retrieve and echoes the recovered original content From 36c1e5e17d1326f1a8f3dc7b25e86a69349d53e7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:34:31 -0700 Subject: [PATCH 002/196] refactor(compression): build the protected index set without mutation --- litellm/compression/compress.py | 33 ++++--------------- .../guardrail_hooks/test_headroom.py | 26 --------------- 2 files changed, 7 insertions(+), 52 deletions(-) diff --git a/litellm/compression/compress.py b/litellm/compression/compress.py index 62b05a4938f..c79e6aed57a 100644 --- a/litellm/compression/compress.py +++ b/litellm/compression/compress.py @@ -206,13 +206,6 @@ def _extract_anthropic_tool_exchange_spans( def _message_has_cache_control(message: Mapping[str, object]) -> bool: - """True if ``message`` carries an Anthropic ``cache_control`` breakpoint. - - A breakpoint can sit directly on the message dict, or on any part of a - list-of-parts ``content`` (the shape Anthropic's own messages use). Either - placement pins the provider's KV-cache prefix to this row's exact bytes, so - either placement must protect the row the same way. - """ if message.get("cache_control") is not None: return True content: Final = message.get("content") @@ -231,28 +224,16 @@ def get_protected_indices(messages: Sequence[Mapping[str, object]]) -> tuple[int The last user message is what the model is being asked to act on right now, so compressing it replaces the live instruction with a marker. Compression - guardrails share this policy; see the Headroom guardrail. - - A cache_control breakpoint pins the provider's prompt-cache prefix to that - row's exact bytes. Rewriting the row (even leaving the marker in place) - changes those bytes, so the next request misses the cache it thinks it is - reusing and silently pays a cache write instead of a cache read. This is - not limited to the last user/assistant row: a marker several turns back - (e.g. on a large cached tool result) needs the same protection. + guardrails share this policy; see the Headroom guardrail. A cache_control + breakpoint pins the provider's prompt-cache prefix to that row's exact + bytes, so rewriting a marked row anywhere in history turns the next + request's cache read into a cache write. """ system_indices: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "system") last_user: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "user")[-1:] - last_assistant = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "assistant")[-1:] - cache_control_indices: Final = tuple( - index for index, msg in enumerate(messages) if _message_has_cache_control(msg) - ) - seen: Final[set[int]] = set() - ordered: Final[list[int]] = [] - for index in system_indices + last_user + last_assistant + cache_control_indices: - if index not in seen: - seen.add(index) - ordered.append(index) - return tuple(ordered) + assistant_indices: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "assistant") + cache_control_indices: Final = tuple(index for index, msg in enumerate(messages) if _message_has_cache_control(msg)) + return tuple(dict.fromkeys(system_indices + last_user + assistant_indices[-1:] + cache_control_indices)) def _combine_scores( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py index 5cd42bd3f83..d4531398ba1 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -1795,14 +1795,6 @@ PARTS_MESSAGES = [ ], }, { - # No cache_control here on purpose: this row exercises the general - # multi-part flatten/merge mechanics (shared with compresr). A row - # carrying its own cache_control is a different, dedicated case -- - # see test_mid_history_cache_control_row_is_never_sent_for_compression - # (#39519): get_protected_indices withholds it from /v1/compress - # entirely rather than letting it be rewritten and re-merged, because - # rewriting the bytes under a live breakpoint busts the cache the - # marker is supposed to preserve. "role": "user", "content": [ {"type": "text", "text": "Earlier turn."}, @@ -1895,14 +1887,6 @@ async def test_apply_guardrail_restores_rewritten_all_text_row( messages = result["structured_messages"] history_content = messages[1]["content"] - # Rewritten all-text row collapses to one part carrying the rewritten text. - # This fixture row carries no cache_control (see PARTS_MESSAGES): the - # last-declared-breakpoint-survives-the-merge behavior is a property of - # merge_rewritten_text_parts and is covered directly by compresr's - # test_all_text_row_merges_and_keeps_last_cache_control, since a - # cache_control-marked row never reaches this merge path through Headroom - # at all -- get_protected_indices withholds it before compression runs - # (see test_mid_history_cache_control_row_is_never_sent_for_compression). assert isinstance(history_content, list) assert len(history_content) == 1 assert history_content[0]["text"] == "compressed history. Retrieve more: hash=b573993006976af767214fac" @@ -2530,15 +2514,6 @@ async def test_history_is_still_compressed(guardrail: HeadroomGuardrail): assert messages[3] == compressed_history[1] -# --------------------------------------------------------------------------- -# #39519: a mid-history row carrying its own Anthropic cache_control marker -# (e.g. a large tool result the client already cached several turns back) was -# still sent to /v1/compress and rewritten. It came back byte-different but -# kept its marker, so the next request's cache read silently became a cache -# write. get_protected_indices() now protects any cache_control-marked row, -# not just system/last-user/last-assistant, so it must never reach the wire. -# --------------------------------------------------------------------------- - CACHE_MARKED_HISTORY_MESSAGES = [ {"role": "system", "content": "You are Claude Code. " + "S" * 5000}, {"role": "user", "content": "old question " + "Q" * 5000}, @@ -2565,7 +2540,6 @@ async def test_mid_history_cache_control_row_is_never_sent_for_compression(guard cached_row = CACHE_MARKED_HISTORY_MESSAGES[3] assert cached_row not in wire assert not any(row.get("tool_call_id") == "old_1" for row in wire) - # Byte-identical, marker intact -- the next request's cache read survives. assert result["structured_messages"][3] == cached_row From 492251c7bc7bd76baf25512e4c43421efbcff799 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 10 Sep 2026 07:32:54 +0000 Subject: [PATCH 003/196] fix(otel): cap per-index OpenInference message attributes span-wide OpenInferenceMapper spelled every captured prompt and response message out as two indexed attributes with no bound. A few dozen turns overran the OTel SDK's 128-attribute span limit, which evicts oldest first, so the gen_ai.* model, provider, usage, cost and finish reason written before it were what got dropped. Both directions now share one MAX_MESSAGE_ATTRS_PER_SPAN ceiling, the response keeps at least half of it, and input.value / output.value still carry the complete conversation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../otel/mappers/openinference.py | 26 ++- litellm/integrations/otel/mappers/utils.py | 12 ++ .../integrations/otel/test_otel_v2_emitter.py | 148 ++++++++++++++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/litellm/integrations/otel/mappers/openinference.py b/litellm/integrations/otel/mappers/openinference.py index 0ba45170b8e..1e2dbf6974d 100644 --- a/litellm/integrations/otel/mappers/openinference.py +++ b/litellm/integrations/otel/mappers/openinference.py @@ -12,6 +12,7 @@ from typing import Final from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData from litellm.integrations.otel.mappers.utils import ( + MAX_MESSAGE_ATTRS_PER_SPAN, MAX_TOOL_DEFINITION_ATTRS_PER_SPAN, collect, drop_none, @@ -26,6 +27,8 @@ from litellm.integrations.otel.model.payloads import ( ToolDefinition, ) +_MAX_INDEXED_MESSAGES: Final = MAX_MESSAGE_ATTRS_PER_SPAN // 2 + class OpenInferenceMapper: """Emits OpenInference attributes for LLM_CALL spans. @@ -84,22 +87,35 @@ class OpenInferenceMapper: return {} def _llm_call(self, data: LLMCallSpanData) -> AttributeMap: + outputs: Final = output_messages(data) + indexed_in, indexed_out = self._indexed_split(len(data.messages_in), len(outputs)) return { **collect(self._LLM_CALL_ATTRS, data), **collect(self._BLOB_ATTRS, data), - **self._messages("llm.input_messages", "input.value", data.messages_in), - **self._messages("llm.output_messages", "output.value", output_messages(data)), + **self._messages("llm.input_messages", "input.value", data.messages_in, indexed_in), + **self._messages("llm.output_messages", "output.value", outputs, indexed_out), **self._tools(data), } @staticmethod - def _messages(prefix: str, value_key: str, messages: Sequence[object]) -> AttributeMap: - """Per-message ``{prefix}.{idx}.message.*`` keys + the ``value_key`` blob.""" + def _indexed_split(inputs: int, outputs: int) -> tuple[int, int]: + """How many prompt and response messages get per-index attributes. + + Both directions share one span-wide allowance. The response is reserved at + least half of it, so a long prompt can never push the completion off the + span, and the prompt takes whatever the response leaves unused. + """ + indexed_out: Final = min(outputs, max(_MAX_INDEXED_MESSAGES // 2, _MAX_INDEXED_MESSAGES - inputs)) + return _MAX_INDEXED_MESSAGES - indexed_out, indexed_out + + @staticmethod + def _messages(prefix: str, value_key: str, messages: Sequence[object], indexed: int) -> AttributeMap: + """``{prefix}.{idx}.message.*`` keys for the leading ``indexed`` messages + the ``value_key`` blob of all.""" parsed: Final = [(m.get("role") if isinstance(m, dict) else None, message_content(m)) for m in messages] attrs: Final = drop_none( { key: value - for idx, (role, content) in enumerate(parsed) + for idx, (role, content) in enumerate(parsed[:indexed]) for key, value in ( ( f"{prefix}.{idx}.message.role", diff --git a/litellm/integrations/otel/mappers/utils.py b/litellm/integrations/otel/mappers/utils.py index d45dca782b2..8d21b774319 100644 --- a/litellm/integrations/otel/mappers/utils.py +++ b/litellm/integrations/otel/mappers/utils.py @@ -32,6 +32,18 @@ core telemetry no matter how many vocabularies are configured. """ +MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 4 +"""Span-wide ceiling on attributes spent spelling out chat messages per index. + +A conversation is the other unbounded family: two attributes per message, for +the prompt and the response alike, on the same span. Past a few dozen turns the +family alone exceeds the span attribute limit and evicts the core telemetry +written before it. The ceiling covers both directions together, since a budget +handed to each direction separately doubles. The complete conversation still +rides the JSON blob attributes; only the per-index convenience keys are capped. +""" + + def tool_attr_budget(vocabularies: int) -> int: """Split the span-wide tool-definition ceiling across active vocabularies.""" return MAX_TOOL_DEFINITION_ATTRS_PER_SPAN // max(vocabularies, 1) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index b1b1b62c820..a417bd62124 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -1,6 +1,8 @@ """Golden tests for the OTel v2 engine: span shape, kinds, semconv attributes, legacy dual-emit, hierarchy, error status, and idempotency. Needs the OTel SDK.""" +import json + import pytest pytest.importorskip("opentelemetry") @@ -18,6 +20,7 @@ from litellm.integrations.otel.plumbing import providers # noqa: E402 from litellm.integrations.otel.emitter import SpanEmitter # noqa: E402 from litellm.integrations.otel.emitter import stamp_error # noqa: E402 from litellm.integrations.otel.mappers.utils import ( # noqa: E402 + MAX_MESSAGE_ATTRS_PER_SPAN, MAX_TOOL_DEFINITION_ATTRS_PER_SPAN, ) from litellm.integrations.otel.model.payloads import ( # noqa: E402 @@ -440,3 +443,148 @@ def test_vendor_tool_definitions_are_truncated_not_dropped(): assert a["llm.tools.0.tool.name"] == "tool_0" assert a["llm.tools.0.tool.json_schema"] assert "llm.tools.126.tool.name" not in a + + +def _conversation_payload(turns, choices=1, **overrides): + """A ``turns``-message chat with ``choices`` response choices, content-bearing.""" + return _payload( + messages=[{"role": ("user", "assistant")[i % 2], "content": f"turn {i}"} for i in range(turns)], + response={ + "id": "resp_1", + "model": "gpt-4o-2024", + "choices": [ + {"finish_reason": "stop", "message": {"role": "assistant", "content": f"reply {i}"}} + for i in range(choices) + ], + }, + **overrides, + ) + + +def _conversation_span(mapper_names, payload): + """The exported LLM-call span for ``payload`` with content capture on.""" + cfg = OpenTelemetryV2Config( + exporter="in_memory", + mapper_names=list(mapper_names), + capture_message_content="span_only", + ) + provider, exporter = providers.in_memory_provider(cfg) + engine = SpanEmitter(providers.get_tracer(provider, "litellm-test"), cfg) + engine.emit( + SpanRole.LLM_CALL, + LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True), + ) + (span,) = exporter.get_finished_spans() + return span + + +def _indexed_message_count(attributes, prefix): + return len({key.split(".")[2] for key in attributes if key.startswith(f"{prefix}.")}) + + +@pytest.mark.parametrize("turns", [60, 200]) +def test_long_conversation_does_not_evict_core_attributes(turns): + """Per-message OpenInference attributes must never crowd core telemetry off the span. + + With content capture on, the OpenInference vocabulary spells every prompt and + response message out as two per-index attributes. A few dozen turns overruns + the OTel SDK's 128-attribute span limit, which evicts oldest-first, so the + ``gen_ai.*`` set written before it is what disappears. + """ + span = _conversation_span(["genai", "openinference"], _conversation_payload(turns)) + a = span.attributes + + assert span.dropped_attributes == 0 + assert a[GenAI.REQUEST_MODEL] == "gpt-4o" + assert a[GenAI.PROVIDER_NAME] == "openai" + assert a[GenAI.USAGE_INPUT_TOKENS] == 10 + assert a[GenAI.USAGE_OUTPUT_TOKENS] == 5 + assert a[GenAI.RESPONSE_FINISH_REASONS] == ("stop",) + assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002 + + assert a["llm.input_messages.0.message.content"] == "turn 0" + assert a["llm.output_messages.0.message.content"] == "reply 0" + assert f"llm.input_messages.{turns - 1}.message.role" not in a + assert len(json.loads(a["input.value"])) == turns + assert len(json.loads(a["output.value"])) == 1 + assert len(json.loads(a[GenAI.INPUT_MESSAGES])) == turns + + +def test_short_conversation_keeps_every_message_indexed(): + """Below the cap nothing is truncated in either direction.""" + a = _conversation_span(["genai", "openinference"], _conversation_payload(4, choices=2)).attributes + for idx in range(4): + assert a[f"llm.input_messages.{idx}.message.content"] == f"turn {idx}" + for idx in range(2): + assert a[f"llm.output_messages.{idx}.message.content"] == f"reply {idx}" + + +def test_message_cap_is_shared_across_input_and_output(): + """One span-wide allowance covers both directions, and the response always keeps a share. + + A long prompt takes what a single reply leaves over, and a many-choice reply + cannot take the whole allowance away from the prompt either. + """ + long_prompt = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=1)).attributes + many_choices = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=20)).attributes + + single_reply_indexed = _indexed_message_count(long_prompt, "llm.output_messages") + assert single_reply_indexed == 1 + assert _indexed_message_count(long_prompt, "llm.input_messages") + single_reply_indexed == ( + MAX_MESSAGE_ATTRS_PER_SPAN // 2 + ) + + assert _indexed_message_count(many_choices, "llm.input_messages") > 0 + assert _indexed_message_count(many_choices, "llm.output_messages") > single_reply_indexed + assert _indexed_message_count(many_choices, "llm.input_messages") + _indexed_message_count( + many_choices, "llm.output_messages" + ) == (MAX_MESSAGE_ATTRS_PER_SPAN // 2) + + +def test_fully_populated_arize_span_stays_within_the_attribute_limit(): + """Every capped family maxed at once still leaves the whole core intact. + + The Arize / Phoenix composition (``genai`` + ``openinference`` + ``legacy``) + with every request parameter, every cost component, a hundred-plus tools, a + two-hundred-turn prompt and twenty choices is the worst case the two + span-wide ceilings have to absorb together. + """ + payload = _conversation_payload( + 200, + choices=20, + stream=True, + model_parameters={ + **_tools_payload(127)["model_parameters"], + "top_p": 0.9, + "frequency_penalty": 0.1, + "presence_penalty": 0.1, + "seed": 7, + "stop": ["\n"], + }, + cost_breakdown={ + key: 0.001 + for key in ( + "input_cost", + "output_cost", + "cache_read_cost", + "cache_creation_cost", + "tool_usage_cost", + "original_cost", + "discount_amount", + "discount_percent", + "margin_fixed_amount", + "margin_percent", + "margin_total_amount", + "total_cost", + ) + }, + ) + span = _conversation_span(["genai", "openinference"], payload) + a = span.attributes + + assert span.dropped_attributes == 0 + assert a[GenAI.REQUEST_MODEL] == "gpt-4o" + assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002 + assert a[LiteLLM.TOOLS_DECLARED] == 127 + assert a["llm.input_messages.0.message.content"] == "turn 0" + assert a["llm.output_messages.0.message.content"] == "reply 0" From fcaf2d7d98164fc8561c411a999ffd42469797e9 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 10 Sep 2026 08:00:51 +0000 Subject: [PATCH 004/196] fix(otel): size the message ceiling so every vocabulary fits beside it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/mappers/utils.py | 9 ++++++--- .../integrations/otel/test_otel_v2_emitter.py | 15 ++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/otel/mappers/utils.py b/litellm/integrations/otel/mappers/utils.py index 8d21b774319..d8918491720 100644 --- a/litellm/integrations/otel/mappers/utils.py +++ b/litellm/integrations/otel/mappers/utils.py @@ -32,15 +32,18 @@ core telemetry no matter how many vocabularies are configured. """ -MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 4 +MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 8 """Span-wide ceiling on attributes spent spelling out chat messages per index. A conversation is the other unbounded family: two attributes per message, for the prompt and the response alike, on the same span. Past a few dozen turns the family alone exceeds the span attribute limit and evicts the core telemetry written before it. The ceiling covers both directions together, since a budget -handed to each direction separately doubles. The complete conversation still -rides the JSON blob attributes; only the per-index convenience keys are capped. +handed to each direction separately doubles. An eighth is the largest share +that still fits beside the tool ceiling and the core of every vocabulary at +once, request parameters, cost breakdown and identity included. The complete +conversation still rides the JSON blob attributes; only the per-index +convenience keys are capped. """ diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index a417bd62124..f571ab7004b 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -461,10 +461,11 @@ def _conversation_payload(turns, choices=1, **overrides): ) -def _conversation_span(mapper_names, payload): +def _conversation_span(mapper_names, payload, legacy_compat=False): """The exported LLM-call span for ``payload`` with content capture on.""" cfg = OpenTelemetryV2Config( exporter="in_memory", + legacy_compat=legacy_compat, mapper_names=list(mapper_names), capture_message_content="span_only", ) @@ -541,13 +542,13 @@ def test_message_cap_is_shared_across_input_and_output(): ) == (MAX_MESSAGE_ATTRS_PER_SPAN // 2) -def test_fully_populated_arize_span_stays_within_the_attribute_limit(): +def test_fully_populated_span_with_every_vocabulary_stays_within_the_attribute_limit(): """Every capped family maxed at once still leaves the whole core intact. - The Arize / Phoenix composition (``genai`` + ``openinference`` + ``legacy``) - with every request parameter, every cost component, a hundred-plus tools, a - two-hundred-turn prompt and twenty choices is the worst case the two - span-wide ceilings have to absorb together. + Every vocabulary in the registry plus ``legacy``, every request parameter, + every cost component, a hundred-plus tools, a two-hundred-turn prompt and + twenty choices is the worst case the two span-wide ceilings have to absorb + together. """ payload = _conversation_payload( 200, @@ -579,7 +580,7 @@ def test_fully_populated_arize_span_stays_within_the_attribute_limit(): ) }, ) - span = _conversation_span(["genai", "openinference"], payload) + span = _conversation_span(["genai", "openinference", "langfuse", "weave", "langtrace"], payload, legacy_compat=True) a = span.attributes assert span.dropped_attributes == 0 From 135ec00b27ac2452611032336c743c57f64ccae7 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Fri, 11 Sep 2026 08:06:09 +0000 Subject: [PATCH 005/196] feat(model_armor): logging_only mode scans completed streams after delivery Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 25 +- .../model_armor/model_armor.py | 68 ++++- .../integrations/test_custom_guardrail.py | 57 +++- .../guardrail_hooks/test_model_armor.py | 289 ++++++++++++++++++ 4 files changed, 428 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 77bf4820a1a..ae6d646b5ae 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -30,6 +30,7 @@ from litellm.types.utils import ( GuardrailStatus, GuardrailTracingDetail, LLMResponseTypes, + ModelResponse, StandardLoggingGuardrailInformation, ) @@ -602,9 +603,7 @@ class CustomGuardrail(CustomLogger): supported_event_hooks: list[GuardrailEventHooks], ) -> None: allowed_hooks: Final = frozenset(supported_event_hooks) | ( - frozenset((GuardrailEventHooks.logging_only,)) - if self.uses_apply_guardrail_interface() and not self.use_native_lifecycle_hooks - else frozenset() + frozenset((GuardrailEventHooks.logging_only,)) if self.uses_apply_guardrail_interface() else frozenset() ) def _validate_event_hook_list_is_in_supported_event_hooks( @@ -883,7 +882,9 @@ class CustomGuardrail(CustomLogger): """logging_only: run apply_guardrail on copies of the logged request/response and record the verdict.""" from litellm.llms import get_guardrail_translation_mapping - if not self.uses_apply_guardrail_interface() or self.use_native_lifecycle_hooks: + if not self.uses_apply_guardrail_interface(): + return kwargs, result + if not self._event_hook_is_event_type(GuardrailEventHooks.logging_only): return kwargs, result try: translation: Final = get_guardrail_translation_mapping(CallTypes(call_type))() @@ -922,6 +923,8 @@ class CustomGuardrail(CustomLogger): translation: "BaseTranslation", scratch_metadata: dict, # mutable-ok: apply_guardrail records its verdict into request metadata ) -> None: + from litellm.llms import get_guardrail_translation_mapping + optional_params: Final = kwargs.get("optional_params") or {} scratch_input: Final = copy.deepcopy(kwargs.get("messages") or kwargs.get("input")) scratch_request: Final = { @@ -933,8 +936,18 @@ class CustomGuardrail(CustomLogger): "metadata": scratch_metadata, } await translation.process_input_messages(data=scratch_request, guardrail_to_apply=self) - await translation.process_output_response( - response=copy.deepcopy(result), guardrail_to_apply=self, request_data=scratch_request + response: Final = ( + kwargs.get("async_complete_streaming_response") or kwargs.get("complete_streaming_response") or result + ) + if response is None: + return + output_translation: Final = ( + get_guardrail_translation_mapping(CallTypes.acompletion)() + if isinstance(response, ModelResponse) + else translation + ) + await output_translation.process_output_response( + response=copy.deepcopy(response), guardrail_to_apply=self, request_data=scratch_request ) def supports_scan_only_tool_results(self) -> bool: diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index fde40111d49..01b05227a91 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1,11 +1,13 @@ +import time from collections.abc import AsyncGenerator, Mapping, Sequence from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Final, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional import httpx from fastapi import HTTPException if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel import json @@ -52,6 +54,7 @@ from litellm.types.utils import ( CallTypes, CallTypesLiteral, Choices, + GenericGuardrailAPIInputs, GuardrailStatus, ModelResponse, ModelResponseStream, @@ -118,8 +121,12 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): Supports: - Pre-call sanitization (sanitizeUserPrompt) - Post-call sanitization (sanitizeModelResponse) + - logging_only: scans the completed response after it reaches the client and + records the verdict in spend logs without blocking """ + use_native_lifecycle_hooks: ClassVar[bool] = True + @classmethod def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: return [ @@ -128,6 +135,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): GuardrailEventHooks.post_call, GuardrailEventHooks.pre_mcp_call, GuardrailEventHooks.during_mcp_call, + GuardrailEventHooks.logging_only, ] def __init__( @@ -1096,6 +1104,11 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): add_guardrail_to_applied_guardrails_header, ) + if self.should_run_guardrail(data=request_data, event_type=GuardrailEventHooks.post_call) is not True: + async for chunk in response: + yield chunk + return + all_chunks: Final[Sequence[object]] = tuple([chunk async for chunk in response]) if not all_chunks or self._is_terminal_error_stream(all_chunks): @@ -1213,6 +1226,59 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): for chunk in all_chunks: yield chunk + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + content: Final = "\n".join(text for text in inputs.get("texts") or () if text) + if not content: + return inputs + + source: Final[Literal["user_prompt", "model_response"]] = ( + "user_prompt" if input_type == "request" else "model_response" + ) + start_time: Final = time.time() + try: + armor_response: Final = await self.make_model_armor_request( + content=content, source=source, request_data=request_data + ) + except ModelArmorAPIError as e: + error_end_time: Final = time.time() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=str(e), + request_data=request_data, + guardrail_status="guardrail_failed_to_respond", + guardrail_provider="model_armor", + start_time=start_time, + end_time=error_end_time, + duration=error_end_time - start_time, + ) + raise + + flagged: Final = self._should_block_content(armor_response, allow_sanitization=False) + end_time: Final = time.time() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=self._build_logging_response(armor_response), + request_data=request_data, + guardrail_status="guardrail_flagged" if flagged else "success", + guardrail_provider="model_armor", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + ) + if flagged: + raise HTTPException( + status_code=400, + detail=self._build_block_error_detail( + "Response blocked by Model Armor" if input_type == "response" else "Violated content safety policy", + armor_response, + ), + ) + return inputs + @staticmethod def get_config_model() -> type["GuardrailConfigModel"] | None: """ diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index ddc8439a83a..c0463210445 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -2404,7 +2404,7 @@ def test_logging_only_requires_framework_support_or_explicit_declaration( event_hook: GuardrailEventHooks | str | list[GuardrailEventHooks] | list[str] | Mode, ) -> None: supported: Final = [GuardrailEventHooks.pre_call] - if guardrail_type is _InheritedApplyGuardrail: + if guardrail_type is not CustomGuardrail: guardrail: Final = guardrail_type(event_hook=event_hook, supported_event_hooks=supported) assert guardrail.event_hook == event_hook assert supported == [GuardrailEventHooks.pre_call] @@ -2566,7 +2566,7 @@ class TestLoggingOnlyApplyGuardrail: assert [e["guardrail_status"] for e in entries] == ["success"] @pytest.mark.asyncio - async def test_native_lifecycle_hook_guardrail_is_left_alone(self): + async def test_native_lifecycle_hook_guardrail_scans_in_logging_only(self): class _NativeHooks(_ApplyOnlyObserver): use_native_lifecycle_hooks = True @@ -2575,9 +2575,9 @@ class TestLoggingOnlyApplyGuardrail: out_kwargs, out_response = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value) - assert guardrail.calls == [] - assert out_kwargs is kwargs + assert guardrail.calls == [("request", ["hello there"]), ("response", ["general kenobi"])] assert out_response is response + assert out_kwargs["standard_logging_object"]["guardrail_information"] @pytest.mark.asyncio async def test_aresponses_scans_logged_messages_when_input_is_cleared(self): @@ -2829,3 +2829,52 @@ class TestCustomGuardrailPostCallSuccessDeploymentHook: assert response.choices[0].message.content == "filtered response" assert "guardrail_to_apply" not in request_data assert len(_guardrail_entries(request_data)) == 1 + + +class _NativeLifecycleLoggingGuardrail(CustomGuardrail): + """Native lifecycle guardrail that also implements apply_guardrail, like the azure guards.""" + + use_native_lifecycle_hooks: ClassVar[bool] = True + + def __init__(self): + from litellm.types.guardrails import GuardrailEventHooks + + super().__init__( + guardrail_name="native-logging-guardrail", + event_hook=GuardrailEventHooks.logging_only, + ) + self.calls: list = [] + + async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + self.calls.append((input_type, list(inputs.get("texts") or []))) + return inputs + + +@pytest.mark.asyncio +async def test_native_lifecycle_guardrail_logging_only_scans_assembled_response(): + """A use_native_lifecycle_hooks guardrail accepts mode logging_only and its + async_logging_hook scans kwargs["async_complete_streaming_response"], not the raw result.""" + from litellm.types.utils import Choices, Message, ModelResponse + + guardrail = _NativeLifecycleLoggingGuardrail() + assembled = ModelResponse( + choices=[Choices(message=Message(role="assistant", content="assembled stream text"))] + ) + sentinel_result = object() + kwargs = { + "model": "gpt-5.4-mini", + "messages": [{"role": "user", "content": "hi"}], + "litellm_call_id": "call-1", + "litellm_params": {"metadata": {}}, + "optional_params": {}, + "standard_logging_object": {"guardrail_information": None}, + "async_complete_streaming_response": assembled, + } + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=sentinel_result, call_type=CallTypes.acompletion.value + ) + + assert out_result is sentinel_result + assert ("response", ["assembled stream text"]) in guardrail.calls + assert out_kwargs["standard_logging_object"]["guardrail_information"] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py index 47089b7b1b1..a6ed4e14616 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py @@ -4929,3 +4929,292 @@ def test_every_responses_delta_event_is_in_the_scanned_set(): } assert not missing assert "response.mcp_call_arguments.delta" in _RESPONSES_DELTA_EVENT_TYPES + + +def _clean_armor_response() -> dict: + return { + "sanitizationResult": { + "filterMatchState": "NO_MATCH_FOUND", + "filterResults": {}, + } + } + + +def _flagged_armor_response() -> dict: + return { + "sanitizationResult": { + "filterMatchState": "MATCH_FOUND", + "filterResults": {"rai": {"raiFilterResult": {"matchState": "MATCH_FOUND"}}}, + } + } + + +def _logging_only_guardrail() -> ModelArmorGuardrail: + return ModelArmorGuardrail( + template_id="test-template", + project_id="test-project", + location="us-central1", + guardrail_name="model-armor-logging", + event_hook=GuardrailEventHooks.logging_only, + ) + + +def _logged_kwargs() -> dict: + return { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "hi"}], + "litellm_call_id": "call-1", + "litellm_params": {"metadata": {}}, + "optional_params": {}, + "standard_logging_object": {"guardrail_information": None}, + } + + +def _chat_response(text: str) -> litellm.ModelResponse: + return litellm.ModelResponse( + choices=[ + litellm.types.utils.Choices( + message=litellm.types.utils.Message(role="assistant", content=text) + ) + ] + ) + + +def _stream_chunk(text: str) -> litellm.ModelResponseStream: + return litellm.ModelResponseStream( + choices=[ + litellm.types.utils.StreamingChoices( + delta=litellm.types.utils.Delta(content=text) + ) + ] + ) + + +def _metadata_entries(kwargs: dict) -> list: + return kwargs["standard_logging_object"].get("guardrail_information") or [] + + +def test_logging_only_mode_is_accepted_and_keeps_native_hooks(): + guardrail = _logging_only_guardrail() + assert guardrail.event_hook == GuardrailEventHooks.logging_only + assert guardrail.use_native_lifecycle_hooks is True + assert GuardrailEventHooks.logging_only in ModelArmorGuardrail.get_supported_event_hooks() + + post_call_guardrail = ModelArmorGuardrail( + template_id="test-template", + project_id="test-project", + location="us-central1", + guardrail_name="model-armor-post", + event_hook=GuardrailEventHooks.post_call, + ) + assert post_call_guardrail._deployment_hook_target() is post_call_guardrail + + +@pytest.mark.asyncio +async def test_logging_only_stream_yields_chunks_without_waiting_for_scan(): + """A logging_only guardrail must pass stream chunks straight through; the scan happens + afterwards on the assembled response via async_logging_hook.""" + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock( + side_effect=AssertionError("logging_only must not scan the stream") + ) + + produced = 0 + + async def gen(): + nonlocal produced + for i in range(3): + produced += 1 + yield _stream_chunk(f"chunk-{i} ") + + hook_iter = guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(), + response=gen(), + request_data={"metadata": {}, "guardrails": ["model-armor-logging"]}, + ) + first = await hook_iter.__anext__() + assert produced == 1 + chunks = [first] + async for chunk in hook_iter: + chunks.append(chunk) + assert len(chunks) == 3 + guardrail.make_model_armor_request.assert_not_awaited() + + guardrail.make_model_armor_request = AsyncMock(return_value=_clean_armor_response()) + response = _chat_response("all clear") + kwargs = _logged_kwargs() + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + assert out_result is response + entries = _metadata_entries(out_kwargs) + assert len(entries) >= 1 + entry = entries[-1] + assert entry["guardrail_status"] == "success" + assert entry["guardrail_mode"] == "logging_only" + assert entry["guardrail_provider"] == "model_armor" + + +@pytest.mark.asyncio +async def test_logging_only_records_flagged_verdict_without_altering_response(): + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_flagged_armor_response()) + response = _chat_response("flagged output") + kwargs = _logged_kwargs() + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + + assert out_result is response + entries = _metadata_entries(out_kwargs) + assert entries[-1]["guardrail_status"] == "guardrail_flagged" + assert entries[-1]["guardrail_mode"] == "logging_only" + + +@pytest.mark.asyncio +async def test_logging_only_records_model_armor_api_error(): + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock( + side_effect=ModelArmorAPIError("Model Armor API error (upstream 500)") + ) + response = _chat_response("some output") + kwargs = _logged_kwargs() + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + + assert out_result is response + entries = _metadata_entries(out_kwargs) + assert entries[-1]["guardrail_status"] == "guardrail_failed_to_respond" + + +@pytest.mark.asyncio +async def test_logging_only_scans_assembled_responses_api_stream(): + """The terminal ResponseCompletedEvent is an envelope; the scan must run on the + assembled ResponsesAPIResponse kept in kwargs.""" + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + from litellm.types.llms.openai import ( + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + assembled = ResponsesAPIResponse( + id="resp-1", + created_at=1700000000, + output=[ + ResponseOutputMessage( + id="msg-1", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + annotations=[], text="assembled output text", type="output_text" + ) + ], + ) + ], + ) + event = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=assembled + ) + + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_clean_armor_response()) + kwargs = _logged_kwargs() + del kwargs["messages"] + kwargs["input"] = "hello" + kwargs["async_complete_streaming_response"] = assembled + + out_kwargs, _ = await guardrail.async_logging_hook( + kwargs=kwargs, result=event, call_type="aresponses" + ) + + response_scans = [ + call + for call in guardrail.make_model_armor_request.await_args_list + if call.kwargs.get("source") == "model_response" + ] + assert response_scans, "expected a model_response scan of the assembled response" + assert "assembled output text" in response_scans[0].kwargs["content"] + assert _metadata_entries(out_kwargs) + + +@pytest.mark.asyncio +async def test_logging_only_scans_anthropic_messages_model_response(): + """/v1/messages logs a ModelResponse; the output scan must extract the assistant text.""" + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_clean_armor_response()) + kwargs = _logged_kwargs() + kwargs["messages"] = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + response = _chat_response("anthropic assembled text") + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="anthropic_messages" + ) + + assert out_result is response + response_scans = [ + call + for call in guardrail.make_model_armor_request.await_args_list + if call.kwargs.get("source") == "model_response" + ] + assert response_scans + assert "anthropic assembled text" in response_scans[0].kwargs["content"] + assert _metadata_entries(out_kwargs) + + +@pytest.mark.asyncio +async def test_logging_only_skips_output_scan_when_no_assembled_response(): + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_clean_armor_response()) + kwargs = _logged_kwargs() + + await guardrail.async_logging_hook(kwargs=kwargs, result=None, call_type="acompletion") + + sources = [call.kwargs.get("source") for call in guardrail.make_model_armor_request.await_args_list] + assert "model_response" not in sources + + +@pytest.mark.asyncio +async def test_native_post_call_mode_ignores_logging_hook(): + guardrail = ModelArmorGuardrail( + template_id="test-template", + project_id="test-project", + location="us-central1", + guardrail_name="model-armor-post", + event_hook=GuardrailEventHooks.post_call, + ) + guardrail.make_model_armor_request = AsyncMock(return_value=_clean_armor_response()) + response = _chat_response("some output") + kwargs = _logged_kwargs() + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + + assert out_kwargs is kwargs + assert out_result is response + guardrail.make_model_armor_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_apply_guardrail_raises_on_flagged_content(): + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_flagged_armor_response()) + request_data = {"metadata": {}} + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": ["forbidden output"]}, + request_data=request_data, + input_type="response", + ) + + assert exc_info.value.status_code == 400 + entries = request_data["metadata"]["standard_logging_guardrail_information"] + assert entries[-1]["guardrail_status"] == "guardrail_flagged" From a5cc65f1a9b5f7a674d98f2e45460790952724e5 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Fri, 11 Sep 2026 08:10:35 +0000 Subject: [PATCH 006/196] refactor(custom_guardrail): resolve logging_only output translation in async_logging_hook Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index ae6d646b5ae..305a5c20764 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -901,8 +901,16 @@ class CustomGuardrail(CustomLogger): for key, value in (litellm_params.get("metadata") or {}).items() if key != "standard_logging_guardrail_information" } + response: Final = ( + kwargs.get("async_complete_streaming_response") or kwargs.get("complete_streaming_response") or result + ) + output_translation: Final = ( + get_guardrail_translation_mapping(CallTypes.acompletion)() + if isinstance(response, ModelResponse) + else translation + ) try: - await self._scan_logged_call(kwargs, result, translation, scratch_metadata) + await self._scan_logged_call(kwargs, response, translation, output_translation, scratch_metadata) except Exception as e: verbose_logger.warning("Guardrail %s: logging_only scan raised: %s", self.guardrail_name, e) recorded: Final = scratch_metadata.get("standard_logging_guardrail_information") @@ -919,12 +927,11 @@ class CustomGuardrail(CustomLogger): async def _scan_logged_call( self, kwargs: dict, # mutable-ok: CustomLogger.async_logging_hook contract - result: object, + response: object | None, translation: "BaseTranslation", + output_translation: "BaseTranslation", scratch_metadata: dict, # mutable-ok: apply_guardrail records its verdict into request metadata ) -> None: - from litellm.llms import get_guardrail_translation_mapping - optional_params: Final = kwargs.get("optional_params") or {} scratch_input: Final = copy.deepcopy(kwargs.get("messages") or kwargs.get("input")) scratch_request: Final = { @@ -936,16 +943,8 @@ class CustomGuardrail(CustomLogger): "metadata": scratch_metadata, } await translation.process_input_messages(data=scratch_request, guardrail_to_apply=self) - response: Final = ( - kwargs.get("async_complete_streaming_response") or kwargs.get("complete_streaming_response") or result - ) if response is None: return - output_translation: Final = ( - get_guardrail_translation_mapping(CallTypes.acompletion)() - if isinstance(response, ModelResponse) - else translation - ) await output_translation.process_output_response( response=copy.deepcopy(response), guardrail_to_apply=self, request_data=scratch_request ) From f417d7f739fca2b8abed6ad3d4a399e39b2cfee7 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Fri, 11 Sep 2026 08:49:11 +0000 Subject: [PATCH 007/196] fix(model_armor): record logging_only verdicts without raising so both scans run Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../model_armor/model_armor.py | 12 +---- .../guardrail_hooks/test_model_armor.py | 53 ++++++++++++++++--- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 01b05227a91..127a7ea6786 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1245,7 +1245,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): armor_response: Final = await self.make_model_armor_request( content=content, source=source, request_data=request_data ) - except ModelArmorAPIError as e: + except (ModelArmorAPIError, httpx.HTTPError) as e: error_end_time: Final = time.time() self.add_standard_logging_guardrail_information_to_request_data( guardrail_json_response=str(e), @@ -1256,7 +1256,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): end_time=error_end_time, duration=error_end_time - start_time, ) - raise + return inputs flagged: Final = self._should_block_content(armor_response, allow_sanitization=False) end_time: Final = time.time() @@ -1269,14 +1269,6 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): end_time=end_time, duration=end_time - start_time, ) - if flagged: - raise HTTPException( - status_code=400, - detail=self._build_block_error_detail( - "Response blocked by Model Armor" if input_type == "response" else "Violated content safety policy", - armor_response, - ), - ) return inputs @staticmethod diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py index a6ed4e14616..bd358e84148 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py @@ -5203,18 +5203,55 @@ async def test_native_post_call_mode_ignores_logging_hook(): @pytest.mark.asyncio -async def test_apply_guardrail_raises_on_flagged_content(): +async def test_apply_guardrail_records_flagged_without_raising(): guardrail = _logging_only_guardrail() guardrail.make_model_armor_request = AsyncMock(return_value=_flagged_armor_response()) request_data = {"metadata": {}} + inputs = {"texts": ["forbidden output"]} - with pytest.raises(HTTPException) as exc_info: - await guardrail.apply_guardrail( - inputs={"texts": ["forbidden output"]}, - request_data=request_data, - input_type="response", - ) + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) - assert exc_info.value.status_code == 400 + assert result == inputs entries = request_data["metadata"]["standard_logging_guardrail_information"] assert entries[-1]["guardrail_status"] == "guardrail_flagged" + + +@pytest.mark.asyncio +async def test_logging_only_records_transport_error(): + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(side_effect=httpx.ConnectError("boom")) + response = _chat_response("some output") + kwargs = _logged_kwargs() + + out_kwargs, out_result = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + + assert out_result is response + entries = _metadata_entries(out_kwargs) + failed = [e for e in entries if e["guardrail_status"] == "guardrail_failed_to_respond"] + assert failed + assert all(e["guardrail_provider"] == "model_armor" for e in failed) + + +@pytest.mark.asyncio +async def test_logging_only_flagged_prompt_still_scans_response(): + """A flagged input scan must not abort the output scan; both verdicts are recorded.""" + guardrail = _logging_only_guardrail() + guardrail.make_model_armor_request = AsyncMock(return_value=_flagged_armor_response()) + response = _chat_response("flagged output") + kwargs = _logged_kwargs() + + out_kwargs, _ = await guardrail.async_logging_hook( + kwargs=kwargs, result=response, call_type="acompletion" + ) + + sources = [call.kwargs.get("source") for call in guardrail.make_model_armor_request.await_args_list] + assert sources == ["user_prompt", "model_response"] + entries = _metadata_entries(out_kwargs) + flagged = [e for e in entries if e["guardrail_status"] == "guardrail_flagged"] + assert len(flagged) == 2 From 39b916f13f3330b5099ff45ede27e59f7278f53a Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Fri, 11 Sep 2026 09:07:06 +0000 Subject: [PATCH 008/196] fix(model_armor): decorate apply_guardrail with log_guardrail_information Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/guardrails/guardrail_hooks/model_armor/model_armor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 127a7ea6786..a833de6096d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1226,6 +1226,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): for chunk in all_chunks: yield chunk + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, From 2ca29a9a9157d52149a4c2cd38e98f89c7f57e30 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Fri, 11 Sep 2026 09:33:57 +0000 Subject: [PATCH 009/196] fix(model_armor): gate apply_guardrail raise to non-logging_only and require native guardrails to declare logging_only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 4 ++- .../model_armor/model_armor.py | 12 +++++++-- .../integrations/test_custom_guardrail.py | 2 +- .../guardrail_hooks/test_model_armor.py | 27 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 305a5c20764..407445b2828 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -603,7 +603,9 @@ class CustomGuardrail(CustomLogger): supported_event_hooks: list[GuardrailEventHooks], ) -> None: allowed_hooks: Final = frozenset(supported_event_hooks) | ( - frozenset((GuardrailEventHooks.logging_only,)) if self.uses_apply_guardrail_interface() else frozenset() + frozenset((GuardrailEventHooks.logging_only,)) + if self.uses_apply_guardrail_interface() and not self.use_native_lifecycle_hooks + else frozenset() ) def _validate_event_hook_list_is_in_supported_event_hooks( diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index a833de6096d..a0563a7a1c9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1,7 +1,7 @@ import time from collections.abc import AsyncGenerator, Mapping, Sequence from enum import Enum, auto -from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal import httpx from fastapi import HTTPException @@ -1232,7 +1232,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): inputs: GenericGuardrailAPIInputs, request_data: dict, input_type: Literal["request", "response"], - logging_obj: Optional["LiteLLMLoggingObj"] = None, + logging_obj: "LiteLLMLoggingObj | None" = None, ) -> GenericGuardrailAPIInputs: content: Final = "\n".join(text for text in inputs.get("texts") or () if text) if not content: @@ -1270,6 +1270,14 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): end_time=end_time, duration=end_time - start_time, ) + if flagged and not self._event_hook_is_event_type(GuardrailEventHooks.logging_only): + raise HTTPException( + status_code=400, + detail=self._build_block_error_detail( + "Response blocked by Model Armor" if input_type == "response" else "Content blocked by Model Armor", + armor_response, + ), + ) return inputs @staticmethod diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index c0463210445..bdb7fad21b3 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -2404,7 +2404,7 @@ def test_logging_only_requires_framework_support_or_explicit_declaration( event_hook: GuardrailEventHooks | str | list[GuardrailEventHooks] | list[str] | Mode, ) -> None: supported: Final = [GuardrailEventHooks.pre_call] - if guardrail_type is not CustomGuardrail: + if guardrail_type is _InheritedApplyGuardrail: guardrail: Final = guardrail_type(event_hook=event_hook, supported_event_hooks=supported) assert guardrail.event_hook == event_hook assert supported == [GuardrailEventHooks.pre_call] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py index bd358e84148..126e162fec8 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py @@ -5255,3 +5255,30 @@ async def test_logging_only_flagged_prompt_still_scans_response(): entries = _metadata_entries(out_kwargs) flagged = [e for e in entries if e["guardrail_status"] == "guardrail_flagged"] assert len(flagged) == 2 + + +@pytest.mark.asyncio +async def test_apply_guardrail_raises_on_flagged_when_not_logging_only(): + """The /guardrails/apply_guardrail endpoint calls apply_guardrail directly; a + non-logging_only instance must signal the block so flagged text is not returned as clean.""" + guardrail = ModelArmorGuardrail( + template_id="test-template", + project_id="test-project", + location="us-central1", + guardrail_name="model-armor-pre", + event_hook=GuardrailEventHooks.pre_call, + ) + guardrail.make_model_armor_request = AsyncMock(return_value=_flagged_armor_response()) + request_data = {"metadata": {}} + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": ["forbidden prompt"]}, + request_data=request_data, + input_type="request", + ) + + assert exc_info.value.status_code == 400 + entries = request_data["metadata"]["standard_logging_guardrail_information"] + flagged = [e for e in entries if e["guardrail_status"] == "guardrail_flagged"] + assert len(flagged) == 1 From a067557dae5c0bb52fdb11176437a39c9a0d9ac3 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 11 Sep 2026 23:16:53 +0000 Subject: [PATCH 010/196] fix(otel): index the opener and the latest prompt turns, not the oldest A value length limit clips the input.value blob, so the per-index keys are the only untruncated copy of a message. Indexing the leading prompt messages dropped the live user turn from every span attribute on long conversations. Keep message 0 and the most recent turns under the same span-wide budget, original indices preserved, reply reservation unchanged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../otel/mappers/openinference.py | 31 +++++++++++++------ .../integrations/otel/test_otel_v2_emitter.py | 29 ++++++++++++++++- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/otel/mappers/openinference.py b/litellm/integrations/otel/mappers/openinference.py index 1e2dbf6974d..1fa19b8d4a5 100644 --- a/litellm/integrations/otel/mappers/openinference.py +++ b/litellm/integrations/otel/mappers/openinference.py @@ -92,8 +92,13 @@ class OpenInferenceMapper: return { **collect(self._LLM_CALL_ATTRS, data), **collect(self._BLOB_ATTRS, data), - **self._messages("llm.input_messages", "input.value", data.messages_in, indexed_in), - **self._messages("llm.output_messages", "output.value", outputs, indexed_out), + **self._messages( + "llm.input_messages", + "input.value", + data.messages_in, + self._prompt_positions(len(data.messages_in), indexed_in), + ), + **self._messages("llm.output_messages", "output.value", outputs, range(indexed_out)), **self._tools(data), } @@ -109,18 +114,26 @@ class OpenInferenceMapper: return _MAX_INDEXED_MESSAGES - indexed_out, indexed_out @staticmethod - def _messages(prefix: str, value_key: str, messages: Sequence[object], indexed: int) -> AttributeMap: - """``{prefix}.{idx}.message.*`` keys for the leading ``indexed`` messages + the ``value_key`` blob of all.""" + def _prompt_positions(total: int, indexed: int) -> tuple[int, ...]: + """Which prompt messages get per-index attributes: message 0 and the most recent turns. + + A value length limit clips the ``input.value`` blob, so the system prompt and the + live turn each keep a short key of their own. The middle of a long prompt does not. + """ + if total <= indexed: + return tuple(range(total)) + return (0, *range(total - indexed + 1, total)) + + @staticmethod + def _messages(prefix: str, value_key: str, messages: Sequence[object], positions: Sequence[int]) -> AttributeMap: + """``{prefix}.{idx}.message.*`` keys for the messages at ``positions`` + the ``value_key`` blob of all.""" parsed: Final = [(m.get("role") if isinstance(m, dict) else None, message_content(m)) for m in messages] attrs: Final = drop_none( { key: value - for idx, (role, content) in enumerate(parsed[:indexed]) + for idx, (role, content) in ((idx, parsed[idx]) for idx in positions) for key, value in ( - ( - f"{prefix}.{idx}.message.role", - role if isinstance(role, str) else None, - ), + (f"{prefix}.{idx}.message.role", role if isinstance(role, str) else None), (f"{prefix}.{idx}.message.content", content), ) } diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index f571ab7004b..6491ab0f79f 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -505,7 +505,8 @@ def test_long_conversation_does_not_evict_core_attributes(turns): assert a["llm.input_messages.0.message.content"] == "turn 0" assert a["llm.output_messages.0.message.content"] == "reply 0" - assert f"llm.input_messages.{turns - 1}.message.role" not in a + assert a[f"llm.input_messages.{turns - 1}.message.content"] == f"turn {turns - 1}" + assert f"llm.input_messages.{turns // 2}.message.role" not in a assert len(json.loads(a["input.value"])) == turns assert len(json.loads(a["output.value"])) == 1 assert len(json.loads(a[GenAI.INPUT_MESSAGES])) == turns @@ -520,6 +521,31 @@ def test_short_conversation_keeps_every_message_indexed(): assert a[f"llm.output_messages.{idx}.message.content"] == f"reply {idx}" +def test_indexed_prompt_keeps_opener_and_latest_turns_under_a_value_length_limit(monkeypatch): + """The per-index keys are the only untruncated copy once the SDK clips string values. + + Operators bound attribute sizes with ``OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT``, + which cuts the ``input.value`` blob short. The system prompt and the live turn + then have to survive as their own short keys, whatever the conversation length. + """ + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT", "256") + payload = _conversation_payload(60) + payload["messages"][0] = {"role": "system", "content": "be terse"} + payload["messages"][-1] = {"role": "user", "content": "LATEST-TURN"} + a = _conversation_span(["genai", "openinference"], payload).attributes + + assert len(a["input.value"]) == 256 + assert a["llm.input_messages.0.message.role"] == "system" + assert a["llm.input_messages.0.message.content"] == "be terse" + assert a["llm.input_messages.59.message.role"] == "user" + assert a["llm.input_messages.59.message.content"] == "LATEST-TURN" + assert a["llm.output_messages.0.message.content"] == "reply 0" + assert [int(key.split(".")[2]) for key in a if key.endswith("message.content") and key.startswith("llm.input_")] == [ + 0, + *range(54, 60), + ] + + def test_message_cap_is_shared_across_input_and_output(): """One span-wide allowance covers both directions, and the response always keeps a share. @@ -588,4 +614,5 @@ def test_fully_populated_span_with_every_vocabulary_stays_within_the_attribute_l assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002 assert a[LiteLLM.TOOLS_DECLARED] == 127 assert a["llm.input_messages.0.message.content"] == "turn 0" + assert a["llm.input_messages.199.message.content"] == "turn 199" assert a["llm.output_messages.0.message.content"] == "reply 0" From a15309dfe820836a41e914228359d7b5becc3744 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 11 Sep 2026 23:32:44 +0000 Subject: [PATCH 011/196] refactor(otel): trim the message cap docstrings to one line each Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../otel/mappers/openinference.py | 13 ++------- litellm/integrations/otel/mappers/utils.py | 13 ++------- .../integrations/otel/test_otel_v2_emitter.py | 29 +++---------------- 3 files changed, 9 insertions(+), 46 deletions(-) diff --git a/litellm/integrations/otel/mappers/openinference.py b/litellm/integrations/otel/mappers/openinference.py index 1fa19b8d4a5..a7e0f1af3ac 100644 --- a/litellm/integrations/otel/mappers/openinference.py +++ b/litellm/integrations/otel/mappers/openinference.py @@ -104,22 +104,13 @@ class OpenInferenceMapper: @staticmethod def _indexed_split(inputs: int, outputs: int) -> tuple[int, int]: - """How many prompt and response messages get per-index attributes. - - Both directions share one span-wide allowance. The response is reserved at - least half of it, so a long prompt can never push the completion off the - span, and the prompt takes whatever the response leaves unused. - """ + """Prompt and response share one allowance; the response is reserved at least half of it.""" indexed_out: Final = min(outputs, max(_MAX_INDEXED_MESSAGES // 2, _MAX_INDEXED_MESSAGES - inputs)) return _MAX_INDEXED_MESSAGES - indexed_out, indexed_out @staticmethod def _prompt_positions(total: int, indexed: int) -> tuple[int, ...]: - """Which prompt messages get per-index attributes: message 0 and the most recent turns. - - A value length limit clips the ``input.value`` blob, so the system prompt and the - live turn each keep a short key of their own. The middle of a long prompt does not. - """ + """Prompt messages that get per-index attributes: message 0 and the most recent turns.""" if total <= indexed: return tuple(range(total)) return (0, *range(total - indexed + 1, total)) diff --git a/litellm/integrations/otel/mappers/utils.py b/litellm/integrations/otel/mappers/utils.py index d8918491720..c023621d2ef 100644 --- a/litellm/integrations/otel/mappers/utils.py +++ b/litellm/integrations/otel/mappers/utils.py @@ -33,17 +33,10 @@ core telemetry no matter how many vocabularies are configured. MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 8 -"""Span-wide ceiling on attributes spent spelling out chat messages per index. +"""Span-wide ceiling on per-index chat message attributes, prompt and response together. -A conversation is the other unbounded family: two attributes per message, for -the prompt and the response alike, on the same span. Past a few dozen turns the -family alone exceeds the span attribute limit and evicts the core telemetry -written before it. The ceiling covers both directions together, since a budget -handed to each direction separately doubles. An eighth is the largest share -that still fits beside the tool ceiling and the core of every vocabulary at -once, request parameters, cost breakdown and identity included. The complete -conversation still rides the JSON blob attributes; only the per-index -convenience keys are capped. +An eighth is the largest share that still fits beside the tool ceiling and the core +of every vocabulary at once. The complete conversation still rides the JSON blobs. """ diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index 6491ab0f79f..16fbb242ebd 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -485,13 +485,7 @@ def _indexed_message_count(attributes, prefix): @pytest.mark.parametrize("turns", [60, 200]) def test_long_conversation_does_not_evict_core_attributes(turns): - """Per-message OpenInference attributes must never crowd core telemetry off the span. - - With content capture on, the OpenInference vocabulary spells every prompt and - response message out as two per-index attributes. A few dozen turns overruns - the OTel SDK's 128-attribute span limit, which evicts oldest-first, so the - ``gen_ai.*`` set written before it is what disappears. - """ + """Per-message OpenInference attributes must never crowd core telemetry off the span.""" span = _conversation_span(["genai", "openinference"], _conversation_payload(turns)) a = span.attributes @@ -522,12 +516,7 @@ def test_short_conversation_keeps_every_message_indexed(): def test_indexed_prompt_keeps_opener_and_latest_turns_under_a_value_length_limit(monkeypatch): - """The per-index keys are the only untruncated copy once the SDK clips string values. - - Operators bound attribute sizes with ``OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT``, - which cuts the ``input.value`` blob short. The system prompt and the live turn - then have to survive as their own short keys, whatever the conversation length. - """ + """The system prompt and the live turn keep their own keys once the SDK clips ``input.value``.""" monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT", "256") payload = _conversation_payload(60) payload["messages"][0] = {"role": "system", "content": "be terse"} @@ -547,11 +536,7 @@ def test_indexed_prompt_keeps_opener_and_latest_turns_under_a_value_length_limit def test_message_cap_is_shared_across_input_and_output(): - """One span-wide allowance covers both directions, and the response always keeps a share. - - A long prompt takes what a single reply leaves over, and a many-choice reply - cannot take the whole allowance away from the prompt either. - """ + """One span-wide allowance covers both directions, and the response always keeps a share.""" long_prompt = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=1)).attributes many_choices = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=20)).attributes @@ -569,13 +554,7 @@ def test_message_cap_is_shared_across_input_and_output(): def test_fully_populated_span_with_every_vocabulary_stays_within_the_attribute_limit(): - """Every capped family maxed at once still leaves the whole core intact. - - Every vocabulary in the registry plus ``legacy``, every request parameter, - every cost component, a hundred-plus tools, a two-hundred-turn prompt and - twenty choices is the worst case the two span-wide ceilings have to absorb - together. - """ + """Every capped family maxed at once still leaves the whole core intact.""" payload = _conversation_payload( 200, choices=20, From 107b4ec4db64985de0b3651f401b290ea09e81ed Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 12 Sep 2026 01:12:39 +0000 Subject: [PATCH 012/196] fix(redis): log a timeout streak once per interval instead of one line per cache call Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/caching/redis_cache.py | 140 ++++++++++++------ litellm/constants.py | 3 + tests/test_litellm/caching/test_dual_cache.py | 57 +++++++ .../test_litellm/caching/test_redis_cache.py | 57 +++++++ 4 files changed, 211 insertions(+), 46 deletions(-) diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 2c36995c4f8..eaac7ef7b0b 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -15,6 +15,7 @@ import hashlib import inspect import json import logging +import threading import time from collections.abc import Awaitable, Callable, Sequence from contextvars import ContextVar @@ -32,6 +33,7 @@ from litellm.constants import ( REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD, REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT, REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION, + REDIS_TIMEOUT_LOG_INTERVAL, ) from litellm.litellm_core_utils.core_helpers import _get_parent_otel_span_from_kwargs from litellm.litellm_core_utils.coroutine_checker import coroutine_checker @@ -404,13 +406,58 @@ class RedisCircuitBreakerOpenError(Exception): pass +class _RedisTimeoutLogThrottle: + """Admits one Redis timeout log line per interval and counts the timeouts it suppressed in between.""" + + def __init__(self, interval: float, clock: Callable[[], float] = time.time) -> None: + self.interval = interval + self._clock = clock + self._lock = threading.Lock() + self._last_logged_at: float | None = None + self._suppressed = 0 + + def admit(self) -> int | None: + """Return the number of timeouts suppressed since the last admitted line, or None to suppress this one.""" + with self._lock: + now: Final = self._clock() + if self._last_logged_at is not None and now - self._last_logged_at < self.interval: + self._suppressed += 1 + return None + suppressed: Final = self._suppressed + self._suppressed = 0 + self._last_logged_at = now + return suppressed + + +_redis_timeout_log_throttle: Final = _RedisTimeoutLogThrottle(REDIS_TIMEOUT_LOG_INTERVAL) + + def log_redis_failure( logger: logging.Logger, level: int, message: str, exc: BaseException, with_traceback: bool = False ) -> None: if isinstance(exc, RedisCircuitBreakerOpenError): - logger.debug("%s: %s", message, exc) + logger.debug("%s: %s", message, exc, stacklevel=2) return - logger.log(level, "%s: %s", message, exc, exc_info=exc if with_traceback else None) + exc_info: Final = exc if with_traceback else None + if not _is_redis_timeout_failure(exc): + logger.log(level, "%s: %s", message, exc, exc_info=exc_info, stacklevel=2) + return + suppressed: Final = _redis_timeout_log_throttle.admit() + if suppressed is None: + logger.debug("%s: %s", message, exc, stacklevel=2) + return + if suppressed == 0: + logger.log(level, "%s: %s", message, exc, exc_info=exc_info, stacklevel=2) + return + logger.log( + level, + "%s: %s (%d more Redis timeouts since the previous Redis timeout line were logged at DEBUG)", + message, + exc, + suppressed, + exc_info=exc_info, + stacklevel=2, + ) @dataclass(frozen=True, slots=True) @@ -783,10 +830,8 @@ class RedisCache(BaseCache): ## LOGGING ## end_time = time.time() _duration = end_time - start_time - verbose_logger.error( - "LiteLLM Redis Caching: increment_cache() - Got exception from REDIS %s, Writing value=%s", - str(e), - value, + log_redis_failure( + verbose_logger, logging.ERROR, "LiteLLM Redis Caching: increment_cache() - Got exception from REDIS", e ) raise e @@ -992,11 +1037,8 @@ class RedisCache(BaseCache): call_type=f"async_set_cache <- {_get_call_stack_info()}", ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async set() - Got exception from REDIS %s, key=%r, value=%r", - str(e), - key, - value, + log_redis_failure( + verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e ) raise e @@ -1044,10 +1086,8 @@ class RedisCache(BaseCache): event_metadata={"key": key}, ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async set() - Got exception from REDIS %s, Writing value=%s", - str(e), - value, + log_redis_failure( + verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e ) _record_swallowed_redis_failure(self._circuit_breaker, e) @@ -1094,7 +1134,6 @@ class RedisCache(BaseCache): start_time: Final = time.time() print_verbose(f"Set Async Redis Cache: key list: {cache_list}\nttl={ttl}, redis_version={self.redis_version}") - cache_value: Final = None try: async with _redis_client.pipeline(transaction=False) as pipe: results: Final = await self._pipeline_helper(pipe, cache_list, ttl) @@ -1131,10 +1170,11 @@ class RedisCache(BaseCache): ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async set_cache_pipeline() - Got exception from REDIS %s, Writing value=%s", - str(e), - cache_value, + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async set_cache_pipeline() - Got exception from REDIS", + e, ) _record_swallowed_redis_failure(self._circuit_breaker, e) @@ -1177,10 +1217,8 @@ class RedisCache(BaseCache): ) ) # NON blocking - notify users Redis is throwing an exception - verbose_logger.error( - "LiteLLM Redis Caching: async set() - Got exception from REDIS %s, Writing value=%s", - str(e), - value, + log_redis_failure( + verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e ) raise e @@ -1216,10 +1254,11 @@ class RedisCache(BaseCache): ) ) # NON blocking - notify users Redis is throwing an exception - verbose_logger.error( - "LiteLLM Redis Caching: async set_cache_sadd() - Got exception from REDIS %s, Writing value=%s", - str(e), - value, + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async set_cache_sadd() - Got exception from REDIS", + e, ) _record_swallowed_redis_failure(self._circuit_breaker, e) @@ -1288,10 +1327,11 @@ class RedisCache(BaseCache): parent_otel_span=parent_otel_span, ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async async_increment() - Got exception from REDIS %s, Writing value=%s", - str(e), - value, + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async async_increment() - Got exception from REDIS", + e, ) raise e @@ -1377,7 +1417,9 @@ class RedisCache(BaseCache): print_verbose(f"Got Redis Cache: key: {key}, cached_response {cached_response}") return self._get_cache_logic(cached_response=cached_response) except Exception as e: - verbose_logger.error("litellm.caching.caching: get() - Got exception from REDIS: %s", e) + log_redis_failure( + verbose_logger, logging.ERROR, "litellm.caching.caching: get() - Got exception from REDIS", e + ) _record_swallowed_redis_failure(self._circuit_breaker, e) def _run_redis_mget_operation(self, keys: list[str]) -> Sequence[bytes | str | None]: @@ -1455,7 +1497,7 @@ class RedisCache(BaseCache): end_time=failed_at, parent_otel_span=parent_otel_span, ) - verbose_logger.error("Error occurred in batch get cache - %s", e) + log_redis_failure(verbose_logger, logging.ERROR, "Error occurred in batch get cache", e) _record_swallowed_redis_failure(self._circuit_breaker, e) return key_value_dict @@ -1574,7 +1616,7 @@ class RedisCache(BaseCache): parent_otel_span=parent_otel_span, ) ) - verbose_logger.error("Error occurred in async batch get cache - %s", e) + log_redis_failure(verbose_logger, logging.ERROR, "Error occurred in async batch get cache", e) _record_swallowed_redis_failure(self._circuit_breaker, e) return key_value_dict @@ -1799,9 +1841,11 @@ class RedisCache(BaseCache): parent_otel_span=_get_parent_otel_span_from_kwargs(kwargs), ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async increment_pipeline() - Got exception from REDIS %s", - str(e), + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async increment_pipeline() - Got exception from REDIS", + e, ) raise e @@ -1878,7 +1922,7 @@ class RedisCache(BaseCache): call_type=f"async_rpush <- {_get_call_stack_info()}", ) ) - verbose_logger.error("LiteLLM Redis Cache RPUSH: - Got exception from REDIS : %s", e) + log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Redis Cache RPUSH: - Got exception from REDIS", e) raise e async def _pipeline_rpush_helper( @@ -1946,9 +1990,11 @@ class RedisCache(BaseCache): call_type=f"async_rpush_pipeline <- {_get_call_stack_info()}", ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async_rpush_pipeline() - Got exception from REDIS %s", - str(e), + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async_rpush_pipeline() - Got exception from REDIS", + e, ) raise e @@ -2024,7 +2070,7 @@ class RedisCache(BaseCache): call_type=f"async_lpop <- {_get_call_stack_info()}", ) ) - verbose_logger.error("LiteLLM Redis Cache LPOP: - Got exception from REDIS : %s", e) + log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Redis Cache LPOP: - Got exception from REDIS", e) raise e async def _pipeline_lpop_helper( @@ -2135,8 +2181,10 @@ class RedisCache(BaseCache): call_type=f"async_lpop_pipeline <- {_get_call_stack_info()}", ) ) - verbose_logger.error( - "LiteLLM Redis Caching: async_lpop_pipeline() - Got exception from REDIS %s", - str(e), + log_redis_failure( + verbose_logger, + logging.ERROR, + "LiteLLM Redis Caching: async_lpop_pipeline() - Got exception from REDIS", + e, ) raise e diff --git a/litellm/constants.py b/litellm/constants.py index 6b984c2673c..a32551b4480 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -459,6 +459,9 @@ REDIS_CIRCUIT_BREAKER_ENABLED: Final = os.getenv("REDIS_CIRCUIT_BREAKER_ENABLED" # minimum seconds a timeout-only failure streak must span before it can open the breaker, # so one event-loop stall timing out many queued calls at once does not trip it REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION: Final = float(os.getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION", 5.0)) +# seconds between Redis timeout log lines: the first timeout of a streak logs at the caller's level, +# later ones log at DEBUG until the interval passes and one line summarizes how many were suppressed +REDIS_TIMEOUT_LOG_INTERVAL: Final = float(os.getenv("REDIS_TIMEOUT_LOG_INTERVAL", "5.0")) # Seconds of idle before a Redis cluster connection is validated with a PING and # reconnected if dead, so a connection silently dropped by a cluster restart # (e.g. ElastiCache Serverless maintenance) is not reused while broken diff --git a/tests/test_litellm/caching/test_dual_cache.py b/tests/test_litellm/caching/test_dual_cache.py index 4c9068722b8..850fa14106b 100644 --- a/tests/test_litellm/caching/test_dual_cache.py +++ b/tests/test_litellm/caching/test_dual_cache.py @@ -704,3 +704,60 @@ async def test_open_breaker_keeps_async_batch_read_memory_hits_and_releases_rese assert list(await cache.async_batch_get_cache(["k1", "k2"])) == ["v1", None] assert "k2" not in cache.last_redis_batch_access_time + + +@pytest.mark.asyncio +async def test_redis_timeouts_falling_back_to_memory_log_once_per_interval(caplog, monkeypatch): + """The in-memory fallback WARNING must not repeat for every timed-out increment during a blip. + + The rate limiter's pipeline increments and the dual cache increments each logged a WARNING per + call while Redis timed out, hundreds of lines per second before the breaker opened. The first + timeout of a streak keeps its WARNING, the rest are DEBUG until the summary interval passes. + """ + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching import redis_cache as redis_cache_module + from litellm.caching.redis_cache import _RedisTimeoutLogThrottle + + clock = MagicMock(return_value=1_000.0) + monkeypatch.setattr( + redis_cache_module, "_redis_timeout_log_throttle", _RedisTimeoutLogThrottle(interval=5.0, clock=clock) + ) + + class _TimingOutRedis: + async def async_increment_pipeline(self, increment_list, **kwargs): + raise RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + + async def async_increment(self, key, value, **kwargs): + raise RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + + cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=_TimingOutRedis()) # pyright: ignore[reportArgumentType] # duck-typed Redis double + increments = [RedisPipelineIncrementOperation(key="k", increment_value=1.0, ttl=60)] + + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + for _ in range(100): + await cache.async_increment_cache_pipeline(increment_list=increments) + await cache.async_increment_cache("k", 1.0) + + visible = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert [(r.levelno, r.getMessage()) for r in visible] == [ + ( + logging.WARNING, + "Redis async_increment_cache_pipeline failed, falling back to in-memory result:" + " Timeout reading from 127.0.0.1:6379", + ) + ] + assert visible[0].filename == "dual_cache.py" + assert sum("Timeout reading from" in r.getMessage() for r in caplog.records) == 200 + + caplog.clear() + clock.return_value += 5.0 + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + await cache.async_increment_cache("k", 1.0) + assert [(r.levelno, r.getMessage()) for r in caplog.records] == [ + ( + logging.WARNING, + "Redis async_increment_cache failed, falling back to in-memory result: Timeout reading from 127.0.0.1:6379" + " (199 more Redis timeouts since the previous Redis timeout line were logged at DEBUG)", + ) + ] diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index bcae33b976e..d0974b2420c 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -1202,3 +1202,60 @@ async def test_a_probe_overtaken_by_a_later_outage_leaves_the_breaker_to_the_new new_probe_release.set() assert await new_probe == "new probe" assert breaker._state == breaker.CLOSED + + +def test_timeouts_during_a_blip_log_once_per_interval_not_once_per_call(sync_batch_redis_cache, caplog, monkeypatch): + """A Redis latency blip must not write one ERROR line per timed-out cache call. + + Before the breaker opens (up to REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION of timeouts) every + cache operation logged its own ERROR or WARNING line, so one single-worker proxy wrote + ~1100 lines in 5 s at LITELLM_LOG=WARNING. A timeout streak now logs its first failure, then + one summary line per REDIS_TIMEOUT_LOG_INTERVAL carrying the count of suppressed timeouts, + while every timeout stays visible at DEBUG. Hard connectivity failures keep their per-call line. + """ + import logging + + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching import redis_cache as redis_cache_module + from litellm.caching.redis_cache import _RedisTimeoutLogThrottle + + clock = MagicMock(return_value=1_000.0) + monkeypatch.setattr( + redis_cache_module, "_redis_timeout_log_throttle", _RedisTimeoutLogThrottle(interval=5.0, clock=clock) + ) + sync_batch_redis_cache.redis_client.get.side_effect = RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + sync_batch_redis_cache.redis_client.mget.side_effect = RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + for _ in range(200): + assert sync_batch_redis_cache.get_cache("lit7520") is None + assert sync_batch_redis_cache.batch_get_cache(key_list=["lit7520"]) == {} + + timeout_records = [r for r in caplog.records if "Timeout reading from" in r.getMessage()] + assert len(timeout_records) == 201, "every timeout must stay visible at DEBUG" + assert [r.getMessage() for r in timeout_records if r.levelno >= logging.WARNING] == [ + "litellm.caching.caching: get() - Got exception from REDIS: Timeout reading from 127.0.0.1:6379" + ] + assert timeout_records[0].levelno == logging.ERROR + assert timeout_records[0].filename == "redis_cache.py" + assert timeout_records[0].lineno != timeout_records[-1].lineno, "the record must point at the cache operation" + + caplog.clear() + clock.return_value += 5.0 + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + assert sync_batch_redis_cache.batch_get_cache(key_list=["lit7520"]) == {} + assert [(r.levelno, r.getMessage()) for r in caplog.records] == [ + ( + logging.ERROR, + "Error occurred in batch get cache: Timeout reading from 127.0.0.1:6379" + " (200 more Redis timeouts since the previous Redis timeout line were logged at DEBUG)", + ) + ] + + caplog.clear() + sync_batch_redis_cache.redis_client.get.side_effect = OSError("redis unavailable") + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + for _ in range(3): + assert sync_batch_redis_cache.get_cache("lit7520") is None + assert [r.levelno for r in caplog.records if "redis unavailable" in r.getMessage()] == [logging.ERROR] * 3 From 9c84e98fb22bd0f6e2c359f335bbc329181bb8bd Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 12 Sep 2026 01:25:32 +0000 Subject: [PATCH 013/196] fix(proxy): treat a Redis timeout in spend counter increments as an already-logged cache failure The cost tracking callback logged its own ERROR with a traceback for every request whose spend counter increment timed out, on top of the cache layer's throttled line. Timeouts now take the same path as breaker-open refusals: invalidate the counters and return. Also exposes is_redis_timeout_failure publicly for that caller and drops the comment on the new constant Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/caching/redis_cache.py | 10 +++---- litellm/constants.py | 2 -- litellm/proxy/proxy_server.py | 4 +-- .../test_litellm/caching/test_redis_cache.py | 26 +++++++++---------- .../proxy/proxy_server/test_spend_counters.py | 22 ++++++++++++++++ 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index eaac7ef7b0b..7a3e689a667 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -330,7 +330,7 @@ def _redis_timeout_error_types() -> tuple[type, ...]: return (RedisTimeoutError, TimeoutError) -def _is_redis_timeout_failure(exc: BaseException) -> bool: +def is_redis_timeout_failure(exc: BaseException) -> bool: return isinstance(exc, _redis_timeout_error_types()) @@ -398,7 +398,7 @@ def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseExcep """ if not _is_redis_health_failure(exc): return - breaker.record_failure(is_timeout=_is_redis_timeout_failure(exc)) + breaker.record_failure(is_timeout=is_redis_timeout_failure(exc)) _swallowed_redis_failures.set(_swallowed_redis_failures.get() + 1) @@ -439,7 +439,7 @@ def log_redis_failure( logger.debug("%s: %s", message, exc, stacklevel=2) return exc_info: Final = exc if with_traceback else None - if not _is_redis_timeout_failure(exc): + if not is_redis_timeout_failure(exc): logger.log(level, "%s: %s", message, exc, exc_info=exc_info, stacklevel=2) return suppressed: Final = _redis_timeout_log_throttle.admit() @@ -504,7 +504,7 @@ async def _run_under_circuit_breaker( result: Final = await call() except Exception as e: if _is_redis_health_failure(e): - breaker.record_failure(is_timeout=_is_redis_timeout_failure(e)) + breaker.record_failure(is_timeout=is_redis_timeout_failure(e)) raise _exit_circuit_breaker(breaker, admission) return result @@ -521,7 +521,7 @@ def _run_under_circuit_breaker_sync( result: Final = call() except Exception as e: if _is_redis_health_failure(e): - breaker.record_failure(is_timeout=_is_redis_timeout_failure(e)) + breaker.record_failure(is_timeout=is_redis_timeout_failure(e)) raise _exit_circuit_breaker(breaker, admission) return result diff --git a/litellm/constants.py b/litellm/constants.py index a32551b4480..60e1c682238 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -459,8 +459,6 @@ REDIS_CIRCUIT_BREAKER_ENABLED: Final = os.getenv("REDIS_CIRCUIT_BREAKER_ENABLED" # minimum seconds a timeout-only failure streak must span before it can open the breaker, # so one event-loop stall timing out many queued calls at once does not trip it REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION: Final = float(os.getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION", 5.0)) -# seconds between Redis timeout log lines: the first timeout of a streak logs at the caller's level, -# later ones log at DEBUG until the interval passes and one line summarizes how many were suppressed REDIS_TIMEOUT_LOG_INTERVAL: Final = float(os.getenv("REDIS_TIMEOUT_LOG_INTERVAL", "5.0")) # Seconds of idle before a Redis cluster connection is validated with a PING and # reconnected if dead, so a connection silently dropped by a cluster restart diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c8fd7edfed6..318ea96dbcb 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -251,7 +251,7 @@ import litellm._redis from litellm import Router from litellm._logging import _redact_string, verbose_proxy_logger, verbose_router_logger from litellm.caching.caching import DualCache, RedisCache -from litellm.caching.redis_cache import RedisCircuitBreakerOpenError +from litellm.caching.redis_cache import RedisCircuitBreakerOpenError, is_redis_timeout_failure from litellm.caching.redis_cluster_cache import RedisClusterCache from litellm.constants import ( _REALTIME_BODY_CACHE_SIZE, @@ -3411,7 +3411,7 @@ async def _apply_spend_counter_increments(pending: Sequence[_PendingSpendIncreme results: Final = await redis_cache.async_increment_pipeline(increment_list=increment_list) except Exception as e: await asyncio.gather(*(_invalidate_spend_counter(counter_key=item.counter_key) for item in pending)) - if isinstance(e, RedisCircuitBreakerOpenError): + if isinstance(e, RedisCircuitBreakerOpenError) or is_redis_timeout_failure(e): return raise for item, current_value in zip(pending, results or ()): diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index d0974b2420c..5840f450ac6 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -977,17 +977,17 @@ async def test_stale_timeout_does_not_let_sub_threshold_hard_failures_open_the_b from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import TimeoutError as RedisTimeoutError - from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out"))) await asyncio.sleep(0.06) for _ in range(breaker.failure_threshold - 1): - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) assert breaker.is_open() is False, "2 hard failures and 1 stale timeout are below both thresholds" - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) assert breaker.is_open() is True, "the threshold-th hard failure must still open it" @@ -999,19 +999,19 @@ async def test_hard_failure_resets_timeout_streak_so_a_later_burst_must_earn_its from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import TimeoutError as RedisTimeoutError - from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) await asyncio.sleep(0.06) for _ in range(breaker.failure_threshold): - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out"))) assert breaker.is_open() is False, "the burst is instantaneous, so the duration gate must hold it closed" await asyncio.sleep(0.06) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out"))) assert breaker.is_open() is True, "the same run of timeouts persisting past the duration must open it" @@ -1022,7 +1022,7 @@ async def test_breaker_metrics_track_state_and_failure_class(): from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import TimeoutError as RedisTimeoutError - from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure def sample(name, labels=None): return REGISTRY.get_sample_value(name, labels) or 0.0 @@ -1034,9 +1034,9 @@ async def test_breaker_metrics_track_state_and_failure_class(): closed_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) breaker = RedisCircuitBreaker(failure_threshold=2, recovery_timeout=60, timeout_min_duration=5.0) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("t"))) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) - breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("t"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "timeout"}) == timeout_before + 1 assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "connectivity"}) == hard_before + 2 diff --git a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py index 2f47736a398..19b5a11af33 100644 --- a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py +++ b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py @@ -1180,6 +1180,28 @@ async def test_apply_spend_counter_increments_open_breaker_invalidates_and_retur fake_cache.in_memory_cache.set_cache.assert_not_called() +@pytest.mark.asyncio +async def test_apply_spend_counter_increments_redis_timeout_invalidates_and_returns(monkeypatch): + """A Redis timeout is the streak the breaker is already counting and the cache layer already logged. + + Re-raising it sent every request in the pre-open window through the cost callback's error + path, which logged a traceback and fired the failed-tracking alert once per request. + """ + from redis.exceptions import TimeoutError as RedisTimeoutError + + fake_cache = _make_spend_counter_cache() + fake_cache.redis_cache.async_increment_pipeline = AsyncMock( + side_effect=RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + ) + monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) + + await ps._apply_spend_counter_increments(_two_pending_increments()) + + deleted_keys = sorted(call.kwargs["key"] for call in fake_cache.in_memory_cache.delete_cache.call_args_list) + assert deleted_keys == ["spend:key:k", "spend:team:t"] + fake_cache.in_memory_cache.set_cache.assert_not_called() + + @pytest.mark.asyncio async def test_apply_spend_counter_increments_other_redis_error_invalidates_and_raises(monkeypatch): fake_cache = _make_spend_counter_cache() From f681a978f06baa13da0f0c24f7b1ac3a20d9a02a Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 12 Sep 2026 01:37:36 +0000 Subject: [PATCH 014/196] fix(redis): use a monotonic clock for the timeout log throttle and trim test docstrings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/caching/redis_cache.py | 2 +- tests/test_litellm/caching/test_dual_cache.py | 7 +------ tests/test_litellm/caching/test_redis_cache.py | 9 +-------- .../proxy/proxy_server/test_spend_counters.py | 6 +----- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 7a3e689a667..e5e27d1e02b 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -409,7 +409,7 @@ class RedisCircuitBreakerOpenError(Exception): class _RedisTimeoutLogThrottle: """Admits one Redis timeout log line per interval and counts the timeouts it suppressed in between.""" - def __init__(self, interval: float, clock: Callable[[], float] = time.time) -> None: + def __init__(self, interval: float, clock: Callable[[], float] = time.monotonic) -> None: self.interval = interval self._clock = clock self._lock = threading.Lock() diff --git a/tests/test_litellm/caching/test_dual_cache.py b/tests/test_litellm/caching/test_dual_cache.py index 850fa14106b..6f29be00b30 100644 --- a/tests/test_litellm/caching/test_dual_cache.py +++ b/tests/test_litellm/caching/test_dual_cache.py @@ -708,12 +708,7 @@ async def test_open_breaker_keeps_async_batch_read_memory_hits_and_releases_rese @pytest.mark.asyncio async def test_redis_timeouts_falling_back_to_memory_log_once_per_interval(caplog, monkeypatch): - """The in-memory fallback WARNING must not repeat for every timed-out increment during a blip. - - The rate limiter's pipeline increments and the dual cache increments each logged a WARNING per - call while Redis timed out, hundreds of lines per second before the breaker opened. The first - timeout of a streak keeps its WARNING, the rest are DEBUG until the summary interval passes. - """ + """The first fallback WARNING of a timeout streak logs, the rest stay at DEBUG until the summary.""" from redis.exceptions import TimeoutError as RedisTimeoutError from litellm.caching import redis_cache as redis_cache_module diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index 5840f450ac6..4ca33894aed 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -1205,14 +1205,7 @@ async def test_a_probe_overtaken_by_a_later_outage_leaves_the_breaker_to_the_new def test_timeouts_during_a_blip_log_once_per_interval_not_once_per_call(sync_batch_redis_cache, caplog, monkeypatch): - """A Redis latency blip must not write one ERROR line per timed-out cache call. - - Before the breaker opens (up to REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION of timeouts) every - cache operation logged its own ERROR or WARNING line, so one single-worker proxy wrote - ~1100 lines in 5 s at LITELLM_LOG=WARNING. A timeout streak now logs its first failure, then - one summary line per REDIS_TIMEOUT_LOG_INTERVAL carrying the count of suppressed timeouts, - while every timeout stays visible at DEBUG. Hard connectivity failures keep their per-call line. - """ + """A timeout streak logs its first failure plus one summary per interval; other failures log per call.""" import logging from redis.exceptions import TimeoutError as RedisTimeoutError diff --git a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py index 19b5a11af33..4a1fc389d3e 100644 --- a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py +++ b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py @@ -1182,11 +1182,7 @@ async def test_apply_spend_counter_increments_open_breaker_invalidates_and_retur @pytest.mark.asyncio async def test_apply_spend_counter_increments_redis_timeout_invalidates_and_returns(monkeypatch): - """A Redis timeout is the streak the breaker is already counting and the cache layer already logged. - - Re-raising it sent every request in the pre-open window through the cost callback's error - path, which logged a traceback and fired the failed-tracking alert once per request. - """ + """A Redis timeout invalidates the counters and returns without reaching the cost callback's error path.""" from redis.exceptions import TimeoutError as RedisTimeoutError fake_cache = _make_spend_counter_cache() From 28f2d1f0168aa31639a23447d391516129267069 Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 12 Sep 2026 01:58:36 +0000 Subject: [PATCH 015/196] test(redis): cover the write and list timeout paths going through the shared log throttle Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_litellm/caching/test_redis_cache.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index 4ca33894aed..5ea21dae539 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -1252,3 +1252,60 @@ def test_timeouts_during_a_blip_log_once_per_interval_not_once_per_call(sync_bat for _ in range(3): assert sync_batch_redis_cache.get_cache("lit7520") is None assert [r.levelno for r in caplog.records if "redis unavailable" in r.getMessage()] == [logging.ERROR] * 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "call_method", + [ + pytest.param(lambda c: c.async_set_cache_pipeline([("lit7520", "v")]), id="async_set_cache_pipeline"), + pytest.param(lambda c: c.async_set_cache_sadd("lit7520", ["v"], ttl=None), id="async_set_cache_sadd"), + pytest.param(lambda c: c.async_increment("lit7520", 1.0), id="async_increment"), + pytest.param( + lambda c: c.async_increment_pipeline([{"key": "lit7520", "increment_value": 1.0, "ttl": 60}]), + id="async_increment_pipeline", + ), + pytest.param(lambda c: c.async_rpush("lit7520", ["v"]), id="async_rpush"), + pytest.param( + lambda c: c.async_rpush_pipeline([{"key": "lit7520", "values": ["v"]}]), id="async_rpush_pipeline" + ), + pytest.param(lambda c: c.async_lpop("lit7520"), id="async_lpop"), + pytest.param(lambda c: c.async_lpop_pipeline([{"key": "lit7520", "count": 1}]), id="async_lpop_pipeline"), + ], +) +async def test_write_path_timeouts_inside_the_interval_stay_at_debug(call_method, caplog, monkeypatch, redis_no_ping): + """A write or list operation timing out mid-streak is counted by the throttle instead of logging its own ERROR.""" + import contextlib + import logging + + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching import redis_cache as redis_cache_module + from litellm.caching.redis_cache import _RedisTimeoutLogThrottle + + clock = MagicMock(return_value=1_000.0) + throttle = _RedisTimeoutLogThrottle(interval=5.0, clock=clock) + assert throttle.admit() == 0 + monkeypatch.setattr(redis_cache_module, "_redis_timeout_log_throttle", throttle) + + timeout = RedisTimeoutError("Timeout reading from 127.0.0.1:6379") + client = MagicMock() + client.pipeline.return_value.__aenter__.side_effect = timeout + client.sadd = AsyncMock(side_effect=timeout) + client.incrbyfloat = AsyncMock(side_effect=timeout) + client.rpush = AsyncMock(side_effect=timeout) + client.lpop = AsyncMock(side_effect=timeout) + monkeypatch.setenv("REDIS_HOST", "https://my-test-host") + cache = RedisCache() + + with ( + patch.object(cache, "init_async_client", return_value=client), + caplog.at_level(logging.DEBUG, logger="LiteLLM"), + ): + with contextlib.suppress(RedisTimeoutError): + await call_method(cache) + + timeout_records = [r for r in caplog.records if "Timeout reading from" in r.getMessage()] + assert [(r.levelno, r.filename) for r in timeout_records] == [(logging.DEBUG, "redis_cache.py")] + clock.return_value += 5.0 + assert throttle.admit() == 1 From 88de192dcf55e26f0f2cabb4d769a825dd8dbc7f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 12 Sep 2026 13:29:04 -0700 Subject: [PATCH 016/196] test: bind management E2E callers and isolate JWT actors --- .github/e2e-stack/assert_tests_ran.py | 15 + .github/e2e-stack/oidc-profile.sh | 5 + .github/e2e-stack/select_tests.py | 2 + .github/workflows/test-e2e-changed.yml | 2 +- .../test_e2e_changed_gate.py | 23 ++ tests/e2e/CONTRIBUTING.md | 6 +- .../e2e/coverage_registry/management_cases.py | 151 +++++++++ tests/e2e/coverage_registry/mgmt.yaml | 8 + tests/e2e/e2e_http.py | 31 +- tests/e2e/idp.py | 254 ++++++++++++++- tests/e2e/junit_properties.py | 3 +- tests/e2e/management/conftest.py | 19 +- tests/e2e/management/jwt_actors.py | 175 ++++++++++ tests/e2e/management/management_client.py | 122 ++++--- .../e2e/management/test_jwt_management_e2e.py | 233 +++++++++++-- tests/e2e/models.py | 28 +- tests/e2e/proxy_client.py | 116 ++++--- tests/e2e/test_e2e_http.py | 10 + tests/e2e/test_idp.py | 122 ++++++- tests/e2e/test_proxy_client.py | 307 +++++++++++++++++- tests/e2e/transport.py | 17 +- tests/e2e/ui/oidcSetup.ts | 30 ++ tests/e2e/ui/playwright.oidc.config.ts | 22 ++ 23 files changed, 1541 insertions(+), 160 deletions(-) create mode 100755 .github/e2e-stack/oidc-profile.sh create mode 100644 tests/e2e/coverage_registry/management_cases.py create mode 100644 tests/e2e/management/jwt_actors.py create mode 100644 tests/e2e/ui/oidcSetup.ts create mode 100644 tests/e2e/ui/playwright.oidc.config.ts diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py index c4348c20873..2303c42f4fb 100644 --- a/.github/e2e-stack/assert_tests_ran.py +++ b/.github/e2e-stack/assert_tests_ran.py @@ -3,6 +3,9 @@ import xml.etree.ElementTree as ET from pathlib import Path from typing import Final +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests/e2e")) +from coverage_registry.management_cases import MANAGEMENT_CASES + def main() -> int: selected: Final = tuple(sys.argv[2:]) @@ -16,6 +19,17 @@ def main() -> int: case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) ) missing: Final = tuple(path for path in selected if path not in passed) + required_nodes: Final = frozenset(case.node for case in MANAGEMENT_CASES if case.node.split("::", 1)[0] in selected) + passed_nodes: Final = frozenset( + prop.get("value") + for case in cases + if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) + for prop in case.findall("./properties/property") + if prop.get("name") == "management_node" + ) + missing_nodes: Final = required_nodes - passed_nodes + for node in sorted(missing_nodes): + _ = sys.stdout.write(f"::error::required management case did not pass: {node}\n") for path in selected: collected: Final = sum(case.get("file") == path for case in cases) skipped: Final = sum(case.get("file") == path and case.find("skipped") is not None for case in cases) @@ -27,6 +41,7 @@ def main() -> int: if ( selected and not missing + and not missing_nodes and not any(case.find(tag) is not None for case in cases for tag in ("failure", "error")) ): return 0 diff --git a/.github/e2e-stack/oidc-profile.sh b/.github/e2e-stack/oidc-profile.sh new file mode 100755 index 00000000000..84eaaaf8051 --- /dev/null +++ b/.github/e2e-stack/oidc-profile.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${REPO_ROOT}" +exec uv run --no-sync python tests/e2e/idp.py "$@" diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index 238818a0d36..982e93cf642 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -12,6 +12,8 @@ UNSUPPORTED: Final = re.compile( HARNESS: Final = re.compile( r"^tests/e2e/[A-Za-z0-9_.-]+\.(py|ini)$" r"|^tests/e2e/idp_realm\.json$" + r"|^tests/e2e/management/(management_client|jwt_actors|conftest)\.py$" + r"|^tests/e2e/coverage_registry/management_cases\.py$" r"|^tests/e2e/gateway/" r"|^\.github/e2e-stack/" r"|^\.github/workflows/test-e2e-changed\.yml$" diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index 1db597ff673..c9f08deb36e 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -183,7 +183,7 @@ jobs: log="${RUNNER_TEMP}/e2e-pass-${pass}.log" echo "::group::pass ${pass} of 3" set +e - uv run --no-sync pytest "${test_files[@]}" --rootdir=. -v -p no:cacheprovider \ + uv run --no-sync pytest "${test_files[@]}" --rootdir=. -v --reruns 0 -p no:cacheprovider \ -o junit_family=xunit1 --junitxml="${report}" > "${log}" 2>&1 status=$? uv run --no-sync python .github/e2e-stack/assert_tests_ran.py "${report}" "${test_files[@]}" diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py index 588402e3996..101816c7f11 100644 --- a/tests/code_coverage_tests/test_e2e_changed_gate.py +++ b/tests/code_coverage_tests/test_e2e_changed_gate.py @@ -81,6 +81,25 @@ def test_missing_execution_evidence_fails(tmp_path: Path, contents: str) -> None assert result.returncode == 1 +@pytest.mark.parametrize("omitted_role", ("proxy_admin", "team_member", "internal_user_viewer")) +def test_one_passing_management_case_cannot_hide_a_missing_actor(tmp_path: Path, omitted_role: str) -> None: + suite: Final = ET.Element("testsuite") + path: Final = "tests/e2e/management/test_jwt_management_e2e.py" + case: Final = ET.SubElement(suite, "testcase", file=path) + properties: Final = ET.SubElement(case, "properties") + _ = ET.SubElement( + properties, + "property", + name="management_node", + value=f"{path}::TestJwtManagement::test_actor_subject_and_database_role[proxy_admin_viewer]", + ) + report: Final = tmp_path / "report.xml" + ET.ElementTree(suite).write(report) + result: Final = subprocess.run([sys.executable, str(GATE), str(report), path], capture_output=True, text=True) + assert result.returncode == 1 + assert f"test_actor_subject_and_database_role[{omitted_role}]" in result.stdout + + def test_short_values_are_written_without_masking_every_digit_in_the_log(tmp_path: Path) -> None: env_path: Final = tmp_path / ".env" @@ -141,6 +160,10 @@ def test_changed_suite_files_are_selected_unless_the_stack_cannot_run_them( ( "tests/e2e/proxy_client.py", "tests/e2e/conftest.py", + "tests/e2e/management/management_client.py", + "tests/e2e/management/jwt_actors.py", + "tests/e2e/management/conftest.py", + "tests/e2e/coverage_registry/management_cases.py", "tests/e2e/pytest.ini", "tests/e2e/gateway/stage_mirror_ci_config.yml", ".github/e2e-stack/up.sh", diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 44564a51e26..78c05ea4b30 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -60,7 +60,11 @@ The suites run against a live proxy, so bring one up first by running the litell Keycloak's password grant is a test-only provisioning shortcut, not a production login recommendation. The `litellm-e2e-admin` client adds the proxy's admin scope; the normal client does not. Never reuse this permissive realm outside an isolated test stack. - Management tests can use the shared `idp` and `jwt_identity` fixtures. Each test gets a unique Keycloak group/user and a matching proxy user/team. Setup and fallback cleanup use the master key; the operations and read-backs being tested must explicitly use `caller_key=idp.access_token(jwt_identity, client_id=ADMIN_CLIENT_ID)` (or a member token). See `management/test_jwt_management_e2e.py` for create/read/update/clear/delete and tenant-denial examples. A group claim alone is not database team membership: permission tests explicitly add the member and prove an allowed read before asserting the denied write. + Management tests can bind a credential once with `client.with_caller(Caller(...))`; direct calls, delegated helpers and replica read-backs then retain that caller. Explicit `caller_key` arguments override the binding. Keep the original master-backed client for bootstrap and cleanup. `actor_factory` lazily provisions database roles and tenant memberships, with `database_role` tokens carrying no groups and `group_scoped` actors retaining the existing team route gate. Token minting is explicit through `actor.mint_caller(idp)`. The factory runs requests without backend retries and reports cleanup failures. `coverage_registry/management_cases.py` records exact canary nodes and non-secret actor labels; the CI execution assertion rejects a missing or skipped actor row + + For the opt-in browser profile, start the existing IdP first, then run `.github/e2e-stack/oidc-profile.sh "$PROXY_BASE_URL" `. The wrapper creates a confidential client with an exact `/sso/callback` redirect and S256 PKCE, passes the client secret only through the child process environment, and removes the client on exit. It uses the existing generic OIDC handler with `GENERIC_USER_ID_ATTRIBUTE=sub`. Preserve the IdP's PostgreSQL data across restarts + + `tests/e2e/ui/playwright.oidc.config.ts` uses an already running OIDC stack and separate storage/output files. Supply `E2E_OIDC_UI_URL`, `JWT_ISSUER`, `E2E_OIDC_USERNAME` and `E2E_OIDC_PASSWORD` for a seeded actor. Its setup follows the real login and callback path. The current Python canary qualifies browser-client configuration and token/userinfo identity mapping; browser journey specs under `ui/oidc/` are a separate coverage step Every successful IdP create immediately registers cleanup, including partial setup failures. Cleanup failures emit warnings. Tokens are minted on demand, and the expiration test waits relative to the token's actual `exp` with a bounded clock-drift check. To check first-attempt behavior locally, run both files with `--reruns 0`: diff --git a/tests/e2e/coverage_registry/management_cases.py b/tests/e2e/coverage_registry/management_cases.py new file mode 100644 index 00000000000..15dc7d333c5 --- /dev/null +++ b/tests/e2e/coverage_registry/management_cases.py @@ -0,0 +1,151 @@ +from dataclasses import dataclass +from typing import Final, Literal + +CredentialKind = Literal["master", "idp_admin", "direct_jwt", "virtual_key", "dashboard_session"] +DependencyProfile = Literal["management_only", "real_oidc_browser", "external_provider_required"] + + +@dataclass(frozen=True, slots=True) +class ManagementCase: + node: str + credential_kind: CredentialKind + actor: str + profile: str + method: Literal["GET", "POST"] + path: str + operation_family: str + dependency_profile: DependencyProfile = "management_only" + + +JWT_FILE: Final = "tests/e2e/management/test_jwt_management_e2e.py" +JWT_CLASS: Final = f"{JWT_FILE}::TestJwtManagement" +ACTORS: Final = ( + "proxy_admin", + "proxy_admin_viewer", + "organization_admin", + "team_admin", + "team_member", + "internal_user", + "internal_user_viewer", + "unrelated_user", +) +MANAGEMENT_CASES: Final = tuple( + ManagementCase( + node=f"{JWT_CLASS}::test_actor_subject_and_database_role[{role}]", + credential_kind="direct_jwt", + actor=role, + profile="database_role", + method="GET", + path="/user/info", + operation_family="identity", + ) + for role in ACTORS +) + ( + ManagementCase( + node=f"{JWT_CLASS}::test_admin_viewer_reads_but_cannot_update", + credential_kind="direct_jwt", + actor="proxy_admin_viewer", + profile="database_role", + method="POST", + path="/key/update", + operation_family="denial", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_admin_creates_reads_updates_clears_and_deletes_a_key[direct_jwt]", + credential_kind="direct_jwt", + actor="proxy_admin", + profile="group_scoped", + method="POST", + path="/key/generate", + operation_family="key_lifecycle", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_admin_creates_reads_updates_clears_and_deletes_a_key[virtual_key]", + credential_kind="virtual_key", + actor="proxy_admin", + profile="database_role", + method="POST", + path="/key/generate", + operation_family="key_lifecycle", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_two_actor_sets_keep_tenants_and_keys_isolated", + credential_kind="direct_jwt", + actor="team_member", + profile="group_scoped", + method="GET", + path="/key/info", + operation_family="tenant_isolation", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_member_cannot_write_and_another_team_cannot_read_the_key", + credential_kind="direct_jwt", + actor="team_member", + profile="group_scoped", + method="POST", + path="/key/update", + operation_family="tenant_isolation", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_multi_group_actor_keeps_exact_memberships", + credential_kind="master", + actor="bootstrap", + profile="group_scoped", + method="GET", + path="/team/info", + operation_family="memberships", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_successful_actor_cleanup_removes_owned_state", + credential_kind="master", + actor="bootstrap", + profile="failure_cleanup", + method="GET", + path="/team/info", + operation_family="cleanup", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_partial_setup_removes_previously_created_identities[group]", + credential_kind="idp_admin", + actor="idp_admin", + profile="failure_cleanup", + method="POST", + path="/groups", + operation_family="cleanup", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_partial_setup_removes_previously_created_identities[user]", + credential_kind="idp_admin", + actor="idp_admin", + profile="failure_cleanup", + method="POST", + path="/users", + operation_family="cleanup", + ), + ManagementCase( + node=f"{JWT_CLASS}::test_oidc_browser_profile_identity_mapping", + credential_kind="direct_jwt", + actor="internal_user", + profile="oidc_configuration", + method="GET", + path="/protocol/openid-connect/userinfo", + operation_family="oidc_identity", + ), +) + + +def canonical_node(node: str) -> str: + return node if node.startswith("tests/e2e/") else f"tests/e2e/{node}" + + +def case_properties(node: str) -> tuple[tuple[str, str], ...]: + case: Final = next((case for case in MANAGEMENT_CASES if case.node == canonical_node(node)), None) + if case is None: + return () + return ( + ("management_node", case.node), + ("credential_kind", case.credential_kind), + ("actor", case.actor), + ("auth_profile", case.profile), + ("dependency_profile", case.dependency_profile), + ) diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index d1227fe7c0c..31ad61ba3e2 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -90,3 +90,11 @@ - {id: mgmt.mcp_toolset.update.persists, module: mgmt, tier: P0, surface: api, assertions: [persists], source: "mcp_management_endpoints.py:3098", rationale: "Narrowing the tools to one entry reads back exactly that entry"} - {id: mgmt.mcp_toolset.update.clear_persists, module: mgmt, tier: P0, surface: api, assertions: [clear_persists], source: "mcp_management_endpoints.py:3098", fail_before_fix: proven, rationale: "An explicit null clears the stored description; the update used to drop null and keep the old value"} - {id: mgmt.mcp_toolset.delete.persists, module: mgmt, tier: P0, surface: api, assertions: [persists], source: "mcp_management_endpoints.py:3149", rationale: "A deleted toolset is gone by id and from the list on every replica"} + +- {id: mgmt.user.jwt.database_roles, module: mgmt, tier: P0, surface: api, assertions: [database_roles], source: "auth/handle_jwt.py", rationale: "User-only JWT subjects retain their seeded database roles and memberships"} +- {id: mgmt.key.jwt.viewer_denied, module: mgmt, tier: P0, surface: api, assertions: [viewer_denied], source: "auth/route_checks.py", rationale: "An admin viewer can read a key but cannot update it or change stored state"} +- {id: mgmt.user.oidc.identity_mapping, module: mgmt, tier: P0, surface: api, assertions: [identity_mapping], source: "tests/e2e/idp.py", rationale: "IdP configuration canary only: confidential-client token and userinfo subjects match the seeded user; application SSO is separate"} +- {id: mgmt.team.jwt.tenant_isolation, module: mgmt, tier: P0, surface: api, assertions: [tenant_isolation], source: "auth/handle_jwt.py", rationale: "Isolated team actors read their own key and receive 403 for the other tenant key"} +- {id: mgmt.team.jwt.multiple_memberships, module: mgmt, tier: P0, surface: api, assertions: [multiple_memberships], source: "auth/handle_jwt.py", rationale: "A multi-group actor has exactly the configured memberships without admin scope"} +- {id: mgmt.user.jwt.cleanup, module: mgmt, tier: P0, surface: api, assertions: [cleanup], source: "management_endpoints/internal_user_endpoints.py", rationale: "Owned users teams organizations keys and IdP objects disappear after successful cleanup"} +- {id: mgmt.user.jwt.partial_cleanup, module: mgmt, tier: P0, surface: api, assertions: [partial_cleanup], source: "auth/handle_jwt.py", rationale: "Partial identity setup removes the group and user created before failure"} diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index ce069720c6e..67370c98274 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -16,9 +16,11 @@ requests itself imports. from __future__ import annotations import time -from collections.abc import Callable, Mapping +from collections.abc import Callable, Generator, Iterator, Mapping +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass -from typing import Final, Generator, Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast +from typing import Final, Generic, Literal, NewType, Protocol, TypeVar, cast import pytest import requests @@ -36,8 +38,8 @@ class Headers(BaseModel): class AuthHeaders(Headers): # litellm accepts either; set whichever the call needs, leave the other None. - authorization: str | None = None - x_litellm_api_key: str | None = Field(default=None, alias="x-litellm-api-key") + authorization: str | None = Field(default=None, repr=False) + x_litellm_api_key: str | None = Field(default=None, alias="x-litellm-api-key", repr=False) class AnthropicHeaders(AuthHeaders): @@ -292,6 +294,22 @@ def _params(params: BaseModel | None) -> dict[str, str]: TRANSIENT_STATUSES: frozenset[int] = frozenset({529}) RETRY_ATTEMPTS: int = 3 +_QUALIFICATION: Final[ContextVar[bool]] = ContextVar("e2e_qualification", default=False) + + +def retry_attempts(default: int) -> int: + return 1 if _QUALIFICATION.get() else default + + +@contextmanager +def without_retries() -> Generator[None]: + token: Final = _QUALIFICATION.set(True) + try: + yield + finally: + _QUALIFICATION.reset(token) + + RETRY_BACKOFF_SECONDS: float = 0.5 @@ -319,7 +337,7 @@ def request_with_retry[T: RetryableResponse]( hang should surface as a hang instead of doubling the wall clock. Every retry prints, so flakiness stays visible in the run log instead of vanishing into green.""" - for attempt in range(1, RETRY_ATTEMPTS): + for attempt in range(1, retry_attempts(RETRY_ATTEMPTS)): resp = issue() if resp.status_code not in TRANSIENT_STATUSES: return resp @@ -414,6 +432,7 @@ def get_external[R: BaseModel]( url: str, *, response_type: type[R], + headers: BaseModel | None = None, timeout: float = 30.0, ) -> Result[R]: """GET an absolute URL outside the proxy (e.g. a public /.well-known document). @@ -422,7 +441,7 @@ def get_external[R: BaseModel]( try: resp = requests.get( url, - headers={"Accept": "application/json"}, + headers={"Accept": "application/json", **(_headers(headers) if headers is not None else {})}, timeout=timeout, ) except requests.RequestException as exc: diff --git a/tests/e2e/idp.py b/tests/e2e/idp.py index 6d2fc84eb27..12db91bbd88 100644 --- a/tests/e2e/idp.py +++ b/tests/e2e/idp.py @@ -2,11 +2,17 @@ from __future__ import annotations +import base64 import os import secrets +import signal +import subprocess +import sys import warnings from collections.abc import Callable -from dataclasses import dataclass, field +from contextlib import ExitStack +from dataclasses import dataclass, field, replace +from types import FrameType from typing import Final, Literal import pytest @@ -14,11 +20,15 @@ from e2e_http import ( AuthHeaders, ExternalWrite, NetworkError, + NoBody, Result, Success, + UnknownApiError, delete_external, + get_external, post_form_external, post_json_external, + unwrap, ) from pydantic import BaseModel, Field @@ -46,7 +56,9 @@ class TokenGrantForm(BaseModel): grant_type: Literal["password"] = "password" client_id: str username: str - password: str + password: str = Field(repr=False) + client_secret: str | None = Field(default=None, repr=False) + scope: str | None = None class TokenResponse(BaseModel): @@ -63,7 +75,7 @@ class GroupCreateBody(BaseModel): class PasswordCredential(BaseModel): type: Literal["password"] = "password" - value: str + value: str = Field(repr=False) temporary: bool = False @@ -101,8 +113,20 @@ class Identity: user_id: str username: str password: str = field(repr=False) - group: str - group_id: str + groups: tuple[str, ...] + group_ids: tuple[str, ...] + + @property + def group(self) -> str: + if len(self.groups) != 1: + raise ValueError("A single-group identity is required") + return self.groups[0] + + @property + def group_id(self) -> str: + if len(self.group_ids) != 1: + raise ValueError("A single-group identity is required") + return self.group_ids[0] @dataclass(frozen=True, slots=True) @@ -111,6 +135,10 @@ class Keycloak: realm: str admin_username: str admin_password: str = field(repr=False) + strict_cleanup: bool = False + + def with_strict_cleanup(self) -> Keycloak: + return replace(self, strict_cleanup=True) @property def issuer(self) -> str: @@ -150,7 +178,9 @@ class Keycloak: f"group {name}", ) - def create_user(self, *, username: str, email: str, password: str, group: str) -> str: + def create_user( + self, *, username: str, email: str, password: str, group: str | None = None, groups: tuple[str, ...] = () + ) -> str: return created_id( post_json_external( self._admin_url("/users"), @@ -158,7 +188,7 @@ class Keycloak: json=UserCreateBody( username=username, email=email, - groups=(group,), + groups=(group,) if group is not None else groups, credentials=(PasswordCredential(value=password),), ), ), @@ -171,14 +201,28 @@ class Keycloak: def delete_group(self, group_id: str) -> None: self._delete(f"/groups/{group_id}") + def assert_absent(self, kind: Literal["users", "groups", "clients"], resource_id: str) -> None: + result: Final = get_external( + self._admin_url(f"/{kind}/{resource_id}"), + headers=self._admin_headers(), + response_type=NoBody, + ) + assert isinstance(result, UnknownApiError) and result.status_code == 404, ( + f"Owned IdP {kind} still exists: {result}" + ) + def _delete(self, path: str) -> None: try: headers: Final = self._admin_headers() except pytest.fail.Exception as exc: + if self.strict_cleanup: + raise RuntimeError(f"Keycloak cleanup could not authenticate for {path}") from exc warnings.warn(f"Keycloak cleanup could not authenticate for {path}: {exc}", RuntimeWarning, stacklevel=2) return result: Final = delete_external(self._admin_url(path), headers=headers) if result.status_code not in (204, 404): + if self.strict_cleanup: + raise RuntimeError(f"Keycloak cleanup failed for {path}: HTTP {result.status_code}") warnings.warn( f"Keycloak cleanup failed for {path}: HTTP {result.status_code} {result.body[:300]}", RuntimeWarning, @@ -188,15 +232,34 @@ class Keycloak: def provision(self, *, marker: str, group: str, defer: Callable[[Callable[[], object]], None]) -> Identity: """Create `group` and a user in it, credentialed with a password generated for this test alone, and hand back the identity a token can be minted for.""" - group_id: Final = self.create_group(group) - defer(lambda: self.delete_group(group_id)) + return self.provision_groups(marker=marker, groups=(group,), defer=defer) + + def provision_groups( + self, *, marker: str, groups: tuple[str, ...], defer: Callable[[Callable[[], object]], None] + ) -> Identity: + def provision_group(name: str) -> str: + created: Final = self.create_group(name) + defer(lambda: self.delete_group(created)) + return created + + group_ids: Final = tuple(provision_group(group) for group in groups) + return self.provision_user(marker=marker, groups=groups, group_ids=group_ids, defer=defer) + + def provision_user( + self, + *, + marker: str, + groups: tuple[str, ...], + group_ids: tuple[str, ...], + defer: Callable[[Callable[[], object]], None], + ) -> Identity: username: Final = f"e2e-jwt-user-{marker}" password: Final = secrets.token_urlsafe(24) user_id: Final = self.create_user( - username=username, email=f"{username}@example.com", password=password, group=group + username=username, email=f"{username}@example.com", password=password, groups=groups ) defer(lambda: self.delete_user(user_id)) - return Identity(user_id=user_id, username=username, password=password, group=group, group_id=group_id) + return Identity(user_id=user_id, username=username, password=password, groups=groups, group_ids=group_ids) def access_token( self, identity: Identity, *, client_id: str = TESTS_CLIENT_ID, issuer_host: str | None = None @@ -211,6 +274,65 @@ class Keycloak: ) return self._token(result, f"a token for {identity.username}") + def discovery(self) -> Discovery: + return unwrap(get_external(f"{self.issuer}/.well-known/openid-configuration", response_type=Discovery)) + + def browser_client(self, *, callback_url: str, defer: Callable[[Callable[[], object]], None]) -> BrowserClient: + client: Final = BrowserClient( + client_id=f"e2e-browser-{secrets.token_hex(8)}", + secret=secrets.token_urlsafe(32), + callback_url=callback_url, + ) + resource_id: Final = created_id( + post_json_external( + self._admin_url("/clients"), + headers=self._admin_headers(), + json=BrowserClientBody( + clientId=client.client_id, + secret=client.secret, + redirectUris=(callback_url,), + ), + ), + "browser client", + ) + defer(lambda: self._delete(f"/clients/{resource_id}")) + configured: Final = unwrap( + get_external( + self._admin_url(f"/clients/{resource_id}"), + headers=self._admin_headers(), + response_type=BrowserClientBody, + ) + ) + assert configured.redirect_uris == (callback_url,) + assert configured.standard_flow_enabled and not configured.public_client + assert configured.attributes.pkce == "S256" + return client + + def browser_token(self, identity: Identity, client: BrowserClient) -> str: + return self._token( + post_form_external( + self.token_url(self.realm), + form=TokenGrantForm( + client_id=client.client_id, + client_secret=client.secret, + username=identity.username, + password=identity.password, + scope="openid email", + ), + response_type=TokenResponse, + ), + "browser-profile identity mapping", + ) + + def userinfo(self, token: str) -> UserInfo: + return unwrap( + get_external( + f"{self.issuer}/protocol/openid-connect/userinfo", + headers=AuthHeaders(authorization=f"Bearer {token}"), + response_type=UserInfo, + ) + ) + def keycloak_from_env() -> Keycloak: admin_username: Final = os.environ.get(KEYCLOAK_ADMIN_USER_ENV, "").strip() @@ -226,3 +348,113 @@ def keycloak_from_env() -> Keycloak: admin_username=admin_username, admin_password=admin_password, ) + + +class TokenClaims(BaseModel): + sub: str + iss: str + aud: str | tuple[str, ...] + exp: int + scope: str = "" + groups: tuple[str, ...] = () + + +class Discovery(BaseModel): + issuer: str + authorization_endpoint: str + token_endpoint: str + userinfo_endpoint: str + jwks_uri: str + + +class UserInfo(BaseModel): + sub: str + email: str + + +class BrowserAttributes(BaseModel): + pkce: str = Field(default="S256", alias="pkce.code.challenge.method") + + +class AudienceConfig(BaseModel): + audience: str = Field(default="litellm-e2e", alias="included.custom.audience") + access_token: str = Field(default="true", alias="access.token.claim") + id_token: str = Field(default="false", alias="id.token.claim") + + +class AudienceMapper(BaseModel): + name: str = "litellm-audience" + protocol: str = "openid-connect" + mapper: str = Field(default="oidc-audience-mapper", alias="protocolMapper") + config: AudienceConfig = Field(default_factory=AudienceConfig) + + +class BrowserClientBody(BaseModel): + client_id: str = Field(alias="clientId") + secret: str = Field(repr=False) + redirect_uris: tuple[str, ...] = Field(alias="redirectUris") + enabled: bool = True + public_client: bool = Field(default=False, alias="publicClient") + standard_flow_enabled: bool = Field(default=True, alias="standardFlowEnabled") + direct_access_grants_enabled: bool = Field(default=True, alias="directAccessGrantsEnabled") + default_client_scopes: tuple[str, ...] = Field(default=("email", "basic"), alias="defaultClientScopes") + attributes: BrowserAttributes = Field(default_factory=BrowserAttributes) + protocol_mappers: tuple[AudienceMapper, ...] = Field(default=(AudienceMapper(),), alias="protocolMappers") + + +@dataclass(frozen=True, slots=True) +class BrowserClient: + client_id: str + secret: str = field(repr=False) + callback_url: str + + def environment(self, discovery: Discovery) -> dict[str, str]: + return { + "GENERIC_CLIENT_ID": self.client_id, + "GENERIC_CLIENT_SECRET": self.secret, + "GENERIC_USER_ID_ATTRIBUTE": "sub", + "GENERIC_AUTHORIZATION_ENDPOINT": discovery.authorization_endpoint, + "GENERIC_TOKEN_ENDPOINT": discovery.token_endpoint, + "GENERIC_USERINFO_ENDPOINT": discovery.userinfo_endpoint, + "GENERIC_CLIENT_USE_PKCE": "true", + "GENERIC_SCOPE": "openid email", + } + + +def token_claims(token: str) -> TokenClaims: + payload: Final = token.split(".")[1] + return TokenClaims.model_validate_json(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + + +def run_oidc_profile(proxy_url: str, command: list[str]) -> int: + idp: Final = keycloak_from_env().with_strict_cleanup() + with ExitStack() as cleanup: + + def terminate(signum: int, frame: FrameType | None) -> None: + raise SystemExit(128 + signum) + + previous: Final = signal.signal(signal.SIGTERM, terminate) + cleanup.callback(signal.signal, signal.SIGTERM, previous) + + def defer(callback: Callable[[], object]) -> None: + cleanup.callback(callback) + + client: Final = idp.browser_client(callback_url=f"{proxy_url.rstrip('/')}/sso/callback", defer=defer) + environment: Final = {**os.environ, **client.environment(idp.discovery()), "PROXY_BASE_URL": proxy_url} + with subprocess.Popen(command, env=environment) as child: + try: + return child.wait() + finally: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + + +if __name__ == "__main__": + if len(sys.argv) < 3: + raise SystemExit("Usage: idp.py PROXY_URL COMMAND [ARG ...]; requires a running test IdP") + raise SystemExit(run_oidc_profile(sys.argv[1], sys.argv[2:])) diff --git a/tests/e2e/junit_properties.py b/tests/e2e/junit_properties.py index c5971c5362c..b9f5da871ae 100644 --- a/tests/e2e/junit_properties.py +++ b/tests/e2e/junit_properties.py @@ -19,6 +19,7 @@ from __future__ import annotations from collections.abc import Iterable import pytest +from coverage_registry.management_cases import case_properties # Hardcoded because the runner image copies tests/e2e/ to /app/e2e, so nothing # at runtime names this suite's place in the repo. test_junit_properties.py @@ -94,7 +95,7 @@ def result_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]: ("package", package_from_nodeid(item.nodeid)), ("covers", ",".join(covers_from_item(item))), ("source", source_from_item(item)), - ) + ) + case_properties(item.nodeid) def attach_result_properties(item: pytest.Item) -> None: diff --git a/tests/e2e/management/conftest.py b/tests/e2e/management/conftest.py index bd69c8c0ff3..5a11b634085 100644 --- a/tests/e2e/management/conftest.py +++ b/tests/e2e/management/conftest.py @@ -5,8 +5,14 @@ holds the shared ProxyClient so `resources` / `scoped_key` clean up keys, teams, users, and orgs this suite creates. """ -import pytest +from collections.abc import Generator +from typing import Final +import pytest +from e2e_http import without_retries +from idp import Keycloak +from lifecycle import ResourceManager +from management.jwt_actors import ActorFactory from management_client import ManagementClient, build_client from proxy_client import ProxyClient @@ -21,3 +27,14 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") def client(proxy: ProxyClient) -> ManagementClient: return build_client(proxy) + + +@pytest.fixture +def actor_factory(proxy: ProxyClient, idp: Keycloak) -> Generator[ActorFactory]: + bootstrap: Final = build_client(proxy) + resources: Final = ResourceManager(client=proxy, strict_cleanup=True) + with without_retries(): + try: + yield ActorFactory(bootstrap=bootstrap, idp=idp, resources=resources) + finally: + resources.teardown() diff --git a/tests/e2e/management/jwt_actors.py b/tests/e2e/management/jwt_actors.py new file mode 100644 index 00000000000..909d1652ada --- /dev/null +++ b/tests/e2e/management/jwt_actors.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal + +from e2e_config import unique_marker +from e2e_http import NoBody, unwrap +from idp import ADMIN_CLIENT_ID, TESTS_CLIENT_ID, Identity, Keycloak +from lifecycle import ResourceManager +from management.management_client import ManagementClient +from models import ( + KeyGenerateBody, + KeyGenerateResponse, + OrgDeleteBody, + OrgDeleteResponse, + OrgMemberAddBody, + OrgMemberEntry, + OrgNewBody, + TeamDeleteBody, + TeamMemberAddBody, + TeamMemberEntry, + TeamNewBody, + UserNewBody, + UserRole, +) +from proxy_client import Caller + +ActorRole = Literal[ + "proxy_admin", + "proxy_admin_viewer", + "organization_admin", + "team_admin", + "team_member", + "internal_user", + "internal_user_viewer", + "unrelated_user", +] +ActorProfile = Literal["database_role", "group_scoped"] + + +@dataclass(frozen=True, slots=True) +class Tenant: + organization_id: str + team_id: str + group_id: str + + +@dataclass(frozen=True, slots=True) +class Actor: + identity: Identity + role: ActorRole + global_role: UserRole + profile: ActorProfile + tenants: tuple[Tenant, ...] + + def mint_caller(self, idp: Keycloak) -> Caller: + return Caller( + credential=idp.access_token( + self.identity, client_id=ADMIN_CLIENT_ID if self.role == "proxy_admin" else TESTS_CLIENT_ID + ), + kind="direct_jwt", + role=self.role, + tenant=self.tenants[0].organization_id if self.tenants else None, + ) + + +@dataclass(frozen=True, slots=True) +class ActorFactory: + bootstrap: ManagementClient + idp: Keycloak + resources: ResourceManager + + def __post_init__(self) -> None: + if self.bootstrap.proxy.caller is not None: + raise ValueError("Actor bootstrap requires a separately held master client") + + def key(self, tenant: Tenant | None = None, *, user_id: str | None = None) -> KeyGenerateResponse: + created: Final = unwrap( + self.bootstrap.generate_key( + KeyGenerateBody( + team_id=tenant.team_id if tenant is not None else None, + user_id=user_id, + key_alias=f"e2e-actor-key-{unique_marker()}", + ) + ) + ) + self.resources.defer(lambda: self.bootstrap.delete_key_strict(created.key)) + return created + + def tenant(self) -> Tenant: + marker: Final = unique_marker() + organization_id: Final = self.bootstrap.create_org(OrgNewBody(organization_alias=f"e2e-organization-{marker}")) + self.resources.defer( + lambda: unwrap( + self.bootstrap.proxy.transport.delete( + "/organization/delete", + headers=self.bootstrap.proxy.management_headers(), + json=OrgDeleteBody(organization_ids=[organization_id]), + response_type=OrgDeleteResponse, + ) + ) + ) + team_id: Final = self.bootstrap.proxy.create_team( + TeamNewBody(team_alias=f"e2e-team-{marker}", organization_id=organization_id) + ) + self.resources.defer( + lambda: unwrap( + self.bootstrap.proxy.transport.post( + "/team/delete", + headers=self.bootstrap.proxy.management_headers(), + json=TeamDeleteBody(team_ids=[team_id]), + response_type=NoBody, + ) + ) + ) + self.bootstrap.delete_team_member(team_id, self.bootstrap.user_info().user_id) + group_id: Final = self.idp.create_group(team_id) + self.resources.defer(lambda: self.idp.with_strict_cleanup().delete_group(group_id)) + return Tenant(organization_id=organization_id, team_id=team_id, group_id=group_id) + + def create( + self, role: ActorRole, *, tenants: tuple[Tenant, ...] = (), profile: ActorProfile = "database_role" + ) -> Actor: + if role in ("team_admin", "team_member", "organization_admin") and not tenants: + raise ValueError("A membership actor requires a tenant") + identity: Final = self.idp.with_strict_cleanup().provision_user( + marker=unique_marker(), + groups=tuple(tenant.team_id for tenant in tenants) if profile == "group_scoped" else (), + group_ids=tuple(tenant.group_id for tenant in tenants) if profile == "group_scoped" else (), + defer=self.resources.defer, + ) + global_role: Final[UserRole] = ( + role + if role in ("proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer") + else "internal_user" + ) + self.bootstrap.create_user( + UserNewBody( + user_id=identity.user_id, + user_email=f"{identity.username}@example.com", + user_role=global_role, + auto_create_key=False, + ) + ) + self.resources.defer(lambda: self.bootstrap.delete_user_strict(identity.user_id)) + for tenant in tenants: + unwrap( + self.bootstrap.proxy.transport.post( + "/organization/member_add", + headers=self.bootstrap.proxy.management_headers(), + json=OrgMemberAddBody( + organization_id=tenant.organization_id, + member=OrgMemberEntry( + user_id=identity.user_id, + role="org_admin" if role == "organization_admin" else "internal_user", + ), + ), + response_type=NoBody, + ) + ) + unwrap( + self.bootstrap.proxy.transport.post( + "/team/member_add", + headers=self.bootstrap.proxy.management_headers(), + json=TeamMemberAddBody( + team_id=tenant.team_id, + member=TeamMemberEntry( + user_id=identity.user_id, + role="admin" if role == "team_admin" else "user", + ), + ), + response_type=NoBody, + ) + ) + return Actor(identity=identity, role=role, global_role=global_role, profile=profile, tenants=tenants) diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index e17b92a13ed..a0243e868e2 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -7,7 +7,8 @@ llm-only key hitting a management route). from __future__ import annotations import time -from dataclasses import dataclass +import warnings +from dataclasses import dataclass, field, replace import jwt from e2e_config import MASTER_KEY @@ -20,6 +21,7 @@ from e2e_http import ( StreamingResponse, Success, UnknownApiError, + retry_attempts, unwrap, ) from models import ( @@ -81,7 +83,7 @@ from models import ( UserNewResponse, UserUpdateBody, ) -from proxy_client import ProxyClient +from proxy_client import Caller, ProxyClient MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied" ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" @@ -98,7 +100,7 @@ class DashboardSession: its bearer on every subsequent call, the claims it renders the signed-in user from, and where it lands the browser.""" - session_key: str + session_key: str = field(repr=False) claims: UiSessionClaims redirect_url: str @@ -106,7 +108,10 @@ class DashboardSession: @dataclass(frozen=True, slots=True) class ManagementClient: proxy: ProxyClient - master_key: str + master_key: str = field(repr=False) + + def with_caller(self, caller: Caller) -> ManagementClient: + return replace(self, proxy=self.proxy.with_caller(caller)) def llm_only_key(self) -> str: return self.proxy.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])) @@ -117,7 +122,7 @@ class ManagementClient: dashboard creates it under the session key their sign-in minted). Returns the outcome rather than unwrapping it, so a caller can poll a route that is only transiently refusing.""" - headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key) + headers = self.proxy.management_headers(caller_key) return self.proxy.transport.post( "/key/generate", headers=headers, @@ -131,9 +136,9 @@ class ManagementClient: sign-in minted, never the master key). Returns the outcome rather than unwrapping it, so a caller can poll a route that is only transiently refusing; `update_key_models` is the unwrapping shorthand.""" - headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key) + headers = self.proxy.management_headers(caller_key) last: Result[NoBody] = NetworkError(message="/key/update was never attempted") - for attempt in range(_KEY_WRITE_ATTEMPTS): + for attempt in range(retry_attempts(_KEY_WRITE_ATTEMPTS)): last = self.proxy.transport.post( "/key/update", headers=headers, @@ -144,6 +149,7 @@ class ManagementClient: case UnknownApiError(body=error_body) if any( marker in error_body.lower() for marker in _TRANSIENT_BACKEND_MARKERS ): + warnings.warn(f"Transient backend response on attempt {attempt + 1}", RuntimeWarning, stacklevel=2) time.sleep(0.5 * (attempt + 1)) continue case _: @@ -153,10 +159,10 @@ class ManagementClient: def update_key_models(self, key: str, models: list[str]) -> None: _ = unwrap(self.update_key(KeyUpdateBody(key=key, models=models))) - def key_info_as(self, key: str, *, caller_key: str) -> Result[KeyInfoResponse]: + def key_info_as(self, key: str, *, caller_key: str | None = None) -> Result[KeyInfoResponse]: return self.proxy.transport.get( "/key/info", - headers=self.proxy.transport.bearer(caller_key), + headers=self.proxy.management_headers(caller_key), params=KeyInfoParams(key=key), response_type=KeyInfoResponse, ) @@ -167,7 +173,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/key/delete", - headers=self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key), + headers=self.proxy.management_headers(caller_key), json=KeyDeleteBody(keys=[key]), response_type=NoBody, ) @@ -179,7 +185,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/model/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=ModelDeleteBody(id=model_id), response_type=NoBody, ) @@ -190,7 +196,7 @@ class ManagementClient: Connection button, probing the live provider with the supplied params.""" return self.proxy.transport.post( "/health/test_connection", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=ConnectionTestResponse, timeout=120.0, @@ -200,7 +206,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/key/block", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=KeyBlockBody(key=key), response_type=NoBody, ) @@ -209,7 +215,7 @@ class ManagementClient: return unwrap( self.proxy.transport.post( "/key/regenerate", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=KeyRegenerateBody(key=key, grace_period=grace_period), response_type=KeyGenerateResponse, ) @@ -219,7 +225,7 @@ class ManagementClient: return unwrap( self.proxy.transport.post( f"/key/{key}/reset_spend", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=KeyResetSpendBody(reset_to=reset_to), response_type=KeyResetSpendResponse, ) @@ -228,7 +234,7 @@ class ManagementClient: def key_list(self, key_alias: str, *, caller_key: str | None = None) -> Result[KeyListResponse]: """GET /key/list, the Virtual Keys page's own inventory call. `caller_key` is who is asking: the master key by default, or a virtual key.""" - headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key) + headers = self.proxy.management_headers(caller_key) return self.proxy.transport.get( "/key/list", headers=headers, @@ -266,7 +272,7 @@ class ManagementClient: team_id = unwrap( self.proxy.transport.post( "/team/new", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=TeamNewResponse, ) @@ -276,10 +282,10 @@ class ManagementClient: def update_team(self, body: TeamUpdateBody) -> None: last: Result[NoBody] | None = None - for attempt in range(5): + for attempt in range(retry_attempts(5)): last = self.proxy.transport.post( "/team/update", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=NoBody, ) @@ -289,6 +295,7 @@ class ManagementClient: case UnknownApiError(body=body_text) if ( "connecting to redis" in body_text.lower() or "name resolution" in body_text.lower() ): + warnings.warn(f"Transient backend response on attempt {attempt + 1}", RuntimeWarning, stacklevel=2) time.sleep(0.5 * (attempt + 1)) continue case _: @@ -299,7 +306,7 @@ class ManagementClient: def delete_team(self, team_id: str) -> None: _ = self.proxy.transport.post( "/team/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=TeamDeleteBody(team_ids=[team_id]), response_type=NoBody, ) @@ -308,7 +315,7 @@ class ManagementClient: return unwrap( self.proxy.transport.get( "/team/info", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) @@ -320,7 +327,7 @@ class ManagementClient: for entry in unwrap( self.proxy.transport.get( "/team/list", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=NoBody(), response_type=TeamListResponse, ) @@ -328,14 +335,16 @@ class ManagementClient: ) def team_info_status(self, team_id: str) -> ProbeResult: - return self.proxy.transport.probe("/team/info", params=TeamInfoParams(team_id=team_id)) + return self.proxy.transport.probe( + "/team/info", params=TeamInfoParams(team_id=team_id), headers=self.proxy.management_headers() + ) def _wait_for_team(self, team_id: str) -> None: last: Result[TeamInfoResponse] | None = None - for _ in range(_TEAM_READY_ATTEMPTS): + for _ in range(retry_attempts(_TEAM_READY_ATTEMPTS)): last = self.proxy.transport.get( "/team/info", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) @@ -343,25 +352,29 @@ class ManagementClient: case Success(): return case _: + warnings.warn("Repeating team read while the team becomes available", RuntimeWarning, stacklevel=2) time.sleep(_TEAM_READY_SLEEP_SECONDS) assert last is not None raise AssertionError(last) def add_team_member(self, team_id: str, user_id: str) -> None: last: Result[NoBody] | None = None - for attempt in range(_TEAM_READY_ATTEMPTS): + for attempt in range(retry_attempts(_TEAM_READY_ATTEMPTS)): last = self.proxy.transport.post( "/team/member_add", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=TeamMemberAddBody(team_id=team_id, member=TeamMemberEntry(role="user", user_id=user_id)), response_type=NoBody, ) match last: case Success(): return - case UnknownApiError(body=body) if ( - "doesn't exist" in body and attempt + 1 < _TEAM_READY_ATTEMPTS + case UnknownApiError(body=body) if "doesn't exist" in body and attempt + 1 < retry_attempts( + _TEAM_READY_ATTEMPTS ): + warnings.warn( + "Retrying team membership while the team becomes available", RuntimeWarning, stacklevel=2 + ) time.sleep(_TEAM_READY_SLEEP_SECONDS) continue case _: @@ -373,7 +386,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/team/member_delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=TeamMemberDeleteBody(team_id=team_id, user_id=user_id), response_type=NoBody, ) @@ -383,7 +396,7 @@ class ManagementClient: return unwrap( self.proxy.transport.post( "/user/new", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=UserNewResponse, ) @@ -393,7 +406,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/customer/new", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=CustomerNewBody(user_id=user_id), response_type=CustomerResponse, ) @@ -404,7 +417,7 @@ class ManagementClient: return unwrap( self.proxy.transport.get( "/customer/info", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=CustomerInfoParams(end_user_id=end_user_id), response_type=CustomerResponse, ) @@ -413,7 +426,7 @@ class ManagementClient: def delete_customer(self, user_id: str) -> None: _ = self.proxy.transport.post( "/customer/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=CustomerDeleteBody(user_ids=[user_id]), response_type=NoBody, ) @@ -422,7 +435,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/user/update", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=NoBody, ) @@ -431,7 +444,7 @@ class ManagementClient: def delete_user(self, user_id: str) -> None: _ = self.proxy.transport.post( "/user/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=UserDeleteBody(user_ids=[user_id]), response_type=NoBody, ) @@ -442,17 +455,17 @@ class ManagementClient: _ = unwrap( self.proxy.transport.post( "/user/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=UserDeleteBody(user_ids=[user_id]), response_type=UserDeleteResponse, ) ) - def user_info(self, user_id: str) -> UserInfoResponse: + def user_info(self, user_id: str | None = None) -> UserInfoResponse: return unwrap( self.proxy.transport.get( "/user/info", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=UserInfoParams(user_id=user_id), response_type=UserInfoResponse, ) @@ -462,7 +475,7 @@ class ManagementClient: return unwrap( self.proxy.transport.get( "/user/list", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=UserListParams(user_ids=user_id), response_type=UserListResponse, ) @@ -472,7 +485,7 @@ class ManagementClient: listing = unwrap( self.proxy.transport.get( "/user/list", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=UserListParams(user_ids=user_id), response_type=UserListResponse, ) @@ -483,7 +496,7 @@ class ManagementClient: return unwrap( self.proxy.transport.post( "/organization/new", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=OrgNewResponse, ) @@ -493,7 +506,7 @@ class ManagementClient: _ = unwrap( self.proxy.transport.patch( "/organization/update", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=NoBody, ) @@ -502,7 +515,7 @@ class ManagementClient: def delete_org(self, organization_id: str) -> None: _ = self.proxy.transport.delete( "/organization/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=OrgDeleteBody(organization_ids=[organization_id]), response_type=NoBody, ) @@ -511,19 +524,24 @@ class ManagementClient: return unwrap( self.proxy.transport.get( "/organization/info", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=OrgInfoParams(organization_id=organization_id), response_type=OrgInfoResponse, ) ) def org_info_status(self, organization_id: str) -> ProbeResult: - return self.proxy.transport.probe("/organization/info", params=OrgInfoParams(organization_id=organization_id)) + return self.proxy.transport.probe( + "/organization/info", + params=OrgInfoParams(organization_id=organization_id), + headers=self.proxy.management_headers(), + ) + def create_tag(self, body: TagNewBody) -> None: _ = unwrap( self.proxy.transport.post( "/tag/new", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=NoBody, ) @@ -532,7 +550,7 @@ class ManagementClient: def delete_tag(self, name: str) -> None: _ = self.proxy.transport.post( "/tag/delete", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=TagDeleteBody(name=name), response_type=NoBody, ) @@ -542,7 +560,7 @@ class ManagementClient: unwrap( self.proxy.transport.get( "/tag/list", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), params=NoBody(), response_type=TagListResponse, ) @@ -553,7 +571,7 @@ class ManagementClient: return unwrap( self.proxy.transport.post( "/v1/mcp/server", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=McpServerRow, ) @@ -565,7 +583,7 @@ class ManagementClient: return unwrap( self.proxy.transport.put( "/v1/mcp/server", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=body, response_type=McpServerRow, ) @@ -576,7 +594,7 @@ class ManagementClient: unwrap it while a deferred teardown can ignore an already-deleted server.""" return self.proxy.transport.delete( f"/v1/mcp/server/{server_id}", - headers=self.proxy.transport.master, + headers=self.proxy.management_headers(), json=NoBody(), response_type=NoBody, ) diff --git a/tests/e2e/management/test_jwt_management_e2e.py b/tests/e2e/management/test_jwt_management_e2e.py index 22306da8eb8..0d23f954158 100644 --- a/tests/e2e/management/test_jwt_management_e2e.py +++ b/tests/e2e/management/test_jwt_management_e2e.py @@ -2,60 +2,249 @@ from __future__ import annotations -from typing import Final +from typing import Final, Literal import pytest -from e2e_config import CHEAP_OPENAI_MODEL, unique_marker +from e2e_config import CHEAP_OPENAI_MODEL, PROXY_BASE_URL, unique_marker from e2e_http import UnauthorizedError, UnknownApiError, unwrap -from idp import ADMIN_CLIENT_ID, Identity, Keycloak +from idp import ADMIN_CLIENT_ID, Identity, Keycloak, token_claims from lifecycle import ResourceManager +from management.jwt_actors import ActorFactory, ActorRole from management_client import ManagementClient -from models import KeyGenerateBody, KeyUpdateBody, TeamNewBody, UserNewBody +from models import KeyGenerateBody, KeyUpdateBody, TeamNewBody, UserInfoParams, UserInfoResponse, UserNewBody +from proxy_client import Caller pytestmark = pytest.mark.e2e class TestJwtManagement: + @pytest.mark.parametrize( + "role", + ( + "proxy_admin", + "proxy_admin_viewer", + "organization_admin", + "team_admin", + "team_member", + "internal_user", + "internal_user_viewer", + "unrelated_user", + ), + ) + @pytest.mark.covers("mgmt.user.jwt.database_roles") + def test_actor_subject_and_database_role(self, actor_factory: ActorFactory, role: ActorRole) -> None: + tenants: Final = ( + (actor_factory.tenant(),) if role in ("organization_admin", "team_admin", "team_member") else () + ) + actor: Final = actor_factory.create(role, tenants=tenants) + caller: Final = actor.mint_caller(actor_factory.idp) + claims: Final = token_claims(caller.credential) + assert claims.sub == actor.identity.user_id + assert claims.iss == actor_factory.idp.issuer + assert claims.aud == "litellm-e2e" or "litellm-e2e" in claims.aud + assert actor.identity.groups == () + assert ("litellm_proxy_admin" in claims.scope.split()) == (role == "proxy_admin") + stored: Final = actor_factory.bootstrap.user_info(actor.identity.user_id) + assert stored.user_id == actor.identity.user_id + assert stored.user_info.user_role == actor.global_role + bound: Final = actor_factory.bootstrap.with_caller(caller) + own: Final = unwrap( + bound.proxy.transport.get( + "/user/info", + headers=bound.proxy.management_headers(), + params=UserInfoParams(), + response_type=UserInfoResponse, + ) + ) + assert own.user_id == actor.identity.user_id + assert own.user_info.user_role == actor.global_role + for tenant in tenants: + info = actor_factory.bootstrap.team_info(tenant.team_id) + assert info.organization_id == tenant.organization_id + assert {(member.user_id, member.role) for member in info.members_with_roles} == { + (actor.identity.user_id, "admin" if role == "team_admin" else "user") + } + assert { + (member.user_id, member.user_role) + for member in actor_factory.bootstrap.org_info(tenant.organization_id).members + } == {(actor.identity.user_id, "org_admin" if role == "organization_admin" else "internal_user")} + + @pytest.mark.covers("mgmt.key.jwt.viewer_denied") + def test_admin_viewer_reads_but_cannot_update(self, actor_factory: ActorFactory) -> None: + actor: Final = actor_factory.create("proxy_admin_viewer") + viewer: Final = actor_factory.bootstrap.with_caller(actor.mint_caller(actor_factory.idp)) + alias: Final = f"e2e-viewer-{unique_marker()}" + key: Final = actor_factory.key().key + unwrap(actor_factory.bootstrap.update_key(KeyUpdateBody(key=key, key_alias=alias))) + assert viewer.proxy.key_info(key).key_alias == alias + denied: Final = viewer.update_key(KeyUpdateBody(key=key, key_alias="forbidden")) + assert isinstance(denied, UnknownApiError) and denied.status_code == 403, f"viewer write was accepted: {denied}" + assert "proxy_admin_viewer" in denied.body and "/key/update" in denied.body + assert actor_factory.bootstrap.proxy.key_info(key).key_alias == alias + + @pytest.mark.covers("mgmt.user.oidc.identity_mapping") + def test_oidc_browser_profile_identity_mapping(self, actor_factory: ActorFactory) -> None: + actor: Final = actor_factory.create("internal_user") + idp: Final = actor_factory.idp.with_strict_cleanup() + discovery: Final = idp.discovery() + assert discovery.issuer == idp.issuer + assert discovery.jwks_uri == idp.jwks_url + callback: Final = f"{PROXY_BASE_URL}/sso/callback" + browser: Final = idp.browser_client(callback_url=callback, defer=actor_factory.resources.defer) + token: Final = idp.browser_token(actor.identity, browser) + assert token_claims(token).sub == actor.identity.user_id + userinfo: Final = idp.userinfo(token) + assert userinfo.sub == actor.identity.user_id + assert userinfo.email == f"{actor.identity.username}@example.com" + assert browser.environment(discovery)["GENERIC_USER_ID_ATTRIBUTE"] == "sub" + @pytest.mark.covers("mgmt.key.jwt.lifecycle") + @pytest.mark.parametrize("credential_kind", ("direct_jwt", "virtual_key")) def test_admin_creates_reads_updates_clears_and_deletes_a_key( - self, client: ManagementClient, idp: Keycloak, jwt_identity: Identity, resources: ResourceManager + self, + client: ManagementClient, + idp: Keycloak, + jwt_identity: Identity, + resources: ResourceManager, + actor_factory: ActorFactory, + credential_kind: Literal["direct_jwt", "virtual_key"], ) -> None: - admin: Final = idp.access_token(jwt_identity, client_id=ADMIN_CLIENT_ID) + actor: Final = actor_factory.create("proxy_admin") + virtual_key: Final = ( + actor_factory.key(user_id=actor.identity.user_id).key if credential_kind == "virtual_key" else None + ) + admin: Final = ( + virtual_key if virtual_key is not None else idp.access_token(jwt_identity, client_id=ADMIN_CLIENT_ID) + ) + bound: Final = client.with_caller(Caller(credential=admin, kind=credential_kind, role="proxy_admin")) alias: Final = f"e2e-jwt-key-{unique_marker()}" created: Final = unwrap( - client.generate_key( + bound.generate_key( KeyGenerateBody(key_alias=alias, team_id=jwt_identity.group, models=[CHEAP_OPENAI_MODEL]), - caller_key=admin, ) ) resources.defer(lambda: client.proxy.delete_key(created.key)) - original: Final = unwrap(client.key_info_as(created.key, caller_key=admin)).info + original: Final = unwrap(bound.key_info_as(created.key)).info assert original.key_alias == alias and original.team_id == jwt_identity.group assert original.models == [CHEAP_OPENAI_MODEL] updated_alias: Final = f"{alias}-updated" - unwrap( - client.update_key(KeyUpdateBody(key=created.key, key_alias=updated_alias, rpm_limit=120), caller_key=admin) - ) - updated: Final = unwrap(client.key_info_as(created.key, caller_key=admin)).info + unwrap(bound.update_key(KeyUpdateBody(key=created.key, key_alias=updated_alias, rpm_limit=120))) + updated: Final = unwrap(bound.key_info_as(created.key)).info assert updated.key_alias == updated_alias and updated.rpm_limit == 120 assert updated.models == [CHEAP_OPENAI_MODEL], "omitted models must preserve the restriction" - unwrap(client.update_key(KeyUpdateBody(key=created.key, models=[]), caller_key=admin)) - cleared: Final = unwrap(client.key_info_as(created.key, caller_key=admin)).info + unwrap(bound.update_key(KeyUpdateBody(key=created.key, models=[]))) + cleared: Final = unwrap(bound.key_info_as(created.key)).info assert cleared.models == [] and cleared.rpm_limit == 120 - assert unwrap(client.key_list(updated_alias, caller_key=admin)).total_count == 1 - client.delete_key_strict(created.key, caller_key=admin) - assert unwrap(client.key_list(updated_alias, caller_key=admin)).total_count == 0 + assert unwrap(bound.key_list(updated_alias)).total_count == 1 + bound.delete_key_strict(created.key) + assert unwrap(bound.key_list(updated_alias)).total_count == 0 + + @pytest.mark.covers("mgmt.team.jwt.tenant_isolation") + def test_two_actor_sets_keep_tenants_and_keys_isolated(self, actor_factory: ActorFactory) -> None: + first: Final = actor_factory.tenant() + second: Final = actor_factory.tenant() + assert first.organization_id != second.organization_id and first.team_id != second.team_id + actors: Final = tuple( + actor_factory.create("team_member", tenants=(tenant,), profile="group_scoped") for tenant in (first, second) + ) + assert actors[0].identity.user_id != actors[1].identity.user_id + callers: Final = tuple( + actor_factory.bootstrap.with_caller(actor.mint_caller(actor_factory.idp)) for actor in actors + ) + keys: Final = tuple(actor_factory.key(tenant) for tenant in (first, second)) + assert keys[0].key != keys[1].key + assert callers[0].proxy.key_info(keys[0].key).team_id == first.team_id + assert callers[1].proxy.key_info(keys[1].key).team_id == second.team_id + for caller, other_key in ((callers[0], keys[1].key), (callers[1], keys[0].key)): + hidden = caller.key_info_as(other_key) + assert isinstance(hidden, UnknownApiError) and hidden.status_code == 403 + assert tuple(actor.identity.groups for actor in actors) == ((first.team_id,), (second.team_id,)) + + @pytest.mark.covers("mgmt.team.jwt.multiple_memberships") + def test_multi_group_actor_keeps_exact_memberships(self, actor_factory: ActorFactory) -> None: + tenants: Final = (actor_factory.tenant(), actor_factory.tenant()) + actor: Final = actor_factory.create("team_member", tenants=tenants, profile="group_scoped") + claims: Final = token_claims(actor.mint_caller(actor_factory.idp).credential) + assert set(claims.groups) == {tenant.team_id for tenant in tenants} + assert "litellm_proxy_admin" not in claims.scope.split() + assert actor.identity.groups == tuple(tenant.team_id for tenant in tenants) + for tenant in tenants: + assert { + (entry.user_id, entry.role) + for entry in actor_factory.bootstrap.team_info(tenant.team_id).members_with_roles + } == {(actor.identity.user_id, "user")} + + @pytest.mark.covers("mgmt.user.jwt.cleanup") + def test_successful_actor_cleanup_removes_owned_state(self, actor_factory: ActorFactory) -> None: + resources: Final = ResourceManager(client=actor_factory.bootstrap.proxy, strict_cleanup=True) + factory: Final = ActorFactory(bootstrap=actor_factory.bootstrap, idp=actor_factory.idp, resources=resources) + try: + tenant: Final = factory.tenant() + actor: Final = factory.create("team_member", tenants=(tenant,), profile="group_scoped") + key: Final = factory.key(tenant) + alias: Final = factory.bootstrap.proxy.key_info(key.key).key_alias + assert alias is not None + finally: + resources.teardown() + assert factory.bootstrap.user_count(actor.identity.user_id) == 0 + assert factory.bootstrap.key_alias_count(alias) == 0 + assert factory.bootstrap.team_info_status(tenant.team_id).status_code == 404 + assert factory.bootstrap.org_info_status(tenant.organization_id).status_code == 404 + factory.idp.assert_absent("users", actor.identity.user_id) + factory.idp.assert_absent("groups", tenant.group_id) + + @pytest.mark.parametrize("stage", ("group", "user")) + @pytest.mark.covers("mgmt.user.jwt.partial_cleanup") + def test_partial_setup_removes_previously_created_identities( + self, + actor_factory: ActorFactory, + stage: Literal["group", "user"], + ) -> None: + idp: Final = actor_factory.idp.with_strict_cleanup() + resources: Final = ResourceManager(client=actor_factory.bootstrap.proxy, strict_cleanup=True) + marker: Final = unique_marker() + group_id: Final = idp.create_group(f"e2e-partial-{marker}") + resources.defer(lambda: idp.delete_group(group_id)) + try: + identity: Final = ( + idp.provision_user( + marker=marker, + groups=(f"e2e-partial-{marker}",), + group_ids=(group_id,), + defer=resources.defer, + ) + if stage == "user" + else None + ) + if identity is None: + with pytest.raises(pytest.fail.Exception, match="HTTP 409"): + idp.create_group(f"e2e-partial-{marker}") + else: + with pytest.raises(pytest.fail.Exception, match="HTTP 409"): + idp.create_user( + username=identity.username, + email=f"{identity.username}@example.com", + password=identity.password, + groups=identity.groups, + ) + finally: + resources.teardown() + idp.assert_absent("groups", group_id) + if identity is not None: + idp.assert_absent("users", identity.user_id) @pytest.mark.covers("mgmt.key.jwt.member_denied", "mgmt.key.jwt.other_team_denied") def test_member_cannot_write_and_another_team_cannot_read_the_key( self, client: ManagementClient, idp: Keycloak, jwt_identity: Identity, resources: ResourceManager ) -> None: admin: Final = idp.access_token(jwt_identity, client_id=ADMIN_CLIENT_ID) + bound: Final = client.with_caller(Caller(credential=admin, kind="direct_jwt", role="proxy_admin")) member: Final = idp.access_token(jwt_identity) + member_client: Final = client.with_caller(Caller(credential=member, kind="direct_jwt", role="team_member")) alias: Final = f"e2e-jwt-owned-{unique_marker()}" created: Final = unwrap( client.generate_key(KeyGenerateBody(key_alias=alias, team_id=jwt_identity.group), caller_key=admin) @@ -63,14 +252,14 @@ class TestJwtManagement: resources.defer(lambda: client.proxy.delete_key(created.key)) client.add_team_member(jwt_identity.group, jwt_identity.user_id) - assert unwrap(client.key_info_as(created.key, caller_key=member)).info.key_alias == alias + assert unwrap(member_client.key_info_as(created.key)).info.key_alias == alias - refused: Final = client.update_key(KeyUpdateBody(key=created.key, key_alias="forbidden"), caller_key=member) + refused: Final = member_client.update_key(KeyUpdateBody(key=created.key, key_alias="forbidden")) assert isinstance(refused, UnauthorizedError), f"member write was accepted: {refused}" assert "does not have permissions for endpoint" in refused.body.lower(), ( f"expected a permission denial: {refused}" ) - assert unwrap(client.key_info_as(created.key, caller_key=admin)).info.key_alias == alias + assert unwrap(bound.key_info_as(created.key)).info.key_alias == alias marker: Final = unique_marker() outsider: Final = idp.provision(marker=marker, group=f"e2e-jwt-team-{marker}", defer=resources.defer) @@ -88,4 +277,4 @@ class TestJwtManagement: assert isinstance(hidden, UnknownApiError) and hidden.status_code == 403, ( f"another team must not read this key: {hidden}" ) - assert unwrap(client.key_info_as(created.key, caller_key=admin)).info.team_id == jwt_identity.group + assert unwrap(bound.key_info_as(created.key)).info.team_id == jwt_identity.group diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 3cab0334dea..6fca1268ebc 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -1091,13 +1091,13 @@ class UiLoginBody(BaseModel): class UiLoginResponse(BaseModel): - token: str + token: str = Field(repr=False) redirect_url: str class UiSessionClaims(BaseModel): user_id: str - key: str + key: str = Field(repr=False) user_role: str login_method: Literal["sso", "username_password"] exp: int @@ -1135,6 +1135,7 @@ class TeamInfoParams(BaseModel): class TeamData(BaseModel): + organization_id: str | None = None team_alias: str | None = None models: list[str] = [] members_with_roles: list[TeamMemberEntry] = [] @@ -1175,6 +1176,7 @@ class UserNewBody(BaseModel): user_email: str user_role: UserRole user_id: str | None = None + auto_create_key: bool | None = None class UserNewResponse(BaseModel): @@ -1187,7 +1189,7 @@ class UserUpdateBody(BaseModel): class UserInfoParams(BaseModel): - user_id: str + user_id: str | None = None class UserData(BaseModel): @@ -1240,16 +1242,36 @@ class OrgInfoParams(BaseModel): organization_id: str +class OrgMembership(BaseModel): + user_id: str + user_role: str + + class OrgInfoResponse(BaseModel): organization_id: str organization_alias: str | None = None models: list[str] = [] + members: tuple[OrgMembership, ...] = () + + +class OrgMemberEntry(BaseModel): + user_id: str + role: Literal["org_admin", "internal_user"] + + +class OrgMemberAddBody(BaseModel): + organization_id: str + member: OrgMemberEntry class OrgDeleteBody(BaseModel): organization_ids: list[str] +class OrgDeleteResponse(RootModel[tuple[OrgInfoResponse, ...]]): + pass + + # ---------- tags (management) ---------- diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index 1fe2ec905ef..48a6110dc0b 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -11,14 +11,23 @@ from __future__ import annotations import time import warnings from collections.abc import Callable, Mapping -from dataclasses import dataclass -from functools import reduce +from dataclasses import dataclass, field, replace from datetime import datetime +from functools import reduce from types import MappingProxyType -from typing import Final - -from pydantic import BaseModel +from typing import Final, Literal +from e2e_config import ( + CONTROL_PLANE_BASE_URL, + MASTER_KEY, + POLL_INTERVAL, + POLL_TIMEOUT, + PROXY_BASE_URL, + PROXY_REPLICA_URLS, + REQUEST_TIMEOUT, + SLOW_PROVIDER_TIMEOUT_SECONDS, + settle_propagation, +) from e2e_http import ( AnthropicHeaders, AuthHeaders, @@ -55,6 +64,7 @@ from models import ( KeyInfoParams, KeyInfoResponse, LiteLLMParamsBody, + MemorySummaryResponse, ModelDeleteBody, ModelInfoBody, ModelInfoEntry, @@ -63,7 +73,6 @@ from models import ( ModelNewBody, ModelNewResponse, ModelsListParams, - MemorySummaryResponse, ModelsListResponse, ModelUpdateBody, OcrBody, @@ -76,23 +85,13 @@ from models import ( TeamDeleteBody, TeamNewBody, TeamNewResponse, - UserDeleteBody, - UserDeleteResponse, ToolsetCreateBody, ToolsetRow, ToolsetUpdateBody, + UserDeleteBody, + UserDeleteResponse, ) -from e2e_config import ( - CONTROL_PLANE_BASE_URL, - MASTER_KEY, - POLL_INTERVAL, - POLL_TIMEOUT, - PROXY_BASE_URL, - PROXY_REPLICA_URLS, - REQUEST_TIMEOUT, - SLOW_PROVIDER_TIMEOUT_SECONDS, - settle_propagation, -) +from pydantic import BaseModel from transport import HttpTransport, SplitTransport, Transport, is_control_plane_path RowsPredicate = Callable[[list[SpendLogRow]], bool] @@ -421,11 +420,23 @@ def converge_timeout_message(*, what: str, replica: str, timeout: float, last_re ) +CredentialKind = Literal["master", "direct_jwt", "virtual_key", "dashboard_session"] + + +@dataclass(frozen=True, slots=True) +class Caller: + credential: str = field(repr=False) + kind: CredentialKind + role: str + tenant: str | None = None + + @dataclass(frozen=True, slots=True) class ProxyClient: transport: Transport replicas: Mapping[str, Transport] control_replicas: Mapping[str, Transport] + caller: Caller | None = None poll_timeout: float = 120.0 poll_interval: float = 5.0 model_servable_timeout: float = MODEL_SERVABLE_TIMEOUT @@ -433,13 +444,24 @@ class ProxyClient: model_servable_interval: float = MODEL_SERVABLE_INTERVAL model_servable_request_timeout: float = MODEL_SERVABLE_REQUEST_TIMEOUT + def with_caller(self, caller: Caller) -> ProxyClient: + return replace(self, caller=caller) + + def management_headers(self, caller_key: str | None = None, *, transport: Transport | None = None) -> AuthHeaders: + selected: Final = self.transport if transport is None else transport + if caller_key is not None: + return selected.bearer(caller_key) + if self.caller is not None: + return selected.bearer(self.caller.credential) + return selected.master + # ---- keys / customers (satisfies lifecycle.ResourceClient) ---------- def generate_key(self, body: KeyGenerateBody) -> str: return unwrap( self.transport.post( "/key/generate", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=KeyGenerateResponse, ) @@ -448,7 +470,7 @@ class ProxyClient: def delete_key(self, key: str) -> None: _ = self.transport.post( "/key/delete", - headers=self.transport.master, + headers=self.management_headers(), json=KeyDeleteBody(keys=[key]), response_type=NoBody, ) @@ -458,7 +480,7 @@ class ProxyClient: return _ = self.transport.post( "/customer/delete", - headers=self.transport.master, + headers=self.management_headers(), json=CustomerDeleteBody(user_ids=user_ids), response_type=NoBody, ) @@ -467,7 +489,7 @@ class ProxyClient: return unwrap( self.transport.get( "/key/info", - headers=self.transport.master, + headers=self.management_headers(), params=KeyInfoParams(key=key), response_type=KeyInfoResponse, ) @@ -477,7 +499,7 @@ class ProxyClient: return { url: transport.get( "/debug/memory/summary", - headers=transport.master, + headers=self.management_headers(transport=transport), params=NoBody(), response_type=MemorySummaryResponse, ) @@ -524,11 +546,12 @@ class ProxyClient: {replica: outcome.result for replica, outcome in outcomes.items() if isinstance(outcome, Converged)} ) - @staticmethod def _body_poller[R: BaseModel]( - transport: Transport, path: str, params: BaseModel, response_type: type[R] + self, transport: Transport, path: str, params: BaseModel, response_type: type[R] ) -> Poller[Result[R]]: - return lambda: transport.get(path, headers=transport.master, params=params, response_type=response_type) + return lambda: transport.get( + path, headers=self.management_headers(transport=transport), params=params, response_type=response_type + ) def model_info(self) -> list[ModelInfoEntry]: """Every configured deployment with the price the proxy resolved for it @@ -536,7 +559,7 @@ class ProxyClient: return unwrap( self.transport.get( "/model/info", - headers=self.transport.master, + headers=self.management_headers(), params=NoBody(), response_type=ModelInfoResponse, ) @@ -546,7 +569,7 @@ class ProxyClient: return unwrap( self.transport.get( "/public/litellm_model_cost_map", - headers=self.transport.master, + headers=self.management_headers(), params=NoBody(), response_type=CostMap, ) @@ -607,7 +630,7 @@ class ProxyClient: model_id = unwrap( self.transport.post( "/model/new", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=ModelNewResponse, ) @@ -623,7 +646,7 @@ class ProxyClient: def _await_model_servable(self, model_name: str, listed_for: str | None = None) -> None: """Block until every replica lists `model_name`, or fail at model_servable_timeout.""" - headers: Final = self.transport.master if listed_for is None else self.transport.bearer(listed_for) + headers: Final = self.management_headers(listed_for) outcome: Final = await_servable_everywhere( {url: self._models_poller(transport, headers) for url, transport in self.replicas.items()}, model_name=model_name, @@ -666,7 +689,7 @@ class ProxyClient: unwrap( self.transport.post( "/model/update", - headers=self.transport.master, + headers=self.management_headers(), json=ModelUpdateBody( litellm_params=litellm_params, model_info=ModelInfoBody(id=model_id), @@ -678,7 +701,7 @@ class ProxyClient: def delete_model(self, model_id: str) -> None: result = self.transport.post( "/model/delete", - headers=self.transport.master, + headers=self.management_headers(), json=ModelDeleteBody(id=model_id), response_type=NoBody, ) @@ -747,11 +770,10 @@ class ProxyClient: f"GET {path} on {replica} still answers {self.poll_timeout}s after the delete; last read: {last}" ) - @staticmethod - def _reader[R: BaseModel](transport: Transport, path: str, response_type: type[R]) -> ReplicaRead[Result[R]]: + def _reader[R: BaseModel](self, transport: Transport, path: str, response_type: type[R]) -> ReplicaRead[Result[R]]: return lambda request_timeout: transport.get( path, - headers=transport.master, + headers=self.management_headers(transport=transport), params=NoBody(), response_type=response_type, timeout=request_timeout, @@ -763,7 +785,7 @@ class ProxyClient: return unwrap( self.transport.post( "/v1/mcp/toolset", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=ToolsetRow, ) @@ -775,7 +797,7 @@ class ProxyClient: return unwrap( self.transport.put( "/v1/mcp/toolset", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=ToolsetRow, ) @@ -786,7 +808,7 @@ class ProxyClient: can unwrap it while a deferred teardown can ignore an already-deleted row.""" return self.transport.delete( f"/v1/mcp/toolset/{toolset_id}", - headers=self.transport.master, + headers=self.management_headers(), json=NoBody(), response_type=NoBody, ) @@ -795,7 +817,7 @@ class ProxyClient: unwrap( self.transport.post( "/credentials", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=CredentialCreateResponse, ) @@ -804,7 +826,7 @@ class ProxyClient: def delete_credential(self, credential_name: str) -> None: result = self.transport.delete( f"/credentials/{credential_name}", - headers=self.transport.master, + headers=self.management_headers(), json=NoBody(), response_type=NoBody, ) @@ -815,7 +837,7 @@ class ProxyClient: return unwrap( self.transport.post( "/team/new", - headers=self.transport.master, + headers=self.management_headers(), json=body, response_type=TeamNewResponse, ) @@ -824,7 +846,7 @@ class ProxyClient: def delete_team(self, team_id: str) -> None: result = self.transport.post( "/team/delete", - headers=self.transport.master, + headers=self.management_headers(), json=TeamDeleteBody(team_ids=[team_id]), response_type=NoBody, ) @@ -836,7 +858,7 @@ class ProxyClient: a user the proxy only upserts after a successful auth.""" result = self.transport.post( "/user/delete", - headers=self.transport.master, + headers=self.management_headers(), json=UserDeleteBody(user_ids=[user_id]), response_type=UserDeleteResponse, ) @@ -909,7 +931,7 @@ class ProxyClient: def spend_logs(self, params: SpendLogsParams) -> list[SpendLogRow]: result = self.transport.get( "/spend/logs", - headers=self.transport.master, + headers=self.management_headers(), params=params, response_type=SpendLogs, ) @@ -924,7 +946,7 @@ class ProxyClient: return unwrap( self.transport.get( "/spend/logs/v2", - headers=self.transport.master, + headers=self.management_headers(), params=SpendLogsPageParams( start_date=start.strftime("%Y-%m-%d %H:%M:%S"), end_date=end.strftime("%Y-%m-%d %H:%M:%S"), @@ -977,7 +999,7 @@ class ProxyClient: # ---- route probe ---------------------------------------------------- def probe(self, path: str, *, params: NoBody) -> ProbeResult: - return self.transport.probe(path, params=params) + return self.transport.probe(path, params=params, headers=self.management_headers()) def build_proxy_client( diff --git a/tests/e2e/test_e2e_http.py b/tests/e2e/test_e2e_http.py index 81cd6c8d3d1..7201da84924 100644 --- a/tests/e2e/test_e2e_http.py +++ b/tests/e2e/test_e2e_http.py @@ -29,6 +29,7 @@ from e2e_http import ( request_with_retry, streaming_outcome, wire_body, + without_retries, ) from pydantic import BaseModel, TypeAdapter @@ -56,6 +57,15 @@ def _issue_from(responses: Sequence[FakeResponse]) -> Callable[[], FakeResponse] class TestTransientRetryPolicy: + def test_qualification_disables_retries_and_restores_the_default(self) -> None: + responses: Final = (FakeResponse(529), FakeResponse(200)) + sleep: Final = SleepRecorder() + with without_retries(): + assert request_with_retry(_issue_from(responses), sleep=sleep) is responses[0] + assert sleep.delays == () + assert request_with_retry(_issue_from(responses), sleep=sleep) is responses[1] + assert sleep.delays == (0.5,) + def test_transient_set_is_only_statuses_the_proxy_cannot_emit(self) -> None: assert TRANSIENT_STATUSES == frozenset({529}) assert 429 not in TRANSIENT_STATUSES diff --git a/tests/e2e/test_idp.py b/tests/e2e/test_idp.py index 33a09a0f13a..8a92dfa527c 100644 --- a/tests/e2e/test_idp.py +++ b/tests/e2e/test_idp.py @@ -4,12 +4,19 @@ these carry no `e2e` marker and run everywhere.""" from __future__ import annotations +import os +import signal +import subprocess +import sys +import time +from builtins import ExceptionGroup from collections.abc import Callable, Generator from contextlib import ExitStack, contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from queue import SimpleQueue from threading import Thread -from typing import Final +from typing import Final, Literal import pytest from e2e_http import ExternalWrite @@ -18,6 +25,8 @@ from idp import ( KEYCLOAK_ADMIN_USER_ENV, KEYCLOAK_REALM_ENV, KEYCLOAK_URL_ENV, + BrowserClientBody, + Discovery, Keycloak, PasswordCredential, UserCreateBody, @@ -60,24 +69,48 @@ def _idp_server( ) -> Generator[tuple[Keycloak, SimpleQueue[str]]]: """Exercise provisioning failures through the same HTTP transport as live tests.""" deletions: SimpleQueue[str] = SimpleQueue() + clients: SimpleQueue[BrowserClientBody] = SimpleQueue() class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: pass def do_POST(self) -> None: - self.rfile.read(int(self.headers.get("Content-Length", "0"))) + body: Final = self.rfile.read(int(self.headers.get("Content-Length", "0"))) if self.path.endswith("/token"): self.send_response(admin_status) self.end_headers() self.wfile.write(b'{"access_token":"synthetic-harness-token"}') else: + if self.path.endswith("/clients"): + clients.put(BrowserClientBody.model_validate_json(body)) self.send_response(user_status if self.path.endswith("/users") else 201) self.send_header("Location", f"{self.path}/resource-1") self.end_headers() if user_status != 201 and self.path.endswith("/users"): self.wfile.write(b"injected create failure") + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + if "/clients/" in self.path: + client: Final = clients.get_nowait() + clients.put(client) + self.wfile.write(client.model_dump_json(by_alias=True).encode()) + else: + issuer: Final = f"http://127.0.0.1:{server.server_port}/realms/test" + self.wfile.write( + Discovery( + issuer=issuer, + authorization_endpoint=f"{issuer}/auth", + token_endpoint=f"{issuer}/token", + userinfo_endpoint=f"{issuer}/userinfo", + jwks_uri=f"{issuer}/certs", + ) + .model_dump_json() + .encode() + ) + def do_DELETE(self) -> None: deletions.put(self.path) self.send_response(delete_status) @@ -115,6 +148,54 @@ def test_partial_provisioning_removes_the_group_when_user_creation_fails() -> No assert deletions.empty() +@pytest.mark.parametrize("exit_mode", ("normal", "parent", "group")) +def test_oidc_launcher_removes_client_on_exit_and_termination( + tmp_path: Path, exit_mode: Literal["normal", "parent", "group"] +) -> None: + ready: Final = tmp_path / "ready" + child_command: Final = ( + "import os,time; from pathlib import Path; " + 'assert os.environ["GENERIC_CLIENT_SECRET"]; ' + 'assert os.environ["GENERIC_CLIENT_USE_PKCE"] == "true"; ' + f"Path({str(ready)!r}).touch(); " + ("raise SystemExit(7)" if exit_mode == "normal" else "time.sleep(120)") + ) + with _idp_server() as (idp, deletions): + with subprocess.Popen( + [ + sys.executable, + str(Path(__file__).with_name("idp.py")), + "http://127.0.0.1:9999", + sys.executable, + "-c", + child_command, + ], + env={ + **os.environ, + KEYCLOAK_URL_ENV: idp.base_url, + KEYCLOAK_REALM_ENV: idp.realm, + KEYCLOAK_ADMIN_USER_ENV: idp.admin_username, + KEYCLOAK_ADMIN_PASSWORD_ENV: idp.admin_password, + }, + start_new_session=True, + ) as process: + try: + deadline: Final = time.monotonic() + 15 + while not ready.exists() and time.monotonic() < deadline and process.poll() is None: + time.sleep(0.05) + assert ready.exists(), "OIDC child did not start" + if exit_mode == "parent": + process.terminate() + elif exit_mode == "group": + os.killpg(process.pid, signal.SIGTERM) + assert process.wait(timeout=10) == (7 if exit_mode == "normal" else 143) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + assert deletions.get(timeout=5) == "/admin/realms/test/clients/resource-1" + assert deletions.empty() + + def test_successful_provisioning_cleans_up_user_before_group() -> None: with _idp_server() as (idp, deletions): with ExitStack() as cleanup: @@ -134,6 +215,43 @@ def test_cleanup_failure_is_visible() -> None: idp.delete_group("group") +def test_strict_cleanup_reports_each_failure_and_continues() -> None: + from lifecycle import ResourceManager + from proxy_client import build_proxy_client + + with _idp_server(delete_status=500) as (idp, deletions): + resources: Final = ResourceManager(client=build_proxy_client(), strict_cleanup=True) + strict: Final = idp.with_strict_cleanup() + resources.defer(lambda: strict.delete_group("group")) + resources.defer(lambda: strict.delete_user("user")) + with pytest.raises(ExceptionGroup, match="Resource cleanup failed") as error: + resources.teardown() + assert len(error.value.exceptions) == 2 + assert deletions.get_nowait() == "/admin/realms/test/users/user" + assert deletions.get_nowait() == "/admin/realms/test/groups/group" + + +@pytest.mark.parametrize("groups", ((), ("one",), ("one", "two"))) +def test_provisioning_records_zero_one_or_multiple_groups(groups: tuple[str, ...]) -> None: + with _idp_server() as (idp, deletions): + with ExitStack() as cleanup: + + def defer(callback: Callable[[], object]) -> None: + cleanup.callback(callback) + + identity: Final = idp.provision_groups( + marker="memberships", + groups=groups, + defer=defer, + ) + assert identity.groups == groups + assert len(identity.group_ids) == len(groups) + assert deletions.get_nowait() == "/admin/realms/test/users/resource-1" + for _ in groups: + assert deletions.get_nowait() == "/admin/realms/test/groups/resource-1" + assert deletions.empty() + + def test_expired_admin_credentials_do_not_abort_remaining_cleanups() -> None: with _idp_server(admin_status=401) as (idp, _): cleanup: Final = ExitStack() diff --git a/tests/e2e/test_proxy_client.py b/tests/e2e/test_proxy_client.py index 1b0133f12cb..879bd88980c 100644 --- a/tests/e2e/test_proxy_client.py +++ b/tests/e2e/test_proxy_client.py @@ -11,19 +11,53 @@ injected clock, so nothing here monkeypatches anything. from __future__ import annotations -from collections.abc import Iterable, Mapping +import json +from builtins import ExceptionGroup +from collections.abc import Callable, Generator, Iterable, Mapping +from contextlib import contextmanager from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from itertools import chain, repeat +from queue import SimpleQueue +from threading import Thread from types import MappingProxyType from typing import Final, cast import pytest from e2e_config import parse_replica_urls -from e2e_http import Result, Success -from models import KeyInfo, KeyInfoResponse, ModelListEntry, ModelsListResponse +from e2e_http import NoBody, Result, Success, without_retries +from idp import Keycloak +from lifecycle import ResourceManager +from management.jwt_actors import ActorFactory +from management.management_client import ManagementClient +from models import ( + ConnectionTestBody, + CredentialCreateBody, + KeyGenerateBody, + KeyInfo, + KeyInfoResponse, + KeyUpdateBody, + LiteLLMParamsBody, + McpServerCreateBody, + McpServerUpdateBody, + ModelListEntry, + ModelsListResponse, + OrgNewBody, + OrgUpdateBody, + SpendLogsParams, + TagNewBody, + TeamNewBody, + TeamUpdateBody, + ToolsetCreateBody, + ToolsetUpdateBody, + UserNewBody, + UserUpdateBody, +) from proxy_client import ( - ConvergeOutcome, + Caller, Converged, + ConvergeOutcome, + CredentialKind, EverywhereConverged, ModelsPoller, NeverConvergedOn, @@ -42,6 +76,108 @@ from proxy_client import ( ) from transport import Transport + +@contextmanager +def caller_boundary( + status: int = 200, bodies: SimpleQueue[bytes] | None = None, *, delete_status: int | None = None +) -> Generator[tuple[ManagementClient, SimpleQueue[str]]]: + received: Final[SimpleQueue[str]] = SimpleQueue() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + pass + + def do_GET(self) -> None: + received.put(self.headers.get("Authorization", "")) + self.send_response(delete_status if self.path == "/key/delete" and delete_status is not None else status) + self.end_headers() + self.wfile.write( + b'{"key":"owned","info":{"key_alias":"owned"},"data":[{"id":"owned"}],"team_id":"owned","team_info":{},"model_id":"owned"}' + ) + + def do_POST(self) -> None: + body: Final = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if bodies is not None: + bodies.put(body) + self.do_GET() + + do_PATCH = do_POST + do_PUT = do_POST + do_DELETE = do_POST + + server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread: Final = Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True) + thread.start() + url: Final = f"http://127.0.0.1:{server.server_port}" + proxy: Final = build_proxy_client( + base_url=url, control_plane_base_url=url, replica_urls=(url,), master_key="bootstrap" + ) + try: + yield ManagementClient(proxy=proxy, master_key="bootstrap"), received + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class TestBoundManagementCaller: + def test_actor_key_cleanup_reports_failure_and_continues(self) -> None: + with caller_boundary(delete_status=500) as (bootstrap, received), without_retries(): + resources: Final = ResourceManager(client=bootstrap.proxy, strict_cleanup=True) + remaining: SimpleQueue[str] = SimpleQueue() + resources.defer(lambda: remaining.put("cleaned")) + factory: Final = ActorFactory( + bootstrap=bootstrap, + idp=Keycloak(base_url="http://unused.test", realm="test", admin_username="test", admin_password="test"), + resources=resources, + ) + assert factory.key().key == "owned" + with pytest.raises(ExceptionGroup, match="Resource cleanup failed") as failure: + resources.teardown() + assert len(failure.value.exceptions) == 1 + assert remaining.get_nowait() == "cleaned" + assert (received.get_nowait(), received.get_nowait()) == ("Bearer bootstrap", "Bearer bootstrap") + + @pytest.mark.parametrize("kind", ("direct_jwt", "virtual_key", "dashboard_session")) + def test_direct_delegated_and_replica_reads_keep_the_bound_caller(self, kind: CredentialKind) -> None: + with caller_boundary() as (bootstrap, received): + caller: Final = Caller(credential="synthetic-caller", kind=kind, role="internal_user", tenant="tenant-a") + bound: Final = bootstrap.with_caller(caller) + bound.update_key(KeyUpdateBody(key="owned", key_alias="updated")) + bound.proxy.key_info("owned") + bound.proxy.read_back_everywhere( + "/key/info", + params=KeyUpdateBody(key="owned"), + response_type=KeyInfoResponse, + converged=lambda result: isinstance(result, Success), + ) + bound.proxy.read_body_back_everywhere( + "/key/info", KeyInfoResponse, settled=lambda result: result.info.key_alias == "owned" + ) + assert tuple(received.get_nowait() for _ in range(4)) == ("Bearer synthetic-caller",) * 4 + assert received.empty() + bootstrap.proxy.key_info("owned") + assert received.get_nowait() == "Bearer bootstrap" + + def test_explicit_override_wins_without_rebinding_or_changing_master(self) -> None: + with caller_boundary() as (bootstrap, received): + bound: Final = bootstrap.with_caller(Caller(credential="bound", kind="direct_jwt", role="internal_user")) + bound.update_key(KeyUpdateBody(key="owned"), caller_key="override") + bound.proxy.key_info("owned") + assert received.get_nowait() == "Bearer override" + assert received.get_nowait() == "Bearer bound" + assert bound.master_key == "bootstrap" + + def test_credentials_are_absent_from_binding_and_header_diagnostics(self) -> None: + with caller_boundary() as (bootstrap, _): + caller: Final = Caller(credential="private-value", kind="direct_jwt", role="internal_user") + bound: Final = bootstrap.with_caller(caller) + assert "private-value" not in repr(caller) + assert "private-value" not in repr(bound) + assert "private-value" not in repr(bound.proxy.management_headers()) + assert "bootstrap" not in repr(bound) + + MODEL: Final = "gpt-under-test" _NO_TRANSPORTS: Final = cast(Transport, None) TIMEOUT: Final = 10.0 @@ -275,3 +411,166 @@ class TestReplicasFor: client: Final = ProxyClient(transport=_NO_TRANSPORTS, replicas={}, control_replicas={}) with pytest.raises(AssertionError, match="no replica is configured"): _ = client.replicas_for("/v1/models") + + +MANAGEMENT_OPERATIONS: Final[tuple[tuple[str, Callable[[ManagementClient], object]], ...]] = ( + ("generate_key", lambda c: c.generate_key(KeyGenerateBody())), + ("llm_only_key", lambda c: c.llm_only_key()), + ("update_key", lambda c: c.update_key(KeyUpdateBody(key="owned"))), + ("update_key_models", lambda c: c.update_key_models("owned", [])), + ("key_info", lambda c: c.key_info_as("owned")), + ("delete_key_strict", lambda c: c.delete_key_strict("owned")), + ("delete_model_strict", lambda c: c.delete_model_strict("owned")), + ( + "connection_test", + lambda c: c.connection_test( + ConnectionTestBody(litellm_params=LiteLLMParamsBody(model="synthetic"), mode="chat") + ), + ), + ("block_key", lambda c: c.block_key("owned")), + ("regenerate_key", lambda c: c.regenerate_key("owned")), + ("reset_key_spend", lambda c: c.reset_key_spend("owned", 0)), + ("key_list", lambda c: c.key_list("owned")), + ("key_alias_count", lambda c: c.key_alias_count("owned")), + ("create_team", lambda c: c.create_team(TeamNewBody(team_alias="owned"))), + ("update_team", lambda c: c.update_team(TeamUpdateBody(team_id="owned", team_alias="updated"))), + ("delete_team", lambda c: c.delete_team("owned")), + ("team_info", lambda c: c.team_info("owned")), + ("team_list_ids", lambda c: c.team_list_ids()), + ("team_info_status", lambda c: c.team_info_status("owned")), + ("add_team_member", lambda c: c.add_team_member("owned", "user")), + ("delete_team_member", lambda c: c.delete_team_member("owned", "user")), + ("create_user", lambda c: c.create_user(UserNewBody(user_email="actor@example.com", user_role="internal_user"))), + ("create_customer", lambda c: c.create_customer("owned")), + ("customer_info", lambda c: c.customer_info("owned")), + ("delete_customer", lambda c: c.delete_customer("owned")), + ("update_user", lambda c: c.update_user(UserUpdateBody(user_id="owned", user_role="internal_user"))), + ("delete_user", lambda c: c.delete_user("owned")), + ("delete_user_strict", lambda c: c.delete_user_strict("owned")), + ("user_info", lambda c: c.user_info("owned")), + ("user_count", lambda c: c.user_count("owned")), + ("user_list_ids", lambda c: c.user_list_ids("owned")), + ("create_org", lambda c: c.create_org(OrgNewBody(organization_alias="owned"))), + ("update_org", lambda c: c.update_org(OrgUpdateBody(organization_id="owned", organization_alias="updated"))), + ("delete_org", lambda c: c.delete_org("owned")), + ("org_info", lambda c: c.org_info("owned")), + ("org_info_status", lambda c: c.org_info_status("owned")), + ("create_tag", lambda c: c.create_tag(TagNewBody(name="owned"))), + ("delete_tag", lambda c: c.delete_tag("owned")), + ("tag_list", lambda c: c.tag_list()), + ("create_mcp_server", lambda c: c.create_mcp_server(McpServerCreateBody(alias="owned", url="http://example.test"))), + ("update_mcp_server", lambda c: c.update_mcp_server(McpServerUpdateBody(server_id="owned", alias=None))), + ("delete_mcp_server", lambda c: c.delete_mcp_server("owned")), + ("proxy.generate_key", lambda c: c.proxy.generate_key(KeyGenerateBody())), + ("proxy.delete_key", lambda c: c.proxy.delete_key("owned")), + ("proxy.delete_customers", lambda c: c.proxy.delete_customers(["owned"])), + ("proxy.key_info", lambda c: c.proxy.key_info("owned")), + ("proxy.memory_summary", lambda c: c.proxy.memory_summary_everywhere()), + ("proxy.model_info", lambda c: c.proxy.model_info()), + ("proxy.model_cost_map", lambda c: c.proxy.model_cost_map()), + ("proxy.create_model", lambda c: c.proxy.create_model("owned", LiteLLMParamsBody(model="synthetic"))), + ("proxy.update_model", lambda c: c.proxy.update_model("owned", LiteLLMParamsBody(model="synthetic"))), + ("proxy.delete_model", lambda c: c.proxy.delete_model("owned")), + ("proxy.create_toolset", lambda c: c.proxy.create_toolset(ToolsetCreateBody(toolset_name="owned", tools=[]))), + ("proxy.update_toolset", lambda c: c.proxy.update_toolset(ToolsetUpdateBody(toolset_id="owned", description=None))), + ("proxy.delete_toolset", lambda c: c.proxy.delete_toolset("owned")), + ( + "proxy.create_credential", + lambda c: c.proxy.create_credential(CredentialCreateBody(credential_name="owned", credential_values={})), + ), + ("proxy.delete_credential", lambda c: c.proxy.delete_credential("owned")), + ("proxy.create_team", lambda c: c.proxy.create_team(TeamNewBody(team_alias="owned"))), + ("proxy.delete_team", lambda c: c.proxy.delete_team("owned")), + ("proxy.delete_user", lambda c: c.proxy.delete_user("owned")), + ("proxy.spend_logs", lambda c: c.proxy.spend_logs(SpendLogsParams(api_key="owned"))), + ("proxy.probe", lambda c: c.proxy.probe("/user/info", params=NoBody())), +) + + +@pytest.mark.parametrize( + ("name", "operation"), MANAGEMENT_OPERATIONS, ids=tuple(name for name, _ in MANAGEMENT_OPERATIONS) +) +@pytest.mark.parametrize("kind", ("master", "direct_jwt", "virtual_key", "dashboard_session")) +def test_management_operations_send_the_selected_credential( + name: str, + operation: Callable[[ManagementClient], object], + kind: CredentialKind, +) -> None: + with caller_boundary(status=401) as (bootstrap, received), without_retries(): + client: Final = ( + bootstrap + if kind == "master" + else bootstrap.with_caller(Caller(credential=f"synthetic-{kind}", kind=kind, role="internal_user")) + ) + try: + operation(client) + except AssertionError: + pass + expected: Final = "Bearer bootstrap" if kind == "master" else f"Bearer synthetic-{kind}" + assert received.get_nowait() == expected, name + assert received.empty(), "an unauthorized request must not be retried" + + +class TestSplitCallerPropagation: + def test_control_and_data_replica_readers_keep_the_caller(self) -> None: + with caller_boundary() as (data, data_headers), caller_boundary() as (control, control_headers): + data_url: Final = next(iter(data.proxy.replicas)) + control_url: Final = next(iter(control.proxy.replicas)) + proxy: Final = build_proxy_client( + base_url=data_url, + control_plane_base_url=control_url, + replica_urls=(data_url,), + master_key="bootstrap", + ).with_caller(Caller(credential="tenant-token", kind="direct_jwt", role="team_member")) + proxy.key_info("owned") + proxy.read_body_back_everywhere( + "/key/info", KeyInfoResponse, settled=lambda info: info.info.key_alias == "owned" + ) + proxy.read_back_everywhere( + "/key/info", + params=NoBody(), + response_type=KeyInfoResponse, + converged=lambda result: isinstance(result, Success), + ) + assert control_headers.get_nowait() == "Bearer tenant-token" + assert control_headers.get_nowait() == "Bearer tenant-token" + assert data_headers.get_nowait() == "Bearer tenant-token" + assert control_headers.empty() and data_headers.empty() + + def test_successful_team_and_model_polling_uses_the_bound_caller(self) -> None: + with caller_boundary() as (bootstrap, received): + bound: Final = bootstrap.with_caller(Caller(credential="caller", kind="direct_jwt", role="proxy_admin")) + bound.create_team(TeamNewBody(team_alias="owned")) + bound.proxy.create_model("owned", LiteLLMParamsBody(model="synthetic")) + assert tuple(received.get_nowait() for _ in range(4)) == ("Bearer caller",) * 4 + assert received.empty() + + def test_expired_shaped_token_is_sent_once_without_renewal(self) -> None: + with caller_boundary(status=401) as (bootstrap, received): + bound: Final = bootstrap.with_caller( + Caller(credential="expired.payload.signature", kind="direct_jwt", role="internal_user") + ) + result: Final = bound.key_info_as("owned") + assert not isinstance(result, Success) + assert received.get_nowait() == "Bearer expired.payload.signature" + assert received.empty() + + +@pytest.mark.parametrize("operation", ("server", "toolset")) +def test_partial_updates_preserve_explicit_null_at_the_http_boundary(operation: str) -> None: + bodies: Final[SimpleQueue[bytes]] = SimpleQueue() + with caller_boundary(status=401, bodies=bodies) as (bootstrap, _): + try: + if operation == "server": + bootstrap.update_mcp_server(McpServerUpdateBody(server_id="owned", alias=None)) + else: + bootstrap.proxy.update_toolset(ToolsetUpdateBody(toolset_id="owned", description=None)) + except AssertionError: + pass + expected: Final = ( + {"server_id": "owned", "alias": None} + if operation == "server" + else {"toolset_id": "owned", "description": None} + ) + assert json.loads(bodies.get_nowait()) == expected + assert bodies.empty() diff --git a/tests/e2e/transport.py b/tests/e2e/transport.py index e8caa801467..037db0c340f 100644 --- a/tests/e2e/transport.py +++ b/tests/e2e/transport.py @@ -7,11 +7,9 @@ client touches requests.* or builds raw dicts; they pass pydantic models here. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Protocol -from pydantic import BaseModel - import e2e_http from e2e_http import ( URL, @@ -21,6 +19,7 @@ from e2e_http import ( Result, StreamingResponse, ) +from pydantic import BaseModel class Transport(Protocol): @@ -85,7 +84,7 @@ class Transport(Protocol): self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] ) -> Result[R]: ... - def probe(self, path: str, *, params: BaseModel) -> ProbeResult: ... + def probe(self, path: str, *, params: BaseModel, headers: BaseModel | None = None) -> ProbeResult: ... def upload[R: BaseModel]( self, @@ -113,7 +112,7 @@ class Transport(Protocol): @dataclass(frozen=True, slots=True) class HttpTransport: base_url: str - master_key: str + master_key: str = field(repr=False) request_timeout: float = 60.0 def _url(self, path: str) -> URL: @@ -245,10 +244,10 @@ class HttpTransport: timeout=self.request_timeout, ) - def probe(self, path: str, *, params: BaseModel) -> ProbeResult: + def probe(self, path: str, *, params: BaseModel, headers: BaseModel | None = None) -> ProbeResult: return e2e_http.probe( self._url(path), - headers=self.master, + headers=self.master if headers is None else headers, params=params, timeout=self.request_timeout, ) @@ -434,8 +433,8 @@ class SplitTransport: path, headers=headers, json=json, params=params, stream=stream ) - def probe(self, path: str, *, params: BaseModel) -> ProbeResult: - return self._route(path).probe(path, params=params) + def probe(self, path: str, *, params: BaseModel, headers: BaseModel | None = None) -> ProbeResult: + return self._route(path).probe(path, params=params, headers=headers) def upload[R: BaseModel]( self, diff --git a/tests/e2e/ui/oidcSetup.ts b/tests/e2e/ui/oidcSetup.ts new file mode 100644 index 00000000000..943fa23cab3 --- /dev/null +++ b/tests/e2e/ui/oidcSetup.ts @@ -0,0 +1,30 @@ +import { chromium, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +export default async function oidcSetup() { + const baseURL = process.env.E2E_OIDC_UI_URL; + const issuer = process.env.JWT_ISSUER; + const username = process.env.E2E_OIDC_USERNAME; + const password = process.env.E2E_OIDC_PASSWORD; + if (!baseURL || !issuer || !username || !password) { + throw new Error("The OIDC setup requires a running stack, issuer, and provisioned actor credentials"); + } + const artifactDir = process.env.E2E_UI_ARTIFACT_DIR || "."; + fs.mkdirSync(artifactDir, { recursive: true }); + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + await page.goto(`${baseURL.replace(/\/$/, "")}/sso/key/generate`); + await expect(page).toHaveURL(new RegExp(`^${issuer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`)); + await page.getByLabel("Username or email").fill(username); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + await page.waitForURL((url) => url.origin === new URL(baseURL).origin && url.pathname.startsWith("/ui")); + const statePath = path.join(artifactDir, "oidc.storageState.json"); + await page.context().storageState({ path: statePath }); + fs.chmodSync(statePath, 0o600); + } finally { + await browser.close(); + } +} diff --git a/tests/e2e/ui/playwright.oidc.config.ts b/tests/e2e/ui/playwright.oidc.config.ts new file mode 100644 index 00000000000..0fbe77e9bd2 --- /dev/null +++ b/tests/e2e/ui/playwright.oidc.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; +import * as path from "path"; + +const baseURL = process.env.E2E_OIDC_UI_URL; +if (!baseURL) throw new Error("E2E_OIDC_UI_URL must point to the running OIDC stack"); + +export default defineConfig({ + testDir: ".", + testMatch: "oidc/**/*.spec.ts", + retries: 0, + workers: 1, + outputDir: path.join(process.env.E2E_UI_ARTIFACT_DIR || ".", "oidc", "test-results"), + globalSetup: require.resolve("./oidcSetup"), + use: { + ...devices["Desktop Chrome"], + baseURL, + storageState: path.join(process.env.E2E_UI_ARTIFACT_DIR || ".", "oidc.storageState.json"), + trace: "off", + screenshot: "off", + video: "off", + }, +}); From c8bb54993e8b6db4eda84819b0015a1d9a85ca99 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 12 Sep 2026 13:49:49 -0700 Subject: [PATCH 017/196] test: enforce isolated actors and stop OIDC process groups --- .../e2e/coverage_registry/management_cases.py | 2 +- tests/e2e/idp.py | 41 +++++++++++++++---- tests/e2e/management/jwt_actors.py | 2 +- tests/e2e/management/management_client.py | 17 ++++---- .../e2e/management/test_jwt_management_e2e.py | 20 ++++----- tests/e2e/test_idp.py | 25 ++++++++--- tests/e2e/test_proxy_client.py | 7 ++++ 7 files changed, 80 insertions(+), 34 deletions(-) diff --git a/tests/e2e/coverage_registry/management_cases.py b/tests/e2e/coverage_registry/management_cases.py index 15dc7d333c5..812dbfe5b8d 100644 --- a/tests/e2e/coverage_registry/management_cases.py +++ b/tests/e2e/coverage_registry/management_cases.py @@ -63,7 +63,7 @@ MANAGEMENT_CASES: Final = tuple( node=f"{JWT_CLASS}::test_admin_creates_reads_updates_clears_and_deletes_a_key[virtual_key]", credential_kind="virtual_key", actor="proxy_admin", - profile="database_role", + profile="group_scoped", method="POST", path="/key/generate", operation_family="key_lifecycle", diff --git a/tests/e2e/idp.py b/tests/e2e/idp.py index 12db91bbd88..2dc7c2ad71b 100644 --- a/tests/e2e/idp.py +++ b/tests/e2e/idp.py @@ -8,6 +8,7 @@ import secrets import signal import subprocess import sys +import time import warnings from collections.abc import Callable from contextlib import ExitStack @@ -426,6 +427,36 @@ def token_claims(token: str) -> TokenClaims: return TokenClaims.model_validate_json(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) +def _signal_process_group(process_id: int, signum: int) -> bool: + try: + os.killpg(process_id, signum) + except ProcessLookupError: + return False + return True + + +def _stop_process_group(child: subprocess.Popen[bytes]) -> None: + _signal_process_group(child.pid, signal.SIGTERM) + deadline: Final = time.monotonic() + 5 + while _process_group_exists(child.pid): + child.poll() + if time.monotonic() >= deadline: + _signal_process_group(child.pid, signal.SIGKILL) + break + time.sleep(0.05) + child.wait() + + +def _process_group_exists(process_id: int) -> bool: + try: + os.killpg(process_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + def run_oidc_profile(proxy_url: str, command: list[str]) -> int: idp: Final = keycloak_from_env().with_strict_cleanup() with ExitStack() as cleanup: @@ -441,17 +472,11 @@ def run_oidc_profile(proxy_url: str, command: list[str]) -> int: client: Final = idp.browser_client(callback_url=f"{proxy_url.rstrip('/')}/sso/callback", defer=defer) environment: Final = {**os.environ, **client.environment(idp.discovery()), "PROXY_BASE_URL": proxy_url} - with subprocess.Popen(command, env=environment) as child: + with subprocess.Popen(command, env=environment, start_new_session=True) as child: try: return child.wait() finally: - if child.poll() is None: - child.terminate() - try: - child.wait(timeout=5) - except subprocess.TimeoutExpired: - child.kill() - child.wait() + _stop_process_group(child) if __name__ == "__main__": diff --git a/tests/e2e/management/jwt_actors.py b/tests/e2e/management/jwt_actors.py index 909d1652ada..2d23549fe71 100644 --- a/tests/e2e/management/jwt_actors.py +++ b/tests/e2e/management/jwt_actors.py @@ -84,7 +84,7 @@ class ActorFactory: ) ) ) - self.resources.defer(lambda: self.bootstrap.delete_key_strict(created.key)) + self.resources.defer(lambda: self.bootstrap.delete_key_strict(created.key, missing_ok=True)) return created def tenant(self) -> Tenant: diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index a0243e868e2..8470d318db8 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -167,17 +167,18 @@ class ManagementClient: response_type=KeyInfoResponse, ) - def delete_key_strict(self, key: str, *, caller_key: str | None = None) -> None: + def delete_key_strict(self, key: str, *, caller_key: str | None = None, missing_ok: bool = False) -> None: """Strict delete for the act phase of a test: a failed delete is a hard failure, unlike the warn-only ProxyClient.delete_key used at teardown.""" - _ = unwrap( - self.proxy.transport.post( - "/key/delete", - headers=self.proxy.management_headers(caller_key), - json=KeyDeleteBody(keys=[key]), - response_type=NoBody, - ) + result = self.proxy.transport.post( + "/key/delete", + headers=self.proxy.management_headers(caller_key), + json=KeyDeleteBody(keys=[key]), + response_type=NoBody, ) + if missing_ok and isinstance(result, UnknownApiError) and result.status_code == 404: + return + _ = unwrap(result) def delete_model_strict(self, model_id: str) -> None: """Strict delete for the act phase of a test: a failed delete is a hard diff --git a/tests/e2e/management/test_jwt_management_e2e.py b/tests/e2e/management/test_jwt_management_e2e.py index 0d23f954158..5898073a4e6 100644 --- a/tests/e2e/management/test_jwt_management_e2e.py +++ b/tests/e2e/management/test_jwt_management_e2e.py @@ -102,31 +102,29 @@ class TestJwtManagement: @pytest.mark.parametrize("credential_kind", ("direct_jwt", "virtual_key")) def test_admin_creates_reads_updates_clears_and_deletes_a_key( self, - client: ManagementClient, - idp: Keycloak, - jwt_identity: Identity, - resources: ResourceManager, actor_factory: ActorFactory, credential_kind: Literal["direct_jwt", "virtual_key"], ) -> None: - actor: Final = actor_factory.create("proxy_admin") + tenant: Final = actor_factory.tenant() + actor: Final = actor_factory.create("proxy_admin", tenants=(tenant,), profile="group_scoped") virtual_key: Final = ( actor_factory.key(user_id=actor.identity.user_id).key if credential_kind == "virtual_key" else None ) - admin: Final = ( - virtual_key if virtual_key is not None else idp.access_token(jwt_identity, client_id=ADMIN_CLIENT_ID) + admin: Final = virtual_key if virtual_key is not None else actor.mint_caller(actor_factory.idp).credential + bound: Final = actor_factory.bootstrap.with_caller( + Caller(credential=admin, kind=credential_kind, role="proxy_admin") ) - bound: Final = client.with_caller(Caller(credential=admin, kind=credential_kind, role="proxy_admin")) + assert bound.user_info().user_id == actor.identity.user_id alias: Final = f"e2e-jwt-key-{unique_marker()}" created: Final = unwrap( bound.generate_key( - KeyGenerateBody(key_alias=alias, team_id=jwt_identity.group, models=[CHEAP_OPENAI_MODEL]), + KeyGenerateBody(key_alias=alias, team_id=tenant.team_id, models=[CHEAP_OPENAI_MODEL]), ) ) - resources.defer(lambda: client.proxy.delete_key(created.key)) + actor_factory.resources.defer(lambda: actor_factory.bootstrap.delete_key_strict(created.key, missing_ok=True)) original: Final = unwrap(bound.key_info_as(created.key)).info - assert original.key_alias == alias and original.team_id == jwt_identity.group + assert original.key_alias == alias and original.team_id == tenant.team_id assert original.models == [CHEAP_OPENAI_MODEL] updated_alias: Final = f"{alias}-updated" diff --git a/tests/e2e/test_idp.py b/tests/e2e/test_idp.py index 8a92dfa527c..cf2d4f3118a 100644 --- a/tests/e2e/test_idp.py +++ b/tests/e2e/test_idp.py @@ -6,6 +6,7 @@ from __future__ import annotations import os import signal +import socket import subprocess import sys import time @@ -148,16 +149,27 @@ def test_partial_provisioning_removes_the_group_when_user_creation_fails() -> No assert deletions.empty() -@pytest.mark.parametrize("exit_mode", ("normal", "parent", "group")) +@pytest.mark.parametrize( + ("exit_mode", "ignore_termination"), (("normal", False), ("parent", False), ("group", False), ("parent", True)) +) def test_oidc_launcher_removes_client_on_exit_and_termination( - tmp_path: Path, exit_mode: Literal["normal", "parent", "group"] + tmp_path: Path, exit_mode: Literal["normal", "parent", "group"], ignore_termination: bool ) -> None: ready: Final = tmp_path / "ready" + descendant_command: Final = ( + "import signal,socket,time; from pathlib import Path; " + + ("signal.signal(signal.SIGTERM, signal.SIG_IGN); " if ignore_termination else "") + + "listener=socket.socket(); listener.bind(('127.0.0.1',0)); listener.listen(); " + f"Path({str(ready)!r}).write_text(str(listener.getsockname()[1])); time.sleep(120)" + ) child_command: Final = ( - "import os,time; from pathlib import Path; " + "import os,subprocess,sys,time; from pathlib import Path; " 'assert os.environ["GENERIC_CLIENT_SECRET"]; ' 'assert os.environ["GENERIC_CLIENT_USE_PKCE"] == "true"; ' - f"Path({str(ready)!r}).touch(); " + ("raise SystemExit(7)" if exit_mode == "normal" else "time.sleep(120)") + f"subprocess.Popen([sys.executable, '-c', {descendant_command!r}]); " + f"ready=Path({str(ready)!r})\n" + "while not ready.exists(): time.sleep(0.05)\n" + + ("raise SystemExit(7)" if exit_mode == "normal" else "time.sleep(120)") ) with _idp_server() as (idp, deletions): with subprocess.Popen( @@ -187,7 +199,10 @@ def test_oidc_launcher_removes_client_on_exit_and_termination( process.terminate() elif exit_mode == "group": os.killpg(process.pid, signal.SIGTERM) - assert process.wait(timeout=10) == (7 if exit_mode == "normal" else 143) + assert process.wait(timeout=15) == (7 if exit_mode == "normal" else 143) + with socket.socket() as connection: + connection.settimeout(1) + assert connection.connect_ex(("127.0.0.1", int(ready.read_text()))) != 0 finally: if process.poll() is None: os.killpg(process.pid, signal.SIGKILL) diff --git a/tests/e2e/test_proxy_client.py b/tests/e2e/test_proxy_client.py index 879bd88980c..0c4aed5bd65 100644 --- a/tests/e2e/test_proxy_client.py +++ b/tests/e2e/test_proxy_client.py @@ -121,6 +121,13 @@ def caller_boundary( class TestBoundManagementCaller: + def test_strict_key_cleanup_accepts_missing_only_when_requested(self) -> None: + with caller_boundary(delete_status=404) as (bootstrap, received), without_retries(): + with pytest.raises(AssertionError): + bootstrap.delete_key_strict("owned") + bootstrap.delete_key_strict("owned", missing_ok=True) + assert (received.get_nowait(), received.get_nowait()) == ("Bearer bootstrap", "Bearer bootstrap") + def test_actor_key_cleanup_reports_failure_and_continues(self) -> None: with caller_boundary(delete_status=500) as (bootstrap, received), without_retries(): resources: Final = ResourceManager(client=bootstrap.proxy, strict_cleanup=True) From cba843cc167bbadf5d18bee0ea07a443a4838ba1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Sat, 12 Sep 2026 12:03:04 -0700 Subject: [PATCH 018/196] feat(proxy): predict prompt-cache costs across deployments --- .../llms/anthropic/prompt_cache_prediction.py | 388 ++++++++++ litellm/proxy/_types.py | 1 + litellm/proxy/auth/auth_checks.py | 5 +- litellm/proxy/auth/auth_utils.py | 35 +- litellm/proxy/auth/user_api_key_auth.py | 15 +- .../common_utils/prompt_cache_pricing.py | 91 +++ litellm/proxy/hooks/__init__.py | 2 + .../hooks/parallel_request_limiter_v3.py | 204 ++--- .../proxy/hooks/prompt_cache_prediction.py | 142 ++++ .../cost_tracking_settings.py | 2 + .../prompt_cache_prediction.py | 278 +++++++ .../streaming_handler.py | 18 + .../prompt_cache_prediction.py | 67 ++ .../test_anthropic_prompt_cache_prediction.py | 209 ++++++ .../proxy/auth/test_auth_utils.py | 226 ++++++ .../common_utils/test_prompt_cache_pricing.py | 105 +++ .../hooks/test_parallel_request_limiter_v3.py | 245 ++++++ .../proxy/hooks/test_prompt_cache_observer.py | 300 ++++++++ .../test_prompt_cache_prediction.py | 698 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 164 ++++ 20 files changed, 3106 insertions(+), 89 deletions(-) create mode 100644 litellm/llms/anthropic/prompt_cache_prediction.py create mode 100644 litellm/proxy/common_utils/prompt_cache_pricing.py create mode 100644 litellm/proxy/hooks/prompt_cache_prediction.py create mode 100644 litellm/proxy/management_endpoints/prompt_cache_prediction.py create mode 100644 litellm/types/management_endpoints/prompt_cache_prediction.py create mode 100644 tests/test_litellm/llms/anthropic/test_anthropic_prompt_cache_prediction.py create mode 100644 tests/test_litellm/proxy/common_utils/test_prompt_cache_pricing.py create mode 100644 tests/test_litellm/proxy/hooks/test_prompt_cache_observer.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_prompt_cache_prediction.py diff --git a/litellm/llms/anthropic/prompt_cache_prediction.py b/litellm/llms/anthropic/prompt_cache_prediction.py new file mode 100644 index 00000000000..e69a02bd93a --- /dev/null +++ b/litellm/llms/anthropic/prompt_cache_prediction.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from itertools import accumulate +from types import MappingProxyType +from typing import Annotated, Final, Literal, Protocol, TypeAlias + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictInt, TypeAdapter, ValidationError + +import litellm +from litellm.llms.anthropic.common_utils import AnthropicModelInfo, is_anthropic_oauth_key +from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import DEFAULT_ANTHROPIC_API_VERSION +from litellm.types.router import LiteLLM_Params +from litellm.types.utils import ModelResponse + +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_HEADERS: Final = TypeAdapter(dict[str, str]) +_counter: Final = AnthropicCountTokensHandler() + + +_NATIVE_HEADERS: Final = frozenset( + ( + "host", + "accept", + "accept-encoding", + "connection", + "user-agent", + "content-length", + "content-type", + "x-api-key", + "anthropic-version", + ) +) + +_DEPLOYMENT_OPTIONS: Final = frozenset( + { + "model", + "api_key", + "api_base", + "custom_llm_provider", + "rpm", + "tpm", + "timeout", + "stream_timeout", + "max_retries", + "num_retries", + "max_parallel_requests", + "input_cost_per_token", + "output_cost_per_token", + "cache_read_input_token_cost", + "cache_creation_input_token_cost", + "cache_creation_input_token_cost_above_1hr", + } +) + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class _CacheControl(_StrictModel): + type: Literal["ephemeral"] + ttl: Literal["5m", "1h"] = "5m" + + +class _Text(_StrictModel): + type: Literal["text"] + text: str = Field(min_length=1, pattern=r"\S") + cache_control: _CacheControl | None = None + + +class _ToolUse(_StrictModel): + type: Literal["tool_use"] + id: str = Field(min_length=1) + name: str = Field(min_length=1) + input: Mapping[str, JsonValue] + cache_control: _CacheControl | None = None + + +class _ResultText(_StrictModel): + type: Literal["text"] + text: str + + +class _ToolResult(_StrictModel): + type: Literal["tool_result"] + tool_use_id: str = Field(min_length=1) + content: str | Annotated[tuple[_ResultText, ...], Field(strict=False)] + is_error: bool | None = None + cache_control: _CacheControl | None = None + + +_Block: TypeAlias = Annotated[_Text | _ToolUse | _ToolResult, Field(discriminator="type")] + + +class _Message(_StrictModel): + role: Literal["user", "assistant"] + content: str | Annotated[tuple[_Block, ...], Field(strict=False)] + + def blocks(self) -> tuple[_Text | _ToolUse | _ToolResult, ...]: + return (_Text(type="text", text=self.content),) if isinstance(self.content, str) else tuple(self.content) + + +class _Tool(_StrictModel): + name: str = Field(min_length=1) + description: str | None = None + input_schema: Mapping[str, JsonValue] + type: Literal["custom"] | None = None + + +class _Request(_StrictModel): + messages: tuple[_Message, ...] = Field(min_length=1, strict=False) + system: str | Annotated[tuple[_ResultText, ...], Field(strict=False)] | None = None + tools: Annotated[tuple[_Tool, ...], Field(strict=False)] | None = None + model: str | None = None + max_tokens: int | None = None + stream: bool | None = None + temperature: float | int | None = None + top_p: float | int | None = None + top_k: int | None = None + stop_sequences: Annotated[tuple[str, ...], Field(strict=False)] | None = None + metadata: Mapping[str, JsonValue] | None = None + + +@dataclass(frozen=True, slots=True) +class PromptPrefix: + prefix_body: Mapping[str, JsonValue] + fingerprint: str + fingerprints: tuple[str, ...] + ttl_seconds: int + + +def _digest(value: object) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + ).hexdigest() + + +def _next_digest(previous: str, boundary: tuple[int, str, Mapping[str, JsonValue]]) -> str: + return _digest((previous, boundary)) + + +def parse_prompt(body: Mapping[str, JsonValue]) -> PromptPrefix | None: + try: + request: Final = _Request.model_validate(body) + blocks: Final = tuple(message.blocks() for message in request.messages) + except ValidationError: + return None + markers: Final = tuple( + (message_index, block_index, block.cache_control) + for message_index, message_blocks in enumerate(blocks) + for block_index, block in enumerate(message_blocks) + if block.cache_control is not None + ) + if len(markers) != 1: + return None + message_end, block_end, marker = markers[0] + normalized: Final = _JSON_OBJECT.validate_python(request.model_dump(mode="json", exclude_none=True)) + context: Final = MappingProxyType({key: normalized[key] for key in ("system", "tools") if key in normalized}) + boundaries: Final = tuple( + ( + message_index, + request.messages[message_index].role, + _JSON_OBJECT.validate_python( + block.model_dump(mode="json", exclude=MappingProxyType({"cache_control": True}), exclude_none=True) + ), + ) + for message_index, message_blocks in enumerate(blocks[: message_end + 1]) + for block_index, block in enumerate(message_blocks) + if message_index < message_end or block_index <= block_end + ) + hashes: Final = tuple( + accumulate(boundaries, _next_digest, initial=_digest((_JSON_OBJECT.validate_python(context), marker.ttl))) + )[1:] + prefix_messages: Final = tuple( + _Message( + role=request.messages[message_index].role, + content=tuple( + block + for block_index, block in enumerate(message_blocks) + if message_index < message_end or block_index <= block_end + ), + ) + for message_index, message_blocks in enumerate(blocks[: message_end + 1]) + ) + return PromptPrefix( + prefix_body=MappingProxyType( + _JSON_OBJECT.validate_python( + _Request(messages=prefix_messages, system=request.system, tools=request.tools).model_dump( + mode="json", exclude_none=True + ) + ) + ), + fingerprint=hashes[-1], + fingerprints=tuple(reversed(hashes[-20:])), + ttl_seconds=3600 if marker.ttl == "1h" else 300, + ) + + +def cache_scope( + caller_key_hash: str, + deployment_id: str, + provider_key: str, + model: str, + anthropic_version: str = DEFAULT_ANTHROPIC_API_VERSION, +) -> str: + return _digest((caller_key_hash, deployment_id, provider_key, model, anthropic_version)) + + +class _TTLUsage(BaseModel): + model_config = ConfigDict(strict=True) + ephemeral_5m_input_tokens: int = Field(default=0, ge=0) + ephemeral_1h_input_tokens: int = Field(default=0, ge=0) + + +class _CacheUsage(BaseModel): + model_config = ConfigDict(strict=True) + cached_tokens: int = Field(default=0, ge=0) + cache_creation_tokens: int = Field(default=0, ge=0) + cache_creation_token_details: _TTLUsage | None = None + + +class _Usage(BaseModel): + model_config = ConfigDict(strict=True) + prompt_tokens: int = Field(ge=0) + prompt_tokens_details: _CacheUsage + + +class _Choice(BaseModel): + finish_reason: str = Field(min_length=1) + + +class _Response(BaseModel): + model_config = ConfigDict(strict=True) + model: str + usage: _Usage + choices: tuple[_Choice, ...] = Field(min_length=1, strict=False) + + +class _CountBody(BaseModel): + messages: Sequence[Mapping[str, JsonValue]] + tools: Sequence[Mapping[str, JsonValue]] | None = None + system: str | Sequence[Mapping[str, JsonValue]] | None = None + + +class _CountResult(BaseModel): + input_tokens: Annotated[StrictInt, Field(ge=0)] + + +class TokenCounter(Protocol): + async def __call__(self, model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: ... + + +def _count_objects( + values: Sequence[Mapping[str, JsonValue]], +) -> list[dict[str, JsonValue]]: # mutable-ok: the existing provider count API requires JSON lists/dicts + return [dict(value) for value in values] # mutable-ok: serialize read-only inputs at the provider API boundary + + +async def count_prompt_tokens(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + native: Final = _CountBody.model_validate(body) + try: + result: Final = _CountResult.model_validate( + await _counter.handle_count_tokens_request( + model=model, + messages=_count_objects(native.messages), + tools=_count_objects(native.tools) if native.tools is not None else None, + system=native.system, + api_key=api_key, + timeout=15.0, + ) + ) + except Exception: # noqa: BLE001 # provider/count validation failures are unavailable estimates, not zero tokens + return None + return result.input_tokens + + +@dataclass(frozen=True, slots=True) +class NativePredictionTarget: + model: str + api_key: str + + +@dataclass(frozen=True, slots=True) +class UnsupportedPredictionTarget: + reason: Literal[ + "unsupported_deployment_configuration", + "unsupported_provider_endpoint", + "unsupported_provider", + "unsupported_provider_credentials", + ] + + +def resolve_prediction_target(params: LiteLLM_Params) -> NativePredictionTarget | UnsupportedPredictionTarget: + configured_options: Final = frozenset(params.model_dump(exclude_defaults=True, exclude_none=True)) + if configured_options - _DEPLOYMENT_OPTIONS: + return UnsupportedPredictionTarget("unsupported_deployment_configuration") + api_base: Final = AnthropicModelInfo.get_api_base(params.api_base) + if api_base not in ("https://api.anthropic.com", "https://api.anthropic.com/v1/messages"): + return UnsupportedPredictionTarget("unsupported_provider_endpoint") + try: + model, provider, _, _ = litellm.get_llm_provider( + model=params.model, custom_llm_provider=params.custom_llm_provider + ) + except Exception: # noqa: BLE001 # the shared provider resolver raises for unknown deployments + return UnsupportedPredictionTarget("unsupported_provider") + if provider != "anthropic": + return UnsupportedPredictionTarget("unsupported_provider") + api_key: Final = AnthropicModelInfo.get_api_key(params.api_key) + if api_key is None or not _supported_provider_key(api_key): + return UnsupportedPredictionTarget("unsupported_provider_credentials") + return NativePredictionTarget(model=model, api_key=api_key) + + +def _supported_provider_key(api_key: str) -> bool: + return bool(api_key) and not is_anthropic_oauth_key(api_key) + + +def supported_prediction_headers(headers: Mapping[str, str]) -> bool: + return all( + name.lower() != "anthropic-beta" + and (name.lower() != "anthropic-version" or value == DEFAULT_ANTHROPIC_API_VERSION) + for name, value in headers.items() + ) + + +@dataclass(frozen=True, slots=True) +class ObservedCachePrefix: + prefix: PromptPrefix + scope: str + cached_tokens: int + cache_creation_tokens: int + + +def parse_observed_cache( + wire: httpx.Request, response_obj: ModelResponse, caller_key_hash: str, deployment_id: str +) -> ObservedCachePrefix | None: + try: + response: Final = _Response.model_validate(response_obj, from_attributes=True) + body: Final = _JSON_OBJECT.validate_json(wire.content) + headers: Final = _HEADERS.validate_python(wire.headers) + except (ValidationError, RuntimeError, httpx.RequestNotRead): + return None + if ( + wire.url.scheme != "https" + or wire.url.host != "api.anthropic.com" + or wire.url.path != "/v1/messages" + or wire.url.query + or wire.url.port not in (None, 443) + ): + return None + if ( + frozenset(headers) - _NATIVE_HEADERS + or not supported_prediction_headers(headers) + or headers.get("anthropic-version") != DEFAULT_ANTHROPIC_API_VERSION + ): + return None + provider_key: Final = headers.get("x-api-key", "") + model: Final = body.get("model") + if not _supported_provider_key(provider_key) or not isinstance(model, str) or model != response.model: + return None + prefix: Final = parse_prompt(body) + if prefix is None: + return None + usage: Final = response.usage.prompt_tokens_details + cache_tokens: Final = usage.cached_tokens + usage.cache_creation_tokens + if cache_tokens <= 0 or cache_tokens > response.usage.prompt_tokens: + return None + split: Final = usage.cache_creation_token_details + if usage.cache_creation_tokens and split is None: + return None + if split is not None and ( + split.ephemeral_5m_input_tokens + split.ephemeral_1h_input_tokens != usage.cache_creation_tokens + or (prefix.ttl_seconds == 300 and split.ephemeral_1h_input_tokens > 0) + or (prefix.ttl_seconds == 3600 and split.ephemeral_5m_input_tokens > 0) + ): + return None + return ObservedCachePrefix( + prefix=prefix, + scope=cache_scope(caller_key_hash, deployment_id, provider_key, model), + cached_tokens=cache_tokens, + cache_creation_tokens=usage.cache_creation_tokens, + ) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ae6c042ab3a..de82d8ec3c8 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -887,6 +887,7 @@ class LiteLLMRoutes(enum.Enum): "/auto_router/validate_complexity_router_config", # Per-session auto-router read - the endpoint scopes the row to the caller's own key hash "/auto_router/session", + "/cost/predict-cache", # Agent registry - reads are role-scoped and writes are proxy-admin-gated # inside agent_endpoints/endpoints.py *agent_management_routes, diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 9c175242a9a..3495bf2ae98 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -878,6 +878,7 @@ async def common_checks( request_query_params=_safe_get_request_query_params(request=request), llm_router=llm_router, request=request, + team_id=valid_token.team_id if valid_token is not None else None, ) skip_all_budget_checks: Final = skip_budget_checks or ( @@ -4356,7 +4357,7 @@ async def stamp_matched_model_access_groups( async def can_key_call_model( model: str | list[str], - llm_model_list: list | None, + llm_model_list: Sequence[object] | None, valid_token: UserAPIKeyAuth, llm_router: litellm.Router | None, ) -> Literal[True]: @@ -4403,7 +4404,7 @@ async def can_key_call_model( async def can_key_call_resolved_model( model: str, - llm_model_list: list | None, + llm_model_list: Sequence[object] | None, valid_token: UserAPIKeyAuth, llm_router: litellm.Router | None, ) -> None: diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index be65c3b39ec..dc304a156cf 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -33,7 +33,7 @@ from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_me from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_ENDPOINT_MARKER, ) -from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS +from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS, Deployment from litellm.types.utils import CustomPricingLiteLLMParams @@ -1736,7 +1736,7 @@ def _append_model_candidates(candidates: list[str], value: Any) -> None: candidates.extend(model for model in model_names if model) -def _dedupe_model_candidates(candidates: list[str]) -> list[str]: +def _dedupe_model_candidates(candidates: Collection[str]) -> list[str]: deduped: Final[list[str]] = [] for model in candidates: if model not in deduped: @@ -1845,13 +1845,42 @@ def _resolve_model_id_with_router(model_id: str | None, llm_router: Router | Non return model_id +def get_cache_prediction_deployments( + *, current_deployment_id: str, candidate_deployment_id: str, llm_router: Router, team_id: str | None +) -> tuple[Deployment, Deployment] | None: + current: Final = llm_router.get_deployment(current_deployment_id) + candidate: Final = llm_router.get_deployment(candidate_deployment_id) + if current is None or candidate is None: + return None + if any(deployment.model_info.team_id not in (None, team_id) for deployment in (current, candidate)): + return None + return current, candidate + + +def _cache_prediction_model_candidates( + request_data: Mapping[str, object], llm_router: Router | None, team_id: str | None +) -> tuple[str, ...]: + current_id: Final = request_data.get("current_deployment_id") + candidate_id: Final = request_data.get("candidate_deployment_id") + if llm_router is None or not isinstance(current_id, str) or not isinstance(candidate_id, str): + return () + deployments: Final = get_cache_prediction_deployments( + current_deployment_id=current_id, candidate_deployment_id=candidate_id, llm_router=llm_router, team_id=team_id + ) + return tuple(deployment.model_name for deployment in deployments) if deployments is not None else () + + def _extract_model_candidates_from_request( request_data: dict, route: str, request_headers: Mapping[str, object] | None = None, request_query_params: Mapping[str, object] | None = None, llm_router: Router | None = None, + team_id: str | None = None, ) -> list[str]: + if route == "/cost/predict-cache": + prediction_models: Final = _cache_prediction_model_candidates(request_data, llm_router, team_id) # pyright: ignore[reportUnknownArgumentType] # the typed reader validates each deployment ID from this legacy payload + return _dedupe_model_candidates(prediction_models) candidates: Final[list[str]] = [] uses_model_routing_sources: Final = _route_uses_model_routing_sources(route=route) uses_header_or_query_model_sources: Final = _route_matches_any_marker( @@ -1945,6 +1974,7 @@ def get_model_from_request( request_query_params: Mapping[str, object] | None = None, llm_router: Router | None = None, request: Request | None = None, + team_id: str | None = None, ) -> str | list[str] | None: """Resolve the model(s) a request targets, for model-access and budget checks. @@ -1967,6 +1997,7 @@ def get_model_from_request( request_headers=request_headers, request_query_params=request_query_params, llm_router=llm_router, + team_id=team_id, ) model = _format_model_candidates(candidates) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 9828311112e..930e3cca703 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -182,6 +182,7 @@ def _get_model_from_request_context( route: str, request: Request | None, llm_router: Any | None = None, + team_id: str | None = None, ) -> str | list[str] | None: return get_model_from_request( request_data=request_data, @@ -190,6 +191,7 @@ def _get_model_from_request_context( request_query_params=_safe_get_request_query_params(request=request), llm_router=llm_router, request=request, + team_id=team_id, ) @@ -208,7 +210,7 @@ async def _normalize_claude_model( return if request is not None and request.scope.get(_CLAUDE_MODEL_NORMALIZED) is True: return - requested: Final = _get_model_from_request_context(request_data, route, request, llm_router) + requested: Final = _get_model_from_request_context(request_data, route, request, llm_router, valid_token.team_id) if not isinstance(requested, str) or requested != request_data.get("model"): return if not requested.startswith("claude-router-") and not requested.lower().endswith("[1m]"): @@ -1592,6 +1594,7 @@ async def _user_api_key_auth_builder( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) skip_budget_checks = False if model is not None and llm_router is not None: @@ -1632,6 +1635,7 @@ async def _user_api_key_auth_builder( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) ), ) @@ -2022,6 +2026,7 @@ async def _user_api_key_auth_builder( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) skip_budget_checks = False if model is not None and llm_router is not None: @@ -2140,6 +2145,7 @@ async def _user_api_key_auth_builder( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) current_models = _get_model_names_for_budget_checks(model=current_model) @@ -2170,6 +2176,7 @@ async def _user_api_key_auth_builder( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) current_models = _get_model_names_for_budget_checks(model=current_model) @@ -2665,6 +2672,7 @@ async def _run_centralized_common_checks( route=route, request=request, llm_router=llm_router, + team_id=user_api_key_auth_obj.team_id, ) # Pin the metadata variable name (litellm_metadata vs metadata) before @@ -2781,12 +2789,14 @@ def _should_skip_budget_checks( route: str, request: Request | None, llm_router: Any | None, + team_id: str | None = None, ) -> bool: model: Final = _get_model_from_request_context( request_data=request_data, route=route, request=request, llm_router=llm_router, + team_id=team_id, ) if model is not None and llm_router is not None: return _is_model_cost_zero(model=model, llm_router=llm_router) @@ -3232,6 +3242,7 @@ async def _enforce_key_and_fallback_model_access( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) if model is not None: @@ -3339,6 +3350,7 @@ async def _run_post_custom_auth_checks( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) current_models = _get_model_names_for_budget_checks(model=current_model) @@ -3380,6 +3392,7 @@ async def _run_post_custom_auth_checks( route=route, request=request, llm_router=llm_router, + team_id=valid_token.team_id, ) current_models = _get_model_names_for_budget_checks(model=current_model) diff --git a/litellm/proxy/common_utils/prompt_cache_pricing.py b/litellm/proxy/common_utils/prompt_cache_pricing.py new file mode 100644 index 00000000000..ff070853b46 --- /dev/null +++ b/litellm/proxy/common_utils/prompt_cache_pricing.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from math import isfinite +from typing import Final + +from pydantic import TypeAdapter + +import litellm +from litellm.cost_calculator import ( + _select_model_name_for_cost_calc, # pyright: ignore[reportPrivateUsage] # shares completion_cost's deployment tariff selection + completion_cost, # pyright: ignore[reportUnknownVariableType] # legacy optional parameters are untyped +) +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.types.management_endpoints.prompt_cache_prediction import CacheTokenBuckets +from litellm.types.utils import CacheCreationTokenDetails, ModelResponse, PromptTokensDetailsWrapper, Usage + +_PRICE_ENTRY: Final = TypeAdapter(Mapping[str, object]) + + +def _valid_price(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0 + + +def _has_required_prices(prices: Mapping[str, object], tokens: CacheTokenBuckets) -> bool: + required: Final = ( + ("input_cost_per_token", True), + ("cache_read_input_token_cost", tokens.cache_read_input_tokens > 0), + ("cache_creation_input_token_cost", tokens.cache_creation_5m_input_tokens > 0), + ("cache_creation_input_token_cost_above_1hr", tokens.cache_creation_1h_input_tokens > 0), + ) + if any(needed and not _valid_price(prices.get(key)) for key, needed in required): + return False + return all( + _valid_price(value) + for key, value in prices.items() + if value is not None and any(needed and key.startswith(f"{base}_above_") for base, needed in required) + ) + + +def price_cache_tokens(model: str, deployment_id: str, tokens: CacheTokenBuckets) -> float | None: + try: + selected_model: Final = _select_model_name_for_cost_calc( + model=model, + completion_response=None, + custom_pricing=True, + custom_llm_provider="anthropic", + router_model_id=deployment_id, + ) + if selected_model is None: + return None + model_info: Final = litellm.get_model_info(model=selected_model, custom_llm_provider="anthropic") + registry: Final = _PRICE_ENTRY.validate_python(litellm.model_cost) # pyright: ignore[reportUnknownMemberType] # legacy registry is validated at this boundary + price_entry: Final = registry.get(model_info["key"]) + if price_entry is None: + return None + prices: Final = _PRICE_ENTRY.validate_python(price_entry) + if not _has_required_prices(prices, tokens): + return None + usage: Final = Usage( + prompt_tokens=tokens.total_tokens, + completion_tokens=0, + total_tokens=tokens.total_tokens, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=tokens.cache_read_input_tokens, + cache_creation_tokens=tokens.cache_creation_5m_input_tokens + tokens.cache_creation_1h_input_tokens, + cache_creation_token_details=CacheCreationTokenDetails( + ephemeral_5m_input_tokens=tokens.cache_creation_5m_input_tokens, + ephemeral_1h_input_tokens=tokens.cache_creation_1h_input_tokens, + ), + ), + ) + logging_obj: Final = Logging( + model=model, + messages=[], # mutable-ok: Logging requires a list + stream=False, + call_type="completion", + start_time=None, + litellm_call_id="prompt-cache-prediction", + function_id="prompt-cache-prediction", + ) + completion_cost( + completion_response=ModelResponse(model=model, usage=usage), + model=model, + custom_llm_provider="anthropic", + custom_pricing=True, + router_model_id=deployment_id, + litellm_logging_obj=logging_obj, + ) + cost: Final = logging_obj.cost_breakdown.get("input_cost") if logging_obj.cost_breakdown is not None else None + return cost if cost is not None and _valid_price(cost) else None + except Exception: # noqa: BLE001 # the shared pricing owners raise plain Exception for unpriceable models + return None diff --git a/litellm/proxy/hooks/__init__.py b/litellm/proxy/hooks/__init__.py index 8714dd5f3d2..f3542098f95 100644 --- a/litellm/proxy/hooks/__init__.py +++ b/litellm/proxy/hooks/__init__.py @@ -9,6 +9,7 @@ from .max_budget_per_session_limiter import _PROXY_MaxBudgetPerSessionHandler from .max_iterations_limiter import _PROXY_MaxIterationsHandler from .parallel_request_limiter import _PROXY_MaxParallelRequestsHandler from .parallel_request_limiter_v3 import _PROXY_MaxParallelRequestsHandler_v3 +from .prompt_cache_prediction import PromptCacheObserver from .responses_id_security import ResponsesIDSecurity from .sensitive_data_routing import _PROXY_SensitiveDataRoutingHandler @@ -25,6 +26,7 @@ PROXY_HOOKS: Final = { "max_iterations_limiter": _PROXY_MaxIterationsHandler, "max_budget_per_session_limiter": _PROXY_MaxBudgetPerSessionHandler, "sensitive_data_routing": _PROXY_SensitiveDataRoutingHandler, + "prompt_cache_prediction": PromptCacheObserver, } ## FEATURE FLAG HOOKS ## diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index c398abff099..a34dc99e472 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -9,10 +9,12 @@ import binascii import logging import os import uuid -from collections.abc import Awaitable, Callable, Mapping, Sequence, Set +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence, Set +from contextlib import asynccontextmanager from contextvars import ContextVar from dataclasses import dataclass, field from datetime import datetime +from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, @@ -23,6 +25,7 @@ from typing import ( TypedDict, ) +from pydantic import TypeAdapter from typing_extensions import NotRequired, ReadOnly from litellm import DualCache @@ -84,6 +87,9 @@ else: InternalUsageCache = Any +_REQUEST_RATE_LIMIT_DATA: Final = TypeAdapter(Mapping[str, object]) + + BATCH_RATE_LIMITER_SCRIPT: Final = """ local results = {} local now = tonumber(ARGV[1]) @@ -2673,12 +2679,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): Returns list of descriptors for API key, user, team, team member, end user, model-specific, agent, and agent-session limits. """ - from litellm.proxy.auth.auth_utils import ( - get_team_model_rpm_limit, - get_team_model_tpm_limit, - ) - - descriptors: Final = [] + descriptors: Final[list[RateLimitDescriptor]] = [] # mutable-ok: existing descriptor helpers append in place # API Key rate limits if user_api_key_dict.api_key and ( @@ -2803,34 +2804,11 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): descriptors=descriptors, ) - if ( - get_team_model_rpm_limit(user_api_key_dict) is not None - or get_team_model_tpm_limit(user_api_key_dict) is not None - ): - _tpm_limit_for_team_model: Final = get_team_model_tpm_limit(user_api_key_dict) or {} - _rpm_limit_for_team_model: Final = get_team_model_rpm_limit(user_api_key_dict) or {} - should_check_rate_limit = False - if requested_model in _tpm_limit_for_team_model or requested_model in _rpm_limit_for_team_model: - should_check_rate_limit = True - - if should_check_rate_limit: - model_specific_tpm_limit = None - model_specific_rpm_limit = None - if requested_model in _tpm_limit_for_team_model: - model_specific_tpm_limit = _tpm_limit_for_team_model[requested_model] - if requested_model in _rpm_limit_for_team_model: - model_specific_rpm_limit = _rpm_limit_for_team_model[requested_model] - descriptors.append( - RateLimitDescriptor( - key="model_per_team", - value=f"{user_api_key_dict.team_id}:{requested_model}", - rate_limit={ - "requests_per_unit": model_specific_rpm_limit, - "tokens_per_unit": model_specific_tpm_limit, - "window_size": self.window_size, - }, - ) - ) + self._add_team_model_rate_limit_descriptor_from_metadata( + user_api_key_dict=user_api_key_dict, + requested_model=requested_model if isinstance(requested_model, str) else None, + descriptors=descriptors, + ) # Agent-level and session-level rate limits resolved_agent_id: Final = self._get_resolved_agent_id(user_api_key_dict, data) @@ -3416,6 +3394,108 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): requested_model, ) + async def _build_request_rate_limit_descriptors( + self, + user_api_key_dict: UserAPIKeyAuth, + data: Mapping[str, object], + call_type: str | None, + ) -> list[RateLimitDescriptor]: # mutable-ok: the shared generation reservation helpers require a list + metadata: Final = _REQUEST_RATE_LIMIT_DATA.validate_python( + user_api_key_dict.metadata or MappingProxyType({}) # pyright: ignore[reportUnknownMemberType] # validates the legacy auth metadata boundary + ) + rpm_value: Final = metadata.get("rpm_limit_type") + tpm_value: Final = metadata.get("tpm_limit_type") + rpm_limit_type: Final = rpm_value if isinstance(rpm_value, str) else None + tpm_limit_type: Final = tpm_value if isinstance(tpm_value, str) else None + model_value: Final = data.get("model") + requested_model: Final = model_value if isinstance(model_value, str) else None + model_has_failures: Final = ( + await self._check_model_has_recent_failures( + model=requested_model, + parent_otel_span=user_api_key_dict.parent_otel_span, + ) + if requested_model and self._is_dynamic_rate_limiting_enabled(rpm_limit_type, tpm_limit_type) + else False + ) + descriptors: Final = self._create_rate_limit_descriptors( # pyright: ignore[reportUnknownMemberType] # legacy helper reads a dictionary with validated keys + user_api_key_dict=user_api_key_dict, + data=dict(data), # mutable-ok: legacy descriptor helpers accept a request dictionary + rpm_limit_type=rpm_limit_type, + tpm_limit_type=tpm_limit_type, + model_has_failures=model_has_failures, + call_type=call_type, + ) + self._add_project_model_rate_limit_descriptor_from_metadata( + user_api_key_dict=user_api_key_dict, + requested_model=requested_model, + descriptors=descriptors, + ) + self.add_project_io_token_rate_limit_descriptors_from_metadata( + user_api_key_dict=user_api_key_dict, + requested_model=requested_model, + descriptors=descriptors, + ) + return [ # mutable-ok: the shared generation reservation helpers require a list + *descriptors, + *self.create_organization_rate_limit_descriptor(user_api_key_dict, requested_model), + ] + + async def _release_request_capacity_when_admitted( + self, + admission: asyncio.Task[RateLimitResponse], + acquisition: ParallelSlotAcquisition, + user_api_key_dict: UserAPIKeyAuth, + ) -> None: + response: Final = await admission + if response["overall_code"] == "OK": + await self._release_parallel_request_slots(acquisition, user_api_key_dict.parent_otel_span) + + @asynccontextmanager + async def request_capacity( + self, + user_api_key_dict: UserAPIKeyAuth, + model: str, + *, + request_data: Mapping[str, object] | None = None, + ) -> AsyncGenerator[None, None]: + """Charge one non-generation provider request to RPM and hold its concurrency slot.""" + data: Final = MappingProxyType({**(request_data or MappingProxyType({})), "model": model}) + descriptors: Final = await self._build_request_rate_limit_descriptors(user_api_key_dict, data, None) + acquisition: Final = ParallelSlotAcquisition( + slot_id=uuid.uuid4().hex, + counter_keys=[ # mutable-ok: the shared slot-release contract requires a list + self.create_rate_limit_keys(d["key"], d["value"], "max_parallel_requests") + for d in descriptors + if d["rate_limit"] is not None and d["rate_limit"].get("max_parallel_requests") is not None + ], + ) + admission: Final = asyncio.create_task( + self.should_rate_limit( + descriptors=descriptors, + parent_otel_span=user_api_key_dict.parent_otel_span, + skip_tpm_check=True, + parallel_slot_id=acquisition["slot_id"], + ) + ) + try: + response: Final = await asyncio.shield(admission) + if response["overall_code"] == "OVER_LIMIT": + self._handle_rate_limit_error(response, descriptors, model) + yield + finally: + cleanup: Final = asyncio.create_task( + self._release_request_capacity_when_admitted(admission, acquisition, user_api_key_dict) + ) + cancellation: asyncio.CancelledError | None = None # rebind-ok: retain cancellation until cleanup finishes + while not cleanup.done(): + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError as exc: + cancellation = exc # rebind-ok: retain the latest cancellation without interrupting slot release + cleanup.result() + if cancellation is not None: + raise cancellation + async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, @@ -3444,59 +3524,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): call_type=call_type, ) - # Get rate limit types from metadata - metadata: Final = user_api_key_dict.metadata or {} - rpm_limit_type: Final = metadata.get("rpm_limit_type") - tpm_limit_type: Final = metadata.get("tpm_limit_type") - - # For dynamic mode, check if the model has recent failures - model_has_failures = False - requested_model: Final = data.get("model", None) - - if ( - self._is_dynamic_rate_limiting_enabled( - rpm_limit_type=rpm_limit_type, - tpm_limit_type=tpm_limit_type, - ) - and requested_model - ): - model_has_failures = await self._check_model_has_recent_failures( - model=requested_model, - parent_otel_span=user_api_key_dict.parent_otel_span, - ) - - # Create rate limit descriptors - descriptors: Final = self._create_rate_limit_descriptors( + request_data: Final = _REQUEST_RATE_LIMIT_DATA.validate_python(data) + model_value: Final = request_data.get("model") + requested_model: Final = model_value if isinstance(model_value, str) else None + descriptors: Final = await self._build_request_rate_limit_descriptors( user_api_key_dict=user_api_key_dict, - data=data, - rpm_limit_type=rpm_limit_type, - tpm_limit_type=tpm_limit_type, - model_has_failures=model_has_failures, + data=request_data, call_type=call_type, ) - # Add team model rate limits from team_metadata - self._add_team_model_rate_limit_descriptor_from_metadata( - user_api_key_dict=user_api_key_dict, - requested_model=requested_model, - descriptors=descriptors, - ) - - # Project Level Rate Limits - self._add_project_model_rate_limit_descriptor_from_metadata( - user_api_key_dict=user_api_key_dict, - requested_model=requested_model, - descriptors=descriptors, - ) - self.add_project_io_token_rate_limit_descriptors_from_metadata( - user_api_key_dict=user_api_key_dict, - requested_model=requested_model, - descriptors=descriptors, - ) - - # Org Level Rate Limits - descriptors.extend(self.create_organization_rate_limit_descriptor(user_api_key_dict, requested_model)) - # Only check rate limits if we have descriptors with actual limits if descriptors: # First pass: RPM and max_parallel_requests sliding-window check. diff --git a/litellm/proxy/hooks/prompt_cache_prediction.py b/litellm/proxy/hooks/prompt_cache_prediction.py new file mode 100644 index 00000000000..65c456c5666 --- /dev/null +++ b/litellm/proxy/hooks/prompt_cache_prediction.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import Callable, Mapping +from datetime import datetime +from typing import TYPE_CHECKING, Final, Literal + +import httpx +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError + +from litellm.caching.dual_cache import DualCache +from litellm.integrations.custom_logger import CustomLogger +from litellm.llms.anthropic.prompt_cache_prediction import PromptPrefix, parse_observed_cache +from litellm.types.utils import ModelResponse + +if TYPE_CHECKING: + from litellm.proxy.utils import InternalUsageCache + +_RETENTION_SECONDS: Final = 86_400 + + +class CacheObservation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$") + cached_tokens: int = Field(gt=0) + observed_at: float = Field(ge=0, allow_inf_nan=False) + expires_at: float = Field(ge=0, allow_inf_nan=False) + + +_CACHE_ENTRY: Final[TypeAdapter[CacheObservation | str | None]] = TypeAdapter(CacheObservation | str | None) + + +def _cache_key(scope: str, fingerprint: str) -> str: + return f"prompt-cache-observation:{scope}:{fingerprint}" + + +async def lookup( + cache: DualCache, scope: str, prefix: PromptPrefix, now: float | None = None +) -> CacheObservation | None: + checked_at: Final = time.time() if now is None else now + exact: Final = await _read_exact(cache, scope, prefix.fingerprint) + if exact is not None and exact.expires_at > checked_at: + return exact + older: Final = await asyncio.gather( + *(_read_exact(cache, scope, fingerprint) for fingerprint in prefix.fingerprints[1:]) + ) + observations: Final = tuple(observation for observation in (exact, *older) if observation is not None) + return next( + (observation for observation in observations if observation.expires_at > checked_at), + next(iter(observations), None), + ) + + +async def _read_exact(cache: DualCache, scope: str, fingerprint: str) -> CacheObservation | None: + try: + value: Final = _CACHE_ENTRY.validate_python(await cache.async_get_cache(_cache_key(scope, fingerprint), ttl=1)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # validate the legacy cache's untyped result at the I/O boundary + if value is None: + return None + observation: Final = CacheObservation.model_validate_json(value) if isinstance(value, str) else value + except ValidationError: + return None + return observation if observation.fingerprint == fingerprint else None + + +class _Metadata(BaseModel): + model_config = ConfigDict(strict=True) + user_api_key_hash: str = Field(min_length=1) + + +class _Logged(BaseModel): + model_config = ConfigDict(strict=True) + status: Literal["success"] + model_id: str = Field(min_length=1) + metadata: _Metadata + + +class _Event(BaseModel): + model_config = ConfigDict(strict=True, arbitrary_types_allowed=True) + call_type: Literal["anthropic_messages"] + custom_llm_provider: Literal["anthropic"] + cache_hit: bool | None = None + httpx_response: httpx.Response + first_api_call_start_time: datetime + standard_logging_object: _Logged + stream: bool = False + prompt_cache_response_complete: bool = False + + +class PromptCacheObserver(CustomLogger): + def __init__(self, internal_usage_cache: InternalUsageCache, clock: Callable[[], float] = time.time) -> None: + super().__init__() # pyright: ignore[reportUnknownMemberType] # base callback constructor accepts untyped kwargs + self.cache = internal_usage_cache.dual_cache + self.clock = clock + + async def async_log_success_event( + self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime + ) -> None: + if not isinstance(response_obj, ModelResponse): + return + try: + event: Final = _Event.model_validate(kwargs) + wire: Final = event.httpx_response.request + except (ValidationError, RuntimeError, httpx.RequestNotRead): + return + if ( + event.cache_hit + or event.httpx_response.status_code != 200 + or (event.stream and not event.prompt_cache_response_complete) + ): + return + observed: Final = parse_observed_cache( + wire, + response_obj, + event.standard_logging_object.metadata.user_api_key_hash, + event.standard_logging_object.model_id, + ) + if observed is None: + return + prefix: Final = observed.prefix + scope: Final = observed.scope + cache_tokens: Final = observed.cached_tokens + now: Final = self.clock() + started: Final = event.first_api_call_start_time.timestamp() + if started > now: + return + if observed.cache_creation_tokens == 0: + previous: Final = await _read_exact(self.cache, scope, prefix.fingerprint) + if previous is None or previous.fingerprint != prefix.fingerprint or previous.cached_tokens != cache_tokens: + return + observation: Final = CacheObservation( + fingerprint=prefix.fingerprint, + cached_tokens=cache_tokens, + observed_at=now, + expires_at=started + prefix.ttl_seconds, + ) + key: Final = _cache_key(scope, prefix.fingerprint) + payload: Final = observation.model_dump_json() + await self.cache.async_set_cache(key, payload, ttl=_RETENTION_SECONDS) # pyright: ignore[reportUnknownMemberType] # legacy cache accepts a serialized validated observation + if self.cache.redis_cache is not None: + await self.cache.async_set_cache(key, payload, local_only=True, ttl=1) # pyright: ignore[reportUnknownMemberType] # keep the local copy short-lived while Redis retains stale evidence diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index dc0da63555f..cb376f286ec 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -28,6 +28,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.prompt_cache_prediction import router as prompt_cache_prediction_router from litellm.types.utils import ( CostBreakdown, CostPerToken, @@ -39,6 +40,7 @@ from litellm.types.utils import ( ) router: Final = APIRouter() +router.include_router(prompt_cache_prediction_router) @dataclass(frozen=True, slots=True) diff --git a/litellm/proxy/management_endpoints/prompt_cache_prediction.py b/litellm/proxy/management_endpoints/prompt_cache_prediction.py new file mode 100644 index 00000000000..56e844214d6 --- /dev/null +++ b/litellm/proxy/management_endpoints/prompt_cache_prediction.py @@ -0,0 +1,278 @@ +import time +from collections.abc import Mapping +from types import MappingProxyType +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, JsonValue, TypeAdapter + +import litellm +from litellm._internal_context import current_billing_time, pinned_billing_time +from litellm.caching.caching import DualCache +from litellm.integrations.custom_logger import CustomLogger +from litellm.llms.anthropic.prompt_cache_prediction import ( + PromptPrefix, + TokenCounter, + UnsupportedPredictionTarget, + cache_scope, + count_prompt_tokens, + parse_prompt, + resolve_prediction_target, + supported_prediction_headers, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.auth_checks import can_key_call_resolved_model +from litellm.proxy.auth.auth_utils import get_cache_prediction_deployments +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.http_parsing_utils import ( + _read_request_body, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # canonical parsed-body owner; validate its legacy result at the endpoint boundary +) +from litellm.proxy.common_utils.prompt_cache_pricing import price_cache_tokens +from litellm.proxy.hooks.parallel_request_limiter_v3 import ( + _PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage] # use the configured proxy limiter's shared capacity owner +) +from litellm.proxy.hooks.prompt_cache_prediction import lookup +from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.types.management_endpoints.prompt_cache_prediction import ( + CacheCostScenario, + CacheEvidence, + CachePredictionArm, + CachePredictionRequest, + CachePredictionResponse, + CacheTokenBuckets, +) +from litellm.types.router import Deployment +from litellm.utils import get_prompt_cache_min_tokens + +router: Final = APIRouter() +_REQUEST_DATA: Final = TypeAdapter(Mapping[str, object]) + + +class _CallerSettings(BaseModel): + config: Mapping[str, object] | None = None + + +def has_request_transforms() -> bool: + from litellm.proxy.hooks import PROXY_HOOKS + + builtins: Final = frozenset(PROXY_HOOKS.values()) + hooks: Final = ("async_pre_call_hook", "async_pre_request_hook", "async_pre_call_deployment_hook") + callbacks: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(callback_type=CustomLogger) + return any( + type(callback) not in builtins + and any(getattr(type(callback), hook) is not getattr(CustomLogger, hook) for hook in hooks) + for callback in callbacks + ) + + +def _buckets(prefix_tokens: int, suffix_tokens: int, read_tokens: int, ttl_seconds: int) -> CacheTokenBuckets: + return CacheTokenBuckets( + uncached_input_tokens=suffix_tokens, + cache_read_input_tokens=read_tokens, + cache_creation_5m_input_tokens=prefix_tokens - read_tokens if ttl_seconds == 300 else 0, + cache_creation_1h_input_tokens=prefix_tokens - read_tokens if ttl_seconds == 3600 else 0, + ) + + +def _scenario(model: str, deployment_id: str, tokens: CacheTokenBuckets) -> CacheCostScenario | None: + cost: Final = price_cache_tokens(model=model, deployment_id=deployment_id, tokens=tokens) + return CacheCostScenario(tokens=tokens, input_cost=cost) if cost is not None else None + + +def _capacity_counter( + limiter: _PROXY_MaxParallelRequestsHandler_v3, + caller: UserAPIKeyAuth, + model_name: str, + request_data: Mapping[str, object], +) -> TokenCounter: + async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + async with limiter.request_capacity(caller, model_name, request_data=request_data): + return await count_prompt_tokens(model, api_key, body) + + return count + + +def _capacity_request_data( + http_request: Request, caller: UserAPIKeyAuth, request_data: Mapping[str, object] +) -> Mapping[str, object]: + # The parsed-body cache retains only original top-level keys. Replay the + # shared idempotent tag merges on limiter-only data when auth added metadata. + data: Final = dict(request_data) # mutable-ok: the existing tag merge owners accept a dictionary out-param + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth(http_request, data, caller) # pyright: ignore[reportUnknownMemberType] # legacy tag owner takes the validated capacity dictionary + LiteLLMProxyRequestSetup.apply_key_tags_pre_auth(data, caller) # pyright: ignore[reportUnknownMemberType] # legacy tag owner merges trusted key tags into capacity metadata + return MappingProxyType(data) + + +async def predict_arm( + deployment: Deployment, + body: Mapping[str, JsonValue], + prefix: PromptPrefix, + caller_key_hash: str, + cache: DualCache, + token_counter: TokenCounter, +) -> CachePredictionArm: + deployment_id: Final = deployment.model_info.id or "" + params: Final = deployment.litellm_params + unknown: Final = CachePredictionArm(deployment_id=deployment_id, model=params.model) + if deployment.model_info.blocked: + return unknown.model_copy(update=MappingProxyType({"reason": "unsupported_deployment_configuration"})) + target: Final = resolve_prediction_target(params) + if isinstance(target, UnsupportedPredictionTarget): + return unknown.model_copy(update=MappingProxyType({"reason": target.reason})) + model: Final = target.model + api_key: Final = target.api_key + total_count: Final = await token_counter(model, api_key, body) + prefix_count: Final = await token_counter(model, api_key, prefix.prefix_body) + if total_count is None or prefix_count is None or total_count < prefix_count: + return unknown.model_copy(update=MappingProxyType({"reason": "token_count_unavailable"})) + scope: Final = cache_scope(caller_key_hash, deployment_id, api_key, model) + observation: Final = await lookup(cache, scope, prefix) + exact: Final = observation is not None and observation.fingerprint == prefix.fingerprint + cacheable: Final = observation.cached_tokens if exact and observation is not None else prefix_count + if cacheable > total_count or (observation is not None and observation.cached_tokens > cacheable): + return unknown.model_copy(update=MappingProxyType({"reason": "inconsistent_prefix_token_count"})) + suffix: Final = total_count - cacheable + evidence: Final = ( + CacheEvidence(observed_at=observation.observed_at, expires_at=observation.expires_at) + if observation is not None + else None + ) + if cacheable < get_prompt_cache_min_tokens(params.model): + disabled: Final = _scenario(model, deployment_id, CacheTokenBuckets(uncached_input_tokens=total_count)) + if disabled is None: + return unknown.model_copy(update=MappingProxyType({"reason": "pricing_unavailable"})) + return CachePredictionArm( + deployment_id=deployment_id, + model=model, + cache_state="disabled", + reason="below_cache_minimum", + estimate=disabled, + cold=disabled, + warm=disabled, + token_count_source="anthropic_count_tokens", + ) + fresh: Final = observation is not None and observation.expires_at > time.time() + read: Final = observation.cached_tokens if fresh and observation is not None else 0 + with pinned_billing_time(current_billing_time()): + cold: Final = _scenario(model, deployment_id, _buckets(cacheable, suffix, 0, prefix.ttl_seconds)) + warm: Final = _scenario(model, deployment_id, _buckets(cacheable, suffix, cacheable, prefix.ttl_seconds)) + estimate: Final = _scenario(model, deployment_id, _buckets(cacheable, suffix, read, prefix.ttl_seconds)) + if cold is None or warm is None or estimate is None: + return unknown.model_copy(update=MappingProxyType({"reason": "pricing_unavailable"})) + return CachePredictionArm( + deployment_id=deployment_id, + model=model, + cache_state="warm" if fresh and exact else "partial" if fresh else "stale" if observation else "unknown", + reason=None if fresh else "observation_expired" if observation else "no_compatible_observation", + estimate=estimate, + cold=cold, + warm=warm, + evidence=evidence, + token_count_source="anthropic_count_tokens", + ) + + +@router.post( + "/cost/predict-cache", + tags=["Cost Tracking"], # mutable-ok: FastAPI requires a list for OpenAPI tags + response_model=CachePredictionResponse, +) +async def predict_cache_cost( + request: CachePredictionRequest, + http_request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> CachePredictionResponse: + """Compare the next native Anthropic request on two configured deployment IDs. + + Estimates use provider token counting and recent successful cache telemetry for this key. + Unknown cache state uses the cold scenario when prices/counts are available. Cache observations + do not guarantee retention. v0 supports one message-content breakpoint, text and client tools; + system/tool-only breakpoints, thinking, images, nondefault Anthropic versions, beta headers and + request transforms are unknown. + Each provider count consumes one RPM unit and holds concurrency capacity; a comparison uses + up to four counts. The legacy rate limiter returns unknown without contacting the provider. + This endpoint does not generate tokens, prewarm caches, choose a model or alter routing. + """ + from litellm.proxy.proxy_server import llm_router, proxy_logging_obj + + if llm_router is None: + raise HTTPException(status_code=503, detail="Model router is unavailable") + deployments: Final = get_cache_prediction_deployments( + current_deployment_id=request.current_deployment_id, + candidate_deployment_id=request.candidate_deployment_id, + llm_router=llm_router, + team_id=user_api_key_dict.team_id, + ) + if deployments is None: + raise HTTPException(status_code=404, detail="Deployment not found") + current, candidate = deployments + for deployment in (current, candidate): + await can_key_call_resolved_model( + model=deployment.model_name, + llm_model_list=llm_router.get_model_list(), + valid_token=user_api_key_dict, + llm_router=llm_router, + ) + prefix: Final = parse_prompt(request.request) + caller: Final = user_api_key_dict.api_key + caller_settings: Final = _CallerSettings.model_validate(user_api_key_dict, from_attributes=True) + unsupported_transform: Final = bool(caller_settings.config) or has_request_transforms() + unsupported_headers: Final = not supported_prediction_headers(http_request.headers) + limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter") + if ( + prefix is None + or not caller + or unsupported_transform + or unsupported_headers + or not isinstance(limiter, _PROXY_MaxParallelRequestsHandler_v3) + ): + reason: Final = ( + "unsupported_provider_headers" + if unsupported_headers + else "unsupported_request_transform" + if unsupported_transform + else "unsupported_prompt_shape" + if prefix is None + else "caller_identity_unavailable" + if not caller + else "limiter_unavailable" + ) + return CachePredictionResponse( + stay=CachePredictionArm(deployment_id=request.current_deployment_id, reason=reason), + switch=CachePredictionArm(deployment_id=request.candidate_deployment_id, reason=reason), + switch_delta=None, + cache_rebuild_penalty=None, + ) + request_data: Final = _capacity_request_data( + http_request, user_api_key_dict, _REQUEST_DATA.validate_python(await _read_request_body(http_request)) + ) + stay: Final = await predict_arm( + current, + request.request, + prefix, + caller, + proxy_logging_obj.internal_usage_cache.dual_cache, + _capacity_counter(limiter, user_api_key_dict, current.model_name, request_data), + ) + switch: Final = ( + stay + if current.model_info.id == candidate.model_info.id + else await predict_arm( + candidate, + request.request, + prefix, + caller, + proxy_logging_obj.internal_usage_cache.dual_cache, + _capacity_counter(limiter, user_api_key_dict, candidate.model_name, request_data), + ) + ) + return CachePredictionResponse( + stay=stay, + switch=switch, + switch_delta=(switch.estimate.input_cost - stay.estimate.input_cost) + if switch.estimate is not None and stay.estimate is not None + else None, + cache_rebuild_penalty=(switch.estimate.input_cost - switch.warm.input_cost) + if switch.estimate is not None and switch.warm is not None + else None, + ) diff --git a/litellm/proxy/pass_through_endpoints/streaming_handler.py b/litellm/proxy/pass_through_endpoints/streaming_handler.py index 4be0235adbb..b310fc661c4 100644 --- a/litellm/proxy/pass_through_endpoints/streaming_handler.py +++ b/litellm/proxy/pass_through_endpoints/streaming_handler.py @@ -270,6 +270,24 @@ class PassThroughStreamingHandler: - Vertex AI - OpenAI """ + from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( + _is_message_stop_chunk, # pyright: ignore[reportPrivateUsage] # both native stream paths share terminal-event detection + _is_provider_error_chunk, # pyright: ignore[reportPrivateUsage] # provider errors must not become cache evidence + ) + + # Transport reads can split event names and JSON payloads. Recognize terminal + # events only after the shared SSE framer has reassembled the collected bytes. + complete_frames, incomplete_tail = split_complete_sse_frames( + b"".join(raw_bytes) if endpoint_type == EndpointType.ANTHROPIC else b"" + ) + litellm_logging_obj.model_call_details[ # rebind-ok: stamp evidence on the per-request state read by callbacks + "prompt_cache_response_complete" + ] = ( + endpoint_type == EndpointType.ANTHROPIC + and not incomplete_tail.strip() + and _is_message_stop_chunk(complete_frames) + and not _is_provider_error_chunk(complete_frames) + ) try: ( standard_logging_response_object, diff --git a/litellm/types/management_endpoints/prompt_cache_prediction.py b/litellm/types/management_endpoints/prompt_cache_prediction.py new file mode 100644 index 00000000000..3789607b021 --- /dev/null +++ b/litellm/types/management_endpoints/prompt_cache_prediction.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Annotated, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictInt + +TokenCount: TypeAlias = Annotated[StrictInt, Field(ge=0)] + + +class CacheTokenBuckets(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + uncached_input_tokens: TokenCount = 0 + cache_read_input_tokens: TokenCount = 0 + cache_creation_5m_input_tokens: TokenCount = 0 + cache_creation_1h_input_tokens: TokenCount = 0 + + @property + def total_tokens(self) -> int: + return ( + self.uncached_input_tokens + + self.cache_read_input_tokens + + self.cache_creation_5m_input_tokens + + self.cache_creation_1h_input_tokens + ) + + +class CacheEvidence(BaseModel): + model_config = ConfigDict(frozen=True) + + observed_at: float + expires_at: float + source: Literal["provider_usage"] = "provider_usage" + confidence: Literal["observed"] = "observed" + + +class CacheCostScenario(BaseModel): + tokens: CacheTokenBuckets + input_cost: float + + +class CachePredictionArm(BaseModel): + deployment_id: str + model: str | None = None + cache_state: Literal["warm", "partial", "stale", "unknown", "disabled"] = "unknown" + reason: str | None = None + estimate: CacheCostScenario | None = None + cold: CacheCostScenario | None = None + warm: CacheCostScenario | None = None + evidence: CacheEvidence | None = None + token_count_source: Literal["anthropic_count_tokens"] | None = None + + +class CachePredictionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_deployment_id: str = Field(min_length=1, max_length=256) + candidate_deployment_id: str = Field(min_length=1, max_length=256) + request: Mapping[str, JsonValue] + + +class CachePredictionResponse(BaseModel): + stay: CachePredictionArm + switch: CachePredictionArm + switch_delta: float | None + cache_rebuild_penalty: float | None + pricing_basis: Literal["input_before_discounts_and_margins"] = "input_before_discounts_and_margins" + cache_guarantee: Literal[False] = False diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_prompt_cache_prediction.py b/tests/test_litellm/llms/anthropic/test_anthropic_prompt_cache_prediction.py new file mode 100644 index 00000000000..2b36866a1a0 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_anthropic_prompt_cache_prediction.py @@ -0,0 +1,209 @@ +import json +from collections.abc import Mapping +from datetime import datetime +from types import SimpleNamespace +from typing import Final + +import httpx +import pytest +import respx +from pydantic import JsonValue + +import litellm +from litellm.caching.dual_cache import DualCache +from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.llms.anthropic.count_tokens import handler as count_handler +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import DEFAULT_ANTHROPIC_API_VERSION +from litellm.llms.anthropic.prompt_cache_prediction import ( + NativePredictionTarget, + cache_scope, + count_prompt_tokens, + parse_observed_cache, + parse_prompt, + resolve_prediction_target, + supported_prediction_headers, +) +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.models.credentials import CredentialItem +from litellm.proxy import proxy_server +from litellm.proxy.hooks.prompt_cache_prediction import PromptCacheObserver, lookup +from litellm.proxy.management_endpoints.prompt_cache_prediction import predict_arm +from litellm.proxy.utils import InternalUsageCache +from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo +from litellm.types.utils import CacheCreationTokenDetails, ModelResponse, PromptTokensDetailsWrapper, Usage + +_MODEL: Final = "claude-sonnet-5" +_KEY: Final = "test-provider-key" +_CALLER: Final = "test-caller-hash" +_DEPLOYMENT: Final = "test-native-deployment" + + +def _body() -> dict[str, JsonValue]: + return { + "model": _MODEL, + "system": "Keep this context", + "tools": [{"name": "lookup", "input_schema": {"type": "object"}}], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "A cacheable prefix", "cache_control": {"type": "ephemeral"}} + ]}], + } + + +@pytest.mark.parametrize("version", [None, "2099-01-01", DEFAULT_ANTHROPIC_API_VERSION]) +@pytest.mark.asyncio +async def test_observer_records_only_version_supported_by_token_counter(version: str | None) -> None: + cache: Final = DualCache() + observer: Final = PromptCacheObserver(InternalUsageCache(dual_cache=cache), clock=lambda: 1010.0) + body: Final = _body() + prefix: Final = parse_prompt(body) + assert prefix is not None + headers: Final = {"x-api-key": _KEY, **({"anthropic-version": version} if version is not None else {})} + wire: Final = httpx.Request("POST", "https://api.anthropic.com/v1/messages", headers=headers, json=body) + response: Final = ModelResponse( + model=_MODEL, + usage=Usage( + prompt_tokens=311, + completion_tokens=2, + total_tokens=313, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=100, + cache_creation_tokens=200, + cache_creation_token_details=CacheCreationTokenDetails( + ephemeral_5m_input_tokens=200, ephemeral_1h_input_tokens=0 + ), + ), + ), + ) + await observer.async_log_success_event( + { + "call_type": "anthropic_messages", + "custom_llm_provider": "anthropic", + "httpx_response": httpx.Response(200, request=wire), + "first_api_call_start_time": datetime.fromtimestamp(1000.0), + "standard_logging_object": { + "status": "success", "model_id": _DEPLOYMENT, + "metadata": {"user_api_key_hash": _CALLER}, + }, + }, + response, + datetime.fromtimestamp(1010.0), + datetime.fromtimestamp(1010.0), + ) + default_scope: Final = cache_scope(_CALLER, _DEPLOYMENT, _KEY, _MODEL) + found: Final = await lookup(cache, default_scope, prefix, now=1010.0) + assert (found is not None) == (version == DEFAULT_ANTHROPIC_API_VERSION) + if version != DEFAULT_ANTHROPIC_API_VERSION: + other_scope: Final = cache_scope(_CALLER, _DEPLOYMENT, _KEY, _MODEL, version or "") + assert await lookup(cache, other_scope, prefix, now=1010.0) is None + + +@pytest.mark.parametrize("headers, supported", [ + ({}, True), + ({"Anthropic-Version": DEFAULT_ANTHROPIC_API_VERSION}, True), + ({"anthropic-version": "2099-01-01"}, False), + ({"Anthropic-Beta": ""}, False), + ({"anthropic-beta": "future-feature"}, False), +]) +def test_prediction_header_eligibility(headers: Mapping[str, str], supported: bool) -> None: + assert supported_prediction_headers(headers) is supported + + +@pytest.mark.asyncio +async def test_provider_count_uses_same_version_and_preserves_native_input(monkeypatch: pytest.MonkeyPatch) -> None: + body: Final = _body() + requests: Final[list[httpx.Request]] = [] + + def provider(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"input_tokens": 311}) + + client: Final = AsyncHTTPHandler() + await client.client.aclose() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider)) + monkeypatch.setattr(count_handler, "get_async_httpx_client", lambda **kwargs: client) + try: + assert await count_prompt_tokens(_MODEL, _KEY, body) == 311 + finally: + await client.client.aclose() + assert len(requests) == 1 + assert requests[0].headers["anthropic-version"] == DEFAULT_ANTHROPIC_API_VERSION + assert requests[0].url == "https://api.anthropic.com/v1/messages/count_tokens" + assert json.loads(requests[0].content) == body + + +@pytest.mark.parametrize("source", ["static", "database"]) +@pytest.mark.asyncio +async def test_environment_credential_matches_native_count_and_observed_scope( + source: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LIT7658_PROVIDER_KEY", _KEY) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) + params: Final = { + "model": f"anthropic/{_MODEL}", "api_key": "os.environ/LIT7658_PROVIDER_KEY", + "api_base": "https://api.anthropic.com", + } + router: Final = litellm.Router(model_list=[{ + "model_name": "test-native", "litellm_params": dict(params), "model_info": {"id": _DEPLOYMENT}, + }] if source == "static" else [], num_retries=0) + if source == "database": + monkeypatch.setattr(proxy_server, "llm_router", router) + assert proxy_server.ProxyConfig()._add_deployment([SimpleNamespace( + model_id=_DEPLOYMENT, model_name="test-native", model_info={}, litellm_params=dict(params), + )]) == 1 + deployment: Final = router.get_deployment(_DEPLOYMENT) + assert deployment is not None + target: Final = resolve_prediction_target(deployment.litellm_params) + assert isinstance(target, NativePredictionTarget) + body: Final = _body() + with respx.mock() as upstream: + native: Final = upstream.post("https://api.anthropic.com/v1/messages").respond(200, json={ + "id": "msg_test", "type": "message", "role": "assistant", "model": _MODEL, + "content": [{"type": "text", "text": "Hello"}], "stop_reason": "end_turn", "stop_sequence": None, + "usage": {"input_tokens": 11, "output_tokens": 1, "cache_read_input_tokens": 300}, + }) + counter: Final = upstream.post("https://api.anthropic.com/v1/messages/count_tokens").respond( + 200, json={"input_tokens": 311}, + ) + await router.aanthropic_messages( + model="test-native", max_tokens=1, **{key: value for key, value in body.items() if key != "model"}, + ) + assert await count_prompt_tokens(target.model, target.api_key, body) == 311 + assert native.call_count == counter.call_count == 1 + assert native.calls.last.request.headers["x-api-key"] == counter.calls.last.request.headers["x-api-key"] == _KEY + observed: Final = parse_observed_cache(native.calls.last.request, ModelResponse( + model=_MODEL, usage=Usage( + prompt_tokens=311, completion_tokens=1, total_tokens=312, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=300), + ), + ), _CALLER, _DEPLOYMENT) + assert observed is not None + assert observed.scope == cache_scope(_CALLER, _DEPLOYMENT, target.api_key, target.model) + + +@pytest.mark.parametrize("inline_key", [None, _KEY]) +@pytest.mark.asyncio +async def test_named_credential_is_explicitly_unsupported_before_count( + inline_key: str | None, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "credential_list", [CredentialItem( + credential_name="test-named", credential_info={}, credential_values={"api_key": "test-named-provider-key"}, + )]) + deployment: Final = Deployment( + model_name="test-native", + litellm_params=LiteLLM_Params( + model=f"anthropic/{_MODEL}", api_key=inline_key, litellm_credential_name="test-named", + ), + model_info=ModelInfo(id=_DEPLOYMENT), + ) + body: Final = _body() + prefix: Final = parse_prompt(body) + assert prefix is not None + + async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + pytest.fail("Unsupported named credentials must not reach provider counting") + + arm: Final = await predict_arm(deployment, body, prefix, _CALLER, DualCache(), count) + assert arm.cache_state == "unknown" + assert arm.reason == "unsupported_deployment_configuration" + assert arm.estimate is None and arm.cold is None and arm.warm is None diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index cdf1f897707..bd6a14cad21 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -433,6 +433,232 @@ def test_get_model_from_request_no_request_extracts_model(): ) +def _cache_prediction_router(): + from litellm.router import Router + + return Router(model_list=[ + { + "model_name": group, + "litellm_params": {"model": "anthropic/claude-sonnet-5", "api_key": "test-provider-key"}, + "model_info": {"id": deployment_id, "team_id": team_id}, + } + for group, deployment_id, team_id in ( + ("current-group", "current-id", None), ("candidate-group", "candidate-id", None), + ("own-group", "own-id", "prediction-team"), ("foreign-group", "foreign-id", "foreign-team"), + ) + ]) + + +@pytest.mark.parametrize("candidate,team_id,expected", [ + ("candidate-id", None, ["current-group", "candidate-group"]), + ("current-id", None, "current-group"), + ("missing-id", None, None), + ("candidate-group", None, None), + ("own-id", None, None), + ("own-id", "prediction-team", ["current-group", "own-group"]), + ("foreign-id", "prediction-team", None), +]) +def test_cache_prediction_auth_resolves_only_exact_deployment_ids(candidate, team_id, expected): + assert get_model_from_request( + request_data={ + "current_deployment_id": "current-id", "candidate_deployment_id": candidate, + "request": {"model": "caller-controlled-provider-model"}, + }, + route="/cost/predict-cache", + llm_router=_cache_prediction_router(), + team_id=team_id, + ) == expected + + +def _cache_prediction_auth_app( + monkeypatch, allowed_routes, user_models, metadata=None, *, team_id=None, key_models=None, team_models=None +): + import importlib + from unittest.mock import AsyncMock + + from fastapi import FastAPI + + import litellm.proxy.proxy_server as proxy_server + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_TeamTableCachedObj, LiteLLM_UserTable, LitellmUserRoles, ProxyException + from litellm.proxy.auth import auth_checks + from litellm.proxy.hooks.parallel_request_limiter_v3 import _PROXY_MaxParallelRequestsHandler_v3 + from litellm.proxy.management_endpoints import prompt_cache_prediction as endpoint + from litellm.proxy.utils import InternalUsageCache, ProxyLogging + + auth = importlib.import_module("litellm.proxy.auth.user_api_key_auth") + router = _cache_prediction_router() + allowed_models = ["current-group", "candidate-group", "own-group"] + token = UserAPIKeyAuth( + api_key="test-proxy-key-hash", user_id="prediction-user", user_role=LitellmUserRoles.INTERNAL_USER, + models=allowed_models if key_models is None else key_models, team_id=team_id, + team_models=allowed_models if team_models is None else team_models, + allowed_routes=allowed_routes, metadata=metadata or {}, + ) + user = LiteLLM_UserTable( + user_id=token.user_id, user_role=LitellmUserRoles.INTERNAL_USER.value, models=user_models, + ) + async def authenticate(request, request_data, **_headers): + await auth._enforce_key_and_fallback_model_access( + valid_token=token, request_data=request_data, route=request.url.path, request=request, + llm_model_list=router.get_model_list(), llm_router=router, + ) + return token + + monkeypatch.setattr(auth, "_user_api_key_auth_builder", authenticate) + monkeypatch.setattr(auth, "get_user_object", AsyncMock(return_value=user)) + team = LiteLLM_TeamTableCachedObj(team_id=team_id, models=token.team_models) if team_id else None + monkeypatch.setattr(auth, "get_team_object", AsyncMock(return_value=team)) + monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=team)) + monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None)) + monkeypatch.setattr(auth, "get_global_proxy_spend", AsyncMock(return_value=0)) + monkeypatch.setattr(proxy_server, "master_key", "test-master-key") + monkeypatch.setattr(proxy_server, "user_custom_auth", None) + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", router.get_model_list()) + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache()) + logging = ProxyLogging(user_api_key_cache=DualCache()) + logging.proxy_hook_mapping["parallel_request_limiter"] = _PROXY_MaxParallelRequestsHandler_v3( + InternalUsageCache(dual_cache=DualCache()) + ) + monkeypatch.setattr(proxy_server, "proxy_logging_obj", logging) + counts = AsyncMock(return_value=6_000) + monkeypatch.setattr(endpoint, "count_prompt_tokens", counts) + app = FastAPI() + app.include_router(endpoint.router) + app.add_exception_handler(ProxyException, proxy_server.openai_exception_handler) + return app, counts + + +def _cache_prediction_payload(candidate="candidate-id", current="current-id"): + return { + "current_deployment_id": current, "candidate_deployment_id": candidate, + "request": {"messages": [{"role": "user", "content": [{ + "type": "text", "text": "Stable cached context", + "cache_control": {"type": "ephemeral"}, + }]}]}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allowed_routes,user_models,candidate,status_code", [ + (["/chat/completions"], ["current-group", "candidate-group"], "candidate-id", 403), + (["/cost/predict-cache"], ["current-group"], "candidate-id", 403), + (["/cost/*"], ["current-group", "candidate-group"], "candidate-id", 200), + (["/cost/predict-cache"], ["current-group"], "current-id", 200), + (["/cost/predict-cache"], ["current-group"], "missing-id", 404), +]) +async def test_cache_prediction_authorizes_route_and_personal_models_before_provider_counts( + monkeypatch, allowed_routes, user_models, candidate, status_code +): + import httpx + + app, counts = _cache_prediction_auth_app(monkeypatch, allowed_routes, user_models) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/cost/predict-cache", json=_cache_prediction_payload(candidate)) + + assert response.status_code == status_code, response.text + if status_code == 200: + assert counts.await_count == (2 if candidate == "current-id" else 4) + else: + assert counts.await_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arm", ["current_deployment_id", "candidate_deployment_id"]) +@pytest.mark.parametrize("team_id,key_models,user_models,team_models", [ + (None, ["*"], ["*"], None), + (None, ["current-group", "candidate-group"], ["*"], None), + (None, ["*"], ["current-group", "candidate-group"], None), + ("prediction-team", ["*"], ["*"], ["current-group", "candidate-group"]), +]) +async def test_cache_prediction_hides_foreign_and_missing_ids_before_model_authorization( + monkeypatch, arm, team_id, key_models, user_models, team_models +): + import httpx + + app, counts = _cache_prediction_auth_app( + monkeypatch, ["/cost/predict-cache"], user_models, + team_id=team_id, key_models=key_models, team_models=team_models, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + missing = await client.post("/cost/predict-cache", json={**_cache_prediction_payload(), arm: "missing-id"}) + foreign = await client.post("/cost/predict-cache", json={**_cache_prediction_payload(), arm: "foreign-id"}) + + assert missing.status_code == foreign.status_code == 404, foreign.text + assert missing.json() == foreign.json() == {"detail": "Deployment not found"} + assert counts.await_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arm", ["current_deployment_id", "candidate_deployment_id"]) +@pytest.mark.parametrize("key_models,team_models,status_code", [ + (["*"], ["*"], 200), + (["current-group", "candidate-group"], ["*"], 403), + (["*"], ["current-group", "candidate-group"], 403), +]) +async def test_cache_prediction_checks_each_visible_team_deployment_model( + monkeypatch, arm, key_models, team_models, status_code +): + import httpx + + app, counts = _cache_prediction_auth_app( + monkeypatch, ["/cost/predict-cache"], ["*"], + team_id="prediction-team", key_models=key_models, team_models=team_models, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/cost/predict-cache", json={**_cache_prediction_payload(), arm: "own-id"}) + + assert response.status_code == status_code, response.text + assert counts.await_count == (4 if status_code == 200 else 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arm", ["current_deployment_id", "candidate_deployment_id"]) +async def test_cache_prediction_checks_each_visible_personal_deployment_model(monkeypatch, arm): + import httpx + + app, counts = _cache_prediction_auth_app(monkeypatch, ["/cost/predict-cache"], ["current-group"]) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/cost/predict-cache", json={**_cache_prediction_payload(candidate="current-id"), arm: "candidate-id"} + ) + + assert response.status_code == 403, response.text + assert response.json()["error"]["type"] == "user_model_access_denied" + assert counts.await_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("header_tag,key_tags,limit,status_code,provider_calls", [ + ("limited", [], 1, 429, 1), + (None, ["limited"], 1, 429, 1), + ("limited", ["limited"], 4, 200, 4), + ("unlimited", [], 1, 200, 4), +]) +async def test_cache_prediction_preserves_authenticated_header_and_key_tag_rpm( + monkeypatch, header_tag, key_tags, limit, status_code, provider_calls +): + import httpx + + app, counts = _cache_prediction_auth_app( + monkeypatch, ["/cost/predict-cache"], ["current-group", "candidate-group"], + metadata={"tag_rpm_limit": {"limited": limit}, "tags": key_tags}, + ) + headers = {"x-litellm-tags": header_tag} if header_tag else {} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/cost/predict-cache", json=_cache_prediction_payload(), headers=headers) + assert response.status_code == status_code, response.text + assert counts.await_count == provider_calls + if limit == 4: + exhausted = await client.post("/cost/predict-cache", json=_cache_prediction_payload(), headers=headers) + assert exhausted.status_code == 429, exhausted.text + assert counts.await_count == 4 + assert all("metadata" not in call.args[2] for call in counts.await_args_list) + + def test_get_model_from_request_supports_google_model_names_with_slashes(): assert ( get_model_from_request( diff --git a/tests/test_litellm/proxy/common_utils/test_prompt_cache_pricing.py b/tests/test_litellm/proxy/common_utils/test_prompt_cache_pricing.py new file mode 100644 index 00000000000..994684a6005 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_prompt_cache_pricing.py @@ -0,0 +1,105 @@ +from typing import Final + +import pytest + +import litellm +from litellm.proxy.common_utils.prompt_cache_pricing import price_cache_tokens +from litellm.types.management_endpoints.prompt_cache_prediction import CacheTokenBuckets + + +@pytest.mark.parametrize( + ("model", "expected"), + [("anthropic/claude-sonnet-4-5", 1.26), ("anthropic/claude-sonnet-4-6", 0.63)], +) +def test_prices_all_cache_buckets_at_total_context_tier(model: str, expected: float) -> None: + tokens: Final = CacheTokenBuckets( + uncached_input_tokens=100_000, + cache_read_input_tokens=50_000, + cache_creation_5m_input_tokens=20_000, + cache_creation_1h_input_tokens=40_000, + ) + assert price_cache_tokens(model, "unconfigured-deployment", tokens) == pytest.approx(expected) + + +@pytest.mark.parametrize(("total", "expected"), [(200_000, 0.387), (200_001, 0.774006)]) +def test_long_context_tier_starts_above_threshold(total: int, expected: float) -> None: + tokens: Final = CacheTokenBuckets( + uncached_input_tokens=total - 100_000, + cache_creation_1h_input_tokens=10_000, + cache_read_input_tokens=90_000, + ) + actual: Final = price_cache_tokens("anthropic/claude-sonnet-4-5", "unconfigured-deployment", tokens) + assert actual == pytest.approx(expected) + + +def test_deployment_tariff_wins_without_proxy_discounts_or_margins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "model_cost", litellm.model_cost.copy()) + litellm.Router( + model_list=[ + { + "model_name": "cache-pricing-test", + "litellm_params": { + "model": "anthropic/claude-sonnet-4-6", + "api_key": "test-only", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00002, + "cache_read_input_token_cost": 0.000001, + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + }, + "model_info": {"id": "cache-pricing-test-a"}, + } + ] + ) + monkeypatch.setattr(litellm, "cost_discount_config", {"anthropic": 0.5}) + monkeypatch.setattr(litellm, "cost_margin_config", {"global": {"percentage": 0.3, "fixed_amount": 1.0}}) + tokens: Final = CacheTokenBuckets( + uncached_input_tokens=3_000, + cache_read_input_tokens=4_000, + cache_creation_5m_input_tokens=1_000, + cache_creation_1h_input_tokens=2_000, + ) + assert price_cache_tokens("anthropic/claude-sonnet-4-6", "cache-pricing-test-a", tokens) == pytest.approx(0.0865) + + +@pytest.mark.parametrize("rate", [None, -1.0, float("nan"), float("inf"), "0.00001", True]) +def test_unknown_for_absent_or_invalid_active_cache_rate(monkeypatch: pytest.MonkeyPatch, rate: object) -> None: + monkeypatch.setitem( + litellm.model_cost, + "cache-pricing-invalid", + { + "litellm_provider": "anthropic", + "mode": "chat", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00002, + "cache_creation_input_token_cost_above_1hr": rate, + }, + ) + tokens: Final = CacheTokenBuckets(cache_creation_1h_input_tokens=4_000) + assert price_cache_tokens("anthropic/claude-sonnet-4-6", "cache-pricing-invalid", tokens) is None + + +def test_missing_input_price_is_unknown_even_when_get_model_info_defaults_to_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(litellm.model_cost, "cache-pricing-missing", {"litellm_provider": "anthropic", "mode": "chat"}) + tokens: Final = CacheTokenBuckets(uncached_input_tokens=4_000) + assert price_cache_tokens("cache-pricing-missing", "unconfigured-deployment", tokens) is None + + +def test_explicit_free_pricing_is_not_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + litellm.model_cost, + "cache-pricing-free", + { + "litellm_provider": "anthropic", + "mode": "chat", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0, + "cache_creation_input_token_cost_above_1hr": 0.0, + }, + ) + tokens: Final = CacheTokenBuckets(uncached_input_tokens=100, cache_read_input_tokens=5_000) + assert price_cache_tokens("anthropic/claude-sonnet-4-6", "cache-pricing-free", tokens) == 0.0 diff --git a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py index 10c0bb88a82..48f980086fd 100644 --- a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py @@ -6284,3 +6284,248 @@ async def test_an_open_circuit_breaker_reads_the_sliding_window_locally_without_ assert isinstance(values, list) assert [record.getMessage() for record in caplog.records if record.levelno >= logging.WARNING] == [] assert any("circuit breaker is open" in record.getMessage() for record in caplog.records) + + +@pytest.mark.parametrize( + "limits, request_data, counter_scope", + [ + ({"rpm_limit": 1}, {}, "api_key"), + ({"user_id": "u", "user_rpm_limit": 1}, {}, "user"), + ({"team_id": "t", "team_rpm_limit": 1}, {}, "team"), + ( + {"team_id": "t", "user_id": "u", "team_member_rpm_limit": 1}, + {}, + "team_member", + ), + ({"end_user_id": "e", "end_user_rpm_limit": 1}, {}, "end_user"), + ( + {"metadata": {"model_rpm_limit": {"test-model": 1}}}, + {}, + "model_per_key", + ), + ( + {"metadata": {"tag_rpm_limit": {"test-tag": 1}}}, + {"metadata": {"tags": ["test-tag"]}}, + "tag_per_key", + ), + ( + { + "team_id": "t", + "metadata": {"model_rpm_limit": {"test-model": 100}}, + "team_metadata": {"model_rpm_limit": {"test-model": 1}}, + }, + {}, + "model_per_team", + ), + ( + {"project_id": "p", "project_metadata": {"model_rpm_limit": {"test-model": 1}}}, + {}, + "model_per_project", + ), + ({"org_id": "o", "organization_rpm_limit": 1}, {}, "organization"), + ( + {"org_id": "o", "organization_metadata": {"model_rpm_limit": {"test-model": 1}}}, + {}, + "model_per_organization", + ), + ], +) +@pytest.mark.parametrize("request_kind", ["count", "generation"]) +@pytest.mark.asyncio +async def test_request_capacity_enforces_shared_rpm_scopes( + limits, request_data, counter_scope, request_kind +): + cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + auth = UserAPIKeyAuth(api_key=hash_token("sk-count-rpm"), **limits) + async def request(): + if request_kind == "generation": + await handler.async_pre_call_hook( + user_api_key_dict=auth, + cache=cache, + data={**request_data, "model": "test-model"}, + call_type="acompletion", + ) + return + async with handler.request_capacity(auth, "test-model", request_data=request_data): + pass + + await request() + with pytest.raises(HTTPException) as exc: + await request() + assert exc.value.status_code == 429 + assert counter_scope in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_request_capacity_keeps_dynamic_rpm_policy(monkeypatch): + import litellm.proxy.proxy_server as proxy_server + + router = Router(model_list=[{ + "model_name": "test-model", + "litellm_params": {"model": "openai/gpt-test", "api_key": "test-key"}, + "model_info": {"id": "test-deployment"}, + }]) + monkeypatch.setattr(proxy_server, "llm_router", router) + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(DualCache())) + auth = UserAPIKeyAuth( + api_key=hash_token("sk-count-dynamic"), + rpm_limit=1, + metadata={"rpm_limit_type": "dynamic"}, + ) + for _ in range(2): + async with handler.request_capacity(auth, "test-model"): + pass + router.cache.set_cache("test-deployment:fails", 100, ttl=60, local_only=True) + async with handler.request_capacity(auth, "test-model"): + pass + with pytest.raises(HTTPException) as exc: + async with handler.request_capacity(auth, "test-model"): + pytest.fail("dynamic RPM must enforce after deployment failures") + assert exc.value.status_code == 429 + + +@pytest.mark.asyncio +async def test_request_capacity_skips_tokens_and_preserves_parent_stash(): + cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + auth = UserAPIKeyAuth( + api_key=hash_token("sk-count-tpm"), + rpm_limit=5, + tpm_limit=1, + max_parallel_requests=1, + project_id="p", + project_metadata={ + "model_tpm_limit": {"test-model": 1}, + "model_itpm_limit": {"test-model": 1}, + "model_otpm_limit": {"test-model": 1}, + }, + ) + token_scopes = ( + ("api_key", auth.api_key), + ("model_per_project", "p:test-model"), + ("model_per_project_itpm", "p:test-model"), + ("model_per_project_otpm", "p:test-model"), + ) + for scope, value in token_scopes: + token_key = handler.create_rate_limit_keys(scope, value, "tokens") + await cache.async_set_cache(token_key, 100, ttl=60) + await cache.async_set_cache(f"{{{scope}:{value}}}:window", int(time.time()), ttl=60) + parent = get_or_create_request_stash() + parent.reserved_tokens = 123 + parent.parallel_slot = ParallelSlotAcquisition(slot_id="parent", counter_keys=["parent-gauge"]) + for _ in range(2): + async with handler.request_capacity(auth, "test-model"): + assert get_request_stash() is parent + assert parent.parallel_slot["slot_id"] == "parent" + assert parent.reserved_tokens == 123 + for scope, value in token_scopes: + assert await cache.async_get_cache(handler.create_rate_limit_keys(scope, value, "tokens")) == 100 + + +@pytest.mark.parametrize("exit_mode", ["success", "failure", "cancel"]) +@pytest.mark.asyncio +async def test_request_capacity_releases_exact_parallel_slot(exit_mode): + cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + auth = UserAPIKeyAuth(api_key=hash_token("sk-count-parallel"), max_parallel_requests=1) + entered = asyncio.Event() + finish = asyncio.Event() + + async def provider(): + async with handler.request_capacity(auth, "test-model"): + entered.set() + await finish.wait() + if exit_mode == "failure": + raise RuntimeError("provider failed") + + task = asyncio.create_task(provider()) + await asyncio.wait_for(entered.wait(), timeout=2) + try: + for _ in range(2): + with pytest.raises(HTTPException) as exc: + async with handler.request_capacity(auth, "test-model"): + pytest.fail("rejected request freed the occupied slot") + assert exc.value.status_code == 429 + finally: + if exit_mode == "cancel": + task.cancel() + else: + finish.set() + if exit_mode == "success": + await task + else: + with pytest.raises(asyncio.CancelledError if exit_mode == "cancel" else RuntimeError): + await task + async with handler.request_capacity(auth, "test-model"): + pass + + +class _DelayedCapacityUsageCache: + def __init__(self): + self.delegate = InternalUsageCache(DualCache()) + self.dual_cache = self.delegate.dual_cache + self.acquired = asyncio.Event() + self.finish_admission = asyncio.Event() + self.releasing = asyncio.Event() + self.finish_release = asyncio.Event() + + async def async_get_cache(self, *args, **kwargs): + return await self.delegate.async_get_cache(*args, **kwargs) + + async def async_batch_get_cache(self, *args, **kwargs): + return await self.delegate.async_batch_get_cache(*args, **kwargs) + + async def async_set_cache(self, key, value, **kwargs): + await self.delegate.async_set_cache(key=key, value=value, **kwargs) + if not key.endswith(":max_parallel_requests"): + return + if value: + self.acquired.set() + await self.finish_admission.wait() + else: + self.releasing.set() + await self.finish_release.wait() + + +@pytest.mark.asyncio +async def test_request_capacity_finishes_admission_and_release_despite_repeated_cancel(): + cache = _DelayedCapacityUsageCache() + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache) + auth = UserAPIKeyAuth(api_key=hash_token("sk-count-cancel-admission"), max_parallel_requests=1) + + async def provider(): + async with handler.request_capacity(auth, "test-model"): + pytest.fail("cancelled admission entered provider body") + + task = asyncio.create_task(provider()) + await asyncio.wait_for(cache.acquired.wait(), timeout=2) + task.cancel() + await asyncio.sleep(0) + cache.finish_admission.set() + await asyncio.wait_for(cache.releasing.wait(), timeout=2) + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + cache.finish_release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=2) + async with handler.request_capacity(auth, "test-model"): + pass + + +@pytest.mark.asyncio +async def test_request_capacity_rejection_keeps_existing_redis_mirror(): + cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + auth = UserAPIKeyAuth(api_key=hash_token("sk-count-mirror"), max_parallel_requests=1) + counter_key = handler.create_rate_limit_keys("api_key", auth.api_key, "max_parallel_requests") + await cache.async_set_cache(counter_key, 1, ttl=60, local_only=True) + for _ in range(2): + with pytest.raises(HTTPException) as exc: + async with handler.request_capacity(auth, "test-model"): + pytest.fail("rejection released another request's mirrored slot") + assert exc.value.status_code == 429 + assert await cache.async_get_cache(counter_key, local_only=True) == 1 diff --git a/tests/test_litellm/proxy/hooks/test_prompt_cache_observer.py b/tests/test_litellm/proxy/hooks/test_prompt_cache_observer.py new file mode 100644 index 00000000000..82af3e9a6ef --- /dev/null +++ b/tests/test_litellm/proxy/hooks/test_prompt_cache_observer.py @@ -0,0 +1,300 @@ +import asyncio +import json +import time +from datetime import datetime + +import httpx +import pytest + +import litellm +from litellm.caching.dual_cache import DualCache +from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.llms.anthropic.prompt_cache_prediction import cache_scope, parse_prompt +from litellm.proxy.hooks.prompt_cache_prediction import ( + PromptCacheObserver, + lookup, +) +from litellm.proxy.utils import InternalUsageCache +from litellm.types.utils import ModelResponse + +MODEL = "claude-sonnet-5" +CALLER = "a" * 64 +DEPLOYMENT = "native-deployment" +KEY = "test-provider-key" + + +def body(ttl="5m", texts=("private cache prefix",)): + return { + "model": MODEL, + "max_tokens": 2, + "system": "private system instructions", + "tools": [{"name": "lookup", "input_schema": {"type": "object"}}], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": text, **( + {"cache_control": {"type": "ephemeral", "ttl": ttl}} + if index == len(texts) - 1 else {} + )} + for index, text in enumerate(texts) + ]}], + } + + +def usage(ttl="5m", read=100, write=200): + return { + "input_tokens": 11, + "output_tokens": 2, + "cache_read_input_tokens": read, + "cache_creation_input_tokens": write, + "cache_creation": { + "ephemeral_5m_input_tokens": write if ttl == "5m" else 0, + "ephemeral_1h_input_tokens": write if ttl == "1h" else 0, + }, + } + + +def event(request_body, started=1000.0, headers=None, **overrides): + request = httpx.Request( + "POST", "https://api.anthropic.com/v1/messages", json=request_body, + headers={"x-api-key": KEY, "anthropic-version": "2023-06-01", **(headers or {})}, + ) + return { + "call_type": "anthropic_messages", + "custom_llm_provider": "anthropic", + "cache_hit": False, + "httpx_response": httpx.Response(200, request=request), + "first_api_call_start_time": datetime.fromtimestamp(started), + "standard_logging_object": { + "status": "success", "model_id": DEPLOYMENT, + "metadata": {"user_api_key_hash": CALLER}, + }, + **overrides, + } + + +async def observe(cache, request_body=None, native_usage=None, now=1010.0, **overrides): + observer = PromptCacheObserver(InternalUsageCache(dual_cache=cache), clock=lambda: now) + response = ModelResponse( + model=MODEL, + usage=AnthropicConfig().calculate_usage(native_usage or usage(), reasoning_content=None), + ) + await observer.async_log_success_event( + event(request_body or body(), **overrides), response, + datetime.fromtimestamp(now), datetime.fromtimestamp(now), + ) + + +def scope(**overrides): + return cache_scope(**{ + "caller_key_hash": CALLER, "deployment_id": DEPLOYMENT, + "provider_key": KEY, "model": MODEL, **overrides, + }) + + +@pytest.mark.parametrize("ttl,expires", [("5m", 1300), ("1h", 4600)]) +@pytest.mark.asyncio +async def test_observed_cache_count_and_request_start_expiry_survive_as_stale(ttl, expires): + cache = DualCache() + request_body = body(ttl=ttl) + await observe(cache, request_body, usage(ttl=ttl)) + prefix = parse_prompt(request_body) + observed = await lookup(cache, scope(), prefix, now=1200) + assert observed.cached_tokens == 300 + assert observed.observed_at == 1010 + assert observed.expires_at == expires + assert await lookup(cache, scope(), prefix, now=expires) == observed + saved = json.dumps(cache.in_memory_cache.cache_dict) + assert "private cache prefix" not in saved + assert "private system instructions" not in saved + assert KEY not in saved + assert CALLER not in saved + + +@pytest.mark.parametrize("changed", [ + {"caller_key_hash": "b" * 64}, {"deployment_id": "other"}, + {"provider_key": "rotated"}, {"model": "claude-opus-5"}, + {"anthropic_version": "different"}, +]) +@pytest.mark.asyncio +async def test_cache_evidence_is_isolated_by_every_scope_dimension(changed): + cache = DualCache() + await observe(cache) + assert await lookup(cache, scope(**changed), parse_prompt(body()), now=1010) is None + + +@pytest.mark.asyncio +async def test_append_only_prefix_finds_prior_evidence_but_edit_or_context_change_does_not(): + cache = DualCache() + await observe(cache) + extended = parse_prompt(body(texts=("private cache prefix", "new turn"))) + prior = await lookup(cache, scope(), extended, now=1010) + assert prior.cached_tokens == 300 + assert prior.fingerprint != extended.fingerprint + for changed in ( + body(texts=("edited prefix", "new turn")), + {**body(), "system": "different system"}, + {**body(), "tools": [{"name": "other", "input_schema": {"type": "object"}}]}, + body(ttl="1h"), + ): + assert await lookup(cache, scope(), parse_prompt(changed), now=1010) is None + outside_lookback = parse_prompt(body(texts=("private cache prefix", *[str(i) for i in range(20)]))) + assert await lookup(cache, scope(), outside_lookback, now=1010) is None + + +@pytest.mark.parametrize("change", [ + {"thinking": {"type": "enabled", "budget_tokens": 1024}}, + {"tool_choice": {"type": "auto"}}, + {"cache_control": {"type": "ephemeral"}}, + {"tools": [{"type": "web_search_20250305", "name": "web_search"}]}, + {"system": [{"type": "text", "text": "system", "cache_control": {"type": "ephemeral"}}]}, + {"messages": [{"role": "user", "content": [{"type": "image", "source": {}}]}]}, + {"messages": [{"role": "user", "content": "no breakpoint"}]}, +]) +def test_unsupported_or_ambiguous_shapes_have_no_cache_identity(change): + assert parse_prompt({**body(), **change}) is None + duplicate = body() + duplicate["messages"][0]["content"].append(duplicate["messages"][0]["content"][0]) + assert parse_prompt(duplicate) is None + + +@pytest.mark.parametrize("overrides", [ + {"cache_hit": True}, {"call_type": "completion"}, + {"custom_llm_provider": "bedrock"}, {"stream": True}, + {"headers": {"anthropic-beta": "unverified-feature"}}, + {"headers": {"x-custom-header": "unverified"}}, + {"standard_logging_object": {"status": "success", "model_id": DEPLOYMENT, "metadata": {}}}, +]) +@pytest.mark.asyncio +async def test_unverified_source_never_creates_observations(overrides): + cache = DualCache() + await observe(cache, **overrides) + assert await lookup(cache, scope(), parse_prompt(body()), now=1010) is None + + +@pytest.mark.parametrize("native_usage", [ + usage(write=0), + {**usage(), "cache_creation": None}, + {**usage(), "cache_creation": {"ephemeral_5m_input_tokens": 199, "ephemeral_1h_input_tokens": 0}}, + usage(ttl="1h"), + {**usage(), "cache_creation_input_tokens": -200}, +]) +@pytest.mark.asyncio +async def test_missing_or_contradictory_telemetry_cannot_create_observations(native_usage): + cache = DualCache() + await observe(cache, native_usage=native_usage) + assert await lookup(cache, scope(), parse_prompt(body()), now=1010) is None + + +@pytest.mark.asyncio +async def test_pure_read_refresh_requires_prior_matching_evidence(): + cache = DualCache() + await observe(cache, native_usage=usage(read=300, write=0)) + assert await lookup(cache, scope(), parse_prompt(body()), now=1010) is None + await observe(cache) + await observe(cache, native_usage=usage(read=300, write=0), started=1100, now=1110) + assert (await lookup(cache, scope(), parse_prompt(body()), now=1110)).expires_at == 1400 + + +class RecordingObserver(PromptCacheObserver): + def __init__(self, cache): + super().__init__(InternalUsageCache(dual_cache=cache)) + self.finished = asyncio.Event() + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + await super().async_log_success_event(kwargs, response_obj, start_time, end_time) + self.finished.set() + + +def native_response(): + return { + "id": "msg_prediction", "type": "message", "role": "assistant", "model": MODEL, + "content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn", + "stop_sequence": None, "usage": usage(ttl="1h"), + } + + +def stream_response(completed, provider_error=False): + response = native_response() + events = [ + {"type": "message_start", "message": {**response, "content": [], "stop_reason": None}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ok"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}}, + ] + if completed: + events.append({"type": "message_stop"}) + if provider_error: + events.append({"type": "error", "error": {"type": "overloaded_error", "message": "temporary failure"}}) + return "".join(f"event: {item['type']}\ndata: {json.dumps(item)}\n\n" for item in events) + + +class TransportChunks(httpx.AsyncByteStream): + def __init__(self, payload, chunk_size, fragment_error_only=False): + self.payload = payload.encode() + self.chunk_size = chunk_size or len(self.payload) + self.prefix_length = self.payload.index(b"event: error") if fragment_error_only else 0 + + async def __aiter__(self): + if self.prefix_length: + yield self.payload[:self.prefix_length] + for offset in range(self.prefix_length, len(self.payload), self.chunk_size): + yield self.payload[offset:offset + self.chunk_size] + + +@pytest.mark.parametrize("stream,completed,provider_error,transport", [ + (False, True, False, "whole"), + (True, True, False, "whole"), + (True, False, False, "whole"), + (True, True, True, "whole"), + (True, True, False, "fragmented"), + (True, False, False, "fragmented"), + (True, True, True, "fragmented"), + (True, True, True, "fragmented_error"), + (True, True, False, "unterminated"), +]) +@pytest.mark.asyncio +async def test_native_production_callback_records_only_completed_wire_requests(stream, completed, provider_error, transport): + cache = DualCache() + observer = RecordingObserver(cache) + litellm.logging_callback_manager.add_litellm_callback(observer) + + def provider(request): + if stream: + payload = stream_response(completed, provider_error) + if transport == "unterminated": + payload = payload.removesuffix("\n\n") + return httpx.Response( + 200, request=request, headers={"content-type": "text/event-stream"}, + stream=TransportChunks( + payload, 1 if transport.startswith("fragmented") else None, + fragment_error_only=transport == "fragmented_error", + ), + ) + return httpx.Response(200, request=request, json=native_response()) + + client = AsyncHTTPHandler() + await client.client.aclose() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider)) + try: + request_body = body(ttl="1h") + before = time.time() + result = await litellm.anthropic_messages( + **{**request_body, "model": f"anthropic/{MODEL}"}, + api_key=KEY, client=client, stream=stream, model_info={"id": DEPLOYMENT}, + litellm_metadata={"user_api_key_hash": CALLER, "model_info": {"id": DEPLOYMENT}}, + ) + if stream: + async for _ in result: + pass + await asyncio.wait_for(observer.finished.wait(), timeout=5) + found = await lookup(cache, scope(), parse_prompt(request_body)) + if completed and not provider_error and transport != "unterminated": + assert found is not None + assert found.cached_tokens == 300 + assert before + 3600 <= found.expires_at <= time.time() + 3600 + else: + assert found is None + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(observer) + await client.client.aclose() diff --git a/tests/test_litellm/proxy/management_endpoints/test_prompt_cache_prediction.py b/tests/test_litellm/proxy/management_endpoints/test_prompt_cache_prediction.py new file mode 100644 index 00000000000..0ec277be884 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_prompt_cache_prediction.py @@ -0,0 +1,698 @@ +import asyncio +import time +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import Final, Literal + +import httpx +import pytest +from fastapi import FastAPI, Request +from pydantic import JsonValue + +import litellm +from litellm.caching.caching import DualCache +from litellm.integrations.custom_logger import CustomLogger +from litellm.proxy._types import ProxyException, UserAPIKeyAuth +from litellm.proxy.common_utils.http_parsing_utils import _read_request_body, _safe_set_request_parsed_body +from litellm.proxy.hooks.parallel_request_limiter_v3 import _PROXY_MaxParallelRequestsHandler_v3 +from litellm.llms.anthropic.prompt_cache_prediction import PromptPrefix, cache_scope, parse_prompt +from litellm.proxy.hooks.prompt_cache_prediction import ( + CacheObservation, + _cache_key, +) +from litellm.proxy.management_endpoints import prompt_cache_prediction as endpoint +from litellm.proxy.utils import InternalUsageCache +from litellm.types.management_endpoints.prompt_cache_prediction import CachePredictionResponse +from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo + + +_PROVIDER_KEY: Final = "cache-prediction-test-provider-key" +_CALLER: Final = "cache-prediction-test-caller-hash" + + +@pytest.fixture(autouse=True) +def anthropic_endpoint_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + + +def _body(ttl: str = "5m", *, extended: bool = False) -> dict[str, JsonValue]: + blocks: Final[list[JsonValue]] = [ + {"type": "text", "text": "Stable context"}, + *([{"type": "text", "text": "Appended context"}] if extended else []), + ] + return { + "max_tokens": 10, + "system": "Follow the project conventions", + "messages": [ + { + "role": "user", + "content": [ + *blocks[:-1], + {**blocks[-1], "cache_control": {"type": "ephemeral", "ttl": ttl}}, + {"type": "text", "text": "Follow-up question"}, + ], + } + ], + } + + +def _prefix(body: Mapping[str, JsonValue]) -> PromptPrefix: + prefix: Final = parse_prompt(body) + assert prefix is not None + return prefix + + +def _deployment( + deployment_id: str = "sonnet", + model: str = "claude-sonnet-5", + *, + team_id: str | None = None, + api_base: str | None = None, +) -> Deployment: + return Deployment( + model_name=deployment_id, + litellm_params=LiteLLM_Params(model=f"anthropic/{model}", api_key=_PROVIDER_KEY, api_base=api_base), + model_info=ModelInfo(id=deployment_id, team_id=team_id), + ) + + +@dataclass(frozen=True) +class Counts: + total: int | None = 6_000 + prefix: int | None = 5_000 + + async def __call__(self, model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + assert api_key == _PROVIDER_KEY + assert model.startswith("claude-") + return self.total if "max_tokens" in body else self.prefix + + +async def _observe( + cache: DualCache, + body: Mapping[str, JsonValue], + *, + deployment_id: str = "sonnet", + model: str = "claude-sonnet-5", + cached_tokens: int = 5_000, + expired: bool = False, + caller: str = _CALLER, +) -> None: + prefix: Final = _prefix(body) + now: Final = time.time() + observation: Final = CacheObservation( + fingerprint=prefix.fingerprint, + cached_tokens=cached_tokens, + observed_at=now - 400 if expired else now - 10, + expires_at=now - 100 if expired else now + 290, + ) + scope: Final = cache_scope(caller, deployment_id, _PROVIDER_KEY, model) + await cache.async_set_cache(_cache_key(scope, prefix.fingerprint), observation.model_dump_json(), ttl=3_600) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("ttl", "cold_cost"), [("5m", 0.0145), ("1h", 0.022)]) +async def test_unobserved_cache_prices_cold_and_warm_bounds(ttl: str, cold_cost: float) -> None: + body: Final = _body(ttl) + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, DualCache(), Counts()) + + assert arm.cache_state == "unknown" + assert arm.reason == "no_compatible_observation" + assert arm.evidence is None + assert arm.estimate is not None and arm.cold is not None and arm.warm is not None + assert arm.estimate.input_cost == pytest.approx(cold_cost) + assert arm.cold.input_cost == pytest.approx(cold_cost) + assert arm.warm.input_cost == pytest.approx(0.003) + assert arm.cold.tokens.uncached_input_tokens == 1_000 + assert arm.cold.tokens.cache_read_input_tokens == 0 + assert arm.cold.tokens.cache_creation_5m_input_tokens == (5_000 if ttl == "5m" else 0) + assert arm.cold.tokens.cache_creation_1h_input_tokens == (5_000 if ttl == "1h" else 0) + assert arm.warm.tokens.cache_read_input_tokens == 5_000 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cached_tokens", "warm_cost", "cold_cost"), [(5_400, 0.00228, 0.0147), (4_600, 0.00372, 0.0143)] +) +@pytest.mark.parametrize("expired", [False, True]) +async def test_exact_prefix_conserves_total_with_observed_count_in_all_scenarios( + cached_tokens: int, warm_cost: float, cold_cost: float, expired: bool +) -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body, cached_tokens=cached_tokens, expired=expired) + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, cache, Counts()) + + assert arm.cache_state == ("stale" if expired else "warm") + assert arm.evidence is not None + assert arm.estimate is not None and arm.warm is not None and arm.cold is not None + assert arm.warm.tokens.cache_read_input_tokens == cached_tokens + assert arm.warm.tokens.cache_creation_5m_input_tokens == 0 + assert arm.cold.tokens.cache_creation_5m_input_tokens == cached_tokens + assert arm.cold.tokens.cache_read_input_tokens == 0 + for scenario in (arm.estimate, arm.cold, arm.warm): + assert scenario.tokens.total_tokens == 6_000 + assert scenario.tokens.uncached_input_tokens == 6_000 - cached_tokens + assert arm.warm.input_cost == pytest.approx(warm_cost) + assert arm.cold.input_cost == pytest.approx(cold_cost) + assert arm.estimate.input_cost == pytest.approx(cold_cost if expired else warm_cost) + + +@pytest.mark.asyncio +async def test_observed_prefix_larger_than_full_request_returns_unknown() -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body, cached_tokens=6_001) + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, cache, Counts()) + + assert arm.cache_state == "unknown" + assert arm.reason == "inconsistent_prefix_token_count" + assert arm.estimate is None and arm.cold is None and arm.warm is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("ttl", "expected"), [("5m", 0.0053), ("1h", 0.0068)]) +async def test_append_only_prefix_reads_old_tokens_and_writes_extension(ttl: str, expected: float) -> None: + cache: Final = DualCache() + await _observe(cache, _body(ttl), cached_tokens=4_000) + body: Final = _body(ttl, extended=True) + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, cache, Counts()) + + assert arm.cache_state == "partial" + assert arm.estimate is not None + assert arm.estimate.tokens.cache_read_input_tokens == 4_000 + assert arm.estimate.tokens.cache_creation_5m_input_tokens == (1_000 if ttl == "5m" else 0) + assert arm.estimate.tokens.cache_creation_1h_input_tokens == (1_000 if ttl == "1h" else 0) + assert arm.estimate.input_cost == pytest.approx(expected) + + +@pytest.mark.asyncio +async def test_expired_observation_estimates_a_cold_rebuild() -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body, expired=True) + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, cache, Counts()) + + assert arm.cache_state == "stale" + assert arm.reason == "observation_expired" + assert arm.evidence is not None and arm.evidence.expires_at < time.time() + assert arm.estimate is not None and arm.cold is not None + assert arm.estimate.tokens.cache_read_input_tokens == 0 + assert arm.estimate.tokens.cache_creation_5m_input_tokens == 5_000 + assert arm.estimate.input_cost == arm.cold.input_cost + + +@pytest.mark.asyncio +async def test_below_model_minimum_prices_all_input_as_uncached() -> None: + body: Final = _body() + arm: Final = await endpoint.predict_arm( + _deployment(), body, _prefix(body), _CALLER, DualCache(), Counts(total=1_500, prefix=1_000) + ) + + assert arm.cache_state == "disabled" + assert arm.reason == "below_cache_minimum" + assert arm.estimate is not None + assert arm.estimate.tokens.uncached_input_tokens == 1_500 + assert arm.estimate.tokens.cache_read_input_tokens == 0 + assert arm.estimate.tokens.cache_creation_5m_input_tokens == 0 + assert arm.estimate.input_cost == pytest.approx(0.003) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("counts", [Counts(total=None), Counts(prefix=None), Counts(total=4_000)]) +async def test_unavailable_or_inconsistent_token_counts_return_null_estimates(counts: Counts) -> None: + body: Final = _body() + arm: Final = await endpoint.predict_arm(_deployment(), body, _prefix(body), _CALLER, DualCache(), counts) + + assert arm.cache_state == "unknown" + assert arm.reason == "token_count_unavailable" + assert arm.estimate is None and arm.cold is None and arm.warm is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("counts", [Counts(), Counts(total=1_500, prefix=1_000)]) +async def test_missing_prices_return_unknown_and_null_estimates( + monkeypatch: pytest.MonkeyPatch, counts: Counts +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "claude-cache-unpriced-5", + {"litellm_provider": "anthropic", "mode": "chat"}, + ) + body: Final = _body() + arm: Final = await endpoint.predict_arm( + _deployment("cache-prediction-unpriced", "claude-cache-unpriced-5"), + body, + _prefix(body), + _CALLER, + DualCache(), + counts, + ) + + assert arm.cache_state == "unknown" + assert arm.reason == "pricing_unavailable" + assert arm.estimate is None and arm.cold is None and arm.warm is None + + +@pytest.mark.asyncio +async def test_custom_api_base_from_environment_returns_unknown_before_counting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ANTHROPIC_API_BASE", "https://custom.invalid") + body: Final = _body() + arm: Final = await endpoint.predict_arm( + _deployment(), body, _prefix(body), _CALLER, DualCache(), _unexpected_count + ) + + assert arm.cache_state == "unknown" + assert arm.reason == "unsupported_provider_endpoint" + assert arm.estimate is None and arm.cold is None and arm.warm is None + + +@pytest.mark.asyncio +async def test_explicit_official_api_base_overrides_custom_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_BASE", "https://custom.invalid") + body: Final = _body() + arm: Final = await endpoint.predict_arm( + _deployment(api_base="https://api.anthropic.com"), body, _prefix(body), _CALLER, DualCache(), Counts() + ) + + assert arm.cache_state == "unknown" + assert arm.reason == "no_compatible_observation" + assert arm.estimate is not None + assert arm.estimate.input_cost == pytest.approx(0.0145) + + +@dataclass(frozen=True) +class _ProxyLogging: + internal_usage_cache: InternalUsageCache + parallel_limiter: CustomLogger | None + + def get_proxy_hook(self, hook: str) -> CustomLogger | None: + return self.parallel_limiter if hook == "parallel_request_limiter" else None + + +def _app( + monkeypatch: pytest.MonkeyPatch, + cache: DualCache, + *, + caller: UserAPIKeyAuth | None = None, + current_team: str | None = None, + candidate_team: str | None = None, + counts: endpoint.TokenCounter = Counts(), + limiter: CustomLogger | Literal["default"] | None = "default", +) -> FastAPI: + import litellm.proxy.proxy_server as proxy_server + + model_list: Final = [ + _deployment("opus", "claude-opus-5", team_id=current_team).model_dump(exclude_unset=True), + _deployment("sonnet", team_id=candidate_team).model_dump(exclude_unset=True), + ] + router: Final = litellm.Router(model_list=model_list) + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", model_list) + monkeypatch.setattr(endpoint, "count_prompt_tokens", counts) + app: Final = FastAPI() + app.include_router(endpoint.router) + app.add_exception_handler(ProxyException, proxy_server.openai_exception_handler) + if caller is not None: + usage_cache: Final = InternalUsageCache(cache) + configured_limiter: Final = ( + _PROXY_MaxParallelRequestsHandler_v3(usage_cache) if isinstance(limiter, str) else limiter + ) + monkeypatch.setattr(proxy_server, "proxy_logging_obj", _ProxyLogging(usage_cache, configured_limiter)) + app.dependency_overrides[endpoint.user_api_key_auth] = lambda: caller + return app + + +async def _post( + app: FastAPI, + body: Mapping[str, JsonValue], + *, + current_deployment_id: str = "opus", + candidate_deployment_id: str = "sonnet", +) -> httpx.Response: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + return await client.post( + "/cost/predict-cache", + json={ + "current_deployment_id": current_deployment_id, + "candidate_deployment_id": candidate_deployment_id, + "request": body, + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("warm_deployment", "warm_model", "expected_delta", "expected_penalty"), + [("sonnet", "claude-sonnet-5", -0.03325, 0.0), ("opus", "claude-opus-5", 0.007, 0.0115)], +) +async def test_switch_delta_accounts_for_each_deployment_cache( + monkeypatch: pytest.MonkeyPatch, + warm_deployment: str, + warm_model: str, + expected_delta: float, + expected_penalty: float, +) -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body, deployment_id=warm_deployment, model=warm_model) + app: Final = _app(monkeypatch, cache, caller=UserAPIKeyAuth(api_key=_CALLER)) + response: Final = await _post(app, body) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.switch_delta == pytest.approx(expected_delta) + assert result.cache_rebuild_penalty == pytest.approx(expected_penalty) + assert result.cache_guarantee is False + assert result.pricing_basis == "input_before_discounts_and_margins" + if warm_deployment == "sonnet": + assert result.switch.cache_state == "warm" + assert result.stay.cache_state == "unknown" + else: + assert result.stay.cache_state == "warm" + assert result.switch.cache_state == "unknown" + + +@pytest.mark.asyncio +async def test_missing_caller_identity_cannot_reuse_observations(monkeypatch: pytest.MonkeyPatch) -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body) + response: Final = await _post( + _app(monkeypatch, cache, caller=UserAPIKeyAuth(api_key=None), counts=_unexpected_count), body + ) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.reason == result.switch.reason == "caller_identity_unavailable" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None + + +@pytest.mark.asyncio +async def test_unauthenticated_request_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + import litellm.proxy.proxy_server as proxy_server + + monkeypatch.setattr(proxy_server, "master_key", "cache-prediction-test-master-key") + response: Final = await _post(_app(monkeypatch, DualCache()), _body()) + assert response.status_code == 401, response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arm", ["current", "candidate"]) +@pytest.mark.parametrize("caller_team", [None, "own-team"]) +@pytest.mark.parametrize("restricted", [False, True]) +async def test_foreign_and_missing_deployments_have_identical_authenticated_responses( + monkeypatch: pytest.MonkeyPatch, arm: str, caller_team: str | None, restricted: bool +) -> None: + allowed: Final = ("sonnet",) if arm == "current" else ("opus",) + app: Final = _app( + monkeypatch, + DualCache(), + caller=UserAPIKeyAuth(api_key=_CALLER, team_id=caller_team, models=list(allowed) if restricted else []), + current_team="foreign-team" if arm == "current" else None, + candidate_team="foreign-team" if arm == "candidate" else None, + counts=_unexpected_count, + ) + foreign: Final = await _post(app, _body()) + missing: Final = await _post( + app, + _body(), + current_deployment_id="missing-deployment" if arm == "current" else "opus", + candidate_deployment_id="missing-deployment" if arm == "candidate" else "sonnet", + ) + + assert foreign.status_code == missing.status_code == 404 + assert foreign.json() == missing.json() == {"detail": "Deployment not found"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("deployment_team", [None, "own-team"]) +async def test_visible_public_and_own_team_deployments_remain_available( + monkeypatch: pytest.MonkeyPatch, deployment_team: str | None +) -> None: + app: Final = _app( + monkeypatch, + DualCache(), + caller=UserAPIKeyAuth(api_key=_CALLER, team_id="own-team"), + current_team=deployment_team, + candidate_team=deployment_team, + ) + response: Final = await _post(app, _body()) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.estimate is not None and result.switch.estimate is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arm", ["current", "candidate"]) +async def test_visible_deployment_outside_key_model_permissions_is_forbidden( + monkeypatch: pytest.MonkeyPatch, arm: str +) -> None: + allowed: Final = "sonnet" if arm == "current" else "opus" + denied: Final = "opus" if arm == "current" else "sonnet" + app: Final = _app(monkeypatch, DualCache(), caller=UserAPIKeyAuth(api_key=_CALLER, models=[allowed])) + response: Final = await _post(app, _body()) + assert response.status_code == 403, response.text + assert denied in response.text + + +@pytest.mark.asyncio +async def test_other_callers_warm_cache_is_not_prediction_evidence(monkeypatch: pytest.MonkeyPatch) -> None: + cache: Final = DualCache() + body: Final = _body() + await _observe(cache, body, caller="other-caller") + response: Final = await _post(_app(monkeypatch, cache, caller=UserAPIKeyAuth(api_key=_CALLER)), body) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.switch.cache_state == "unknown" + assert result.switch.reason == "no_compatible_observation" + assert result.switch.evidence is None + assert result.switch.estimate is not None + assert result.switch.estimate.tokens.cache_read_input_tokens == 0 + + +@pytest.mark.asyncio +async def test_count_failure_nulls_switch_comparison(monkeypatch: pytest.MonkeyPatch) -> None: + app: Final = _app( + monkeypatch, DualCache(), caller=UserAPIKeyAuth(api_key=_CALLER), counts=Counts(total=None) + ) + response: Final = await _post(app, _body()) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.reason == result.switch.reason == "token_count_unavailable" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limiter", [None, CustomLogger()]) +async def test_missing_or_unsupported_limiter_returns_unknown_before_counting( + monkeypatch: pytest.MonkeyPatch, limiter: CustomLogger | None +) -> None: + app: Final = _app( + monkeypatch, DualCache(), caller=UserAPIKeyAuth(api_key=_CALLER), counts=_unexpected_count, limiter=limiter + ) + response: Final = await _post(app, _body()) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.reason == result.switch.reason == "limiter_unavailable" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None + + +@pytest.mark.asyncio +async def test_occupied_parallel_capacity_rejects_before_provider_count(monkeypatch: pytest.MonkeyPatch) -> None: + cache: Final = DualCache() + limiter: Final = _PROXY_MaxParallelRequestsHandler_v3(InternalUsageCache(cache)) + caller: Final = UserAPIKeyAuth(api_key=_CALLER, max_parallel_requests=1) + app: Final = _app(monkeypatch, cache, caller=caller, counts=_unexpected_count, limiter=limiter) + async with limiter.request_capacity(caller, "opus"): + response: Final = await _post(app, _body()) + + assert response.status_code == 429, response.text + assert "max_parallel_requests" in response.text + recovered: Final = await _post(_app(monkeypatch, cache, caller=caller, limiter=limiter), _body()) + assert recovered.status_code == 200, recovered.text + + +@pytest.mark.asyncio +async def test_each_count_consumes_the_deployment_group_rpm_limit(monkeypatch: pytest.MonkeyPatch) -> None: + calls: Final = asyncio.Queue[str]() + + async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + calls.put_nowait(model) + return await Counts()(model, api_key, body) + + caller: Final = UserAPIKeyAuth(api_key=_CALLER, metadata={"model_rpm_limit": {"sonnet": 1}}) + app: Final = _app(monkeypatch, DualCache(), caller=caller, counts=count) + response: Final = await _post(app, _body()) + + assert response.status_code == 429, response.text + assert calls.qsize() == 3 + assert tuple(calls.get_nowait() for _ in range(3)) == ( + "claude-opus-5", "claude-opus-5", "claude-sonnet-5" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +async def test_each_count_preserves_auth_cached_request_tag_limits( + monkeypatch: pytest.MonkeyPatch, metadata_key: str +) -> None: + calls: Final = asyncio.Queue[str]() + caller: Final = UserAPIKeyAuth(api_key=_CALLER, metadata={"tag_rpm_limit": {"cache-cost": 1}}) + + async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + calls.put_nowait(model) + return await Counts()(model, api_key, body) + + async def authenticated_request(request: Request) -> UserAPIKeyAuth: + data: Final = await _read_request_body(request) + _safe_set_request_parsed_body(request, {**data, metadata_key: {"tags": ["cache-cost"]}}) + return caller + + app: Final = _app(monkeypatch, DualCache(), caller=caller, counts=count) + app.dependency_overrides[endpoint.user_api_key_auth] = authenticated_request + response: Final = await _post(app, _body()) + + assert response.status_code == 429, response.text + assert "tag_per_key" in response.text + assert calls.qsize() == 1 + assert calls.get_nowait() == "claude-opus-5" + + +@pytest.mark.asyncio +async def test_provider_counter_failure_releases_parallel_capacity(monkeypatch: pytest.MonkeyPatch) -> None: + cache: Final = DualCache() + limiter: Final = _PROXY_MaxParallelRequestsHandler_v3(InternalUsageCache(cache)) + caller: Final = UserAPIKeyAuth(api_key=_CALLER, max_parallel_requests=1) + + async def fail_count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + raise RuntimeError("provider counter failed") + + app: Final = _app(monkeypatch, cache, caller=caller, counts=fail_count, limiter=limiter) + with pytest.raises(RuntimeError, match="provider counter failed"): + await _post(app, _body()) + recovered: Final = await _post(_app(monkeypatch, cache, caller=caller, limiter=limiter), _body()) + assert recovered.status_code == 200, recovered.text + assert recovered.json()["switch"]["estimate"]["input_cost"] == pytest.approx(0.0145) + + +@pytest.mark.asyncio +async def test_cancelled_provider_counter_releases_parallel_capacity(monkeypatch: pytest.MonkeyPatch) -> None: + cache: Final = DualCache() + limiter: Final = _PROXY_MaxParallelRequestsHandler_v3(InternalUsageCache(cache)) + caller: Final = UserAPIKeyAuth(api_key=_CALLER, max_parallel_requests=1) + started: Final = asyncio.Event() + release: Final = asyncio.Event() + + async def wait_count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + started.set() + await release.wait() + return await Counts()(model, api_key, body) + + app: Final = _app(monkeypatch, cache, caller=caller, counts=wait_count, limiter=limiter) + pending: Final = asyncio.create_task(_post(app, _body())) + try: + await asyncio.wait_for(started.wait(), timeout=5) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + release.set() + recovered: Final = await asyncio.wait_for(_post(app, _body()), timeout=5) + assert recovered.status_code == 200, recovered.text + assert recovered.json()["switch"]["estimate"]["input_cost"] == pytest.approx(0.0145) + finally: + pending.cancel() + release.set() + await asyncio.gather(pending, return_exceptions=True) + + +async def _unexpected_count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: + pytest.fail("Unsupported prediction must return before contacting the token counter") + + +class RequestMutator(CustomLogger): + async def async_pre_call_hook( + self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: str + ) -> dict[str, object]: + return {**data, "system": "Injected policy"} + + +@pytest.fixture +def request_mutator() -> Iterator[RequestMutator]: + callback: Final = RequestMutator() + litellm.logging_callback_manager.add_litellm_callback(callback) + try: + yield callback + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(callback) + + +@pytest.mark.asyncio +async def test_request_transform_callback_returns_unknown_before_token_counting( + monkeypatch: pytest.MonkeyPatch, request_mutator: RequestMutator +) -> None: + app: Final = _app( + monkeypatch, DualCache(), caller=UserAPIKeyAuth(api_key=_CALLER), counts=_unexpected_count + ) + response: Final = await _post(app, _body()) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.cache_state == result.switch.cache_state == "unknown" + assert result.stay.reason == result.switch.reason == "unsupported_request_transform" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None + + +@pytest.mark.asyncio +async def test_key_config_returns_unknown_before_token_counting(monkeypatch: pytest.MonkeyPatch) -> None: + app: Final = _app( + monkeypatch, + DualCache(), + caller=UserAPIKeyAuth(api_key=_CALLER, config={"model_list": []}), + counts=_unexpected_count, + ) + response: Final = await _post(app, _body()) + + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.cache_state == result.switch.cache_state == "unknown" + assert result.stay.reason == result.switch.reason == "unsupported_request_transform" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None + + +@pytest.mark.parametrize("headers", [ + {"anthropic-version": "2099-01-01"}, + {"anthropic-beta": "future-feature"}, +]) +@pytest.mark.asyncio +async def test_unsupported_provider_headers_cannot_reuse_default_version_evidence( + monkeypatch: pytest.MonkeyPatch, headers: dict[str, str] +) -> None: + cache: Final = DualCache() + await _observe(cache, _body(), deployment_id="sonnet") + app: Final = _app( + monkeypatch, cache, caller=UserAPIKeyAuth(api_key=_CALLER), counts=_unexpected_count + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response: Final = await client.post( + "/cost/predict-cache", + headers=headers, + json={"current_deployment_id": "opus", "candidate_deployment_id": "sonnet", "request": _body()}, + ) + assert response.status_code == 200, response.text + result: Final = CachePredictionResponse.model_validate(response.json()) + assert result.stay.cache_state == result.switch.cache_state == "unknown" + assert result.stay.reason == result.switch.reason == "unsupported_provider_headers" + assert result.stay.estimate is None and result.switch.estimate is None + assert result.switch_delta is None and result.cache_rebuild_penalty is None diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9e844b992b2..cac1005cc2f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -3387,6 +3387,35 @@ export interface paths { patch?: never; trace?: never; }; + "/cost/predict-cache": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Predict Cache Cost + * @description Compare the next native Anthropic request on two configured deployment IDs. + * + * Estimates use provider token counting and recent successful cache telemetry for this key. + * Unknown cache state uses the cold scenario when prices/counts are available. Cache observations + * do not guarantee retention. v0 supports one message-content breakpoint, text and client tools; + * system/tool-only breakpoints, thinking, images, nondefault Anthropic versions, beta headers and + * request transforms are unknown. + * Each provider count consumes one RPM unit and holds concurrency capacity; a comparison uses + * up to four counts. The legacy rate limiter returns unknown without contacting the provider. + * This endpoint does not generate tokens, prewarm caches, choose a model or alter routing. + */ + post: operations["predict_cache_cost_cost_predict_cache_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/credentials": { parameters: { query?: never; @@ -24618,6 +24647,31 @@ export interface components { /** Failed Requests */ failed_requests: number; }; + /** CacheCostScenario */ + CacheCostScenario: { + /** Input Cost */ + input_cost: number; + tokens: components["schemas"]["CacheTokenBuckets"]; + }; + /** CacheEvidence */ + CacheEvidence: { + /** + * Confidence + * @default observed + * @constant + */ + confidence: "observed"; + /** Expires At */ + expires_at: number; + /** Observed At */ + observed_at: number; + /** + * Source + * @default provider_usage + * @constant + */ + source: "provider_usage"; + }; /** CachePingResponse */ CachePingResponse: { /** Cache Type */ @@ -24635,6 +24689,59 @@ export interface components { /** Status */ status: string; }; + /** CachePredictionArm */ + CachePredictionArm: { + /** + * Cache State + * @default unknown + * @enum {string} + */ + cache_state: "warm" | "partial" | "stale" | "unknown" | "disabled"; + cold?: components["schemas"]["CacheCostScenario"] | null; + /** Deployment Id */ + deployment_id: string; + estimate?: components["schemas"]["CacheCostScenario"] | null; + evidence?: components["schemas"]["CacheEvidence"] | null; + /** Model */ + model?: string | null; + /** Reason */ + reason?: string | null; + /** Token Count Source */ + token_count_source?: "anthropic_count_tokens" | null; + warm?: components["schemas"]["CacheCostScenario"] | null; + }; + /** CachePredictionRequest */ + CachePredictionRequest: { + /** Candidate Deployment Id */ + candidate_deployment_id: string; + /** Current Deployment Id */ + current_deployment_id: string; + /** Request */ + request: { + [key: string]: components["schemas"]["JsonValue"]; + }; + }; + /** CachePredictionResponse */ + CachePredictionResponse: { + /** + * Cache Guarantee + * @default false + * @constant + */ + cache_guarantee: false; + /** Cache Rebuild Penalty */ + cache_rebuild_penalty: number | null; + /** + * Pricing Basis + * @default input_before_discounts_and_margins + * @constant + */ + pricing_basis: "input_before_discounts_and_margins"; + stay: components["schemas"]["CachePredictionArm"]; + switch: components["schemas"]["CachePredictionArm"]; + /** Switch Delta */ + switch_delta: number | null; + }; /** CacheSettingsField */ CacheSettingsField: { /** Field Default */ @@ -24716,6 +24823,29 @@ export interface components { */ status: string; }; + /** CacheTokenBuckets */ + CacheTokenBuckets: { + /** + * Cache Creation 1H Input Tokens + * @default 0 + */ + cache_creation_1h_input_tokens: number; + /** + * Cache Creation 5M Input Tokens + * @default 0 + */ + cache_creation_5m_input_tokens: number; + /** + * Cache Read Input Tokens + * @default 0 + */ + cache_read_input_tokens: number; + /** + * Uncached Input Tokens + * @default 0 + */ + uncached_input_tokens: number; + }; /** * CallTypes * @enum {string} @@ -28332,6 +28462,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -45167,6 +45298,39 @@ export interface operations { }; }; }; + predict_cache_cost_cost_predict_cache_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CachePredictionRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CachePredictionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_credentials_credentials_get: { parameters: { query?: never; From 8577d63ff5212173aa5b30bdfe8118b666c74179 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 00:14:49 +0000 Subject: [PATCH 019/196] fix(proxy): forward provider request id headers on mapped error responses Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/common_request_processing.py | 10 +++--- .../proxy/test_common_request_processing.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index e6ed60ba177..9b8a98ca0e7 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -3445,15 +3445,13 @@ class ProxyBaseLLMRequestProcessing: # a failed request reports no timing, matching /v1/chat/completions read_timing_from_logging_obj=False, ) - # Extract headers from exception - check both e.headers and e.response.headers headers = getattr(e, "headers", None) or {} if not headers: - # Try to get headers from e.response.headers (httpx.Response) _response: Final = attribute_of(e, "response") - if _response is not None: - _response_headers: Final = getattr(_response, "headers", None) - if _response_headers: - headers = get_response_headers(dict(_response_headers)) + _response_headers: Final = getattr(_response, "headers", None) if _response is not None else None + _provider_headers: Final = _response_headers or getattr(e, "litellm_response_headers", None) + if _provider_headers: + headers = get_response_headers(dict(_provider_headers)) headers.update(custom_headers) # Call response headers hook for failure diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index efbb5eedad4..45e436f756a 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -8386,6 +8386,41 @@ async def test_handle_llm_api_exception_forwards_provider_headers_on_http_status assert exc_info.value.headers["llm_provider-x-amzn-requestid"] == "req-passthrough-500" +@pytest.mark.asyncio +async def test_handle_llm_api_exception_forwards_litellm_response_headers_when_response_is_synthetic(): + """Exception mapping hands the proxy a mapped error whose ``response`` is a synthetic empty + ``httpx.Response`` and parks the provider's real headers on ``litellm_response_headers``. + The client must still get the provider request id, as it does on a 200. + """ + import httpx + + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + mapped = litellm.BadRequestError( + message="OpenAIException - max_tokens is too large: 999999999.", + model="gpt-4o-mini", + llm_provider="openai", + ) + mapped.litellm_response_headers = httpx.Headers({"x-request-id": "req_openai_400"}) + assert dict(mapped.response.headers) == {} + + processor = ProxyBaseLLMRequestProcessing(data={}) + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={}) + + with pytest.raises(ProxyException) as exc_info: + await processor._handle_llm_api_exception( + e=mapped, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + proxy_logging_obj=proxy_logging_obj, + ) + + assert exc_info.value.code == "400" + assert "max_tokens is too large: 999999999." in exc_info.value.message + assert exc_info.value.headers["llm_provider-x-request-id"] == "req_openai_400" + + class TestBackgroundResponseRetrievalGovernance: """LIT-7175: retrieving a background Response attaches the model's post_call policy pipelines.""" From 555e321cf170ad2d15a2932fccdea104da1c39b6 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 01:05:51 +0000 Subject: [PATCH 020/196] fix(router): record flat retry attempts and cap retries from attempted_retries Router.log_retry used to copy the failed attempt's kwargs and metadata into metadata.previous_models. Nothing downstream read those copies, but they carried client credentials into spend logs and grew the payload on every retry. Each attempt now leaves a flat record (model group, deployment id, exception type and string, attempt number), which drops RETRY_BREADCRUMB_EXCLUDED_KWARGS and the per-retry credential masking. num_retries_per_request was enforced from len(previous_models), which only looked at the metadata bucket and never exceeded four records. The sync and async client wrappers and the Rust lifecycle guard now read attempted_retries from whichever metadata bucket the call carries. Resolves LIT-7505 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 2 +- litellm/litellm_core_utils/core_helpers.py | 13 +++ litellm/router.py | 45 +++----- litellm/rust_bridge/lifecycle.py | 16 +-- litellm/types/router.py | 8 ++ litellm/utils.py | 18 +-- .../test_router_helper_utils.py | 29 +++-- .../rust_bridge/test_lifecycle.py | 30 +++++ tests/test_litellm/test_router.py | 106 +++++++++++------- tests/test_litellm/test_utils.py | 47 ++++++++ tests/test_litellm_rust/ocr/test_lifecycle.py | 2 +- 11 files changed, 209 insertions(+), 107 deletions(-) create mode 100644 tests/test_litellm/rust_bridge/test_lifecycle.py diff --git a/litellm/__init__.py b/litellm/__init__.py index ccfbf80369f..261457d6889 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -538,7 +538,7 @@ context_window_fallbacks: Optional[List] = None content_policy_fallbacks: Optional[List] = None allowed_fails: int = 3 allow_dynamic_callback_disabling: bool = True -num_retries_per_request: Optional[int] = None # for the request overall (incl. fallbacks + model retries) +num_retries_per_request: Optional[int] = None # cap on Router retries of one model group; resets per fallback hop ####### SECRET MANAGERS ##################### secret_manager_client: Optional[Any] = ( None # list of instantiated key management clients - e.g. azure kv, infisical, etc. diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index aa7d6ca1699..ecd9cdac88b 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -303,6 +303,19 @@ def get_metadata_variable_name_from_kwargs( return "litellm_metadata" if "litellm_metadata" in kwargs else "metadata" +def max_retries_per_request_hit(kwargs: Mapping[str, object], num_retries_per_request: int | None) -> bool: + """ + Whether the Router retry about to run (``attempted_retries`` >= 1 in the metadata bucket) is past the cap + """ + if num_retries_per_request is None: + return False + metadata: Final = kwargs.get(get_metadata_variable_name_from_kwargs(kwargs)) + if not isinstance(metadata, Mapping): + return False + attempted_retries: Final = metadata.get("attempted_retries") + return type(attempted_retries) is int and 0 < attempted_retries and num_retries_per_request <= attempted_retries + + def get_or_create_metadata_bucket( request_data: dict, ) -> tuple[Literal["metadata", "litellm_metadata"], dict]: diff --git a/litellm/router.py b/litellm/router.py index 8865543badd..a46ffa83b05 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -96,7 +96,6 @@ from litellm.litellm_core_utils.request_timeout_resolver import ( from litellm.litellm_core_utils.secret_redaction import redact_string from litellm.litellm_core_utils.sensitive_data_masker import ( SensitiveDataMasker, - mask_credentials_in_payload, mask_sensitive_structure, ) from litellm.litellm_core_utils.token_counter import offload_token_count @@ -242,6 +241,7 @@ from litellm.types.router import ( ModelGroupInfo, OptionalPreCallChecks, PreRoutingStrategy, + RetryAttemptRecord, RetryPolicy, RouterCacheEnum, RouterErrors, @@ -623,20 +623,6 @@ def _replay_live_router_model_cost() -> None: set_live_deployment_replay(_replay_live_router_model_cost) -# Kwargs that carry no signal about the failed attempt, so log_retry drops them from a -# breadcrumb entirely: the request payload, the proxy's snapshot of the inbound request (its body -# aliases the live request metadata, earlier breadcrumbs included, so copying it would nest every -# breadcrumb inside the next one), and the router-internal walk state. Credentials are handled -# separately by mask_credentials_in_payload, which scrubs credential-named values from whatever -# kwargs remain rather than trying to enumerate every credential-bearing key here. -RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset( - ( - "messages", - "original_function", - "attempted_targets", - "proxy_server_request", - ) -) RETRY_BREADCRUMB_LIMIT: Final = 4 @@ -8374,31 +8360,28 @@ class Router: def log_retry(self, kwargs: dict, e: Exception) -> dict: """ - When a retry or fallback happens, log the details of the just failed model call - similar to Sentry breadcrumbing + When a retry or fallback happens, record which model group, deployment and attempt just failed and why """ _metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata" request_metadata: Final[Mapping[str, object]] = kwargs[_metadata_var] - attempt_kwargs: Final = MappingProxyType( - {k: v for k, v in kwargs.items() if k != _metadata_var and k not in RETRY_BREADCRUMB_EXCLUDED_KWARGS} - ) - attempt_metadata: Final = MappingProxyType( - {k: v for k, v in request_metadata.items() if k != "previous_models"} - ) - previous_model: Final = MappingProxyType( - { - "exception_type": type(e).__name__, - "exception_string": str(e), - **attempt_kwargs, - _metadata_var: attempt_metadata, - } - ) + model_group: Final = kwargs.get("model") + model_info: Final = request_metadata.get("model_info") + deployment_id: Final = model_info.get("id") if isinstance(model_info, Mapping) else None + attempted_retries: Final = request_metadata.get("attempted_retries") + attempt_record: Final[RetryAttemptRecord] = { + "model_group": model_group if isinstance(model_group, str) else None, + "deployment_id": deployment_id if isinstance(deployment_id, str) else None, + "exception_type": type(e).__name__, + "exception_string": str(e), + "attempted_retries": attempted_retries if type(attempted_retries) is int else None, + } earlier_breadcrumbs: Final = request_metadata.get("previous_models") kept_breadcrumbs: Final[tuple[object, ...]] = ( tuple(earlier_breadcrumbs)[-(RETRY_BREADCRUMB_LIMIT - 1) :] if isinstance(earlier_breadcrumbs, (list, tuple)) else () ) - breadcrumbs: Final = (*kept_breadcrumbs, mask_credentials_in_payload(previous_model)) + breadcrumbs: Final = (*kept_breadcrumbs, attempt_record) kwargs[_metadata_var]["previous_models"] = breadcrumbs # rebind-ok: the logging object already holds this dict return kwargs diff --git a/litellm/rust_bridge/lifecycle.py b/litellm/rust_bridge/lifecycle.py index f5e0c1b0fc6..f1cc912129d 100644 --- a/litellm/rust_bridge/lifecycle.py +++ b/litellm/rust_bridge/lifecycle.py @@ -99,23 +99,13 @@ def setup( def check_limits(kwargs: Mapping[str, object]) -> None: import litellm + from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit current_cost: Final = litellm._current_cost # pyright: ignore[reportPrivateUsage] # shared SDK budget counter has no public accessor if litellm.max_budget and current_cost > litellm.max_budget: raise litellm.BudgetExceededError(current_cost=current_cost, max_budget=litellm.max_budget) - metadata: Final = kwargs.get("metadata") - if isinstance(metadata, Mapping): - typed_metadata: Final = cast( # cast-ok: runtime Mapping check establishes read-only metadata - Mapping[str, object], metadata - ) - previous: Final = typed_metadata.get("previous_models") - if ( - isinstance(previous, list) - and litellm.num_retries_per_request is not None - and len(cast(list[object], previous)) # cast-ok: runtime list check establishes the retry history - >= litellm.num_retries_per_request - ): - raise RuntimeError("Max retries per request hit!") + if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request): + raise RuntimeError("Max retries per request hit!") def finalize( diff --git a/litellm/types/router.py b/litellm/types/router.py index fc09c40fe08..fecc0e00f99 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -883,6 +883,14 @@ class RouterModelGroupAliasItem(TypedDict): hidden: bool # if 'True', don't return on `.get_model_list` +class RetryAttemptRecord(TypedDict): + model_group: ReadOnly[str | None] + deployment_id: ReadOnly[str | None] + exception_type: ReadOnly[str] + exception_string: ReadOnly[str] + attempted_retries: ReadOnly[int | None] + + VALID_LITELLM_ENVIRONMENTS = [ "development", "staging", diff --git a/litellm/utils.py b/litellm/utils.py index 394ab4b4094..98881e68986 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -81,7 +81,7 @@ from litellm.constants import ( PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO, TOOL_CHOICE_OBJECT_TOKEN_COUNT, ) -from litellm.litellm_core_utils.core_helpers import normalize_drop_params +from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit, normalize_drop_params from litellm.litellm_core_utils.fallback_generalizations import ( match_capability_generalizations, ) @@ -1509,12 +1509,8 @@ def client(original_function): call_type = original_function.__name__ if _is_async_request(kwargs): # [OPTIONAL] CHECK MAX RETRIES / REQUEST - if litellm.num_retries_per_request is not None: - # check if previous_models passed in as ['litellm_params']['metadata]['previous_models'] - previous_models = (kwargs.get("metadata") or {}).get("previous_models", None) - if previous_models is not None: - if litellm.num_retries_per_request <= len(previous_models): - raise Exception("Max retries per request hit!") + if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request): + raise Exception("Max retries per request hit!") # MODEL CALL result = original_function(*args, **kwargs) @@ -1573,12 +1569,8 @@ def client(original_function): ) # [OPTIONAL] CHECK MAX RETRIES / REQUEST - if litellm.num_retries_per_request is not None: - # check if previous_models passed in as ['litellm_params']['metadata]['previous_models'] - previous_models = (kwargs.get("metadata") or {}).get("previous_models", None) - if previous_models is not None: - if litellm.num_retries_per_request <= len(previous_models): - raise Exception("Max retries per request hit!") + if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request): + raise Exception("Max retries per request hit!") # [OPTIONAL] CHECK CACHE print_verbose( diff --git a/tests/router_unit_tests/test_router_helper_utils.py b/tests/router_unit_tests/test_router_helper_utils.py index 7dbac243d55..5b06c5fdb01 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -1,3 +1,4 @@ +import json import os import traceback from dotenv import load_dotenv @@ -628,17 +629,29 @@ def test_deployment_callback_respects_cooldown_time(model_list): assert mock_set.call_args.kwargs["time_to_cooldown"] == 0 -def test_log_retry(model_list): - """Test if the '_log_retry' function is working correctly""" - import time - +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +def test_log_retry(model_list, metadata_key): + """log_retry appends one flat record per failed attempt and copies neither the request kwargs nor + the request metadata into it""" router = Router(model_list=model_list) new_kwargs = router.log_retry( - kwargs={"metadata": {}}, - e=Exception(), + kwargs={ + "model": "gpt-3.5-turbo", + "api_key": "sk-must-not-be-recorded", + "messages": [{"role": "user", "content": "hi"}], + metadata_key: {"model_info": {"id": "deployment-1"}, "attempted_retries": 2, "user_api_key": "sk-proxy"}, + }, + e=litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-3.5-turbo"), ) - assert "metadata" in new_kwargs - assert "previous_models" in new_kwargs["metadata"] + assert json.loads(json.dumps(new_kwargs[metadata_key]["previous_models"])) == [ + { + "model_group": "gpt-3.5-turbo", + "deployment_id": "deployment-1", + "exception_type": "RateLimitError", + "exception_string": "litellm.RateLimitError: slow down", + "attempted_retries": 2, + } + ] def test_update_usage(model_list): diff --git a/tests/test_litellm/rust_bridge/test_lifecycle.py b/tests/test_litellm/rust_bridge/test_lifecycle.py new file mode 100644 index 00000000000..1f0b5591c2b --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_lifecycle.py @@ -0,0 +1,30 @@ +from typing import Final + +import pytest + +import litellm +from litellm.rust_bridge.lifecycle import check_limits + + +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +@pytest.mark.parametrize( + "cap, attempted_retries, refused", + [(5, 5, True), (5, 4, False), (0, 0, False), (0, 1, True)], + ids=[ + "cap-above-four-reached", + "cap-above-four-not-reached", + "first-attempt-passes-cap-of-zero", + "cap-of-zero-refuses-first-retry", + ], +) +def test_check_limits_reads_attempted_retries( + monkeypatch: pytest.MonkeyPatch, metadata_key: str, cap: int, attempted_retries: int, refused: bool +) -> None: + monkeypatch.setattr(litellm, "num_retries_per_request", cap) + monkeypatch.setattr(litellm, "max_budget", None) + kwargs: Final = {"model": "mistral/mistral-ocr-latest", metadata_key: {"attempted_retries": attempted_retries}} + if refused: + with pytest.raises(RuntimeError, match="Max retries per request hit!"): + check_limits(kwargs) + else: + check_limits(kwargs) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index f5e9b2091a0..6150ce287ed 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -10607,6 +10607,7 @@ def _cyclic_fallback_router(num_retries=0): "api_key": "sk-fake", "mock_response": "litellm.InternalServerError", }, + "model_info": {"id": f"{group}-deployment"}, } for group in groups ], @@ -10656,28 +10657,37 @@ async def test_cyclic_fallback_graph_does_not_amplify_one_request(): assert sum(len(message) for message in capture.messages) < 5_000 +_FLAT_ATTEMPT_RECORD_KEYS = frozenset( + {"model_group", "deployment_id", "exception_type", "exception_string", "attempted_retries"} +) +_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip" + + @pytest.mark.asyncio -async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): - """log_retry copies every kwarg into previous_models, which reaches spend logs and - logging callbacks. The set of already-attempted groups is router-internal walk state - with no diagnostic value there, and it is the one entry that is not a plain scalar. - A retry has to be configured for the walk state to reach log_retry at all.""" +async def test_retry_records_are_flat_and_name_the_failed_group_on_fallback_hops(): + """Each failed attempt leaves a flat record in previous_models, which reaches spend logs and + logging callbacks. Nothing downstream reads the failed attempt's kwargs or metadata, and copying + them is what carried client credentials and multiplied the payload on every retry. A fallback hop + calls log_retry too, so the record has to name the group that failed, not the one taken next.""" router = _cyclic_fallback_router(num_retries=1) capture = _LogCapture(logging.ERROR) recorder = _FallbackAttemptRecorder() await _drive_cyclic_fallback(router, capture, recorder) - breadcrumbs = [breadcrumb for hop in recorder.breadcrumbs_per_target for breadcrumb in hop] - assert breadcrumbs, "no retry breadcrumbs were recorded" - assert any( - "fallback_depth" in breadcrumb for breadcrumb in breadcrumbs - ), "no breadcrumb carried router walk state, so this test cannot see the leak" - for breadcrumb in breadcrumbs: - assert "attempted_targets" not in breadcrumb - - -_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip" + records = [record for hop in recorder.breadcrumbs_per_target for record in hop] + assert records, "no retry records were recorded" + for record in records: + assert set(record) == _FLAT_ATTEMPT_RECORD_KEYS + assert record["exception_type"] == "InternalServerError" + assert record["deployment_id"] == f"{record['model_group']}-deployment" + group_failed_before_hop = {"group-b": "group-a", "group-c": "group-b", "group-d": "group-c"} + for failed_target, hop_records in zip(recorder.failed_targets, recorder.breadcrumbs_per_target): + groups = [record["model_group"] for record in hop_records] + first_own_attempt = groups.index(failed_target) + assert groups[first_own_attempt - 1] == group_failed_before_hop[failed_target] + assert set(groups[first_own_attempt:]) == {failed_target} + assert [record["attempted_retries"] for record in hop_records[first_own_attempt:]][:2] == [0, 1] @pytest.mark.parametrize( @@ -10703,22 +10713,20 @@ _BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doN ], ) @pytest.mark.asyncio -async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_key, request_kwargs): - """log_retry copies kwargs into previous_models, which reaches spend logs and logging callbacks. - Any of these kwargs can carry a client's forwarded Authorization token or a provider key, and a - breadcrumb has no diagnostic use for the raw secret. A denylist of key names is always one new - credential kwarg behind, so log_retry scrubs credential-named values by pattern instead: the - container still reaches the breadcrumb, but the raw secret never does, whatever key holds it.""" +async def test_retry_records_never_carry_a_forwarded_credential(container_key, request_kwargs): + """previous_models reaches spend logs and logging callbacks. Any request kwarg can carry a client's + forwarded Authorization token or a provider key, so the record must not carry request kwargs at + all: neither the credential-bearing container nor the raw secret, whatever key holds it.""" router = _cyclic_fallback_router(num_retries=1) capture = _LogCapture(logging.ERROR) metadata = {} await _drive_cyclic_fallback(router, capture, metadata=metadata, **request_kwargs) - breadcrumbs = metadata["previous_models"] - assert breadcrumbs, "no retry breadcrumbs were recorded" - dumped = json.dumps(breadcrumbs, default=str) - assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" + records = metadata["previous_models"] + assert records, "no retry records were recorded" + dumped = json.dumps(records) + assert container_key not in dumped assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped @@ -10743,7 +10751,7 @@ async def _fail_one_proxy_shaped_request(router, request_marker): shallow copy of the request, so body["metadata"] is the very same dict the router later stamps previous_models onto.""" metadata = {"request_marker": request_marker} - with pytest.raises(litellm.InternalServerError): + with pytest.raises((litellm.InternalServerError, litellm.APIConnectionError)): await router.acompletion( model="broken-group", messages=[{"role": "user", "content": "hi"}], @@ -10769,34 +10777,52 @@ def _nested_breadcrumb_lists(node): @pytest.mark.asyncio -async def test_retry_breadcrumbs_stay_per_request_and_flat_across_failing_requests(): - """Every failed attempt appends a breadcrumb to metadata["previous_models"], and the proxy's +async def test_retry_records_stay_per_request_and_flat_across_failing_requests(): + """Every failed attempt appends a record to metadata["previous_models"], and the proxy's request snapshot aliases that same metadata dict. Kept on the Router and copied wholesale, - each breadcrumb embedded every earlier one from every earlier request, so the breadcrumb + each breadcrumb once embedded every earlier one from every earlier request, so the breadcrumb tree, and with it the debug repr of the kwargs, roughly doubled on each failed attempt until a single-worker proxy spent minutes in the redaction regex and stopped answering.""" router = _always_failing_router(num_retries=2) - breadcrumbs_per_request = [ + records_per_request = [ await _fail_one_proxy_shaped_request(router, f"request-{request_number}") for request_number in range(1, 7) ] - for request_number, breadcrumbs in enumerate(breadcrumbs_per_request, start=1): - assert len(breadcrumbs) == 3, "one initial attempt plus two retries failed, each leaving one breadcrumb" - assert {breadcrumb["metadata"]["request_marker"] for breadcrumb in breadcrumbs} == {f"request-{request_number}"} - for breadcrumb in breadcrumbs: - assert _nested_breadcrumb_lists(breadcrumb) == [] - assert len({len(repr(breadcrumbs)) for breadcrumbs in breadcrumbs_per_request}) == 1 + for records in records_per_request: + assert [record["attempted_retries"] for record in records] == [0, 1, 2] + for record in records: + assert set(record) == _FLAT_ATTEMPT_RECORD_KEYS + assert _nested_breadcrumb_lists(record) == [] + assert len({len(repr(records)) for records in records_per_request}) == 1 @pytest.mark.asyncio -async def test_retry_breadcrumbs_keep_only_the_last_four_attempts(): +async def test_retry_records_keep_only_the_last_four_attempts(): router = _always_failing_router(num_retries=6) - breadcrumbs = await _fail_one_proxy_shaped_request(router, "request-1") + records = await _fail_one_proxy_shaped_request(router, "request-1") - assert len(breadcrumbs) == 4 - assert [breadcrumb["metadata"]["attempted_retries"] for breadcrumb in breadcrumbs] == [3, 4, 5, 6] + assert [record["attempted_retries"] for record in records] == [3, 4, 5, 6] + + +@pytest.mark.asyncio +async def test_num_retries_per_request_stops_retries_at_caps_above_four(monkeypatch): + """The cap used to be read off len(previous_models), which never exceeds four, so any cap above + four was inert. Reading the Router's attempted_retries counter instead lets a cap of five refuse + retries five and six before they reach the deployment.""" + monkeypatch.setattr(litellm, "num_retries_per_request", 5) + router = _always_failing_router(num_retries=6) + + records = await _fail_one_proxy_shaped_request(router, "request-1") + + assert [record["attempted_retries"] for record in records] == [3, 4, 5, 6] + assert ["Max retries per request hit!" in record["exception_string"] for record in records] == [ + False, + False, + True, + True, + ] @pytest.mark.asyncio diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 835e87aff88..523feb2e54a 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4061,6 +4061,53 @@ class TestMetadataNoneHandling: assert metadata == {} +_RETRY_CAP_CASES: Final = ( + pytest.param(5, {"attempted_retries": 5}, True, id="cap-above-four-reached"), + pytest.param(5, {"attempted_retries": 4}, False, id="cap-above-four-not-reached"), + pytest.param(0, {"attempted_retries": 0}, False, id="first-attempt-passes-cap-of-zero"), + pytest.param(0, {"attempted_retries": 1}, True, id="cap-of-zero-refuses-first-retry"), + pytest.param(5, {"previous_models": ("a", "b", "c", "d", "e")}, False, id="breadcrumb-count-is-not-the-cap"), + pytest.param(5, None, False, id="metadata-none"), +) + + +def _capped_completion_kwargs(metadata_key: str, metadata: object) -> dict[str, object]: + return { + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "hi"}], + "api_key": "sk-fake", + "mock_response": "ok", + metadata_key: metadata, + } + + +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +@pytest.mark.parametrize("cap, metadata, refused", _RETRY_CAP_CASES) +def test_num_retries_per_request_reads_attempted_retries_sync(monkeypatch, metadata_key, cap, metadata, refused): + """num_retries_per_request is enforced from the Router's attempted_retries counter in whichever + metadata bucket the call carries, so callers on litellm_metadata and caps above four both work""" + monkeypatch.setattr(litellm, "num_retries_per_request", cap) + kwargs: Final = _capped_completion_kwargs(metadata_key, metadata) + if refused: + with pytest.raises(Exception, match="Max retries per request hit!"): + litellm.completion(**kwargs) + else: + assert litellm.completion(**kwargs).choices[0].message.content == "ok" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +@pytest.mark.parametrize("cap, metadata, refused", _RETRY_CAP_CASES) +async def test_num_retries_per_request_reads_attempted_retries_async(monkeypatch, metadata_key, cap, metadata, refused): + monkeypatch.setattr(litellm, "num_retries_per_request", cap) + kwargs: Final = _capped_completion_kwargs(metadata_key, metadata) + if refused: + with pytest.raises(Exception, match="Max retries per request hit!"): + await litellm.acompletion(**kwargs) + else: + assert (await litellm.acompletion(**kwargs)).choices[0].message.content == "ok" + + class TestValidateAndFixThinkingParam: """Tests for validate_and_fix_thinking_param.""" diff --git a/tests/test_litellm_rust/ocr/test_lifecycle.py b/tests/test_litellm_rust/ocr/test_lifecycle.py index e7ebc5b3018..77d9ef167d0 100644 --- a/tests/test_litellm_rust/ocr/test_lifecycle.py +++ b/tests/test_litellm_rust/ocr/test_lifecycle.py @@ -806,7 +806,7 @@ async def test_shared_call_limits_still_reject_before_reading_ocr_file( monkeypatch.setattr(litellm, "_current_cost", 2) monkeypatch.setattr(litellm, "num_retries_per_request", 1 if limit == "retries" else None) expected: Final = litellm.BudgetExceededError if limit == "budget" else RuntimeError - arguments: Final = {"document": {"type": "file", "file": File()}, "metadata": {"previous_models": ["earlier"]}} + arguments: Final = {"document": {"type": "file", "file": File()}, "metadata": {"attempted_retries": 1}} with pytest.raises(expected, match=r"Budget has been exceeded|Max retries per request hit"): await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(ocr_server, **arguments) assert reads == [] From 566da87771b65665d9d8489898a86db693b5ca06 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 01:20:46 +0000 Subject: [PATCH 021/196] test(router): expect the exact error per retry-cap case and drop explanatory docstrings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/core_helpers.py | 3 --- tests/test_litellm/test_router.py | 9 +++------ tests/test_litellm/test_utils.py | 2 -- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index ecd9cdac88b..6e76bf9d49e 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -304,9 +304,6 @@ def get_metadata_variable_name_from_kwargs( def max_retries_per_request_hit(kwargs: Mapping[str, object], num_retries_per_request: int | None) -> bool: - """ - Whether the Router retry about to run (``attempted_retries`` >= 1 in the metadata bucket) is past the cap - """ if num_retries_per_request is None: return False metadata: Final = kwargs.get(get_metadata_variable_name_from_kwargs(kwargs)) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 6150ce287ed..eb29f717a20 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -10746,12 +10746,12 @@ def _always_failing_router(num_retries): ) -async def _fail_one_proxy_shaped_request(router, request_marker): +async def _fail_one_proxy_shaped_request(router, request_marker, expected_error=litellm.InternalServerError): """The proxy hands the router a metadata dict and a proxy_server_request whose body is a shallow copy of the request, so body["metadata"] is the very same dict the router later stamps previous_models onto.""" metadata = {"request_marker": request_marker} - with pytest.raises((litellm.InternalServerError, litellm.APIConnectionError)): + with pytest.raises(expected_error): await router.acompletion( model="broken-group", messages=[{"role": "user", "content": "hi"}], @@ -10808,13 +10808,10 @@ async def test_retry_records_keep_only_the_last_four_attempts(): @pytest.mark.asyncio async def test_num_retries_per_request_stops_retries_at_caps_above_four(monkeypatch): - """The cap used to be read off len(previous_models), which never exceeds four, so any cap above - four was inert. Reading the Router's attempted_retries counter instead lets a cap of five refuse - retries five and six before they reach the deployment.""" monkeypatch.setattr(litellm, "num_retries_per_request", 5) router = _always_failing_router(num_retries=6) - records = await _fail_one_proxy_shaped_request(router, "request-1") + records = await _fail_one_proxy_shaped_request(router, "request-1", expected_error=litellm.APIConnectionError) assert [record["attempted_retries"] for record in records] == [3, 4, 5, 6] assert ["Max retries per request hit!" in record["exception_string"] for record in records] == [ diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 523feb2e54a..3f3b8ce5343 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4084,8 +4084,6 @@ def _capped_completion_kwargs(metadata_key: str, metadata: object) -> dict[str, @pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) @pytest.mark.parametrize("cap, metadata, refused", _RETRY_CAP_CASES) def test_num_retries_per_request_reads_attempted_retries_sync(monkeypatch, metadata_key, cap, metadata, refused): - """num_retries_per_request is enforced from the Router's attempted_retries counter in whichever - metadata bucket the call carries, so callers on litellm_metadata and caps above four both work""" monkeypatch.setattr(litellm, "num_retries_per_request", cap) kwargs: Final = _capped_completion_kwargs(metadata_key, metadata) if refused: From a635d7be6a10d5126790cd10df7b67fcfbf1a2a7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:29:09 -0700 Subject: [PATCH 022/196] fix(guardrails): write per-message guardrail rewrites back onto Responses input items A guardrail that answers one rewritten text per message it saw no longer matches the texts the Responses handler extracted once the request carries instructions or tool items, so the rewrite was rejected with a 500. Spread such an answer over the structured messages' text slots and write it back through the structured path, have Prompt Security modify return structured_messages directly, and give the chat completions pairing the same named rejection instead of a silent misalignment when the counts differ. --- .../base_llm/guardrail_translation/utils.py | 77 ++++++++++++- .../chat/guardrail_translation/handler.py | 4 + .../guardrail_translation/handler.py | 27 ++++- .../prompt_security/prompt_security.py | 39 ++++++- .../test_openai_guardrail_handler.py | 43 ++++++++ ...test_openai_responses_guardrail_handler.py | 103 ++++++++++++++++++ .../test_prompt_security_guardrails.py | 89 +++++++++++++++ 7 files changed, 373 insertions(+), 9 deletions(-) diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 94a780f8148..383e668e45c 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -1,8 +1,10 @@ from __future__ import annotations import json -from collections.abc import Callable, Iterator, Sequence -from typing import Final, TypeVar +from collections.abc import Callable, Iterator, Mapping, Sequence +from itertools import accumulate +from types import MappingProxyType +from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row has no typed constructor across roles from pydantic import BaseModel @@ -364,3 +366,74 @@ def merge_guardrailed_scoped_messages( yield from appended return list(_merged()) + + +def _content_part_text(part: object) -> str | None: + if not isinstance(part, Mapping): + return None + text: Final = part.get("text") + return text if isinstance(text, str) else None + + +def message_text_slot_count(message: AllMessageValues) -> int: + content: Final = message.get("content") + if isinstance(content, str): + return 1 + if isinstance(content, list): + return sum(1 for part in content if _content_part_text(part) is not None) + return 0 + + +def _part_with_text(part: object, text: str) -> object: + if not isinstance(part, Mapping): + return part + return {**part, "text": text} # mutable-ok: content parts stay JSON-plain dicts + + +def _content_with_slot_texts(content: Sequence[object], texts: Sequence[str]) -> list[object]: + text_part_indices: Final = tuple( + index for index, part in enumerate(content) if _content_part_text(part) is not None + ) + replacement_by_index: Final = MappingProxyType(dict(zip(text_part_indices, texts))) + return [ # mutable-ok: message content stays a JSON list + _part_with_text(part, replacement_by_index[index]) if index in replacement_by_index else part + for index, part in enumerate(content) + ] + + +def _message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> AllMessageValues: + content: Final = message.get("content") + if not isinstance(content, (str, list)): + return message + rewritten_content: Final = texts[0] if isinstance(content, str) else _content_with_slot_texts(content, texts) + rewritten: Final = {**message, "content": rewritten_content} # mutable-ok: chat rows stay JSON-plain dicts + return cast("AllMessageValues", rewritten) # cast-ok: the same row with only its text slots swapped + + +def message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> AllMessageValues | None: + if message_text_slot_count(message) != len(texts): + return None + return _message_with_slot_texts(message, texts) + + +def messages_with_slot_texts( + messages: Sequence[AllMessageValues], + texts: Sequence[str], +) -> list[AllMessageValues] | None: + """Spread one flat list of rewritten texts over the messages' text slots, in order. + + A slot is a string ``content`` or one list part carrying a string ``text``; + images and other parts ride along untouched. A guardrail that answers one + text per message it saw produces exactly this shape, which stops matching + the endpoint's own per-text extraction as soon as the request carries + instructions or tool items. Returns None unless the counts line up exactly, + so a rewrite never lands on the wrong slot. + """ + slot_counts: Final = tuple(message_text_slot_count(message) for message in messages) + if sum(slot_counts) != len(texts): + return None + offsets: Final = tuple(accumulate(slot_counts, initial=0)) + return [ # mutable-ok: guardrail rows travel as a list + _message_with_slot_texts(message, texts[start:end]) + for message, start, end in zip(messages, offsets, offsets[1:]) + ] diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 58ff03e6a0d..56fda636e9a 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -196,6 +196,10 @@ class OpenAIChatCompletionsHandler(BaseTranslation): else: # Step 3: Map guardrail responses back to original message structure if guardrailed_texts and texts_to_check: + if len(guardrailed_texts) != len(text_task_mappings): + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + raise UnappliableRequestRewrite(guardrail_to_apply.guardrail_name or "unknown") await self._apply_guardrail_responses_to_input_texts( messages=messages, responses=guardrailed_texts, diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 2fe11d9f7bd..61903a54cd3 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -53,6 +53,7 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import ( ) from litellm.llms.base_llm.guardrail_translation.utils import ( blocked_responses_stream_usage, + messages_with_slot_texts, stream_item_field, stream_item_fingerprint, stream_item_items, @@ -395,6 +396,20 @@ def _patched_request_fields( ) +def _guardrailed_structured_messages( + structured_messages: Sequence[AllMessageValues] | None, + sent_text_count: int, + guardrailed_inputs: GenericGuardrailAPIInputs, +) -> Sequence[AllMessageValues] | None: + returned: Final = guardrailed_inputs.get("structured_messages") + if returned is not None and returned is not structured_messages: + return returned + rewritten_texts: Final = guardrailed_inputs.get("texts") + if not structured_messages or rewritten_texts is None or len(rewritten_texts) == sent_text_count: + return None + return messages_with_slot_texts(structured_messages, rewritten_texts) + + def _patch_or_convert_request_fields( raw_input: object, instructions: object, @@ -473,7 +488,8 @@ class OpenAIResponsesHandler(BaseTranslation): form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools) ) extracted: Final = self._extract_guardrail_inputs(data, input_data, flattened_tool_groups) - if not extracted.inputs.get("texts"): + sent_texts: Final = extracted.inputs.get("texts") + if not sent_texts: return data if structured_messages: extracted.inputs["structured_messages"] = structured_messages @@ -486,7 +502,9 @@ class OpenAIResponsesHandler(BaseTranslation): self._apply_guardrailed_tools_to_data( data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools") ) - written_back: Final = self._written_back_request_fields(data, structured_messages, guardrailed_inputs) + written_back: Final = self._written_back_request_fields( + data, structured_messages, len(sent_texts), guardrailed_inputs + ) if written_back is not None: data["input"] = list(written_back.input) # mutable-ok: JSON body if written_back.instructions is None: @@ -553,10 +571,11 @@ class OpenAIResponsesHandler(BaseTranslation): def _written_back_request_fields( data: Mapping[str, object], structured_messages: Sequence[AllMessageValues] | None, + sent_text_count: int, guardrailed_inputs: GenericGuardrailAPIInputs, ) -> _RequestFields | None: - guardrailed: Final = guardrailed_inputs.get("structured_messages") - if guardrailed is None or guardrailed is structured_messages: + guardrailed: Final = _guardrailed_structured_messages(structured_messages, sent_text_count, guardrailed_inputs) + if guardrailed is None: return None return _patch_or_convert_request_fields( data.get("input"), diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 0954fe1698a..72c87a793a5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -2,6 +2,7 @@ import asyncio import base64 import os from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Optional import httpx @@ -14,11 +15,13 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) +from litellm.llms.base_llm.guardrail_translation.utils import message_with_slot_texts from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: @@ -27,6 +30,7 @@ if TYPE_CHECKING: _SANITIZE_FILE_FAIL_OPEN_TIMEOUT_SECONDS: Final = 30.0 +_PROTECT_ROLES: Final = frozenset({"system", "user", "assistant"}) class PromptSecurityGuardrailMissingSecrets(Exception): @@ -275,14 +279,44 @@ class PromptSecurityGuardrail(CustomGuardrail): detail="Blocked by Prompt Security, Violations: " + ", ".join(violations), ) elif action == "modify": - # Extract modified texts from modified_messages modified_messages: Final = result.get("modified_messages", []) modified_texts: Final = self._extract_texts_from_messages(modified_messages) if modified_texts: inputs["texts"] = modified_texts + rewritten_messages: Final = self._structured_messages_with_modifications( + structured_messages, modified_messages + ) + if rewritten_messages is not None: + inputs["structured_messages"] = rewritten_messages return inputs + def _is_sent_to_protect(self, message: Mapping[str, object]) -> bool: + return self.check_tool_results or message.get("role") in _PROTECT_ROLES + + def _structured_messages_with_modifications( + self, + structured_messages: Sequence[AllMessageValues], + modified_messages: Sequence[Mapping[str, object]], + ) -> list[AllMessageValues] | None: + sent_indices: Final = tuple( + index for index, message in enumerate(structured_messages) if self._is_sent_to_protect(message) + ) + if not sent_indices or len(sent_indices) != len(modified_messages): + return None + rewritten: Final = tuple( + message_with_slot_texts(structured_messages[index], self._extract_texts_from_messages((modified,))) + for index, modified in zip(sent_indices, modified_messages) + ) + replacements: Final = MappingProxyType( + {index: message for index, message in zip(sent_indices, rewritten) if message is not None} + ) + if len(replacements) != len(sent_indices): + return None + return [ # mutable-ok: guardrail inputs take a list + replacements.get(index, message) for index, message in enumerate(structured_messages) + ] + async def _apply_guardrail_on_response( self, inputs: GenericGuardrailAPIInputs, @@ -678,14 +712,13 @@ class PromptSecurityGuardrail(CustomGuardrail): This allows checking tool results for indirect prompt injection when enabled. """ - supported_roles: Final = ["system", "user", "assistant"] filtered_messages: Final = [] transformed_count = 0 filtered_count = 0 for message in messages: role = message.get("role", "") - if role in supported_roles: + if role in _PROTECT_ROLES: filtered_messages.append(message) else: if self.check_tool_results: diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index 5a29a96829f..4e2291fdec1 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1893,6 +1893,49 @@ class TestScanOnlyToolResults: assert data["messages"][4]["content"] == "and then?" +class ToolDroppingTextGuardrail(CustomGuardrail): + """Answers one text per non-tool message it saw, the way a guardrail that + filters tool rows out before scanning does, and hands back only texts.""" + + def __init__(self): + super().__init__(guardrail_name="tool-dropping-redactor") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + kept = [m for m in inputs.get("structured_messages") or [] if m.get("role") != "tool"] + return {**inputs, "texts": [str(m.get("content")).replace("POISON", "[BLOCKED]") for m in kept]} + + +class TestPerMessageTextWriteBack: + """Texts that no longer pair one-to-one with what the handler extracted must be + rejected by name instead of sliding onto the wrong messages.""" + + @pytest.mark.asyncio + async def test_fewer_texts_than_extracted_over_a_tool_message_is_rejected(self): + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + handler = OpenAIChatCompletionsHandler() + original_messages = [ + {"role": "system", "content": "SYSTEM-PROMPT"}, + {"role": "user", "content": "fetch the page"}, + {"role": "assistant", "content": "fetching"}, + {"role": "tool", "tool_call_id": "call_1", "content": "page says POISON here"}, + {"role": "user", "content": "and then?"}, + ] + data = {"messages": json.loads(json.dumps(original_messages))} + + with pytest.raises(UnappliableRequestRewrite) as excinfo: + await handler.process_input_messages(data=data, guardrail_to_apply=ToolDroppingTextGuardrail()) + + assert excinfo.value.guardrail_name == "tool-dropping-redactor" + assert data["messages"] == original_messages, "a rejected rewrite must leave the request untouched" + + class TestBuildBlockSseChunks: """build_block_sse_chunks turns a streaming ModifyResponseException into 200 SSE chunks""" diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index a4f0a77a9b6..b4b467773da 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -2338,6 +2338,109 @@ def _parallel_tool_call_input() -> list: ] +SSN = "123-45-6789" +REDACTED_SSN = "" + + +def _slot_texts(message: dict) -> list[str]: + content = message.get("content") + if isinstance(content, str): + return [content] + if isinstance(content, list): + return [part["text"] for part in content if isinstance(part, dict) and isinstance(part.get("text"), str)] + return [] + + +class PerMessageRedactionGuardrail(CustomGuardrail): + """Guardrail that answers one redacted text per message it was shown and hands + back only texts, the way Prompt Security in modify mode and a generic guardrail + API server that scans per message do.""" + + def __init__(self, extra_texts: int = 0): + super().__init__(guardrail_name="per-message-redactor") + self.extra_texts = extra_texts + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + messages = inputs.get("structured_messages") or [] + texts = [text.replace(SSN, REDACTED_SSN) for message in messages for text in _slot_texts(message)] + return {**inputs, "texts": texts + ["junk"] * self.extra_texts} + + +class TestPerMessageTextWriteBack: + """A guardrail that rewrites one text per message it saw must land on the + instructions and the input items those messages came from, not be rejected.""" + + @pytest.mark.asyncio + async def test_instructions_plus_tool_replay_gets_each_rewrite_in_place(self): + handler = OpenAIResponsesHandler() + function_call_item = { + "type": "function_call", + "call_id": "call_1", + "name": "lookup_customer", + "arguments": '{"query": "' + SSN + '"}', + } + data = { + "model": "gpt-5.6", + "instructions": "Never repeat the SSN " + SSN + " back.", + "input": [ + {"role": "user", "content": "Look up " + SSN + " for me."}, + function_call_item, + {"type": "function_call_output", "call_id": "call_1", "output": '{"ssn": "' + SSN + '"}'}, + ], + } + + result = await handler.process_input_messages(data, PerMessageRedactionGuardrail()) + + assert result["instructions"] == "Never repeat the SSN " + REDACTED_SSN + " back." + assert [item.get("type", item.get("role")) for item in result["input"]] == [ + "user", + "function_call", + "function_call_output", + ] + assert _slot_texts(result["input"][0]) == ["Look up " + REDACTED_SSN + " for me."] + assert result["input"][1] == function_call_item + assert result["input"][2]["output"] == '{"ssn": "' + REDACTED_SSN + '"}' + assert result["input"][2]["call_id"] == "call_1" + + @pytest.mark.asyncio + async def test_string_input_with_instructions_keeps_the_two_apart(self): + handler = OpenAIResponsesHandler() + data = { + "model": "gpt-5.6", + "instructions": "Redact " + SSN + " everywhere.", + "input": "My SSN is " + SSN + ".", + } + + result = await handler.process_input_messages(data, PerMessageRedactionGuardrail()) + + assert result["instructions"] == "Redact " + REDACTED_SSN + " everywhere." + assert [_slot_texts(item) for item in result["input"]] == [["My SSN is " + REDACTED_SSN + "."]] + + @pytest.mark.asyncio + async def test_count_matching_neither_texts_nor_messages_is_still_rejected(self): + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + handler = OpenAIResponsesHandler() + original_input = [ + {"role": "user", "content": "Look up " + SSN + " for me."}, + {"type": "function_call_output", "call_id": "call_1", "output": '{"ssn": "' + SSN + '"}'}, + ] + data = {"model": "gpt-5.6", "instructions": "Be terse.", "input": copy.deepcopy(original_input)} + + with pytest.raises(UnappliableRequestRewrite) as excinfo: + await handler.process_input_messages(data, PerMessageRedactionGuardrail(extra_texts=1)) + + assert excinfo.value.guardrail_name == "per-message-redactor" + assert data["input"] == original_input + assert data["instructions"] == "Be terse." + + class TestProvenancePatching: """The O(n) provenance pass must keep patching rewritten rows in place for the shapes real agent loops produce, and fall back safely everywhere else.""" diff --git a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py index e650f796f29..9e83098eb04 100644 --- a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py @@ -174,6 +174,95 @@ async def test_apply_guardrail_modify_request(monkeypatch: pytest.MonkeyPatch): assert result["texts"] == ["User prompt with PII: SSN [REDACTED]"] +def _modify_response(modified_messages: list) -> Response: + mock_response = Response( + json={"result": {"prompt": {"action": "modify", "modified_messages": modified_messages}}}, + status_code=200, + request=Request(method="POST", url="https://test.prompt.security/api/protect"), + ) + mock_response.raise_for_status = lambda: None + return mock_response + + +def _tool_replay_messages() -> list: + return [ + {"role": "system", "content": "Never echo an SSN like 123-45-6789."}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Look up 123-45-6789"}, + {"type": "image_url", "image_url": {"url": "https://example.com/id-card.png"}}, + ], + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": '{"ssn": "123-45-6789"}'}, + {"role": "user", "content": "Summarize what you found."}, + ] + + +@pytest.mark.asyncio +async def test_modify_returns_structured_messages_with_tool_rows_kept(monkeypatch: pytest.MonkeyPatch): + """A per-message modify verdict comes back as structured_messages so the + endpoint handler can write it back by message, with the rows Prompt Security + never saw (tool results) and the non-text parts (images) left in place.""" + monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key") + monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security") + guardrail = PromptSecurityGuardrail(guardrail_name="test-guard", event_hook="pre_call", default_on=True) + messages = _tool_replay_messages() + inputs = {"texts": ["Look up 123-45-6789", "Summarize what you found."], "structured_messages": messages} + modified_messages = [ + {"role": "system", "content": "Never echo an SSN like [REDACTED]."}, + {"role": "user", "content": [{"type": "text", "text": "Look up [REDACTED]"}]}, + {"role": "assistant", "content": None}, + {"role": "user", "content": "Summarize what you found."}, + ] + + with patch.object(guardrail.async_handler, "post", return_value=_modify_response(modified_messages)): + result = await guardrail.apply_guardrail(inputs=inputs, request_data={"messages": messages}, input_type="request") + + assert result["structured_messages"] == [ + {"role": "system", "content": "Never echo an SSN like [REDACTED]."}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Look up [REDACTED]"}, + {"type": "image_url", "image_url": {"url": "https://example.com/id-card.png"}}, + ], + }, + messages[2], + messages[3], + {"role": "user", "content": "Summarize what you found."}, + ] + assert result["structured_messages"] is not messages + assert result["texts"] == [ + "Never echo an SSN like [REDACTED].", + "Look up [REDACTED]", + "Summarize what you found.", + ] + + +@pytest.mark.asyncio +async def test_modify_with_unexpected_message_count_keeps_texts_only(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key") + monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security") + guardrail = PromptSecurityGuardrail(guardrail_name="test-guard", event_hook="pre_call", default_on=True) + messages = _tool_replay_messages() + inputs = {"texts": ["Look up 123-45-6789", "Summarize what you found."], "structured_messages": messages} + modified_messages = [{"role": "user", "content": "Look up [REDACTED]"}] + + with patch.object(guardrail.async_handler, "post", return_value=_modify_response(modified_messages)): + result = await guardrail.apply_guardrail(inputs=inputs, request_data={"messages": messages}, input_type="request") + + assert result["structured_messages"] is messages + assert result["texts"] == ["Look up [REDACTED]"] + + @pytest.mark.asyncio async def test_apply_guardrail_allow_request(monkeypatch: pytest.MonkeyPatch): """Test that apply_guardrail allows safe prompts""" From c1d0d29d01dbd4c28e5a80a52b5af99a73383dd0 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Sun, 13 Sep 2026 04:50:29 +0000 Subject: [PATCH 023/196] fix(guardrails): import ModelResponse lazily to avoid cyclic import alert Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 34b79e295e1..1d00ad8c29a 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -31,7 +31,6 @@ from litellm.types.utils import ( GuardrailStatus, GuardrailTracingDetail, LLMResponseTypes, - ModelResponse, StandardLoggingGuardrailInformation, ) @@ -907,6 +906,8 @@ class CustomGuardrail(CustomLogger): response: Final = ( kwargs.get("async_complete_streaming_response") or kwargs.get("complete_streaming_response") or result ) + from litellm.types.utils import ModelResponse + output_translation: Final = ( get_guardrail_translation_mapping(CallTypes.acompletion)() if isinstance(response, ModelResponse) From 6423acc11a1577d70fa7a0aadd7ece09ca21505b Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:55:39 +0000 Subject: [PATCH 024/196] chore(prices): sync prices for 5 providers: 278 models, 34 new fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/deepseek-v4-flash-0731: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-vision-exp: fireworks_ai/deepseek-v4-flash-vision-exp: fireworks_ai/accounts/fireworks/models/deepseek-v4-pro: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost, input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/deepseek-v4-pro: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost, input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/deepseek-v4p1-flash: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/glm-5p2: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/glm-5p2: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/glm-5p3: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/glm-5p3-flash: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/gpt-oss-120b: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/gpt-oss-120b: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/kimi-k2p6: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/kimi-k2p6: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/kimi-k2p7-code: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/kimi-k2p7-code: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/kimi-k3: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/kimi-k3: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/minimax-m2p7: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/minimax-m2p7: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/minimax-m3: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/minimax-m3: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/models/muse-glimmer-30b: fireworks_ai/muse-glimmer-30b: fireworks_ai/accounts/fireworks/models/nemotron-3-ultra-nvfp4: fireworks_ai/nemotron-3-ultra-nvfp4: fireworks_ai/accounts/fireworks/models/qwen3-embedding-8b: fireworks_ai/accounts/fireworks/models/qwen3-reranker-8b: input_cost_per_token fireworks_ai/accounts/fireworks/models/qwen3p7-plus: fireworks_ai/qwen3p7-plus: fireworks_ai/accounts/fireworks/models/qwen3p8-max: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/qwen3p8-max: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority fireworks_ai/accounts/fireworks/routers/glm-5p2-fast: fireworks_ai/accounts/fireworks/routers/glm-5p3-fast: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost fireworks_ai/accounts/fireworks/routers/kimi-k3-fast: together_ai/arcee-ai/trinity-mini: input_cost_per_token, output_cost_per_token together_ai/arize-ai/qwen-2-1.5b-instruct: babbage-002: input_cost_per_token_batches, output_cost_per_token_batches chat-latest: chatgpt-image-latest: output_cost_per_token, input_cost_per_image_token, output_cost_per_image_token, input_cost_per_token_batches, output_cost_per_token_batches claude-fable-5: claude-fable-5-1: claude-haiku-4-5: claude-mythos-5: claude-mythos-5-1: claude-opus-4-5: claude-opus-4-6: claude-opus-4-7: claude-opus-4-8: claude-opus-5: claude-sonnet-4-5: claude-sonnet-4-6: claude-sonnet-5: davinci-002: input_cost_per_token_batches, output_cost_per_token_batches deep-research-pro-preview-12-2025: cache_read_input_token_cost together_ai/deepseek-ai/deepseek-coder-33b-instruct: input_cost_per_token, output_cost_per_token together_ai/deepseek-ai/DeepSeek-R1-0528: --- ...odel_prices_and_context_window_backup.json | 968 ++++++++++++++---- model_prices_and_context_window.json | 968 ++++++++++++++---- 2 files changed, 1576 insertions(+), 360 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2220d0e1fe5..879cf894152 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -11101,12 +11101,15 @@ "babbage-002": { "deprecation_date": "2026-09-28", "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 4e-07 + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "bedrock/*/1-month-commitment/cohere.command-light-text-v14": { "input_cost_per_second": 0.001902, @@ -13171,7 +13174,9 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 0.0001, + "source": "https://developers.openai.com/api/docs/pricing" }, "claude-haiku-4-5-20251001": { "deprecation_date": "2026-10-15", @@ -13219,7 +13224,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, @@ -13378,7 +13384,8 @@ "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-5-20250929": { "deprecation_date": "2026-09-29", @@ -13452,7 +13459,7 @@ }, "supports_output_config": true, "prompt_cache_min_tokens": 1024, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-6": { "deprecation_date": "2027-02-17", @@ -13488,7 +13495,8 @@ "prompt_cache_min_tokens": 1024, "provider_specific_entry": { "us": 1.1 - } + }, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -13665,7 +13673,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-6": { "deprecation_date": "2027-02-05", @@ -13702,7 +13711,8 @@ "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_speed": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-6-20260205": { "deprecation_date": "2027-02-05", @@ -13777,7 +13787,8 @@ }, "supports_output_config": true, "supports_speed": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-7-20260416": { "deprecation_date": "2027-04-16", @@ -13855,7 +13866,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-fable-5-1": { "deprecation_date": "2027-09-01", @@ -13896,7 +13907,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://platform.claude.com/docs/en/models/fable-5-1/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-5": { "deprecation_date": "2027-07-24", @@ -13937,7 +13948,7 @@ "supports_output_config": true, "supports_speed": true, "prompt_cache_min_tokens": 512, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-8": { "deprecation_date": "2027-05-28", @@ -13977,7 +13988,8 @@ }, "supports_output_config": true, "supports_speed": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-06-15", @@ -19246,12 +19258,15 @@ "davinci-002": { "deprecation_date": "2026-09-28", "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "deepgram/base": { "input_cost_per_second": 0.00020833, @@ -22298,15 +22313,18 @@ "supports_vision": false }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { - "cache_read_input_token_cost": 1.45e-07, - "input_cost_per_token": 1.74e-06, + "cache_read_input_token_cost": 6e-07, + "cache_read_input_token_cost_priority": 6e-07, + "input_cost_per_token": 1.2e-06, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22315,14 +22333,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813": { "cache_read_input_token_cost": 4.4e-08, + "cache_read_input_token_cost_priority": 5.5e-08, "input_cost_per_token": 1.32e-06, + "input_cost_per_token_priority": 1.65e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 4.95e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22417,14 +22438,17 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p2": { "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22433,14 +22457,17 @@ }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "cache_read_input_token_cost": 1.5e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 7.2e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22519,14 +22546,17 @@ }, "fireworks_ai/accounts/fireworks/models/kimi-k2p6": { "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_priority": 2.2e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.5e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22535,14 +22565,17 @@ }, "fireworks_ai/accounts/fireworks/models/kimi-k2p7-code": { "cache_read_input_token_cost": 1.9e-07, + "cache_read_input_token_cost_priority": 2.85e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.425e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22668,14 +22701,17 @@ }, "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 6e-07, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 196608, "max_output_tokens": 196608, "max_tokens": 196608, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22684,14 +22720,17 @@ }, "fireworks_ai/accounts/fireworks/models/minimax-m3": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 512000, "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22767,15 +22806,18 @@ "supports_vision": false }, "fireworks_ai/deepseek-v4-pro": { - "cache_read_input_token_cost": 1.45e-07, - "input_cost_per_token": 1.74e-06, + "cache_read_input_token_cost": 6e-07, + "cache_read_input_token_cost_priority": 6e-07, + "input_cost_per_token": 1.2e-06, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22831,14 +22873,17 @@ }, "fireworks_ai/glm-5p2": { "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22847,14 +22892,17 @@ }, "fireworks_ai/gpt-oss-120b": { "cache_read_input_token_cost": 1.5e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 7.2e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22893,14 +22941,17 @@ }, "fireworks_ai/kimi-k2p6": { "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_priority": 2.2e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.5e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22925,14 +22976,17 @@ }, "fireworks_ai/kimi-k2p7-code": { "cache_read_input_token_cost": 1.9e-07, + "cache_read_input_token_cost_priority": 2.85e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.425e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22971,14 +23025,17 @@ }, "fireworks_ai/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 6e-07, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 196608, "max_output_tokens": 196608, "max_tokens": 196608, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22987,14 +23044,17 @@ }, "fireworks_ai/minimax-m3": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 512000, "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -23010,7 +23070,7 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -23287,26 +23347,28 @@ "ft:babbage-002": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1.6e-06, - "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_batches": 8e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 1.6e-06, - "output_cost_per_token_batches": 2e-07 + "output_cost_per_token_batches": 9e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "ft:davinci-002": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1.2e-05, - "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_batches": 6e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 1.2e-05, - "output_cost_per_token_batches": 1e-06 + "output_cost_per_token_batches": 6e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "ft:gpt-3.5-turbo": { "deprecation_date": "2026-10-23", @@ -23319,6 +23381,7 @@ "mode": "chat", "output_cost_per_token": 6e-06, "output_cost_per_token_batches": 3e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_system_messages": true, "supports_tool_choice": true }, @@ -23375,14 +23438,15 @@ "ft:gpt-4o-2024-08-06": { "cache_read_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, - "input_cost_per_token_batches": 1.875e-06, + "input_cost_per_token_batches": 2.225e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-05, - "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_batches": 1.25e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -23420,6 +23484,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-06, "output_cost_per_token_batches": 6e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -23439,6 +23504,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23457,6 +23523,7 @@ "mode": "chat", "output_cost_per_token": 3.2e-06, "output_cost_per_token_batches": 1.6e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23476,6 +23543,7 @@ "mode": "chat", "output_cost_per_token": 8e-07, "output_cost_per_token_batches": 4e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23495,6 +23563,7 @@ "mode": "chat", "output_cost_per_token": 1.6e-05, "output_cost_per_token_batches": 8e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -23505,15 +23574,18 @@ "gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_character": 3.75e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 4e-07, - "source": "https://ai.google.dev/pricing#2_0flash", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image", @@ -23582,13 +23654,16 @@ "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_character": 1.875e-08, "input_cost_per_token": 7.5e-08, + "input_cost_per_token_batches": 3.75e-08, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 8192, "mode": "chat", "output_cost_per_token": 3e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "output_cost_per_token_batches": 1.5e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image", @@ -23651,6 +23726,8 @@ "gemini-2.5-flash": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_flex": 3e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", @@ -23660,7 +23737,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23693,6 +23770,12 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_priority": 4.5e-06, "supports_image_size": false }, "gemini-2.5-flash-image": { @@ -23700,6 +23783,9 @@ "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32768, "max_output_tokens": 32768, @@ -23709,8 +23795,10 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23741,10 +23829,19 @@ "supports_image_size": false }, "gemini-3-pro-image": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.6e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -23753,8 +23850,12 @@ "output_cost_per_image": 0.134, "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image", + "output_cost_per_token_flex": 6e-06, + "output_cost_per_token_priority": 2.16e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23822,9 +23923,13 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image": { + "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -23833,7 +23938,9 @@ "output_cost_per_image": 0.0672, "output_cost_per_image_token": 6e-05, "output_cost_per_token": 3e-06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23900,9 +24007,11 @@ }, "gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_flex": 1.25e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 4096, @@ -23912,6 +24021,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 1.5e-06, "output_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_flex": 7.5e-07, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -24005,7 +24115,7 @@ "output_cost_per_token_batches": 7.5e-07, "output_cost_per_token_flex": 7.5e-07, "output_cost_per_token_priority": 2.7e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24047,7 +24157,7 @@ "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_flex": 1.5e-08, - "cache_read_input_token_cost_priority": 5e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -24062,7 +24172,7 @@ "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24101,6 +24211,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "deep-research-pro-preview-12-2025": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -24113,7 +24224,7 @@ "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24137,6 +24248,8 @@ "gemini-2.5-flash-lite": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 1e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", @@ -24146,7 +24259,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24179,6 +24292,12 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 1.8e-07, + "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_flex": 2e-07, + "output_cost_per_token_priority": 7.2e-07, "supports_image_size": false }, "gemini-2.5-flash-lite-preview-09-2025": { @@ -24330,7 +24449,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/vertex_ai/live" ], @@ -24361,7 +24480,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "gemini_native_audio": true + "gemini_native_audio": true, + "input_cost_per_image_token": 3e-06 }, "gemini/gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -24461,6 +24581,9 @@ "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -24470,7 +24593,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -24500,7 +24623,15 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "google_maps_grounding_cost_per_query": 0.025 + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.25e-06, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 1.8e-05 }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -24575,7 +24706,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "output_cost_per_image": 0.00012, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24609,13 +24740,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -25127,13 +25261,15 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text" ], @@ -25340,7 +25476,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/computer-use", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image" @@ -25381,8 +25517,11 @@ }, "gemini-embedding-2": { "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_audio_token": 6.5e-06, "input_cost_per_image": 0.00012, + "input_cost_per_image_token": 4.5e-07, "input_cost_per_token": 2e-07, + "input_cost_per_token_batches": 1e-07, "input_cost_per_video_per_second": 0.00079, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 8192, @@ -25390,7 +25529,7 @@ "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supports_multimodal": true, "uses_embed_content": true }, @@ -27055,6 +27194,7 @@ }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "vertex_ai-language-models", @@ -27064,7 +27204,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27102,7 +27242,11 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06 }, "gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -27115,7 +27259,7 @@ "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions" ], @@ -27148,7 +27292,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27211,7 +27355,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27268,7 +27412,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27325,7 +27469,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -28783,6 +28927,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, @@ -28791,12 +28936,15 @@ "gpt-3.5-turbo-0125": { "deprecation_date": "2026-10-23", "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -28806,12 +28954,15 @@ "gpt-3.5-turbo-1106": { "deprecation_date": "2026-09-28", "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -28839,7 +28990,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, @@ -28894,12 +29046,15 @@ "gpt-4-0613": { "deprecation_date": "2026-10-23", "input_cost_per_token": 3e-05, + "input_cost_per_token_batches": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, @@ -28940,12 +29095,15 @@ "gpt-4-turbo-2024-04-09": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1e-05, + "input_cost_per_token_batches": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -28989,6 +29147,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29031,6 +29190,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29073,6 +29233,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29115,6 +29276,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29153,6 +29315,7 @@ "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "output_cost_per_token_priority": 8e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29190,6 +29353,7 @@ "output_cost_per_token": 4e-07, "output_cost_per_token_priority": 8e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29226,6 +29390,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_priority": 1.7e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29248,6 +29413,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 2.625e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29270,6 +29436,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 1.7e-05, "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29293,6 +29460,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 1.7e-05, "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29367,6 +29535,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29403,6 +29572,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions" ], @@ -29437,6 +29607,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29474,6 +29645,7 @@ "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29511,6 +29683,7 @@ "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29572,7 +29745,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false, - "deprecation_date": "2027-01-20" + "deprecation_date": "2027-01-20", + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -29600,7 +29774,8 @@ "search_context_size_high": 0.025, "search_context_size_low": 0.025, "search_context_size_medium": 0.025 - } + }, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, @@ -29621,6 +29796,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29769,15 +29945,18 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 5e-05, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini-tts": { - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -29909,7 +30088,9 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 0.0001, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-image-1.5": { "cache_read_input_token_cost": 1.25e-06, @@ -29919,7 +30100,10 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations" ], @@ -29934,7 +30118,10 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations" ], @@ -29947,7 +30134,9 @@ "litellm_provider": "openai", "mode": "image_generation", "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -30394,6 +30583,7 @@ "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -30402,6 +30592,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "search_context_cost_per_query": { @@ -30409,6 +30600,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -30438,6 +30630,7 @@ }, "gpt-5.1": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, @@ -30478,11 +30671,17 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, "gpt-5.1-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, @@ -30523,6 +30722,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, @@ -30574,6 +30778,7 @@ }, "gpt-5.2": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_flex": 8.75e-08, "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, "input_cost_per_token_priority": 3.5e-06, @@ -30615,11 +30820,17 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_flex": 8.75e-07, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_flex": 7e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, "gpt-5.2-2025-12-11": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_flex": 8.75e-08, "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, "input_cost_per_token_priority": 3.5e-06, @@ -30661,6 +30872,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_flex": 8.75e-07, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_flex": 7e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -30754,17 +30970,20 @@ }, "gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -30793,17 +31012,20 @@ }, "gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -30869,6 +31091,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31005,6 +31228,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31073,6 +31297,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31140,6 +31365,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31203,7 +31429,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "source": "https://developers.openai.com/api/docs/models/gpt-5.6-cyber", + "source": "https://developers.openai.com/api/docs/pricing", "supports_computer_use": true, "supports_parallel_function_calling": true }, @@ -31373,7 +31599,7 @@ "reasoning_effort_levels": [ "medium" ], - "source": "https://developers.openai.com/api/docs/models/chat-latest", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -31452,7 +31678,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "output_cost_per_token_above_272k_tokens_flex": 2.25e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-07, @@ -31509,7 +31736,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "output_cost_per_token_above_272k_tokens_flex": 2.25e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.5-pro": { "input_cost_per_token": 3e-05, @@ -31532,6 +31760,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -31580,6 +31809,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -31657,7 +31887,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-07, @@ -31709,7 +31940,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-pro": { "input_cost_per_token": 3e-05, @@ -31758,7 +31990,8 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 3e-05, - "output_cost_per_token_above_272k_tokens_flex": 0.000135 + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-pro-2026-03-05": { "input_cost_per_token": 3e-05, @@ -31807,7 +32040,8 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 3e-05, - "output_cost_per_token_above_272k_tokens_flex": 0.000135 + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -31858,6 +32092,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -31910,6 +32145,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -31959,6 +32195,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -32008,6 +32245,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -32026,6 +32264,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -32068,6 +32307,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -32100,6 +32340,7 @@ "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2026-12-11", "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -32108,6 +32349,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "search_context_cost_per_query": { @@ -32115,6 +32357,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32439,6 +32682,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses" ], @@ -32469,6 +32713,7 @@ "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", @@ -32477,6 +32722,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "search_context_cost_per_query": { @@ -32484,6 +32730,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32517,6 +32764,7 @@ "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2026-12-11", "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", @@ -32525,6 +32773,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "search_context_cost_per_query": { @@ -32532,6 +32781,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32563,6 +32813,7 @@ "cache_read_input_token_cost": 5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -32571,12 +32822,14 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, "output_cost_per_token_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32609,6 +32862,7 @@ "cache_read_input_token_cost_flex": 2.5e-09, "deprecation_date": "2026-12-11", "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", @@ -32617,12 +32871,14 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, "output_cost_per_token_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32655,9 +32911,11 @@ "deprecation_date": "2026-10-23", "input_cost_per_image_token": 1e-05, "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_image_token": 4e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -32668,9 +32926,11 @@ "deprecation_date": "2026-12-01", "input_cost_per_image_token": 2.5e-06, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_image_token": 8e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -32690,6 +32950,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32722,6 +32983,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32755,6 +33017,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 2.4e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32790,6 +33053,7 @@ "output_cost_per_token": 2.4e-05, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32825,6 +33089,7 @@ "output_cost_per_token": 2.4e-06, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32847,8 +33112,10 @@ "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 6e-08, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, + "input_cost_per_image_token": 8e-07, "input_cost_per_token": 6e-07, "litellm_provider": "openai", "max_input_tokens": 32000, @@ -32857,6 +33124,7 @@ "mode": "realtime", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32890,6 +33158,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -37609,12 +37878,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -37629,12 +37901,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -37656,6 +37931,7 @@ "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37689,6 +37965,7 @@ "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37716,6 +37993,7 @@ "cache_read_input_token_cost_flex": 2.5e-07, "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", @@ -37724,6 +38002,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, "output_cost_per_token_flex": 4e-06, "output_cost_per_token_priority": 1.4e-05, "search_context_cost_per_query": { @@ -37731,6 +38010,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -37760,6 +38040,7 @@ "cache_read_input_token_cost_priority": 8.75e-07, "deprecation_date": "2026-12-11", "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", @@ -37768,6 +38049,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, "output_cost_per_token_flex": 4e-06, "output_cost_per_token_priority": 1.4e-05, "search_context_cost_per_query": { @@ -37775,6 +38057,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -37884,12 +38167,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -37902,12 +38188,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -37931,6 +38220,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37968,6 +38258,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37991,10 +38282,11 @@ }, "o4-mini": { "cache_read_input_token_cost": 2.75e-07, - "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_flex": 1.38e-07, "cache_read_input_token_cost_priority": 5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "input_cost_per_token_flex": 5.5e-07, "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", @@ -38003,6 +38295,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, "output_cost_per_token_flex": 2.2e-06, "output_cost_per_token_priority": 8e-06, "search_context_cost_per_query": { @@ -38010,6 +38303,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -38022,10 +38316,11 @@ }, "o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, - "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_flex": 1.38e-07, "cache_read_input_token_cost_priority": 5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "input_cost_per_token_flex": 5.5e-07, "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", @@ -38034,6 +38329,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, "output_cost_per_token_flex": 2.2e-06, "output_cost_per_token_priority": 8e-06, "search_context_cost_per_query": { @@ -38041,6 +38337,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -43203,7 +43500,8 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, - "output_vector_size": 3072 + "output_vector_size": 3072, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-3-small": { "input_cost_per_token": 2e-08, @@ -43214,7 +43512,8 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, - "output_vector_size": 1536 + "output_vector_size": 1536, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-ada-002": { "input_cost_per_token": 1e-07, @@ -43223,7 +43522,8 @@ "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, - "output_vector_size": 1536 + "output_vector_size": 1536, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-ada-002-v2": { "input_cost_per_token": 1e-07, @@ -43394,7 +43694,7 @@ "input_cost_per_token": 1.2e-06, "output_cost_per_token": 1.2e-06, "max_input_tokens": 131072, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { "litellm_provider": "together_ai", @@ -43406,7 +43706,7 @@ "input_cost_per_token": 3e-07, "output_cost_per_token": 3e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { "deprecation_date": "2026-07-10", @@ -43453,7 +43753,7 @@ "max_input_tokens": 256000, "mode": "chat", "output_cost_per_token": 2e-06, - "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43515,7 +43815,7 @@ }, "mode": "chat", "output_cost_per_token": 1.7e-06, - "source": "https://www.together.ai/models/deepseek-v3-1", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43539,7 +43839,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.04e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43573,6 +43873,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 5.9e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43595,6 +43896,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43606,6 +43908,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 1.8e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43622,7 +43925,7 @@ "input_cost_per_token": 2e-07, "output_cost_per_token": 2e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { "deprecation_date": "2026-04-02", @@ -43634,7 +43937,7 @@ "input_cost_per_token": 1e-07, "output_cost_per_token": 3e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { "deprecation_date": "2026-04-16", @@ -43642,6 +43945,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 6e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43668,7 +43972,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://www.together.ai/models/gpt-oss-120b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43682,7 +43986,7 @@ "max_input_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://www.together.ai/models/gpt-oss-20b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43702,7 +44006,7 @@ "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-06, - "source": "https://www.together.ai/models/glm-4-5-air", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43718,7 +44022,7 @@ }, "mode": "chat", "output_cost_per_token": 2.2e-06, - "source": "https://www.together.ai/models/glm-4-6", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43735,7 +44039,7 @@ }, "mode": "chat", "output_cost_per_token": 2e-06, - "source": "https://www.together.ai/models/glm-4-7", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43783,7 +44087,7 @@ }, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43799,7 +44103,7 @@ }, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43813,7 +44117,7 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 3.6e-06, - "source": "https://www.together.ai/models/qwen3-5-397b-a17b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43828,7 +44132,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43853,7 +44157,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 2.5e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43868,7 +44172,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_reasoning": true }, "together_ai/Qwen/Qwen3.7-Max": { @@ -43879,7 +44183,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 7.5e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/Qwen/Qwen3.7-Plus": { @@ -43889,7 +44193,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.28e-06, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3.8-2.4T-A95B": { "cache_read_input_token_cost": 2.5e-07, @@ -43899,7 +44203,7 @@ "max_tokens": 1010000, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/arize-ai/qwen-2-1.5b-instruct": { @@ -43909,7 +44213,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-07, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": { "cache_read_input_token_cost": 3e-08, @@ -43919,7 +44223,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 2.8e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43951,7 +44255,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43976,7 +44280,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 9.7e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -44012,7 +44316,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/moonshotai/Kimi-K2.7-Code": { @@ -44024,7 +44328,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44045,7 +44349,7 @@ "high", "max" ], - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44063,7 +44367,7 @@ "max_tokens": 512288, "mode": "chat", "output_cost_per_token": 3.6e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44089,7 +44393,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 4.05e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44105,7 +44409,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/zai-org/GLM-5.2": { @@ -44117,7 +44421,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44134,7 +44438,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44151,7 +44455,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44164,6 +44468,7 @@ "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", "mode": "audio_speech", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] @@ -44172,6 +44477,7 @@ "input_cost_per_character": 3e-05, "litellm_provider": "openai", "mode": "audio_speech", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] @@ -49497,7 +49803,8 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "source": "https://developers.openai.com/api/docs/pricing" }, "xai/grok-3": { "cache_read_input_token_cost": 2e-07, @@ -52594,10 +52901,11 @@ "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, - "input_cost_per_token": 0.0, + "input_cost_per_token": 2e-07, "output_cost_per_token": 0.0, "litellm_provider": "fireworks_ai", - "mode": "rerank" + "mode": "rerank", + "source": "https://api.fireworks.ai/v1/serverless/models" }, "fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct": { "max_tokens": 262144, @@ -52662,7 +52970,7 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -54561,12 +54869,13 @@ }, "gpt-4o-mini-tts-2025-03-20": { "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -54579,12 +54888,13 @@ ] }, "gpt-4o-mini-tts-2025-12-15": { - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -54599,24 +54909,28 @@ "gpt-4o-mini-transcribe-2025-03-20": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_second": 5e-05, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "gpt-4o-mini-transcribe-2025-12-15": { "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_second": 5e-05, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] @@ -54635,6 +54949,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -54662,6 +54977,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -54689,6 +55005,7 @@ "mode": "realtime", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -54740,13 +55057,14 @@ "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, - "deprecation_date": "2027-01-20" + "deprecation_date": "2027-01-20", + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-realtime-whisper": { - "input_cost_per_second": 0.0002833333333333333, + "input_cost_per_second": 0.000283333333333, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-realtime-whisper", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime", "/v1/realtime/transcription_sessions" @@ -54764,7 +55082,7 @@ "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.1, - "source": "https://platform.openai.com/docs/api-reference/videos", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "image" @@ -54778,7 +55096,7 @@ "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.3, - "source": "https://platform.openai.com/docs/api-reference/videos", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "image" @@ -54803,11 +55121,15 @@ "chatgpt-image-latest": { "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-12-01", - "input_cost_per_image_token": 1e-05, + "input_cost_per_image_token": 8e-06, "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", "mode": "image_generation", - "output_cost_per_image_token": 4e-05, + "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -57282,7 +57604,7 @@ "input_cost_per_second": 7.5e-05, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-transcribe", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions", "/v1/realtime/transcription_sessions" @@ -57297,10 +57619,10 @@ "supports_audio_input": true }, "gpt-live-transcribe": { - "input_cost_per_second": 0.0002833333333333333, + "input_cost_per_second": 0.000283333333333, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-live-transcribe", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime", "/v1/realtime/transcription_sessions" @@ -57315,10 +57637,10 @@ "supports_audio_input": true }, "gpt-live-1": { - "input_cost_per_second": 0.0008333333333333334, + "input_cost_per_second": 0.000833333333333, "litellm_provider": "openai", "mode": "realtime", - "source": "https://developers.openai.com/api/docs/models/gpt-live-1", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "audio" @@ -57332,13 +57654,13 @@ "supports_function_calling": true }, "gpt-realtime-translate": { - "input_cost_per_second": 0.0005666666666666667, + "input_cost_per_second": 0.000566666666667, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "max_tokens": 2000, "mode": "realtime", - "source": "https://developers.openai.com/api/docs/models/gpt-realtime-translate", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "audio" ], @@ -57366,7 +57688,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "source": "https://platform.claude.com/docs/en/about-claude/models/overview", + "source": "https://platform.claude.com/docs/en/about-claude/pricing", "supports_adaptive_thinking": true, "thinking_always_on": true, "supports_mid_conversation_system": true, @@ -57427,7 +57749,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://platform.claude.com/docs/en/models/mythos-5-1/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-mythos-preview": { "cache_creation_input_token_cost": 1.25e-05, @@ -57779,6 +58101,7 @@ }, "vertex_ai/gemini-3.5-live-translate-preview": { "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_second": 8.83333333333e-05, "input_cost_per_token": 3.5e-06, "litellm_provider": "vertex_ai", "mode": "realtime", @@ -57881,14 +58204,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57897,14 +58223,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://fireworks.ai/models/deepseek-ai/deepseek-v4p1-flash", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -57920,26 +58249,29 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/kimi-k3": { "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, "reasoning_effort_levels": [ "low", "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57948,14 +58280,17 @@ }, "fireworks_ai/deepseek-v4-flash-0731": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57964,14 +58299,17 @@ }, "fireworks_ai/deepseek-v4p1-flash": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://fireworks.ai/models/deepseek-ai/deepseek-v4p1-flash", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -57987,7 +58325,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -58026,19 +58364,22 @@ }, "fireworks_ai/kimi-k3": { "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, "reasoning_effort_levels": [ "low", "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58089,12 +58430,15 @@ }, "fireworks_ai/qwen3p8-max": { "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58110,7 +58454,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58142,7 +58486,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58158,7 +58502,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58190,7 +58534,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58199,12 +58543,15 @@ }, "fireworks_ai/accounts/fireworks/models/qwen3p8-max": { "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58220,7 +58567,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58257,7 +58604,7 @@ "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -60955,14 +61302,17 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p3": { "cache_read_input_token_cost": 2.6e-07, + "cache_read_input_token_cost_priority": 3.25e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -60971,13 +61321,16 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_priority": 3.75e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.875e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -61005,7 +61358,7 @@ "max_output_tokens": 40960, "max_tokens": 40960, "mode": "embedding", - "source": "https://docs.fireworks.ai/serverless/pricing" + "source": "https://api.fireworks.ai/v1/serverless/models" }, "zai/glm-5.2": { "cache_creation_input_token_cost": 0, @@ -61029,7 +61382,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 4.7e-07, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/moonshotai/Kimi-K2.6": { "deprecation_date": "2026-08-19", @@ -61039,7 +61392,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/moonshotai/Kimi-K2.5-fp4": { "input_cost_per_token": 5e-07, @@ -61047,7 +61400,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/MiniMaxAI/MiniMax-M2.7": { "input_cost_per_token": 3e-07, @@ -61056,7 +61409,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 196608, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/zai-org/GLM-5": { "deprecation_date": "2026-06-22", @@ -61065,7 +61418,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 202752, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/zai-org/GLM-5.1": { "deprecation_date": "2026-07-10", @@ -61075,7 +61428,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 202752, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, @@ -61083,7 +61436,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 163840, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-Coder-Next-FP8": { "deprecation_date": "2026-05-14", @@ -61092,7 +61445,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-VL-32B-Instruct": { "deprecation_date": "2026-02-25", @@ -61101,7 +61454,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-VL-8B-Instruct": { "deprecation_date": "2026-04-16", @@ -61110,7 +61463,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Ministral-3-14B-Instruct-2512": { "input_cost_per_token": 2e-07, @@ -61118,7 +61471,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/nvidia/NVIDIA-Nemotron-Nano-9B-v2": { "input_cost_per_token": 6e-08, @@ -61126,7 +61479,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 131072, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mistral-7B-Instruct-v0.3": { "input_cost_per_token": 2e-07, @@ -61134,7 +61487,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/QwQ-32B": { "deprecation_date": "2025-11-13", @@ -61143,7 +61496,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 131072, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "cerebras/gemma-4-31b": { "input_cost_per_token": 9.9e-07, @@ -65270,5 +65623,260 @@ "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": { + "cache_read_input_token_cost": 3.9e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models" + }, + "together_ai/arcee-ai/trinity-mini": { + "input_cost_per_token": 4.5e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { + "input_cost_per_token": 1.6e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-V4.1-Flash": { + "cache_read_input_token_cost": 6e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "vertex_ai/gemini-2.5-flash-native-audio": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 2e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-2.5-flash-preview-tts": { + "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_audio_token": 1e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.1-flash-live-preview": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_second": 8.33333333333e-05, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "vertex_ai", + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.1-flash-tts-preview": { + "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.5-transcribe": { + "input_cost_per_audio_token": 2e-06, + "input_cost_per_second": 5e-05, + "litellm_provider": "vertex_ai", + "mode": "audio_transcription", + "output_cost_per_token": 1.2e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.5-transcribe-live": { + "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_second": 8.33333333333e-05, + "litellm_provider": "vertex_ai", + "mode": "audio_transcription", + "output_cost_per_token": 2.1e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-omni-1.1-flash": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 9e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-robotics-er-2": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_batches": 2.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemma-4-26b-a4b-it": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "together_ai/google/gemma-2-27b-it": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "gpt-5.5-cyber": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 1.25e-05, + "litellm_provider": "openai", + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "source": "https://developers.openai.com/api/docs/pricing" + }, + "gpt-rosalind-research": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://developers.openai.com/api/docs/pricing" + }, + "together_ai/meta-llama/Llama-3-8b-chat-hf": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.1-405B-Instruct": { + "input_cost_per_token": 3.5e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 3.5e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.2-1B-Instruct": { + "input_cost_per_token": 6e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.2-3B-Instruct": { + "input_cost_per_token": 6e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-1.5B-Instruct": { + "input_cost_per_token": 2e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-72B-Instruct": { + "input_cost_per_token": 9e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-VL-72B-Instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-14B-Instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-72B-Instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { + "input_cost_per_token": 1.95e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://api.together.ai/v1/models" } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2220d0e1fe5..879cf894152 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -11101,12 +11101,15 @@ "babbage-002": { "deprecation_date": "2026-09-28", "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 4e-07 + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "bedrock/*/1-month-commitment/cohere.command-light-text-v14": { "input_cost_per_second": 0.001902, @@ -13171,7 +13174,9 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 0.0001, + "source": "https://developers.openai.com/api/docs/pricing" }, "claude-haiku-4-5-20251001": { "deprecation_date": "2026-10-15", @@ -13219,7 +13224,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, @@ -13378,7 +13384,8 @@ "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-5-20250929": { "deprecation_date": "2026-09-29", @@ -13452,7 +13459,7 @@ }, "supports_output_config": true, "prompt_cache_min_tokens": 1024, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-6": { "deprecation_date": "2027-02-17", @@ -13488,7 +13495,8 @@ "prompt_cache_min_tokens": 1024, "provider_specific_entry": { "us": 1.1 - } + }, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -13665,7 +13673,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-6": { "deprecation_date": "2027-02-05", @@ -13702,7 +13711,8 @@ "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_speed": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-6-20260205": { "deprecation_date": "2027-02-05", @@ -13777,7 +13787,8 @@ }, "supports_output_config": true, "supports_speed": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-7-20260416": { "deprecation_date": "2027-04-16", @@ -13855,7 +13866,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-fable-5-1": { "deprecation_date": "2027-09-01", @@ -13896,7 +13907,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://platform.claude.com/docs/en/models/fable-5-1/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-5": { "deprecation_date": "2027-07-24", @@ -13937,7 +13948,7 @@ "supports_output_config": true, "supports_speed": true, "prompt_cache_min_tokens": 512, - "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-opus-4-8": { "deprecation_date": "2027-05-28", @@ -13977,7 +13988,8 @@ }, "supports_output_config": true, "supports_speed": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-06-15", @@ -19246,12 +19258,15 @@ "davinci-002": { "deprecation_date": "2026-09-28", "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "deepgram/base": { "input_cost_per_second": 0.00020833, @@ -22298,15 +22313,18 @@ "supports_vision": false }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { - "cache_read_input_token_cost": 1.45e-07, - "input_cost_per_token": 1.74e-06, + "cache_read_input_token_cost": 6e-07, + "cache_read_input_token_cost_priority": 6e-07, + "input_cost_per_token": 1.2e-06, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22315,14 +22333,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813": { "cache_read_input_token_cost": 4.4e-08, + "cache_read_input_token_cost_priority": 5.5e-08, "input_cost_per_token": 1.32e-06, + "input_cost_per_token_priority": 1.65e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 4.95e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22417,14 +22438,17 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p2": { "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22433,14 +22457,17 @@ }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "cache_read_input_token_cost": 1.5e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 7.2e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22519,14 +22546,17 @@ }, "fireworks_ai/accounts/fireworks/models/kimi-k2p6": { "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_priority": 2.2e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.5e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22535,14 +22565,17 @@ }, "fireworks_ai/accounts/fireworks/models/kimi-k2p7-code": { "cache_read_input_token_cost": 1.9e-07, + "cache_read_input_token_cost_priority": 2.85e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.425e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22668,14 +22701,17 @@ }, "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 6e-07, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 196608, "max_output_tokens": 196608, "max_tokens": 196608, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22684,14 +22720,17 @@ }, "fireworks_ai/accounts/fireworks/models/minimax-m3": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 512000, "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22767,15 +22806,18 @@ "supports_vision": false }, "fireworks_ai/deepseek-v4-pro": { - "cache_read_input_token_cost": 1.45e-07, - "input_cost_per_token": 1.74e-06, + "cache_read_input_token_cost": 6e-07, + "cache_read_input_token_cost_priority": 6e-07, + "input_cost_per_token": 1.2e-06, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22831,14 +22873,17 @@ }, "fireworks_ai/glm-5p2": { "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22847,14 +22892,17 @@ }, "fireworks_ai/gpt-oss-120b": { "cache_read_input_token_cost": 1.5e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 7.2e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22893,14 +22941,17 @@ }, "fireworks_ai/kimi-k2p6": { "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_priority": 2.2e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.5e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22925,14 +22976,17 @@ }, "fireworks_ai/kimi-k2p7-code": { "cache_read_input_token_cost": 1.9e-07, + "cache_read_input_token_cost_priority": 2.85e-07, "input_cost_per_token": 9.5e-07, + "input_cost_per_token_priority": 1.425e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22971,14 +23025,17 @@ }, "fireworks_ai/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 6e-07, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 196608, "max_output_tokens": 196608, "max_tokens": 196608, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.2e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -22987,14 +23044,17 @@ }, "fireworks_ai/minimax-m3": { "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 512000, "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -23010,7 +23070,7 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -23287,26 +23347,28 @@ "ft:babbage-002": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1.6e-06, - "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_batches": 8e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 1.6e-06, - "output_cost_per_token_batches": 2e-07 + "output_cost_per_token_batches": 9e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "ft:davinci-002": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1.2e-05, - "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_batches": 6e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 1.2e-05, - "output_cost_per_token_batches": 1e-06 + "output_cost_per_token_batches": 6e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "ft:gpt-3.5-turbo": { "deprecation_date": "2026-10-23", @@ -23319,6 +23381,7 @@ "mode": "chat", "output_cost_per_token": 6e-06, "output_cost_per_token_batches": 3e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_system_messages": true, "supports_tool_choice": true }, @@ -23375,14 +23438,15 @@ "ft:gpt-4o-2024-08-06": { "cache_read_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, - "input_cost_per_token_batches": 1.875e-06, + "input_cost_per_token_batches": 2.225e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-05, - "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_batches": 1.25e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -23420,6 +23484,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-06, "output_cost_per_token_batches": 6e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -23439,6 +23504,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23457,6 +23523,7 @@ "mode": "chat", "output_cost_per_token": 3.2e-06, "output_cost_per_token_batches": 1.6e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23476,6 +23543,7 @@ "mode": "chat", "output_cost_per_token": 8e-07, "output_cost_per_token_batches": 4e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -23495,6 +23563,7 @@ "mode": "chat", "output_cost_per_token": 1.6e-05, "output_cost_per_token_batches": 8e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -23505,15 +23574,18 @@ "gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_character": 3.75e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 4e-07, - "source": "https://ai.google.dev/pricing#2_0flash", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image", @@ -23582,13 +23654,16 @@ "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_character": 1.875e-08, "input_cost_per_token": 7.5e-08, + "input_cost_per_token_batches": 3.75e-08, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 8192, "mode": "chat", "output_cost_per_token": 3e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "output_cost_per_token_batches": 1.5e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image", @@ -23651,6 +23726,8 @@ "gemini-2.5-flash": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_flex": 3e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", @@ -23660,7 +23737,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23693,6 +23770,12 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_priority": 4.5e-06, "supports_image_size": false }, "gemini-2.5-flash-image": { @@ -23700,6 +23783,9 @@ "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32768, "max_output_tokens": 32768, @@ -23709,8 +23795,10 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23741,10 +23829,19 @@ "supports_image_size": false }, "gemini-3-pro-image": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.6e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -23753,8 +23850,12 @@ "output_cost_per_image": 0.134, "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image", + "output_cost_per_token_flex": 6e-06, + "output_cost_per_token_priority": 2.16e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23822,9 +23923,13 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image": { + "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -23833,7 +23938,9 @@ "output_cost_per_image": 0.0672, "output_cost_per_image_token": 6e-05, "output_cost_per_token": 3e-06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -23900,9 +24007,11 @@ }, "gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_flex": 1.25e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 65536, "max_output_tokens": 4096, @@ -23912,6 +24021,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 1.5e-06, "output_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_flex": 7.5e-07, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -24005,7 +24115,7 @@ "output_cost_per_token_batches": 7.5e-07, "output_cost_per_token_flex": 7.5e-07, "output_cost_per_token_priority": 2.7e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24047,7 +24157,7 @@ "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_flex": 1.5e-08, - "cache_read_input_token_cost_priority": 5e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -24062,7 +24172,7 @@ "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24101,6 +24211,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "deep-research-pro-preview-12-2025": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -24113,7 +24224,7 @@ "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24137,6 +24248,8 @@ "gemini-2.5-flash-lite": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 1e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", @@ -24146,7 +24259,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24179,6 +24292,12 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 1.8e-07, + "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_flex": 2e-07, + "output_cost_per_token_priority": 7.2e-07, "supports_image_size": false }, "gemini-2.5-flash-lite-preview-09-2025": { @@ -24330,7 +24449,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/vertex_ai/live" ], @@ -24361,7 +24480,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "gemini_native_audio": true + "gemini_native_audio": true, + "input_cost_per_image_token": 3e-06 }, "gemini/gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -24461,6 +24581,9 @@ "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -24470,7 +24593,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -24500,7 +24623,15 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "google_maps_grounding_cost_per_query": 0.025 + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.25e-06, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 1.8e-05 }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -24575,7 +24706,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "output_cost_per_image": 0.00012, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -24609,13 +24740,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -25127,13 +25261,15 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text" ], @@ -25340,7 +25476,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/computer-use", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_modalities": [ "text", "image" @@ -25381,8 +25517,11 @@ }, "gemini-embedding-2": { "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_audio_token": 6.5e-06, "input_cost_per_image": 0.00012, + "input_cost_per_image_token": 4.5e-07, "input_cost_per_token": 2e-07, + "input_cost_per_token_batches": 1e-07, "input_cost_per_video_per_second": 0.00079, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 8192, @@ -25390,7 +25529,7 @@ "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supports_multimodal": true, "uses_embed_content": true }, @@ -27055,6 +27194,7 @@ }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "vertex_ai-language-models", @@ -27064,7 +27204,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27102,7 +27242,11 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06 }, "gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -27115,7 +27259,7 @@ "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions" ], @@ -27148,7 +27292,7 @@ "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27211,7 +27355,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27268,7 +27412,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27325,7 +27469,7 @@ "output_cost_per_token": 3.75e-06, "output_cost_per_token_batches": 1.875e-06, "output_cost_per_token_flex": 1.875e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -28783,6 +28927,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, @@ -28791,12 +28936,15 @@ "gpt-3.5-turbo-0125": { "deprecation_date": "2026-10-23", "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -28806,12 +28954,15 @@ "gpt-3.5-turbo-1106": { "deprecation_date": "2026-09-28", "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -28839,7 +28990,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, @@ -28894,12 +29046,15 @@ "gpt-4-0613": { "deprecation_date": "2026-10-23", "input_cost_per_token": 3e-05, + "input_cost_per_token_batches": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, @@ -28940,12 +29095,15 @@ "gpt-4-turbo-2024-04-09": { "deprecation_date": "2026-10-23", "input_cost_per_token": 1e-05, + "input_cost_per_token_batches": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -28989,6 +29147,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29031,6 +29190,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29073,6 +29233,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29115,6 +29276,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29153,6 +29315,7 @@ "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "output_cost_per_token_priority": 8e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29190,6 +29353,7 @@ "output_cost_per_token": 4e-07, "output_cost_per_token_priority": 8e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -29226,6 +29390,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_priority": 1.7e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29248,6 +29413,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 2.625e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29270,6 +29436,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 1.7e-05, "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29293,6 +29460,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 1.7e-05, "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29367,6 +29535,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29403,6 +29572,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions" ], @@ -29437,6 +29607,7 @@ "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29474,6 +29645,7 @@ "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29511,6 +29683,7 @@ "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses", @@ -29572,7 +29745,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false, - "deprecation_date": "2027-01-20" + "deprecation_date": "2027-01-20", + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -29600,7 +29774,8 @@ "search_context_size_high": 0.025, "search_context_size_low": 0.025, "search_context_size_medium": 0.025 - } + }, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, @@ -29621,6 +29796,7 @@ "search_context_size_low": 0.025, "search_context_size_medium": 0.025 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -29769,15 +29945,18 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 5e-05, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-4o-mini-tts": { - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -29909,7 +30088,9 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "input_cost_per_second": 0.0001, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-image-1.5": { "cache_read_input_token_cost": 1.25e-06, @@ -29919,7 +30100,10 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations" ], @@ -29934,7 +30118,10 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations" ], @@ -29947,7 +30134,9 @@ "litellm_provider": "openai", "mode": "image_generation", "input_cost_per_image_token": 8e-06, + "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -30394,6 +30583,7 @@ "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -30402,6 +30592,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "search_context_cost_per_query": { @@ -30409,6 +30600,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -30438,6 +30630,7 @@ }, "gpt-5.1": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, @@ -30478,11 +30671,17 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, "gpt-5.1-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, @@ -30523,6 +30722,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, @@ -30574,6 +30778,7 @@ }, "gpt-5.2": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_flex": 8.75e-08, "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, "input_cost_per_token_priority": 3.5e-06, @@ -30615,11 +30820,17 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_flex": 8.75e-07, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_flex": 7e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, "gpt-5.2-2025-12-11": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_flex": 8.75e-08, "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, "input_cost_per_token_priority": 3.5e-06, @@ -30661,6 +30872,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_flex": 8.75e-07, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_flex": 7e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -30754,17 +30970,20 @@ }, "gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -30793,17 +31012,20 @@ }, "gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -30869,6 +31091,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31005,6 +31228,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31073,6 +31297,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31140,6 +31365,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -31203,7 +31429,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "source": "https://developers.openai.com/api/docs/models/gpt-5.6-cyber", + "source": "https://developers.openai.com/api/docs/pricing", "supports_computer_use": true, "supports_parallel_function_calling": true }, @@ -31373,7 +31599,7 @@ "reasoning_effort_levels": [ "medium" ], - "source": "https://developers.openai.com/api/docs/models/chat-latest", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -31452,7 +31678,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "output_cost_per_token_above_272k_tokens_flex": 2.25e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-07, @@ -31509,7 +31736,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "output_cost_per_token_above_272k_tokens_flex": 2.25e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.5-pro": { "input_cost_per_token": 3e-05, @@ -31532,6 +31760,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -31580,6 +31809,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -31657,7 +31887,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-07, @@ -31709,7 +31940,8 @@ "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, - "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07 + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-pro": { "input_cost_per_token": 3e-05, @@ -31758,7 +31990,8 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 3e-05, - "output_cost_per_token_above_272k_tokens_flex": 0.000135 + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-pro-2026-03-05": { "input_cost_per_token": 3e-05, @@ -31807,7 +32040,8 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "input_cost_per_token_above_272k_tokens_flex": 3e-05, - "output_cost_per_token_above_272k_tokens_flex": 0.000135 + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-5.4-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -31858,6 +32092,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -31910,6 +32145,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -31959,6 +32195,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -32008,6 +32245,7 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "source": "https://developers.openai.com/api/docs/pricing", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, @@ -32026,6 +32264,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -32068,6 +32307,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -32100,6 +32340,7 @@ "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2026-12-11", "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -32108,6 +32349,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "search_context_cost_per_query": { @@ -32115,6 +32357,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32439,6 +32682,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses" ], @@ -32469,6 +32713,7 @@ "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", @@ -32477,6 +32722,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "search_context_cost_per_query": { @@ -32484,6 +32730,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32517,6 +32764,7 @@ "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2026-12-11", "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", @@ -32525,6 +32773,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "search_context_cost_per_query": { @@ -32532,6 +32781,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32563,6 +32813,7 @@ "cache_read_input_token_cost": 5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", @@ -32571,12 +32822,14 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, "output_cost_per_token_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32609,6 +32862,7 @@ "cache_read_input_token_cost_flex": 2.5e-09, "deprecation_date": "2026-12-11", "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", @@ -32617,12 +32871,14 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, "output_cost_per_token_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -32655,9 +32911,11 @@ "deprecation_date": "2026-10-23", "input_cost_per_image_token": 1e-05, "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_image_token": 4e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -32668,9 +32926,11 @@ "deprecation_date": "2026-12-01", "input_cost_per_image_token": 2.5e-06, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_image_token": 8e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -32690,6 +32950,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32722,6 +32983,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32755,6 +33017,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 2.4e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32790,6 +33053,7 @@ "output_cost_per_token": 2.4e-05, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32825,6 +33089,7 @@ "output_cost_per_token": 2.4e-06, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32847,8 +33112,10 @@ "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 6e-08, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, + "input_cost_per_image_token": 8e-07, "input_cost_per_token": 6e-07, "litellm_provider": "openai", "max_input_tokens": 32000, @@ -32857,6 +33124,7 @@ "mode": "realtime", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -32890,6 +33158,7 @@ "mode": "realtime", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -37609,12 +37878,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -37629,12 +37901,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -37656,6 +37931,7 @@ "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37689,6 +37965,7 @@ "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37716,6 +37993,7 @@ "cache_read_input_token_cost_flex": 2.5e-07, "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", @@ -37724,6 +38002,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, "output_cost_per_token_flex": 4e-06, "output_cost_per_token_priority": 1.4e-05, "search_context_cost_per_query": { @@ -37731,6 +38010,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -37760,6 +38040,7 @@ "cache_read_input_token_cost_priority": 8.75e-07, "deprecation_date": "2026-12-11", "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", @@ -37768,6 +38049,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, "output_cost_per_token_flex": 4e-06, "output_cost_per_token_priority": 1.4e-05, "search_context_cost_per_query": { @@ -37775,6 +38057,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -37884,12 +38167,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -37902,12 +38188,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -37931,6 +38220,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37968,6 +38258,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -37991,10 +38282,11 @@ }, "o4-mini": { "cache_read_input_token_cost": 2.75e-07, - "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_flex": 1.38e-07, "cache_read_input_token_cost_priority": 5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "input_cost_per_token_flex": 5.5e-07, "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", @@ -38003,6 +38295,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, "output_cost_per_token_flex": 2.2e-06, "output_cost_per_token_priority": 8e-06, "search_context_cost_per_query": { @@ -38010,6 +38303,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -38022,10 +38316,11 @@ }, "o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, - "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_flex": 1.38e-07, "cache_read_input_token_cost_priority": 5e-07, "deprecation_date": "2026-10-23", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "input_cost_per_token_flex": 5.5e-07, "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", @@ -38034,6 +38329,7 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, "output_cost_per_token_flex": 2.2e-06, "output_cost_per_token_priority": 8e-06, "search_context_cost_per_query": { @@ -38041,6 +38337,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, @@ -43203,7 +43500,8 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, - "output_vector_size": 3072 + "output_vector_size": 3072, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-3-small": { "input_cost_per_token": 2e-08, @@ -43214,7 +43512,8 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, - "output_vector_size": 1536 + "output_vector_size": 1536, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-ada-002": { "input_cost_per_token": 1e-07, @@ -43223,7 +43522,8 @@ "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, - "output_vector_size": 1536 + "output_vector_size": 1536, + "source": "https://developers.openai.com/api/docs/pricing" }, "text-embedding-ada-002-v2": { "input_cost_per_token": 1e-07, @@ -43394,7 +43694,7 @@ "input_cost_per_token": 1.2e-06, "output_cost_per_token": 1.2e-06, "max_input_tokens": 131072, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { "litellm_provider": "together_ai", @@ -43406,7 +43706,7 @@ "input_cost_per_token": 3e-07, "output_cost_per_token": 3e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { "deprecation_date": "2026-07-10", @@ -43453,7 +43753,7 @@ "max_input_tokens": 256000, "mode": "chat", "output_cost_per_token": 2e-06, - "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43515,7 +43815,7 @@ }, "mode": "chat", "output_cost_per_token": 1.7e-06, - "source": "https://www.together.ai/models/deepseek-v3-1", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43539,7 +43839,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.04e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43573,6 +43873,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 5.9e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43595,6 +43896,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43606,6 +43908,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 1.8e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43622,7 +43925,7 @@ "input_cost_per_token": 2e-07, "output_cost_per_token": 2e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { "deprecation_date": "2026-04-02", @@ -43634,7 +43937,7 @@ "input_cost_per_token": 1e-07, "output_cost_per_token": 3e-07, "max_input_tokens": 32768, - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { "deprecation_date": "2026-04-16", @@ -43642,6 +43945,7 @@ "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 6e-07, + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43668,7 +43972,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://www.together.ai/models/gpt-oss-120b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43682,7 +43986,7 @@ "max_input_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://www.together.ai/models/gpt-oss-20b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43702,7 +44006,7 @@ "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-06, - "source": "https://www.together.ai/models/glm-4-5-air", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43718,7 +44022,7 @@ }, "mode": "chat", "output_cost_per_token": 2.2e-06, - "source": "https://www.together.ai/models/glm-4-6", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43735,7 +44039,7 @@ }, "mode": "chat", "output_cost_per_token": 2e-06, - "source": "https://www.together.ai/models/glm-4-7", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43783,7 +44087,7 @@ }, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43799,7 +44103,7 @@ }, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -43813,7 +44117,7 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 3.6e-06, - "source": "https://www.together.ai/models/qwen3-5-397b-a17b", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43828,7 +44132,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43853,7 +44157,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 2.5e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -43868,7 +44172,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_reasoning": true }, "together_ai/Qwen/Qwen3.7-Max": { @@ -43879,7 +44183,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 7.5e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/Qwen/Qwen3.7-Plus": { @@ -43889,7 +44193,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.28e-06, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3.8-2.4T-A95B": { "cache_read_input_token_cost": 2.5e-07, @@ -43899,7 +44203,7 @@ "max_tokens": 1010000, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/arize-ai/qwen-2-1.5b-instruct": { @@ -43909,7 +44213,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-07, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": { "cache_read_input_token_cost": 3e-08, @@ -43919,7 +44223,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 2.8e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43951,7 +44255,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -43976,7 +44280,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 9.7e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -44012,7 +44316,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/moonshotai/Kimi-K2.7-Code": { @@ -44024,7 +44328,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44045,7 +44349,7 @@ "high", "max" ], - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44063,7 +44367,7 @@ "max_tokens": 512288, "mode": "chat", "output_cost_per_token": 3.6e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44089,7 +44393,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 4.05e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44105,7 +44409,7 @@ "max_tokens": 524288, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, "together_ai/zai-org/GLM-5.2": { @@ -44117,7 +44421,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44134,7 +44438,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44151,7 +44455,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://docs.together.ai/docs/serverless-models", + "source": "https://api.together.ai/v1/models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -44164,6 +44468,7 @@ "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", "mode": "audio_speech", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] @@ -44172,6 +44477,7 @@ "input_cost_per_character": 3e-05, "litellm_provider": "openai", "mode": "audio_speech", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] @@ -49497,7 +49803,8 @@ "supported_endpoints": [ "/v1/audio/transcriptions" ], - "deprecation_date": "2027-02-26" + "deprecation_date": "2027-02-26", + "source": "https://developers.openai.com/api/docs/pricing" }, "xai/grok-3": { "cache_read_input_token_cost": 2e-07, @@ -52594,10 +52901,11 @@ "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, - "input_cost_per_token": 0.0, + "input_cost_per_token": 2e-07, "output_cost_per_token": 0.0, "litellm_provider": "fireworks_ai", - "mode": "rerank" + "mode": "rerank", + "source": "https://api.fireworks.ai/v1/serverless/models" }, "fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct": { "max_tokens": 262144, @@ -52662,7 +52970,7 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -54561,12 +54869,13 @@ }, "gpt-4o-mini-tts-2025-03-20": { "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -54579,12 +54888,13 @@ ] }, "gpt-4o-mini-tts-2025-12-15": { - "input_cost_per_token": 2.5e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -54599,24 +54909,28 @@ "gpt-4o-mini-transcribe-2025-03-20": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_second": 5e-05, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "gpt-4o-mini-transcribe-2025-12-15": { "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_second": 5e-05, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] @@ -54635,6 +54949,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -54662,6 +54977,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://developers.openai.com/api/docs/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -54689,6 +55005,7 @@ "mode": "realtime", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -54740,13 +55057,14 @@ "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, - "deprecation_date": "2027-01-20" + "deprecation_date": "2027-01-20", + "source": "https://developers.openai.com/api/docs/pricing" }, "gpt-realtime-whisper": { - "input_cost_per_second": 0.0002833333333333333, + "input_cost_per_second": 0.000283333333333, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-realtime-whisper", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime", "/v1/realtime/transcription_sessions" @@ -54764,7 +55082,7 @@ "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.1, - "source": "https://platform.openai.com/docs/api-reference/videos", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "image" @@ -54778,7 +55096,7 @@ "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.3, - "source": "https://platform.openai.com/docs/api-reference/videos", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "image" @@ -54803,11 +55121,15 @@ "chatgpt-image-latest": { "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-12-01", - "input_cost_per_image_token": 1e-05, + "input_cost_per_image_token": 8e-06, "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", "mode": "image_generation", - "output_cost_per_image_token": 4e-05, + "output_cost_per_image_token": 3.2e-05, + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -57282,7 +57604,7 @@ "input_cost_per_second": 7.5e-05, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-transcribe", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/audio/transcriptions", "/v1/realtime/transcription_sessions" @@ -57297,10 +57619,10 @@ "supports_audio_input": true }, "gpt-live-transcribe": { - "input_cost_per_second": 0.0002833333333333333, + "input_cost_per_second": 0.000283333333333, "litellm_provider": "openai", "mode": "audio_transcription", - "source": "https://developers.openai.com/api/docs/models/gpt-live-transcribe", + "source": "https://developers.openai.com/api/docs/pricing", "supported_endpoints": [ "/v1/realtime", "/v1/realtime/transcription_sessions" @@ -57315,10 +57637,10 @@ "supports_audio_input": true }, "gpt-live-1": { - "input_cost_per_second": 0.0008333333333333334, + "input_cost_per_second": 0.000833333333333, "litellm_provider": "openai", "mode": "realtime", - "source": "https://developers.openai.com/api/docs/models/gpt-live-1", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "text", "audio" @@ -57332,13 +57654,13 @@ "supports_function_calling": true }, "gpt-realtime-translate": { - "input_cost_per_second": 0.0005666666666666667, + "input_cost_per_second": 0.000566666666667, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "max_tokens": 2000, "mode": "realtime", - "source": "https://developers.openai.com/api/docs/models/gpt-realtime-translate", + "source": "https://developers.openai.com/api/docs/pricing", "supported_modalities": [ "audio" ], @@ -57366,7 +57688,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "source": "https://platform.claude.com/docs/en/about-claude/models/overview", + "source": "https://platform.claude.com/docs/en/about-claude/pricing", "supports_adaptive_thinking": true, "thinking_always_on": true, "supports_mid_conversation_system": true, @@ -57427,7 +57749,7 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512, "supports_native_structured_output": true, - "source": "https://platform.claude.com/docs/en/models/mythos-5-1/overview" + "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, "claude-mythos-preview": { "cache_creation_input_token_cost": 1.25e-05, @@ -57779,6 +58101,7 @@ }, "vertex_ai/gemini-3.5-live-translate-preview": { "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_second": 8.83333333333e-05, "input_cost_per_token": 3.5e-06, "litellm_provider": "vertex_ai", "mode": "realtime", @@ -57881,14 +58204,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57897,14 +58223,17 @@ }, "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://fireworks.ai/models/deepseek-ai/deepseek-v4p1-flash", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -57920,26 +58249,29 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/kimi-k3": { "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, "reasoning_effort_levels": [ "low", "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57948,14 +58280,17 @@ }, "fireworks_ai/deepseek-v4-flash-0731": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -57964,14 +58299,17 @@ }, "fireworks_ai/deepseek-v4p1-flash": { "cache_read_input_token_cost": 7e-09, + "cache_read_input_token_cost_priority": 8.75e-09, "input_cost_per_token": 2.2e-07, + "input_cost_per_token_priority": 2.75e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 393216, "max_tokens": 393216, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://fireworks.ai/models/deepseek-ai/deepseek-v4p1-flash", + "output_cost_per_token_priority": 8.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -57987,7 +58325,7 @@ "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 6.6e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -58026,19 +58364,22 @@ }, "fireworks_ai/kimi-k3": { "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, "reasoning_effort_levels": [ "low", "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58089,12 +58430,15 @@ }, "fireworks_ai/qwen3p8-max": { "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58110,7 +58454,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58142,7 +58486,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58158,7 +58502,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58190,7 +58534,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58199,12 +58543,15 @@ }, "fireworks_ai/accounts/fireworks/models/qwen3p8-max": { "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58220,7 +58567,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -58257,7 +58604,7 @@ "high", "max" ], - "source": "https://docs.fireworks.ai/serverless/pricing", + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -60955,14 +61302,17 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p3": { "cache_read_input_token_cost": 2.6e-07, + "cache_read_input_token_cost_priority": 3.25e-07, "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -60971,13 +61321,16 @@ }, "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_priority": 3.75e-08, "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.875e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 1048576, "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token_priority": 6.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -61005,7 +61358,7 @@ "max_output_tokens": 40960, "max_tokens": 40960, "mode": "embedding", - "source": "https://docs.fireworks.ai/serverless/pricing" + "source": "https://api.fireworks.ai/v1/serverless/models" }, "zai/glm-5.2": { "cache_creation_input_token_cost": 0, @@ -61029,7 +61382,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 4.7e-07, - "source": "https://docs.together.ai/docs/serverless-models" + "source": "https://api.together.ai/v1/models" }, "together_ai/moonshotai/Kimi-K2.6": { "deprecation_date": "2026-08-19", @@ -61039,7 +61392,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/moonshotai/Kimi-K2.5-fp4": { "input_cost_per_token": 5e-07, @@ -61047,7 +61400,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/MiniMaxAI/MiniMax-M2.7": { "input_cost_per_token": 3e-07, @@ -61056,7 +61409,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 196608, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/zai-org/GLM-5": { "deprecation_date": "2026-06-22", @@ -61065,7 +61418,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 202752, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/zai-org/GLM-5.1": { "deprecation_date": "2026-07-10", @@ -61075,7 +61428,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 202752, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, @@ -61083,7 +61436,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 163840, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-Coder-Next-FP8": { "deprecation_date": "2026-05-14", @@ -61092,7 +61445,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-VL-32B-Instruct": { "deprecation_date": "2026-02-25", @@ -61101,7 +61454,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen3-VL-8B-Instruct": { "deprecation_date": "2026-04-16", @@ -61110,7 +61463,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Ministral-3-14B-Instruct-2512": { "input_cost_per_token": 2e-07, @@ -61118,7 +61471,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/nvidia/NVIDIA-Nemotron-Nano-9B-v2": { "input_cost_per_token": 6e-08, @@ -61126,7 +61479,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 131072, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/mistralai/Mistral-7B-Instruct-v0.3": { "input_cost_per_token": 2e-07, @@ -61134,7 +61487,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/QwQ-32B": { "deprecation_date": "2025-11-13", @@ -61143,7 +61496,7 @@ "litellm_provider": "together_ai", "max_input_tokens": 131072, "mode": "chat", - "source": "https://api.together.xyz/v1/models" + "source": "https://api.together.ai/v1/models" }, "cerebras/gemma-4-31b": { "input_cost_per_token": 9.9e-07, @@ -65270,5 +65623,260 @@ "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": { + "cache_read_input_token_cost": 3.9e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models" + }, + "together_ai/arcee-ai/trinity-mini": { + "input_cost_per_token": 4.5e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { + "input_cost_per_token": 1.6e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/deepseek-ai/DeepSeek-V4.1-Flash": { + "cache_read_input_token_cost": 6e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "vertex_ai/gemini-2.5-flash-native-audio": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 2e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-2.5-flash-preview-tts": { + "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_audio_token": 1e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.1-flash-live-preview": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_second": 8.33333333333e-05, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "vertex_ai", + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.1-flash-tts-preview": { + "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.5-transcribe": { + "input_cost_per_audio_token": 2e-06, + "input_cost_per_second": 5e-05, + "litellm_provider": "vertex_ai", + "mode": "audio_transcription", + "output_cost_per_token": 1.2e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3.5-transcribe-live": { + "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_second": 8.33333333333e-05, + "litellm_provider": "vertex_ai", + "mode": "audio_transcription", + "output_cost_per_token": 2.1e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-omni-1.1-flash": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 9e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-robotics-er-2": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_batches": 2.5e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemma-4-26b-a4b-it": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai", + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "together_ai/google/gemma-2-27b-it": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "gpt-5.5-cyber": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 1.25e-05, + "litellm_provider": "openai", + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "source": "https://developers.openai.com/api/docs/pricing" + }, + "gpt-rosalind-research": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://developers.openai.com/api/docs/pricing" + }, + "together_ai/meta-llama/Llama-3-8b-chat-hf": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.1-405B-Instruct": { + "input_cost_per_token": 3.5e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 3.5e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.2-1B-Instruct": { + "input_cost_per_token": 6e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Llama-3.2-3B-Instruct": { + "input_cost_per_token": 6e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-1.5B-Instruct": { + "input_cost_per_token": 2e-08, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-08, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-72B-Instruct": { + "input_cost_per_token": 9e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2-VL-72B-Instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-14B-Instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-72B-Instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://api.together.ai/v1/models" + }, + "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { + "input_cost_per_token": 1.95e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://api.together.ai/v1/models" } } From 09314f239c34f35a86ce4155b8c4d445944a6038 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:54:29 -0700 Subject: [PATCH 025/196] fix(guardrails): hand per-message rewrites back as structured_messages A guardrail that rewrites text per chat message now returns the rewritten rows as structured_messages instead of only texts, so the Responses and chat handlers write the rewrite back through the structured path. The generic guardrail API response accepts an optional structured_messages list, Prompt Security modify builds one from modified_messages, and rows a server echoes back exactly as shown are restored to the original row objects because the request model drops undeclared keys. Texts-only per-message answers keep the named rejection on both endpoints. --- .../base_llm/guardrail_translation/utils.py | 42 ++--- .../guardrail_translation/handler.py | 27 +--- .../generic_guardrail_api.py | 30 +++- .../prompt_security/prompt_security.py | 8 +- .../guardrail_hooks/generic_guardrail_api.py | 15 +- ...test_openai_responses_guardrail_handler.py | 148 +++++++++--------- .../test_generic_guardrail_api.py | 105 +++++++++++++ .../test_prompt_security_guardrails.py | 14 +- 8 files changed, 244 insertions(+), 145 deletions(-) diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 383e668e45c..1172c93959b 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import json from collections.abc import Callable, Iterator, Mapping, Sequence -from itertools import accumulate from types import MappingProxyType from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row has no typed constructor across roles @@ -390,7 +389,7 @@ def _part_with_text(part: object, text: str) -> object: return {**part, "text": text} # mutable-ok: content parts stay JSON-plain dicts -def _content_with_slot_texts(content: Sequence[object], texts: Sequence[str]) -> list[object]: +def _content_with_slot_texts(content: Sequence[object], texts: Sequence[str]) -> Sequence[object]: text_part_indices: Final = tuple( index for index, part in enumerate(content) if _content_part_text(part) is not None ) @@ -401,39 +400,18 @@ def _content_with_slot_texts(content: Sequence[object], texts: Sequence[str]) -> ] -def _message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> AllMessageValues: +def message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> AllMessageValues | None: + """Swap one rewritten text into each text slot of a chat row, in order. + + A slot is a string ``content`` or one list part carrying a string ``text``; + images and other parts ride along untouched. Returns None unless the counts + line up exactly, so a rewrite never lands on the wrong slot. + """ + if message_text_slot_count(message) != len(texts): + return None content: Final = message.get("content") if not isinstance(content, (str, list)): return message rewritten_content: Final = texts[0] if isinstance(content, str) else _content_with_slot_texts(content, texts) rewritten: Final = {**message, "content": rewritten_content} # mutable-ok: chat rows stay JSON-plain dicts return cast("AllMessageValues", rewritten) # cast-ok: the same row with only its text slots swapped - - -def message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> AllMessageValues | None: - if message_text_slot_count(message) != len(texts): - return None - return _message_with_slot_texts(message, texts) - - -def messages_with_slot_texts( - messages: Sequence[AllMessageValues], - texts: Sequence[str], -) -> list[AllMessageValues] | None: - """Spread one flat list of rewritten texts over the messages' text slots, in order. - - A slot is a string ``content`` or one list part carrying a string ``text``; - images and other parts ride along untouched. A guardrail that answers one - text per message it saw produces exactly this shape, which stops matching - the endpoint's own per-text extraction as soon as the request carries - instructions or tool items. Returns None unless the counts line up exactly, - so a rewrite never lands on the wrong slot. - """ - slot_counts: Final = tuple(message_text_slot_count(message) for message in messages) - if sum(slot_counts) != len(texts): - return None - offsets: Final = tuple(accumulate(slot_counts, initial=0)) - return [ # mutable-ok: guardrail rows travel as a list - _message_with_slot_texts(message, texts[start:end]) - for message, start, end in zip(messages, offsets, offsets[1:]) - ] diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 61903a54cd3..2fe11d9f7bd 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -53,7 +53,6 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import ( ) from litellm.llms.base_llm.guardrail_translation.utils import ( blocked_responses_stream_usage, - messages_with_slot_texts, stream_item_field, stream_item_fingerprint, stream_item_items, @@ -396,20 +395,6 @@ def _patched_request_fields( ) -def _guardrailed_structured_messages( - structured_messages: Sequence[AllMessageValues] | None, - sent_text_count: int, - guardrailed_inputs: GenericGuardrailAPIInputs, -) -> Sequence[AllMessageValues] | None: - returned: Final = guardrailed_inputs.get("structured_messages") - if returned is not None and returned is not structured_messages: - return returned - rewritten_texts: Final = guardrailed_inputs.get("texts") - if not structured_messages or rewritten_texts is None or len(rewritten_texts) == sent_text_count: - return None - return messages_with_slot_texts(structured_messages, rewritten_texts) - - def _patch_or_convert_request_fields( raw_input: object, instructions: object, @@ -488,8 +473,7 @@ class OpenAIResponsesHandler(BaseTranslation): form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools) ) extracted: Final = self._extract_guardrail_inputs(data, input_data, flattened_tool_groups) - sent_texts: Final = extracted.inputs.get("texts") - if not sent_texts: + if not extracted.inputs.get("texts"): return data if structured_messages: extracted.inputs["structured_messages"] = structured_messages @@ -502,9 +486,7 @@ class OpenAIResponsesHandler(BaseTranslation): self._apply_guardrailed_tools_to_data( data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools") ) - written_back: Final = self._written_back_request_fields( - data, structured_messages, len(sent_texts), guardrailed_inputs - ) + written_back: Final = self._written_back_request_fields(data, structured_messages, guardrailed_inputs) if written_back is not None: data["input"] = list(written_back.input) # mutable-ok: JSON body if written_back.instructions is None: @@ -571,11 +553,10 @@ class OpenAIResponsesHandler(BaseTranslation): def _written_back_request_fields( data: Mapping[str, object], structured_messages: Sequence[AllMessageValues] | None, - sent_text_count: int, guardrailed_inputs: GenericGuardrailAPIInputs, ) -> _RequestFields | None: - guardrailed: Final = _guardrailed_structured_messages(structured_messages, sent_text_count, guardrailed_inputs) - if guardrailed is None: + guardrailed: Final = guardrailed_inputs.get("structured_messages") + if guardrailed is None or guardrailed is structured_messages: return None return _patch_or_convert_request_fields( data.get("input"), diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index d8296003ae9..16159d32a7f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -7,7 +7,7 @@ import fnmatch import os -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Literal, Optional import httpx @@ -24,7 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.llms.openai import ChatCompletionToolParam +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIMetadata, GenericGuardrailAPIRequest, @@ -150,6 +150,22 @@ def _extract_inbound_headers( return None +def _rows_with_unchanged_originals( + original_rows: Sequence[AllMessageValues] | None, + shown_rows: Sequence[AllMessageValues] | None, + returned_rows: Sequence[AllMessageValues], +) -> tuple[AllMessageValues, ...]: + """The request model drops row keys its message types do not declare, so a + row the server echoes back verbatim is restored to the original row object; + only rows the server actually changed reach the endpoint write-back.""" + if original_rows is None or shown_rows is None or len(returned_rows) != len(original_rows): + return tuple(returned_rows) + return tuple( + original if returned == shown else returned + for original, shown, returned in zip(original_rows, shown_rows, returned_rows) + ) + + class GenericGuardrailAPI(CustomGuardrail): """ Generic Guardrail API integration for LiteLLM. @@ -322,6 +338,8 @@ class GenericGuardrailAPI(CustomGuardrail): texts: list, images: list[str] | None, tools: list[ChatCompletionToolParam] | None, + structured_messages: Sequence[AllMessageValues] | None, + shown_messages: Sequence[AllMessageValues] | None, guardrail_response: GenericGuardrailAPIResponse, ) -> GenericGuardrailAPIInputs: # Action is NONE or no modifications needed @@ -336,6 +354,12 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools + if guardrail_response.structured_messages: + return_inputs["structured_messages"] = list( # mutable-ok: guardrail inputs take a list + _rows_with_unchanged_originals( + structured_messages, shown_messages, guardrail_response.structured_messages + ) + ) if guardrail_response.stream_holdback_chars is not None: return_inputs["stream_holdback_chars"] = guardrail_response.stream_holdback_chars return return_inputs @@ -473,6 +497,8 @@ class GenericGuardrailAPI(CustomGuardrail): texts=texts, images=images, tools=tools, + structured_messages=structured_messages, + shown_messages=guardrail_request.structured_messages, guardrail_response=guardrail_response, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 72c87a793a5..41d3b202344 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -287,7 +287,7 @@ class PromptSecurityGuardrail(CustomGuardrail): structured_messages, modified_messages ) if rewritten_messages is not None: - inputs["structured_messages"] = rewritten_messages + inputs["structured_messages"] = list(rewritten_messages) # mutable-ok: guardrail inputs take a list return inputs @@ -298,7 +298,7 @@ class PromptSecurityGuardrail(CustomGuardrail): self, structured_messages: Sequence[AllMessageValues], modified_messages: Sequence[Mapping[str, object]], - ) -> list[AllMessageValues] | None: + ) -> tuple[AllMessageValues, ...] | None: sent_indices: Final = tuple( index for index, message in enumerate(structured_messages) if self._is_sent_to_protect(message) ) @@ -313,9 +313,7 @@ class PromptSecurityGuardrail(CustomGuardrail): ) if len(replacements) != len(sent_indices): return None - return [ # mutable-ok: guardrail inputs take a list - replacements.get(index, message) for index, message in enumerate(structured_messages) - ] + return tuple(replacements.get(index, message) for index, message in enumerate(structured_messages)) async def _apply_guardrail_on_response( self, diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index 4a868c48352..44e2cc2404f 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -1,4 +1,5 @@ -from typing import Any, Final, Literal +from collections.abc import Mapping, Sequence +from typing import Any, Final, Literal, cast # noqa: TID251 # JSON chat rows have no typed constructor across roles from pydantic import BaseModel, ConfigDict, Field from typing_extensions import TypedDict @@ -158,12 +159,21 @@ def coerce_stream_holdback_value(value: Any) -> int: return 0 +def structured_messages_from_response(value: object) -> Sequence[AllMessageValues] | None: + if not isinstance(value, list): + return None + if not all(isinstance(message, Mapping) and isinstance(message.get("role"), str) for message in value): + return None + return cast("Sequence[AllMessageValues]", value) # cast-ok: JSON rows checked for a role, the same trust texts get + + class GenericGuardrailAPIResponse: """Response model for the Generic Guardrail API""" texts: list[str] | None images: list[str] | None tools: list[GuardrailToolParam] | None + structured_messages: Sequence[AllMessageValues] | None action: str blocked_reason: str | None stream_holdback_chars: list[int] | None @@ -176,12 +186,14 @@ class GenericGuardrailAPIResponse: images: list[str] | None = None, tools: list[GuardrailToolParam] | None = None, stream_holdback_chars: list[int] | None = None, + structured_messages: Sequence[AllMessageValues] | None = None, ) -> None: self.action = action self.blocked_reason = blocked_reason self.texts = texts self.images = images self.tools = tools + self.structured_messages = structured_messages # Number of trailing chars, indexed the same as ``texts``, that the # framework must withhold from streaming emission until the next # processing round (word-boundary safety for text transformations). @@ -200,4 +212,5 @@ class GenericGuardrailAPIResponse: images=data.get("images"), tools=data.get("tools"), stream_holdback_chars=stream_holdback_chars, + structured_messages=structured_messages_from_response(data.get("structured_messages")), ) diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index b4b467773da..394f134e99c 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -8,7 +8,7 @@ with guardrail transformations. import copy from collections.abc import Callable from typing import Any, List, Literal, Optional, Tuple -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import logging @@ -31,6 +31,7 @@ from litellm.llms.openai.responses.guardrail_translation.handler import ( OpenAIResponsesHandler, ) from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools +from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import GenericGuardrailAPI from litellm.types.llms.openai import ChatCompletionToolCallChunk from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, @@ -2342,103 +2343,94 @@ SSN = "123-45-6789" REDACTED_SSN = "" -def _slot_texts(message: dict) -> list[str]: - content = message.get("content") - if isinstance(content, str): - return [content] - if isinstance(content, list): - return [part["text"] for part in content if isinstance(part, dict) and isinstance(part.get("text"), str)] - return [] +def _redacted(value: object) -> object: + if isinstance(value, str): + return value.replace(SSN, REDACTED_SSN) + if isinstance(value, list): + return [{**part, "text": _redacted(part["text"])} if "text" in part else part for part in value] + return value -class PerMessageRedactionGuardrail(CustomGuardrail): - """Guardrail that answers one redacted text per message it was shown and hands - back only texts, the way Prompt Security in modify mode and a generic guardrail - API server that scans per message do.""" +def _per_message_guardrail_server(structured_messages_in_answer: bool) -> Callable[..., MagicMock]: + """Answers one redacted text per chat row it was shown, the way a guardrail + that scans per message does, and optionally the rewritten rows themselves.""" - def __init__(self, extra_texts: int = 0): - super().__init__(guardrail_name="per-message-redactor") - self.extra_texts = extra_texts + def post(url: str, json: dict, headers: dict) -> MagicMock: + rows = json["structured_messages"] + answer: dict = { + "action": "GUARDRAIL_INTERVENED", + "texts": [_redacted(row["content"]) if isinstance(row.get("content"), str) else "" for row in rows], + } + if structured_messages_in_answer: + answer["structured_messages"] = [{**row, "content": _redacted(row.get("content"))} for row in rows] + response = MagicMock() + response.json.return_value = answer + response.raise_for_status = MagicMock() + return response - async def apply_guardrail( - self, - inputs: GenericGuardrailAPIInputs, - request_data: dict, - input_type: Literal["request", "response"], - logging_obj: Optional[Any] = None, - ) -> GenericGuardrailAPIInputs: - messages = inputs.get("structured_messages") or [] - texts = [text.replace(SSN, REDACTED_SSN) for message in messages for text in _slot_texts(message)] - return {**inputs, "texts": texts + ["junk"] * self.extra_texts} + return post -class TestPerMessageTextWriteBack: - """A guardrail that rewrites one text per message it saw must land on the - instructions and the input items those messages came from, not be rejected.""" +def _per_message_redactor() -> GenericGuardrailAPI: + return GenericGuardrailAPI( + api_base="https://guardrail.test", + guardrail_name="per-message-redactor", + event_hook="pre_call", + default_on=True, + ) + + +def _tool_replay_request() -> dict: + return { + "model": "gpt-5.6", + "instructions": "Never repeat the SSN " + SSN + " back.", + "input": [ + {"role": "user", "content": "Look up " + SSN + " for me."}, + {"type": "function_call", "call_id": "call_1", "name": "lookup_customer", "arguments": '{"id": "42"}'}, + {"type": "function_call_output", "call_id": "call_1", "output": '{"ssn": "' + SSN + '"}'}, + ], + } + + +class TestPerMessageRewriteWriteBack: + """A guardrail that rewrites per chat row hands the rows back as + structured_messages, and the handler lands them on the instructions and the + input items they came from; the same rewrite handed back as texts alone has + no item to land on and is rejected by name instead of sent unrewritten.""" @pytest.mark.asyncio - async def test_instructions_plus_tool_replay_gets_each_rewrite_in_place(self): - handler = OpenAIResponsesHandler() - function_call_item = { - "type": "function_call", - "call_id": "call_1", - "name": "lookup_customer", - "arguments": '{"query": "' + SSN + '"}', - } - data = { - "model": "gpt-5.6", - "instructions": "Never repeat the SSN " + SSN + " back.", - "input": [ - {"role": "user", "content": "Look up " + SSN + " for me."}, - function_call_item, - {"type": "function_call_output", "call_id": "call_1", "output": '{"ssn": "' + SSN + '"}'}, - ], - } + async def test_structured_rows_land_on_instructions_and_tool_output(self): + guardrail = _per_message_redactor() + data = _tool_replay_request() + function_call_item = data["input"][1] - result = await handler.process_input_messages(data, PerMessageRedactionGuardrail()) + with patch.object(guardrail.async_handler, "post", side_effect=_per_message_guardrail_server(True)): + result = await OpenAIResponsesHandler().process_input_messages(data, guardrail) assert result["instructions"] == "Never repeat the SSN " + REDACTED_SSN + " back." - assert [item.get("type", item.get("role")) for item in result["input"]] == [ - "user", - "function_call", - "function_call_output", - ] - assert _slot_texts(result["input"][0]) == ["Look up " + REDACTED_SSN + " for me."] + assert _texts(result["input"][0]) == ["Look up " + REDACTED_SSN + " for me."] assert result["input"][1] == function_call_item - assert result["input"][2]["output"] == '{"ssn": "' + REDACTED_SSN + '"}' - assert result["input"][2]["call_id"] == "call_1" - - @pytest.mark.asyncio - async def test_string_input_with_instructions_keeps_the_two_apart(self): - handler = OpenAIResponsesHandler() - data = { - "model": "gpt-5.6", - "instructions": "Redact " + SSN + " everywhere.", - "input": "My SSN is " + SSN + ".", + assert result["input"][2] == { + "type": "function_call_output", + "call_id": "call_1", + "output": '{"ssn": "' + REDACTED_SSN + '"}', } - result = await handler.process_input_messages(data, PerMessageRedactionGuardrail()) - - assert result["instructions"] == "Redact " + REDACTED_SSN + " everywhere." - assert [_slot_texts(item) for item in result["input"]] == [["My SSN is " + REDACTED_SSN + "."]] - @pytest.mark.asyncio - async def test_count_matching_neither_texts_nor_messages_is_still_rejected(self): + async def test_texts_only_per_message_answer_is_rejected_by_name(self): from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite - handler = OpenAIResponsesHandler() - original_input = [ - {"role": "user", "content": "Look up " + SSN + " for me."}, - {"type": "function_call_output", "call_id": "call_1", "output": '{"ssn": "' + SSN + '"}'}, - ] - data = {"model": "gpt-5.6", "instructions": "Be terse.", "input": copy.deepcopy(original_input)} + guardrail = _per_message_redactor() + data = _tool_replay_request() + original = copy.deepcopy(data) - with pytest.raises(UnappliableRequestRewrite) as excinfo: - await handler.process_input_messages(data, PerMessageRedactionGuardrail(extra_texts=1)) + with patch.object(guardrail.async_handler, "post", side_effect=_per_message_guardrail_server(False)): + with pytest.raises(UnappliableRequestRewrite) as excinfo: + await OpenAIResponsesHandler().process_input_messages(data, guardrail) assert excinfo.value.guardrail_name == "per-message-redactor" - assert data["input"] == original_input - assert data["instructions"] == "Be terse." + assert data["input"] == original["input"] + assert data["instructions"] == original["instructions"] class TestProvenancePatching: diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 83cc9ae8bb9..cc9942e0e40 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -582,6 +582,111 @@ class TestGuardrailActions: assert result_images is None +class TestStructuredMessagesInResponse: + """A guardrail server that rewrites per chat row answers with the rewritten + rows as structured_messages, which the endpoint handlers write back by row.""" + + @pytest.mark.asyncio + async def test_returned_rows_are_handed_back_as_structured_messages( + self, generic_guardrail, mock_request_data_input + ): + rewritten_rows = [ + {"role": "system", "content": "Never repeat an SSN."}, + {"role": "user", "content": "Look up [REDACTED] for me."}, + {"role": "tool", "tool_call_id": "call_1", "content": '{"ssn": "[REDACTED]"}'}, + ] + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "texts": ["Never repeat an SSN.", "Look up [REDACTED] for me.", '{"ssn": "[REDACTED]"}'], + "structured_messages": rewritten_rows, + } + mock_response.raise_for_status = MagicMock() + + with patch.object(generic_guardrail.async_handler, "post", return_value=mock_response): + guardrailed_inputs = await generic_guardrail.apply_guardrail( + inputs={"texts": ["Look up 123-45-6789 for me."]}, + request_data=mock_request_data_input, + input_type="request", + ) + + assert guardrailed_inputs["structured_messages"] == rewritten_rows + assert guardrailed_inputs["texts"] == mock_response.json.return_value["texts"] + + @pytest.mark.asyncio + async def test_rows_echoed_back_as_shown_keep_their_original_keys( + self, generic_guardrail, mock_request_data_input + ): + tool_call_row = { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}, "index": 0} + ], + } + original_rows = [ + {"role": "user", "content": "Look up 123-45-6789 for me.", "name": "pat"}, + tool_call_row, + {"role": "tool", "tool_call_id": "call_1", "content": '{"ssn": "123-45-6789"}'}, + ] + + def echo_with_tool_output_redacted(url, json, headers): + shown_rows = json["structured_messages"] + assert "index" not in shown_rows[1]["tool_calls"][0] + assert "name" not in shown_rows[0] + answer = MagicMock() + answer.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "texts": ["Look up 123-45-6789 for me."], + "structured_messages": [ + shown_rows[0], + shown_rows[1], + {**shown_rows[2], "content": '{"ssn": "[REDACTED]"}'}, + ], + } + answer.raise_for_status = MagicMock() + return answer + + with patch.object(generic_guardrail.async_handler, "post", side_effect=echo_with_tool_output_redacted): + guardrailed_inputs = await generic_guardrail.apply_guardrail( + inputs={"texts": ["Look up 123-45-6789 for me."], "structured_messages": original_rows}, + request_data=mock_request_data_input, + input_type="request", + ) + + returned_rows = guardrailed_inputs["structured_messages"] + assert returned_rows[0] is original_rows[0] + assert returned_rows[1] is tool_call_row + assert returned_rows[2] == {"role": "tool", "tool_call_id": "call_1", "content": '{"ssn": "[REDACTED]"}'} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "structured_messages", + [[], [{"content": "a row with no role"}], "not a list"], + ids=["empty", "no_role", "not_a_list"], + ) + async def test_rows_that_are_not_chat_messages_are_ignored( + self, generic_guardrail, mock_request_data_input, structured_messages + ): + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "texts": ["[REDACTED]"], + "structured_messages": structured_messages, + } + mock_response.raise_for_status = MagicMock() + + with patch.object(generic_guardrail.async_handler, "post", return_value=mock_response): + guardrailed_inputs = await generic_guardrail.apply_guardrail( + inputs={"texts": ["Look up 123-45-6789 for me."]}, + request_data=mock_request_data_input, + input_type="request", + ) + + assert "structured_messages" not in guardrailed_inputs + assert guardrailed_inputs["texts"] == ["[REDACTED]"] + + class TestImageSupport: """Test image handling in guardrail requests""" diff --git a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py index 9e83098eb04..70083e50f01 100644 --- a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py @@ -1,5 +1,6 @@ import asyncio import base64 +from collections.abc import Mapping, Sequence from unittest.mock import AsyncMock, patch import pytest @@ -12,6 +13,7 @@ from litellm.proxy.guardrails.guardrail_hooks.prompt_security.prompt_security im PromptSecurityGuardrailMissingSecrets, ) from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2 +from litellm.types.llms.openai import AllMessageValues def test_prompt_security_guard_config(monkeypatch: pytest.MonkeyPatch): @@ -174,7 +176,7 @@ async def test_apply_guardrail_modify_request(monkeypatch: pytest.MonkeyPatch): assert result["texts"] == ["User prompt with PII: SSN [REDACTED]"] -def _modify_response(modified_messages: list) -> Response: +def _modify_response(modified_messages: Sequence[Mapping[str, object]]) -> Response: mock_response = Response( json={"result": {"prompt": {"action": "modify", "modified_messages": modified_messages}}}, status_code=200, @@ -184,7 +186,7 @@ def _modify_response(modified_messages: list) -> Response: return mock_response -def _tool_replay_messages() -> list: +def _tool_replay_messages() -> list[AllMessageValues]: return [ {"role": "system", "content": "Never echo an SSN like 123-45-6789."}, { @@ -224,7 +226,9 @@ async def test_modify_returns_structured_messages_with_tool_rows_kept(monkeypatc ] with patch.object(guardrail.async_handler, "post", return_value=_modify_response(modified_messages)): - result = await guardrail.apply_guardrail(inputs=inputs, request_data={"messages": messages}, input_type="request") + result = await guardrail.apply_guardrail( + inputs=inputs, request_data={"messages": messages}, input_type="request" + ) assert result["structured_messages"] == [ {"role": "system", "content": "Never echo an SSN like [REDACTED]."}, @@ -257,7 +261,9 @@ async def test_modify_with_unexpected_message_count_keeps_texts_only(monkeypatch modified_messages = [{"role": "user", "content": "Look up [REDACTED]"}] with patch.object(guardrail.async_handler, "post", return_value=_modify_response(modified_messages)): - result = await guardrail.apply_guardrail(inputs=inputs, request_data={"messages": messages}, input_type="request") + result = await guardrail.apply_guardrail( + inputs=inputs, request_data={"messages": messages}, input_type="request" + ) assert result["structured_messages"] is messages assert result["texts"] == ["Look up [REDACTED]"] From 93f3911b32e04c124027e8c5a12961fdcb0fca90 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:22:49 -0700 Subject: [PATCH 026/196] fix(guardrails): drop the types import CodeQL reads as a package cycle --- litellm/llms/base_llm/guardrail_translation/utils.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 1172c93959b..34d648cf184 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import json from collections.abc import Callable, Iterator, Mapping, Sequence -from types import MappingProxyType from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row has no typed constructor across roles from pydantic import BaseModel @@ -390,13 +389,10 @@ def _part_with_text(part: object, text: str) -> object: def _content_with_slot_texts(content: Sequence[object], texts: Sequence[str]) -> Sequence[object]: - text_part_indices: Final = tuple( - index for index, part in enumerate(content) if _content_part_text(part) is not None - ) - replacement_by_index: Final = MappingProxyType(dict(zip(text_part_indices, texts))) + remaining_texts: Final = iter(texts) return [ # mutable-ok: message content stays a JSON list - _part_with_text(part, replacement_by_index[index]) if index in replacement_by_index else part - for index, part in enumerate(content) + _part_with_text(part, next(remaining_texts)) if _content_part_text(part) is not None else part + for part in content ] From 1760fe628436fac7fb292fae9ecdf6016a51fc55 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:22:49 -0700 Subject: [PATCH 027/196] refactor(guardrails): return a fresh inputs mapping from the Prompt Security modify branch --- .../prompt_security/prompt_security.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 41d3b202344..5d533e98ff6 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -37,6 +37,18 @@ class PromptSecurityGuardrailMissingSecrets(Exception): pass +def _inputs_with_modifications( + inputs: GenericGuardrailAPIInputs, + modified_texts: list[str], + rewritten_messages: Sequence[AllMessageValues] | None, +) -> GenericGuardrailAPIInputs: + texts_patch: Final[GenericGuardrailAPIInputs] = {"texts": modified_texts} if modified_texts else {} + messages_patch: Final[GenericGuardrailAPIInputs] = ( + {"structured_messages": list(rewritten_messages)} if rewritten_messages is not None else {} + ) + return {**inputs, **texts_patch, **messages_patch} + + class _ProtectVerdict(TypedDict, total=False): """One side (``prompt`` or ``response``) of an ``/api/protect`` verdict.""" @@ -280,14 +292,11 @@ class PromptSecurityGuardrail(CustomGuardrail): ) elif action == "modify": modified_messages: Final = result.get("modified_messages", []) - modified_texts: Final = self._extract_texts_from_messages(modified_messages) - if modified_texts: - inputs["texts"] = modified_texts - rewritten_messages: Final = self._structured_messages_with_modifications( - structured_messages, modified_messages + return _inputs_with_modifications( + inputs, + self._extract_texts_from_messages(modified_messages), + self._structured_messages_with_modifications(structured_messages, modified_messages), ) - if rewritten_messages is not None: - inputs["structured_messages"] = list(rewritten_messages) # mutable-ok: guardrail inputs take a list return inputs From 23a98cb85134342e20ccb7387875c9b0037bc7d2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:22:49 -0700 Subject: [PATCH 028/196] chore(ui): regenerate schema.d.ts after merging main --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..839aa52fa84 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16886,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) From 49b2d71057d4cff4e3a4baad843a7db7ac35c7c2 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Sun, 13 Sep 2026 08:47:47 +0000 Subject: [PATCH 029/196] test(guardrails): type the native lifecycle logging_only test double Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_litellm/integrations/test_custom_guardrail.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 8095be3c0de..bb29bfed283 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -2902,9 +2902,15 @@ class _NativeLifecycleLoggingGuardrail(CustomGuardrail): guardrail_name="native-logging-guardrail", event_hook=GuardrailEventHooks.logging_only, ) - self.calls: list = [] + self.calls: list[tuple[Literal["request", "response"], list[str]]] = [] - async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], + input_type: Literal["request", "response"], + logging_obj: "LiteLLMLoggingObj | None" = None, + ) -> GenericGuardrailAPIInputs: self.calls.append((input_type, list(inputs.get("texts") or []))) return inputs From 76b26e41abfe6e72dd846e7272dcb45069b98b73 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:07:53 +0000 Subject: [PATCH 030/196] fix(router): cool down team deployments on 429 when a sibling serves the same public model Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 10 ++++ litellm/router_utils/cooldown_handlers.py | 5 +- .../router_utils/test_cooldown_handlers.py | 50 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 8865543badd..4929b17f7fc 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1553,6 +1553,16 @@ class Router: return False return sum(len(self.model_name_to_deployment_indices.get(member) or ()) for member in group.models) > 1 + def team_model_has_alternatives(self, deployment_id: str) -> bool: + deployment: Final = self.get_deployment(model_id=deployment_id) + if deployment is None: + return False + team_id: Final = deployment.model_info.team_id + public_model_name: Final = deployment.model_info.team_public_model_name + if team_id is None or public_model_name is None: + return False + return len(self.team_model_to_deployment_indices.get((team_id, public_model_name)) or ()) > 1 + _OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY}) def _get_request_routing_strategy_override(self, request_kwargs: dict | None) -> str | None: diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index f722b6fd20c..027f0a9ca05 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -343,8 +343,9 @@ def _should_cooldown_deployment( model_group: Final = litellm_router_instance.get_model_group(id=deployment) is_single_deployment_model_group = False if model_group is not None and len(model_group) == 1: - is_single_deployment_model_group = not litellm_router_instance.routing_group_has_alternatives( - requested_model_group + is_single_deployment_model_group = not ( + litellm_router_instance.routing_group_has_alternatives(requested_model_group) + or litellm_router_instance.team_model_has_alternatives(deployment) ) ## CHECK DEPLOYMENT-LEVEL POLICY FIRST (overrides router-level) diff --git a/tests/test_litellm/router_utils/test_cooldown_handlers.py b/tests/test_litellm/router_utils/test_cooldown_handlers.py index 7ee0ed3701b..6f66bb863cc 100644 --- a/tests/test_litellm/router_utils/test_cooldown_handlers.py +++ b/tests/test_litellm/router_utils/test_cooldown_handlers.py @@ -437,3 +437,53 @@ class TestRoutingGroupCooldownAlternatives: ) is False ) + + +class TestTeamModelCooldownAlternatives: + def _router(self, team_deployments: int): + from litellm import Router + + return Router( + model_list=[ + { + "model_name": f"model_name_team-1_{i}", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, + "model_info": { + "id": f"team-deploy-{i}", + "team_id": "team-1", + "team_public_model_name": "team-gpt-4o-mini", + }, + } + for i in range(team_deployments) + ] + ) + + def test_429_on_team_deployment_with_sibling_cools_down(self): + from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment + + router = self._router(team_deployments=2) + assert ( + _should_cooldown_deployment( + litellm_router_instance=router, + deployment="team-deploy-0", + exception_status=429, + original_exception=Exception("rate limited"), + requested_model_group="team-gpt-4o-mini", + ) + is True + ) + + def test_429_on_only_team_deployment_keeps_single_deployment_exemption(self): + from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment + + router = self._router(team_deployments=1) + assert ( + _should_cooldown_deployment( + litellm_router_instance=router, + deployment="team-deploy-0", + exception_status=429, + original_exception=Exception("rate limited"), + requested_model_group="team-gpt-4o-mini", + ) + is False + ) From 073d4fe2b01500526829523f6596a60428fbead9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:11:06 -0700 Subject: [PATCH 031/196] fix(responses): route mid-stream error events through exception_type so content_policy_fallbacks fire Mid-stream error events on the streaming Responses API were all raised as APIError, so a content_policy_violation event never matched the router's content-policy fallback dispatch and the client got the raw error instead of the fallback model's answer. Map each error event's code and status through the existing exception_type mapping, matching the non-streaming path, and unwrap the typed ContentPolicyViolationError and ContextWindowExceededError so the router routes them to the configured content_policy_fallbacks and context_window_fallbacks. --- litellm/responses/streaming_iterator.py | 48 ++++-- litellm/router.py | 10 +- .../test_streaming_iterator_error_events.py | 157 ++++++++++++++++-- tests/test_litellm/test_router.py | 105 ++++++++++++ 4 files changed, 288 insertions(+), 32 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 40ff88fc557..b3426fbbfef 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -31,6 +31,7 @@ from litellm.litellm_core_utils.llm_response_utils.response_metadata import ( update_response_metadata, ) from litellm.litellm_core_utils.thread_pool_executor import executor +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils from litellm.types.llms.openai import ( @@ -221,6 +222,13 @@ def _status_code_for_error_fields(error_type: str | None, error_code: str | None ) +def _mid_stream_fallback_eligible(mapped_exception: Exception) -> bool: + if isinstance(mapped_exception, (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError)): + return True + status_code: Final = getattr(mapped_exception, "status_code", None) + return not isinstance(status_code, int) or status_code >= 500 or status_code == 429 + + class BaseResponsesAPIStreamingIterator: """ Base class for streaming iterators that process responses from the Responses API. @@ -521,15 +529,8 @@ class BaseResponsesAPIStreamingIterator: getattr(self.completed_response, "response", None) if self.completed_response else None ) error_info: Final = getattr(response_obj, "error", None) if response_obj else None - error_message, error_type, error_code = _error_event_fields(error_info) self._record_failed_response_usage(response_obj) - exception: Final = litellm.APIError( - status_code=_status_code_for_error_fields(error_type, error_code), - message=error_message, - llm_provider=self.custom_llm_provider or "", - model=self.model or "", - ) - self._handle_failure(exception) + self._handle_failure(self._map_error_event_exception(error_info)) def _record_failed_response_usage(self, response_obj: ResponsesAPIResponse | None) -> None: if response_obj is None or self.logging_obj is None: @@ -551,6 +552,26 @@ class BaseResponsesAPIStreamingIterator: self.logging_obj._response_cost_calculator(result=response_obj) or 0.0 ) + def _map_error_event_exception(self, error_obj: object) -> Exception: + error_message, error_type, error_code = _error_event_fields(error_obj) + status_code: Final = _status_code_for_error_fields(error_type, error_code) + error_body: Final = {"message": error_message, "type": error_type, "code": error_code} + provider_exception: Final = BaseLLMException( + status_code=status_code, + message=f"Error code: {status_code} - {{'error': {error_body}}}", + body=error_body, + ) + try: + return litellm.exception_type( + model=self.model or "", + custom_llm_provider=self.custom_llm_provider or "", + original_exception=provider_exception, + completion_kwargs={}, + extra_kwargs={}, + ) + except Exception as mapped_exception: + return mapped_exception + def _maybe_raise_for_error_event(self, result: object) -> None: chunk_type: Final = getattr(result, "type", None) if chunk_type not in ("error", "response.failed"): @@ -562,15 +583,8 @@ class BaseResponsesAPIStreamingIterator: else getattr(result, "error", None) ) - error_message, error_type, error_code = _error_event_fields(error_obj) - status_code: Final = _status_code_for_error_fields(error_type, error_code) - mapped_exception: Final = litellm.APIError( - status_code=status_code, - message=error_message, - llm_provider=self.custom_llm_provider or "", - model=self.model or "", - ) - if 400 <= status_code < 500 and status_code != 429: + mapped_exception: Final = self._map_error_event_exception(error_obj) + if not _mid_stream_fallback_eligible(mapped_exception): raise mapped_exception raise MidStreamFallbackError( message=str(mapped_exception), diff --git a/litellm/router.py b/litellm/router.py index 8865543badd..79854a11150 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -3271,8 +3271,16 @@ class Router: kwargs=initial_kwargs, metadata_variable_name="litellm_metadata", ) + fallback_trigger: Final[Exception] = ( + e.original_exception + if isinstance( + e.original_exception, + (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError), + ) + else e + ) fallback_response = await self.async_function_with_fallbacks_common_utils( - e=e, + e=fallback_trigger, disable_fallbacks=False, fallbacks=fallbacks, context_window_fallbacks=context_window_fallbacks, diff --git a/tests/test_litellm/responses/test_streaming_iterator_error_events.py b/tests/test_litellm/responses/test_streaming_iterator_error_events.py index ad74861c096..73afbb5e63a 100644 --- a/tests/test_litellm/responses/test_streaming_iterator_error_events.py +++ b/tests/test_litellm/responses/test_streaming_iterator_error_events.py @@ -1,9 +1,14 @@ """ Regression: in-stream error events (type="error", type="response.failed") must raise instead of being returned as benign chunks, mirroring chat streaming -semantics (_handle_stream_fallback_error): non-retriable 4xx (except 429) -raise litellm.APIError directly; 429 and 5xx are wrapped in -MidStreamFallbackError so the Router's mid-stream fallback machinery fires. +semantics (_handle_stream_fallback_error). The event's code, type and status go +through litellm.exception_type, so each event raises the same typed exception +the non-streaming path raises for that provider error: non-retriable 4xx +(except 429) raise that typed exception directly, while 429, 5xx, +ContentPolicyViolationError and ContextWindowExceededError are wrapped in +MidStreamFallbackError so the Router's mid-stream fallback machinery fires and +its content_policy_fallbacks / context_window_fallbacks dispatch sees the +trigger it matches on. Status mapping must consider both the OpenAI error `type` (e.g. "invalid_request_error") and `code` (e.g. "invalid_prompt", @@ -66,12 +71,12 @@ def test_maybe_raise_for_error_event_wraps_unknown_error_in_mid_stream_fallback( with pytest.raises(MidStreamFallbackError) as exc_info: iterator._maybe_raise_for_error_event(chunk) assert exc_info.value.status_code == 500 - assert isinstance(exc_info.value.original_exception, litellm.APIError) + assert isinstance(exc_info.value.original_exception, litellm.InternalServerError) assert exc_info.value.original_exception.status_code == 500 def test_maybe_raise_for_error_event_maps_rate_limit_code_to_429_mid_stream_fallback(): - """429 is retriable: it must be wrapped so the Router can fall back, carrying the mapped APIError.""" + """429 is retriable: it must be wrapped so the Router can fall back, carrying the mapped RateLimitError.""" iterator = _make_iterator() chunk = _make_error_chunk("tokens", "rate_limit_exceeded", "Too many requests") with pytest.raises(MidStreamFallbackError) as exc_info: @@ -79,15 +84,15 @@ def test_maybe_raise_for_error_event_maps_rate_limit_code_to_429_mid_stream_fall assert exc_info.value.status_code == 429 assert exc_info.value.generated_content == "" assert exc_info.value.is_pre_first_chunk is True - assert isinstance(exc_info.value.original_exception, litellm.APIError) + assert isinstance(exc_info.value.original_exception, litellm.RateLimitError) assert exc_info.value.original_exception.status_code == 429 def test_maybe_raise_for_error_event_maps_invalid_request_type_to_400(): - """Client errors classified via the `type` field must raise APIError directly (no fallback).""" + """Client errors classified via the `type` field must raise BadRequestError directly (no fallback).""" iterator = _make_iterator() chunk = _make_error_chunk("invalid_request_error", "invalid_prompt", "bad request") - with pytest.raises(litellm.APIError) as exc_info: + with pytest.raises(litellm.BadRequestError) as exc_info: iterator._maybe_raise_for_error_event(chunk) assert exc_info.value.status_code == 400 assert not isinstance(exc_info.value, MidStreamFallbackError) @@ -99,12 +104,84 @@ def test_maybe_raise_for_error_event_maps_context_length_code_to_400(): chunk = Mock() chunk.type = "error" chunk.error = {"code": "context_length_exceeded", "message": "too long"} - with pytest.raises(litellm.APIError) as exc_info: + with pytest.raises(litellm.BadRequestError) as exc_info: iterator._maybe_raise_for_error_event(chunk) assert exc_info.value.status_code == 400 assert not isinstance(exc_info.value, MidStreamFallbackError) +def test_maybe_raise_for_error_event_wraps_context_window_exceeded_for_context_window_fallbacks(): + """A context-length error event maps to ContextWindowExceededError exactly like the non-streaming + path and is wrapped so the Router's context_window_fallbacks dispatch fires mid-stream.""" + iterator = _make_iterator() + chunk = _make_error_chunk( + "invalid_request_error", + "context_length_exceeded", + "This model's maximum context length is 128000 tokens. However, your messages resulted in 130000 tokens.", + ) + with pytest.raises(MidStreamFallbackError) as exc_info: + iterator._maybe_raise_for_error_event(chunk) + assert isinstance(exc_info.value.original_exception, litellm.ContextWindowExceededError) + assert exc_info.value.status_code == 400 + + +CONTENT_POLICY_MESSAGE = "This content was flagged for possible cybersecurity risk. The response was halted mid-stream." + + +@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"]) +def test_maybe_raise_for_error_event_wraps_content_policy_violation_for_content_policy_fallbacks( + custom_llm_provider: str, +): + """Regression: a content_policy_violation error event used to raise a bare APIError, so the Router's + content_policy_fallbacks never fired. It must map to ContentPolicyViolationError (the same exception the + non-streaming path raises) and be wrapped so the Router's mid-stream fallback catches it.""" + iterator = _make_iterator() + iterator.custom_llm_provider = custom_llm_provider + chunk = _make_error_chunk("invalid_request_error", "content_policy_violation", CONTENT_POLICY_MESSAGE) + with pytest.raises(MidStreamFallbackError) as exc_info: + iterator._maybe_raise_for_error_event(chunk) + assert isinstance(exc_info.value.original_exception, litellm.ContentPolicyViolationError) + assert exc_info.value.original_exception.status_code == 400 + assert exc_info.value.status_code == 400 + assert exc_info.value.is_pre_first_chunk is True + assert CONTENT_POLICY_MESSAGE in str(exc_info.value.original_exception) + + +def test_maybe_raise_for_response_failed_event_wraps_content_policy_violation(): + iterator = _make_iterator() + chunk = _make_failed_chunk( + {"type": "invalid_request_error", "code": "content_policy_violation", "message": CONTENT_POLICY_MESSAGE} + ) + with pytest.raises(MidStreamFallbackError) as exc_info: + iterator._maybe_raise_for_error_event(chunk) + assert isinstance(exc_info.value.original_exception, litellm.ContentPolicyViolationError) + + +@pytest.mark.parametrize( + "error_type,error_code,expected_exception", + [ + ("invalid_request_error", "content_policy_violation", litellm.ContentPolicyViolationError), + ("tokens", "rate_limit_exceeded", litellm.RateLimitError), + ("invalid_request_error", "insufficient_quota", litellm.RateLimitError), + ("server_error", "internal_error", litellm.InternalServerError), + ("invalid_request_error", "invalid_prompt", litellm.BadRequestError), + ("invalid_request_error", "model_not_found", litellm.NotFoundError), + ("server_error", "vector_store_timeout", litellm.Timeout), + ], +) +def test_error_event_raises_the_same_typed_exception_as_the_non_streaming_path( + error_type: str, error_code: str, expected_exception: type[Exception] +): + iterator = _make_iterator() + chunk = _make_error_chunk(error_type, error_code, "provider message") + with pytest.raises((MidStreamFallbackError, expected_exception)) as exc_info: + iterator._maybe_raise_for_error_event(chunk) + raised = exc_info.value + typed_exception = raised.original_exception if isinstance(raised, MidStreamFallbackError) else raised + assert type(typed_exception) is expected_exception + assert "provider message" in str(typed_exception) + + def test_maybe_raise_for_error_event_maps_insufficient_quota_to_429(): """OpenAI returns HTTP 429 for insufficient_quota; it must not map to 400 even though its type is invalid_request_error-adjacent, and it must be wrapped for fallback.""" @@ -113,6 +190,7 @@ def test_maybe_raise_for_error_event_maps_insufficient_quota_to_429(): with pytest.raises(MidStreamFallbackError) as exc_info: iterator._maybe_raise_for_error_event(chunk) assert exc_info.value.status_code == 429 + assert isinstance(exc_info.value.original_exception, litellm.RateLimitError) def test_maybe_raise_for_error_event_passes_through_normal_chunk(): @@ -186,10 +264,43 @@ async def test_async_iterator_raises_mid_stream_fallback_on_rate_limit_error_eve assert exc_info.value.status_code == 429 assert exc_info.value.is_pre_first_chunk is True assert exc_info.value.generated_content == "" - assert isinstance(exc_info.value.original_exception, litellm.APIError) + assert isinstance(exc_info.value.original_exception, litellm.RateLimitError) assert exc_info.value.original_exception.status_code == 429 +@pytest.mark.asyncio +async def test_async_iterator_content_policy_violation_after_first_chunk_carries_generated_content(): + """The customer's case: text streams, then the provider halts the stream with a + content_policy_violation error event. The iterator must surface ContentPolicyViolationError + inside MidStreamFallbackError, together with the text already streamed.""" + iterator = _make_async_iterator_with_events( + [ + {"type": "response.output_text.delta", "delta": "partial "}, + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "content_policy_violation", + "message": CONTENT_POLICY_MESSAGE, + }, + }, + ] + ) + + chunks = [] + + async def _drain(): + async for chunk in iterator: + chunks.append(chunk) + + with pytest.raises(MidStreamFallbackError) as exc_info: + await _drain() + assert len(chunks) == 1 + assert isinstance(exc_info.value.original_exception, litellm.ContentPolicyViolationError) + assert exc_info.value.is_pre_first_chunk is False + assert exc_info.value.generated_content == "partial " + + @pytest.mark.asyncio async def test_async_iterator_error_after_first_chunk_carries_generated_content(): """An error after streamed output must expose the accumulated text so the router's @@ -265,7 +376,7 @@ def test_handle_logging_failed_response_maps_rate_limit_to_429(): ): iterator._handle_logging_failed_response() logged_exception = mock_run_async.call_args.kwargs["exception"] - assert isinstance(logged_exception, litellm.APIError) + assert isinstance(logged_exception, litellm.RateLimitError) assert logged_exception.status_code == 429 assert "throttled" in str(logged_exception) @@ -282,10 +393,28 @@ def test_handle_logging_failed_response_maps_type_field_to_400(): ): iterator._handle_logging_failed_response() logged_exception = mock_run_async.call_args.kwargs["exception"] - assert isinstance(logged_exception, litellm.APIError) + assert isinstance(logged_exception, litellm.BadRequestError) assert logged_exception.status_code == 400 +def test_handle_logging_failed_response_logs_content_policy_violation(): + """Failure logging must record the same typed exception the stream raises, so logging + integrations see a content policy violation instead of a generic APIError.""" + iterator = _make_iterator() + iterator.completed_response = _make_failed_chunk( + {"type": "invalid_request_error", "code": "content_policy_violation", "message": CONTENT_POLICY_MESSAGE} + ) + with ( + patch.object(import_module("litellm.responses.streaming_iterator"), "run_async_function") as mock_run_async, + patch.object(import_module("litellm.responses.streaming_iterator"), "executor"), + ): + iterator._handle_logging_failed_response() + logged_exception = mock_run_async.call_args.kwargs["exception"] + assert isinstance(logged_exception, litellm.ContentPolicyViolationError) + assert logged_exception.status_code == 400 + assert CONTENT_POLICY_MESSAGE in str(logged_exception) + + def test_handle_logging_failed_response_records_usage_and_cost(): """Usage on a response.failed event must reach failure spend accounting via combined_usage_object.""" iterator = _make_iterator() @@ -357,7 +486,7 @@ def test_sync_iterator_raises_mid_stream_fallback_on_rate_limit_error_event(): for _ in iterator: pass assert exc_info.value.status_code == 429 - assert isinstance(exc_info.value.original_exception, litellm.APIError) + assert isinstance(exc_info.value.original_exception, litellm.RateLimitError) def test_every_openai_sdk_response_error_code_has_explicit_status_mapping(): @@ -413,7 +542,7 @@ def test_maybe_raise_for_response_failed_event_maps_image_code_to_400(): chunk = Mock() chunk.type = "response.failed" chunk.response = mock_response_obj - with pytest.raises(litellm.APIError) as exc_info: + with pytest.raises(litellm.BadRequestError) as exc_info: iterator._maybe_raise_for_error_event(chunk) assert exc_info.value.status_code == 400 assert not isinstance(exc_info.value, MidStreamFallbackError) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b8a0d70f5bc..d74074dd149 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3654,6 +3654,111 @@ async def test_aresponses_streaming_iterator_fallback(): assert call_kwargs["disable_fallbacks"] is False +@pytest.mark.asyncio +async def test_aresponses_streaming_content_policy_error_event_routes_to_content_policy_fallback(): + """Regression: a mid-stream content_policy_violation error event never reached + content_policy_fallbacks. The iterator raised a bare APIError the wrapper does not + catch, and even once wrapped, the MidStreamFallbackError envelope was handed to the + fallback dispatch, whose isinstance branch on ContentPolicyViolationError never matched. + The stream below is the customer's shape: a raw OpenAI error event with code + content_policy_violation, transformed by the real OpenAI config, and the router must + call the content_policy_fallbacks target, not the general fallbacks one.""" + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator + + router = litellm.Router( + model_list=[ + {"model_name": "primary", "litellm_params": {"model": "openai/gpt-5.4", "api_key": "k1"}}, + { + "model_name": "content-fallback", + "litellm_params": {"model": "gemini/gemini-2.5-flash", "api_key": "k2"}, + }, + {"model_name": "general-fallback", "litellm_params": {"model": "openai/gpt-5-mini", "api_key": "k3"}}, + ], + fallbacks=[{"primary": ["general-fallback"]}], + content_policy_fallbacks=[{"primary": ["content-fallback"]}], + ) + error_event = { + "type": "error", + "sequence_number": 2, + "error": { + "type": "invalid_request_error", + "code": "content_policy_violation", + "message": "This content was flagged for possible cybersecurity risk. The response was halted mid-stream.", + "param": None, + }, + } + + async def aiter_bytes(): + yield f"data: {json.dumps(error_event)}\n\n".encode() + + raw_response = MagicMock() + raw_response.headers = {} + raw_response.aiter_bytes = aiter_bytes + logging_obj = MagicMock(spec=LiteLLMLogging) + logging_obj.model_call_details = {"litellm_params": {}} + logging_obj.completion_start_time = None + source = ResponsesAPIStreamingIterator( + response=raw_response, + model="gpt-5.4", + responses_api_provider_config=OpenAIResponsesAPIConfig(), + logging_obj=logging_obj, + custom_llm_provider="openai", + ) + fallback_chunks = [MagicMock(type="response.output_text.delta"), MagicMock(type="response.completed")] + fallback_call = AsyncMock(return_value=_AsyncList(fallback_chunks)) + + wrapped = await router._aresponses_streaming_iterator( + response=source, + initial_kwargs={ + "model": "primary", + "stream": True, + "input": "Hi", + "original_generic_function": fallback_call, + }, + ) + collected = [chunk async for chunk in wrapped] + + assert collected == fallback_chunks + fallback_call.assert_awaited_once() + assert fallback_call.await_args.kwargs["model"] == "gemini/gemini-2.5-flash" + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_unwraps_content_policy_trigger_for_fallback_dispatch(): + """The fallback dispatch matches on the trigger's own type, so the wrapper must hand it the + ContentPolicyViolationError carried inside MidStreamFallbackError, not the envelope.""" + router = _make_router_with_fallback("openai/gpt-5.4", "openai/gpt-5-mini") + content_policy_error = litellm.ContentPolicyViolationError( + message="flagged mid-stream", llm_provider="openai", model="openai/gpt-5.4" + ) + src = _make_responses_iterator( + chunks=[MagicMock(type="response.created")], + error=MidStreamFallbackError( + message=str(content_policy_error), + model="openai/gpt-5.4", + llm_provider="openai", + original_exception=content_policy_error, + is_pre_first_chunk=True, + ), + model="openai/gpt-5.4", + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + new=AsyncMock(return_value=_AsyncList([MagicMock(type="response.completed")])), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={"model": "openai/gpt-5.4", "stream": True, "input": "Hi"}, + ) + [chunk async for chunk in wrapped] + + mock_fallback_utils.assert_awaited_once() + assert mock_fallback_utils.await_args.kwargs["e"] is content_policy_error + + @pytest.mark.asyncio @pytest.mark.parametrize( "fallback_headers", From 330ba7cbf91d4db59f3e1b433aa937fcdaddbb2d Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:21:09 +0000 Subject: [PATCH 032/196] fix(ui): show the team alias on the model info page and in its raw JSON Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/ModelInfoEditForm.tsx | 10 ++- .../src/components/model_info_view.test.tsx | 78 +++++++++++++++++++ .../src/components/model_info_view.tsx | 11 ++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index d56e65237eb..fb4d90b5ea2 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -271,6 +271,7 @@ const displayCost = (localModelData: any, field: TouchedPricingField): string => interface ModelInfoEditFormProps { localModelData: any; modelData: { model_info: { team_id?: string | null } & Record }; + teamAlias: string | null; accessToken: string | null; isEditing: boolean; isSaving: boolean; @@ -341,6 +342,7 @@ const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, e const ModelInfoEditForm: React.FC = ({ localModelData, modelData, + teamAlias, accessToken, isEditing, isSaving, @@ -799,8 +801,12 @@ const ModelInfoEditForm: React.FC = ({
- Team ID - {modelData.model_info.team_id || "Not Set"} + Team + + {teamAlias + ? `${teamAlias} (${modelData.model_info.team_id})` + : modelData.model_info.team_id || "Not Set"} +
diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 3db9418dfb9..f714b8e5c4a 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -42,6 +42,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: (...args: any[]) => mockUseModelCostMap(...args), })); +const mockUseTeams = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: () => mockUseTeams(), +})); + const mockUsePtuCostAttributionEnabled = vi.fn(); vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => ({ usePtuCostAttributionEnabled: () => mockUsePtuCostAttributionEnabled(), @@ -102,6 +107,7 @@ describe("ModelInfoView", () => { }); vi.clearAllMocks(); mockUsePtuCostAttributionEnabled.mockReturnValue(false); + mockUseTeams.mockReturnValue({ data: undefined, isLoading: false, error: null }); mockUseModelsInfo.mockReturnValue({ data: { @@ -1305,6 +1311,78 @@ describe("ModelInfoView", () => { }); }); + describe("team alias", () => { + const teamModel = { + ...defaultModelData, + model_info: { ...defaultModelData.model_info, team_id: "team-1" }, + }; + + beforeEach(() => { + mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] }); + }); + + const readRawJson = async (user: ReturnType) => { + await user.click(await screen.findByRole("tab", { name: /raw json/i })); + const pre = await screen.findByText(/"model_name": "GPT-4"/, { selector: "pre" }); + return JSON.parse(pre.textContent ?? ""); + }; + + it("shows the team alias next to the team id and adds team_alias to the raw JSON", async () => { + mockUseTeams.mockReturnValue({ + data: [ + { team_id: "team-0", team_alias: "other" }, + { team_id: "team-1", team_alias: "alpha" }, + ], + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + render(, { wrapper }); + + expect(await screen.findByText("alpha (team-1)")).toBeInTheDocument(); + + const raw = await readRawJson(user); + expect(raw.model_info).toMatchObject({ team_id: "team-1", team_alias: "alpha" }); + const keys = Object.keys(raw.model_info); + expect(keys.indexOf("team_alias")).toBe(keys.indexOf("team_id") + 1); + }); + + it("falls back to the bare team id when the team is not in the caller's team list", async () => { + mockUseTeams.mockReturnValue({ + data: [{ team_id: "team-0", team_alias: "other" }], + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + render(, { wrapper }); + + expect(await screen.findByText("team-1")).toBeInTheDocument(); + + const raw = await readRawJson(user); + expect(raw.model_info.team_id).toBe("team-1"); + expect(raw.model_info).not.toHaveProperty("team_alias"); + }); + + it("shows Not Set and no team_alias for a model without a team", async () => { + mockUseModelsInfo.mockReturnValue({ data: { data: [defaultModelData] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [defaultModelData] }); + mockUseTeams.mockReturnValue({ + data: [{ team_id: "team-1", team_alias: "alpha" }], + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + render(, { wrapper }); + + expect(await screen.findByText("Team")).toBeInTheDocument(); + expect(screen.queryByText(/alpha/)).not.toBeInTheDocument(); + + const raw = await readRawJson(user); + expect(raw.model_info).not.toHaveProperty("team_alias"); + }); + }); + it("renders the provider card logo from the bundled provider map", async () => { render(, { wrapper }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 35afcdb2985..f25416327c0 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -169,6 +169,12 @@ export default function ModelInfoView({ // Keep modelData variable name for backwards compatibility const modelData = transformedModelData; + const teamAlias = teams?.find((team) => team.team_id === modelData?.model_info?.team_id)?.team_alias || null; + const rawModelInfoEntries = Object.entries(modelData?.model_info ?? {}).flatMap((entry) => + entry[0] === "team_id" && teamAlias ? [entry, ["team_alias", teamAlias]] : [entry], + ); + const rawModelData = modelData && { ...modelData, model_info: Object.fromEntries(rawModelInfoEntries) }; + const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, { teamId: modelData?.model_info?.team_id, isDbModel: modelData?.model_info?.db_model === true, @@ -765,6 +771,7 @@ export default function ModelInfoView({ -
{JSON.stringify(modelData, null, 2)}
+
+                {JSON.stringify(rawModelData, null, 2)}
+              
From d0a846c8be5ec1ae9036254eeb04575f6b406921 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:27:33 +0000 Subject: [PATCH 033/196] test(router): cover team_model_has_alternatives directly in the mapped router test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_router.py | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b8a0d70f5bc..7af60e01f7f 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -896,6 +896,40 @@ def test_arouter_test_team_model(): assert result is not None +def test_team_model_has_alternatives(): + def team_deployment(deployment_id: str, team_id: str, public_model_name: str): + return { + "model_name": f"model_name_{team_id}_{deployment_id}", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, + "model_info": { + "id": deployment_id, + "team_id": team_id, + "team_public_model_name": public_model_name, + }, + } + + router = litellm.Router( + model_list=[ + team_deployment("team-a-1", "team-a", "shared-model"), + team_deployment("team-a-2", "team-a", "shared-model"), + team_deployment("team-a-solo", "team-a", "solo-model"), + team_deployment("team-b-1", "team-b", "shared-model"), + { + "model_name": "plain-model", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, + "model_info": {"id": "plain-1"}, + }, + ], + ) + + assert router.team_model_has_alternatives("team-a-1") is True + assert router.team_model_has_alternatives("team-a-2") is True + assert router.team_model_has_alternatives("team-a-solo") is False + assert router.team_model_has_alternatives("team-b-1") is False + assert router.team_model_has_alternatives("plain-1") is False + assert router.team_model_has_alternatives("missing-deployment") is False + + def test_arouter_ignore_invalid_deployments(): """ Test that router.ignore_invalid_deployments is set to True From 37447c98f77116a54e3c902a64a64fae618c7996 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:28:43 -0700 Subject: [PATCH 034/196] fix(guardrails): reject per-message texts that cannot land on a string input or a Messages request --- .../chat/guardrail_translation/handler.py | 3 ++ .../base_llm/guardrail_translation/utils.py | 6 +++ .../chat/guardrail_translation/handler.py | 5 +- .../guardrail_translation/handler.py | 7 +-- .../test_anthropic_guardrail_handler.py | 52 +++++++++++++++++++ ...test_openai_responses_guardrail_handler.py | 35 +++++++++++++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 9d50345d70d..656e9978eff 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -44,6 +44,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( scoped_structured_message_indices, stream_item_field, stream_item_fingerprint, + unappliable_request_rewrite, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, @@ -570,6 +571,8 @@ class AnthropicMessagesHandler(BaseTranslation): preserve_system_messages=has_midturn_system_message, ) else: + if guardrailed_texts and len(guardrailed_texts) != len(scanned): + raise unappliable_request_rewrite(guardrail_to_apply.guardrail_name) # Step 3: Map guardrail responses back to original message structure await self._apply_guardrail_responses_to_input( messages=messages, diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 34d648cf184..a80e20c5404 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -411,3 +411,9 @@ def message_with_slot_texts(message: AllMessageValues, texts: Sequence[str]) -> rewritten_content: Final = texts[0] if isinstance(content, str) else _content_with_slot_texts(content, texts) rewritten: Final = {**message, "content": rewritten_content} # mutable-ok: chat rows stay JSON-plain dicts return cast("AllMessageValues", rewritten) # cast-ok: the same row with only its text slots swapped + + +def unappliable_request_rewrite(guardrail_name: str | None) -> Exception: + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + return UnappliableRequestRewrite(guardrail_name or "unknown") diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 56fda636e9a..ee68f8f6546 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -42,6 +42,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( stream_item_field, stream_item_fingerprint, stream_item_items, + unappliable_request_rewrite, ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -197,9 +198,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # Step 3: Map guardrail responses back to original message structure if guardrailed_texts and texts_to_check: if len(guardrailed_texts) != len(text_task_mappings): - from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite - - raise UnappliableRequestRewrite(guardrail_to_apply.guardrail_name or "unknown") + raise unappliable_request_rewrite(guardrail_to_apply.guardrail_name) await self._apply_guardrail_responses_to_input_texts( messages=messages, responses=guardrailed_texts, diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 2fe11d9f7bd..27ff55f120c 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -56,6 +56,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( stream_item_field, stream_item_fingerprint, stream_item_items, + unappliable_request_rewrite, ) from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools from litellm.responses.litellm_completion_transformation.transformation import ( @@ -495,13 +496,13 @@ class OpenAIResponsesHandler(BaseTranslation): data["instructions"] = written_back.instructions # rebind-ok: data is an out-param elif isinstance(input_data, str): guardrailed_texts: Final = guardrailed_inputs.get("texts") or () + if len(guardrailed_texts) > 1: + raise unappliable_request_rewrite(guardrail_to_apply.guardrail_name) data["input"] = guardrailed_texts[0] if guardrailed_texts else input_data # rebind-ok: data is an out-param else: rewritten_texts: Final = guardrailed_inputs.get("texts") or () if len(rewritten_texts) != len(extracted.task_mappings): - from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite - - raise UnappliableRequestRewrite(guardrail_to_apply.guardrail_name or "unknown") + raise unappliable_request_rewrite(guardrail_to_apply.guardrail_name) await self._apply_guardrail_responses_to_input( messages=input_data, responses=rewritten_texts, diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 9fe56f4dc65..d6fd30638cc 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -2272,6 +2272,58 @@ class TestAnthropicMessagesHandlerStreamingScanKey: assert ended_key != open_key +class PerRowTextGuardrail(CustomGuardrail): + """Answers one redacted text per chat row it was shown, the way a guardrail + that scans per message does, and hands back only texts.""" + + def __init__(self): + super().__init__(guardrail_name="per-row-redactor") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + rows = inputs.get("structured_messages") or [] + return {**inputs, "texts": [str(row.get("content")).replace("123-45-6789", "") for row in rows]} + + +class TestPerMessageTextWriteBack: + """Texts that no longer pair one-to-one with what the handler extracted must be + rejected by name instead of sliding onto the wrong messages.""" + + @pytest.mark.asyncio + async def test_one_text_per_row_over_a_system_prompt_is_rejected_by_name(self): + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + data = { + "model": "claude-sonnet-4-5", + "system": "Reply with exactly the SSN you were given.", + "messages": [{"role": "user", "content": "My SSN is 123-45-6789."}], + } + original = json.loads(json.dumps(data)) + + with pytest.raises(UnappliableRequestRewrite) as excinfo: + await AnthropicMessagesHandler().process_input_messages(data=data, guardrail_to_apply=PerRowTextGuardrail()) + + assert excinfo.value.guardrail_name == "per-row-redactor" + assert data["system"] == original["system"], "a rejected rewrite must leave the request untouched" + assert data["messages"] == original["messages"], "a rejected rewrite must leave the request untouched" + + @pytest.mark.asyncio + async def test_one_text_per_row_without_a_system_prompt_is_applied(self): + data = { + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": "My SSN is 123-45-6789."}], + } + + await AnthropicMessagesHandler().process_input_messages(data=data, guardrail_to_apply=PerRowTextGuardrail()) + + assert data["messages"] == [{"role": "user", "content": "My SSN is ."}] + + class TestAnthropicMessagesHandlerPostCallHookResponse: def test_openai_shaped_stream_assembly_reaches_the_hook_as_a_messages_response(self): from litellm.types.utils import Choices, Message, ModelResponse, Usage diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index 394f134e99c..ac719da169c 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -2392,6 +2392,14 @@ def _tool_replay_request() -> dict: } +def _string_input_request() -> dict: + return { + "model": "gpt-5.6", + "instructions": "Never repeat the SSN " + SSN + " back.", + "input": "My SSN is " + SSN + ".", + } + + class TestPerMessageRewriteWriteBack: """A guardrail that rewrites per chat row hands the rows back as structured_messages, and the handler lands them on the instructions and the @@ -2432,6 +2440,33 @@ class TestPerMessageRewriteWriteBack: assert data["input"] == original["input"] assert data["instructions"] == original["instructions"] + @pytest.mark.asyncio + async def test_structured_rows_land_on_instructions_and_string_input(self): + guardrail = _per_message_redactor() + data = _string_input_request() + + with patch.object(guardrail.async_handler, "post", side_effect=_per_message_guardrail_server(True)): + result = await OpenAIResponsesHandler().process_input_messages(data, guardrail) + + assert result["instructions"] == "Never repeat the SSN " + REDACTED_SSN + " back." + assert [_texts(item) for item in result["input"]] == [["My SSN is " + REDACTED_SSN + "."]] + + @pytest.mark.asyncio + async def test_texts_only_per_message_answer_over_a_string_input_is_rejected_by_name(self): + from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite + + guardrail = _per_message_redactor() + data = _string_input_request() + original = copy.deepcopy(data) + + with patch.object(guardrail.async_handler, "post", side_effect=_per_message_guardrail_server(False)): + with pytest.raises(UnappliableRequestRewrite) as excinfo: + await OpenAIResponsesHandler().process_input_messages(data, guardrail) + + assert excinfo.value.guardrail_name == "per-message-redactor" + assert data["input"] == original["input"] + assert data["instructions"] == original["instructions"] + class TestProvenancePatching: """The O(n) provenance pass must keep patching rewritten rows in place for the From 10f411e60dd7a771a2b3199e3306d197a3127bea Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:34:16 +0000 Subject: [PATCH 035/196] fix(router): name the all-deployments-in-cooldown error on 429 responses RouterRateLimitError now carries the model group's deployment ids so it can tell when every deployment is cooled down, and exposes that as type=all_deployments_in_cooldown with an explicit message. A partial cooldown keeps type=rate_limit_error. Either way the proxy no longer reports type=internal_server_error next to code 429 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 4 ++ litellm/router_utils/handle_error.py | 1 + litellm/types/router.py | 21 ++++++++- .../proxy/test_common_request_processing.py | 33 +++++++++++++ tests/test_litellm/test_router.py | 46 +++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 8865543badd..0983c9689c3 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13878,6 +13878,7 @@ class Router: cooldown_time=_cooldown_time, enable_pre_call_checks=self.enable_pre_call_checks, cooldown_list=_cooldown_list, + model_ids=model_ids, ) if strategy == "simple-shuffle": @@ -13910,6 +13911,7 @@ class Router: cooldown_time=_cooldown_time, enable_pre_call_checks=self.enable_pre_call_checks, cooldown_list=_cooldown_list, + model_ids=model_ids, ) self._override_selector_pre_call_check(strategy, strategy_selector, deployment) verbose_router_logger.info( @@ -14024,6 +14026,7 @@ class Router: cooldown_time=_cooldown_time, enable_pre_call_checks=self.enable_pre_call_checks, cooldown_list=_cooldown_list, + model_ids=model_ids, ) # 6. Apply load balancing strategy @@ -14057,6 +14060,7 @@ class Router: cooldown_time=_cooldown_time, enable_pre_call_checks=self.enable_pre_call_checks, cooldown_list=_cooldown_list, + model_ids=model_ids, ) self._override_selector_pre_call_check(strategy, strategy_selector, deployment) diff --git a/litellm/router_utils/handle_error.py b/litellm/router_utils/handle_error.py index 0e7490d31b1..bfe02675162 100644 --- a/litellm/router_utils/handle_error.py +++ b/litellm/router_utils/handle_error.py @@ -93,4 +93,5 @@ async def async_raise_no_deployment_exception( cooldown_time=_cooldown_time, enable_pre_call_checks=litellm_router_instance.enable_pre_call_checks, cooldown_list=cooldown_list_ids, + model_ids=model_ids, ) diff --git a/litellm/types/router.py b/litellm/types/router.py index c7363502017..ddc13e18567 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -645,6 +645,7 @@ class RouterErrors(enum.Enum): user_defined_ratelimit_error = "Deployment over user-defined ratelimit." no_deployments_available = "No deployments available for selected model" + all_deployments_in_cooldown = "All deployments for selected model are in cooldown" no_deployments_with_tag_routing = "Not allowed to access model due to tags configuration" no_deployments_with_provider_budget_routing = "No deployments available - crossed budget" no_healthy_deployments = "There are no healthy deployments for this model" @@ -868,6 +869,11 @@ class RouterRateLimitErrorBasic(ValueError): super().__init__(_message) +class RouterErrorTypes(str, enum.Enum): + rate_limit_error = "rate_limit_error" + all_deployments_in_cooldown = "all_deployments_in_cooldown" + + class RouterRateLimitError(ValueError): def __init__( self, @@ -875,12 +881,25 @@ class RouterRateLimitError(ValueError): cooldown_time: float, enable_pre_call_checks: bool, cooldown_list: list, + model_ids: Sequence[str] = (), ) -> None: self.model = model self.cooldown_time = cooldown_time self.enable_pre_call_checks = enable_pre_call_checks self.cooldown_list = cooldown_list - _message = f"{RouterErrors.no_deployments_available.value}, Try again in {cooldown_time} seconds. Passed model={model}. pre-call-checks={enable_pre_call_checks}, cooldown_list={cooldown_list}" + self.all_deployments_in_cooldown = bool(model_ids) and frozenset(model_ids) <= frozenset(cooldown_list) + self.type = ( + RouterErrorTypes.all_deployments_in_cooldown.value + if self.all_deployments_in_cooldown + else RouterErrorTypes.rate_limit_error.value + ) + _reason: Final = ( + f" {RouterErrors.all_deployments_in_cooldown.value}." if self.all_deployments_in_cooldown else "" + ) + _message: Final = ( + f"{RouterErrors.no_deployments_available.value}, Try again in {cooldown_time} seconds.{_reason} " + f"Passed model={model}. pre-call-checks={enable_pre_call_checks}, cooldown_list={cooldown_list}" + ) super().__init__(_message) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 69e89d1c604..92b80685a1f 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -3879,6 +3879,39 @@ class TestHandleLLMApiExceptionRetryAfter: assert proxy_exc.headers["retry-after"] == "43" assert proxy_exc.headers["x-custom"] == "1" + async def test_handle_llm_api_exception_names_cooldown_when_every_deployment_is_cooled_down(self): + from litellm.types.router import RouterRateLimitError + + exc = RouterRateLimitError( + model="gpt-4", + cooldown_time=120, + enable_pre_call_checks=False, + cooldown_list=["dep-a", "dep-b"], + model_ids=["dep-a", "dep-b"], + ) + proxy_exc = await self._invoke(exc) + body = proxy_exc.to_dict() + assert body["type"] == "all_deployments_in_cooldown" + assert body["code"] == "429" + assert "All deployments for selected model are in cooldown" in body["message"] + assert proxy_exc.headers["retry-after"] == "120" + + async def test_handle_llm_api_exception_keeps_rate_limit_type_when_cooldown_is_partial(self): + from litellm.types.router import RouterRateLimitError + + exc = RouterRateLimitError( + model="gpt-4", + cooldown_time=120, + enable_pre_call_checks=False, + cooldown_list=["dep-a"], + model_ids=["dep-a", "dep-b"], + ) + proxy_exc = await self._invoke(exc) + body = proxy_exc.to_dict() + assert body["type"] == "rate_limit_error" + assert body["code"] == "429" + assert "All deployments for selected model are in cooldown" not in body["message"] + class TestHandleLLMApiExceptionFramingHeaders: """HTTP-framing headers on the provider exception must be stripped before the diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b8a0d70f5bc..cc571ad3d3c 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -7735,6 +7735,52 @@ def test_get_available_deployment_raises_when_addressed_dict_is_blocked(): router.get_available_deployment(model="dep-0", request_kwargs={}) +def _cool_down(router: Router, *deployment_ids: str) -> None: + for deployment_id in deployment_ids: + router.cooldown_cache.add_deployment_to_cooldown( + model_id=deployment_id, + original_exception=litellm.RateLimitError(message="upstream 429", llm_provider="openai", model="gpt-4o"), + exception_status=429, + cooldown_time=60, + ) + + +async def _select_deployment(router: Router, use_async: bool) -> None: + if use_async: + await router.async_get_available_deployment(model="gpt-4o", request_kwargs={}) + return + router.get_available_deployment(model="gpt-4o", request_kwargs={}) + + +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_get_available_deployment_names_cooldown_when_every_deployment_is_cooled_down(use_async: bool): + from litellm.types.router import RouterErrors, RouterRateLimitError + + router: Final = _router_with_two_deployments([False, False]) + _cool_down(router, "dep-0", "dep-1") + with pytest.raises(RouterRateLimitError) as exc_info: + await _select_deployment(router, use_async) + assert exc_info.value.all_deployments_in_cooldown is True + assert exc_info.value.type == "all_deployments_in_cooldown" + assert RouterErrors.all_deployments_in_cooldown.value in str(exc_info.value) + assert str(exc_info.value).startswith("No deployments available for selected model, Try again in ") + + +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_get_available_deployment_keeps_generic_error_when_cooldown_is_partial(use_async: bool): + from litellm.types.router import RouterErrors, RouterRateLimitError + + router: Final = _router_with_two_deployments([False, True]) + _cool_down(router, "dep-0") + with pytest.raises(RouterRateLimitError) as exc_info: + await _select_deployment(router, use_async) + assert exc_info.value.all_deployments_in_cooldown is False + assert exc_info.value.type == "rate_limit_error" + assert RouterErrors.all_deployments_in_cooldown.value not in str(exc_info.value) + + def _router_with_two_pass_through_deployments(blocked_flags): import litellm From c2463728593b935448bcb09f54ef4fd070e9f8bf Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:35:53 -0700 Subject: [PATCH 036/196] fix(responses): import BaseLLMException lazily and collect stream chunks via anext Move the BaseLLMException import into _map_error_event_exception so the module no longer imports it at load time, clearing the module-level cyclic import CodeQL flagged. The class is used only on the cold error path. Replace the mutable list-append test collector with aiter/anext so the regression tests read the stream immutably. --- litellm/responses/streaming_iterator.py | 3 ++- .../test_streaming_iterator_error_events.py | 22 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index b3426fbbfef..a15571acb7c 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -31,7 +31,6 @@ from litellm.litellm_core_utils.llm_response_utils.response_metadata import ( update_response_metadata, ) from litellm.litellm_core_utils.thread_pool_executor import executor -from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils from litellm.types.llms.openai import ( @@ -553,6 +552,8 @@ class BaseResponsesAPIStreamingIterator: ) def _map_error_event_exception(self, error_obj: object) -> Exception: + from litellm.llms.base_llm.chat.transformation import BaseLLMException + error_message, error_type, error_code = _error_event_fields(error_obj) status_code: Final = _status_code_for_error_fields(error_type, error_code) error_body: Final = {"message": error_message, "type": error_type, "code": error_code} diff --git a/tests/test_litellm/responses/test_streaming_iterator_error_events.py b/tests/test_litellm/responses/test_streaming_iterator_error_events.py index 73afbb5e63a..2f4fba45cee 100644 --- a/tests/test_litellm/responses/test_streaming_iterator_error_events.py +++ b/tests/test_litellm/responses/test_streaming_iterator_error_events.py @@ -287,15 +287,12 @@ async def test_async_iterator_content_policy_violation_after_first_chunk_carries ] ) - chunks = [] - - async def _drain(): - async for chunk in iterator: - chunks.append(chunk) + stream = aiter(iterator) + first_chunk = await anext(stream) + assert first_chunk is not None with pytest.raises(MidStreamFallbackError) as exc_info: - await _drain() - assert len(chunks) == 1 + await anext(stream) assert isinstance(exc_info.value.original_exception, litellm.ContentPolicyViolationError) assert exc_info.value.is_pre_first_chunk is False assert exc_info.value.generated_content == "partial " @@ -316,14 +313,13 @@ async def test_async_iterator_error_after_first_chunk_carries_generated_content( ] ) - chunks = [] - async def _drain(): - async for chunk in iterator: - chunks.append(chunk) + stream = aiter(iterator) + first_chunk = await anext(stream) + second_chunk = await anext(stream) + assert first_chunk is not None and second_chunk is not None with pytest.raises(MidStreamFallbackError) as exc_info: - await _drain() - assert len(chunks) == 2 + await anext(stream) assert exc_info.value.status_code == 500 assert exc_info.value.is_pre_first_chunk is False assert exc_info.value.generated_content == "hello world" From 9080f0904ad6bee6c5f10debdccdd1b064e1efe3 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:35:54 +0000 Subject: [PATCH 037/196] fix(router): ignore blocked siblings when checking team model cooldown alternatives Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 4 +++- .../router_utils/test_cooldown_handlers.py | 18 +++++++++++++++++- tests/test_litellm/test_router.py | 6 +++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 4929b17f7fc..63d5f86d46a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1561,7 +1561,9 @@ class Router: public_model_name: Final = deployment.model_info.team_public_model_name if team_id is None or public_model_name is None: return False - return len(self.team_model_to_deployment_indices.get((team_id, public_model_name)) or ()) > 1 + sibling_indices: Final = self.team_model_to_deployment_indices.get((team_id, public_model_name)) or () + routable_siblings: Final = self._filter_blocked_deployments([self.model_list[idx] for idx in sibling_indices]) + return len(routable_siblings) > 1 _OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY}) diff --git a/tests/test_litellm/router_utils/test_cooldown_handlers.py b/tests/test_litellm/router_utils/test_cooldown_handlers.py index 6f66bb863cc..fdbfd618dab 100644 --- a/tests/test_litellm/router_utils/test_cooldown_handlers.py +++ b/tests/test_litellm/router_utils/test_cooldown_handlers.py @@ -440,7 +440,7 @@ class TestRoutingGroupCooldownAlternatives: class TestTeamModelCooldownAlternatives: - def _router(self, team_deployments: int): + def _router(self, team_deployments: int, blocked_ids: frozenset[str] = frozenset()): from litellm import Router return Router( @@ -452,6 +452,7 @@ class TestTeamModelCooldownAlternatives: "id": f"team-deploy-{i}", "team_id": "team-1", "team_public_model_name": "team-gpt-4o-mini", + "blocked": f"team-deploy-{i}" in blocked_ids, }, } for i in range(team_deployments) @@ -487,3 +488,18 @@ class TestTeamModelCooldownAlternatives: ) is False ) + + def test_429_with_only_a_blocked_sibling_keeps_single_deployment_exemption(self): + from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment + + router = self._router(team_deployments=2, blocked_ids=frozenset({"team-deploy-1"})) + assert ( + _should_cooldown_deployment( + litellm_router_instance=router, + deployment="team-deploy-0", + exception_status=429, + original_exception=Exception("rate limited"), + requested_model_group="team-gpt-4o-mini", + ) + is False + ) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 7af60e01f7f..7786bfb4788 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -897,7 +897,7 @@ def test_arouter_test_team_model(): def test_team_model_has_alternatives(): - def team_deployment(deployment_id: str, team_id: str, public_model_name: str): + def team_deployment(deployment_id: str, team_id: str, public_model_name: str, blocked: bool = False): return { "model_name": f"model_name_{team_id}_{deployment_id}", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, @@ -905,6 +905,7 @@ def test_team_model_has_alternatives(): "id": deployment_id, "team_id": team_id, "team_public_model_name": public_model_name, + "blocked": blocked, }, } @@ -914,6 +915,8 @@ def test_team_model_has_alternatives(): team_deployment("team-a-2", "team-a", "shared-model"), team_deployment("team-a-solo", "team-a", "solo-model"), team_deployment("team-b-1", "team-b", "shared-model"), + team_deployment("team-c-1", "team-c", "paused-sibling-model"), + team_deployment("team-c-paused", "team-c", "paused-sibling-model", blocked=True), { "model_name": "plain-model", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, @@ -926,6 +929,7 @@ def test_team_model_has_alternatives(): assert router.team_model_has_alternatives("team-a-2") is True assert router.team_model_has_alternatives("team-a-solo") is False assert router.team_model_has_alternatives("team-b-1") is False + assert router.team_model_has_alternatives("team-c-1") is False assert router.team_model_has_alternatives("plain-1") is False assert router.team_model_has_alternatives("missing-deployment") is False From e61b6bfd5ff58c61381e32aae075b2516fd3c76d Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:48:35 +0000 Subject: [PATCH 038/196] fix(router): classify pass-through cooldown against pass-through deployments only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 7 ++++++- tests/test_litellm/test_router.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 0983c9689c3..e74b2079fd4 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13989,6 +13989,11 @@ class Router: model=model, llm_provider="", ) + pass_through_model_ids: Final = tuple( + deployment["model_info"]["id"] + for deployment in pass_through_deployments + if "id" in deployment.get("model_info", {}) + ) # 4. Apply health-check and cooldown filtering parent_otel_span: Final[Span | None] = _get_parent_otel_span_from_kwargs(request_kwargs) @@ -14026,7 +14031,7 @@ class Router: cooldown_time=_cooldown_time, enable_pre_call_checks=self.enable_pre_call_checks, cooldown_list=_cooldown_list, - model_ids=model_ids, + model_ids=pass_through_model_ids, ) # 6. Apply load balancing strategy diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index cc571ad3d3c..35d0b8104bd 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -7818,6 +7818,24 @@ def test_get_available_deployment_for_pass_through_raises_when_dict_blocked(): ) +def test_get_available_deployment_for_pass_through_names_cooldown_despite_healthy_non_pass_through(): + from litellm.types.router import RouterRateLimitError + + router: Final = _router_with_two_pass_through_deployments([False, False]) + router.add_deployment( + Deployment( + model_name="gpt-4o", + litellm_params=LiteLLM_Params(model="openai/gpt-4o-plain", api_key="sk-fake-for-tests"), + model_info=ModelInfo(id="plain-0"), + ) + ) + _cool_down(router, "pt-0", "pt-1") + with pytest.raises(RouterRateLimitError) as exc_info: + router.get_available_deployment_for_pass_through(model="gpt-4o", request_kwargs={}) + assert exc_info.value.all_deployments_in_cooldown is True + assert exc_info.value.type == "all_deployments_in_cooldown" + + def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): """ Bedrock deployments using IAM/OIDC auth have no api_key; pass-through From e41b3bd13fa6415de7b4076dda82f673fac8b957 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:50:12 +0000 Subject: [PATCH 039/196] test(router): annotate return types of team cooldown test helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/router_utils/test_cooldown_handlers.py | 6 ++---- tests/test_litellm/test_router.py | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/router_utils/test_cooldown_handlers.py b/tests/test_litellm/router_utils/test_cooldown_handlers.py index fdbfd618dab..6fed4be5909 100644 --- a/tests/test_litellm/router_utils/test_cooldown_handlers.py +++ b/tests/test_litellm/router_utils/test_cooldown_handlers.py @@ -440,10 +440,8 @@ class TestRoutingGroupCooldownAlternatives: class TestTeamModelCooldownAlternatives: - def _router(self, team_deployments: int, blocked_ids: frozenset[str] = frozenset()): - from litellm import Router - - return Router( + def _router(self, team_deployments: int, blocked_ids: frozenset[str] = frozenset()) -> litellm.Router: + return litellm.Router( model_list=[ { "model_name": f"model_name_team-1_{i}", diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 7786bfb4788..c6b3b7fb8d6 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -897,7 +897,9 @@ def test_arouter_test_team_model(): def test_team_model_has_alternatives(): - def team_deployment(deployment_id: str, team_id: str, public_model_name: str, blocked: bool = False): + def team_deployment( + deployment_id: str, team_id: str, public_model_name: str, blocked: bool = False + ) -> DeploymentTypedDict: return { "model_name": f"model_name_{team_id}_{deployment_id}", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}, From 2923c4ac5520642bc3c3e728f9b2974850a43f7e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:02:15 -0700 Subject: [PATCH 040/196] fix(guardrails): read the rewrite from texts when a guardrail echoes every row back unchanged --- .../generic_guardrail_api.py | 25 ++++++++------ .../test_generic_guardrail_api.py | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 16159d32a7f..3d1a173635e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -150,16 +150,20 @@ def _extract_inbound_headers( return None -def _rows_with_unchanged_originals( +def _structured_rows_to_write_back( original_rows: Sequence[AllMessageValues] | None, shown_rows: Sequence[AllMessageValues] | None, returned_rows: Sequence[AllMessageValues], -) -> tuple[AllMessageValues, ...]: +) -> tuple[AllMessageValues, ...] | None: """The request model drops row keys its message types do not declare, so a - row the server echoes back verbatim is restored to the original row object; - only rows the server actually changed reach the endpoint write-back.""" + row the server echoes back verbatim is restored to the original row object. + A server that echoes every row back unchanged has not rewritten anything + per row, so its answer is read from texts, as it was before rows could be + returned at all.""" if original_rows is None or shown_rows is None or len(returned_rows) != len(original_rows): return tuple(returned_rows) + if all(returned == shown for shown, returned in zip(shown_rows, returned_rows)): + return None return tuple( original if returned == shown else returned for original, shown, returned in zip(original_rows, shown_rows, returned_rows) @@ -354,12 +358,13 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools - if guardrail_response.structured_messages: - return_inputs["structured_messages"] = list( # mutable-ok: guardrail inputs take a list - _rows_with_unchanged_originals( - structured_messages, shown_messages, guardrail_response.structured_messages - ) - ) + rows_to_write_back: Final = ( + _structured_rows_to_write_back(structured_messages, shown_messages, guardrail_response.structured_messages) + if guardrail_response.structured_messages + else None + ) + if rows_to_write_back is not None: + return_inputs["structured_messages"] = list(rows_to_write_back) # mutable-ok: guardrail inputs take a list if guardrail_response.stream_holdback_chars is not None: return_inputs["stream_holdback_chars"] = guardrail_response.stream_holdback_chars return return_inputs diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index cc9942e0e40..a5e79f84ef1 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -659,6 +659,40 @@ class TestStructuredMessagesInResponse: assert returned_rows[1] is tool_call_row assert returned_rows[2] == {"role": "tool", "tool_call_id": "call_1", "content": '{"ssn": "[REDACTED]"}'} + @pytest.mark.asyncio + async def test_rows_all_echoed_back_as_shown_leave_the_rewrite_to_texts( + self, generic_guardrail, mock_request_data_input + ): + """A server written against the texts contract that echoes the request rows back + untouched while rewriting texts still gets its texts rewrite applied.""" + original_rows = [ + {"role": "system", "content": "Never repeat an SSN."}, + {"role": "user", "content": "Look up 123-45-6789 for me."}, + ] + + def echo_rows_and_rewrite_texts(url, json, headers): + answer = MagicMock() + answer.json.return_value = { + "action": "NONE", + "texts": [text.replace("123-45-6789", "[REDACTED]") for text in json["texts"]], + "structured_messages": json["structured_messages"], + } + answer.raise_for_status = MagicMock() + return answer + + with patch.object(generic_guardrail.async_handler, "post", side_effect=echo_rows_and_rewrite_texts): + guardrailed_inputs = await generic_guardrail.apply_guardrail( + inputs={ + "texts": ["Never repeat an SSN.", "Look up 123-45-6789 for me."], + "structured_messages": original_rows, + }, + request_data=mock_request_data_input, + input_type="request", + ) + + assert "structured_messages" not in guardrailed_inputs + assert guardrailed_inputs["texts"] == ["Never repeat an SSN.", "Look up [REDACTED] for me."] + @pytest.mark.asyncio @pytest.mark.parametrize( "structured_messages", From fff7a2cecfb113082681665d236525f2690aae45 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:18:59 -0700 Subject: [PATCH 041/196] fix(responses): keep context-window events out of mid-stream fallback and fix stale exception assertions --- litellm/responses/streaming_iterator.py | 2 +- litellm/router.py | 7 +++---- .../test_openai_responses_api.py | 9 +++++---- ...est_router_aresponses_streaming_fallback.py | 2 +- .../test_streaming_iterator_error_events.py | 18 ++++++++++-------- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index a15571acb7c..b39e130242d 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -222,7 +222,7 @@ def _status_code_for_error_fields(error_type: str | None, error_code: str | None def _mid_stream_fallback_eligible(mapped_exception: Exception) -> bool: - if isinstance(mapped_exception, (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError)): + if isinstance(mapped_exception, litellm.ContentPolicyViolationError): return True status_code: Final = getattr(mapped_exception, "status_code", None) return not isinstance(status_code, int) or status_code >= 500 or status_code == 429 diff --git a/litellm/router.py b/litellm/router.py index 79854a11150..a9a8f3e2739 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -3271,12 +3271,11 @@ class Router: kwargs=initial_kwargs, metadata_variable_name="litellm_metadata", ) + # The content-policy dispatch branch matches on the trigger's own type, so a refusal's + # MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted. fallback_trigger: Final[Exception] = ( e.original_exception - if isinstance( - e.original_exception, - (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError), - ) + if isinstance(e.original_exception, litellm.ContentPolicyViolationError) else e ) fallback_response = await self.async_function_with_fallbacks_common_utils( diff --git a/tests/llm_responses_api_testing/test_openai_responses_api.py b/tests/llm_responses_api_testing/test_openai_responses_api.py index 05bb9113835..c7712d96969 100644 --- a/tests/llm_responses_api_testing/test_openai_responses_api.py +++ b/tests/llm_responses_api_testing/test_openai_responses_api.py @@ -1627,9 +1627,10 @@ async def test_openai_responses_api_token_limit_error(): Parsing the in-stream ErrorEvent must not raise "pydantic_core._pydantic_core.ValidationError: 3 validation errors for ErrorEvent". - The iterator now surfaces the event as litellm.APIError with status 400 - (invalid_request_error is a non-retriable client error, so no - MidStreamFallbackError wrapping) carrying the provider's message. + The iterator routes the event through litellm.exception_type, so it surfaces as + the typed 400 client error the non-streaming path raises (litellm.BadRequestError) + carrying the provider's message. invalid_request_error is a non-retriable client + error, so there is no MidStreamFallbackError wrapping. """ litellm._turn_on_debug() @@ -1644,7 +1645,7 @@ async def test_openai_responses_api_token_limit_error(): async for event in response: print(event) - with pytest.raises(litellm.APIError) as exc_info: + with pytest.raises(litellm.BadRequestError) as exc_info: await _drain() assert exc_info.value.status_code == 400 diff --git a/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py index ee4750e9db8..0d33435cf7a 100644 --- a/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py +++ b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py @@ -372,7 +372,7 @@ async def test_aresponses_fallback_on_in_stream_error_event(): raised = mock_fallback.await_args.kwargs["e"] assert isinstance(raised, MidStreamFallbackError) assert raised.status_code == 429 - assert isinstance(raised.original_exception, litellm.APIError) + assert isinstance(raised.original_exception, litellm.RateLimitError) assert raised.original_exception.status_code == 429 assert mock_fallback.await_args.kwargs["kwargs"]["input"] == "original question" diff --git a/tests/test_litellm/responses/test_streaming_iterator_error_events.py b/tests/test_litellm/responses/test_streaming_iterator_error_events.py index 2f4fba45cee..3d7c220804a 100644 --- a/tests/test_litellm/responses/test_streaming_iterator_error_events.py +++ b/tests/test_litellm/responses/test_streaming_iterator_error_events.py @@ -4,11 +4,11 @@ raise instead of being returned as benign chunks, mirroring chat streaming semantics (_handle_stream_fallback_error). The event's code, type and status go through litellm.exception_type, so each event raises the same typed exception the non-streaming path raises for that provider error: non-retriable 4xx -(except 429) raise that typed exception directly, while 429, 5xx, -ContentPolicyViolationError and ContextWindowExceededError are wrapped in +(except 429) raise that typed exception directly, so a context-length event +surfaces as ContextWindowExceededError(400) with no MidStreamFallbackError +wrapping, while 429, 5xx and ContentPolicyViolationError are wrapped in MidStreamFallbackError so the Router's mid-stream fallback machinery fires and -its content_policy_fallbacks / context_window_fallbacks dispatch sees the -trigger it matches on. +its content_policy_fallbacks dispatch sees the trigger it matches on. Status mapping must consider both the OpenAI error `type` (e.g. "invalid_request_error") and `code` (e.g. "invalid_prompt", @@ -110,19 +110,21 @@ def test_maybe_raise_for_error_event_maps_context_length_code_to_400(): assert not isinstance(exc_info.value, MidStreamFallbackError) -def test_maybe_raise_for_error_event_wraps_context_window_exceeded_for_context_window_fallbacks(): +def test_maybe_raise_for_error_event_raises_context_window_exceeded_directly(): """A context-length error event maps to ContextWindowExceededError exactly like the non-streaming - path and is wrapped so the Router's context_window_fallbacks dispatch fires mid-stream.""" + path and, being a non-retriable client error, is raised directly rather than wrapped for mid-stream + fallback, preserving the direct-SDK 400 contract from issue #15785.""" iterator = _make_iterator() chunk = _make_error_chunk( "invalid_request_error", "context_length_exceeded", "This model's maximum context length is 128000 tokens. However, your messages resulted in 130000 tokens.", ) - with pytest.raises(MidStreamFallbackError) as exc_info: + with pytest.raises(litellm.ContextWindowExceededError) as exc_info: iterator._maybe_raise_for_error_event(chunk) - assert isinstance(exc_info.value.original_exception, litellm.ContextWindowExceededError) assert exc_info.value.status_code == 400 + assert not isinstance(exc_info.value, MidStreamFallbackError) + assert "maximum context length" in str(exc_info.value) CONTENT_POLICY_MESSAGE = "This content was flagged for possible cybersecurity risk. The response was halted mid-stream." From 0201ca60e7397912cf46e0a1bddf1d6befb86e0a Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 13 Sep 2026 23:09:27 +0000 Subject: [PATCH 042/196] fix(ui): move tags typed into key metadata JSON into the Tags field Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../templates/KeyEditViewControls.tsx | 41 +++++++++++++++++- .../templates/keyEditFieldNormalizers.test.ts | 42 +++++++++++++++++++ .../templates/keyEditFieldNormalizers.ts | 33 +++++++++++++++ .../key_edit_view.integration.test.tsx | 26 ++++++++++++ .../components/templates/key_edit_view.tsx | 22 ++++++---- 5 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts diff --git a/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx b/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx index f5d5562ccfe..39542882798 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx @@ -1,12 +1,15 @@ import React from "react"; -import { Control } from "react-hook-form"; +import { Control, UseFormReturn } from "react-hook-form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CircleHelp } from "lucide-react"; import { FormField } from "@/components/shared/form/FormField"; +import { toast } from "@/lib/toast"; import AgentSelector from "../agent_management/AgentSelector"; import NumericalInput from "../shared/numerical_input"; import SkillSelector from "../skills/SkillSelector"; +import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers"; import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues"; export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( @@ -85,6 +88,42 @@ export const KeyAgentAndSkillFields = ({ ); +type KeyEditForm = Pick< + UseFormReturn, + "control" | "getValues" | "setValue" +>; + +export const moveMetadataTagsToTagsField = (form: KeyEditForm): void => { + const moved = moveTagsOutOfMetadataJson(form.getValues("metadata"), form.getValues("tags")); + if (moved === null) return; + form.setValue("metadata", moved.metadata, { shouldDirty: true }); + form.setValue("tags", moved.tags, { shouldDirty: true }); + if (moved.movedTags.length > 0) { + toast.info(`Moved ${moved.movedTags.join(", ")} from metadata to the Tags field`); + } +}; + +export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => ( + + {(field) => ( +