litellm/tests/unit/llms/watsonx/test_watsonx.py
Vineeth Sai Varikuntla a7b66dd8b9
fix(openai): stop stripping cache_control from the caller's own messages and tools (#35602)
* fix(openai): stop stripping cache_control from the caller's messages

Copy before removing the field, so the caller's own message objects are left alone.

* fix(fireworks_ai): strip cache_control on a copy so a fallback keeps the caller's hints

* fix: keep the caller's request intact on more providers and trim extra Claude cache hints under drop_params

* fix: drop only the oldest extra cache hints inside one message when trimming to the Claude limit

* fix: keep replayed session turns yielding their hints as whole turns while a request's own message keeps its newest hints

* fix(cache_control): count only hints that reach Claude and cap hints on OpenAI-compatible Claude routes under drop_params

* fix(cache_control): keep the capped Claude messages a list in OpenAI-compatible request bodies

* fix(caching): cap Claude hints for string drop_params, a Claude base_model, and Converse tool messages

The 4-hint cap now reads drop_params through drops_unsupported_params on chat, Converse, and Anthropic routes, names Claude by base_model on OpenAI-compatible routes, and counts a Converse tool message's hints as the one cache point it sends.

* fix(caching): trim Claude cache hints in one wire-order pass and reserve a top-level cache_control on OpenAI-compatible routes

Dropping the oldest hint one at a time re-walked the whole request per hint, which took seconds on long tool histories. The cap now drops each message's oldest hints in the order the provider counts them, so a string assistant turn loses its text hint before its tool call hints. OpenAI-compatible Claude routes also keep room for a request-level or extra_body cache_control under drop_params.

* test: drop narrating comments and stacked generator clauses from cache_control tests

* fix(vertex_ai): put every system message in the Gemini context cache so generateContent never sends it beside cachedContent

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-31 00:29:10 -07:00

586 lines
21 KiB
Python

