* fix(opentelemetry): JSON-serialize dict metadata fields for OTEL span attributes (#27451) (#27455) Squash-merged by litellm-agent from Anai-Guo's PR. * feat(dashscope): add embeddings and reranks(qwen3-rerank) support via OpenAI-compatible endpoint (#27508) Squash-merged by litellm-agent from yimao's PR. * fix(vertex_ai/gemini): raise BadRequestError when image_url or url fi… (#24550) Squash-merged by litellm-agent from krisxia0506's PR. * fix(vertex_ai): raise error on mid-stream 429/error chunks instead of silently swallowing (#23711) Squash-merged by litellm-agent from krisxia0506's PR. * fix: raise BadRequestError for file content blocks missing 'file' sub… (#24503) Squash-merged by litellm-agent from krisxia0506's PR. * Fix Gemini MIME detection for extensionless GCS URIs (#27278) Squash-merged by litellm-agent from krisxia0506's PR. * fix(vertex_ai/partner_models): drop unused vertexai SDK gate from count_tokens (closes #28084) (#28107) Squash-merged by litellm-agent from voidborne-d's PR. * feat(chart): add support for autoscaling behavior in HPA (#27990) Squash-merged by litellm-agent from FabrizioCafolla's PR. * feat(proxy): add blocked flag to models for pause/resume from the UI (#27927) Squash-merged by litellm-agent from Cyberfilo's PR. * fix: pass socket timeouts to Redis cluster clients (#27920) Squash-merged by litellm-agent from tomdee's PR. * Fix/cache token (#28009) Squash-merged by litellm-agent from escon1004's PR. * fix(deepseek): forward reasoning_content in multi-turn thinking mode conversations (#28080) Squash-merged by litellm-agent from Divyansh8321's PR. * fix(guardrails): return HTTP 400 instead of 500 for blocked requests (#27617) * fix: reset org and tag budgets (#27326) * reset org budgets * reset tag budgets --------- Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> * fix(ui): omit allowed_routes from key edit save when unchanged (#27553) * fix(ui): omit allowed_routes from key edit save when unchanged When a team admin opens Edit Settings on a key with key_type=AI APIs and saves without changing anything, the UI re-sends the existing allowed_routes value, which the backend's _check_allowed_routes_caller_permission gate rejects for non-proxy-admins (LIT-2681). Strip allowed_routes from the patch in handleSubmit when it deep-equals the original keyData.allowed_routes. The backend treats absence as "leave alone," so no-op saves now succeed for non-admins. Admins explicitly editing the field still send the new value. * fix(ui): order-insensitive allowed_routes diff + cover null-original case Address Greptile review: - Switch the "is allowed_routes unchanged" check to a Set-based comparison so a server-side reorder of the array doesn't register as a user edit and re-trigger LIT-2681. - Add two regression tests: (1) keyData.allowed_routes is null and the form is untouched — patch should strip the field; (2) server returned routes in a different order than the user originally entered — patch should still recognize the value as unchanged. * chore(ui): strip ticket refs and tighten comments in key edit fix - Remove internal-tracker references from in-code comments - Tighten the WHY comment in handleSubmit to two lines - Drop redundant test-block comments — test names already describe the case * fix(ui): annotate Set<string> generic in allowed_routes diff to fix tsc * fix(guardrails): return HTTP 400 instead of 500 for guardrail-blocked requests GuardrailRaisedException and BlockedPiiEntityError both lacked a status_code attribute. When these exceptions reached the proxy exception handler (getattr(e, 'status_code', 500)), the fallback defaulted to HTTP 500 — making intentional guardrail blocks indistinguishable from server errors and causing unnecessary client retries. Changes: - Add status_code=400 (keyword-only) to GuardrailRaisedException - Add status_code=400 (keyword-only) to BlockedPiiEntityError - Update _is_guardrail_intervention() to recognize both exceptions so downstream loggers record 'guardrail_intervened' instead of 'guardrail_failed_to_respond' - Add 6 unit tests for default/custom status codes and getattr pattern - Strengthen existing blocked-action test with status_code assertion Fixes #24348 --------- Co-authored-by: Michael-RZ-Berri <michael@berri.ai> Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> * fix(router/proxy): address Greptile P1+P2 review comments on PR #28161 - router: raise ServiceUnavailableError (503) instead of RouterRateLimitErrorBasic (429) when a specifically-addressed deployment is administratively blocked; 429 misleads retry-enabled clients into spinning forever against a paused model - proxy_server: compute get_fully_blocked_model_names() once before both branches in model_list() instead of duplicating the call in each branch - deepseek: upgrade silent debug log to warning when injecting placeholder reasoning_content so callers are clearly notified of degraded multi-turn quality - tests: update two blocked-deployment assertions to expect ServiceUnavailableError Co-authored-by: Cursor <cursoragent@cursor.com> * fix: address bug detection findings (cache token order, mutable defaults) Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix: address bugs in async pass-through, anthropic cache token detection, rerank tests - async_get_available_deployment_for_pass_through: enforce blocked check on specific deployments - cost_calculator: detect anthropic-style usage by attribute presence (not truthiness) to avoid mixing OpenAI cached_tokens into anthropic normalization when read=0 - dashscope rerank tests: pass request to httpx.Response constructions for consistency Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix code qa * fix(vertex_ai/gemini): strip MIME parameters from GCS contentType GCS object metadata's contentType field can include parameters such as 'text/html; charset=utf-8'. Strip them in _apply_gemini_mime_type_aliases so downstream get_file_extension_from_mime_type sees a bare MIME type. Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(vertex_ai/gemini): clarify mime-type error message string concatenation Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: Tai An <antai12232931@outlook.com> Co-authored-by: Vincent <yimao1231@gmail.com> Co-authored-by: Kris Xia <xiajiayi0506@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Fabrizio Cafolla <developer@fabriziocafolla.com> Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> Co-authored-by: Tom Denham <tom@tomdee.co.uk> Co-authored-by: escon1004 <70471150+escon1004@users.noreply.github.com> Co-authored-by: Divyansh Singhal <97736786+Divyansh8321@users.noreply.github.com> Co-authored-by: robin-fiddler <robin@fiddler.ai> Co-authored-by: Michael-RZ-Berri <michael@berri.ai> Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
from base_llm_unit_tests import BaseLLMChatTest
|
|
import pytest
|
|
import litellm
|
|
|
|
|
|
# Test implementations
|
|
@pytest.mark.skip(reason="Deepseek API is hanging")
|
|
class TestDeepSeekChatCompletion(BaseLLMChatTest):
|
|
def get_base_completion_call_args(self) -> dict:
|
|
return {
|
|
"model": "deepseek/deepseek-reasoner",
|
|
}
|
|
|
|
def test_tool_call_no_arguments(self, tool_call_no_arguments):
|
|
"""Test that tool calls with no arguments is translated correctly. Relevant issue: https://github.com/BerriAI/litellm/issues/6833"""
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize("stream", [True, False])
|
|
def test_deepseek_mock_completion(stream):
|
|
"""
|
|
Deepseek API is hanging. Mock the call, to a fake endpoint, so we can confirm our integration is working.
|
|
"""
|
|
import litellm
|
|
from litellm import completion
|
|
|
|
litellm._turn_on_debug()
|
|
|
|
response = completion(
|
|
model="deepseek/deepseek-reasoner",
|
|
messages=[{"role": "user", "content": "Hello, world!"}],
|
|
api_base="https://exampleopenaiendpoint-production.up.railway.app/v1/chat/completions",
|
|
stream=stream,
|
|
mock_response="Hello! How can I help you today?",
|
|
)
|
|
print(f"response: {response}")
|
|
if stream:
|
|
for chunk in response:
|
|
print(chunk)
|
|
else:
|
|
assert response is not None
|
|
|
|
|
|
@pytest.mark.parametrize("stream", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_deepseek_provider_async_completion(stream):
|
|
"""
|
|
Test that Deepseek provider requests are formatted correctly with the proper parameters
|
|
"""
|
|
import litellm
|
|
import json
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
from litellm import acompletion
|
|
|
|
litellm._turn_on_debug()
|
|
|
|
# Set up the test parameters
|
|
api_key = "fake_api_key"
|
|
model = "deepseek/deepseek-reasoner"
|
|
messages = [{"role": "user", "content": "Hello, world!"}]
|
|
|
|
# Mock AsyncHTTPHandler.post method for async test
|
|
with patch(
|
|
"litellm.llms.custom_httpx.llm_http_handler.AsyncHTTPHandler.post"
|
|
) as mock_post:
|
|
mock_response_data = litellm.ModelResponse(
|
|
choices=[
|
|
litellm.Choices(
|
|
message=litellm.Message(content="Hello!"),
|
|
index=0,
|
|
finish_reason="stop",
|
|
)
|
|
]
|
|
).model_dump()
|
|
# Create a proper mock response
|
|
mock_response = MagicMock() # Use MagicMock instead of AsyncMock
|
|
mock_response.status_code = 200
|
|
mock_response.text = json.dumps(mock_response_data)
|
|
mock_response.headers = {"Content-Type": "application/json"}
|
|
|
|
# Make json() return a value directly, not a coroutine
|
|
mock_response.json.return_value = mock_response_data
|
|
|
|
# Set the return value for the post method
|
|
mock_post.return_value = mock_response
|
|
|
|
await acompletion(
|
|
custom_llm_provider="deepseek",
|
|
api_key=api_key,
|
|
model=model,
|
|
messages=messages,
|
|
stream=stream,
|
|
)
|
|
|
|
# Verify the request was made with the correct parameters
|
|
mock_post.assert_called_once()
|
|
call_args = mock_post.call_args
|
|
print("request call=", json.dumps(call_args.kwargs, indent=4, default=str))
|
|
|
|
# Check request body
|
|
request_body = json.loads(call_args.kwargs["data"])
|
|
assert call_args.kwargs["url"] == "https://api.deepseek.com/beta/chat/completions"
|
|
assert (
|
|
request_body["model"] == "deepseek-reasoner"
|
|
) # Model name should be stripped of provider prefix
|
|
assert request_body["messages"] == messages
|
|
assert request_body["stream"] == stream
|
|
|
|
|
|
def test_completion_cost_deepseek():
|
|
litellm.set_verbose = True
|
|
model_name = "deepseek/deepseek-chat"
|
|
messages_1 = [
|
|
{
|
|
"role": "system",
|
|
"content": "You are a history expert. The user will provide a series of questions, and your answers should be concise and start with `Answer:`",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "In what year did Qin Shi Huang unify the six states?",
|
|
},
|
|
{"role": "assistant", "content": "Answer: 221 BC"},
|
|
{"role": "user", "content": "Who was the founder of the Han Dynasty?"},
|
|
{"role": "assistant", "content": "Answer: Liu Bang"},
|
|
{"role": "user", "content": "Who was the last emperor of the Tang Dynasty?"},
|
|
{"role": "assistant", "content": "Answer: Li Zhu"},
|
|
{
|
|
"role": "user",
|
|
"content": "Who was the founding emperor of the Ming Dynasty?",
|
|
},
|
|
{"role": "assistant", "content": "Answer: Zhu Yuanzhang"},
|
|
{
|
|
"role": "user",
|
|
"content": "Who was the founding emperor of the Qing Dynasty?",
|
|
},
|
|
]
|
|
|
|
message_2 = [
|
|
{
|
|
"role": "system",
|
|
"content": "You are a history expert. The user will provide a series of questions, and your answers should be concise and start with `Answer:`",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "In what year did Qin Shi Huang unify the six states?",
|
|
},
|
|
{"role": "assistant", "content": "Answer: 221 BC"},
|
|
{"role": "user", "content": "Who was the founder of the Han Dynasty?"},
|
|
{"role": "assistant", "content": "Answer: Liu Bang"},
|
|
{"role": "user", "content": "Who was the last emperor of the Tang Dynasty?"},
|
|
{"role": "assistant", "content": "Answer: Li Zhu"},
|
|
{
|
|
"role": "user",
|
|
"content": "Who was the founding emperor of the Ming Dynasty?",
|
|
},
|
|
{"role": "assistant", "content": "Answer: Zhu Yuanzhang"},
|
|
{"role": "user", "content": "When did the Shang Dynasty fall?"},
|
|
]
|
|
try:
|
|
response_1 = litellm.completion(model=model_name, messages=messages_1)
|
|
response_2 = litellm.completion(model=model_name, messages=message_2)
|
|
# Add any assertions here to check the response
|
|
print(response_2)
|
|
assert response_2.usage.prompt_cache_hit_tokens is not None
|
|
assert response_2.usage.prompt_cache_miss_tokens is not None
|
|
assert (
|
|
response_2.usage.prompt_tokens
|
|
== response_2.usage.prompt_cache_miss_tokens
|
|
+ response_2.usage.prompt_cache_hit_tokens
|
|
)
|
|
assert (
|
|
response_2.usage._cache_read_input_tokens
|
|
== response_2.usage.prompt_cache_hit_tokens
|
|
)
|
|
except litellm.APIError as e:
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"Error occurred: {e}")
|
|
|
|
|
|
def test_deepseek_fill_reasoning_content_multiturn():
|
|
"""
|
|
Unit test for _fill_reasoning_content.
|
|
Reproduces issue #28045: DeepSeek thinking mode fails in multi-turn conversations
|
|
because reasoning_content is not passed back to the API.
|
|
"""
|
|
from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig
|
|
|
|
config = DeepSeekChatConfig()
|
|
|
|
# Case 1: assistant message already has reasoning_content — should be left as-is
|
|
messages_with_rc = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi", "reasoning_content": "I thought about it"},
|
|
{"role": "user", "content": "Follow up"},
|
|
]
|
|
result = config._fill_reasoning_content(messages_with_rc)
|
|
assert result[1]["reasoning_content"] == "I thought about it"
|
|
|
|
# Case 2: assistant message has reasoning_content in provider_specific_fields — should be promoted
|
|
messages_with_psf = [
|
|
{"role": "user", "content": "Hello"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Hi",
|
|
"provider_specific_fields": {"reasoning_content": "stored thinking"},
|
|
},
|
|
{"role": "user", "content": "Follow up"},
|
|
]
|
|
result = config._fill_reasoning_content(messages_with_psf)
|
|
assert result[1]["reasoning_content"] == "stored thinking"
|
|
# Should be removed from provider_specific_fields to avoid duplication
|
|
assert "reasoning_content" not in result[1].get("provider_specific_fields", {})
|
|
|
|
# Case 3: assistant message has no reasoning_content anywhere — should inject placeholder
|
|
messages_no_rc = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi"},
|
|
{"role": "user", "content": "Follow up"},
|
|
]
|
|
result = config._fill_reasoning_content(messages_no_rc)
|
|
assert result[1]["reasoning_content"] == " "
|
|
|
|
# Case 4: non-assistant messages should never be touched
|
|
messages_user_only = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "system", "content": "You are helpful"},
|
|
]
|
|
result = config._fill_reasoning_content(messages_user_only)
|
|
assert "reasoning_content" not in result[0]
|
|
assert "reasoning_content" not in result[1]
|
|
|
|
|
|
def test_deepseek_fill_reasoning_content_guard_in_transform_request():
|
|
"""
|
|
_fill_reasoning_content must only run when BOTH conditions are true:
|
|
1. supports_reasoning() is True for the model
|
|
2. thinking mode is explicitly enabled in optional_params ({"type": "enabled"})
|
|
|
|
This prevents spurious injection on models like deepseek-v3.2 that support
|
|
thinking as opt-in but not always-on. Addresses oss-pr-review-agent feedback
|
|
on PR #28057.
|
|
"""
|
|
from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig
|
|
|
|
config = DeepSeekChatConfig()
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi"},
|
|
{"role": "user", "content": "Follow up"},
|
|
]
|
|
|
|
# Case 1: reasoning model + thinking enabled -> injection should happen
|
|
result = config.transform_request(
|
|
model="deepseek-reasoner",
|
|
messages=messages,
|
|
optional_params={"thinking": {"type": "enabled"}},
|
|
litellm_params={},
|
|
headers={},
|
|
)
|
|
assert result["messages"][1].get("reasoning_content") == " ", (
|
|
"reasoning_content should be injected when thinking is enabled"
|
|
)
|
|
|
|
# Case 2: reasoning model + thinking NOT in optional_params -> no injection
|
|
result = config.transform_request(
|
|
model="deepseek-reasoner",
|
|
messages=messages,
|
|
optional_params={},
|
|
litellm_params={},
|
|
headers={},
|
|
)
|
|
assert "reasoning_content" not in result["messages"][1], (
|
|
"reasoning_content should not be injected when thinking is not enabled"
|
|
)
|
|
|
|
# Case 3: non-reasoning model + thinking enabled -> no injection
|
|
result = config.transform_request(
|
|
model="deepseek-chat",
|
|
messages=messages,
|
|
optional_params={"thinking": {"type": "enabled"}},
|
|
litellm_params={},
|
|
headers={},
|
|
)
|
|
assert "reasoning_content" not in result["messages"][1], (
|
|
"reasoning_content should not be injected for non-reasoning models"
|
|
)
|