litellm/tests/litellm_utils_tests/test_utils.py
yucheng-berri e319bf270c
feat(langfuse): migrate the sdk callback to langfuse v4 (#36741)
* feat(langfuse): migrate the sdk callback to langfuse v4

Replace the v2 trace()/generation()/span() calls with SDK v4 observations exported over OpenTelemetry, with one isolated tracer provider per Langfuse credential set, a discarding exporter for mock mode, and v4 trace and observation id normalization. Keeps the session-header trace provenance logic from main so each call under a session alias still gets its own trace

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

* fix(langfuse): drop the always-true prompt client check now that v4 get_prompt is non-optional

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

* test(langfuse): isolate the e2e sync test from cached clients and log the real sdk major

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

* refactor(langfuse): type the slack trace-url lookup and drop dead v2 test shims

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

* test(slack): cover the langfuse trace url built from the logger host

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

* build(docker): pin langfuse to the locked 4.15.2 in the pip image

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

* fix(langfuse): hash all-zero trace and observation ids instead of passing them through

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

* feat(langfuse): honour caller generation ids and assert v4 OTLP exports in legacy tests

v2 accepted generation(id=...). v4 derives the observation id from the OTel
span id, so the isolated tracer provider now carries an id generator that
hands out the id start_generation asked for through a context variable, and
the callback passes the resolved generation_id metadata into it.

The legacy e2e suite patched httpx.Client.post and compared v2 ingestion
batches; it now patches requests.Session.post, decodes the OTLP protobuf
and compares the exported generation against regenerated fixtures. The
local readback test replaces the removed get_generations() with
api.observations.get_many() and polls Langfuse Cloud instead of sleeping.

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

* refactor(langfuse): read the sdk version header from package metadata

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

* fix(langfuse): propagate trace_metadata as trace-level attributes in v4

v2 wrote trace(metadata=...) onto the trace object. In v4 the trace only
carries what the observations propagate, so a continuation request with
update_trace_keys=["trace_metadata"] updated the generation's metadata
while the trace kept its stale values. Coerce each entry to the SDK's
string limit and hand it to propagate_attributes(metadata=...).

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

* fix(langfuse): propagate interrupts raised during deferred client teardown

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

* fix(langfuse): honor ssl_verify=False and SSL_VERIFY on the v4 OTLP exporter

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

* fix(langfuse): fall back to the default CA when the configured bundle path is missing

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

* fix(langfuse): renew the client when eviction lands before the callback lease

The cache can evict a logger between handing it to the callback and the callback taking its
lease. Such a lease now hands back a fresh client acquired through the same parameters, so that
callback exports through a live tracer provider instead of one teardown already shut down.

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

* fix(langfuse): emit litellm_call_id and response_id as generation metadata

v2 put the provider response id inside the generation id. v4 observation ids are 16 hex chars derived from that string, so the ids move to generation metadata to keep generations searchable by response id

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

* refactor(langfuse): read the response id through a typed protocol

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

* fix(langfuse): do not claim trace root when continuing an existing trace

Langfuse derives a trace's name and I/O from any observation flagged
langfuse.internal.as_root, so a request carrying existing_trace_id
renamed the trace to the generation name and replaced the trace input
and output on every continuation. v2 only updated the keys listed in
update_trace_keys. Continuations now export as plain children of the
remote parent and keep the explicit langfuse.trace.* attributes for the
fields they do want changed.

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

* fix(langfuse): iterate lease renewal instead of recursing, monkeypatch update_trace_keys flag in tests

The recursive lease fallback tripped tests/code_coverage_tests/recursive_detector.py; the renewal
candidates are now walked with itertools.chain. The six update_trace_keys tests set the litellm
global through pytest monkeypatch so the TQ008 budget stays within its ceiling

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

* fix(langfuse): retry raised OTLP exports and honor LANGFUSE_TIMEOUT

The OTLP http exporter only retries 429 and 5xx; a connect or read timeout
propagates and BatchSpanProcessor drops the batch. Wrap the exporter in
RetryingSpanExporter (three backoff retries, as the v2 consumer did) and
build it on every path so the default and private-CA deployments share the
same channel, timeout and retry behaviour

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

* fix(langfuse): sample on a hash of the full trace id and tolerate bad LANGFUSE_SAMPLE_RATE

TraceIdRatioBased reads the low 64 bits of the trace id. litellm trace ids are
UUIDs, whose variant bits sit at the top of that word, so every fractional rate
up to 0.5 dropped all traces. A SHA-256 of the full id gives an unbiased,
deterministic decision. Values outside [0, 1] or non numeric now warn and export
everything instead of raising during callback construction, which surfaced as a
500 on the first request of each worker

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

* fix(langfuse): put the Langfuse trace link back into Slack alerts

The proxy registers LangfusePromptManagement for callbacks: ["langfuse"], so the alert helper never saw the literal "langfuse" string and returned before looking up the trace id, and the prompt management logger never stored the trace id it got back from log_event_on_langfuse. Recognize LangFuseLogger instances in the callback list, record the returned trace id in the shared service trace id cache, and skip the link when no trace id arrives

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

* chore(deps): relock langfuse 4.15.2 and opentelemetry 1.33.1 on current main

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

* chore(langfuse): mark the deliberate blind except in client teardown for the strict ruff gate

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

* refactor(langfuse): pass the resource attributes mapping straight to Resource.create

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

* fix(langfuse): warn about ignored UPSTREAM_LANGFUSE_* on the shared client init path too

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

* fix(langfuse): normalise the OTLP export path so a trailing host slash never yields a double slash

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

* fix(langfuse): nest guardrail and grounding spans under the generation

Langfuse v4 derives the trace name and I/O from every observation marked as_root, and the one with the latest start time wins. Guardrail and grounding spans used to claim root next to the generation, so a post_call guardrail could replace the model's request and response on the trace with its own. Only the generation claims root now; the sibling spans become its children

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

* fix(langfuse): rebuild the cached bundle when mock mode or sample rate changes

The SDK keys resource bundles on the public key alone, so a bundle built with the discarding exporter for LANGFUSE_MOCK, or with an earlier LANGFUSE_SAMPLE_RATE, was handed back to a client that asked for a live exporter or a different rate. Compare both when deciding whether the cached bundle is still valid

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

* fix(langfuse): keep trace_public true when a guardrail span is exported

Langfuse folds langfuse.trace.public across every observation in the trace and reads a missing attribute as false, so a guardrail child span without the flag turned a trace_public: true request private on Langfuse Cloud. Child spans now repeat the generation's value

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

* refactor(langfuse): emit observations as plain OTel spans, keep the SDK for prompts and auth

The callback now owns an isolated TracerProvider and OTLP exporter and builds generation and child spans with public OpenTelemetry APIs plus the LangfuseOtelSpanAttributes constants. Caller trace ids, generation ids, parent observation ids and historical start and end times are honoured through the OTel id generator, remote SpanContext and explicit span timestamps, so no private Langfuse SDK tracing handle is used any more. The Langfuse client stays only for get_prompt and auth_check

This also resolves the gauntlet findings on the previous draft: fresh traces start from an empty context so caller application spans are never stamped, the Slack trace link is read from the request logging state instead of constructing a logger per alert, a truthy non-mapping trace_metadata is serialized instead of raising, trace_input and trace_output land on the root generation, discarding a cached client is done under the lock, and the prompt cache no longer leaks a task manager because the client cache no longer tears down shared providers

Fixtures under tests/logging_callback_tests lose the SDK-private langfuse.internal.as_root marker; every other exported attribute is unchanged

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

* fix(langfuse): hand the SDK client a validated sample rate so an unusable LANGFUSE_SAMPLE_RATE no longer breaks the callback

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

* fix(langfuse): gate the SDK version before importing the OTel module in prompt management

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

* fix(langfuse): flush every export channel on proxy shutdown and use the callback's host in Slack trace links

The shutdown hook imported litellm.utils.langFuseLogger, a global the callback registry never assigns, so a graceful restart dropped the spans still queued in the batch processors. Shutdown now calls flush_langfuse_tracing, which force-flushes every acquired channel. The Slack alert link falls back to the registered LangFuseLogger's langfuse_host when the request carries no dynamic host, and the export endpoint tests pin that scheme-relative or absolute LANGFUSE_OTEL_TRACES_EXPORT_PATH values stay on the configured host

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

* fix(langfuse): store resolved credentials on LangfusePromptManagement

The Slack alert trace link reads langfuse_host from every registered LangFuseLogger. Prompt management subclasses it without calling the parent constructor, so it never set the attribute and the alerting handler crashed before posting

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

* fix(langfuse): flush every export channel concurrently under one shutdown deadline

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

* fix(langfuse): flush export channels on daemon threads so a stuck channel cannot hold up interpreter exit

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

* fix(langfuse): own the tracer config and drop the SDK client for prompts and auth

The callback's TracerProvider now sets its sampler, span limits and id generator explicitly so unrelated OTEL_* variables no longer change what Langfuse receives, and trace metadata is written once on the trace instead of folded into the generation, which kept input and output under the attribute cap. Spans are emitted under the langfuse-sdk scope so Langfuse renders them natively, the batch processor queues 100k spans and honors LANGFUSE_FLUSH_AT, and the proxy shutdown flush runs off the event loop with a 10s deadline and logs a miss.

Prompts, auth_check and the project id now go through LangfuseAPI directly with a litellm-owned TTL cache, so no Langfuse() client is built and a host application's client on the same public key is left alone. Dead attributes, the unreachable exporter branch and the export list are cleaned up, and the client-budget eviction behavior is documented.

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

* refactor(langfuse): export OTLP spans and fetch prompts through litellm's HTTPHandler instead of a private requests session

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

* fix(langfuse): gate the SDK version before importing the tracing module and retire unheld export channels

An installed v2 SDK used to fail inside the langfuse_sdk import and surface as "Langfuse not installed"; the version check now runs first so v2 users get the upgrade message, and only PackageNotFoundError means the package is missing

Export channels are now leased per credential set: acquire adds a holder, LangFuseLogger.stop (called by DynamicLoggingCache on expiry) releases one, and a channel with no holders is flushed and shut down after a 60 s grace, so rotating key or team credentials no longer grows one batch thread per credential set for the life of the process

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

* fix(langfuse): end the generation when a child span fails, take the client slot last, keep prompt cache keys structured

Generation spans now end in a finally block so a bad guardrail or provider entry cannot strand the trace. The logger acquires its export channel and REST client before counting a client slot and releases the channel synchronously if the REST client fails to build, so retries after a bad config do not exhaust the budget. LANGFUSE_TIMEOUT accepts decimals for the REST client like it already did for OTLP export. The prompt cache keys on (name, version, label) so a missing label and the literal label None stay apart

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

* fix(langfuse): claim the cache entry before releasing its slot and channel hold on eviction

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

* fix(langfuse): coerce generation names, keep v2 release, timeout and retry defaults, refresh stale prompts off the loop

A non-string metadata generation_name reached the OTLP encoder and took the whole batch down; it is now exported as its text and the exporter drops only the span the encoder rejects. LANGFUSE_RELEASE falls back to the deploy platform's commit variable again, the export deadline is back to the v2 default of 20 s and LANGFUSE_MAX_RETRIES sizes the retry ladder. An expired prompt is served at once while one background thread refreshes it, a re-acquired export channel cancels the pending retire timer, and flush reports delivery rather than a drained queue

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

* test(proxy): assert the current Langfuse shutdown flush warning

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

* fix(langfuse): keep host OTel resource out, carry big metadata ints, tolerate bad flush and TTL env, stamp trace I/O under a parent

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

* fix(langfuse): name a malformed prompt cache TTL before the SDK import, keep metadata ints JSON safe, retry every 5xx export

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

* fix(langfuse): name the auth check failure, split a 413 export, wire LANGFUSE_DEBUG, stamp error output under a parent, send the ingestion version header

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

* fix(langfuse): honor LANGFUSE_DEBUG on the callbacks path, cap retry backoff, name the auth failure status and body

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

* fix(langfuse): cap LANGFUSE_MAX_RETRIES at 1000 so an absurd value cannot stall callback init

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

* refactor(langfuse): fold 413 halving into bounded rounds instead of recursion

The code-quality recursive-function gate flagged LangfuseSpanExporter.export. A batch of n spans settles within n.bit_length() halving rounds, so the split is a reduce over a frozen round state with the same posts, logs and results. The TTL gate test now asserts the gate returns without raising

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

* fix(langfuse): truncate a single oversized span like v2 instead of dropping it, no retries on REST auth and project lookups

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

* fix(langfuse): write the metadata truncation marker under a flattened key so Langfuse keeps it

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

* test(langfuse): patch the HTTPHandler export path and sync the metadata fixture and lease registry with the v4 callback

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

* refactor(langfuse): give the 413 split helpers a single explicit return path

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

* fix(langfuse): url-encode prompt names and fetch cold prompts without client retries

A cold get_prompt runs inline on the event loop; the generated v4 client's default two retries slept through
Retry-After (up to 60 s per attempt) and held the loop. The wrapper also passed the raw name into
api/public/v2/prompts/{name}, so 'what?' fetched prompt 'what' and folder names left the route. Quote the
name with safe='' like the v4 SDK's own get_prompt and pass max_retries=0 like the projects.get calls

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

* fix(langfuse): retry a cold prompt miss once and drop upstream headers from prompt errors

A cold prompt fetch makes one immediate second attempt after a 5xx or a
transport failure, as the v2 client did, still with the generated client's
sleeping retries and Retry-After handling off so the event loop never stalls.
A failed fetch raises LangfusePromptError carrying only the status and body,
so the proxy no longer forwards Langfuse's response headers to its client

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

* test(langfuse): stub the logger in the health auth_check test instead of dialing a closed port

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

* test(langfuse): integration test for OTLP v4 delivery and prompt fetch through a real proxy

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

* build(docker): keep the pip image's langfuse and otel pins on the v2 line its litellm 1.83.0 wheel expects

The image validates the published PyPI artifact, whose langfuse callback still
reads langfuse.version, so the 4.15.2 pin broke that callback. The pins move
together with the next LITELLM_VERSION bump. Also rewords the trace_version
precedence test docstring: v2 carried two version fields, v4 has one per span

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 23:22:56 -07:00

2317 lines
77 KiB
Python

import copy
import logging
import time
from datetime import datetime
from unittest import mock
from dotenv import load_dotenv
from litellm.types.utils import StandardCallbackDynamicParams
load_dotenv()
import os
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, headers
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.duration_parser import (
get_last_day_of_month,
_extract_from_regex,
)
from litellm.utils import (
check_valid_key,
get_llm_provider,
get_supported_openai_params,
get_token_count,
get_valid_models,
trim_messages,
validate_environment,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from unittest.mock import AsyncMock, MagicMock, patch
# Assuming your trim_messages, shorten_message_to_fit_limit, and get_token_count functions are all in a module named 'message_utils'
@pytest.fixture(autouse=True)
def reset_mock_cache():
from litellm.utils import _model_cache
_model_cache.flush_cache()
# Test 1: Check trimming of normal message
def test_basic_trimming():
litellm._turn_on_debug()
messages = [
{
"role": "user",
"content": "This is a long message that definitely exceeds the token limit.",
}
]
trimmed_messages = trim_messages(messages, model="claude-2", max_tokens=8)
print("trimmed messages")
print(trimmed_messages)
# print(get_token_count(messages=trimmed_messages, model="claude-2"))
assert (get_token_count(messages=trimmed_messages, model="claude-2")) <= 8
# test_basic_trimming()
def test_basic_trimming_no_max_tokens_specified():
messages = [
{
"role": "user",
"content": "This is a long message that is definitely under the token limit.",
}
]
trimmed_messages = trim_messages(messages, model="gpt-4")
print("trimmed messages for gpt-4")
print(trimmed_messages)
# print(get_token_count(messages=trimmed_messages, model="claude-2"))
assert (
get_token_count(messages=trimmed_messages, model="gpt-4")
) <= litellm.model_cost["gpt-4"]["max_tokens"]
# test_basic_trimming_no_max_tokens_specified()
def test_multiple_messages_trimming():
messages = [
{
"role": "user",
"content": "This is a long message that will exceed the token limit.",
},
{
"role": "user",
"content": "This is another long message that will also exceed the limit.",
},
]
trimmed_messages = trim_messages(
messages=messages, model="gpt-3.5-turbo", max_tokens=20
)
# print(get_token_count(messages=trimmed_messages, model="gpt-3.5-turbo"))
assert (get_token_count(messages=trimmed_messages, model="gpt-3.5-turbo")) <= 20
# test_multiple_messages_trimming()
def test_multiple_messages_no_trimming():
messages = [
{
"role": "user",
"content": "This is a long message that will exceed the token limit.",
},
{
"role": "user",
"content": "This is another long message that will also exceed the limit.",
},
]
trimmed_messages = trim_messages(
messages=messages, model="gpt-3.5-turbo", max_tokens=100
)
print("Trimmed messages")
print(trimmed_messages)
assert messages == trimmed_messages
# test_multiple_messages_no_trimming()
def test_large_trimming_multiple_messages():
messages = [
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."},
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."},
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."},
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."},
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."},
]
trimmed_messages = trim_messages(messages, max_tokens=20, model="gpt-4-0613")
print("trimmed messages")
print(trimmed_messages)
assert (get_token_count(messages=trimmed_messages, model="gpt-4-0613")) <= 20
# test_large_trimming()
def test_large_trimming_single_message():
messages = [
{"role": "user", "content": "This is a singlelongwordthatexceedsthelimit."}
]
trimmed_messages = trim_messages(messages, max_tokens=5, model="gpt-4-0613")
assert (get_token_count(messages=trimmed_messages, model="gpt-4-0613")) <= 5
assert (get_token_count(messages=trimmed_messages, model="gpt-4-0613")) > 0
def test_trimming_with_system_message_within_max_tokens():
# This message is 33 tokens long
messages = [
{"role": "system", "content": "This is a short system message"},
{
"role": "user",
"content": "This is a medium normal message, let's say litellm is awesome.",
},
]
trimmed_messages = trim_messages(
messages, max_tokens=30, model="gpt-4-0613"
) # The system message should fit within the token limit
assert len(trimmed_messages) == 2
assert trimmed_messages[0]["content"] == "This is a short system message"
def test_trimming_with_system_message_exceeding_max_tokens():
# This message is 33 tokens long. The system message is 13 tokens long.
messages = [
{"role": "system", "content": "This is a short system message"},
{
"role": "user",
"content": "This is a medium normal message, let's say litellm is awesome.",
},
]
trimmed_messages = trim_messages(messages, max_tokens=12, model="gpt-4-0613")
assert len(trimmed_messages) == 1
def test_trimming_with_tool_calls():
from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message
messages = [
{
"role": "user",
"content": "What's the weather like in San Francisco, Tokyo, and Paris?",
},
Message(
content=None,
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
function=Function(
arguments='{"location": "San Francisco, CA", "unit": "celsius"}',
name="get_current_weather",
),
id="call_G11shFcS024xEKjiAOSt6Tc9",
type="function",
),
ChatCompletionMessageToolCall(
function=Function(
arguments='{"location": "Tokyo, Japan", "unit": "celsius"}',
name="get_current_weather",
),
id="call_e0ss43Bg7H8Z9KGdMGWyZ9Mj",
type="function",
),
ChatCompletionMessageToolCall(
function=Function(
arguments='{"location": "Paris, France", "unit": "celsius"}',
name="get_current_weather",
),
id="call_nRjLXkWTJU2a4l9PZAf5as6g",
type="function",
),
],
function_call=None,
),
{
"tool_call_id": "call_G11shFcS024xEKjiAOSt6Tc9",
"role": "tool",
"name": "get_current_weather",
"content": '{"location": "San Francisco", "temperature": "72", "unit": "fahrenheit"}',
},
{
"tool_call_id": "call_e0ss43Bg7H8Z9KGdMGWyZ9Mj",
"role": "tool",
"name": "get_current_weather",
"content": '{"location": "Tokyo", "temperature": "10", "unit": "celsius"}',
},
{
"tool_call_id": "call_nRjLXkWTJU2a4l9PZAf5as6g",
"role": "tool",
"name": "get_current_weather",
"content": '{"location": "Paris", "temperature": "22", "unit": "celsius"}',
},
]
num_tool_calls = 3
result = trim_messages(messages=messages, max_tokens=1)
print(result)
# only trailing tool calls are returned
assert len(result) == num_tool_calls
assert result == messages[-num_tool_calls:]
result = trim_messages(messages=messages, max_tokens=999)
# message length is below max_tokens, so output should match input
assert messages == result
def test_trimming_should_not_change_original_messages():
messages = [
{"role": "system", "content": "This is a short system message"},
{
"role": "user",
"content": "This is a medium normal message, let's say litellm is awesome.",
},
]
messages_copy = copy.deepcopy(messages)
trimmed_messages = trim_messages(messages, max_tokens=12, model="gpt-4-0613")
assert messages == messages_copy
@pytest.mark.parametrize("model", ["gpt-5.4-mini", "claude-sonnet-4-6"])
def test_trimming_with_model_cost_max_input_tokens(model):
messages = [
{"role": "system", "content": "This is a normal system message"},
{
"role": "user",
"content": "This is a sentence" * 100000,
},
]
trimmed_messages = trim_messages(messages, model=model)
assert (
get_token_count(trimmed_messages, model=model)
< litellm.model_cost[model]["max_input_tokens"]
)
def test_trimming_with_untokenizable_field(caplog: pytest.LogCaptureFixture) -> None:
from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message
messages = [
{
"role": "system",
"content": "You are a helpful assistant.",
},
{
"role": "user",
"content": "What's the weather like in San Francisco?",
# non-string values will cause the tokenizer to raise an exception
"user_id": 123,
},
Message(
content=None,
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
function=Function(
arguments='{"location": "San Francisco, CA", "unit": "celsius"}',
name="get_current_weather",
),
id="call_G11shFcS024xEKjiAOSt6Tc9",
type="function",
),
],
function_call=None,
),
{
"tool_call_id": "call_G11shFcS024xEKjiAOSt6Tc9",
"role": "tool",
"name": "get_current_weather",
"content": '{"location": "San Francisco", "temperature": "72", "unit": "fahrenheit"}',
},
]
# trim_messages() catches the exception raised by the tokenizer and logs an error
with caplog.at_level(level=logging.ERROR, logger="LiteLLM"):
trimmed_messages = trim_messages(messages, max_tokens=999)
assert trimmed_messages == messages
def test_aget_valid_models():
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "temp"}, clear=True):
valid_models = get_valid_models()
print(valid_models)
# list of openai supported llms on litellm
expected_models = (
litellm.open_ai_chat_completion_models | litellm.open_ai_text_completion_models
)
assert set(valid_models) == set(expected_models)
# GEMINI
with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "temp"}, clear=True):
valid_models = get_valid_models()
print(valid_models)
assert set(valid_models) == set(litellm.gemini_models)
@pytest.mark.parametrize("custom_llm_provider", ["anthropic", "xai"])
def test_get_valid_models_with_custom_llm_provider(custom_llm_provider):
from litellm.utils import ProviderConfigManager
from litellm.types.utils import LlmProviders
provider_config = ProviderConfigManager.get_provider_model_info(
model=None,
provider=LlmProviders(custom_llm_provider),
)
assert provider_config is not None
valid_models = get_valid_models(
check_provider_endpoint=True, custom_llm_provider=custom_llm_provider
)
print(valid_models)
assert len(valid_models) > 0
assert set(provider_config.get_models()) == set(valid_models)
# test_get_valid_models()
def test_bad_key():
key = "bad-key"
response = check_valid_key(model="gpt-5-mini", api_key=key)
print(response, key)
assert response == False
def test_good_key():
key = os.environ["OPENAI_API_KEY"]
response = check_valid_key(model="gpt-5-mini", api_key=key)
assert response == True
# test validate environment
def test_validate_environment_empty_model():
api_key = validate_environment()
if api_key is None:
raise Exception()
def test_validate_environment_api_key():
response_obj = validate_environment(model="gpt-5-mini", api_key="sk-my-test-key")
assert (
response_obj["keys_in_environment"] is True
), f"Missing keys={response_obj['missing_keys']}"
def test_validate_environment_api_version():
response_obj = validate_environment(
model="azure/openai-deployment",
api_key="sk-my-test-key",
api_base="https://fake.openai.azure.com/",
api_version="2024-02-15",
)
assert (
response_obj["keys_in_environment"] is True
), f"Missing keys={response_obj['missing_keys']}"
def test_validate_environment_api_base_dynamic():
for provider in ["ollama", "ollama_chat"]:
kv = validate_environment(provider + "/mistral", api_base="https://example.com")
assert kv["keys_in_environment"]
assert kv["missing_keys"] == []
@mock.patch.dict(os.environ, {"OLLAMA_API_BASE": "foo"}, clear=True)
def test_validate_environment_ollama():
for provider in ["ollama", "ollama_chat"]:
kv = validate_environment(provider + "/mistral")
assert kv["keys_in_environment"]
assert kv["missing_keys"] == []
@mock.patch.dict(os.environ, {}, clear=True)
def test_validate_environment_ollama_failed():
for provider in ["ollama", "ollama_chat"]:
kv = validate_environment(provider + "/mistral")
assert not kv["keys_in_environment"]
assert kv["missing_keys"] == ["OLLAMA_API_BASE"]
def test_function_to_dict():
print("testing function to dict for get current weather")
def get_current_weather(location: str, unit: str):
"""Get the current weather in a given location
Parameters
----------
location : str
The city and state, e.g. San Francisco, CA
unit : {'celsius', 'fahrenheit'}
Temperature unit
Returns
-------
str
a sentence indicating the weather
"""
if location == "Boston, MA":
return "The weather is 12F"
function_json = litellm.utils.function_to_dict(get_current_weather)
print(function_json)
expected_output = {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {
"type": "string",
"description": "Temperature unit",
"enum": "['fahrenheit', 'celsius']",
},
},
"required": ["location", "unit"],
},
}
print(expected_output)
assert function_json["name"] == expected_output["name"]
assert function_json["description"] == expected_output["description"]
assert function_json["parameters"]["type"] == expected_output["parameters"]["type"]
assert (
function_json["parameters"]["properties"]["location"]
== expected_output["parameters"]["properties"]["location"]
)
# the enum can change it can be - which is why we don't assert on unit
# {'type': 'string', 'description': 'Temperature unit', 'enum': "['fahrenheit', 'celsius']"}
# {'type': 'string', 'description': 'Temperature unit', 'enum': "['celsius', 'fahrenheit']"}
assert (
function_json["parameters"]["required"]
== expected_output["parameters"]["required"]
)
print("passed")
# test_function_to_dict()
def test_get_supported_openai_params() -> None:
# Mapped provider
assert isinstance(get_supported_openai_params("gpt-4"), list)
# Unmapped provider
assert get_supported_openai_params("nonexistent") is None
def test_get_chat_completion_prompt():
"""
Unit test to ensure get_chat_completion_prompt updates messages in logging object.
"""
from litellm.litellm_core_utils.litellm_logging import Logging
litellm_logging_obj = Logging(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="acompletion",
litellm_call_id="1234",
start_time=datetime.now(),
function_id="1234",
)
updated_message = "hello world"
litellm_logging_obj.get_chat_completion_prompt(
model="gpt-5-mini",
messages=[{"role": "user", "content": updated_message}],
non_default_params={},
prompt_id="1234",
prompt_variables=None,
)
assert litellm_logging_obj.messages == [
{"role": "user", "content": updated_message}
]
def test_redact_msgs_from_logs():
"""
Tests that turn_off_message_logging does not modify the response_obj
On the proxy some users were seeing the redaction impact client side responses
"""
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.redact_messages import (
redact_message_input_output_from_logging,
)
litellm.turn_off_message_logging = True
response_obj = litellm.ModelResponse(
choices=[
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "I'm LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner.",
"role": "assistant",
},
}
]
)
litellm_logging_obj = Logging(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="acompletion",
litellm_call_id="1234",
start_time=datetime.now(),
function_id="1234",
)
_redacted_response_obj = redact_message_input_output_from_logging(
result=response_obj,
model_call_details=litellm_logging_obj.model_call_details,
)
# Assert the response_obj content is NOT modified
assert (
response_obj.choices[0].message.content
== "I'm LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner."
)
litellm.turn_off_message_logging = False
print("Test passed")
def test_redact_embedding_response():
"""
Tests that EmbeddingResponse redaction preserves critical metadata while clearing sensitive data
This test ensures that:
1. usage field is preserved for token/cost tracking
2. model field is preserved for response structure integrity
3. data field (containing embeddings) is cleared for privacy
4. original response object is not modified
"""
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.redact_messages import (
redact_message_input_output_from_logging,
)
litellm.turn_off_message_logging = True
# Create a test EmbeddingResponse with usage data
original_usage = litellm.Usage(
prompt_tokens=10, completion_tokens=0, total_tokens=10
)
original_data = [
{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3, 0.4, 0.5]},
{"object": "embedding", "index": 1, "embedding": [0.6, 0.7, 0.8, 0.9, 1.0]},
]
response_obj = litellm.EmbeddingResponse(
model="text-embedding-3-small",
data=original_data,
usage=original_usage,
object="list",
)
litellm_logging_obj = Logging(
model="text-embedding-3-small",
messages=[{"role": "user", "content": "test input"}],
stream=False,
call_type="embedding",
litellm_call_id="1234",
start_time=datetime.now(),
function_id="1234",
)
_redacted_response_obj = redact_message_input_output_from_logging(
result=response_obj,
model_call_details=litellm_logging_obj.model_call_details,
)
# Assert the original response_obj is NOT modified
assert response_obj.data == original_data
assert response_obj.usage == original_usage
assert response_obj.model == "text-embedding-3-small"
assert response_obj.object == "list"
# Assert the redacted response preserves critical metadata
assert _redacted_response_obj.usage == original_usage # usage should be preserved
assert (
_redacted_response_obj.model == "text-embedding-3-small"
) # model should be preserved
assert _redacted_response_obj.object == "list" # object should be preserved
# Assert sensitive data is cleared
assert _redacted_response_obj.data == [] # data should be cleared
# Assert it's still an EmbeddingResponse instance
assert isinstance(_redacted_response_obj, litellm.EmbeddingResponse)
litellm.turn_off_message_logging = False
print("Test passed")
def test_redact_msgs_from_logs_with_dynamic_params():
"""
Tests redaction behavior based on standard_callback_dynamic_params setting:
In all tests litellm.turn_off_message_logging is True
1. When standard_callback_dynamic_params.turn_off_message_logging is False (or not set): No redaction should occur. User has opted out of redaction.
2. When standard_callback_dynamic_params.turn_off_message_logging is True: Redaction should occur. User has opted in to redaction.
3. standard_callback_dynamic_params.turn_off_message_logging not set, litellm.turn_off_message_logging is True: Redaction should occur.
"""
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.redact_messages import (
redact_message_input_output_from_logging,
)
litellm.turn_off_message_logging = True
test_content = "I'm LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner."
response_obj = litellm.ModelResponse(
choices=[
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": test_content,
"role": "assistant",
},
}
]
)
litellm_logging_obj = Logging(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="acompletion",
litellm_call_id="1234",
start_time=datetime.now(),
function_id="1234",
)
# Test Case 1: standard_callback_dynamic_params = False (or not set)
standard_callback_dynamic_params = StandardCallbackDynamicParams(
turn_off_message_logging=False
)
litellm_logging_obj.model_call_details["standard_callback_dynamic_params"] = (
standard_callback_dynamic_params
)
_redacted_response_obj = redact_message_input_output_from_logging(
result=response_obj,
model_call_details=litellm_logging_obj.model_call_details,
)
# Assert no redaction occurred
assert _redacted_response_obj.choices[0].message.content == test_content
# Test Case 2: standard_callback_dynamic_params = True
standard_callback_dynamic_params = StandardCallbackDynamicParams(
turn_off_message_logging=True
)
litellm_logging_obj.model_call_details["standard_callback_dynamic_params"] = (
standard_callback_dynamic_params
)
_redacted_response_obj = redact_message_input_output_from_logging(
result=response_obj,
model_call_details=litellm_logging_obj.model_call_details,
)
# Assert redaction occurred
assert _redacted_response_obj.choices[0].message.content == "redacted-by-litellm"
# Test Case 3: standard_callback_dynamic_params does not set turn_off_message_logging
# since litellm.turn_off_message_logging is True redaction should occur
standard_callback_dynamic_params = StandardCallbackDynamicParams()
litellm_logging_obj.model_call_details["standard_callback_dynamic_params"] = (
standard_callback_dynamic_params
)
_redacted_response_obj = redact_message_input_output_from_logging(
result=response_obj,
model_call_details=litellm_logging_obj.model_call_details,
)
# Assert no redaction occurred
assert _redacted_response_obj.choices[0].message.content == "redacted-by-litellm"
# Reset settings
litellm.turn_off_message_logging = False
print("Test passed")
@pytest.mark.parametrize(
"duration, unit",
[("7s", "s"), ("7m", "m"), ("7h", "h"), ("7d", "d"), ("7mo", "mo")],
)
def test_extract_from_regex(duration, unit):
value, _unit = _extract_from_regex(duration=duration)
assert value == 7
assert _unit == unit
def test_duration_in_seconds():
"""
Test if duration int is correctly calculated for different str
"""
import time
now = time.time()
current_time = datetime.fromtimestamp(now)
if current_time.month == 12:
target_year = current_time.year + 1
target_month = 1
else:
target_year = current_time.year
target_month = current_time.month + 1
# Determine the day to set for next month
target_day = current_time.day
last_day_of_target_month = get_last_day_of_month(target_year, target_month)
if target_day > last_day_of_target_month:
target_day = last_day_of_target_month
next_month = datetime(
year=target_year,
month=target_month,
day=target_day,
hour=current_time.hour,
minute=current_time.minute,
second=current_time.second,
microsecond=current_time.microsecond,
)
# Calculate the duration until the first day of the next month
duration_until_next_month = next_month - current_time
expected_duration = int(duration_until_next_month.total_seconds())
value = duration_in_seconds(duration="1mo")
assert value - expected_duration < 2
def test_duration_in_seconds_basic():
assert duration_in_seconds(duration="3s") == 3
assert duration_in_seconds(duration="3m") == 180
assert duration_in_seconds(duration="3h") == 10800
assert duration_in_seconds(duration="3d") == 259200
assert duration_in_seconds(duration="3w") == 1814400
def test_get_llm_provider_ft_models():
"""
All ft prefixed models should map to OpenAI
gpt-3.5-turbo-0125 (recommended),
gpt-3.5-turbo-1106,
gpt-3.5-turbo,
gpt-4-0613 (experimental)
gpt-4o-2024-05-13.
babbage-002, davinci-002,
"""
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-3.5-turbo-0125")
assert custom_llm_provider == "openai"
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-3.5-turbo-1106")
assert custom_llm_provider == "openai"
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-3.5-turbo")
assert custom_llm_provider == "openai"
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-4-0613")
assert custom_llm_provider == "openai"
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-3.5-turbo")
assert custom_llm_provider == "openai"
model, custom_llm_provider, _, _ = get_llm_provider(model="ft:gpt-4o-2024-05-13")
assert custom_llm_provider == "openai"
@pytest.mark.parametrize("langfuse_trace_id", [None, "my-unique-trace-id"])
@pytest.mark.parametrize(
"langfuse_existing_trace_id", [None, "my-unique-existing-trace-id"]
)
def test_logging_trace_id(langfuse_trace_id, langfuse_existing_trace_id):
"""
- Unit test for `_get_trace_id` function in Logging obj
"""
from litellm.integrations.langfuse.langfuse_sdk import resolve_trace_id
from litellm.litellm_core_utils.litellm_logging import Logging
litellm.success_callback = ["langfuse"]
litellm_call_id = "my-unique-call-id"
litellm_logging_obj = Logging(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="acompletion",
litellm_call_id=litellm_call_id,
start_time=datetime.now(),
function_id="1234",
)
metadata = {}
if langfuse_trace_id is not None:
metadata["trace_id"] = langfuse_trace_id
if langfuse_existing_trace_id is not None:
metadata["existing_trace_id"] = langfuse_existing_trace_id
litellm.completion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hey how's it going?"}],
mock_response="Hey!",
litellm_logging_obj=litellm_logging_obj,
metadata=metadata,
)
time.sleep(3)
assert litellm_logging_obj._get_trace_id(service_name="langfuse") is not None
# langfuse addresses a trace by a 32-hex id, so the id litellm reports back is the
# resolved form of whichever source won; that is what the alerting deep link needs
if langfuse_existing_trace_id is not None:
expected_source = langfuse_existing_trace_id
elif langfuse_trace_id is not None:
expected_source = langfuse_trace_id
else:
expected_source = litellm_logging_obj.litellm_trace_id
assert litellm_logging_obj._get_trace_id(service_name="langfuse") == resolve_trace_id(
expected_source
)
def test_convert_model_response_object():
"""
Unit test to ensure model response object correctly handles openrouter errors.
"""
args = {
"response_object": {
"id": None,
"choices": None,
"created": None,
"model": None,
"object": None,
"service_tier": None,
"system_fingerprint": None,
"usage": None,
"error": {
"message": '{"type":"error","error":{"type":"invalid_request_error","message":"Output blocked by content filtering policy"}}',
"code": 400,
},
},
"model_response_object": litellm.ModelResponse(
id="chatcmpl-b88ce43a-7bfc-437c-b8cc-e90d59372cfb",
choices=[
litellm.Choices(
finish_reason="stop",
index=0,
message=litellm.Message(content="default", role="assistant"),
)
],
created=1719376241,
model="openrouter/anthropic/claude-3.5-sonnet",
object="chat.completion",
system_fingerprint=None,
usage=litellm.Usage(),
),
"response_type": "completion",
"stream": False,
"start_time": None,
"end_time": None,
"hidden_params": None,
}
with pytest.raises(Exception) as exc_info: # noqa: PT011 # bare Exception() with attributes, so str(e) is empty
litellm.convert_to_model_response_object(**args)
e = exc_info.value
assert e.status_code == 400
assert (
e.message
== '{"type":"error","error":{"type":"invalid_request_error","message":"Output blocked by content filtering policy"}}'
)
@pytest.mark.parametrize(
"content, expected_reasoning, expected_content",
[
(None, None, None),
(
"<think>I am thinking here</think>The sky is a canvas of blue",
"I am thinking here",
"The sky is a canvas of blue",
),
(
"<budget:thinking>I am thinking here</budget:thinking>The sky is a canvas of blue",
"I am thinking here",
"The sky is a canvas of blue",
),
("I am a regular response", None, "I am a regular response"),
],
)
def test_parse_content_for_reasoning(content, expected_reasoning, expected_content):
assert litellm.utils._parse_content_for_reasoning(content) == (
expected_reasoning,
expected_content,
)
def test_usage_object_null_tokens():
"""
Unit test.
Asserts Usage obj always returns int.
Fixes https://github.com/BerriAI/litellm/issues/5096
"""
usage_obj = litellm.Usage(prompt_tokens=2, completion_tokens=None, total_tokens=2)
assert usage_obj.completion_tokens == 0
def test_is_base64_encoded():
import base64
import requests
litellm.set_verbose = True
url = "https://dummyimage.com/100/100/fff&text=Test+image"
response = requests.get(url)
file_data = response.content
encoded_file = base64.b64encode(file_data).decode("utf-8")
base64_image = f"data:image/png;base64,{encoded_file}"
from litellm.utils import is_base64_encoded
assert is_base64_encoded(s=base64_image) is True
@mock.patch("httpx.AsyncClient")
@mock.patch.dict(
os.environ,
{"SSL_VERIFY": "/certificate.pem", "SSL_CERTIFICATE": "/client.pem"},
clear=True,
)
def test_async_http_handler(mock_async_client):
import ssl
timeout = 120
event_hooks = {"request": [lambda r: r]}
concurrent_limit = 2
# Mock the transport creation to return a specific transport
with mock.patch.object(
AsyncHTTPHandler, "_create_async_transport"
) as mock_create_transport:
mock_transport = mock.MagicMock()
mock_create_transport.return_value = mock_transport
AsyncHTTPHandler(timeout, event_hooks, concurrent_limit)
# Get the call arguments
call_args = mock_async_client.call_args[1]
# Assert SSL context is being used instead of direct cert/verify params
assert call_args["cert"] == "/client.pem"
assert isinstance(call_args["verify"], ssl.SSLContext)
assert call_args["transport"] == mock_transport
assert call_args["event_hooks"] == event_hooks
assert call_args["headers"] == headers
assert call_args["timeout"] == timeout
assert call_args["follow_redirects"] is True
@mock.patch("httpx.AsyncClient")
@mock.patch.dict(os.environ, {}, clear=True)
def test_async_http_handler_force_ipv4(mock_async_client):
"""
Test AsyncHTTPHandler when litellm.force_ipv4 is True
This is prod test - we need to ensure that httpx always uses ipv4 when litellm.force_ipv4 is True
"""
import httpx
import ssl
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
# Set force_ipv4 to True
litellm.force_ipv4 = True
litellm.disable_aiohttp_transport = True
try:
timeout = 120
event_hooks = {"request": [lambda r: r]}
concurrent_limit = 2
AsyncHTTPHandler(timeout, event_hooks, concurrent_limit)
# Get the call arguments
call_args = mock_async_client.call_args[1]
############# IMPORTANT ASSERTION #################
# Assert transport exists and is configured correctly for using ipv4
assert isinstance(call_args["transport"], httpx.AsyncHTTPTransport)
print(call_args["transport"])
assert call_args["transport"]._pool._local_address == "0.0.0.0"
####################################
# Assert other parameters match
assert call_args["event_hooks"] == event_hooks
assert call_args["headers"] == headers
assert call_args["timeout"] == timeout
assert isinstance(call_args["verify"], ssl.SSLContext)
assert call_args["cert"] is None
assert call_args["follow_redirects"] is True
finally:
# Reset force_ipv4 to default
litellm.force_ipv4 = False
def test_is_base64_encoded_2():
from litellm.utils import is_base64_encoded
assert (
is_base64_encoded(
s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/x+AAwMCAO+ip1sAAAAASUVORK5CYII="
)
is True
)
assert is_base64_encoded(s="Dog") is False
@pytest.mark.parametrize(
"messages, expected_bool",
[
([{"role": "user", "content": "hi"}], True),
([{"role": "user", "content": [{"type": "text", "text": "hi"}]}], True),
(
[
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"file_id": "123",
"file_name": "test.txt",
"file_size": 100,
"file_type": "text/plain",
"file_url": "https://example.com/test.txt",
},
}
],
}
],
True,
),
(
[
{
"role": "user",
"content": [
{"type": "image_url", "url": "https://example.com/image.png"}
],
}
],
True,
),
(
[
{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{
"type": "image",
"source": {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "1234",
},
},
},
],
}
],
False,
),
],
)
def test_validate_chat_completion_user_messages(messages, expected_bool):
from litellm.utils import validate_chat_completion_user_messages
if expected_bool:
## Valid message
validate_chat_completion_user_messages(messages=messages)
else:
## Invalid message
with pytest.raises(Exception, match="Invalid user message at index 0"):
validate_chat_completion_user_messages(messages=messages)
@pytest.mark.parametrize(
"tool_choice, expected_bool",
[
({"type": "function", "function": {"name": "get_current_weather"}}, True),
({"type": "tool", "name": "get_current_weather"}, False),
(None, True),
("auto", True),
("required", True),
],
)
def test_validate_chat_completion_tool_choice(tool_choice, expected_bool):
from litellm.utils import validate_chat_completion_tool_choice
if expected_bool:
validate_chat_completion_tool_choice(tool_choice=tool_choice, model="gpt-5.6-sol")
else:
with pytest.raises(litellm.BadRequestError, match="Invalid tool choice"):
validate_chat_completion_tool_choice(tool_choice=tool_choice, model="gpt-5.6-sol")
def test_models_by_provider():
"""
Make sure all providers from model map are in the valid providers list
"""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
from litellm import models_by_provider
providers = set()
for k, v in litellm.model_cost.items():
if "_" in v["litellm_provider"] and "-" in v["litellm_provider"]:
continue
elif k == "sample_spec":
continue
elif (
v["litellm_provider"] == "sagemaker"
or v["litellm_provider"] == "bedrock_converse"
):
continue
elif v.get("mode") in ("search", "evaluation"):
continue
else:
providers.add(v["litellm_provider"])
for provider in providers:
assert provider in models_by_provider.keys() or JSONProviderRegistry.exists(
provider
)
@pytest.mark.parametrize(
"litellm_params, disable_end_user_cost_tracking, expected_end_user_id",
[
({}, False, None),
({"user_api_key_end_user_id": "123"}, False, "123"),
({"user_api_key_end_user_id": "123"}, True, None),
],
)
def test_get_end_user_id_for_cost_tracking(
litellm_params, disable_end_user_cost_tracking, expected_end_user_id
):
from litellm.utils import get_end_user_id_for_cost_tracking
litellm.disable_end_user_cost_tracking = disable_end_user_cost_tracking
assert (
get_end_user_id_for_cost_tracking(litellm_params=litellm_params)
== expected_end_user_id
)
@pytest.mark.parametrize(
"litellm_params, enable_end_user_cost_tracking_prometheus_only, expected_end_user_id",
[
({}, True, None),
({"user_api_key_end_user_id": "123"}, True, "123"),
({"user_api_key_end_user_id": "123"}, False, None),
],
)
def test_get_end_user_id_for_cost_tracking_prometheus_only(
litellm_params, enable_end_user_cost_tracking_prometheus_only, expected_end_user_id
):
from litellm.utils import get_end_user_id_for_cost_tracking
litellm.enable_end_user_cost_tracking_prometheus_only = (
enable_end_user_cost_tracking_prometheus_only
)
assert (
get_end_user_id_for_cost_tracking(
litellm_params=litellm_params, service_type="prometheus"
)
== expected_end_user_id
)
@pytest.mark.parametrize(
"litellm_params, expected_end_user_id",
[
# Test with only metadata field (old behavior)
(
{"metadata": {"user_api_key_end_user_id": "user_from_metadata"}},
"user_from_metadata",
),
# Test with only litellm_metadata field (new behavior)
(
{
"litellm_metadata": {
"user_api_key_end_user_id": "user_from_litellm_metadata"
}
},
"user_from_litellm_metadata",
),
# Test with both fields - metadata should take precedence for user_api_key fields
(
{
"metadata": {"user_api_key_end_user_id": "user_from_metadata"},
"litellm_metadata": {
"user_api_key_end_user_id": "user_from_litellm_metadata"
},
},
"user_from_metadata",
),
# Test with user_api_key_end_user_id in litellm_params (should take precedence over metadata)
(
{
"user_api_key_end_user_id": "user_from_params",
"metadata": {"user_api_key_end_user_id": "user_from_metadata"},
},
"user_from_params",
),
# Test with empty metadata but valid litellm_metadata
(
{
"metadata": {},
"litellm_metadata": {
"user_api_key_end_user_id": "user_from_litellm_metadata"
},
},
"user_from_litellm_metadata",
),
# Test with no metadata fields
({}, None),
],
)
def test_get_end_user_id_for_cost_tracking_metadata_handling(
litellm_params, expected_end_user_id
):
"""
Test that get_end_user_id_for_cost_tracking correctly handles both metadata and litellm_metadata
fields using the get_litellm_metadata_from_kwargs helper function.
"""
from litellm.utils import get_end_user_id_for_cost_tracking
# Ensure cost tracking is enabled for this test
litellm.disable_end_user_cost_tracking = False
result = get_end_user_id_for_cost_tracking(litellm_params=litellm_params)
assert result == expected_end_user_id
def test_is_prompt_caching_enabled_error_handling():
"""
Assert that `is_prompt_caching_valid_prompt` safely handles errors in `token_counter`.
"""
with patch(
"litellm.utils.token_counter",
side_effect=Exception(
"Mocked error, This should not raise an error. Instead is_prompt_caching_valid_prompt should return False."
),
):
result = litellm.utils.is_prompt_caching_valid_prompt(
messages=[{"role": "user", "content": "test"}],
tools=None,
custom_llm_provider="anthropic",
model="anthropic/claude-sonnet-4-5-20250929",
)
assert result is False # Should return False when an error occurs
def test_is_prompt_caching_enabled_return_default_image_dimensions():
"""
Assert that `is_prompt_caching_valid_prompt` calls token_counter with use_default_image_token_count=True
when processing messages containing images
IMPORTANT: Ensures Get token counter does not make a GET request to the image url
"""
with patch("litellm.utils.token_counter") as mock_token_counter:
litellm.utils.is_prompt_caching_valid_prompt(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": "https://www.gstatic.com/webp/gallery/1.webp",
"detail": "high",
},
},
],
}
],
tools=None,
custom_llm_provider="openai",
model="gpt-4o-mini",
)
# Assert token_counter was called with use_default_image_token_count=True
args_to_mock_token_counter = mock_token_counter.call_args[1]
print("args_to_mock", args_to_mock_token_counter)
assert args_to_mock_token_counter["use_default_image_token_count"] is True
def test_token_counter_with_image_url_with_detail_high():
"""
Assert that token_counter does not make a GET request to the image url when `use_default_image_token_count=True`
PROD TEST this is importat - Can impact latency very badly
"""
from litellm.constants import DEFAULT_IMAGE_TOKEN_COUNT
from litellm._logging import verbose_logger
import logging
verbose_logger.setLevel(logging.DEBUG)
_tokens = litellm.utils.token_counter(
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": "https://www.gstatic.com/webp/gallery/1.webp",
"detail": "high",
},
},
],
}
],
model="gpt-4o-mini",
use_default_image_token_count=True,
)
print("tokens", _tokens)
assert _tokens == DEFAULT_IMAGE_TOKEN_COUNT + 7
def test_logprobs_type():
from litellm.types.utils import Logprobs
logprobs = {
"text_offset": None,
"token_logprobs": None,
"tokens": None,
"top_logprobs": None,
}
logprobs = Logprobs(**logprobs)
assert logprobs.text_offset is None
assert logprobs.token_logprobs is None
assert logprobs.tokens is None
assert logprobs.top_logprobs is None
def test_get_valid_models_openai_proxy(monkeypatch):
from litellm.utils import get_valid_models
import litellm
litellm._turn_on_debug()
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-1234")
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://litellm-api.up.railway.app/")
monkeypatch.delenv("FIREWORKS_AI_ACCOUNT_ID", None)
monkeypatch.delenv("FIREWORKS_AI_API_KEY", None)
mock_response_data = {
"object": "list",
"data": [
{
"id": "gpt-5.5",
"object": "model",
"created": 1686935002,
"owned_by": "organization-owner",
},
],
}
# Create a mock response object
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
with patch.object(
litellm.module_level_client, "get", return_value=mock_response
) as mock_post:
valid_models = get_valid_models(check_provider_endpoint=True)
assert "litellm_proxy/gpt-5.5" in valid_models
def test_get_valid_models_fireworks_ai(monkeypatch):
from litellm.utils import get_valid_models
import litellm
litellm._turn_on_debug()
monkeypatch.setenv("FIREWORKS_API_KEY", "sk-1234")
monkeypatch.setenv("FIREWORKS_ACCOUNT_ID", "1234")
monkeypatch.setattr(litellm, "provider_list", ["fireworks_ai"])
mock_response_data = {
"models": [
{
"name": "accounts/fireworks/models/llama-3.1-8b-instruct",
"displayName": "<string>",
"description": "<string>",
"createTime": "2023-11-07T05:31:56Z",
"createdBy": "<string>",
"state": "STATE_UNSPECIFIED",
"status": {"code": "OK", "message": "<string>"},
"kind": "KIND_UNSPECIFIED",
"githubUrl": "<string>",
"huggingFaceUrl": "<string>",
"baseModelDetails": {
"worldSize": 123,
"checkpointFormat": "CHECKPOINT_FORMAT_UNSPECIFIED",
"parameterCount": "<string>",
"moe": True,
"tunable": True,
},
"peftDetails": {
"baseModel": "<string>",
"r": 123,
"targetModules": ["<string>"],
},
"teftDetails": {},
"public": True,
"conversationConfig": {
"style": "<string>",
"system": "<string>",
"template": "<string>",
},
"contextLength": 123,
"supportsImageInput": True,
"supportsTools": True,
"importedFrom": "<string>",
"fineTuningJob": "<string>",
"defaultDraftModel": "<string>",
"defaultDraftTokenCount": 123,
"precisions": ["PRECISION_UNSPECIFIED"],
"deployedModelRefs": [
{
"name": "<string>",
"deployment": "<string>",
"state": "STATE_UNSPECIFIED",
"default": True,
"public": True,
}
],
"cluster": "<string>",
"deprecationDate": {"year": 123, "month": 123, "day": 123},
}
],
"nextPageToken": "<string>",
"totalSize": 123,
}
# Create a mock response object
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
with patch.object(
litellm.module_level_client, "get", return_value=mock_response
) as mock_post:
valid_models = get_valid_models(check_provider_endpoint=True)
print("valid_models", valid_models)
mock_post.assert_called_once()
assert (
"fireworks_ai/accounts/fireworks/models/llama-3.1-8b-instruct"
in valid_models
)
def test_get_valid_models_default(monkeypatch):
"""
Ensure that the default models is used when error retrieving from model api.
Prevent regression for existing usage.
"""
from litellm.utils import get_valid_models
monkeypatch.setenv("FIREWORKS_API_KEY", "sk-1234")
valid_models = get_valid_models()
assert len(valid_models) > 0
def test_pick_cheapest_chat_model_from_llm_provider():
from litellm.litellm_core_utils.llm_request_utils import (
pick_cheapest_chat_models_from_llm_provider,
)
assert len(pick_cheapest_chat_models_from_llm_provider("openai", n=3)) == 3
assert len(pick_cheapest_chat_models_from_llm_provider("unknown", n=1)) == 0
@pytest.mark.parametrize("num_retries", [0, 1, 5])
def test_get_num_retries(num_retries):
from litellm.utils import _get_wrapper_num_retries
assert _get_wrapper_num_retries(
kwargs={"num_retries": num_retries}, exception=Exception("test")
) == (
num_retries,
{
"num_retries": num_retries,
},
)
def test_add_custom_logger_callback_to_specific_event(monkeypatch):
from litellm.utils import _add_custom_logger_callback_to_specific_event
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
_add_custom_logger_callback_to_specific_event("langfuse", "success")
assert len(litellm.success_callback) == 1
assert len(litellm.failure_callback) == 0
def test_add_custom_logger_callback_to_specific_event_e2e(monkeypatch):
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
litellm.success_callback = ["humanloop"]
curr_len_success_callback = len(litellm.success_callback)
curr_len_failure_callback = len(litellm.failure_callback)
litellm.completion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing langfuse",
)
assert len(litellm.success_callback) == curr_len_success_callback
assert len(litellm.failure_callback) == curr_len_failure_callback
def test_custom_logger_exists_in_callbacks_individual_functions(monkeypatch):
"""
Test _custom_logger_class_exists_in_success_callbacks and _custom_logger_class_exists_in_failure_callbacks helper functions
Tests if logger is found in different callback lists
"""
from litellm.integrations.custom_logger import CustomLogger
from litellm.utils import (
_custom_logger_class_exists_in_failure_callbacks,
_custom_logger_class_exists_in_success_callbacks,
)
# Create a mock CustomLogger class
class MockCustomLogger(CustomLogger):
def log_success_event(self, kwargs, response_obj, start_time, end_time):
pass
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
pass
# Reset all callback lists
for list_name in [
"callbacks",
"_async_success_callback",
"_async_failure_callback",
"success_callback",
"failure_callback",
]:
monkeypatch.setattr(litellm, list_name, [])
mock_logger = MockCustomLogger()
# Test 1: No logger exists in any callback list
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == False
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == False
# Test 2: Logger exists in success_callback
litellm.success_callback.append(mock_logger)
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == True
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == False
# Reset callbacks
litellm.success_callback = []
# Test 3: Logger exists in _async_success_callback
litellm._async_success_callback.append(mock_logger)
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == True
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == False
# Reset callbacks
litellm._async_success_callback = []
# Test 4: Logger exists in failure_callback
litellm.failure_callback.append(mock_logger)
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == False
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == True
# Reset callbacks
litellm.failure_callback = []
# Test 5: Logger exists in _async_failure_callback
litellm._async_failure_callback.append(mock_logger)
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == False
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == True
# Test 6: Logger exists in both success and failure callbacks
litellm.success_callback.append(mock_logger)
litellm.failure_callback.append(mock_logger)
assert _custom_logger_class_exists_in_success_callbacks(mock_logger) == True
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger) == True
# Test 7: Different instance of same logger class
mock_logger_2 = MockCustomLogger()
assert _custom_logger_class_exists_in_success_callbacks(mock_logger_2) == True
assert _custom_logger_class_exists_in_failure_callbacks(mock_logger_2) == True
@pytest.mark.asyncio
async def test_add_custom_logger_callback_to_specific_event_with_duplicates(
monkeypatch,
):
"""
Test that when a callback exists in both success_callback and _async_success_callback,
it's not added again
"""
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
# Reset all callback lists
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
# Add logger to both success_callback and _async_success_callback
langfuse_logger = LangfusePromptManagement()
litellm.success_callback.append(langfuse_logger)
litellm._async_success_callback.append(langfuse_logger)
# Get initial lengths
initial_success_callback_len = len(litellm.success_callback)
initial_async_success_callback_len = len(litellm._async_success_callback)
# Make a completion call
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing duplicate callbacks",
)
# Assert no new callbacks were added
assert len(litellm.success_callback) == initial_success_callback_len
assert len(litellm._async_success_callback) == initial_async_success_callback_len
@pytest.mark.asyncio
async def test_add_custom_logger_callback_to_specific_event_with_duplicates_success_callback(
monkeypatch,
):
"""
Test that when a callback exists in both success_callback and _async_success_callback,
it's not added again
"""
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
# Reset all callback lists
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
# Add logger to both success_callback and _async_success_callback
langfuse_logger = LangfusePromptManagement()
litellm.success_callback.append(langfuse_logger)
# Get initial lengths
initial_success_callback_len = len(litellm.success_callback)
initial_async_success_callback_len = len(litellm._async_success_callback)
# Make a completion call
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing duplicate callbacks",
)
# Assert no new callbacks were added
assert len(litellm.success_callback) == initial_success_callback_len
assert len(litellm._async_success_callback) == initial_async_success_callback_len
@pytest.mark.asyncio
async def test_add_custom_logger_callback_to_specific_event_with_duplicates_callbacks(
monkeypatch,
):
"""
Test that when a callback exists in both success_callback and _async_success_callback,
it's not added again
"""
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
# Reset all callback lists
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
# Add logger to both success_callback and _async_success_callback
langfuse_logger = LangfusePromptManagement()
litellm.callbacks.append(langfuse_logger)
# Make a completion call
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing duplicate callbacks",
)
# Assert no new callbacks were added
initial_callbacks_len = len(litellm.callbacks)
initial_async_success_callback_len = len(litellm._async_success_callback)
initial_success_callback_len = len(litellm.success_callback)
print(
f"Num callbacks before: litellm.callbacks: {len(litellm.callbacks)}, litellm._async_success_callback: {len(litellm._async_success_callback)}, litellm.success_callback: {len(litellm.success_callback)}"
)
for _ in range(10):
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing duplicate callbacks",
)
assert len(litellm.callbacks) == initial_callbacks_len
assert len(litellm._async_success_callback) == initial_async_success_callback_len
assert len(litellm.success_callback) == initial_success_callback_len
print(
f"Num callbacks after 10 mock calls: litellm.callbacks: {len(litellm.callbacks)}, litellm._async_success_callback: {len(litellm._async_success_callback)}, litellm.success_callback: {len(litellm.success_callback)}"
)
def test_add_custom_logger_callback_to_specific_event_e2e_failure(monkeypatch):
from litellm.integrations.openmeter import OpenMeterLogger
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setenv("OPENMETER_API_KEY", "wedlwe")
monkeypatch.setenv("OPENMETER_API_URL", "https://openmeter.dev")
litellm.failure_callback = ["openmeter"]
curr_len_success_callback = len(litellm.success_callback)
curr_len_failure_callback = len(litellm.failure_callback)
litellm.completion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
mock_response="Testing langfuse",
)
assert len(litellm.success_callback) == curr_len_success_callback
assert len(litellm.failure_callback) == curr_len_failure_callback
assert any(
isinstance(callback, OpenMeterLogger) for callback in litellm.failure_callback
)
@pytest.mark.asyncio
async def test_wrapper_kwargs_passthrough():
from litellm.utils import client
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObject,
)
# Create mock original function
mock_original = AsyncMock()
# Apply decorator
@client
async def test_function(**kwargs):
return await mock_original(**kwargs)
# Test kwargs
test_kwargs = {"base_model": "gpt-5-mini"}
# Call decorated function
await test_function(**test_kwargs)
mock_original.assert_called_once()
# get litellm logging object
litellm_logging_obj: LiteLLMLoggingObject = mock_original.call_args.kwargs.get(
"litellm_logging_obj"
)
assert litellm_logging_obj is not None
print(
f"litellm_logging_obj.model_call_details: {litellm_logging_obj.model_call_details}"
)
# get base model
assert (
litellm_logging_obj.model_call_details["litellm_params"]["base_model"]
== "gpt-5-mini"
)
def test_dict_to_response_format_helper():
from litellm.llms.base_llm.base_utils import _dict_to_response_format_helper
args = {
"response_format": {
"type": "json_schema",
"json_schema": {
"schema": {
"$defs": {
"CalendarEvent": {
"properties": {
"name": {"title": "Name", "type": "string"},
"date": {"title": "Date", "type": "string"},
"participants": {
"items": {"type": "string"},
"title": "Participants",
"type": "array",
},
},
"required": ["name", "date", "participants"],
"title": "CalendarEvent",
"type": "object",
"additionalProperties": False,
}
},
"properties": {
"events": {
"items": {"$ref": "#/$defs/CalendarEvent"},
"title": "Events",
"type": "array",
}
},
"required": ["events"],
"title": "EventsList",
"type": "object",
"additionalProperties": False,
},
"name": "EventsList",
"strict": True,
},
},
"ref_template": "/$defs/{model}",
}
_dict_to_response_format_helper(**args)
def test_validate_user_messages_invalid_content_type():
from litellm.utils import validate_chat_completion_user_messages
messages = [{"content": [{"type": "invalid_type", "text": "Hello"}]}]
with pytest.raises(Exception, match='Please ensure all messages are valid OpenAI chat completion') as e:
validate_chat_completion_user_messages(messages)
assert "Invalid message" in str(e)
print(e)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.utils import get_applied_guardrails
from unittest.mock import Mock
@pytest.mark.parametrize(
"test_case",
[
{
"name": "default_on_guardrail",
"callbacks": [
CustomGuardrail(guardrail_name="test_guardrail", default_on=True)
],
"kwargs": {"metadata": {"requester_metadata": {"guardrails": []}}},
"expected": ["test_guardrail"],
},
{
"name": "request_specific_guardrail",
"callbacks": [
CustomGuardrail(guardrail_name="test_guardrail", default_on=False)
],
"kwargs": {
"metadata": {"requester_metadata": {"guardrails": ["test_guardrail"]}}
},
"expected": ["test_guardrail"],
},
{
"name": "multiple_guardrails",
"callbacks": [
CustomGuardrail(guardrail_name="default_guardrail", default_on=True),
CustomGuardrail(guardrail_name="request_guardrail", default_on=False),
],
"kwargs": {
"metadata": {
"requester_metadata": {"guardrails": ["request_guardrail"]}
}
},
"expected": ["default_guardrail", "request_guardrail"],
},
{
"name": "empty_metadata",
"callbacks": [
CustomGuardrail(guardrail_name="test_guardrail", default_on=False)
],
"kwargs": {},
"expected": [],
},
{
"name": "none_callback",
"callbacks": [
None,
CustomGuardrail(guardrail_name="test_guardrail", default_on=True),
],
"kwargs": {},
"expected": ["test_guardrail"],
},
{
"name": "non_guardrail_callback",
"callbacks": [
Mock(),
CustomGuardrail(guardrail_name="test_guardrail", default_on=True),
],
"kwargs": {},
"expected": ["test_guardrail"],
},
],
)
def test_get_applied_guardrails(test_case):
# Setup
litellm.callbacks = test_case["callbacks"]
# Execute
result = get_applied_guardrails(test_case["kwargs"])
# Assert
assert sorted(result) == sorted(test_case["expected"])
@pytest.mark.parametrize(
"endpoint, params, expected_bool",
[
("localhost:4000/v1/rerank", ["max_chunks_per_doc"], True),
("localhost:4000/v2/rerank", ["max_chunks_per_doc"], False),
("localhost:4000", ["max_chunks_per_doc"], True),
("localhost:4000/v1/rerank", ["max_tokens_per_doc"], True),
("localhost:4000/v2/rerank", ["max_tokens_per_doc"], False),
("localhost:4000", ["max_tokens_per_doc"], False),
(
"localhost:4000/v1/rerank",
["max_chunks_per_doc", "max_tokens_per_doc"],
True,
),
(
"localhost:4000/v2/rerank",
["max_chunks_per_doc", "max_tokens_per_doc"],
False,
),
("localhost:4000", ["max_chunks_per_doc", "max_tokens_per_doc"], False),
],
)
def test_should_use_cohere_v1_client(endpoint, params, expected_bool):
assert litellm.utils.should_use_cohere_v1_client(endpoint, params) == expected_bool
def test_add_openai_metadata():
from litellm.utils import add_openai_metadata
metadata = {
"user_api_key_end_user_id": "123",
"hidden_params": {"api_key": "123"},
"litellm_parent_otel_span": MagicMock(),
"none-val": None,
"int-val": 1,
"dict-val": {"a": 1, "b": 2},
}
result = add_openai_metadata(metadata)
assert result == {
"user_api_key_end_user_id": "123",
}
def test_message_object():
from litellm.types.utils import Message
message = Message(content="Hello, world!", role="user")
assert message.content == "Hello, world!"
assert message.role == "user"
assert not hasattr(message, "audio")
assert not hasattr(message, "thinking_blocks")
assert not hasattr(message, "reasoning_content")
def test_delta_object():
from litellm.types.utils import Delta
delta = Delta(content="Hello, world!", role="user")
assert delta.content == "Hello, world!"
assert delta.role == "user"
assert not hasattr(delta, "thinking_blocks")
assert not hasattr(delta, "reasoning_content")
def test_get_provider_audio_transcription_config():
from litellm.utils import ProviderConfigManager
from litellm.types.utils import LlmProviders
for provider in LlmProviders:
config = ProviderConfigManager.get_provider_audio_transcription_config(
model="whisper-1", provider=provider
)
@pytest.mark.parametrize(
"model, expected_bool",
[
("anthropic.claude-sonnet-4-5-20250929-v1:0", True),
("us.anthropic.claude-sonnet-4-5-20250929-v1:0", True),
],
)
def test_claude_sonnet_4_5_supports_pdf_input(model, expected_bool):
from litellm.utils import supports_pdf_input
assert supports_pdf_input(model) == expected_bool
def test_get_valid_models_from_provider():
"""
Test that get_valid_models returns the correct models for a given provider
"""
from litellm.utils import get_valid_models
valid_models = get_valid_models(custom_llm_provider="openai")
assert len(valid_models) > 0
assert "gpt-5-mini" in valid_models
print("Valid models: ", valid_models)
valid_models.remove("gpt-5-mini")
assert "gpt-5-mini" not in valid_models
valid_models = get_valid_models(custom_llm_provider="openai")
assert len(valid_models) > 0
assert "gpt-5-mini" in valid_models
def test_get_valid_models_from_provider_cache_invalidation(monkeypatch):
"""
Test that get_valid_models returns the correct models for a given provider
"""
from litellm.utils import _model_cache
monkeypatch.setenv("OPENAI_API_KEY", "123")
_model_cache.set_cached_model_info(
"openai", litellm_params=None, available_models=["gpt-5-mini"]
)
monkeypatch.delenv("OPENAI_API_KEY")
assert _model_cache.get_cached_model_info("openai") is None
def test_get_valid_models_from_dynamic_api_key():
"""
Test that get_valid_models returns the correct models for a given provider
"""
from litellm.utils import get_valid_models
from litellm.types.router import CredentialLiteLLMParams
creds = CredentialLiteLLMParams(api_key="123")
valid_models = get_valid_models(
custom_llm_provider="anthropic",
litellm_params=creds,
check_provider_endpoint=True,
)
assert len(valid_models) == 0
creds = CredentialLiteLLMParams(api_key=os.getenv("ANTHROPIC_API_KEY"))
valid_models = get_valid_models(
custom_llm_provider="anthropic",
litellm_params=creds,
check_provider_endpoint=True,
)
assert len(valid_models) > 0
assert "anthropic/claude-sonnet-4-6" in valid_models
def test_get_whitelisted_models():
"""
Snapshot of all bedrock models as of 12/24/2024.
Enforce any new bedrock chat model to be added as `bedrock_converse` unless explicitly whitelisted.
Create whitelist to prevent naming regressions for older litellm versions.
"""
whitelisted_models = []
for model, info in litellm.model_cost.items():
if info.get("litellm_provider") == "bedrock" and info.get("mode") == "chat":
whitelisted_models.append(model)
# Write to a local file
with open("whitelisted_bedrock_models.txt", "w") as file:
for model in whitelisted_models:
file.write(f"{model}\n")
print("whitelisted_models written to whitelisted_bedrock_models.txt")
def test_delta_tool_calls_sequential_indices():
"""
Test that multiple tool calls without explicit indices receive sequential indices.
When providers don't include index fields in tool calls, the Delta class
should automatically assign sequential indices (0, 1, 2, ...) instead of
defaulting all tool calls to index=0.
"""
import json
from litellm.types.utils import Delta
# Simulate tool calls from streaming responses without explicit indices
tool_calls_without_indices = [
{
"id": "call_1",
"function": {"name": "get_weather_for_dallas", "arguments": json.dumps({})},
"type": "function",
# Note: no "index" field - simulates provider response
},
{
"id": "call_2",
"function": {
"name": "get_weather_precise",
"arguments": json.dumps({"location": "Dallas, TX"}),
},
"type": "function",
# Note: no "index" field - simulates provider response
},
]
# Create Delta object as LiteLLM would when processing streaming response
delta = Delta(content=None, tool_calls=tool_calls_without_indices)
# Verify tool calls have sequential indices
assert delta.tool_calls is not None, "Tool calls should not be None"
assert len(delta.tool_calls) == 2
assert (
delta.tool_calls[0].index == 0
), f"First tool call should have index 0, got {delta.tool_calls[0].index}"
assert (
delta.tool_calls[1].index == 1
), f"Second tool call should have index 1, got {delta.tool_calls[1].index}"
# Verify tool call details are preserved
assert delta.tool_calls[0].function.name == "get_weather_for_dallas"
assert delta.tool_calls[1].function.name == "get_weather_precise"
def test_completion_with_no_model():
"""
Ensure error is raised when no model is provided
"""
# test on empty
with pytest.raises(TypeError):
response = litellm.completion(
messages=[{"role": "user", "content": "Hello, how are you?"}]
)
def test_get_base_model_from_metadata():
"""
Test _get_base_model_from_metadata function with both metadata and litellm_metadata.
This ensures cost tracking works for both Chat Completions API and Responses API.
Related issue: https://github.com/BerriAI/litellm/issues/16772
"""
from litellm.utils import _get_base_model_from_metadata
# Test 1: base_model in metadata (Chat Completions API pattern)
model_call_details_with_metadata = {
"litellm_params": {"metadata": {"model_info": {"base_model": "azure/gpt-5.5"}}}
}
result = _get_base_model_from_metadata(model_call_details_with_metadata)
assert result == "azure/gpt-5.5", f"Expected 'azure/gpt-5.5', got {result}"
# Test 2: base_model in litellm_metadata (Responses API and generic API calls pattern)
model_call_details_with_litellm_metadata = {
"litellm_params": {
"litellm_metadata": {"model_info": {"base_model": "azure/gpt-5-mini"}}
}
}
result = _get_base_model_from_metadata(model_call_details_with_litellm_metadata)
assert result == "azure/gpt-5-mini", f"Expected 'azure/gpt-5-mini', got {result}"
# Test 3: base_model in litellm_params (direct base_model)
model_call_details_with_direct_base_model = {
"litellm_params": {"base_model": "azure/gpt-5-mini"}
}
result = _get_base_model_from_metadata(model_call_details_with_direct_base_model)
assert (
result == "azure/gpt-5-mini"
), f"Expected 'azure/gpt-5-mini', got {result}"
# Test 4: metadata takes precedence over litellm_metadata
model_call_details_with_both = {
"litellm_params": {
"metadata": {"model_info": {"base_model": "azure/gpt-4-from-metadata"}},
"litellm_metadata": {
"model_info": {"base_model": "azure/gpt-4-from-litellm-metadata"}
},
}
}
result = _get_base_model_from_metadata(model_call_details_with_both)
assert (
result == "azure/gpt-4-from-metadata"
), f"Expected metadata to take precedence, got {result}"
# Test 5: No base_model present
model_call_details_without_base_model = {"litellm_params": {"metadata": {}}}
result = _get_base_model_from_metadata(model_call_details_without_base_model)
assert result is None, f"Expected None when no base_model present, got {result}"
# Test 6: None input
result = _get_base_model_from_metadata(None)
assert result is None, f"Expected None for None input, got {result}"