chore(oss): litellm oss staging 150626 (#30463)

* fix(pricing): add GitHub Copilot MAI Code Flash pricing (#30415)

* fix(pricing): add GitHub Copilot MAI Code Flash pricing

Add GitHub Copilot pricing entries for MAI-Code-1-Flash and the internal Copilot CLI model name so cost calculation can price input, cached input, and output tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(pricing): cover GitHub Copilot MAI Code Flash pricing

Add regression coverage for both GitHub Copilot MAI-Code-1-Flash model names, including cached input pricing, chat endpoint metadata, and cost_per_token arithmetic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(router/proxy): propagate completed_response through FallbackResponsesStreamWrapper for streaming /v1/responses container ownership (#30210) (#30213)

* fix(router/proxy): propagate completed_response through FallbackResponsesStreamWrapper for streaming /v1/responses container ownership (#30210)

#28990 added ownership recording for streaming /v1/responses via
_wrap_responses_stream_for_container_ownership, which reads
`getattr(stream_response, 'completed_response', None)` to extract the
ResponsesAPIResponse. The unit test bypassed the Router, so it never
exercised the production wrapping path.

Through the Router (every proxy deployment), the stream is wrapped by
FallbackResponsesStreamWrapper (router.py:2527). Its __init__ set
`self.completed_response = None` and __anext__ only forwarded chunks
— the inner source iterator's terminal event never bubbled up to the
attribute the ownership hook reads, so the hook silently recorded
nothing and every follow-up /v1/containers/<id>/files call returned
403 for non-admin keys.

This commit:

- router.py: pre-resolves the responses-API terminal event tuple
  (response.completed / .incomplete / .failed) once per
  _aresponses_streaming_iterator call, and has the wrapper's __anext__
  sniff each forwarded chunk's .type. First terminal event hit gets
  stored on the wrapper's completed_response. Iterator-agnostic — works
  for source_iterator AND any future wrapper.

- common_request_processing.py: when _extract_completed_responses_response
  returns None we now warn instead of silently skipping. Reporter on
  #30210 lost a day to this exact silent skip; the warning surfaces
  future regressions of the same shape directly in operator logs.

Fixes #30210

* fix(router): type-ignore wrapper getattr-defaults; broaden ownership-skip warning

CI lint (mypy) flagged the three pre-existing getattr(..., None) assignments
in FallbackResponsesStreamWrapper.__init__:

  router.py:2564 self.response = getattr(source_iterator, 'response', None)
  router.py:2565 self.model    = getattr(source_iterator, 'model', None)
  router.py:2566 self.logging_obj = getattr(..., None)

Those lines also exist on litellm_internal_staging and pass mypy there.
Adding the typed terminal-event tuple above the class made the function
body more narrowable, which surfaced the pre-existing mismatch — base
class declares non-Optional types but the bridge path
(LiteLLMCompletionStreamingIterator) legitimately omits these. Keep
the None fallback and silence with type: ignore[assignment].

Greptile 4/5 note: the ownership-skip warning hard-named code_interpreter
which misleads operators when a non-code_interpreter stream aborts.
Generalize to 'any tool container (e.g. code_interpreter)'.

* fix(register_model): drop synthesized zero costs to preserve sparse entries (#30198) (#30201)

* fix(register_model): drop synthesized zero costs to preserve sparse entries (#30198)

get_model_info synthesizes input_cost_per_token / output_cost_per_token = 0
when they are absent from the raw entry (the price-unknown and free cases
share the same representation). register_model then merges that result back
into litellm.model_cost, which flips a sparse entry from 'no cost keys'
(priced via model name) to 'cost keys = 0' (free).

That defeats _is_cost_explicitly_configured (#24949) on re-registration:
_is_model_cost_zero returns True, common_checks skips every tag / key /
team / user / org budget check for the group, and over-budget traffic
keeps returning 200. Spend keeps recording because cost calc still resolves
by model name, so the symptom is silent and only triggers on the second
register_model pass (router rebuild, /model/update, config sync).

Mirror the existing litellm_provider-None guard one block above and pop
the cost fields from the synthesized result when they are absent from the
raw entry and not in the caller's value. Caller-provided zeros (genuinely
free models, BYOK overrides) are preserved.

Fixes #30198

* fix(register_model): switch _raw_entry to is-None checks + drop dead test assertion

Greptile #30201 review notes:
- the `or`-chain in the raw-entry lookup treated an empty dict (a key
  with no fields) as falsy and fell through to the second arm — replace
  with explicit `is None` checks so a present-but-empty entry is still
  taken at face value.
- the first assertion in `test_router_double_init_keeps_db_model_entry_sparse`
  used `in (None, 0)` which passes under the bug condition (cost = 0
  matches the tuple); the strong follow-up assertion already covers
  every shape, so drop the dead branch.

* fix(bedrock mantle): use unique function-call id for responses->chat tool calls (#30426)

* fix(bedrock mantle): use unique function-call id for responses->chat tool calls

...

* fix(bedrock mantle): scope unique tool-call id fallback to degenerate call_id

The previous revision preferred the Responses item id for every tool call, which broke providers (and existing tests) where call_id is a unique, canonical correlation key. Restrict the fallback to the degenerate index-based call_id that Bedrock Mantle returns (call_0, call_1, ... resetting per response) and keep call_id otherwise. Revert the change to the OUTPUT_ITEM_DONE streaming handler, whose tool_call_chunk is never emitted (dead code, per review). Extend the regression tests to assert a normal call_id is preserved.

* fix(router): preserve azure_ad_token through CredentialLiteLLMParams for /v1/files + batches (#30235) (#30241)

* fix(router): preserve azure_ad_token through CredentialLiteLLMParams for /v1/files + batches (#30235)

Router.get_deployment_credentials_with_provider re-validates a
deployment's litellm_params through CredentialLiteLLMParams before
handing them to file/batch/passthrough callers:

    return CredentialLiteLLMParams(
        **deployment.litellm_params.model_dump(exclude_none=True)
    ).model_dump(exclude_none=True)

Any field NOT declared on CredentialLiteLLMParams gets silently dropped
on the way through. azure_ad_token was undeclared, so Azure deployments
using OAuth/M2M (azure_ad_token instead of a static api_key) silently
lost their token at the files endpoint and the proxy returned:

    Missing credentials. Please pass one of api_key, azure_ad_token,
    azure_ad_token_provider, ...

Declare azure_ad_token on CredentialLiteLLMParams alongside api_key /
api_base / api_version so it rides through the round-trip. Static-key
deployments stay unaffected (Optional, default None, dropped by
exclude_none=True). Provider-callable (azure_ad_token_provider) is a
separate concern and out of scope here.

Fixes #30235

* fix(ui-types): regenerate schema.d.ts for new azure_ad_token field

CI's 'Verify schema.d.ts matches the proxy OpenAPI spec' check
auto-detected the new field and emitted the exact diff to apply.
Two schemas had `aws_secret_access_key` from CredentialLiteLLMParams,
both get the new azure_ad_token marker next to it.

* fix(proxy): org_admin with own user_id now sees all org teams on /v2/team/list (#30247)

When the UI sends the callers own user_id (as it does for non-Admin
global roles), _enforce_list_team_v2_access now nulls it out for org
admins so _build_team_list_where_conditions scopes by organization_id
only -- matching the legacy /team/list behavior and the documented intent.

Fixes #30215

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* test(vertex_ai): multi-region regression coverage for cachedContents host (#29571) (#29707)

litellm_internal_staging already routes the cachedContents URL through
get_vertex_base_url, fixing the multi-region 404 reported in #29571 —
but carries no test coverage for the actual regression scenario (eu/us
must resolve to the REP host aiplatform.{geo}.rep.googleapis.com).

Add TestContextCachingMultiRegionUrls: parametrized eu/us REP-host
assertions (including absence of the old broken {geo}-aiplatform host),
plus regional (us-central1) and global no-regression checks.

* fix(proxy): close upstream LLM stream when client disconnects mid-stream (#30245)

* fix(proxy): close upstream LLM stream when client disconnects mid-stream

When a streaming client disconnects, Starlette abandons the response
body iterator without calling aclose(), so the proxy's connection to
the upstream backend stays open until garbage collection, which may
never come. The backend (e.g. vLLM) keeps generating into a dead pipe:
small responses drain invisibly into TCP buffers while large ones block
the backend on a full send buffer indefinitely (observed via lsof as an
ESTABLISHED proxy->backend connection minutes after the client left)

create_response now returns a StreamingResponse subclass that closes
both its body iterator and the wrapped upstream-facing generator in a
shielded finally. The upstream generator is closed directly rather than
through a cascade because aclose() on a never-started generator skips
its body, which would make the cascade a no-op when the client
disconnects before the first chunk is sent.
async_streaming_data_generator also gains the same shielded
finally-aclose that async_data_generator in proxy_server.py already
had, covering the Anthropic and Google SSE paths

With this, killing a streaming client causes the backend to observe the
abort within about a second and free its slot, while completed streams
are unaffected. No flag is needed, unlike the non-streaming opt-in
cancel in #30223: this only releases resources after the client is
already gone and does not change any response a client can observe

Fixes #30244

* fix(proxy): close upstream even when body iterator aclose raises BaseException

Addresses the Greptile finding on #30245: the cleanup loop caught only
Exception while the generator-level cleanup catches BaseException, so a
CancelledError or GeneratorExit escaping body_iterator.aclose() would
skip closing the upstream generator. Both sites now use the same scope
and a regression test pins that the upstream is closed even when the
body iterator explodes with a BaseException

* fix(llms): expose aclose on BaseModelResponseIterator so stream close reaches the provider connection

The response-level close added for #30244 only worked for SDK-based
providers (e.g. openai), whose streams expose aclose all the way down.
Providers served by base_llm_http_handler (hosted_vllm and most modern
transformation-based providers) wrap a bare response.aiter_lines()
generator in BaseModelResponseIterator, which had no aclose or close at
all, and nothing retained the httpx response object; so
CustomStreamWrapper.aclose() silently did nothing and the upstream
connection stayed open. Verified with a vLLM-style mock: with
hosted_vllm/ the backend streamed all 100 chunks to completion after
the client disconnected, while openai/ aborted at chunk 6

BaseModelResponseIterator now carries an optional http_response and an
aclose() that closes it; make_async_call_stream_helper attaches the
response after building the iterator. With this, hosted_vllm aborts the
backend within ~1.6s of the client dropping, and completed streams are
unaffected

---------

Co-authored-by: kursad <kursad.lacin@brado.net>

* feat(anthropic): surface compaction usage iterations data (#27065)

* feat(anthropic): surface compaction usage iterations data

* style: apply black formatting to fix lint checks

* fix(usage): correct calculate usage with cached tokens when use ChatCompletionUsageBlock (#30422)

* fix(usage): correct calculate usage with cached tokens when use ChatCompletionUsageBlock

* fix(usage): optimize test imports

* feat: add fastCRW search provider (#30434)

* feat(provider): add LibertAI as a JSON-configured OpenAI-compatible provider (#30203)

* feat(provider): add LibertAI as a JSON-configured OpenAI-compatible provider

* libertai: update served endpoints backup + add mode/matrix tests

Addresses review feedback:
- Add libertai to litellm/provider_endpoints_support_backup.json, the file
  actually served by GET /public/supported_endpoints (the root
  provider_endpoints_support.json already had it).
- Add tests asserting bge-m3 normalizes to mode='embedding' and that the
  served matrix lists libertai. embeddings stays false: the JSON-configured
  provider path only wires chat routing (OpenAILike embedding handler is
  reached only for literal openai_like/llamafile/lm_studio), matching the
  llamagate precedent; bge-m3 remains in the cost map for metadata.

---------

Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com>

* feat(provider): add ModelScope as an OpenAI-compatible provider (#28460)

* add ModelScope API support

* add modelscope api support

* update modelscope model list

* add image-genetation support

* update test and multimodal

* fix: address PR review feedback for modelscope provider

* update README

* fix(customer_endpoints): restrict /customer/daily/activity to admin-only (#28849)

* fix(customer_endpoints): restrict /customer/daily/activity to admin-only

* fix(customer_endpoints): check role before prisma_client guard

* fix(custom_guardrail): key disable_global_guardrails takes precedence over team guardrail list (#28563)

* fix(fallbacks): preserve fallback model in SDK fallback responses (#28260)

* fix(fallbacks): preserve fallback model in response when using SDK-level fallbacks

* fix(fallbacks): gate x-litellm-* passthrough to trusted callers only

The previous patch unconditionally let `x-litellm-*` keys bypass the
`llm_provider-` prefix in `process_response_headers`. That function is
also called on raw upstream-provider response headers (e.g. from
`llm_http_handler.py`), so a malicious provider could return
`x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker,
bypassing the proxy model-override guard.

Add a `preserve_litellm_internal_headers` flag (default False). Only
`response_metadata.py`, which re-processes the already-built
`_hidden_params["additional_headers"]` dict (LiteLLM-owned), passes
True. Raw provider header callsites keep the default False, so upstream
`x-litellm-*` still gets the `llm_provider-` prefix.

Adds a regression test for the spoofing case and renames the existing
preserve test to make the trusted-path semantics explicit.

* fix(fallbacks): ignore preserve_litellm_internal_headers for raw httpx.Headers inputs

* style(core_helpers): apply black formatting

* fix(lint): remove banned typing.List/Dict/Any imports and suppress PLR0913 on interface overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): apply black formatting to modelscope chat transformation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): replace noqa with proper fixes — use **kwargs and Awaitable instead of Any/List

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove unused AllMessageValues import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore base_model_iterator.py to original PR state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): restore full method signatures for MyPy compatibility; bump PLR0913 budget for new provider files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): use @override to suppress PLR0913 on inherited signatures instead of bumping budget

The overrides keep their full base-class signatures for MyPy compatibility, but those signatures carry more than five parameters, which tripped PLR0913 on each subclass redeclaration. Since the arity is dictated by the base class and cannot be reduced, decorate the overrides with typing_extensions.override; ruff treats that as the intended signal that the parameter count is not under the author's control and skips PLR0913. This restores the PLR0913 baseline to 1813.

* fix(lint): add @override to modelscope image generation overrides

Apply the same typing_extensions.override treatment to the image generation config so its inherited-signature overrides do not count against PLR0913.

---------

Co-authored-by: Joel Tony <github@jaytau.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: hcl <chenglunhu@gmail.com>
Co-authored-by: ztko <96878659+koztkozt@users.noreply.github.com>
Co-authored-by: Nahrin <nahrin@nahrinoda.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Humphrey <a739376838@gmail.com>
Co-authored-by: kursadlacin <kursadlacin@gmail.com>
Co-authored-by: kursad <kursad.lacin@brado.net>
Co-authored-by: Dushyant Acharya <dushyantacharya873@gmail.com>
Co-authored-by: Yuriy <yuriy.shuyskiy@gmail.com>
Co-authored-by: Recep S <22618852+us@users.noreply.github.com>
Co-authored-by: Moshe Malawach <moshe.malawach@protonmail.com>
Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com>
Co-authored-by: Rongkun Yan <2493404415@qq.com>
Co-authored-by: Varshith <kvarshithgowda@gmail.com>
Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Sameer Kankute 2026-06-17 00:36:41 +05:30 committed by GitHub
parent 9fa74ad8b4
commit 816fca939f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4053 additions and 29 deletions

View file

@ -327,6 +327,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
| [Maritalk (`maritalk`)](https://docs.litellm.ai/docs/providers/maritalk) | ✅ | ✅ | ✅ | | | | | | | |
| [Meta - Llama API (`meta_llama`)](https://docs.litellm.ai/docs/providers/meta_llama) | ✅ | ✅ | ✅ | | | | | | | |
| [Mistral AI API (`mistral`)](https://docs.litellm.ai/docs/providers/mistral) | ✅ | ✅ | ✅ | ✅ | | | | | | |
| [ModelScope (`modelscope`)](https://docs.litellm.ai/docs/providers/modelscope) | ✅ | ✅ | ✅ | | ✅ | | | | | |
| [Moonshot (`moonshot`)](https://docs.litellm.ai/docs/providers/moonshot) | ✅ | ✅ | ✅ | | | | | | | |
| [Morph (`morph`)](https://docs.litellm.ai/docs/providers/morph) | ✅ | ✅ | ✅ | | | | | | | |
| [Nebius AI Studio (`nebius`)](https://docs.litellm.ai/docs/providers/nebius) | ✅ | ✅ | ✅ | ✅ | | | | | | |

View file

@ -73,6 +73,7 @@ from litellm.constants import (
replicate_models,
clarifai_models,
huggingface_models,
modelscope_models,
empower_models,
together_ai_models,
baseten_models,
@ -900,6 +901,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
heroku_models.add(key)
elif value.get("litellm_provider") == "dashscope":
dashscope_models.add(key)
elif value.get("litellm_provider") == "modelscope":
modelscope_models.add(key)
elif value.get("litellm_provider") == "moonshot":
moonshot_models.add(key)
elif value.get("litellm_provider") == "publicai":
@ -1019,6 +1022,7 @@ model_list = list(
| zai_models
| fal_ai_models
| deepseek_models
| modelscope_models
| azure_ai_models
| voyage_models
| infinity_models
@ -1152,6 +1156,7 @@ models_by_provider: dict = {
"elevenlabs": elevenlabs_models,
"heroku": heroku_models,
"dashscope": dashscope_models,
"modelscope": modelscope_models,
"moonshot": moonshot_models,
"publicai": publicai_models,
"v0": v0_models,
@ -1975,6 +1980,9 @@ if TYPE_CHECKING:
from .llms.dashscope.rerank.transformation import (
DashScopeRerankConfig as DashScopeRerankConfig,
)
from .llms.modelscope.chat.transformation import (
ModelScopeChatConfig as ModelScopeChatConfig,
)
from .llms.moonshot.chat.transformation import (
MoonshotChatConfig as MoonshotChatConfig,
)

View file

@ -306,6 +306,7 @@ LLM_CONFIG_NAMES = (
"GigaChatConfig",
"GigaChatEmbeddingConfig",
"DashScopeChatConfig",
"ModelScopeChatConfig",
"MoonshotChatConfig",
"DockerModelRunnerChatConfig",
"V0ChatConfig",
@ -1161,6 +1162,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.dashscope.chat.transformation",
"DashScopeChatConfig",
),
"ModelScopeChatConfig": (
".llms.modelscope.chat.transformation",
"ModelScopeChatConfig",
),
"MoonshotChatConfig": (".llms.moonshot.chat.transformation", "MoonshotChatConfig"),
"DockerModelRunnerChatConfig": (
".llms.docker_model_runner.chat.transformation",

View file

@ -1293,9 +1293,15 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
provider_specific_fields
)
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
tool_call_index = parsed_chunk.get("output_index", 0)
tool_call_chunk = ChatCompletionToolCallChunk(
id=output_item.get("call_id"),
id=LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(
output_item.get("id"), output_item.get("call_id")
),
index=tool_call_index,
type="function",
function=function_chunk,

View file

@ -622,6 +622,7 @@ LITELLM_CHAT_PROVIDERS = [
"nscale",
"nebius",
"dashscope",
"modelscope",
"moonshot",
"publicai",
"v0",
@ -780,6 +781,7 @@ openai_compatible_endpoints: List = [
"inference.api.nscale.com/v1",
"api.studio.nebius.ai/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"https://api-inference.modelscope.cn/v1",
"https://api.moonshot.ai/v1",
"https://api.publicai.co/v1",
"https://api.synthetic.new/openai/v1",
@ -797,6 +799,7 @@ openai_compatible_endpoints: List = [
"https://ai-gateway.vercel.sh/v1",
"https://api.inference.wandb.ai/v1",
"https://api.clarifai.com/v2/ext/openai/v1",
"https://api.libertai.io/v1",
]
@ -840,10 +843,12 @@ openai_compatible_providers: List = [
"poe", # Poe - JSON-configured provider
"chutes", # Chutes - JSON-configured provider
"parasail", # Parasail - JSON-configured provider
"libertai", # LibertAI - JSON-configured provider
"featherless_ai",
"nscale",
"nebius",
"dashscope",
"modelscope",
"moonshot",
"v0",
"helicone",
@ -869,6 +874,7 @@ openai_text_completion_compatible_providers: List = (
"featherless_ai",
"nebius",
"dashscope",
"modelscope",
"moonshot",
"publicai",
"synthetic",
@ -1129,6 +1135,48 @@ WANDB_MODELS: set = set(
]
)
modelscope_models: set = set(
[
# Qwen series models
"Qwen/Qwen3-0.6B",
"Qwen/Qwen3-1.7B",
"Qwen/Qwen3-4B",
"Qwen/Qwen3-8B",
"Qwen/Qwen3-14B",
"Qwen/Qwen3-30B-A3B",
"Qwen/Qwen3-32B",
"Qwen/Qwen3-235B-A22B",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-30B-A3B-Thinking-2507",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Thinking",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-VL-8B-Instruct",
"Qwen/Qwen3-VL-8B-Thinking",
"Qwen/Qwen3.5-122B-A10B",
"Qwen/Qwen3.5-27B",
"Qwen/Qwen3.5-35B-A3B",
"Qwen/Qwen3.5-397B-A17B",
"Qwen/QwQ-32B",
"Qwen/QwQ-32B-Preview",
"Qwen/QVQ-72B-Preview",
"Qwen/Qwen-Image-Edit",
# DeepSeek series models
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
"deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-V4-Flash",
]
)
BEDROCK_INVOKE_PROVIDERS_LITERAL = Literal[
"cohere",
"anthropic",

View file

@ -621,6 +621,9 @@ class CustomGuardrail(CustomLogger):
):
return False
if self.default_on is True and disable_global_guardrail is True:
return False
if self.default_on is True and disable_global_guardrail is not True:
if self._event_hook_is_event_type(event_type):
if isinstance(self.event_hook, Mode):

View file

@ -242,9 +242,28 @@ def _get_parent_otel_span_from_kwargs(
return None
def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> dict:
def process_response_headers(
response_headers: Union[httpx.Headers, dict],
preserve_litellm_internal_headers: bool = False,
) -> dict:
"""
`preserve_litellm_internal_headers` must only be True when the input is a
LiteLLM-owned dict (e.g. `_hidden_params["additional_headers"]` that has
already been through one round of processing). For raw upstream provider
headers whether passed as `httpx.Headers` or a plain dict it must
remain False, otherwise a malicious provider returning `x-litellm-*` could
spoof LiteLLM-internal markers (e.g. `x-litellm-attempted-fallbacks`).
When the input is an `httpx.Headers` object the flag is always treated as
False regardless of what the caller requested, because `httpx.Headers` is
always a raw provider response and can never be LiteLLM-owned.
"""
from litellm.types.utils import OPENAI_RESPONSE_HEADERS
# Raw httpx.Headers objects come directly from provider HTTP responses and
# must never be treated as LiteLLM-owned, regardless of caller intent.
_preserve = preserve_litellm_internal_headers and isinstance(response_headers, dict)
openai_headers = {}
processed_headers = {}
additional_headers = {}
@ -256,6 +275,12 @@ def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> di
"llm_provider-"
): # return raw provider headers (incl. openai-compatible ones)
processed_headers[k] = v
elif _preserve and k.startswith("x-litellm-"):
# LiteLLM's own internal headers (e.g. x-litellm-attempted-fallbacks,
# x-litellm-model-group) are not LLM provider headers and must not be
# prefixed. Downstream consumers (proxy override, callers checking
# whether a fallback happened) look up the bare key.
processed_headers[k] = v
else:
additional_headers["{}-{}".format("llm_provider", k)] = v

View file

@ -7,6 +7,9 @@ from litellm.litellm_core_utils.core_helpers import (
safe_deep_copy,
filter_internal_params,
)
from litellm.router_utils.add_retry_fallback_headers import (
add_fallback_headers_to_response,
)
from .asyncify import run_async_function
@ -42,7 +45,7 @@ async def async_completion_with_fallbacks(**kwargs):
# Try each fallback model
most_recent_exception_str: Optional[str] = None
for fallback in fallbacks:
for attempted_fallbacks, fallback in enumerate(fallbacks):
try:
completion_kwargs = safe_deep_copy(base_kwargs)
# Handle dictionary fallback configurations
@ -63,7 +66,10 @@ async def async_completion_with_fallbacks(**kwargs):
)
if response is not None:
return response
return add_fallback_headers_to_response(
response=response,
attempted_fallbacks=attempted_fallbacks,
)
except Exception as e:
verbose_logger.exception(

View file

@ -334,6 +334,9 @@ def get_llm_provider( # noqa: PLR0915
elif endpoint == "dashscope-intl.aliyuncs.com/compatible-mode/v1":
custom_llm_provider = "dashscope"
dynamic_api_key = get_secret_str("DASHSCOPE_API_KEY")
elif endpoint == "https://api-inference.modelscope.cn/v1":
custom_llm_provider = "modelscope"
dynamic_api_key = get_secret_str("MODELSCOPE_API_KEY")
elif endpoint == "api.moonshot.ai/v1":
custom_llm_provider = "moonshot"
dynamic_api_key = get_secret_str("MOONSHOT_API_KEY")
@ -927,6 +930,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
) = litellm.DashScopeChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
elif custom_llm_provider == "modelscope":
(
api_base,
dynamic_api_key,
) = litellm.ModelScopeChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
elif custom_llm_provider == "moonshot":
(
api_base,

View file

@ -49,7 +49,8 @@ class ResponseMetadata:
result=self.result, litellm_model_name=model, router_model_id=model_id
),
"additional_headers": process_response_headers(
self._get_value_from_hidden_params("additional_headers") or {}
self._get_value_from_hidden_params("additional_headers") or {},
preserve_litellm_internal_headers=True,
),
"litellm_model_name": model,
}

View file

@ -604,6 +604,8 @@ class ChunkProcessor:
usage_chunk = chunk._hidden_params.get("usage", None)
if usage_chunk is not None:
if isinstance(usage_chunk, dict):
usage_chunk = Usage(**usage_chunk)
usage_chunk_dict = self._usage_chunk_calculation_helper(usage_chunk)
if (
usage_chunk_dict["prompt_tokens"] is not None

View file

@ -2214,18 +2214,33 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if "inference_geo" in _usage and _usage["inference_geo"] is not None:
inference_geo = _usage["inference_geo"]
if (
"cache_creation_input_tokens" in _usage
and _usage["cache_creation_input_tokens"] is not None
):
cache_creation_input_tokens = _usage["cache_creation_input_tokens"]
prompt_tokens += cache_creation_input_tokens
if (
"cache_read_input_tokens" in _usage
and _usage["cache_read_input_tokens"] is not None
):
cache_read_input_tokens = _usage["cache_read_input_tokens"]
prompt_tokens += cache_read_input_tokens
iterations: Optional[List[Any]] = _usage.get("iterations")
if iterations:
prompt_tokens = sum(it.get("input_tokens", 0) or 0 for it in iterations)
completion_tokens = sum(
it.get("output_tokens", 0) or 0 for it in iterations
)
cache_creation_input_tokens = sum(
it.get("cache_creation_input_tokens", 0) or 0 for it in iterations
)
cache_read_input_tokens = sum(
it.get("cache_read_input_tokens", 0) or 0 for it in iterations
)
prompt_tokens += cache_creation_input_tokens + cache_read_input_tokens
if not iterations:
if (
"cache_creation_input_tokens" in _usage
and _usage["cache_creation_input_tokens"] is not None
):
cache_creation_input_tokens = _usage["cache_creation_input_tokens"]
prompt_tokens += cache_creation_input_tokens
if (
"cache_read_input_tokens" in _usage
and _usage["cache_read_input_tokens"] is not None
):
cache_read_input_tokens = _usage["cache_read_input_tokens"]
prompt_tokens += cache_read_input_tokens
if "server_tool_use" in _usage and _usage["server_tool_use"] is not None:
if (
"web_search_requests" in _usage["server_tool_use"]
@ -2264,7 +2279,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
),
)
raw_input_tokens = usage_object.get("input_tokens", 0) or 0
raw_input_tokens = (
prompt_tokens - cache_read_input_tokens - cache_creation_input_tokens
)
prompt_tokens_details = PromptTokensDetailsWrapper(
cached_tokens=cache_read_input_tokens,
cache_creation_tokens=cache_creation_input_tokens,
@ -2296,6 +2313,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
completion_tokens_details=completion_token_details,
iterations=iterations,
server_tool_use=(
ServerToolUse(
web_search_requests=web_search_requests,

View file

@ -1,8 +1,11 @@
import json
from abc import abstractmethod
from typing import List, Optional, Union, cast
from typing import TYPE_CHECKING, List, Optional, Union, cast
import litellm
if TYPE_CHECKING:
import httpx
from litellm.types.utils import (
Choices,
Delta,
@ -69,6 +72,18 @@ class BaseModelResponseIterator:
self.streaming_response = streaming_response
self.response_iterator = self.streaming_response
self.json_mode = json_mode
self.http_response: Optional["httpx.Response"] = None
async def aclose(self) -> None:
"""Close the upstream HTTP response so the provider connection is
released (and a backend like vLLM aborts generation) when the stream
is abandoned before its natural end.
``streaming_response`` is usually a bare ``aiter_lines()`` generator
that holds no reference to the response, so the handler that owns the
response attaches it here after construction."""
if self.http_response is not None:
await self.http_response.aclose()
def chunk_parser(
self, chunk: dict

View file

@ -33,7 +33,10 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
from litellm.llms.base_llm.audio_transcription.transformation import (
BaseAudioTranscriptionConfig,
)
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
from litellm.llms.base_llm.base_model_iterator import (
BaseModelResponseIterator,
MockResponseIterator,
)
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
from litellm.llms.base_llm.chat.transformation import BaseConfig
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
@ -814,6 +817,8 @@ class BaseLLMHTTPHandler:
completion_stream = provider_config.get_model_response_iterator(
streaming_response=response.aiter_lines(), sync_stream=False
)
if isinstance(completion_stream, BaseModelResponseIterator):
completion_stream.http_response = response
# LOGGING
logging_obj.post_call(
input=messages,

View file

@ -0,0 +1,7 @@
"""
fastCRW API integration module.
"""
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
__all__ = ["FastCRWSearchConfig"]

View file

@ -0,0 +1,7 @@
"""
fastCRW Search API module.
"""
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
__all__ = ["FastCRWSearchConfig"]

View file

@ -0,0 +1,182 @@
"""
Calls fastCRW's /v1/search endpoint to search the web.
fastCRW is a Firecrawl-compatible web data engine (single Rust binary; self-host
or cloud). The search response uses the Firecrawl-compatible envelope
{ "success": true, "data": [ { "title", "url", "description", "markdown"? } ] }.
fastCRW API Reference: https://fastcrw.com/docs/rest-api
"""
from typing import Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _FastCRWSearchRequestRequired(TypedDict):
"""Required fields for fastCRW Search API request."""
query: str # Required - search query
class FastCRWSearchRequest(_FastCRWSearchRequestRequired, total=False):
"""
fastCRW Search API request format.
Based on: https://fastcrw.com/docs/rest-api
"""
limit: int # Optional - maximum number of results to return
sources: list[
str
] # Optional - sources to search ('web', 'images'), default ['web']
scrapeOptions: dict # Optional - options for scraping search results
class FastCRWSearchConfig(BaseSearchConfig):
FASTCRW_API_BASE = "https://fastcrw.com/api/v1"
@staticmethod
def ui_friendly_name() -> str:
return "fastCRW"
def validate_environment(
self,
headers: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> dict:
"""
Validate environment and return headers.
"""
api_key = api_key or get_secret_str("CRW_API_KEY")
if not api_key:
raise ValueError(
"CRW_API_KEY is not set. Set `CRW_API_KEY` environment variable."
)
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[dict, list[dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
"""
api_base = api_base or get_secret_str("CRW_API_BASE") or self.FASTCRW_API_BASE
# Append "/search" to the api base if it's not already there
if not api_base.endswith("/search"):
api_base = f"{api_base}/search"
return api_base
def transform_search_request(
self,
query: Union[str, list[str]],
optional_params: dict,
**kwargs,
) -> dict:
"""
Transform Search request to fastCRW API format.
Transforms Perplexity unified spec parameters:
- query -> query (same)
- max_results -> limit
All other fastCRW-specific parameters are passed through as-is.
Args:
query: Search query (string or list of strings). fastCRW only supports single string queries.
optional_params: Optional parameters for the request
Returns:
Dict with typed request data following FastCRWSearchRequest spec
"""
if isinstance(query, list):
# fastCRW only supports single string queries, join with spaces
query = " ".join(query)
request_data: FastCRWSearchRequest = {
"query": query,
}
# Transform Perplexity unified spec parameters to fastCRW format
if "max_results" in optional_params:
request_data["limit"] = optional_params["max_results"]
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# pass through all other parameters as-is
for param, value in optional_params.items():
if (
param not in self.get_supported_perplexity_optional_params()
and param not in result_data
):
result_data[param] = value
# By default, request markdown content if not explicitly specified
# fastCRW doesn't return content unless explicitly requested via scrapeOptions
if "scrapeOptions" not in result_data:
result_data["scrapeOptions"] = {
"formats": ["markdown"],
"onlyMainContent": True,
}
return result_data
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform fastCRW API response to LiteLLM unified SearchResponse format.
fastCRW (Firecrawl-compatible) returns:
{"success": true, "data": [{"url": "...", "title": "...", "description": "...", "markdown"?: "..."}, ...]}
Args:
raw_response: Raw httpx response from fastCRW API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
results = []
data = response_json.get("data", [])
if isinstance(data, list):
for result in data:
snippet = result.get("markdown") or result.get("description", "")
search_result = SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=snippet,
date=None,
last_updated=None,
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -0,0 +1,93 @@
"""
Translates from OpenAI's `/v1/chat/completions` to ModelScope's `/v1/chat/completions`
"""
from typing import Any, Coroutine, Literal, Optional, Tuple, Union, cast, overload
from typing_extensions import override
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
def _has_non_text_content(message: AllMessageValues) -> bool:
"""Check if a message has non-text content items (e.g. image_url)."""
content = message.get("content")
if not isinstance(content, list):
return False
return any(item.get("type") != "text" for item in content)
class ModelScopeChatConfig(OpenAIGPTConfig):
DEFAULT_BASE_URL: str = "https://api-inference.modelscope.cn/v1"
@overload
def _transform_messages(
self, messages: list[AllMessageValues], model: str, is_async: Literal[True]
) -> Coroutine[Any, Any, list[AllMessageValues]]: ...
@overload
def _transform_messages(
self,
messages: list[AllMessageValues],
model: str,
is_async: Literal[False] = False,
) -> list[AllMessageValues]: ...
def _transform_messages(
self, messages: list[AllMessageValues], model: str, is_async: bool = False
) -> Union[list[AllMessageValues], Coroutine[Any, Any, list[AllMessageValues]]]:
"""
Flatten text-only content lists to strings for ModelScope.
Messages with non-text content (e.g. image_url for vision models)
are kept as lists so the parent class can normalize them properly.
"""
messages = [cast(AllMessageValues, {**m}) for m in messages]
for message in messages:
if _has_non_text_content(message):
continue
content = message.get("content")
if isinstance(content, list):
message["content"] = "".join(item.get("text") or "" for item in content)
if is_async:
return super()._transform_messages(
messages=messages, model=model, is_async=True
)
else:
return super()._transform_messages(
messages=messages, model=model, is_async=False
)
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
api_base = (
api_base or get_secret_str("MODELSCOPE_API_BASE") or self.DEFAULT_BASE_URL
) # type: ignore
dynamic_api_key = api_key or get_secret_str("MODELSCOPE_API_KEY")
return api_base, dynamic_api_key
@override
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
"""
If api_base is not provided, use the default ModelScope /chat/completions endpoint.
"""
if not api_base:
api_base = self.DEFAULT_BASE_URL
if not api_base.endswith("/chat/completions"):
api_base = f"{api_base}/chat/completions"
return api_base

View file

@ -0,0 +1,31 @@
"""
ModelScope Image Generation Module
Factory function for getting the appropriate config class.
"""
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from .transformation import ModelScopeImageGenerationConfig
__all__ = [
"ModelScopeImageGenerationConfig",
"get_modelscope_image_generation_config",
]
def get_modelscope_image_generation_config(
model: str,
) -> BaseImageGenerationConfig:
"""
Get the ModelScope config for image generation.
Args:
model: The model name (e.g., "modelscope/Qwen/Qwen-Image-Edit")
Returns:
BaseImageGenerationConfig instance for ModelScope
"""
return ModelScopeImageGenerationConfig()

View file

@ -0,0 +1,248 @@
"""
ModelScope Image Generation Config
Handles transformation between OpenAI-compatible format and ModelScope API format.
API Reference: https://modelscope.cn/docs/model-service/API-Inference/intro
"""
from typing import TYPE_CHECKING, Optional, Union
import httpx
from typing_extensions import override
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIImageGenerationOptionalParams,
)
from litellm.types.utils import ImageObject, ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = object
class ModelScopeImageGenerationConfig(BaseImageGenerationConfig):
"""
Configuration for ModelScope image generation.
Supports text-to-image models like:
- Qwen/Qwen-Image-Edit
- And other ModelScope-hosted image generation models
"""
DEFAULT_BASE_URL: str = "https://api-inference.modelscope.cn/v1"
def get_supported_openai_params(
self, model: str
) -> list[OpenAIImageGenerationOptionalParams]:
"""
Return list of OpenAI params supported by ModelScope.
ModelScope supports standard OpenAI image generation parameters.
"""
return [
"n", # Number of images to generate
"size", # Size of the generated images
"response_format", # url or b64_json
"user", # User identifier
]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
"""
Map OpenAI parameters to ModelScope parameters.
ModelScope uses the same parameter names as OpenAI.
"""
supported_params = self.get_supported_openai_params(model)
if drop_params:
non_default_params = {
k: v for k, v in non_default_params.items() if k in supported_params
}
optional_params.update(non_default_params)
return optional_params
@override
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
"""
Get the complete URL for the ModelScope image generation API request.
"""
base_url: str = (
api_base or get_secret_str("MODELSCOPE_API_BASE") or self.DEFAULT_BASE_URL
)
base_url = base_url.rstrip("/")
# Return the images endpoint
return f"{base_url}/images/generations"
@override
def validate_environment(
self,
headers: dict,
model: str,
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate environment and set up headers for ModelScope.
"""
final_api_key: Optional[str] = api_key or get_secret_str("MODELSCOPE_API_KEY")
if not final_api_key:
raise ValueError(
"MODELSCOPE_API_KEY is not set. "
"Please set it via environment variable or pass api_key parameter."
)
default_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {final_api_key}",
}
headers = {**headers, **default_headers}
return headers
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
"""
Transform OpenAI-style request to ModelScope request format.
ModelScope uses the same format as OpenAI for image generation.
"""
# Build the request body (same as OpenAI)
request_data: dict = {
"model": model,
"prompt": prompt,
}
# Add optional params
for key, value in optional_params.items():
if key.startswith("_"):
continue
request_data[key] = value
return request_data
@override
def transform_image_generation_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ImageResponse,
logging_obj: LiteLLMLoggingObj,
request_data: dict,
optional_params: dict,
litellm_params: dict,
encoding: object,
api_key: Optional[str] = None,
json_mode: Optional[bool] = None,
) -> ImageResponse:
"""
Transform ModelScope response to OpenAI-compatible ImageResponse.
ModelScope returns the same format as OpenAI:
{"created": timestamp, "data": [{"url": "..."}]}
"""
try:
response_data = raw_response.json()
except Exception as e:
raise self.get_error_class(
error_message=f"Error parsing ModelScope response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
# Check for errors in response
if "error" in response_data:
error_msg = response_data["error"].get(
"message", str(response_data["error"])
)
raise self.get_error_class(
error_message=f"ModelScope error: {error_msg}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
# Extract images from response
data_list = response_data.get("data", [])
if not model_response.data:
model_response.data = []
for item in data_list:
image_obj = ImageObject(
url=item.get("url"),
b64_json=item.get("b64_json"),
revised_prompt=item.get("revised_prompt"),
)
model_response.data.append(image_obj)
return model_response
def get_error_class(
self,
error_message: str,
status_code: int,
headers: Union[dict, httpx.Headers],
) -> BaseLLMException:
"""Return the appropriate error class for ModelScope."""
from litellm.exceptions import (
AuthenticationError,
BadRequestError,
InternalServerError,
)
if status_code == 400:
return BadRequestError( # type: ignore[return-value]
message=error_message,
model="",
llm_provider="modelscope",
)
elif status_code == 401:
return AuthenticationError( # type: ignore[return-value]
message=error_message,
model="",
llm_provider="modelscope",
)
elif status_code >= 500:
return InternalServerError( # type: ignore[return-value]
message=error_message,
model="",
llm_provider="modelscope",
)
else:
return BadRequestError( # type: ignore[return-value]
message=error_message,
model="",
llm_provider="modelscope",
)

View file

@ -143,6 +143,14 @@
"force_store_false": true
}
},
"libertai": {
"base_url": "https://api.libertai.io/v1",
"api_key_env": "LIBERTAI_API_KEY",
"api_base_env": "LIBERTAI_API_BASE",
"param_mappings": {
"max_completion_tokens": "max_tokens"
}
},
"empiriolabs": {
"base_url": "https://api.empiriolabs.ai/v1",
"api_key_env": "EMPIRIOLABS_API_KEY",

View file

@ -18822,6 +18822,38 @@
"supports_response_schema": true,
"supports_vision": true
},
"github_copilot/mai-code-1-flash": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true
},
"github_copilot/mai-code-1-flash-internal": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true
},
"github_copilot/text-embedding-3-small": {
"litellm_provider": "github_copilot",
"max_input_tokens": 8191,
@ -40784,6 +40816,174 @@
"litellm_provider": "llamagate",
"mode": "embedding"
},
"libertai/hermes-3-8b-tee": {
"max_tokens": 16000,
"max_input_tokens": 16000,
"max_output_tokens": 16000,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 6e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/gemma-4-31b-it": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 4e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/gemma-4-31b-it-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 4e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-27b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-27b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-35b-a3b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-35b-a3b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.5-122b-a10b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.5-122b-a10b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/deepseek-v4-flash": {
"max_tokens": 200000,
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/deepseek-v4-flash-thinking": {
"max_tokens": 200000,
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/bge-m3": {
"max_tokens": 8192,
"max_input_tokens": 8192,
"input_cost_per_token": 1e-08,
"output_cost_per_token": 0.0,
"litellm_provider": "libertai",
"mode": "embedding",
"source": "https://docs.libertai.io/apis/text/"
},
"sarvam/sarvam-m": {
"cache_creation_input_token_cost": 0,
"cache_creation_input_token_cost_above_1hr": 0,

View file

@ -1288,6 +1288,23 @@
"interactions": true
}
},
"libertai": {
"display_name": "LibertAI (`libertai`)",
"url": "https://docs.litellm.ai/docs/providers/libertai",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": false,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false
}
},
"litellm_proxy": {
"display_name": "LiteLLM Proxy (`litellm_proxy`)",
"url": "https://docs.litellm.ai/docs/providers/litellm_proxy",

View file

@ -17,10 +17,12 @@ from typing import (
Union,
)
import anyio
import httpx
import orjson
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse, Response, StreamingResponse
from starlette.types import Receive, Scope, Send
import litellm
from litellm._logging import _redact_string, verbose_proxy_logger
@ -240,6 +242,64 @@ def _extract_error_from_sse_chunk(event_line: Union[str, bytes]) -> dict:
return default_error
async def _aclose_upstream_response(response: Any) -> None:
"""Release the upstream HTTP connection when a stream ends for any
reason, including client disconnect. Mirrors the finally block of
async_data_generator in proxy_server.py."""
with anyio.CancelScope(shield=True):
if hasattr(response, "aclose"):
try:
await response.aclose()
except BaseException as e:
verbose_proxy_logger.debug(
"error closing upstream response stream: %s", e
)
class _UpstreamClosingStreamingResponse(StreamingResponse):
"""StreamingResponse that always closes its body iterator and the wrapped
upstream generator.
When the client disconnects mid-stream, Starlette abandons the body
iterator without calling aclose(), leaving the upstream LLM connection
open until garbage collection; the backend (e.g. vLLM) keeps generating
into a dead pipe. The upstream generator is closed directly (not via the
body iterator) because aclose() on a never-started generator skips its
body, so a cascade through it would be a no-op if the client disconnects
before the first chunk is sent.
"""
def __init__(
self,
content: AsyncGenerator[str, None],
*,
media_type: Optional[str] = None,
headers: Optional[dict] = None,
status_code: int = status.HTTP_200_OK,
upstream_generator: Optional[AsyncGenerator[str, None]] = None,
) -> None:
super().__init__(
content, status_code=status_code, headers=headers, media_type=media_type
)
self._upstream_generator = upstream_generator
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
try:
await super().__call__(scope, receive, send)
finally:
with anyio.CancelScope(shield=True):
for target in (self.body_iterator, self._upstream_generator):
aclose = getattr(target, "aclose", None)
if aclose is None:
continue
try:
await aclose()
except BaseException as e:
verbose_proxy_logger.debug(
"error closing streaming generator: %s", e
)
async def create_response( # noqa: PLR0915
generator: AsyncGenerator[str, None],
media_type: str,
@ -366,11 +426,12 @@ async def create_response( # noqa: PLR0915
with tracer.trace(DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE):
yield chunk
return StreamingResponse(
return _UpstreamClosingStreamingResponse(
combined_generator(),
media_type=media_type,
headers=streaming_headers,
status_code=final_status_code,
upstream_generator=generator,
)
@ -1702,6 +1763,23 @@ class ProxyBaseLLMRequestProcessing:
response=completed_obj,
user_api_key_dict=user_api_key_dict,
)
else:
# Silent skip caused #30210: the proxy's Router wrapper
# of the responses streaming iterator wasn't propagating
# ``completed_response``, so this hook recorded nothing
# and follow-up /v1/containers/<id>/files calls 403'd
# for non-admin keys with no proxy-side hint. Log a
# warning so future regressions of the same shape
# surface in operator logs.
verbose_proxy_logger.warning(
"Container ownership recording skipped on streaming "
"/v1/responses: no completed_response on stream "
"iterator %s. If this stream created any tool "
"container (e.g. code_interpreter), follow-up "
"/v1/containers/<id>/files calls will 403 for "
"non-admin keys.",
type(original_stream_response).__name__,
)
except Exception as e:
verbose_proxy_logger.exception(
"Container ownership recording failed after streaming responses call: %s",
@ -2424,6 +2502,8 @@ class ProxyBaseLLMRequestProcessing:
code=getattr(e, "status_code", 500),
)
yield serialize_error(proxy_exception)
finally:
await _aclose_upstream_response(response)
@staticmethod
def async_sse_data_generator(

View file

@ -885,6 +885,19 @@ async def get_customer_daily_activity(
"""
Get daily activity for specific organizations or all accessible organizations.
"""
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
):
raise HTTPException(
status_code=401,
detail={
"error": "Admin-only endpoint. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:

View file

@ -4244,6 +4244,14 @@ async def _enforce_list_team_v2_access(
status_code=403,
detail={"error": "You can only view teams within your organizations."},
)
# When the caller is an org admin querying their own teams (or no
# specific user), null out user_id so that
# _build_team_list_where_conditions scopes only by organization_id
# — org admins should see all teams in their orgs, not just teams
# they are a direct member of. Keep user_id when the org admin
# explicitly queries a *different* user's teams.
if user_id is None or user_id == user_api_key_dict.user_id:
user_id = None
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
user_api_key_dict.user_id,

View file

@ -2,6 +2,7 @@
Handles transforming from Responses API -> LiteLLM completion (Chat Completion API)
"""
import re
from collections.abc import Sequence
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast
@ -1554,6 +1555,20 @@ class LiteLLMCompletionResponsesConfig:
# Default to completed for unknown finish reasons
return "completed"
@staticmethod
def _tool_call_id_from_responses_item(
item_id: Optional[str], call_id: Optional[str]
) -> str:
"""Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0``,
``call_1``, ... that resets every response) alongside a unique ``id``
(``fc_...``). ``call_id`` is the canonical Responses API correlation key, so
prefer it; fall back to the unique ``id`` only when ``call_id`` is absent or
in that degenerate index form, otherwise multi-turn tool calls collide and an
agent cannot correlate its tool results."""
if call_id and re.fullmatch(r"call_\d+", call_id) is None:
return call_id
return item_id or call_id or ""
@staticmethod
def convert_response_function_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
@ -1601,7 +1616,10 @@ class LiteLLMCompletionResponsesConfig:
function_dict["provider_specific_fields"] = provider_specific_fields
tool_call_dict: Dict[str, Any] = {
"id": tool_call_item.call_id,
"id": LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(
getattr(tool_call_item, "id", None),
getattr(tool_call_item, "call_id", None),
),
"function": function_dict,
"type": "function",
"index": 0,

View file

@ -2521,10 +2521,22 @@ class Router:
from litellm.exceptions import MidStreamFallbackError
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
_get_openai_response_types,
)
source_iterator = response
# Pre-resolve the set of terminal stream event types so the
# per-chunk type check inside FallbackResponsesStreamWrapper
# stays cheap; mirrors the source-iterator filter at
# responses/streaming_iterator.py:243-247.
_openai_types = _get_openai_response_types()
_RESPONSES_TERMINAL_EVENT_TYPES = (
_openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
_openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE,
_openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED,
)
class FallbackResponsesStreamWrapper(BaseResponsesAPIStreamingIterator):
"""
Subclasses BaseResponsesAPIStreamingIterator only for isinstance
@ -2550,9 +2562,16 @@ class Router:
# is missing many of these attributes — use getattr fallbacks
# so wrapper construction never raises AttributeError. The
# bridge stores the logging object as `litellm_logging_obj`.
self.response = getattr(source_iterator, "response", None)
self.model = getattr(source_iterator, "model", None)
self.logging_obj = getattr(
# base class declares non-Optional types for these
# fields but the bridge path (LiteLLMCompletionStreamingIterator)
# can legitimately omit them at runtime — keep the None
# fallback. Same lines passed mypy on the pre-fix file
# because the surrounding function body wasn't fully
# type-narrowed; the new typed terminal-event tuple above
# is what made these surface.
self.response = getattr(source_iterator, "response", None) # type: ignore[assignment]
self.model = getattr(source_iterator, "model", None) # type: ignore[assignment]
self.logging_obj = getattr( # type: ignore[assignment]
source_iterator,
"logging_obj",
getattr(source_iterator, "litellm_logging_obj", None),
@ -2587,7 +2606,23 @@ class Router:
return self
async def __anext__(self):
return await self._async_generator.__anext__()
chunk = await self._async_generator.__anext__()
# Sniff the terminal stream event off each forwarded chunk
# so ``self.completed_response`` is populated regardless of
# which inner iterator produced it (source_iterator,
# fallback_iterator, or any future wrapper). Without this
# the proxy's container-ownership hook (which reads
# ``getattr(stream_response, "completed_response", None)``
# via _extract_completed_responses_response) silently
# records nothing on streaming /v1/responses calls — every
# follow-up /v1/containers/<id>/files call then 403s for
# the very key that created the container (#30210).
if (
self.completed_response is None
and getattr(chunk, "type", None) in _RESPONSES_TERMINAL_EVENT_TYPES
):
self.completed_response = chunk
return chunk
async def aclose(self):
# async generators always expose aclose — no defensive check needed.

View file

@ -166,6 +166,13 @@ class CredentialLiteLLMParams(BaseModel):
api_key: Optional[str] = None
api_base: Optional[str] = None
api_version: Optional[str] = None
## AZURE OAUTH ##
# Without this field, ``get_deployment_credentials_with_provider``
# round-trips ``litellm_params`` through a strict Pydantic dump and
# silently drops the OAuth token before the files/batch/passthrough
# callers see it, breaking Azure deployments configured with
# ``azure_ad_token`` instead of a static ``api_key`` (#30235).
azure_ad_token: Optional[str] = None
## VERTEX AI ##
vertex_project: Optional[str] = None
vertex_location: Optional[str] = None

View file

@ -3338,6 +3338,7 @@ class LlmProviders(str, Enum):
CODESTRAL = "codestral"
TEXT_COMPLETION_CODESTRAL = "text-completion-codestral"
DASHSCOPE = "dashscope"
MODELSCOPE = "modelscope"
MOONSHOT = "moonshot"
PUBLICAI = "publicai"
V0 = "v0"
@ -3420,6 +3421,7 @@ class LlmProviders(str, Enum):
PARASAIL = "parasail"
XIAOMI_MIMO = "xiaomi_mimo"
TENSORMESH = "tensormesh"
LIBERTAI = "libertai"
LITELLM_AGENT = "litellm_agent"
CURSOR = "cursor"
BEDROCK_MANTLE = "bedrock_mantle"
@ -3455,6 +3457,7 @@ class SearchProviders(str, Enum):
GOOGLE_PSE = "google_pse"
DATAFORSEO = "dataforseo"
FIRECRAWL = "firecrawl"
FASTCRW = "fastcrw"
SEARXNG = "searxng"
LINKUP = "linkup"
DUCKDUCKGO = "duckduckgo"

View file

@ -3015,6 +3015,21 @@ def register_model(model_cost: Union[str, dict]): # noqa: PLR0915
# custom pricing on subsequent cost lookups.
if existing_model.get("litellm_provider") is None:
existing_model.pop("litellm_provider", None)
# Same pattern for cost fields (#30198): ``_get_model_info_helper``
# synthesizes ``input_cost_per_token`` / ``output_cost_per_token``
# = 0 when they are absent from the raw entry. Writing those zeros
# back flips a sparse entry from "no cost keys" (priced via name)
# to "cost keys = 0" (free), which makes
# ``_is_cost_explicitly_configured`` return True and silently
# disables budget enforcement on the next re-registration.
_raw_entry = litellm.model_cost.get(model_cost_key)
if _raw_entry is None:
_raw_entry = litellm.model_cost.get(key)
if _raw_entry is None:
_raw_entry = {}
for _cost_field in ("input_cost_per_token", "output_cost_per_token"):
if _cost_field not in _raw_entry and _cost_field not in value:
existing_model.pop(_cost_field, None)
## override / add new keys to the existing model cost dictionary
updated_dictionary = _update_dictionary(existing_model, value)
litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary)
@ -6833,6 +6848,11 @@ def validate_environment( # noqa: PLR0915
keys_in_environment = True
else:
missing_keys.append("DASHSCOPE_API_KEY")
elif custom_llm_provider == "modelscope":
if "MODELSCOPE_API_KEY" in os.environ:
keys_in_environment = True
else:
missing_keys.append("MODELSCOPE_API_KEY")
elif custom_llm_provider == "moonshot":
if "MOONSHOT_API_KEY" in os.environ:
keys_in_environment = True
@ -8504,6 +8524,7 @@ class ProviderConfigManager:
LlmProviders.NEBIUS: (lambda: litellm.NebiusConfig(), False),
LlmProviders.WANDB: (lambda: litellm.WandbConfig(), False),
LlmProviders.DASHSCOPE: (lambda: litellm.DashScopeChatConfig(), False),
LlmProviders.MODELSCOPE: (lambda: litellm.ModelScopeChatConfig(), False),
LlmProviders.MOONSHOT: (lambda: litellm.MoonshotChatConfig(), False),
LlmProviders.DOCKER_MODEL_RUNNER: (
lambda: litellm.DockerModelRunnerChatConfig(),
@ -9442,6 +9463,12 @@ class ProviderConfigManager:
)
return get_dashscope_image_generation_config(model)
elif LlmProviders.MODELSCOPE == provider:
from litellm.llms.modelscope.image_generation import (
get_modelscope_image_generation_config,
)
return get_modelscope_image_generation_config(model)
return None
@staticmethod
@ -9647,6 +9674,7 @@ class ProviderConfigManager:
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
from litellm.llms.linkup.search.transformation import LinkupSearchConfig
@ -9669,6 +9697,7 @@ class ProviderConfigManager:
SearchProviders.GOOGLE_PSE: GooglePSESearchConfig,
SearchProviders.DATAFORSEO: DataForSEOSearchConfig,
SearchProviders.FIRECRAWL: FirecrawlSearchConfig,
SearchProviders.FASTCRW: FastCRWSearchConfig,
SearchProviders.SEARXNG: SearXNGSearchConfig,
SearchProviders.LINKUP: LinkupSearchConfig,
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,

View file

@ -18822,6 +18822,38 @@
"supports_response_schema": true,
"supports_vision": true
},
"github_copilot/mai-code-1-flash": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true
},
"github_copilot/mai-code-1-flash-internal": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true
},
"github_copilot/text-embedding-3-small": {
"litellm_provider": "github_copilot",
"max_input_tokens": 8191,
@ -40986,6 +41018,174 @@
"litellm_provider": "llamagate",
"mode": "embedding"
},
"libertai/hermes-3-8b-tee": {
"max_tokens": 16000,
"max_input_tokens": 16000,
"max_output_tokens": 16000,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 6e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/gemma-4-31b-it": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 4e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/gemma-4-31b-it-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 4e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-27b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-27b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-35b-a3b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.6-35b-a3b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.5-122b-a10b": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/qwen3.5-122b-a10b-thinking": {
"max_tokens": 262144,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/deepseek-v4-flash": {
"max_tokens": 200000,
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/deepseek-v4-flash-thinking": {
"max_tokens": 200000,
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"input_cost_per_token": 2.5e-07,
"output_cost_per_token": 1.75e-06,
"litellm_provider": "libertai",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_system_messages": true,
"supports_vision": false,
"supports_reasoning": true,
"source": "https://docs.libertai.io/apis/text/"
},
"libertai/bge-m3": {
"max_tokens": 8192,
"max_input_tokens": 8192,
"input_cost_per_token": 1e-08,
"output_cost_per_token": 0.0,
"litellm_provider": "libertai",
"mode": "embedding",
"source": "https://docs.libertai.io/apis/text/"
},
"sarvam/sarvam-m": {
"cache_creation_input_token_cost": 0,
"cache_creation_input_token_cost_above_1hr": 0,

View file

@ -972,6 +972,23 @@
"search": true
}
},
"fastcrw": {
"display_name": "fastCRW (`fastcrw`)",
"url": "https://docs.litellm.ai/docs/search/fastcrw",
"endpoints": {
"chat_completions": false,
"messages": false,
"responses": false,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"search": true
}
},
"linkup": {
"display_name": "Linkup (`linkup`)",
"url": "https://docs.litellm.ai/docs/search/linkup",
@ -1359,6 +1376,23 @@
"interactions": true
}
},
"libertai": {
"display_name": "LibertAI (`libertai`)",
"url": "https://docs.litellm.ai/docs/providers/libertai",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": false,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false
}
},
"litellm_proxy": {
"display_name": "LiteLLM Proxy (`litellm_proxy`)",
"url": "https://docs.litellm.ai/docs/providers/litellm_proxy",
@ -1468,6 +1502,24 @@
"interactions": true
}
},
"modelscope": {
"display_name": "ModelScope (`modelscope`)",
"url": "https://docs.litellm.ai/docs/providers/modelscope",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": true,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false,
"interactions": false
}
},
"moonshot": {
"display_name": "Moonshot (`moonshot`)",
"url": "https://docs.litellm.ai/docs/providers/moonshot",

View file

@ -14,6 +14,7 @@ SEARCH_PROVIDERS = [
"exa_ai",
"brave",
"firecrawl",
"fastcrw",
"searxng",
"linkup",
"duckduckgo",

View file

@ -0,0 +1,96 @@
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
def test_anthropic_compaction_usage_calculation():
"""
Test that calculate_usage correctly sums tokens from the iterations array
as requested in Issue #27060.
"""
anthropic_config = AnthropicConfig()
# Mock usage object with compaction iterations
usage_object = {
"input_tokens": 100, # Top-level (excludes compaction)
"output_tokens": 50, # Top-level (excludes compaction)
"iterations": [
{
"iteration": 1,
"type": "compaction",
"input_tokens": 1000,
"output_tokens": 500,
},
{
"iteration": 2,
"type": "message",
"input_tokens": 100,
"output_tokens": 50,
},
],
}
usage = anthropic_config.calculate_usage(
usage_object=usage_object, reasoning_content=None
)
# Assertions
# Total prompt tokens should be 1000 + 100 = 1100
assert usage.prompt_tokens == 1100
# Total completion tokens should be 500 + 50 = 550
assert usage.completion_tokens == 550
# Total tokens should be 1650
assert usage.total_tokens == 1650
# Assert details
assert usage.prompt_tokens_details.text_tokens == 1100
# Assert iterations passthrough
assert usage.iterations is not None
assert len(usage.iterations) == 2
assert usage.iterations[0]["type"] == "compaction"
def test_anthropic_compaction_usage_with_iteration_cache():
"""
Test that calculate_usage correctly sums caching tokens FROM iterations.
This covers the specific case mentioned by JasonPan.
"""
anthropic_config = AnthropicConfig()
usage_object = {
"input_tokens": 100,
"output_tokens": 50,
"iterations": [
{
"type": "compaction",
"input_tokens": 500,
"output_tokens": 200,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 17000,
},
{
"type": "message",
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 10,
"cache_read_input_tokens": 20,
},
],
}
usage = anthropic_config.calculate_usage(
usage_object=usage_object, reasoning_content=None
)
# input_tokens sum = 500 + 100 = 600
# cache_creation sum = 50 + 10 = 60
# cache_read sum = 17000 + 20 = 17020
# Total prompt tokens = 600 + 60 + 17020 = 17680
assert usage.prompt_tokens == 17680
assert usage.completion_tokens == 250
assert usage.prompt_tokens_details.cache_creation_tokens == 60
assert usage.prompt_tokens_details.cached_tokens == 17020
if __name__ == "__main__":
test_anthropic_compaction_usage_calculation()
test_anthropic_compaction_usage_with_iteration_cache()

View file

@ -2819,3 +2819,37 @@ def test_reasoning_items_streaming_emitted_on_response_completed():
ri["encrypted_content"] == encrypted
), "encrypted_content must be preserved in streaming"
assert ri["summary"][0]["text"] == summary_text
def test_streaming_function_call_tool_id_for_degenerate_call_id():
"""In streaming, Bedrock Mantle's function_call event carries a unique ``id``
(``fc_...``) and a non-unique, index-based ``call_id`` (``call_0``). For that
degenerate form the chat tool-call chunk must use the unique ``id`` so multi-turn
streaming agents don't collapse every tool call to the same id (which makes the
agent loop). A normal (unique) ``call_id`` must be preserved. Regression for the
bedrock-mantle gpt-5.5 streaming path."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
def stream_tool_id(item_id, call_id):
chunk = {
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"id": item_id,
"call_id": call_id,
"name": "get_weather",
"arguments": "",
},
}
out = OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
chunk
)
tool_calls = out.model_dump()["choices"][0]["delta"]["tool_calls"]
assert tool_calls, "expected a tool_call chunk in the streaming delta"
return tool_calls[0]["id"]
assert stream_tool_id("fc_unique_abc123", "call_0") == "fc_unique_abc123"
assert stream_tool_id("fc_2", "call_tokyo") == "call_tokyo"

View file

@ -363,6 +363,54 @@ class TestCustomGuardrailShouldRunGuardrail:
result is False
), "Admin config in metadata must be respected when other metadata key is empty"
def test_should_run_guardrail_key_disable_global_not_overruled_by_team_guardrail_list(
self,
):
"""Key disable_global_guardrails must take precedence over the guardrail
appearing in the team's explicit guardrails list."""
from litellm.types.guardrails import GuardrailEventHooks
custom_guardrail = CustomGuardrail(
guardrail_name="global_guardrail",
default_on=True,
event_hook=GuardrailEventHooks.pre_call,
)
# Key disabled globals; team added the same guardrail to its explicit list
# (simulates what _add_guardrails_from_key_or_team_metadata produces).
data_key_disabled_team_listed = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
"metadata": {
"user_api_key_metadata": {"disable_global_guardrails": True},
"guardrails": ["global_guardrail"],
},
}
assert (
custom_guardrail.should_run_guardrail(
data=data_key_disabled_team_listed,
event_type=GuardrailEventHooks.pre_call,
)
is False
), "Key disable_global_guardrails must win over team's explicit guardrail list"
# Complementary: key NOT disabled, team added guardrail → should run
data_key_enabled_team_listed = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
"metadata": {
"user_api_key_metadata": {},
"guardrails": ["global_guardrail"],
},
}
assert (
custom_guardrail.should_run_guardrail(
data=data_key_enabled_team_listed,
event_type=GuardrailEventHooks.pre_call,
)
is True
), "Guardrail in team's explicit list should run when key has not disabled globals"
def test_should_run_guardrail_with_opted_out_global_guardrails(self):
"""Test that per-guardrail opt-out only works from admin metadata"""
from litellm.types.guardrails import GuardrailEventHooks

View file

@ -1,7 +1,13 @@
"""Tests for litellm.litellm_core_utils.fallback_utils."""
import pytest
import httpx
import litellm
from litellm.litellm_core_utils.fallback_utils import async_completion_with_fallbacks
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.fallback_utils import (
async_completion_with_fallbacks,
)
@pytest.mark.asyncio
@ -41,3 +47,123 @@ async def test_fallback_dict_not_mutated(monkeypatch):
"primary-model",
"fallback-model",
]
@pytest.mark.asyncio
async def test_async_completion_with_fallbacks_sets_attempted_fallbacks_header():
"""
When a fallback succeeds, the response must carry the
`x-litellm-attempted-fallbacks` header so the proxy and other callers can
detect that a fallback occurred. Without it,
`_override_openai_response_model` stamps the requested model back over the
fallback model used. See issue #28241.
"""
response = await async_completion_with_fallbacks(
model="openai/primary-llm",
messages=[{"role": "user", "content": "hi"}],
api_key="fake-key",
mock_response=Exception("forced failure"),
kwargs={
"fallbacks": [
{
"model": "openai/backup-llm",
"api_key": "fake-key",
"mock_response": "backup-resp",
}
]
},
)
hidden_params = getattr(response, "_hidden_params", None)
assert isinstance(hidden_params, dict)
headers = hidden_params.get("additional_headers") or {}
assert headers.get("x-litellm-attempted-fallbacks") == 1
@pytest.mark.asyncio
async def test_async_completion_with_fallbacks_header_is_zero_when_primary_succeeds():
"""
When the primary model succeeds on the first attempt, the header should be
`0` (no fallback was used). This mirrors the existing router-level
semantics in `async_function_with_fallbacks`.
"""
response = await async_completion_with_fallbacks(
model="openai/primary-llm",
messages=[{"role": "user", "content": "hi"}],
api_key="fake-key",
mock_response="primary-resp",
kwargs={
"fallbacks": [
{
"model": "openai/backup-llm",
"api_key": "fake-key",
"mock_response": "backup-resp",
}
]
},
)
hidden_params = getattr(response, "_hidden_params", None)
assert isinstance(hidden_params, dict)
headers = hidden_params.get("additional_headers") or {}
assert headers.get("x-litellm-attempted-fallbacks") == 0
assert response.choices[0].message.content == "primary-resp"
def test_process_response_headers_preserves_x_litellm_headers_when_internal():
"""
`process_response_headers` must not add the `llm_provider-` prefix to
LiteLLM's own internal headers (anything starting with `x-litellm-`) when
the caller has marked the input as LiteLLM-owned. These are markers set by
LiteLLM (e.g. fallback / retry headers); the proxy and other callers look
up the bare key.
"""
result = process_response_headers(
{
"x-litellm-attempted-fallbacks": 1,
"x-litellm-model-group": "gpt-4",
"x-stainless-arch": "arm64",
},
preserve_litellm_internal_headers=True,
)
assert result["x-litellm-attempted-fallbacks"] == 1
assert result["x-litellm-model-group"] == "gpt-4"
assert result["llm_provider-x-stainless-arch"] == "arm64"
def test_process_response_headers_prefixes_x_litellm_from_raw_provider():
"""
On raw upstream-provider headers (default `preserve_litellm_internal_headers=False`),
a header whose name starts with `x-litellm-` MUST still get the
`llm_provider-` prefix. Otherwise a malicious provider could return
`x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker,
bypassing the proxy model-override guard.
"""
result = process_response_headers(
{
"x-litellm-attempted-fallbacks": 99,
"x-stainless-arch": "arm64",
}
)
assert "x-litellm-attempted-fallbacks" not in result
assert result["llm_provider-x-litellm-attempted-fallbacks"] == 99
assert result["llm_provider-x-stainless-arch"] == "arm64"
def test_process_response_headers_ignores_preserve_flag_for_httpx_headers():
"""
Some providers store raw httpx.Headers directly in _hidden_params["additional_headers"]
without a prior normalization pass. If preserve_litellm_internal_headers=True were
honored for httpx.Headers inputs, a provider returning x-litellm-attempted-fallbacks
could spoof it as a bare LiteLLM-internal marker and make the proxy skip
stamping the correct response model. The flag must be ignored for httpx.Headers.
"""
raw = httpx.Headers(
{
"x-litellm-attempted-fallbacks": "1",
"content-type": "application/json",
}
)
result = process_response_headers(raw, preserve_litellm_internal_headers=True)
assert "x-litellm-attempted-fallbacks" not in result
assert result["llm_provider-x-litellm-attempted-fallbacks"] == "1"

View file

@ -8,7 +8,8 @@ sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm import stream_chunk_builder
from litellm import ChatCompletionUsageBlock, stream_chunk_builder
from litellm.types.utils import GenericStreamingChunk
from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
@ -324,6 +325,42 @@ def test_cache_read_input_tokens_retained():
assert usage.cache_read_input_tokens == 11775
assert usage.prompt_tokens_details.cached_tokens == 11775
def test_cache_read_input_tokens_retained_genericstreamingchunk():
chunk1 = GenericStreamingChunk(
text="Test1",
is_finished=False,
finish_reason="",
usage=None,
index=1,
)
chunk2 = GenericStreamingChunk(
text="Test2",
is_finished=True,
finish_reason="stop",
usage=ChatCompletionUsageBlock(
completion_tokens=5,
prompt_tokens=1234,
total_tokens=1239,
completion_tokens_details=None,
prompt_tokens_details=PromptTokensDetails(
audio_tokens=None, cached_tokens=543
).model_dump(),
),
index=2,
)
# Use dictionaries directly instead of ModelResponseStream
chunks = [chunk1, chunk2]
processor = ChunkProcessor(chunks=chunks)
usage = processor.calculate_usage(
chunks=chunks,
model="gpt-5.5",
completion_output="",
)
assert usage.prompt_tokens_details.cached_tokens == 543
def test_stream_chunk_builder_litellm_usage_chunks():
"""

View file

@ -221,3 +221,38 @@ async def test_pydantic_basemodel_chunk_passes_through_async():
assert len(chunks) == 1
assert "response.created" in chunks[0]["text"]
@pytest.mark.asyncio
async def test_aclose_closes_attached_http_response():
"""Regression for BerriAI/litellm#30244: CustomStreamWrapper.aclose() can
only release the upstream provider connection if the iterator exposes
aclose() and it reaches the underlying HTTP response. Without this, a
client disconnect leaves backends like vLLM generating into a dead pipe."""
from unittest.mock import AsyncMock, MagicMock
async def async_gen():
yield "data: {}"
iterator = BaseModelResponseIterator(
streaming_response=async_gen(), sync_stream=False
)
http_response = MagicMock()
http_response.aclose = AsyncMock()
iterator.http_response = http_response
await iterator.aclose()
http_response.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_aclose_is_noop_without_http_response():
async def async_gen():
yield "data: {}"
iterator = BaseModelResponseIterator(
streaming_response=async_gen(), sync_stream=False
)
await iterator.aclose()

View file

@ -0,0 +1,182 @@
import os
from unittest.mock import Mock, patch
import pytest
import litellm
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
def _config() -> FastCRWSearchConfig:
return FastCRWSearchConfig()
def test_fastcrw_search_request_body():
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"data": [
{
"title": "Test Title",
"url": "https://example.com",
"description": "Test description",
"markdown": "Test content",
}
],
}
with (
patch.dict(os.environ, {"CRW_API_KEY": "test-api-key"}),
patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
return_value=mock_response,
) as mock_post,
):
response = litellm.search(
query="test query",
search_provider="fastcrw",
max_results=10,
)
assert mock_post.called
call_kwargs = mock_post.call_args.kwargs
assert call_kwargs.get("url", "").endswith("/search")
request_body = call_kwargs.get("json")
assert request_body is not None
assert request_body["query"] == "test query"
assert request_body["limit"] == 10
assert len(response.results) == 1
result = response.results[0]
assert result.title == "Test Title"
assert result.url == "https://example.com"
assert result.snippet == "Test content"
def test_ui_friendly_name():
assert _config().ui_friendly_name() == "fastCRW"
def test_validate_environment_with_explicit_key():
headers = _config().validate_environment({}, api_key="explicit-key")
assert headers["Authorization"] == "Bearer explicit-key"
assert headers["Content-Type"] == "application/json"
def test_validate_environment_reads_env_key():
with patch.dict(os.environ, {"CRW_API_KEY": "env-key"}, clear=False):
headers = _config().validate_environment({})
assert headers["Authorization"] == "Bearer env-key"
def test_validate_environment_missing_key_raises():
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="CRW_API_KEY"):
_config().validate_environment({})
def test_get_complete_url_default_base():
with patch.dict(os.environ, {}, clear=True):
assert _config().get_complete_url(None, {}) == "https://fastcrw.com/api/v1/search"
def test_get_complete_url_appends_search():
assert (
_config().get_complete_url("https://self-hosted.local/api/v1", {})
== "https://self-hosted.local/api/v1/search"
)
def test_get_complete_url_does_not_double_append():
assert (
_config().get_complete_url("https://self-hosted.local/api/v1/search", {})
== "https://self-hosted.local/api/v1/search"
)
def test_get_complete_url_reads_env_base():
with patch.dict(
os.environ, {"CRW_API_BASE": "https://env-base.local/v1"}, clear=True
):
assert _config().get_complete_url(None, {}) == "https://env-base.local/v1/search"
def test_transform_search_request_basic():
data = _config().transform_search_request("hello", {"max_results": 5})
assert data["query"] == "hello"
assert data["limit"] == 5
assert data["scrapeOptions"]["formats"] == ["markdown"]
assert data["scrapeOptions"]["onlyMainContent"] is True
def test_transform_search_request_joins_list_query():
assert _config().transform_search_request(["foo", "bar"], {})["query"] == "foo bar"
def test_transform_search_request_passes_through_extra_params():
data = _config().transform_search_request("q", {"sources": ["web", "images"]})
assert data["sources"] == ["web", "images"]
def test_transform_search_request_preserves_explicit_scrape_options():
custom = {"formats": ["html"]}
data = _config().transform_search_request("q", {"scrapeOptions": custom})
assert data["scrapeOptions"] == custom
def _resp(payload):
r = Mock()
r.json.return_value = payload
return r
def test_transform_search_response_prefers_markdown():
resp = _config().transform_search_response(
_resp(
{
"success": True,
"data": [
{
"title": "T",
"url": "https://e.com",
"description": "d",
"markdown": "md",
}
],
}
),
logging_obj=Mock(),
)
assert len(resp.results) == 1
assert resp.results[0].snippet == "md"
def test_transform_search_response_falls_back_to_description():
resp = _config().transform_search_response(
_resp(
{
"success": True,
"data": [
{"title": "T", "url": "https://e.com", "description": "only-desc"}
],
}
),
logging_obj=Mock(),
)
assert resp.results[0].snippet == "only-desc"
def test_transform_search_response_empty_data():
resp = _config().transform_search_response(
_resp({"success": True, "data": []}), logging_obj=Mock()
)
assert resp.results == []
def test_transform_search_response_non_list_data():
resp = _config().transform_search_response(
_resp({"success": True, "data": {"unexpected": "shape"}}), logging_obj=Mock()
)
assert resp.results == []

View file

@ -0,0 +1,394 @@
"""
Unit tests for ModelScope configuration.
These tests validate the ModelScopeChatConfig class which extends OpenAIGPTConfig.
ModelScope is an OpenAI-compatible provider with minor customizations.
"""
import json
import os
import sys
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from unittest.mock import patch
import httpx
import pytest
import respx
import litellm
from litellm import completion
from litellm.llms.modelscope.chat.transformation import ModelScopeChatConfig
DEFAULT_MODEL = "Qwen/Qwen3.5-35B-A3B"
class TestModelScopeConfig:
"""Test class for ModelScope functionality"""
def test_default_api_base(self):
"""Test that default API base is used when none is provided"""
config = ModelScopeChatConfig()
headers = {}
api_key = "fake-modelscope-key"
result = config.validate_environment(
headers=headers,
model=DEFAULT_MODEL,
messages=[{"role": "user", "content": "Hey"}],
optional_params={},
litellm_params={},
api_key=api_key,
api_base=None,
)
assert result["Authorization"] == f"Bearer {api_key}"
assert result["Content-Type"] == "application/json"
@pytest.mark.respx()
def test_modelscope_completion_mock(self, respx_mock):
"""Mock test for basic ModelScope completion."""
litellm.disable_aiohttp_transport = True
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
respx_mock.post(f"{api_base}/chat/completions").respond(
json={
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": '```python\nprint("Hey from LiteLLM!")\n```',
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 12,
"total_tokens": 21,
},
},
status_code=200,
)
response = completion(
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{"role": "user", "content": "write code for saying hey from LiteLLM"}
],
api_key=api_key,
api_base=api_base,
)
assert response is not None
assert response.choices[0].message.content is not None
assert "```python" in response.choices[0].message.content
# ── _transform_messages tests ──────────────────────────────────────
def test_transform_messages_flattens_text_content_list(self):
"""Content lists containing only text items should be flattened to a string."""
config = ModelScopeChatConfig()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": " world"},
],
}
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hello world"
def test_transform_messages_preserves_multimodal_content_list(self):
"""Content lists with image_url should be preserved as lists for vision models."""
config = ModelScopeChatConfig()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
],
}
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert isinstance(result[0]["content"], list)
assert len(result[0]["content"]) == 2
assert result[0]["content"][0]["type"] == "text"
assert result[0]["content"][1]["type"] == "image_url"
def test_transform_messages_string_content_unchanged(self):
"""Messages with string content should pass through unchanged."""
config = ModelScopeChatConfig()
messages = [{"role": "user", "content": "Hello"}]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hello"
def test_transform_messages_multi_turn(self):
"""Multi-turn conversations should be handled correctly."""
config = ModelScopeChatConfig()
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{
"role": "user",
"content": [
{"type": "text", "text": "Tell me more"},
],
},
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hi"
assert result[1]["content"] == "Hello!"
assert result[2]["content"] == "Tell me more"
def test_transform_messages_multimodal_multi_turn(self):
"""Multi-turn with mixed text-only and multimodal messages."""
config = ModelScopeChatConfig()
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}},
],
},
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hi"
assert result[1]["content"] == "Hello!"
# Multimodal message should keep list format
assert isinstance(result[2]["content"], list)
assert result[2]["content"][1]["type"] == "image_url"
# ── get_complete_url tests ─────────────────────────────────────────
def test_get_complete_url_default(self):
"""Default api_base should append /chat/completions."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base=None,
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://api-inference.modelscope.cn/v1/chat/completions"
def test_get_complete_url_custom_base(self):
"""Custom api_base should append /chat/completions."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base="https://custom.modelscope.cn/v1",
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://custom.modelscope.cn/v1/chat/completions"
def test_get_complete_url_already_has_endpoint(self):
"""api_base already ending in /chat/completions should not be doubled."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base="https://api-inference.modelscope.cn/v1/chat/completions",
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://api-inference.modelscope.cn/v1/chat/completions"
assert url.count("/chat/completions") == 1
# ── _get_openai_compatible_provider_info tests ─────────────────────
def test_get_provider_info_with_explicit_api_base(self):
"""Explicit api_base and api_key should be returned as-is."""
config = ModelScopeChatConfig()
api_base, api_key = config._get_openai_compatible_provider_info(
api_base="https://custom.example.com/v1",
api_key="my-key",
)
assert api_base == "https://custom.example.com/v1"
assert api_key == "my-key"
def test_get_provider_info_default_fallback(self):
"""When no api_base or env var is set, DEFAULT_BASE_URL should be used."""
config = ModelScopeChatConfig()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("MODELSCOPE_API_BASE", None)
os.environ.pop("MODELSCOPE_API_KEY", None)
api_base, api_key = config._get_openai_compatible_provider_info(
api_base=None,
api_key=None,
)
assert api_base == "https://api-inference.modelscope.cn/v1"
assert api_key is None
def test_get_provider_info_env_var_fallback(self):
"""MODELSCOPE_API_BASE env var should be used when api_base is not provided."""
config = ModelScopeChatConfig()
with patch.dict(
os.environ,
{"MODELSCOPE_API_BASE": "https://env.modelscope.cn/v1"},
):
api_base, _ = config._get_openai_compatible_provider_info(
api_base=None,
api_key=None,
)
assert api_base == "https://env.modelscope.cn/v1"
# ── Mock HTTP tests ────────────────────────────────────────────────
@pytest.mark.respx()
def test_completion_with_text_content_list(self, respx_mock):
"""Verify that text-only content list messages are flattened before sending."""
litellm.disable_aiohttp_transport = True
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
captured_request = {}
def capture_request(request):
captured_request["body"] = request.content
return httpx.Response(
200,
json={
"id": "chatcmpl-456",
"object": "chat.completion",
"created": 1677652288,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Sure!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
},
)
respx_mock.post(f"{api_base}/chat/completions").mock(side_effect=capture_request)
response = completion(
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": " world"},
],
}
],
api_key=api_key,
api_base=api_base,
)
assert response.choices[0].message.content == "Sure!"
body = json.loads(captured_request["body"])
assert isinstance(body["messages"][0]["content"], str)
assert body["messages"][0]["content"] == "Hello world"
@pytest.mark.respx()
def test_completion_with_multimodal_messages(self, respx_mock):
"""Verify that multimodal messages (text + image_url) are sent as content lists."""
litellm.disable_aiohttp_transport = True
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
captured_request = {}
def capture_request(request):
captured_request["body"] = request.content
return httpx.Response(
200,
json={
"id": "chatcmpl-789",
"object": "chat.completion",
"created": 1677652288,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "A cat sitting on a couch.",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 8, "total_tokens": 108},
},
)
respx_mock.post(f"{api_base}/chat/completions").mock(side_effect=capture_request)
response = completion(
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.jpg"},
},
],
}
],
api_key=api_key,
api_base=api_base,
)
assert response.choices[0].message.content == "A cat sitting on a couch."
body = json.loads(captured_request["body"])
msg = body["messages"][0]
# Multimodal content should remain as a list
assert isinstance(msg["content"], list)
assert len(msg["content"]) == 2
assert msg["content"][0] == {"type": "text", "text": "What is in this image?"}
assert msg["content"][1]["type"] == "image_url"
assert msg["content"][1]["image_url"]["url"] == "https://example.com/cat.jpg"

View file

@ -0,0 +1,456 @@
"""
Unit tests for ModelScope image generation configuration.
These tests validate the ModelScopeImageGenerationConfig class which handles
transformation between OpenAI-compatible format and ModelScope API format.
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from litellm.llms.modelscope.image_generation.transformation import (
ModelScopeImageGenerationConfig,
)
from litellm.types.utils import ImageResponse
class TestModelScopeImageGenerationTransformation:
def setup_method(self):
"""Set up test fixtures before each test method."""
self.config = ModelScopeImageGenerationConfig()
self.model = "modelscope/Qwen/Qwen-Image-Edit"
self.logging_obj = MagicMock()
def test_get_supported_openai_params(self):
"""Test that get_supported_openai_params returns correct parameters."""
supported_params = self.config.get_supported_openai_params(self.model)
assert "n" in supported_params
assert "size" in supported_params
assert "response_format" in supported_params
assert "user" in supported_params
def test_map_openai_params(self):
"""Test that map_openai_params correctly passes through parameters."""
non_default_params = {
"n": 2,
"size": "1024x1024",
"response_format": "url",
}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert result["n"] == 2
assert result["size"] == "1024x1024"
assert result["response_format"] == "url"
def test_map_openai_params_with_user(self):
"""Test that map_openai_params correctly passes through user parameter."""
non_default_params = {"user": "test-user-123"}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert result["user"] == "test-user-123"
def test_get_complete_url_default(self):
"""Test that get_complete_url returns default ModelScope URL."""
result = self.config.get_complete_url(
api_base=None,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == "https://api-inference.modelscope.cn/v1/images/generations"
def test_get_complete_url_with_custom_base(self):
"""Test that get_complete_url uses custom api_base."""
custom_base = "https://custom.modelscope.cn/v1"
result = self.config.get_complete_url(
api_base=custom_base,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == f"{custom_base}/images/generations"
def test_get_complete_url_with_trailing_slash(self):
"""Test that get_complete_url strips trailing slashes from base."""
custom_base = "https://custom.modelscope.cn/v1/"
result = self.config.get_complete_url(
api_base=custom_base,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == "https://custom.modelscope.cn/v1/images/generations"
@patch("litellm.llms.modelscope.image_generation.transformation.get_secret_str")
def test_validate_environment_with_api_key(self, mock_get_secret):
"""Test that validate_environment correctly sets authorization header."""
headers = {}
api_key = "test_api_key"
result = self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=api_key,
)
assert result["Authorization"] == f"Bearer {api_key}"
assert result["Content-Type"] == "application/json"
mock_get_secret.assert_not_called()
@patch("litellm.llms.modelscope.image_generation.transformation.get_secret_str")
def test_validate_environment_with_secret_key(self, mock_get_secret):
"""Test that validate_environment uses secret API key when api_key is None."""
mock_get_secret.return_value = "secret_api_key"
headers = {}
result = self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer secret_api_key"
mock_get_secret.assert_called_once_with("MODELSCOPE_API_KEY")
@patch("litellm.llms.modelscope.image_generation.transformation.get_secret_str")
def test_validate_environment_no_api_key(self, mock_get_secret):
"""Test that validate_environment raises error when no API key is available."""
mock_get_secret.return_value = None
headers = {}
with pytest.raises(ValueError) as exc_info:
self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert "MODELSCOPE_API_KEY is not set" in str(exc_info.value)
def test_transform_image_generation_request_basic(self):
"""Test that transform_image_generation_request creates correct request body."""
prompt = "A beautiful sunset over mountains"
optional_params = {}
result = self.config.transform_image_generation_request(
model=self.model,
prompt=prompt,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["model"] == self.model
assert result["prompt"] == prompt
def test_transform_image_generation_request_with_optional_params(self):
"""Test that transform_image_generation_request includes optional params."""
prompt = "A beautiful sunset"
optional_params = {
"n": 2,
"size": "1024x1024",
"response_format": "b64_json",
}
result = self.config.transform_image_generation_request(
model=self.model,
prompt=prompt,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["model"] == self.model
assert result["prompt"] == prompt
assert result["n"] == 2
assert result["size"] == "1024x1024"
assert result["response_format"] == "b64_json"
def test_transform_image_generation_request_ignores_internal_params(self):
"""Test that transform_image_generation_request ignores params starting with _."""
prompt = "A beautiful sunset"
optional_params = {
"n": 2,
"_internal_param": "should_be_ignored",
}
result = self.config.transform_image_generation_request(
model=self.model,
prompt=prompt,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["model"] == self.model
assert result["n"] == 2
assert "_internal_param" not in result
def test_transform_image_generation_response_with_url_images(self):
"""Test that transform_image_generation_response correctly extracts URL images."""
response_data = {
"created": 1234567890,
"data": [
{"url": "https://example.com/image1.png"},
{"url": "https://example.com/image2.png"},
],
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 2
assert result.data[0].url == "https://example.com/image1.png"
assert result.data[1].url == "https://example.com/image2.png"
def test_transform_image_generation_response_with_b64_json(self):
"""Test that transform_image_generation_response correctly extracts base64 images."""
response_data = {
"created": 1234567890,
"data": [
{"b64_json": "iVBORw0KGgoAAAANS"},
],
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 1
assert result.data[0].b64_json == "iVBORw0KGgoAAAANS"
assert result.data[0].url is None
def test_transform_image_generation_response_with_revised_prompt(self):
"""Test that transform_image_generation_response extracts revised_prompt."""
response_data = {
"created": 1234567890,
"data": [
{
"url": "https://example.com/image.png",
"revised_prompt": "A detailed description of a beautiful sunset",
},
],
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 1
assert (
result.data[0].revised_prompt
== "A detailed description of a beautiful sunset"
)
def test_transform_image_generation_response_empty_data(self):
"""Test that transform_image_generation_response handles empty data array."""
response_data = {
"created": 1234567890,
"data": [],
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 0
def test_transform_image_generation_response_error_handling(self):
"""Test that transform_image_generation_response raises error on API error."""
response_data = {
"error": {
"message": "Invalid prompt provided",
"type": "invalid_request_error",
}
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 400
mock_response.headers = {}
model_response = ImageResponse(data=[])
with pytest.raises(Exception) as exc_info:
self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert "ModelScope error" in str(exc_info.value)
assert "Invalid prompt provided" in str(exc_info.value)
def test_transform_image_generation_response_json_error(self):
"""Test that transform_image_generation_response raises error on invalid JSON."""
import json
mock_response = MagicMock()
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_response.status_code = 500
mock_response.headers = {}
model_response = ImageResponse(data=[])
with pytest.raises(Exception) as exc_info:
self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert "Error parsing ModelScope response" in str(exc_info.value)
def test_get_error_class_bad_request(self):
"""Test that get_error_class returns BadRequestError for 400 status."""
from litellm.exceptions import BadRequestError
error = self.config.get_error_class(
error_message="Bad request",
status_code=400,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, BadRequestError)
def test_get_error_class_authentication_error(self):
"""Test that get_error_class returns AuthenticationError for 401 status."""
from litellm.exceptions import AuthenticationError
error = self.config.get_error_class(
error_message="Invalid API key",
status_code=401,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, AuthenticationError)
def test_get_error_class_internal_server_error(self):
"""Test that get_error_class returns InternalServerError for 500+ status."""
from litellm.exceptions import InternalServerError
error = self.config.get_error_class(
error_message="Internal server error",
status_code=500,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, InternalServerError)
def test_get_error_class_default(self):
"""Test that get_error_class returns BadRequestError for other status codes."""
from litellm.exceptions import BadRequestError
error = self.config.get_error_class(
error_message="Some error",
status_code=404,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, BadRequestError)

View file

@ -0,0 +1,131 @@
"""
Tests for LibertAI provider configuration and integration.
"""
import litellm
class TestLibertAIProviderConfig:
"""Test LibertAI provider configuration"""
def test_libertai_in_provider_list(self):
"""Test that libertai is in the provider list"""
from litellm import LlmProviders
assert hasattr(LlmProviders, "LIBERTAI")
assert LlmProviders.LIBERTAI.value == "libertai"
assert "libertai" in litellm.provider_list
def test_libertai_json_config_exists(self):
"""Test that libertai is configured in providers.json"""
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.exists("libertai")
libertai = JSONProviderRegistry.get("libertai")
assert libertai is not None
assert libertai.base_url == "https://api.libertai.io/v1"
assert libertai.api_key_env == "LIBERTAI_API_KEY"
assert libertai.api_base_env == "LIBERTAI_API_BASE"
assert libertai.param_mappings.get("max_completion_tokens") == "max_tokens"
def test_libertai_provider_resolution(self):
"""Test that provider resolution finds libertai and the default base URL"""
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="libertai/qwen3.6-27b",
custom_llm_provider=None,
api_base=None,
api_key=None,
)
assert model == "qwen3.6-27b"
assert provider == "libertai"
assert api_base == "https://api.libertai.io/v1"
def test_libertai_api_base_override(self):
"""Test that an explicit api_base / api_key overrides the default"""
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="libertai/qwen3.6-27b",
custom_llm_provider=None,
api_base="https://custom.example.com/v1",
api_key="sk-test",
)
assert provider == "libertai"
assert api_base == "https://custom.example.com/v1"
assert api_key == "sk-test"
def test_libertai_model_cost_map(self):
"""Test that libertai models are present in the model cost map"""
model_cost = litellm.model_cost
assert "libertai/qwen3.6-27b" in model_cost
info = model_cost["libertai/qwen3.6-27b"]
assert info["litellm_provider"] == "libertai"
assert info["mode"] == "chat"
assert info["max_input_tokens"] == 262144
assert info["max_output_tokens"] == 262144
# thinking variants are marked as reasoning models
assert (
model_cost["libertai/qwen3.6-27b-thinking"].get("supports_reasoning")
is True
)
def test_libertai_router_config(self):
"""Test that libertai can be used in Router configuration"""
from litellm import Router
router = Router(
model_list=[
{
"model_name": "libertai-chat",
"litellm_params": {
"model": "libertai/qwen3.6-27b",
"api_key": "test-key",
},
}
]
)
assert len(router.model_list) == 1
assert router.model_list[0]["model_name"] == "libertai-chat"
def test_libertai_model_modes(self):
"""Chat models carry mode 'chat'; the embedding model carries mode 'embedding'."""
model_cost = litellm.model_cost
# chat model
assert model_cost["libertai/qwen3.6-27b"]["mode"] == "chat"
# embedding model (bge-m3) must be normalized to mode 'embedding' so
# /embeddings routing and the supported-endpoints matrix stay consistent
assert "libertai/bge-m3" in model_cost
bge = model_cost["libertai/bge-m3"]
assert bge["litellm_provider"] == "libertai"
assert bge["mode"] == "embedding"
def test_libertai_supported_endpoints_matrix(self):
"""The runtime-served backup matrix (GET /public/supported_endpoints) lists libertai."""
import json
from pathlib import Path
import litellm as _litellm
backup_path = (
Path(_litellm.__file__).parent / "provider_endpoints_support_backup.json"
)
matrix = json.loads(backup_path.read_text())
assert "libertai" in matrix["providers"]
endpoints = matrix["providers"]["libertai"]["endpoints"]
assert endpoints["chat_completions"] is True
# embeddings is advertised false: the JSON-configured-provider path only
# wires chat routing (the OpenAILike embedding handler is reached solely
# for the literal openai_like/llamafile/lm_studio providers), matching
# the llamagate precedent. bge-m3 stays in the cost map for metadata.
assert endpoints["embeddings"] is False

View file

@ -1917,3 +1917,57 @@ class TestVertexAIGlobalLocation:
assert "generativelanguage.googleapis.com" in url
assert "cachedContents" in url
class TestContextCachingMultiRegionUrls:
"""Regression coverage for #29571: multi-region vertex_location values
(`eu`, `us`) must resolve to the REP host (`aiplatform.{geo}.rep.googleapis.com`)
on the cachedContents endpoint, matching the inference path (already
fixed in #27293). Previously the URL was hardcoded to
`{location}-aiplatform.googleapis.com`, which doesn't exist for
multi-region locations and 404'd."""
def setup_method(self):
self.caching = ContextCachingEndpoints()
@pytest.mark.parametrize("location", ["eu", "us"])
def test_vertex_ai_multi_region_uses_rep_host(self, location):
_, url = self.caching._get_token_and_url_context_caching(
gemini_api_key=None,
custom_llm_provider="vertex_ai",
api_base=None,
vertex_project="my-project",
vertex_location=location,
vertex_auth_header="Bearer token",
)
assert url.startswith(f"https://aiplatform.{location}.rep.googleapis.com/")
assert f"/locations/{location}/cachedContents" in url
# Old broken host must no longer appear.
assert f"{location}-aiplatform.googleapis.com" not in url
def test_vertex_ai_regional_still_uses_regional_host(self):
_, url = self.caching._get_token_and_url_context_caching(
gemini_api_key=None,
custom_llm_provider="vertex_ai",
api_base=None,
vertex_project="my-project",
vertex_location="us-central1",
vertex_auth_header="Bearer token",
)
assert url.startswith("https://us-central1-aiplatform.googleapis.com/")
assert "/locations/us-central1/cachedContents" in url
def test_vertex_ai_global_still_uses_global_host(self):
_, url = self.caching._get_token_and_url_context_caching(
gemini_api_key=None,
custom_llm_provider="vertex_ai",
api_base=None,
vertex_project="my-project",
vertex_location="global",
vertex_auth_header="Bearer token",
)
assert url.startswith("https://aiplatform.googleapis.com/")
assert "/locations/global/cachedContents" in url

View file

@ -409,3 +409,101 @@ async def test_get_customer_daily_activity_with_end_user_aliases(monkeypatch):
"end-user-1": {"alias": "Customer One"},
"end-user-2": {"alias": "Customer Two"},
}
@pytest.mark.asyncio
async def test_get_customer_daily_activity_non_admin_is_rejected(monkeypatch):
"""
Security regression: any non-admin caller must receive 401 from
/customer/daily/activity and /end_user/daily/activity.
Before this fix, the endpoint performed no role check. A caller with
user_role=INTERNAL_USER could omit end_user_ids, causing entity_id=None
to flow into get_daily_activity where the SQL builder treats it as no
filter returning every tenant's spend across the full
LiteLLM_DailyEndUserSpend table.
LiteLLM_EndUserTable has no per-tenant ownership column, so non-admin
scoping is not possible. The correct fix is admin-only, matching the
existing /customer/list gate.
"""
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
get_daily_activity_mock = AsyncMock()
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
non_admin_key = UserAPIKeyAuth(
user_id="regular-user-abc",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_customer_daily_activity(
end_user_ids=None,
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_end_user_ids=None,
user_api_key_dict=non_admin_key,
)
assert exc_info.value.status_code == 401
assert "Admin-only endpoint" in str(exc_info.value.detail)
get_daily_activity_mock.assert_not_called()
@pytest.mark.asyncio
async def test_get_customer_daily_activity_service_account_key_is_rejected(monkeypatch):
"""
Security regression: service-account keys (user_id=None, role=INTERNAL_USER)
must be rejected at the admin gate before reaching get_daily_activity.
A service-account key with end_user_ids omitted is the worst-case caller:
entity_id=None and no user identity to scope by the SQL builder would
return the full LiteLLM_DailyEndUserSpend table with no WHERE clause.
"""
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
get_daily_activity_mock = AsyncMock()
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
service_account_key = UserAPIKeyAuth(
user_id=None,
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_customer_daily_activity(
end_user_ids=None,
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_end_user_ids=None,
user_api_key_dict=service_account_key,
)
assert exc_info.value.status_code == 401
assert "Admin-only endpoint" in str(exc_info.value.detail)
get_daily_activity_mock.assert_not_called()

View file

@ -3064,6 +3064,106 @@ async def test_list_team_v2_org_admin_sees_org_teams():
assert where["organization_id"] == {"in": ["org_A"]}
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams():
"""
Test that an org admin whose own user_id is sent (as the UI does for
non-Admin roles) still sees all teams in their organization, not just
teams they are a direct member of.
Regression test for https://github.com/BerriAI/litellm/issues/30215
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
mock_request = Mock(spec=Request)
mock_user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="org_admin_user",
)
mock_user = LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_1"], # direct member of only 1 team
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="org_admin_user",
organization_id="org_A",
user_role="org_admin",
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
),
],
)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache"),
patch("litellm.proxy.proxy_server.proxy_logging_obj"),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
),
):
mock_db = Mock()
mock_prisma.db = mock_db
mock_team_1 = Mock()
mock_team_1.model_dump.return_value = {
"team_id": "team_1",
"team_alias": "Team One",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "org_admin_user", "role": "admin"}],
}
mock_team_2 = Mock()
mock_team_2.model_dump.return_value = {
"team_id": "team_2",
"team_alias": "Team Two",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "other_user", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team_1, mock_team_2]
)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
# UI sends the caller's own user_id for non-Admin roles
result = await list_team_v2(
http_request=mock_request,
user_id="org_admin_user", # same as caller — UI sends this
organization_id=None,
team_id=None,
team_alias=None,
user_api_key_dict=mock_user_api_key_dict,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)
assert result["total"] == 2
assert len(result["teams"]) == 2
# Verify the where clause scopes by org only — no team_id filter
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["organization_id"] == {"in": ["org_A"]}
assert "team_id" not in where
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_cannot_view_other_orgs():
"""

View file

@ -359,6 +359,7 @@ ignored_keys = [
"metadata.additional_usage_values.cache_read_input_tokens",
"metadata.additional_usage_values.inference_geo",
"metadata.additional_usage_values.speed",
"metadata.additional_usage_values.iterations",
"metadata.litellm_overhead_time_ms",
"metadata.cost_breakdown",
"metadata.user_api_key",

View file

@ -24,6 +24,7 @@ from litellm.proxy.common_request_processing import (
_is_azure_model_router_request,
_override_openai_response_model,
_parse_event_data_for_error,
_UpstreamClosingStreamingResponse,
create_response,
)
from litellm.proxy.dd_span_tagger import DDSpanTagger
@ -2415,6 +2416,186 @@ class TestHandleLLMApiExceptionDictDetail:
assert proxy_exc.code == "500"
class TestStreamCloseOnDisconnect:
"""
Coverage for closing the upstream LLM stream when the client disconnects
mid-stream. Starlette abandons the response body iterator without calling
aclose(), so without these hooks the proxy->backend connection stays open
and the backend (e.g. vLLM) keeps generating into a dead pipe.
"""
async def test_response_closes_body_iterator_when_task_cancelled(self):
"""Cancellation landing in send() leaves the generator suspended at a
yield; only the response-level finally can close it."""
closed = asyncio.Event()
async def body():
try:
while True:
yield "data: x\n\n"
finally:
closed.set()
response = _UpstreamClosingStreamingResponse(
body(), media_type="text/event-stream"
)
async def receive():
await asyncio.Event().wait()
async def send(message):
if message["type"] == "http.response.body":
await asyncio.Event().wait()
task = asyncio.create_task(response({"type": "http"}, receive, send))
await asyncio.sleep(0.05)
assert not closed.is_set()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed.is_set()
async def test_response_closes_body_iterator_on_http_disconnect(self):
closed = asyncio.Event()
disconnected = asyncio.Event()
body_sends = 0
async def body():
try:
for i in range(1000):
yield f"data: {i}\n\n"
finally:
closed.set()
response = _UpstreamClosingStreamingResponse(
body(), media_type="text/event-stream"
)
async def receive():
await disconnected.wait()
return {"type": "http.disconnect"}
async def send(message):
nonlocal body_sends
if message["type"] == "http.response.body":
body_sends += 1
if body_sends == 3:
disconnected.set()
await asyncio.sleep(0.05)
await response({"type": "http"}, receive, send)
assert closed.is_set()
assert body_sends < 1000
async def test_upstream_closed_even_if_body_iterator_aclose_raises(self):
"""A BaseException from body_iterator.aclose() (e.g. CancelledError)
must not prevent the upstream generator from being closed."""
upstream_closed = asyncio.Event()
class ExplodingIterator:
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
async def aclose(self):
raise asyncio.CancelledError()
async def upstream():
try:
yield "data: a\n\n"
finally:
upstream_closed.set()
upstream_gen = upstream()
await upstream_gen.__anext__()
response = _UpstreamClosingStreamingResponse(
ExplodingIterator(),
media_type="text/event-stream",
upstream_generator=upstream_gen,
)
async def receive():
await asyncio.Event().wait()
async def send(message):
pass
await response({"type": "http"}, receive, send)
assert upstream_closed.is_set()
async def test_create_response_closes_wrapped_generator_on_cancellation(self):
"""End to end through create_response: the upstream-facing generator
must be closed even when the body iterator was never started (client
gone before the first chunk could be sent)."""
inner_closed = asyncio.Event()
async def wrapped():
try:
while True:
yield "data: a\n\n"
finally:
inner_closed.set()
response = await create_response(
generator=wrapped(), media_type="text/event-stream", headers={}
)
async def receive():
await asyncio.Event().wait()
async def send(message):
await asyncio.Event().wait()
task = asyncio.create_task(response({"type": "http"}, receive, send))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert inner_closed.is_set()
async def test_async_streaming_data_generator_closes_upstream_on_early_close(
self,
):
class FakeUpstream:
def __init__(self):
self.aclosed = False
def __aiter__(self):
return self
async def __anext__(self):
return {"type": "chunk"}
async def aclose(self):
self.aclosed = True
ProxyLogging._callback_capabilities_cache.clear()
upstream = FakeUpstream()
gen = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=upstream,
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
request_data={"model": "mock-model"},
proxy_logging_obj=ProxyLogging(user_api_key_cache=MagicMock()),
serialize_chunk=lambda c: "data: x\n\n",
serialize_error=lambda e: "data: error\n\n",
)
await gen.__anext__()
await gen.__anext__()
assert not upstream.aclosed
await gen.aclose()
assert upstream.aclosed
class TestHandleLLMApiExceptionRetryAfter:
"""RouterRateLimitError cooldown_time must surface as a retry-after header."""

View file

@ -2275,3 +2275,28 @@ class TestCacheControlPreservation:
assert isinstance(result, list)
assert len(result) == 1
assert result[0]["cache_control"] == {"type": "ephemeral"}
def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id():
"""Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0`` that
resets every response) alongside a unique ``id`` (``fc_...``). For that degenerate
form the converter must expose the unique ``id``; otherwise every tool call across
an agent's turns collapses to the same id, the agent cannot correlate its tool
results, and it loops re-issuing the same call. A normal (unique) ``call_id`` must
be preserved, since it is the canonical Responses API correlation key. Regression
for the bedrock-mantle gpt-5.5 non-streaming path."""
from types import SimpleNamespace
convert = (
LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call
)
mantle = SimpleNamespace(
id="fc_unique_abc123", call_id="call_0", name="get_weather", arguments="{}"
)
assert convert(mantle)["id"] == "fc_unique_abc123"
openai = SimpleNamespace(
id="fc_2", call_id="call_tokyo", name="get_weather", arguments="{}"
)
assert convert(openai)["id"] == "call_tokyo"

View file

@ -0,0 +1,145 @@
"""
Regression for #30235.
``Router.get_deployment_credentials_with_provider`` (router.py:8954) is
used by the proxy's ``/v1/files``, ``/v1/batches`` and passthrough
routing code paths to resolve the upstream credentials for a deployment
by model_id::
return CredentialLiteLLMParams(
**deployment.litellm_params.model_dump(exclude_none=True)
).model_dump(exclude_none=True)
That re-validation is strict. Any field NOT declared on
``CredentialLiteLLMParams`` gets dropped on the way through, even when
it was present on the original ``litellm_params``.
Pre-fix, ``azure_ad_token`` was undeclared, so Azure deployments
configured with OAuth/M2M (``azure_ad_token`` in place of ``api_key``)
silently lost their token on every file upload and the proxy returned::
Missing credentials. Please pass one of api_key, azure_ad_token,
azure_ad_token_provider, ...
Tests below pin two things:
1. ``CredentialLiteLLMParams`` directly accepts and round-trips
``azure_ad_token``.
2. ``Router.get_deployment_credentials_with_provider`` preserves
``azure_ad_token`` from a deployment's ``litellm_params``.
"""
from unittest.mock import MagicMock, patch
import pytest
class TestCredentialLiteLLMParamsAzureAdToken:
def test_azure_ad_token_round_trips_through_model_dump(self):
from litellm.types.router import CredentialLiteLLMParams
params = CredentialLiteLLMParams(
api_base="https://my.openai.azure.com",
api_version="2024-08-01-preview",
azure_ad_token="oauth-bearer-token-xyz",
)
dumped = params.model_dump(exclude_none=True)
assert dumped["azure_ad_token"] == "oauth-bearer-token-xyz", (
"azure_ad_token dropped from CredentialLiteLLMParams.model_dump() — "
"every callsite that round-trips litellm_params through this class "
"will lose the token (#30235)"
)
def test_azure_ad_token_is_optional(self):
"""Adding the field must not break deployments that don't use it
confirm the default is None and it's excluded by
``exclude_none``."""
from litellm.types.router import CredentialLiteLLMParams
params = CredentialLiteLLMParams(api_key="sk-static")
dumped = params.model_dump(exclude_none=True)
assert "azure_ad_token" not in dumped
assert dumped["api_key"] == "sk-static"
def test_round_trip_preserves_full_credential_shape(self):
"""The Router's get_deployment_credentials_with_provider pattern:
construct from a dict that has azure_ad_token alongside other
fields, dump, expect azure_ad_token to ride through alongside
the other declared fields."""
from litellm.types.router import CredentialLiteLLMParams
source = {
"api_base": "https://my.openai.azure.com",
"api_version": "2024-08-01-preview",
"azure_ad_token": "tok-123",
"api_key": None, # M2M deployment has no static key
}
rebuilt = CredentialLiteLLMParams(
**{k: v for k, v in source.items() if v is not None}
).model_dump(exclude_none=True)
assert rebuilt.get("azure_ad_token") == "tok-123"
assert rebuilt.get("api_base") == "https://my.openai.azure.com"
assert "api_key" not in rebuilt
class TestRouterCredentialResolution:
"""The actual fix surface: Router.get_deployment_credentials_with_provider
must preserve azure_ad_token on the resolved credentials dict so the
files endpoint can forward it to the Azure files client."""
def test_credentials_preserve_azure_ad_token(self):
from litellm import Router
deployment_id = "azure-m2m-deployment-fixed-uuid"
router = Router(
model_list=[
{
"model_name": "gpt-4o-azure-m2m",
"litellm_params": {
"model": "azure/gpt-4o",
"api_base": "https://my.openai.azure.com",
"api_version": "2024-08-01-preview",
"azure_ad_token": "tok-azure-m2m-xyz",
},
"model_info": {"id": deployment_id},
}
]
)
credentials = router.get_deployment_credentials_with_provider(
model_id=deployment_id
)
assert credentials is not None
assert credentials.get("azure_ad_token") == "tok-azure-m2m-xyz", (
"Router credential resolution dropped azure_ad_token; the "
"files / batches / passthrough callers will not be able to "
"authenticate against Azure (#30235)"
)
def test_credentials_static_api_key_unaffected(self):
"""Don't break the pre-fix happy path: a deployment with a
static api_key (no azure_ad_token) keeps its api_key and
azure_ad_token doesn't appear in the dump."""
from litellm import Router
deployment_id = "azure-static-key-deployment-fixed-uuid"
router = Router(
model_list=[
{
"model_name": "gpt-4o-azure-static",
"litellm_params": {
"model": "azure/gpt-4o",
"api_base": "https://my.openai.azure.com",
"api_version": "2024-08-01-preview",
"api_key": "sk-static-key",
},
"model_info": {"id": deployment_id},
}
]
)
credentials = router.get_deployment_credentials_with_provider(
model_id=deployment_id
)
assert credentials is not None
assert credentials.get("api_key") == "sk-static-key"
assert "azure_ad_token" not in credentials

View file

@ -180,6 +180,43 @@ def test_openrouter_qwen36_plus_model_info():
assert model_info["supports_vision"] is True
@pytest.mark.parametrize(
"model",
[
"github_copilot/mai-code-1-flash",
"github_copilot/mai-code-1-flash-internal",
],
)
def test_github_copilot_mai_code_1_flash_pricing(model):
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model_info = litellm.model_cost.get(model)
assert model_info is not None, f"Missing model pricing entry: {model}"
assert model_info["litellm_provider"] == "github_copilot"
assert model_info["mode"] == "chat"
assert model_info["input_cost_per_token"] == 7.5e-07
assert model_info["cache_read_input_token_cost"] == 7.5e-08
assert model_info["output_cost_per_token"] == 4.5e-06
assert model_info["supported_endpoints"] == ["/v1/chat/completions"]
prompt_usd, completion_usd = cost_per_token(
model=model,
prompt_tokens=1000,
completion_tokens=500,
custom_llm_provider="github_copilot",
usage_object=Usage(
prompt_tokens=1000,
completion_tokens=500,
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=200),
),
)
assert prompt_usd == pytest.approx((800 * 7.5e-07) + (200 * 7.5e-08))
assert completion_usd == pytest.approx(500 * 4.5e-06)
def test_cost_calculator_with_usage(monkeypatch):
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
@ -385,7 +422,7 @@ def test_handle_realtime_stream_cost_calculation():
)
assert cost == 0.0 # No usage, no cost
def test_realtime_stream_combines_text_and_audio_token_details():
"""Realtime response.done usage with input_token_details / output_token_details."""
from litellm.cost_calculator import RealtimeAPITokenUsageProcessor

View file

@ -0,0 +1,187 @@
"""
Regression for #30198.
``register_model`` calls ``get_model_info(key)`` to fetch the existing
entry, then ``_update_dictionary`` merges its own ``value`` over it and
the result is written back into ``litellm.model_cost``.
``_get_model_info_helper`` synthesizes ``input_cost_per_token`` and
``output_cost_per_token`` as 0 when the cost keys are missing from the
raw entry (the "price unknown" and "free" cases share the same
representation). So on the SECOND ``register_model`` call against an
already-present sparse entry (e.g. router model id with only
``{"id": ..., "db_model": True}``), the synthesized zeros get written
back, and the entry flips from "no cost keys" "cost keys = 0".
That defeats ``_is_cost_explicitly_configured`` (added in #24949), which
checks whether the cost keys are present in the raw entry after the
write-back they are. ``_is_model_cost_zero`` then returns ``True`` and
``common_checks`` skips every tag / key / team / user / org budget check
for the group. Spend keeps recording (cost calc resolves by model name),
so the symptom is silent: requests that should 429 keep returning 200.
Tests below replicate the Router-built-twice scenario from the report
and confirm the sparse entry stays sparse.
"""
import importlib
import os
import sys
from typing import Any, Dict
import pytest
@pytest.fixture(autouse=True)
def _restore_model_cost():
import litellm
original = dict(litellm.model_cost)
try:
yield
finally:
litellm.model_cost.clear()
litellm.model_cost.update(original)
def _sparse_router_value(model_cost_key: str) -> Dict[str, Any]:
# Mirrors what Router builds for a db_model deployment with no custom
# pricing (litellm/router.py:_create_deployment).
return {
"model_name": "gpt-4o-mini",
"litellm_params": {
"model": "gpt-4o-mini",
"custom_llm_provider": "openai",
"api_key": "sk-test",
},
"model_info": {"id": model_cost_key, "db_model": True},
}
def test_first_registration_leaves_sparse_entry_without_cost_keys():
"""First ``register_model`` call against an unknown key must NOT add
cost keys to the entry otherwise the very first registration would
already poison the map."""
import litellm
key = "fixed-uuid-30198-first"
litellm.model_cost.pop(key, None)
litellm.register_model({key: {"litellm_provider": "openai"}})
entry = litellm.model_cost.get(key, {})
assert "input_cost_per_token" not in entry, entry
assert "output_cost_per_token" not in entry, entry
def test_second_registration_does_not_persist_synthesized_zero_costs():
"""The #30198 bug: re-registering the same sparse entry made
``get_model_info`` synthesize cost = 0 and write it back. Verify the
entry stays clean after a second pass."""
import litellm
key = "fixed-uuid-30198-double-register"
litellm.model_cost.pop(key, None)
payload = {key: {"litellm_provider": "openai"}}
litellm.register_model(payload)
litellm.register_model(payload)
entry = litellm.model_cost.get(key, {})
assert "input_cost_per_token" not in entry, (
"second register_model() persisted a synthesized zero "
"input_cost_per_token; this disables budget enforcement"
)
assert "output_cost_per_token" not in entry, (
"second register_model() persisted a synthesized zero "
"output_cost_per_token; this disables budget enforcement"
)
def test_explicit_zero_cost_in_value_is_preserved():
"""If the caller actually wants the model marked free, the explicit
zero must survive the dedup. The fix must only strip SYNTHESIZED
zeros, not caller-provided ones."""
import litellm
key = "fixed-uuid-30198-explicit-zero"
litellm.model_cost.pop(key, None)
litellm.register_model(
{
key: {
"litellm_provider": "openai",
"input_cost_per_token": 0,
"output_cost_per_token": 0,
}
}
)
entry = litellm.model_cost[key]
assert entry["input_cost_per_token"] == 0
assert entry["output_cost_per_token"] == 0
# Re-registering with the same explicit zeros must keep them.
litellm.register_model(
{
key: {
"litellm_provider": "openai",
"input_cost_per_token": 0,
"output_cost_per_token": 0,
}
}
)
entry = litellm.model_cost[key]
assert entry["input_cost_per_token"] == 0
assert entry["output_cost_per_token"] == 0
def test_real_pricing_for_known_model_survives_re_registration():
"""A model with built-in pricing (e.g. gpt-4o-mini) must keep its
real per-token rates across repeated registrations of an empty
payload that names the same key."""
import litellm
base_in = litellm.model_cost["gpt-4o-mini"]["input_cost_per_token"]
base_out = litellm.model_cost["gpt-4o-mini"]["output_cost_per_token"]
assert base_in > 0 and base_out > 0
litellm.register_model({"gpt-4o-mini": {"litellm_provider": "openai"}})
litellm.register_model({"gpt-4o-mini": {"litellm_provider": "openai"}})
assert litellm.model_cost["gpt-4o-mini"]["input_cost_per_token"] == base_in
assert litellm.model_cost["gpt-4o-mini"]["output_cost_per_token"] == base_out
def test_router_double_init_keeps_db_model_entry_sparse():
"""End-to-end repro from the issue body: building Router twice on
the same model_list must not flip the per-deployment entry to
cost=0. This is the exact production symptom (#30198)."""
import litellm
from litellm import Router
deployment_id = "fixed-uuid-30198-router-init"
litellm.model_cost.pop(deployment_id, None)
model_list = [_sparse_router_value(deployment_id)]
Router(model_list=model_list)
after_first = dict(litellm.model_cost.get(deployment_id, {}))
Router(model_list=model_list)
after_second = dict(litellm.model_cost.get(deployment_id, {}))
# Cost keys must not appear AT ALL on a sparse db_model deployment
# (matches the pre-bug shape) — the bug rewrites them as 0.
for snapshot, label in (
(after_first, "first Router()"),
(after_second, "second Router()"),
):
assert "input_cost_per_token" not in snapshot, (
f"{label} persisted input_cost_per_token={snapshot.get('input_cost_per_token')!r} "
f"on a sparse db_model entry; this disables budget enforcement"
)
assert "output_cost_per_token" not in snapshot, (
f"{label} persisted output_cost_per_token={snapshot.get('output_cost_per_token')!r} "
f"on a sparse db_model entry; this disables budget enforcement"
)

View file

@ -0,0 +1,261 @@
"""
Regression for #30210.
When streaming /v1/responses goes through the proxy + Router, the
streaming iterator is wrapped by ``Router._aresponses_streaming_iterator``
which returns ``FallbackResponsesStreamWrapper``. That wrapper set
``self.completed_response = None`` in __init__ and never updated it,
so the proxy's container-ownership hook (which reads
``getattr(stream_response, "completed_response", None)`` via
``ProxyBaseLLMRequestProcessing._extract_completed_responses_response``)
saw None on every streaming call and silently recorded nothing
follow-up ``GET /v1/containers/<id>/files`` then 403'd for the very
key that created the container.
Tests below construct the wrapper from a fake async generator that
yields one terminal ``response.completed`` chunk and assert the
wrapper now carries that chunk on ``completed_response`` so the
proxy hook can walk it.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _make_wrapper_class():
"""Pull ``FallbackResponsesStreamWrapper`` out by running
``Router._aresponses_streaming_iterator`` long enough to construct
the class then return it. Mirrors how the wrapper is actually
instantiated in production."""
from litellm.router import Router
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
)
# Minimal source iterator stub with every attribute the wrapper
# copies in __init__ (see router.py:2552-2583).
source = SimpleNamespace(
response=None,
model="openai/gpt-5.5",
logging_obj=None,
responses_api_provider_config=None,
start_time=None,
litellm_metadata=None,
custom_llm_provider="openai",
request_data={},
call_type="aresponses",
_hidden_params={},
)
# The class is defined inside _aresponses_streaming_iterator; capture
# it by patching FallbackResponsesStreamWrapper into a sentinel on
# construction.
captured = {}
real_router_module = __import__("litellm.router", fromlist=["Router"])
async def _drive():
async def empty_gen():
if False:
yield # pragma: no cover
return
router = Router(
model_list=[
{
"model_name": "openai/gpt-5.5",
"litellm_params": {"model": "openai/gpt-5.5", "api_key": "sk-test"},
}
]
)
wrapped = await router._aresponses_streaming_iterator(
response=source, # type: ignore[arg-type]
initial_kwargs={},
)
captured["wrapper_cls"] = type(wrapped)
captured["instance"] = wrapped
asyncio.run(_drive())
return captured["wrapper_cls"], captured["instance"]
def _terminal_chunk(event_type: str):
"""A SimpleNamespace shaped like the openai responses-api terminal
event chunks the wrapper inspects (.type attribute)."""
return SimpleNamespace(
type=event_type,
response=SimpleNamespace(
id="resp_test",
output=[],
container={"id": "cntr_test", "type": "code_interpreter"},
),
)
def _non_terminal_chunk(event_type: str = "response.output_text.delta"):
return SimpleNamespace(type=event_type, delta="hello")
class TestStreamWrapperCapturesTerminalEvent:
def test_terminal_completed_event_is_recorded_on_wrapper(self):
"""The #30210 bug: a forwarded ``response.completed`` chunk used
to leave ``completed_response`` at None on the wrapper. Verify
it now carries the chunk."""
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk()
yield _terminal_chunk("response.completed")
wrapper = wrapper_cls(gen())
# Drain the wrapper.
out = asyncio.run(_drain(wrapper))
assert len(out) == 2
assert wrapper.completed_response is not None, (
"FallbackResponsesStreamWrapper.completed_response is still None "
"after a response.completed chunk passed through — the proxy "
"container-ownership hook will 403 follow-up file lookups (#30210)"
)
assert wrapper.completed_response.type == "response.completed"
def test_terminal_incomplete_event_is_recorded(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.incomplete")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response is not None
assert wrapper.completed_response.type == "response.incomplete"
def test_terminal_failed_event_is_recorded(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.failed")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response is not None
assert wrapper.completed_response.type == "response.failed"
def test_non_terminal_chunks_do_not_set_completed_response(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk("response.output_text.delta")
yield _non_terminal_chunk("response.code_interpreter.in_progress")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert (
wrapper.completed_response is None
), "non-terminal chunks must not set completed_response"
def test_first_terminal_event_wins(self):
"""Real streams only emit one terminal event, but defend against
future producers emitting more: keep the first one (the inner
source iterator behaves the same way)."""
wrapper_cls, _ = _make_wrapper_class()
first = _terminal_chunk("response.completed")
first.response.id = "resp_first"
second = _terminal_chunk("response.completed")
second.response.id = "resp_second"
async def gen():
yield first
yield second
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response.response.id == "resp_first"
class TestProxyOwnershipHookReadsCompletedResponse:
"""End-to-end: the proxy hook reads exactly the attribute the
wrapper now populates. Pin that the helper still extracts the
response correctly so the ownership recording path doesn't break."""
def test_extract_returns_inner_response_object(self):
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.completed")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
extracted = ProxyBaseLLMRequestProcessing._extract_completed_responses_response(
wrapper
)
assert extracted is not None
assert extracted.id == "resp_test"
assert extracted.container["id"] == "cntr_test"
class TestSilentSkipNowLogged:
"""Reporter's secondary ask: when completed_response is None, the
ownership hook silently dropped on the floor. Make sure the new
warning fires so operators see a hint instead of a mute 403."""
def test_warning_logged_when_completed_response_missing(self, caplog):
import logging
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
# Wrap a generator that produces NO terminal event so the
# wrapper stays at completed_response=None — same shape as the
# pre-fix bug.
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk()
wrapper = wrapper_cls(gen())
async def driver():
async def inner_gen():
async for c in wrapper:
yield c
# Patch _record_container_owners_from_responses_if_needed to
# a noop async so the warning branch is exercised in
# isolation.
with patch.object(
ProxyBaseLLMRequestProcessing,
"_record_container_owners_from_responses_if_needed",
new=MagicMock(),
):
wrapped = ProxyBaseLLMRequestProcessing._wrap_responses_stream_for_container_ownership(
original_stream_response=wrapper,
wrapped_generator=inner_gen(),
user_api_key_dict=MagicMock(),
)
async for _ in wrapped:
pass
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
asyncio.run(driver())
assert any(
"Container ownership recording skipped on streaming /v1/responses"
in r.message
for r in caplog.records
), "silent-skip warning never fired despite completed_response=None"
async def _drain(it):
out = []
async for chunk in it:
out.append(chunk)
return out

View file

@ -24993,6 +24993,8 @@ export interface components {
aws_region_name?: string | null;
/** Aws Secret Access Key */
aws_secret_access_key?: string | null;
/** Azure Ad Token */
azure_ad_token?: string | null;
/** Budget Duration */
budget_duration?: string | null;
/** Cache Creation Input Audio Token Cost */
@ -32632,6 +32634,8 @@ export interface components {
aws_region_name?: string | null;
/** Aws Secret Access Key */
aws_secret_access_key?: string | null;
/** Azure Ad Token */
azure_ad_token?: string | null;
/** Budget Duration */
budget_duration?: string | null;
/** Cache Creation Input Audio Token Cost */