mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 06:22:48 +00:00
Litellm agent oss staging 05 11 2026 (#27733)
* fix(ollama): Include provider in model list for ollama (#26135) * Include provider in model names for ollama * Fix unit tests * fix(ollama): process both thinking and content in same streaming chunk (#26098) * fix(health_check): skip max_tokens for image_generation mode (#26417) * fix(health_check): skip max_tokens for image_generation mode `_update_litellm_params_for_health_check` injected `max_tokens` for every deployment. OpenAI `/v1/images/generations` strictly rejects unknown fields, so health checks for dall-e-* and gpt-image-1 always failed with `400 "Unknown parameter: 'max_tokens'"` even though the actual image endpoint calls succeed. Skip the `max_tokens` injection when `model_info.mode == "image_generation"`. `messages` still gets injected (downstream `_filter_model_params` already strips it for non-chat handlers). * Switch to allow-list with per-deployment override Per @krrishdholakia review: deny-listing image_generation only re-introduces the same bug for every other non-chat mode (embedding, audio_*, rerank, video_generation, ocr, search, moderation, ...). Replace the single image_generation skip with `_MAX_TOKEN_SUPPORT_MODES = {chat, completion, responses}`. Missing `mode` is treated as chat for backward compatibility. New modes are safe by default. Add `model_info.health_check_supports_max_tokens` as an operator escape hatch — True forces injection on a non-listed deployment (operator wants to bound probe tokens), False suppresses it on a chat-style deployment behind a strict-schema provider. Tests: parametrize over 3 chat-style + 10 non-chat modes, plus override on/off and the no-mode legacy path. * fix(http_handler): handle RequestNotRead in MaskedHTTPStatusError for multipart uploads (#26718) Squash-merged by litellm-agent from dawidkulpa's PR. * fix(ollama): guard against double 'ollama/' prefix in live model listing Greptile flagged that Ollama servers can return names that already start with 'ollama/'. Check the prefix before prepending so we don't produce 'ollama/ollama/...'. Adds a regression test. * Fix Ollama empty reasoning stream chunks Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: James Myatt <james@jamesmyatt.co.uk> Co-authored-by: VHash <225398745+vhash0@users.noreply.github.com> Co-authored-by: hayden <sewhan.kim+@a-bly.com> Co-authored-by: dawidkulpa <84176950+dawidkulpa@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Yassin Kortam <yassin@berri.ai>
This commit is contained in:
co-authored by
James Myatt
VHash
hayden
dawidkulpa
Cursor
Claude
Yassin Kortam
parent
06c1c3497f
commit
bbeb094d00
@@ -485,11 +485,16 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
|
||||
if k.lower() not in ("content-encoding", "content-length")
|
||||
}
|
||||
|
||||
try:
|
||||
request_content = original_error.request.content
|
||||
except httpx.RequestNotRead:
|
||||
request_content = b""
|
||||
|
||||
masked_request = httpx.Request(
|
||||
method=original_error.request.method,
|
||||
url=masked_url,
|
||||
headers=original_error.request.headers,
|
||||
content=original_error.request.content,
|
||||
content=request_content,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -507,10 +507,10 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
|
||||
# PROCESS REASONING CONTENT
|
||||
reasoning_content: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
if chunk["message"].get("thinking") is not None:
|
||||
if chunk["message"].get("thinking"):
|
||||
reasoning_content = chunk["message"].get("thinking")
|
||||
self.started_reasoning_content = True
|
||||
elif chunk["message"].get("content") is not None:
|
||||
if chunk["message"].get("content"):
|
||||
if (
|
||||
self.started_reasoning_content
|
||||
and not self.finished_reasoning_content
|
||||
|
||||
@@ -108,7 +108,7 @@ class OllamaModelInfo(BaseLLMModelInfo):
|
||||
continue
|
||||
nm = entry.get("name") or entry.get("model")
|
||||
if isinstance(nm, str):
|
||||
names.add(nm)
|
||||
names.add(nm if nm.startswith("ollama/") else f"ollama/{nm}")
|
||||
except Exception as e:
|
||||
verbose_logger.warning(f"Error retrieving ollama tag endpoint: {e}")
|
||||
# If tags endpoint fails, fall back to static list
|
||||
|
||||
@@ -36,6 +36,31 @@ ADMIN_ONLY_HEALTH_DISPLAY_PARAMS = ("api_base", "api_version")
|
||||
|
||||
MINIMAL_DISPLAY_PARAMS = ["model", "mode_error"]
|
||||
|
||||
# Modes whose health-check probe is a chat-style completion call and
|
||||
# therefore accept `max_tokens`. Other modes (embedding, image_generation,
|
||||
# audio_*, rerank, video_generation, ocr, search, moderation, ...) hit
|
||||
# endpoints that reject unknown fields with 400 "Unknown parameter:
|
||||
# 'max_tokens'". Allow-list so new modes are safe by default.
|
||||
# Per-deployment override: `model_info.health_check_supports_max_tokens`.
|
||||
_MAX_TOKEN_SUPPORT_MODES: frozenset = frozenset({"chat", "completion", "responses"})
|
||||
|
||||
|
||||
def _should_inject_health_check_max_tokens(model_info: dict) -> bool:
|
||||
"""
|
||||
Whether the health-check probe should include `max_tokens`.
|
||||
|
||||
Order:
|
||||
1. `model_info.health_check_supports_max_tokens` (operator override).
|
||||
2. `_MAX_TOKEN_SUPPORT_MODES`. Missing `mode` is treated as `chat`
|
||||
for backward compatibility.
|
||||
"""
|
||||
explicit = model_info.get("health_check_supports_max_tokens")
|
||||
if explicit is not None:
|
||||
return bool(explicit)
|
||||
mode = model_info.get("mode") or "chat"
|
||||
return mode in _MAX_TOKEN_SUPPORT_MODES
|
||||
|
||||
|
||||
# Health-check modes that forward `reasoning_effort` to the provider (chat-style calls).
|
||||
_HEALTH_CHECK_MODES_SUPPORTING_REASONING_EFFORT = frozenset(
|
||||
(None, "chat", "completion")
|
||||
@@ -389,14 +414,22 @@ def _update_litellm_params_for_health_check(
|
||||
Update the litellm params for health check.
|
||||
|
||||
- gets a short `messages` param for health check
|
||||
- adds a bounded `max_tokens` when the deployment is a chat-style mode
|
||||
(`chat`, `completion`, `responses`) or the operator explicitly opts in
|
||||
via `model_info.health_check_supports_max_tokens`. Non-chat endpoints
|
||||
(image, embedding, audio_*, rerank, video, ocr, search, moderation, ...)
|
||||
reject unknown fields with 400 "Unknown parameter: 'max_tokens'".
|
||||
- updates the `model` param with the `health_check_model` if it exists Doc: https://docs.litellm.ai/docs/proxy/health#wildcard-routes
|
||||
- updates the `voice` param with the `health_check_voice` for `audio_speech` mode if it exists Doc: https://docs.litellm.ai/docs/proxy/health#text-to-speech-models
|
||||
- for Bedrock models with region routing (bedrock/region/model), strips the litellm routing prefix but preserves the model ID
|
||||
"""
|
||||
litellm_params["messages"] = _get_random_llm_message()
|
||||
_resolved_max_tokens = _resolve_health_check_max_tokens(model_info, litellm_params)
|
||||
if _resolved_max_tokens is not None:
|
||||
litellm_params["max_tokens"] = _resolved_max_tokens
|
||||
if _should_inject_health_check_max_tokens(model_info):
|
||||
_resolved_max_tokens = _resolve_health_check_max_tokens(
|
||||
model_info, litellm_params
|
||||
)
|
||||
if _resolved_max_tokens is not None:
|
||||
litellm_params["max_tokens"] = _resolved_max_tokens
|
||||
|
||||
# Per-model reasoning effort for health checks only (e.g. reasoning_effort=none).
|
||||
if model_info.get("mode", None) in _HEALTH_CHECK_MODES_SUPPORTING_REASONING_EFFORT:
|
||||
|
||||
@@ -104,6 +104,31 @@ class TestMaskedHTTPStatusError:
|
||||
# The attached request must be the masked one, not the original.
|
||||
assert "KEY_X" not in str(req.url)
|
||||
|
||||
def test_handles_streaming_request_content(self):
|
||||
"""MaskedHTTPStatusError must not crash when request body is streamed."""
|
||||
streaming_request = httpx.Request(
|
||||
"POST",
|
||||
"https://api.openai.com/v1/images/edits?key=SECRET_KEY",
|
||||
stream=httpx.ByteStream(b"multipart-data"),
|
||||
)
|
||||
response = httpx.Response(
|
||||
400,
|
||||
request=streaming_request,
|
||||
content=b'{"error": "bad request"}',
|
||||
)
|
||||
orig = httpx.HTTPStatusError(
|
||||
message="400 Bad Request",
|
||||
request=streaming_request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
masked = MaskedHTTPStatusError(orig)
|
||||
|
||||
assert masked.status_code == 400
|
||||
assert masked.response.status_code == 400
|
||||
assert masked.response.request is not None
|
||||
assert "SECRET_KEY" not in str(masked.request.url)
|
||||
|
||||
def test_strips_content_encoding_to_avoid_double_decode(self):
|
||||
"""If the upstream response declared Content-Encoding (e.g. gzip),
|
||||
the rebuilt Response must not carry that header over — otherwise httpx
|
||||
|
||||
@@ -694,6 +694,71 @@ class TestOllamaReasoningContentStreaming:
|
||||
# reasoning_content is not set when there's no thinking in the chunk
|
||||
assert getattr(result2.choices[0].delta, "reasoning_content", None) is None
|
||||
|
||||
def test_thinking_and_content_in_same_chunk(self):
|
||||
"""
|
||||
Test that a chunk containing both thinking and content preserves both fields.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"thinking": "Let me reason first.",
|
||||
"content": "Final answer.",
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
|
||||
result = iterator.chunk_parser(chunk)
|
||||
|
||||
assert result.choices[0].delta.reasoning_content == "Let me reason first."
|
||||
assert result.choices[0].delta.content == "Final answer."
|
||||
|
||||
def test_streaming_chunks_ignore_inactive_empty_reasoning_fields(self):
|
||||
"""
|
||||
Test that Ollama chunks with inactive empty fields stay in the active delta.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"thinking": "Let me reason first.",
|
||||
"content": "",
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
|
||||
result = iterator.chunk_parser(chunk)
|
||||
|
||||
assert result.choices[0].delta.reasoning_content == "Let me reason first."
|
||||
assert result.choices[0].delta.content is None
|
||||
assert iterator.finished_reasoning_content is False
|
||||
|
||||
content_chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"thinking": "",
|
||||
"content": "Final answer.",
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
|
||||
result = iterator.chunk_parser(content_chunk)
|
||||
|
||||
assert getattr(result.choices[0].delta, "reasoning_content", None) is None
|
||||
assert result.choices[0].delta.content == "Final answer."
|
||||
assert iterator.finished_reasoning_content is True
|
||||
|
||||
def test_think_tags_in_content(self):
|
||||
"""
|
||||
Test that <think> tags embedded in content are properly parsed.
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestOllamaModelInfo:
|
||||
info = OllamaModelInfo()
|
||||
models = info.get_models()
|
||||
# Only 'alpha' and 'zeta' should be returned, sorted alphabetically
|
||||
assert models == ["alpha", "zeta"]
|
||||
assert models == ["ollama/alpha", "ollama/zeta"]
|
||||
# Ensure correct endpoint was called
|
||||
assert calls and calls[0].endswith("/api/tags")
|
||||
assert call_headers and call_headers[0] == {}
|
||||
@@ -122,7 +122,7 @@ class TestOllamaModelInfo:
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
info = OllamaModelInfo()
|
||||
models = info.get_models()
|
||||
assert models == ["m1", "m2"]
|
||||
assert models == ["ollama/m1", "ollama/m2"]
|
||||
|
||||
def test_get_models_fallback_on_error(self, monkeypatch):
|
||||
"""
|
||||
@@ -139,6 +139,32 @@ class TestOllamaModelInfo:
|
||||
# Default static ollama_models is ['llama2'], so expect ['ollama/llama2']
|
||||
assert models == ["ollama/llama2"]
|
||||
|
||||
def test_get_models_no_double_prefix(self, monkeypatch):
|
||||
"""
|
||||
Names that already carry the 'ollama/' prefix (or are returned by an
|
||||
Ollama server that's been configured to emit them) should not be
|
||||
prefixed a second time.
|
||||
"""
|
||||
sample = {
|
||||
"models": [
|
||||
{"name": "ollama/already-prefixed"},
|
||||
{"name": "fresh"},
|
||||
{"name": "hf.co/Qwen/Qwen3-14B:latest"},
|
||||
]
|
||||
}
|
||||
|
||||
def mock_get(url, headers):
|
||||
return DummyResponse(sample, status_code=200)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
info = OllamaModelInfo()
|
||||
models = info.get_models()
|
||||
assert models == [
|
||||
"ollama/already-prefixed",
|
||||
"ollama/fresh",
|
||||
"ollama/hf.co/Qwen/Qwen3-14B:latest",
|
||||
]
|
||||
|
||||
|
||||
class TestOllamaGetModelInfo:
|
||||
"""Tests for OllamaConfig.get_model_info() api_base threading and graceful fallback."""
|
||||
|
||||
@@ -227,6 +227,132 @@ def test_wildcard_ignores_reasoning_split_model_info(monkeypatch):
|
||||
assert _resolve_health_check_max_tokens(model_info, litellm_params) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# image_generation must not receive max_tokens.
|
||||
#
|
||||
# _update_litellm_params_for_health_check injected `max_tokens` for every
|
||||
# deployment. For `mode: image_generation` that leaked into OpenAI
|
||||
# `/v1/images/generations`, which strictly rejects unknown fields with
|
||||
# `400 "Unknown parameter: 'max_tokens'"`, marking dall-e-* and
|
||||
# gpt-image-1 as permanently unhealthy even though their actual image
|
||||
# calls succeed. `messages` still gets injected (downstream
|
||||
# `_filter_model_params` already strips it for non-chat handlers).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_image_generation_mode_skips_max_tokens():
|
||||
"""image_generation must not receive max_tokens."""
|
||||
model_info = {"mode": "image_generation"}
|
||||
litellm_params = {"model": "openai/dall-e-3", "api_key": "sk-test"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert "max_tokens" not in updated
|
||||
# connection-level params must still pass through unchanged
|
||||
assert updated["api_key"] == "sk-test"
|
||||
|
||||
|
||||
def test_health_check_max_tokens_value_is_ignored_for_non_chat_modes():
|
||||
"""A configured `health_check_max_tokens` *value* (the int that controls
|
||||
how many tokens to inject) is still skipped when the mode is outside the
|
||||
allow-list — the inject decision runs before value resolution, so the
|
||||
value never reaches `_resolve_health_check_max_tokens`. Note this is
|
||||
distinct from `health_check_supports_max_tokens` (the bool that toggles
|
||||
injection on/off per deployment)."""
|
||||
model_info = {"mode": "image_generation", "health_check_max_tokens": 50}
|
||||
litellm_params = {"model": "openai/dall-e-3"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert "max_tokens" not in updated
|
||||
|
||||
|
||||
def test_chat_mode_still_injects_max_tokens():
|
||||
"""Regression guard: the chat-style probe payload is unchanged."""
|
||||
model_info = {"mode": "chat"}
|
||||
litellm_params = {"model": "gpt-4"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert updated["max_tokens"] == 5
|
||||
|
||||
|
||||
def test_no_mode_still_injects_max_tokens():
|
||||
"""Regression guard: model_info without `mode` keeps the legacy path."""
|
||||
model_info: dict = {}
|
||||
litellm_params = {"model": "gpt-4"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert updated["max_tokens"] == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allow-list behavior: only chat-style modes (chat / completion / responses)
|
||||
# receive max_tokens. Every other mode is skipped by default.
|
||||
#
|
||||
# Per-deployment override via `health_check_supports_max_tokens` lets the
|
||||
# operator force injection on (e.g. a non-listed but max_tokens-capable
|
||||
# endpoint where they want to bound probe token usage) or off (e.g. a
|
||||
# chat-style provider with a strict schema that rejects unknown fields).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["chat", "completion", "responses"])
|
||||
def test_chat_style_modes_inject_max_tokens(mode):
|
||||
updated = _update_litellm_params_for_health_check(
|
||||
{"mode": mode}, {"model": f"openai/dummy-{mode}"}
|
||||
)
|
||||
|
||||
assert updated["max_tokens"] == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
[
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"image_edit",
|
||||
"audio_speech",
|
||||
"audio_transcription",
|
||||
"rerank",
|
||||
"video_generation",
|
||||
"ocr",
|
||||
"search",
|
||||
"moderation",
|
||||
],
|
||||
)
|
||||
def test_non_chat_modes_skip_max_tokens(mode):
|
||||
updated = _update_litellm_params_for_health_check(
|
||||
{"mode": mode}, {"model": f"openai/dummy-{mode}"}
|
||||
)
|
||||
|
||||
assert "max_tokens" not in updated
|
||||
|
||||
|
||||
def test_explicit_override_true_forces_injection_outside_allowlist():
|
||||
"""Operator opts a non-listed deployment in to bound probe token usage."""
|
||||
model_info = {
|
||||
"mode": "image_generation",
|
||||
"health_check_supports_max_tokens": True,
|
||||
}
|
||||
litellm_params = {"model": "openai/some-future-image-model"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert updated["max_tokens"] == 5
|
||||
|
||||
|
||||
def test_explicit_override_false_suppresses_injection_inside_allowlist():
|
||||
"""Operator opts a chat-style deployment out (strict-schema provider)."""
|
||||
model_info = {"mode": "chat", "health_check_supports_max_tokens": False}
|
||||
litellm_params = {"model": "openai/strict-schema-chat"}
|
||||
|
||||
updated = _update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert "max_tokens" not in updated
|
||||
|
||||
|
||||
def test_update_litellm_params_health_check_reasoning_effort():
|
||||
"""model_info.health_check_reasoning_effort sets reasoning_effort for chat-style health checks."""
|
||||
model_info = {"health_check_reasoning_effort": "low"}
|
||||
|
||||
Reference in New Issue
Block a user