litellm/tests/unit/llms/test_predibase_transformation.py
Mateo Wang 66c8f5b1a9
fix(llms): join api_base and route with one shared helper (#47535)
* fix(llms): sanitize trailing slashes in provider get_complete_url

* test(llms): move trailing-slash url tests into each provider's mapped unit file

* fix(llms): strip trailing slashes in Perplexity search and Ollama embedding URLs and run the Topaz tests in CI

* fix(llms): strip trailing slashes in search, DashScope, VolcEngine, GigaChat and Gradient AI URL builders

* fix(fastcrw): strip a trailing slash from api_base and expect one slash in the GigaChat tests

* fix(gigachat): strip a trailing slash from the pass-through api_base

* fix(llms): join api_base and route with one shared helper

A deployment api_base that ends in / made several get_complete_url builders emit a double slash, which Envoy and other gateways answer with 404. Add join_api_base in url_utils and use it in those builders, with a regression test that drives every get_complete_url under litellm/llms with a trailing-slash api_base

* refactor(llms): move per-provider api_base strips onto join_api_base

Use the shared join_api_base helper in the search, chat, embedding, image variation, GigaChat and VolcEngine URL builders instead of a per-provider rstrip and suffix check, so one helper owns the single-slash join

* ci: run the trailing-slash URL builder test in the llms shard

* test(integration): cover trailing-slash api_base across provider URL builders

Wire cells for chat, streaming, messages, responses, embeddings, search and pass-through with and without a trailing slash, an owned-proxy module for the flag-gated providers, env bases and a worker kill, two chaos bursts, and a Topaz SDK cell

* fix(llms): send every provider route through join_api_base

Every place under litellm/llms and litellm/main.py that joined a caller-settable api_base with a route by concatenation or f-string now goes through join_api_base, so an api_base with a trailing slash sends the same upstream path as one without it

Routes made only of a caller-supplied id, model, or passthrough path stay on a plain join over an already normalized base, because the helper's suffix dedup would turn a lookup by id into a list or create call

The sweep test now also reaches the factory-made JSON provider builders, and a new loopback test drives the public entrypoints with an api_base ending in /gw/ and checks that every request path keeps /gw/ with no double slash

* fix(llms): keep the route skip only where a builder already had it

join_api_base is a pure join again. The new ensure_api_route skips the route only when the URL path
already ends in it, and it is used only where the merge base or the trailing-slash test PR head
already skipped. Evals cancel, Predibase, and Azure AI threads append their route again, EdenAI
model listing and Gemini RAG ingestion join through join_api_base, and Azure AI Anthropic checks the
path so a host named anthropic-* builds /anthropic/v1/messages

* fix(azure_ai): read /anthropic from the URL path when building the count_tokens URL

* test(llms): run the Gemini RAG trailing-slash case through aingest

The sync ingest wrapper needs a current event loop, which an earlier async test in the same worker clears

* test(llms): cover the Azure realtime, Bedrock rerank, and oobabooga joins with a trailing-slash api_base

* test(integration): cover api_base joins across providers, query strings and named hosts

* feat(url_utils): add join_api_base and ensure_api_route helpers

* fix(url_utils): keep the scheme slashes when a stripped suffix was the whole host

join_api_base and ensure_api_route now keep the // of a base left with only a scheme,
so http://v1 with /v1 stripped joins to http://v1/files as before. The Azure v1 client
base_url goes through ensure_api_route so a base ending in /openai/v1 no longer doubles it

* fix(azure_ai): build the count_tokens URL from the messages URL

* fix(azure_ai): match anthropic as a whole path segment in the messages URL

* fix(llms): keep main's Azure AI anthropic match and non-string Bedrock endpoints

Azure AI chat and messages go back to cutting the base at the first
/anthropic, as on main, and count_tokens keeps its path check, so a
gateway path like /anthropic-proxy builds the same URLs as main. A base
that already ends in /v1/messages still gets count_tokens next to it.

A non-string aws_bedrock_runtime_endpoint (an httpx.URL VPC endpoint) is
used as its text again in AgentCore, Bedrock responses and the RAGFlow
base, instead of falling back to the default host.

get_volcengine_base_url and GigaChat's get_api_base strip a trailing
slash again for outside callers. The cache-hit wire test is skipped as a
known proxy bug, since one cache hit writes two cache_hit spend rows.

* fix(llms): drop a slash before the api_base query and tighten the route test checks

* test(llms): annotate new test locals as Final and flatten the builder generator

* fix(url_utils): keep a scheme-only api_base hostless so it fails before sending

---------

Co-authored-by: Raushankumar0720 <raushan.singh.cg@gmail.com>
2026-10-21 06:59:32 +00:00

641 lines
21 KiB
Python

from typing import Final
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
import respx
import litellm
from litellm.llms.predibase.chat.handler import PredibaseChatCompletion
from litellm.llms.predibase.chat.transformation import PredibaseConfig
from litellm.llms.predibase.common_utils import PredibaseError
from litellm.utils import Choices, Message, ModelResponse
def _build_model_response() -> ModelResponse:
return ModelResponse(
choices=[
Choices(
finish_reason=None,
index=0,
message=Message(role="assistant", content=""),
)
]
)
def test_predibase_transform_request_non_stream():
config = PredibaseConfig()
request_data = config.transform_request(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
optional_params={"temperature": 0.2},
litellm_params={},
headers={},
)
assert request_data["inputs"]
assert request_data["parameters"]["temperature"] == 0.2
assert request_data["parameters"]["details"] is True
assert "stream" not in request_data["parameters"]
def test_predibase_transform_request_custom_prompt():
config = PredibaseConfig()
request_data = config.transform_request(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={
"custom_prompt_dict": {
"predibase-model": {
"roles": {"user": {"pre_message": "<user>", "post_message": "</user>"}},
"initial_prompt_value": "<s>",
"final_prompt_value": "</s>",
}
}
},
headers={},
)
assert request_data["inputs"] == "<s><user>hello</user></s>"
def test_predibase_get_complete_url_stream_and_non_stream():
config = PredibaseConfig()
litellm_params = {"predibase_tenant_id": "tenant-123"}
non_stream_url = config.get_complete_url(
api_base="https://serving.example.com",
api_key="test-key",
model="predibase-model",
optional_params={"stream": False},
litellm_params=litellm_params,
)
stream_url = config.get_complete_url(
api_base="https://serving.example.com",
api_key="test-key",
model="predibase-model",
optional_params={"stream": True},
litellm_params=litellm_params,
)
assert non_stream_url.endswith("/generate")
assert stream_url.endswith("/generate_stream")
@pytest.mark.parametrize(("model", "stream"), [("generate", False), ("generate_stream", True)])
def test_predibase_get_complete_url_appends_the_route_to_a_model_named_like_it(model: str, stream: bool) -> None:
url: Final = PredibaseConfig().get_complete_url(
api_base="https://serving.example.com",
api_key="test-key",
model=model,
optional_params={"stream": stream},
litellm_params={"predibase_tenant_id": "tenant-123"},
)
assert url.endswith(f"/llms/{model}/{model}")
def test_predibase_get_complete_url_missing_tenant_id():
config = PredibaseConfig()
with pytest.raises(ValueError, match="Missing Predibase Tenant ID"):
config.get_complete_url(
api_base=None,
api_key="test-key",
model="predibase-model",
optional_params={},
litellm_params={},
)
def test_predibase_get_complete_url_with_tenant_id_key():
config = PredibaseConfig()
url = config.get_complete_url(
api_base="https://serving.example.com",
api_key="test-key",
model="predibase-model",
optional_params={},
litellm_params={"tenant_id": "tenant-xyz"},
)
assert "tenant-xyz" in url
assert url.endswith("/generate")
def test_predibase_authentication_error_does_not_expose_the_api_key(respx_mock: respx.MockRouter) -> None:
route = respx_mock.post(
"https://serving.app.predibase.com/c4768f95/deployments/v2/llms/llama-3-8b-instruct/generate"
).mock(return_value=httpx.Response(401, json={"message": "invalid API key"}))
with pytest.raises(litellm.AuthenticationError) as exc_info:
litellm.completion(
model="predibase/llama-3-8b-instruct",
messages=[{"role": "user", "content": "What is the meaning of life?"}],
predibase_tenant_id="c4768f95",
api_key="hf-rawapikey",
max_retries=0,
)
assert "hf-rawapikey" not in str(exc_info.value)
assert route.calls.last.request.headers["Authorization"] == "Bearer hf-rawapikey"
def test_predibase_transform_response_success_best_of(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
encoding.encode.return_value = [1, 2, 3]
monkeypatch.setattr("litellm.token_counter", lambda messages: 5)
raw_response = httpx.Response(
status_code=200,
json={
"generated_text": "<|assistant|>primary-output</s>",
"details": {
"finish_reason": "eos_token",
"tokens": [{"logprob": -0.2}, {"logprob": None}],
"best_of_sequences": [
{
"generated_text": "<s>secondary-output</s>",
"finish_reason": "length",
"tokens": [{"logprob": -0.5}],
}
],
},
},
headers={"x-request-id": "req-123"},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {}},
messages=[{"role": "user", "content": "hello"}],
optional_params={"best_of": 2},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
assert result.choices[0].message.content == "primary-output"
assert len(result.choices) == 2
assert result.choices[1].message.content == "secondary-output"
assert result.usage.prompt_tokens == 5
assert result.usage.completion_tokens == 3
assert (
result._hidden_params["additional_headers"]["llm_provider-x-request-id"]
== "req-123"
)
def test_predibase_transform_response_invalid_json():
config = PredibaseConfig()
with pytest.raises(PredibaseError) as exc:
config.transform_response(
model="predibase-model",
raw_response=httpx.Response(status_code=200, content=b"not-json"),
model_response=_build_model_response(),
logging_obj=Mock(),
request_data={},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=Mock(),
api_key="test-key",
)
assert exc.value.status_code == 422
def test_predibase_transform_response_error_field():
config = PredibaseConfig()
with pytest.raises(PredibaseError) as exc:
config.transform_response(
model="predibase-model",
raw_response=httpx.Response(
status_code=400, json={"error": "invalid request"}
),
model_response=_build_model_response(),
logging_obj=Mock(),
request_data={},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=Mock(),
api_key="test-key",
)
assert exc.value.status_code == 400
def test_predibase_transform_response_missing_generated_text():
config = PredibaseConfig()
with pytest.raises(PredibaseError, match="'generated_text' is not a key"):
config.transform_response(
model="predibase-model",
raw_response=httpx.Response(status_code=200, json={"details": {}}),
model_response=_build_model_response(),
logging_obj=Mock(),
request_data={},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=Mock(),
api_key="test-key",
)
def test_predibase_transform_response_non_dict_payload():
config = PredibaseConfig()
raw_response = Mock()
raw_response.text = "[]"
raw_response.status_code = 200
raw_response.headers = {}
raw_response.json.return_value = []
with pytest.raises(PredibaseError, match="'completion_response' is not a dictionary"):
config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=Mock(),
request_data={},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=Mock(),
api_key="test-key",
)
def test_predibase_transform_response_best_of_with_empty_generated_text(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
encoding.encode.return_value = [1]
monkeypatch.setattr("litellm.token_counter", lambda messages: 1)
raw_response = httpx.Response(
status_code=200,
json={
"generated_text": "primary-output",
"details": {
"finish_reason": "stop",
"tokens": [],
"best_of_sequences": [
{
"generated_text": "",
"finish_reason": "length",
"tokens": [],
}
],
},
},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {}},
messages=[{"role": "user", "content": "hello"}],
optional_params={"best_of": 2},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
assert len(result.choices) == 2
assert result.choices[1].message.content is None
def test_predibase_transform_response_best_of_from_request_data(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
encoding.encode.return_value = [1]
monkeypatch.setattr("litellm.token_counter", lambda messages: 1)
raw_response = httpx.Response(
status_code=200,
json={
"generated_text": "primary-output",
"details": {
"finish_reason": "stop",
"tokens": [],
"best_of_sequences": [
{
"generated_text": "secondary-output",
"finish_reason": "length",
"tokens": [],
}
],
},
},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {"best_of": 2}},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
assert len(result.choices) == 2
assert result.choices[1].message.content == "secondary-output"
def test_predibase_transform_response_best_of_invalid_value_falls_back(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
encoding.encode.return_value = [1]
monkeypatch.setattr("litellm.token_counter", lambda messages: 1)
raw_response = httpx.Response(
status_code=200,
json={
"generated_text": "primary-output",
"details": {
"finish_reason": "stop",
"tokens": [],
"best_of_sequences": [
{
"generated_text": "secondary-output",
"finish_reason": "length",
"tokens": [],
}
],
},
},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {}},
messages=[{"role": "user", "content": "hello"}],
optional_params={"best_of": "invalid-int"},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
# Invalid best_of should safely fall back to 0 and not append extra choices.
assert len(result.choices) == 1
assert result.choices[0].message.content == "primary-output"
def test_predibase_transform_response_empty_output_sets_completion_tokens_zero(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
monkeypatch.setattr("litellm.token_counter", lambda messages: 3)
raw_response = httpx.Response(
status_code=200,
json={"generated_text": "", "details": {"tokens": [], "finish_reason": "stop"}},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {}},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
assert result.usage.prompt_tokens == 3
assert result.usage.completion_tokens == 0
def test_predibase_get_complete_url_uses_env_base_url(monkeypatch):
config = PredibaseConfig()
monkeypatch.setenv("PREDIBASE_API_BASE", "https://env.predibase.com")
url = config.get_complete_url(
api_base=None,
api_key="test-key",
model="predibase-model",
optional_params={},
litellm_params={"predibase_tenant_id": "tenant-123"},
)
assert url.startswith("https://env.predibase.com/tenant-123/")
def test_predibase_transform_response_usage_fallbacks(monkeypatch):
config = PredibaseConfig()
logging_obj = Mock()
encoding = Mock()
encoding.encode.side_effect = RuntimeError("encoding failure")
monkeypatch.setattr(
"litellm.token_counter", lambda messages: (_ for _ in ()).throw(RuntimeError())
)
raw_response = httpx.Response(
status_code=200,
json={"generated_text": "ok", "details": {"tokens": [], "finish_reason": "stop"}},
)
result = config.transform_response(
model="predibase-model",
raw_response=raw_response,
model_response=_build_model_response(),
logging_obj=logging_obj,
request_data={"inputs": "hello", "parameters": {}},
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
encoding=encoding,
api_key="test-key",
)
assert result.usage.prompt_tokens == 0
assert result.usage.completion_tokens == 0
@pytest.mark.asyncio
async def test_predibase_async_completion_uses_default_config_when_none(monkeypatch):
handler = PredibaseChatCompletion()
mock_response = httpx.Response(status_code=200, json={"generated_text": "ok"})
async_handler = Mock()
async_handler.post = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.llms.predibase.chat.handler.get_async_httpx_client",
lambda **kwargs: async_handler,
)
default_config = Mock()
default_config.transform_response.return_value = _build_model_response()
monkeypatch.setattr("litellm.PredibaseConfig", lambda: default_config)
result = await handler.async_completion(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
api_base="https://serving.example.com/x/generate",
model_response=_build_model_response(),
print_verbose=Mock(),
encoding=Mock(),
api_key="test-key",
logging_obj=Mock(),
stream=False,
data={"inputs": "hello", "parameters": {}},
optional_params={},
timeout=10,
litellm_params={},
headers={"Authorization": "Bearer test"},
)
assert result is default_config.transform_response.return_value
default_config.transform_response.assert_called_once()
@pytest.mark.asyncio
async def test_predibase_async_completion_uses_passed_config(monkeypatch):
handler = PredibaseChatCompletion()
mock_response = httpx.Response(status_code=200, json={"generated_text": "ok"})
async_handler = Mock()
async_handler.post = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.llms.predibase.chat.handler.get_async_httpx_client",
lambda **kwargs: async_handler,
)
passed_config = Mock()
passed_config.transform_response.return_value = _build_model_response()
result = await handler.async_completion(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
api_base="https://serving.example.com/x/generate",
model_response=_build_model_response(),
print_verbose=Mock(),
encoding=Mock(),
api_key="test-key",
logging_obj=Mock(),
stream=False,
data={"inputs": "hello", "parameters": {}},
optional_params={},
timeout=10,
litellm_params={},
headers={"Authorization": "Bearer test"},
predibase_config=passed_config,
)
assert result is passed_config.transform_response.return_value
passed_config.transform_response.assert_called_once()
def test_predibase_completion_sync_returns_transform_response(monkeypatch):
handler = PredibaseChatCompletion()
expected = _build_model_response()
def fake_validate_environment(self, **kwargs):
return {"Authorization": "Bearer test"}
def fake_get_complete_url(self, **kwargs):
return "https://serving.example.com/tenant/deployments/v2/llms/model/generate"
def fake_transform_request(self, **kwargs):
return {"inputs": "hello", "parameters": {}}
def fake_transform_response(self, **kwargs):
return expected
monkeypatch.setattr(PredibaseConfig, "validate_environment", fake_validate_environment)
monkeypatch.setattr(PredibaseConfig, "get_complete_url", fake_get_complete_url)
monkeypatch.setattr(PredibaseConfig, "transform_request", fake_transform_request)
monkeypatch.setattr(PredibaseConfig, "transform_response", fake_transform_response)
monkeypatch.setattr(
"litellm.module_level_client.post",
lambda *args, **kwargs: httpx.Response(status_code=200, json={"generated_text": "ok"}),
)
result = handler.completion(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
api_base="https://serving.example.com",
custom_prompt_dict={},
model_response=_build_model_response(),
print_verbose=Mock(),
encoding=Mock(),
api_key="test-key",
logging_obj=Mock(),
optional_params={},
litellm_params={},
tenant_id="tenant-123",
timeout=10,
acompletion=False,
)
assert result is expected
def test_predibase_completion_passes_existing_config_to_async_completion(monkeypatch):
handler = PredibaseChatCompletion()
captured = {}
def fake_validate_environment(self, **kwargs):
captured["config_instance"] = self
return {"Authorization": "Bearer test"}
def fake_get_complete_url(self, **kwargs):
return "https://serving.example.com/tenant/deployments/v2/llms/model/generate"
def fake_transform_request(self, **kwargs):
return {"inputs": "hello", "parameters": {}}
def fake_async_completion(**kwargs):
captured["async_kwargs"] = kwargs
return "async-result"
monkeypatch.setattr(PredibaseConfig, "validate_environment", fake_validate_environment)
monkeypatch.setattr(PredibaseConfig, "get_complete_url", fake_get_complete_url)
monkeypatch.setattr(PredibaseConfig, "transform_request", fake_transform_request)
monkeypatch.setattr(handler, "async_completion", fake_async_completion)
result = handler.completion(
model="predibase-model",
messages=[{"role": "user", "content": "hello"}],
api_base="https://serving.example.com",
custom_prompt_dict={},
model_response=_build_model_response(),
print_verbose=Mock(),
encoding=Mock(),
api_key="test-key",
logging_obj=Mock(),
optional_params={},
litellm_params={},
tenant_id="tenant-123",
timeout=10,
acompletion=True,
)
assert result == "async-result"
assert captured["async_kwargs"]["predibase_config"] is captured["config_instance"]