import asyncio
import copy
import importlib
import json
from typing import Final, Optional, cast
from unittest.mock import Mock, patch
import httpx
import pytest
import litellm
from litellm import completion, embedding
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.watsonx.chat.transformation import IBMWatsonXChatConfig
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@pytest.mark.parametrize("tokenizer_config_cached", [False, True], ids=["tokenizer_config", "cached_config_jinja"])
async def test_watsonx_text_gpt_oss_async_completion_fetches_hf_template_off_the_event_loop(
monkeypatch, tokenizer_config_cached
):
import httpx
from litellm._uuid import uuid
from litellm.litellm_core_utils.prompt_templates import huggingface_template_handler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
hf_model = f"openai/gpt-oss-{uuid.uuid4()}"
chat_template = "{% for m in messages %}<|{{ m['role'] }}|>{{ m['content'] }}{% endfor %}"
if tokenizer_config_cached:
cached_config = {"status": "success", "tokenizer": {"bos_token": None, "eos_token": None}}
monkeypatch.setattr(litellm, "known_tokenizer_config", {hf_model: cached_config})
expected_fetch = f"https://huggingface.co/{hf_model}/raw/main/chat_template.jinja"
else:
monkeypatch.setattr(litellm, "known_tokenizer_config", {})
expected_fetch = f"https://huggingface.co/{hf_model}/raw/main/tokenizer_config.json"
hf_fetched = []
captured = {}
def forbid_sync_client():
raise AssertionError("sync HuggingFace fetch ran on the request path")
async def serve_hf_file(url, **kwargs):
hf_fetched.append(url)
if url.endswith(".jinja"):
return httpx.Response(200, content=chat_template.encode())
return httpx.Response(200, json={"chat_template": chat_template, "bos_token": None, "eos_token": None})
monkeypatch.setattr(huggingface_template_handler, "get_httpx_client", forbid_sync_client)
monkeypatch.setattr(
huggingface_template_handler, "get_async_httpx_client", lambda **kwargs: Mock(get=serve_hf_file)
)
def handle(request):
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
json={
"model_id": hf_model,
"results": [
{
"generated_text": "Hi",
"generated_token_count": 1,
"input_token_count": 1,
"stop_reason": "eos_token",
}
],
},
)
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
response = await litellm.acompletion(
model=f"watsonx_text/{hf_model}",
messages=[{"role": "user", "content": "Hi there"}],
api_base="https://test-api.watsonx.ai",
project_id="test-project-id",
token="test-token",
client=client,
)
assert response.choices[0].message.content == "Hi"
assert hf_fetched == [expected_fetch]
assert captured["body"]["input"] == "<|user|>Hi there"
@pytest.fixture()
def _vcr_outcome_gate(request, vcr):
install_live_call_probe(request, vcr)
yield
record_vcr_outcome(request, vcr)
@pytest.fixture(scope="session")
def event_loop():
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="function")
def setup_and_teardown(event_loop):
import litellm
original_state = {}
for attr in (
"callbacks",
"success_callback",
"failure_callback",
"_async_success_callback",
"_async_failure_callback",
):
if hasattr(litellm, attr):
val = getattr(litellm, attr)
original_state[attr] = val.copy() if val else []
for attr in _SCALAR_DEFAULTS:
if hasattr(litellm, attr):
original_state[attr] = getattr(litellm, attr)
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
importlib.reload(litellm)
asyncio.set_event_loop(event_loop)
yield
for attr, original_value in original_state.items():
if hasattr(litellm, attr):
setattr(litellm, attr, original_value)
pending = asyncio.all_tasks(event_loop)
for task in pending:
task.cancel()
if pending:
event_loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
_SCALAR_DEFAULTS = {
"num_retries": getattr(litellm, "num_retries", None),
"set_verbose": getattr(litellm, "set_verbose", False),
"cache": getattr(litellm, "cache", None),
"allowed_fails": getattr(litellm, "allowed_fails", 3),
"disable_aiohttp_transport": getattr(litellm, "disable_aiohttp_transport", False),
"force_ipv4": getattr(litellm, "force_ipv4", False),
"drop_params": getattr(litellm, "drop_params", None),
"modify_params": getattr(litellm, "modify_params", False),
"api_base": getattr(litellm, "api_base", None),
"api_key": getattr(litellm, "api_key", None),
"cohere_key": getattr(litellm, "cohere_key", None),
}
@pytest.fixture
def watsonx_env_vars(monkeypatch):
"""Set required WatsonX env vars so the provider passes validation.
Also clear WATSONX_ZENAPIKEY/WATSONX_TOKEN so they don't bypass the IAM token mock.
"""
monkeypatch.setenv("WATSONX_URL", "https://us-south.ml.cloud.ibm.com")
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.delenv("WATSONX_ZENAPIKEY", raising=False)
monkeypatch.delenv("WATSONX_TOKEN", raising=False)
@pytest.fixture
def watsonx_chat_completion_call():
def _call(
model="watsonx/my-test-model",
messages=None,
api_key="test_api_key",
space_id: Optional[str] = None,
headers=None,
client=None,
patch_token_call=True,
):
if messages is None:
messages = [{"role": "user", "content": "Hello, how are you?"}]
if client is None:
client = HTTPHandler()
if patch_token_call:
mock_response = Mock()
mock_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_response.raise_for_status = Mock() # No-op to simulate no exception
with (
patch.object(client, "post") as mock_post,
patch.object(litellm.module_level_client, "post", return_value=mock_response) as mock_get,
):
try:
completion(
model=model,
messages=messages,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, mock_get
else:
with patch.object(client, "post") as mock_post:
try:
completion(
model=model,
messages=messages,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, None
return _call
@pytest.fixture
def watsonx_embedding_call():
def _call(
model="watsonx/my-test-model",
input=None,
api_key="test_api_key",
space_id: Optional[str] = None,
headers=None,
client=None,
patch_token_call=True,
):
if input is None:
input = ["Hello, how are you?"]
if client is None:
client = HTTPHandler()
if patch_token_call:
mock_response = Mock()
mock_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_response.raise_for_status = Mock() # No-op to simulate no exception
with (
patch.object(client, "post") as mock_post,
patch.object(litellm.module_level_client, "post", return_value=mock_response) as mock_get,
):
try:
embedding(
model=model,
input=input,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, mock_get
else:
with patch.object(client, "post") as mock_post:
try:
embedding(
model=model,
input=input,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, None
return _call
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize("with_custom_auth_header", [True, False])
def test_watsonx_custom_auth_header(with_custom_auth_header, watsonx_chat_completion_call):
headers = {"Authorization": "Bearer my-custom-auth-header"} if with_custom_auth_header else {}
mock_post, _ = watsonx_chat_completion_call(headers=headers)
assert mock_post.call_count == 1
if with_custom_auth_header:
assert mock_post.call_args[1]["headers"]["Authorization"] == "Bearer my-custom-auth-header"
else:
assert mock_post.call_args[1]["headers"]["Authorization"] == "Bearer mock_access_token"
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize("env_var_key", ["WATSONX_ZENAPIKEY", "WATSONX_TOKEN"])
def test_watsonx_token_in_env_var(monkeypatch, watsonx_chat_completion_call, env_var_key):
monkeypatch.setenv(env_var_key, "my-custom-token")
mock_post, _ = watsonx_chat_completion_call(patch_token_call=False)
assert mock_post.call_count == 1
if env_var_key == "WATSONX_ZENAPIKEY":
assert mock_post.call_args[1]["headers"]["Authorization"] == "ZenApiKey my-custom-token"
else:
assert mock_post.call_args[1]["headers"]["Authorization"] == "Bearer my-custom-token"
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
def test_watsonx_chat_completions_endpoint(watsonx_chat_completion_call):
model = "watsonx/another-model"
messages = [{"role": "user", "content": "Test message"}]
mock_post, _ = watsonx_chat_completion_call(model=model, messages=messages)
assert mock_post.call_count == 1
assert "deployment" not in mock_post.call_args.kwargs["url"]
@pytest.mark.parametrize("sync_mode", [True])
def test_watsonx_tool_choice(sync_mode: bool, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("WATSONX_API_KEY", "mock-api-key")
monkeypatch.setenv("WATSONX_TOKEN", "mock-watsonx-token")
monkeypatch.setenv("WATSONX_API_BASE", "https://us-south.ml.cloud.ibm.com")
monkeypatch.setenv("WATSONX_PROJECT_ID", "mock-project-id")
model: Final = "watsonx/meta-llama/llama-3-1-8b-instruct"
tools: Final = [
{
"type": "function",
"function": {
"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", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
}
]
messages: Final = [{"role": "user", "content": "What is the weather in San Francisco?"}]
def handle_request(request: httpx.Request) -> httpx.Response:
request_body: Final = cast(dict[str, object], json.loads(request.content))
assert request_body["tool_choice_option"] == "auto"
return httpx.Response(
200,
json={
"model_id": "meta-llama/llama-3-1-8b-instruct",
"results": [
{
"generated_text": "The weather is sunny.",
"generated_token_count": 1,
"input_token_count": 1,
"stop_reason": "eos_token",
}
],
},
request=request,
)
transport: Final = httpx.MockTransport(handle_request)
with httpx.Client(transport=transport) as http_client:
client: Final = HTTPHandler(client=http_client)
response: Final = completion(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
client=client,
)
assert len(response.choices) == 1
async def test_watsonx_async_streamed_tool_choice_reaches_the_stream_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("WATSONX_TOKEN", "mock-watsonx-token")
monkeypatch.setenv("WATSONX_API_BASE", "https://us-south.ml.cloud.ibm.com")
monkeypatch.setenv("WATSONX_PROJECT_ID", "mock-project-id")
stream_chunks: Final = (
{
"id": "chatcmpl-watsonx",
"object": "chat.completion.chunk",
"created": 1760000000,
"model": "ibm/granite-3-8b-instruct",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "chatcmpl-tool-weather",
"type": "function",
"function": {"name": "get_current_weather", "arguments": ""},
}
],
},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-watsonx",
"object": "chat.completion.chunk",
"created": 1760000000,
"model": "ibm/granite-3-8b-instruct",
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [{"index": 0, "function": {"arguments": '{"location": "San Francisco, CA"}'}}]
},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-watsonx",
"object": "chat.completion.chunk",
"created": 1760000000,
"model": "ibm/granite-3-8b-instruct",
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
},
)
body: Final = "".join(
f"id: {index}\nevent: message\ndata: {json.dumps(chunk)}\n\n" for index, chunk in enumerate(stream_chunks)
)
requests: Final[list[httpx.Request]] = []
def handle_request(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"})
client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(handle_request))
response: Final = await litellm.acompletion(
model="watsonx/ibm/granite-3-8b-instruct",
messages=[{"role": "user", "content": "What is the weather in San Francisco?"}],
tools=[
{
"type": "function",
"function": {
"name": "get_current_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
},
}
],
tool_choice="auto",
stream=True,
client=client,
)
chunks: Final = [chunk async for chunk in response]
tool_calls: Final = tuple(
chunk.choices[0].delta.tool_calls[0] for chunk in chunks if chunk.choices[0].delta.tool_calls
)
request_body: Final = cast(dict[str, object], json.loads(requests[0].content))
assert len(requests) == 1
assert requests[0].url.path == "/ml/v1/text/chat_stream"
assert request_body["tool_choice_option"] == "auto"
assert request_body["stream"] is True
assert tool_calls[0].id == "chatcmpl-tool-weather"
assert tool_calls[0].function.name == "get_current_weather"
assert json.loads("".join(tool_call.function.arguments or "" for tool_call in tool_calls)) == {
"location": "San Francisco, CA"
}
assert chunks[-1].choices[0].finish_reason == "tool_calls"
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
def test_watsonx_chat_completions_endpoint_space_id(monkeypatch, watsonx_chat_completion_call):
my_fake_space_id = "xxx-xxx-xxx-xxx-xxx"
monkeypatch.setenv("WATSONX_SPACE_ID", my_fake_space_id)
monkeypatch.delenv("WATSONX_PROJECT_ID", raising=False)
model = "watsonx/another-model"
messages = [{"role": "user", "content": "Test message"}]
mock_post, _ = watsonx_chat_completion_call(model=model, messages=messages)
assert mock_post.call_count == 1
assert "deployment" not in mock_post.call_args.kwargs["url"]
json_data = json.loads(mock_post.call_args.kwargs["data"])
assert my_fake_space_id == json_data["space_id"]
assert not json_data.get("project_id")
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize(
"model",
[
"watsonx/deployment/<xxxx.xxx.xxx.xxxx>",
"watsonx_text/deployment/<xxxx.xxx.xxx.xxxx>",
],
)
def test_watsonx_deployment_space_id(monkeypatch, watsonx_chat_completion_call, model):
my_fake_space_id = "xxx-xxx-xxx-xxx-xxx"
monkeypatch.setenv("WATSONX_SPACE_ID", my_fake_space_id)
mock_post, _ = watsonx_chat_completion_call(
model=model,
messages=[{"content": "Hello, how are you?", "role": "user"}],
)
assert mock_post.call_count == 1
json_data = json.loads(mock_post.call_args.kwargs["data"])
assert my_fake_space_id not in json_data
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize(
"model",
[
"watsonx/deployment/<xxxx.xxx.xxx.xxxx>",
"watsonx_text/deployment/<xxxx.xxx.xxx.xxxx>",
],
)
def test_watsonx_deployment(watsonx_chat_completion_call, model):
messages = [{"content": "Hello, how are you?", "role": "user"}]
mock_post, _ = watsonx_chat_completion_call(
model=model,
messages=messages,
)
assert mock_post.call_count == 1
json_data = json.loads(mock_post.call_args.kwargs["data"])
# nor space_id or project_id is required by wx.ai API when inferencing deployment
assert "project_id" not in json_data and "space_id" not in json_data
@pytest.mark.usefixtures("watsonx_env_vars", "_vcr_outcome_gate", "setup_and_teardown")
def test_watsonx_deployment_space_id_embedding(monkeypatch, watsonx_embedding_call):
my_fake_space_id = "xxx-xxx-xxx-xxx-xxx"
monkeypatch.setenv("WATSONX_SPACE_ID", my_fake_space_id)
mock_post, _ = watsonx_embedding_call(model="watsonx/deployment/my-test-model")
assert mock_post.call_count == 1
json_data = json.loads(mock_post.call_args.kwargs["data"])
# nor space_id or project_id is required by wx.ai API when inferencing deployment
assert "project_id" not in json_data and "space_id" not in json_data
def _strict_weather_tool() -> dict[str, object]:
return {
"type": "function",
"function": {
"name": "get_weather",
"strict": True,
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
},
}
def test_watsonx_chat_tools_are_rewritten_without_touching_the_callers_tool() -> None:
tools: Final = [_strict_weather_tool()]
tools_before: Final = copy.deepcopy(tools)
optional_params: Final = IBMWatsonXChatConfig().map_openai_params(
non_default_params={"tools": tools}, optional_params={}, model="ibm/granite-4-h-small", drop_params=False
)
assert optional_params["tools"] == [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
]
assert tools == tools_before