chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:44 +08:00
commit 5a558eb09e
11579 changed files with 1795921 additions and 0 deletions
@@ -0,0 +1 @@
"""Unit tests for evaluation models."""
@@ -0,0 +1,944 @@
import json
import sys
import types
from contextlib import asynccontextmanager, contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, AsyncMock
import pydantic
import pytest
from opik.evaluation.models import models_factory
from opik.evaluation.models import base_model
from opik.evaluation.models.anthropic import anthropic_chat_model
from opik.evaluation.models.anthropic import message_adapter, response_parser
class SampleFormat(pydantic.BaseModel):
score: int
reason: str
def _make_text_response(text="ok"):
block = SimpleNamespace(type="text", text=text)
return SimpleNamespace(content=[block])
def _make_tool_use_response(data, *, block_id="call_1", name="json_tool_call"):
block = SimpleNamespace(type="tool_use", id=block_id, name=name, input=data)
return SimpleNamespace(content=[block])
def _install_anthropic_stub(monkeypatch):
stub = types.ModuleType("anthropic")
mock_messages = MagicMock()
mock_messages.create = MagicMock(return_value=_make_text_response())
mock_client = MagicMock()
mock_client.messages = mock_messages
async_mock_messages = MagicMock()
async_mock_messages.create = AsyncMock(return_value=_make_text_response())
async_mock_client = MagicMock()
async_mock_client.messages = async_mock_messages
stub.Anthropic = MagicMock(return_value=mock_client)
stub.AsyncAnthropic = MagicMock(return_value=async_mock_client)
monkeypatch.setitem(sys.modules, "anthropic", stub)
return stub, mock_client, async_mock_client
@pytest.fixture(autouse=True)
def _clear_model_cache():
models_factory._MODEL_CACHE.clear()
yield
models_factory._MODEL_CACHE.clear()
class TestResponseParser:
def test_parses_text_response(self):
message = response_parser.parse_assistant_message(
_make_text_response("hello world")
)
assert message == {"role": "assistant", "content": "hello world"}
def test_concatenates_multiple_text_blocks(self):
response = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="hello "),
SimpleNamespace(type="text", text="world"),
]
)
message = response_parser.parse_assistant_message(response)
assert message["content"] == "hello world"
def test_promotes_single_tool_use_arguments_into_content(self):
data = {"score": 10, "reason": "good"}
message = response_parser.parse_assistant_message(_make_tool_use_response(data))
assert message["role"] == "assistant"
assert "tool_calls" not in message
assert json.loads(message["content"]) == data
def test_emits_tool_calls_when_text_and_tool_use_coexist(self):
response = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="picking a tool"),
SimpleNamespace(
type="tool_use",
id="call_42",
name="web_search",
input={"query": "capital of France"},
),
]
)
message = response_parser.parse_assistant_message(response)
assert message["content"] == "picking a tool"
assert message["tool_calls"] == [
{
"id": "call_42",
"type": "function",
"function": {
"name": "web_search",
"arguments": json.dumps({"query": "capital of France"}),
},
}
]
def test_raises_when_no_text_and_no_tool_use(self):
from opik import exceptions
response = SimpleNamespace(content=[])
with pytest.raises(exceptions.BaseLLMError):
response_parser.parse_assistant_message(response)
def test_keeps_registered_tool_use_as_tool_call(self):
"""Regression: with `output_format` set, Anthropic emits the
structured-output finalizer as a `tool_use` block too. Without
disambiguation we'd misclassify a *real* registered-tool call
(e.g. `read`) as the finalizer and promote its arguments to
`content`, leaving the agentic loop with nothing to execute.
Passing `registered_tool_names` lets the parser tell them apart.
"""
response = SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="call_42",
name="read",
input={"type": "trace", "id": "t-1"},
),
]
)
message = response_parser.parse_assistant_message(
response, registered_tool_names=["read", "scan", "search"]
)
assert message["role"] == "assistant"
assert "content" not in message
assert message["tool_calls"] == [
{
"id": "call_42",
"type": "function",
"function": {
"name": "read",
"arguments": json.dumps({"type": "trace", "id": "t-1"}),
},
}
]
def test_promotes_unknown_tool_use_when_tools_registered(self):
"""Counterpart to the previous test: when the single tool_use's
name is NOT in the registered set, treat it as the structured-
output finalizer (Anthropic's name for it varies by SDK version,
but it's always not one of the user's tools).
"""
data = {"score": 10, "reason": "good"}
message = response_parser.parse_assistant_message(
_make_tool_use_response(data),
registered_tool_names=["read", "scan", "search"],
)
assert "tool_calls" not in message
assert json.loads(message["content"]) == data
class TestMessageAdapter:
def test_extracts_system_messages(self):
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
system_text, non_system = message_adapter.extract_system_messages(messages)
assert system_text == "You are helpful."
assert len(non_system) == 1
assert non_system[0]["role"] == "user"
def test_multiple_system_messages(self):
messages = [
{"role": "system", "content": "Part 1"},
{"role": "system", "content": "Part 2"},
{"role": "user", "content": "Hello"},
]
system_text, non_system = message_adapter.extract_system_messages(messages)
assert system_text == "Part 1\n\nPart 2"
assert len(non_system) == 1
def test_no_system_messages(self):
messages = [{"role": "user", "content": "Hello"}]
system_text, non_system = message_adapter.extract_system_messages(messages)
assert system_text is None
assert len(non_system) == 1
def test_converts_pydantic_model_to_output_config(self):
config = message_adapter.pydantic_to_output_config(SampleFormat)
assert config["format"]["type"] == "json_schema"
schema = config["format"]["schema"]
assert "score" in schema["properties"]
assert "reason" in schema["properties"]
assert "title" not in schema
def test_strips_prefix(self):
assert (
message_adapter.strip_anthropic_prefix("anthropic/claude-sonnet-4-20250514")
== "claude-sonnet-4-20250514"
)
def test_no_prefix(self):
assert (
message_adapter.strip_anthropic_prefix("claude-sonnet-4-20250514")
== "claude-sonnet-4-20250514"
)
def test_filter_unsupported_params_drops_openai_specific(self):
warned: set = set()
result = message_adapter.filter_unsupported_params(
{"temperature": 0.5, "logprobs": True, "top_logprobs": 20, "top_p": 0.9},
warned,
)
assert result == {"temperature": 0.5, "top_p": 0.9}
assert "logprobs" in warned
assert "top_logprobs" in warned
def test_filter_unsupported_params_warns_once(self):
warned: set = set()
message_adapter.filter_unsupported_params({"logprobs": True}, warned)
message_adapter.filter_unsupported_params({"logprobs": True}, warned)
assert warned == {"logprobs"}
def test_normalize_tool_choice_translates_openai_strings(self):
# OpenAI-style string forms map to Anthropic's object form.
assert message_adapter.normalize_tool_choice("auto") == {"type": "auto"}
assert message_adapter.normalize_tool_choice("none") == {"type": "none"}
# "required" → "any" (Anthropic's name for "force *some* tool").
assert message_adapter.normalize_tool_choice("required") == {"type": "any"}
def test_normalize_tool_choice_translates_openai_function_object(self):
# OpenAI "force this specific function" → Anthropic "force this tool".
translated = message_adapter.normalize_tool_choice(
{"type": "function", "function": {"name": "read"}}
)
assert translated == {"type": "tool", "name": "read"}
def test_normalize_tool_choice_passes_through_anthropic_native_shape(self):
# Already-correct Anthropic forms shouldn't be touched.
assert message_adapter.normalize_tool_choice({"type": "auto"}) == {
"type": "auto"
}
assert message_adapter.normalize_tool_choice(
{"type": "tool", "name": "read"}
) == {"type": "tool", "name": "read"}
def test_normalize_tool_choice_passes_through_unknown_values(self):
# Unrecognized strings or shapes pass through unchanged so the
# Anthropic SDK can surface the error rather than us silently
# dropping the field.
assert message_adapter.normalize_tool_choice("bogus") == "bogus"
assert message_adapter.normalize_tool_choice(
{"type": "function"} # missing function.name
) == {"type": "function"}
def test_normalize_tools_translates_openai_function_specs(self):
openai_spec = {
"type": "function",
"function": {
"name": "read",
"description": "Fetch a trace by id.",
"parameters": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
}
translated = message_adapter.normalize_tools([openai_spec])
assert translated == [
{
"type": "custom",
"name": "read",
"description": "Fetch a trace by id.",
"input_schema": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
}
]
def test_normalize_tools_passes_through_native_anthropic_specs(self):
# Hand-rolled Anthropic-native specs (no `type=function` wrapper)
# must not be rewritten — we have no information to safely map
# them, and rewriting would corrupt a working spec.
native = {
"type": "custom",
"name": "scan",
"description": "Evaluate a jq path.",
"input_schema": {"type": "object"},
}
assert message_adapter.normalize_tools([native]) == [native]
def test_normalize_tools_passes_through_malformed_specs(self):
# No function.name → unrecognizable; pass through so the SDK
# surfaces the error rather than us masking it.
malformed = {"type": "function", "function": {"description": "x"}}
assert message_adapter.normalize_tools([malformed]) == [malformed]
def test_normalize_tools_passes_through_non_list_input(self):
# `None` (or any non-list sentinel) should not blow up — just
# return it so the SDK's own validation handles it.
assert message_adapter.normalize_tools(None) is None
def test_extract_tool_names_handles_openai_shape(self):
tools = [
{"type": "function", "function": {"name": "read"}},
{"type": "function", "function": {"name": "scan"}},
]
assert message_adapter.extract_tool_names(tools) == ["read", "scan"]
def test_extract_tool_names_handles_anthropic_native_shape(self):
# After `normalize_tools` runs, names live at the top level.
# `extract_tool_names` must handle both shapes so it stays
# usable on either side of normalization.
tools = [
{"type": "custom", "name": "read"},
{"type": "custom", "name": "search"},
]
assert message_adapter.extract_tool_names(tools) == ["read", "search"]
def test_extract_tool_names_skips_malformed_entries(self):
tools = [
{"type": "function"}, # missing function dict
{"type": "function", "function": {"description": "x"}}, # no name
{"type": "custom", "name": "read"}, # well-formed
"not a dict", # ignored
]
assert message_adapter.extract_tool_names(tools) == ["read"]
def test_extract_tool_names_returns_empty_for_non_list(self):
assert message_adapter.extract_tool_names(None) == []
assert message_adapter.extract_tool_names("nope") == []
def test_normalize_messages_passes_through_plain_history(self):
# User + plain assistant text → no shape change beyond the
# `tool_calls=None` cleanup that pop'd through the loop.
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
assert message_adapter.normalize_messages(messages) == messages
def test_normalize_messages_converts_assistant_tool_calls_to_blocks(self):
# Assistant message with one tool_call → assistant message
# whose content is a list of blocks (no leading text block
# when `content` is empty/None).
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read",
"arguments": json.dumps({"type": "trace", "id": "t-1"}),
},
}
],
}
]
assert message_adapter.normalize_messages(messages) == [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "read",
"input": {"type": "trace", "id": "t-1"},
}
],
}
]
def test_normalize_messages_keeps_leading_text_block(self):
# Assistant emits text alongside the tool_use — both blocks
# must appear, text first.
messages = [
{
"role": "assistant",
"content": "let me check",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read",
"arguments": "{}",
},
}
],
}
]
normalized = message_adapter.normalize_messages(messages)
assert normalized[0]["content"] == [
{"type": "text", "text": "let me check"},
{"type": "tool_use", "id": "call_1", "name": "read", "input": {}},
]
def test_normalize_messages_translates_tool_role_to_user_tool_result(self):
messages = [
{
"role": "tool",
"tool_call_id": "call_1",
"content": "{'data': 'value'}",
}
]
assert message_adapter.normalize_messages(messages) == [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "{'data': 'value'}",
}
],
}
]
def test_normalize_messages_coalesces_consecutive_tool_messages(self):
# Two tool replies in a row must end up as a single user
# message with two tool_result blocks — Anthropic rejects
# split tool_result responses when the prior assistant turn
# emitted multiple tool_use blocks.
messages = [
{"role": "tool", "tool_call_id": "call_1", "content": "result-1"},
{"role": "tool", "tool_call_id": "call_2", "content": "result-2"},
]
assert message_adapter.normalize_messages(messages) == [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "result-1",
},
{
"type": "tool_result",
"tool_use_id": "call_2",
"content": "result-2",
},
],
}
]
def test_normalize_messages_coerces_non_object_arguments_to_empty_dict(self):
"""Anthropic's `tool_use.input` is specified as a JSON object.
OpenAI's `arguments` is *almost* always a stringified dict, but
a malformed model output (top-level array, scalar, null, or
non-JSON text) could leak a non-dict value through. The
translator must coerce those to `{}` so we hit the SDK's own
schema validation instead of a generic 400 from the API.
"""
# Top-level JSON list → empty dict.
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read",
"arguments": json.dumps([1, 2, 3]),
},
}
],
}
]
normalized = message_adapter.normalize_messages(messages)
assert normalized[0]["content"][0]["input"] == {}
# Top-level scalar → empty dict.
messages[0]["tool_calls"][0]["function"]["arguments"] = json.dumps(42)
assert (
message_adapter.normalize_messages(messages)[0]["content"][0]["input"] == {}
)
# Null → empty dict.
messages[0]["tool_calls"][0]["function"]["arguments"] = "null"
assert (
message_adapter.normalize_messages(messages)[0]["content"][0]["input"] == {}
)
# Non-JSON text → empty dict.
messages[0]["tool_calls"][0]["function"]["arguments"] = "not json"
assert (
message_adapter.normalize_messages(messages)[0]["content"][0]["input"] == {}
)
# Already-a-list (not a string) → empty dict too — we only
# forward dict-shaped values.
messages[0]["tool_calls"][0]["function"]["arguments"] = [1, 2]
assert (
message_adapter.normalize_messages(messages)[0]["content"][0]["input"] == {}
)
def test_normalize_messages_full_round_trip(self):
# End-to-end: a typical agentic-loop history with one
# round-trip should land in valid Anthropic shape.
messages = [
{"role": "user", "content": "find the marker"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read",
"arguments": json.dumps({"type": "trace", "id": "t-1"}),
},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "MARKER-XYZ-987"},
]
normalized = message_adapter.normalize_messages(messages)
# User → unchanged.
assert normalized[0] == {"role": "user", "content": "find the marker"}
# Assistant → tool_use content block.
assert normalized[1]["role"] == "assistant"
assert normalized[1]["content"][0]["type"] == "tool_use"
# Tool result → user message with tool_result block.
assert normalized[2]["role"] == "user"
assert normalized[2]["content"][0]["type"] == "tool_result"
assert normalized[2]["content"][0]["tool_use_id"] == "call_1"
class TestAnthropicChatModelGenerateString:
def test_generate_string_text(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
@contextmanager
def fake_provider_response(model_provider, messages, **kwargs):
yield _make_text_response("test output")
monkeypatch.setattr(base_model, "get_provider_response", fake_provider_response)
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
result = model.generate_string("hello")
assert result == "test output"
def test_generate_string_with_response_format(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
json_text = json.dumps({"score": 10, "reason": "good"})
@contextmanager
def fake_provider_response(model_provider, messages, **kwargs):
yield _make_text_response(json_text)
monkeypatch.setattr(base_model, "get_provider_response", fake_provider_response)
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
result = model.generate_string("hello", response_format=SampleFormat)
parsed = json.loads(result)
assert parsed["score"] == 10
assert parsed["reason"] == "good"
class TestAnthropicChatModelProviderResponse:
def test_passes_system_as_top_level_param(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
messages = [
{"role": "system", "content": "Be helpful"},
{"role": "user", "content": "Hello"},
]
model.generate_provider_response(messages)
call_kwargs = mock_client.messages.create.call_args
assert call_kwargs.kwargs["system"] == "Be helpful"
assert all(m["role"] != "system" for m in call_kwargs.kwargs["messages"])
def test_response_format_uses_parse(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
mock_client.messages.parse = MagicMock(return_value=_make_text_response())
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
messages = [{"role": "user", "content": "Score this"}]
model.generate_provider_response(messages, response_format=SampleFormat)
mock_client.messages.parse.assert_called_once()
call_kwargs = mock_client.messages.parse.call_args.kwargs
assert call_kwargs["output_format"] is SampleFormat
assert "tools" not in call_kwargs
assert "tool_choice" not in call_kwargs
def test_default_max_tokens(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
model.generate_provider_response([{"role": "user", "content": "hi"}])
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["max_tokens"] == 4096
def test_strips_anthropic_prefix_in_api_call(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
model.generate_provider_response([{"role": "user", "content": "hi"}])
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["model"] == "claude-sonnet-4-20250514"
def test_constructor_tools_feed_response_parser_disambiguation(self, monkeypatch):
"""Regression: when `tools` is supplied only at construction time
(the agentic loop's default path through the factory), the
response parser still needs the registered names to tell a real
`read` tool call from the structured-output finalizer. Looking
only at the per-call `kwargs` (as the original code did) would
miss constructor-time tools and leave the parser blind.
"""
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
mock_client.messages.parse = MagicMock(
return_value=SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="call_42",
name="read",
input={"type": "trace", "id": "t-1"},
)
]
)
)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514",
track=False,
tools=[
{
"type": "function",
"function": {
"name": "read",
"description": "Fetch a trace.",
"parameters": {"type": "object", "properties": {}},
},
},
],
)
# Per-call `tools` omitted on purpose — the constructor-time
# list must still reach the parser.
message = model.generate_chat_completion(
messages=[{"role": "user", "content": "go"}],
response_format=SampleFormat,
)
# If the parser got the registered names, the `read` tool_use
# block stays as a tool_call. Without them, it would have been
# promoted to `content` and the test would see `tool_calls`
# missing.
assert message["tool_calls"] == [
{
"id": "call_42",
"type": "function",
"function": {
"name": "read",
"arguments": json.dumps({"type": "trace", "id": "t-1"}),
},
}
]
def test_per_call_tools_override_constructor_tools_for_parser(self, monkeypatch):
"""Per-call `tools` replace constructor-time `tools` in
`_build_call_kwargs` (last-write-wins merge). The names the
parser sees must follow the same precedence — otherwise a
caller who narrows tools per-call could still get a tool_use
block misclassified because the parser was keyed on the wider
constructor set, or vice versa.
"""
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
# Response uses the per-call tool name (`scan`), not the
# constructor name (`read`) — proves the per-call list wins.
mock_client.messages.create = MagicMock(
return_value=SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="call_99",
name="scan",
input={"path": "$.trace"},
)
]
)
)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514",
track=False,
tools=[
{
"type": "function",
"function": {
"name": "read",
"description": "Fetch a trace.",
"parameters": {"type": "object"},
},
},
],
)
message = model.generate_chat_completion(
messages=[{"role": "user", "content": "go"}],
tools=[
{
"type": "function",
"function": {
"name": "scan",
"description": "Evaluate a jq path.",
"parameters": {"type": "object"},
},
},
],
)
# `scan` (the per-call tool) is in the registered set the
# parser saw, so the response's `scan` tool_use stays as a
# tool call. If the precedence was wrong, the parser would have
# seen only `read` and promoted `scan` to content.
assert message.get("tool_calls", [])[0]["function"]["name"] == "scan"
class TestParamFiltering:
def test_filters_unsupported_constructor_kwargs(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514",
track=False,
temperature=0.5,
logprobs=True,
top_logprobs=20,
frequency_penalty=0.1,
)
assert model._completion_kwargs["temperature"] == 0.5
assert "logprobs" not in model._completion_kwargs
assert "top_logprobs" not in model._completion_kwargs
assert "frequency_penalty" not in model._completion_kwargs
def test_normalizes_constructor_tools_into_anthropic_shape(self, monkeypatch):
"""Regression: OpenAI-shape `tools` passed at construction time
(e.g. via `AnthropicChatModel(tools=[...])` or the factory)
must be normalized before they're merged into per-call kwargs.
Without this, the per-call normalization in `_build_call_kwargs`
only catches `tools` arriving as call-time kwargs, and the
constructor-time list reaches the Anthropic SDK unchanged.
"""
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514",
track=False,
tools=[
{
"type": "function",
"function": {
"name": "read",
"description": "Fetch a trace.",
"parameters": {"type": "object", "properties": {}},
},
},
],
)
model.generate_provider_response([{"role": "user", "content": "hi"}])
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["tools"] == [
{
"type": "custom",
"name": "read",
"description": "Fetch a trace.",
"input_schema": {"type": "object", "properties": {}},
}
]
def test_normalizes_constructor_tool_choice_into_anthropic_shape(self, monkeypatch):
# Companion to the `tools` test: this is the existing
# constructor-side normalization for `tool_choice`. Pinning it
# here so future refactors that reshuffle the __init__ order
# don't silently regress the pairing.
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514",
track=False,
tool_choice="auto",
)
assert model._completion_kwargs["tool_choice"] == {"type": "auto"}
def test_filters_unsupported_per_call_kwargs(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
model.generate_provider_response(
[{"role": "user", "content": "hi"}],
logprobs=True,
top_logprobs=20,
temperature=0.7,
)
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["temperature"] == 0.7
assert "logprobs" not in call_kwargs
assert "top_logprobs" not in call_kwargs
def test_keeps_all_valid_anthropic_params(self, monkeypatch):
_, mock_client, _ = _install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
model.generate_provider_response(
[{"role": "user", "content": "hi"}],
temperature=0.5,
top_p=0.9,
top_k=40,
stop_sequences=["END"],
)
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["temperature"] == 0.5
assert call_kwargs["top_p"] == 0.9
assert call_kwargs["top_k"] == 40
assert call_kwargs["stop_sequences"] == ["END"]
class TestFactoryRouting:
def test_factory_routes_anthropic_prefix(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = models_factory.get("anthropic/claude-sonnet-4-20250514", track=False)
assert isinstance(model, anthropic_chat_model.AnthropicChatModel)
def test_factory_routes_bare_claude_name(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
model = models_factory.get("claude-sonnet-4-20250514", track=False)
assert isinstance(model, anthropic_chat_model.AnthropicChatModel)
def test_factory_does_not_route_non_anthropic(self, monkeypatch):
litellm_stub = types.ModuleType("litellm")
litellm_stub.suppress_debug_info = False
def completion(model, messages, **kwargs):
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]
)
litellm_stub.completion = completion
litellm_stub.acompletion = completion
litellm_stub.get_supported_openai_params = lambda model: [
"temperature",
"response_format",
]
litellm_stub.get_llm_provider = lambda model: ("openai", "openai")
litellm_stub.utils = SimpleNamespace(UnsupportedParamsError=Exception)
litellm_stub.exceptions = SimpleNamespace(BadRequestError=Exception)
litellm_stub.callbacks = []
monkeypatch.setitem(sys.modules, "litellm", litellm_stub)
litellm_integration_stub = types.ModuleType("opik.integrations.litellm")
litellm_integration_stub.track_completion = lambda **kw: (lambda f: f)
monkeypatch.setitem(
sys.modules, "opik.integrations.litellm", litellm_integration_stub
)
model = models_factory.get("gpt-4o", track=False)
from opik.evaluation.models.litellm.litellm_chat_model import LiteLLMChatModel
assert isinstance(model, LiteLLMChatModel)
@pytest.mark.asyncio
class TestAnthropicChatModelAsync:
async def test_agenerate_string(self, monkeypatch):
_install_anthropic_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "false")
@asynccontextmanager
async def fake_aget_provider_response(model_provider, messages, **kwargs):
yield _make_text_response("async result")
monkeypatch.setattr(
base_model, "aget_provider_response", fake_aget_provider_response
)
model = anthropic_chat_model.AnthropicChatModel(
model_name="anthropic/claude-sonnet-4-20250514", track=False
)
result = await model.agenerate_string("hello async")
assert result == "async result"
@@ -0,0 +1,878 @@
import json
import logging
import sys
import types
from contextlib import asynccontextmanager, contextmanager
from types import SimpleNamespace
import pytest
from opik.evaluation.metrics.llm_judges import g_eval
from opik.evaluation.models import models_factory
from opik.evaluation.models.litellm import litellm_chat_model, response_parser
from opik.evaluation.models import base_model
def _install_litellm_stub(monkeypatch, *, supported_params=None):
stub_module = types.ModuleType("litellm")
stub_module.suppress_debug_info = False
stub_module._calls = []
if supported_params is None:
supported_params = ["temperature", "response_format"]
def completion(model, messages, **kwargs):
stub_module._calls.append((model, messages, kwargs))
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]
)
async def acompletion(model, messages, **kwargs):
# Async version for testing
return await completion(model, messages, **kwargs)
def get_supported_openai_params(model):
return list(supported_params)
def get_llm_provider(model):
return ("openai", "openai")
stub_module.completion = completion
stub_module.acompletion = acompletion
stub_module.get_supported_openai_params = get_supported_openai_params
stub_module.get_llm_provider = get_llm_provider
stub_module.utils = SimpleNamespace(UnsupportedParamsError=Exception)
stub_module.exceptions = SimpleNamespace(BadRequestError=Exception)
stub_module.callbacks = []
monkeypatch.setitem(sys.modules, "litellm", stub_module)
# Mock the track_completion decorator to be a no-op for unit tests
def mock_track_completion(project_name=None):
def decorator(func):
# Mark as tracked to prevent actual tracking
func.opik_tracked = True
return func
return decorator
litellm_integration_stub = types.ModuleType("opik.integrations.litellm")
litellm_integration_stub.track_completion = mock_track_completion
monkeypatch.setitem(
sys.modules, "opik.integrations.litellm", litellm_integration_stub
)
return stub_module
@pytest.fixture(autouse=True)
def _clear_model_cache():
models_factory._MODEL_CACHE.clear()
yield
models_factory._MODEL_CACHE.clear()
def test_models_factory_reuses_cached_instance(monkeypatch):
_install_litellm_stub(monkeypatch)
first_gpt5 = models_factory.get("gpt-5-nano")
second_gpt5 = models_factory.get("gpt-5-nano")
first_gpt4 = models_factory.get("gpt-4o")
second_gpt4 = models_factory.get("gpt-4o")
assert first_gpt5 is second_gpt5
assert first_gpt4 is second_gpt4
assert first_gpt5 is not first_gpt4
def test_models_factory_cache_freezes_unhashable(monkeypatch):
_install_litellm_stub(monkeypatch)
params = {"metadata": {"labels": ["a", "b"], "nested": {"c"}}}
first = models_factory.get("gpt-4o", **params)
second = models_factory.get("gpt-4o", **params)
assert first is second
def test_models_factory_default_model(monkeypatch):
_install_litellm_stub(monkeypatch)
default_instance = models_factory.get(None)
assert default_instance.model_name == "openai/gpt-5-nano"
def test_models_factory_default_model_from_env(monkeypatch):
_install_litellm_stub(monkeypatch)
monkeypatch.setenv("OPIK_DEFAULT_LLM", "gpt-4o-mini")
default_instance = models_factory.get(None)
assert default_instance.model_name == "gpt-4o-mini"
class TestCoerceTemperatureToFloat:
"""The helper underpins every "is temperature 1?" check on the
LiteLLM path (GPT-5 filter, Anthropic reasoning_effort conflict).
Pin its semantics directly so the indirect tests above don't
over-couple to incidental call-site behavior.
"""
def test_returns_float_for_int(self):
from opik.evaluation.models.litellm import util
assert util.coerce_temperature_to_float(1) == 1.0
def test_returns_float_for_float(self):
from opik.evaluation.models.litellm import util
assert util.coerce_temperature_to_float(0.5) == 0.5
def test_returns_float_for_numeric_string(self):
from opik.evaluation.models.litellm import util
# The bug the reviewer flagged: `temperature="1"` would compare
# unequal to `1` and trigger an unwanted drop. Pinning the
# coercion path directly here so regressions surface fast.
assert util.coerce_temperature_to_float("1") == 1.0
assert util.coerce_temperature_to_float("1.0") == 1.0
assert util.coerce_temperature_to_float(" 0.7 ") == 0.7
def test_returns_none_for_non_numeric_string(self):
from opik.evaluation.models.litellm import util
assert util.coerce_temperature_to_float("not-a-number") is None
def test_returns_none_for_none(self):
from opik.evaluation.models.litellm import util
assert util.coerce_temperature_to_float(None) is None
def test_returns_none_for_arbitrary_object(self):
from opik.evaluation.models.litellm import util
assert util.coerce_temperature_to_float(object()) is None
def test_litellm_chat_model_drops_temperature_for_gpt5(monkeypatch, caplog):
stub = _install_litellm_stub(monkeypatch)
caplog.set_level(logging.WARNING)
model = litellm_chat_model.LiteLLMChatModel(
model_name="gpt-5-nano",
temperature=0.5,
)
assert any(
"temperature" in record.message and "Dropping" in record.message
for record in caplog.records
)
caplog.clear()
model.generate_string("hello")
# Assert against the actual outbound call, not on the private
# `_completion_kwargs` constructor state — the contract callers
# care about is "what gets sent to litellm.completion".
assert stub._calls, "Expected completion to be invoked"
_, _, kwargs = stub._calls[-1]
assert "temperature" not in kwargs
assert not caplog.records
def test_litellm_chat_model_drops_temperature_for_provider_prefixed_gpt5(
monkeypatch, caplog
):
stub = _install_litellm_stub(monkeypatch)
caplog.set_level(logging.WARNING)
model = litellm_chat_model.LiteLLMChatModel(
model_name="openai/gpt-5-nano",
temperature=1e-8,
)
assert any(
"temperature" in record.message and "Dropping" in record.message
for record in caplog.records
)
caplog.clear()
model.generate_string("hello")
assert stub._calls, "Expected completion to be invoked"
_, _, kwargs = stub._calls[-1]
assert "temperature" not in kwargs
assert not caplog.records
def test_litellm_chat_model_drops_seed_when_provider_does_not_support(
monkeypatch, caplog
):
"""`seed` is an OpenAI-shape param; Anthropic (and a handful of
other providers) reject it with `UnsupportedParamsError` rather
than ignoring it. The native `AnthropicChatModel` silently filters
it; this test pins the same behavior on the LiteLLM path so callers
that pass `seed` (e.g. the agentic judge integration tests for
reproducibility) don't blow up when the underlying provider is
Anthropic.
"""
stub = _install_litellm_stub(
monkeypatch,
# Mirror Anthropic's litellm-reported support: no `seed`.
supported_params=["temperature", "response_format", "tools", "tool_choice"],
)
caplog.set_level(
logging.DEBUG, logger="opik.evaluation.models.litellm.litellm_chat_model"
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
seed=42,
temperature=0.0,
)
model.generate_string("hello")
# Public observable: the outbound litellm.completion call. `seed`
# must be filtered out; `temperature` must survive.
assert stub._calls, "Expected completion to be invoked"
_, _, kwargs = stub._calls[-1]
assert "seed" not in kwargs
assert kwargs.get("temperature") == 0.0
def test_litellm_chat_model_drops_reasoning_effort_for_anthropic_when_temperature_conflicts(
monkeypatch, caplog
):
"""LiteLLM translates OpenAI-shape `reasoning_effort` into the
Anthropic-specific `thinking` parameter; with thinking enabled,
Anthropic requires `temperature == 1`. Callers that set a
deterministic `temperature` (the agentic loop's default, plus
most reproducibility-sensitive callers) hit a 400 from the
provider. The drop only fires when the conflict is real — i.e.
when temperature is explicitly set to a non-1 value — so callers
who opt into both keep extended thinking. See the
`_keeps_reasoning_effort_when_temperature_is_one` counterpart.
"""
stub = _install_litellm_stub(
monkeypatch,
# LiteLLM reports `reasoning_effort` as supported for
# Anthropic — that's exactly why the generic
# supported_params filter doesn't catch it.
supported_params=[
"temperature",
"response_format",
"reasoning_effort",
"tools",
"tool_choice",
],
)
caplog.set_level(
logging.DEBUG, logger="opik.evaluation.models.litellm.litellm_chat_model"
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
temperature=0.0,
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
# The actual call to `litellm.completion` is what matters: the
# conflict-resolution pass must strip `reasoning_effort` from the
# outbound request even though it was set at construction time.
# `temperature` is preserved because it's the half of the conflict
# that survives, and the provider needs it to know the request is
# in deterministic mode.
assert "reasoning_effort" not in kwargs
assert kwargs.get("temperature") == 0.0
def test_litellm_chat_model_drops_reasoning_effort_for_bare_claude_when_temperature_conflicts(
monkeypatch,
):
# Same drop must trigger for the bare `claude-...` form too,
# matching `model_name_helper.is_anthropic_model`'s predicate.
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "response_format", "reasoning_effort"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="claude-sonnet-4-6",
reasoning_effort="low",
temperature=0.5,
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
assert "reasoning_effort" not in kwargs
def test_litellm_chat_model_drops_reasoning_effort_when_temperature_is_per_call_only(
monkeypatch,
):
"""Cross-source conflict — `reasoning_effort` from constructor,
`temperature=0` from the per-call kwargs (the agentic judge loop's
pattern). The conflict-resolution pass must run on the merged
effective dict so it catches conflicts whose two halves come from
different sources. The per-source `_remove_unnecessary_not_supported_params`
by itself couldn't see this — at constructor time it never saw the
per-call temperature, and at call time it never sees the
constructor-time reasoning_effort.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "response_format", "reasoning_effort"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
)
# Per-call temperature=0 (the agentic judge loop's hardcoded pin)
# is the half of the conflict that lives outside `_completion_kwargs`.
model.generate_string("hello", temperature=0)
_, _, kwargs = stub._calls[-1]
assert "reasoning_effort" not in kwargs
def test_litellm_chat_model_keeps_reasoning_effort_when_temperature_is_string_one(
monkeypatch,
):
"""`temperature` can arrive as a string ("1", "1.0") and LiteLLM
coerces it before the API call. The conflict-resolution pass must
coerce the same way before comparing, otherwise it'd drop
`reasoning_effort` on a caller who legitimately opted into
thinking via a stringy temperature.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
for stringy_one in ("1", "1.0", " 1 "):
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
temperature=stringy_one,
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
assert kwargs.get("reasoning_effort") == "low", (
f"temperature={stringy_one!r} should coerce to 1.0 and "
f"preserve reasoning_effort; got kwargs={kwargs}"
)
def test_litellm_chat_model_drops_reasoning_effort_when_temperature_is_string_non_one(
monkeypatch,
):
# Counterpart: stringy non-1 values must still trigger the drop.
# Otherwise stringy callers would bypass conflict detection
# entirely.
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
temperature="0.5",
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
assert "reasoning_effort" not in kwargs
def test_litellm_chat_model_keeps_reasoning_effort_when_temperature_is_non_numeric(
monkeypatch,
):
"""Coercion failure ("unknown" type) is treated as "don't drop"
a non-numeric temperature is the provider's problem to surface,
and we shouldn't compound the error by also silently dropping
`reasoning_effort`.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
temperature="not-a-number",
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
# `reasoning_effort` stays — the provider will surface the bad
# temperature shape on its own.
assert kwargs.get("reasoning_effort") == "low"
def test_litellm_chat_model_drops_reasoning_effort_when_reasoning_effort_is_per_call_only(
monkeypatch,
):
"""Mirror of the previous test — the other cross-source ordering:
`temperature` from the constructor, `reasoning_effort` arriving
per-call. Without the merged-dict check this would slip past:
constructor `_remove_unnecessary_not_supported_params` saw only
`temperature`, per-call `_remove_unnecessary_not_supported_params`
saw only `reasoning_effort`, neither was the conflict pair.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "response_format", "reasoning_effort"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
temperature=0,
)
model.generate_string("hello", reasoning_effort="low")
_, _, kwargs = stub._calls[-1]
assert "reasoning_effort" not in kwargs
def test_litellm_chat_model_keeps_reasoning_effort_for_anthropic_when_temperature_is_one(
monkeypatch,
):
"""Opt-in path for Anthropic extended thinking: explicit
`temperature=1` plus `reasoning_effort=...` keeps both. The
`thinking` mode LiteLLM enables under the hood is compatible with
`temperature=1`, so the drop must not fire.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="medium",
temperature=1,
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
# Both must reach the outbound call — that's the opt-in shape for
# Anthropic extended thinking.
assert kwargs.get("reasoning_effort") == "medium"
assert kwargs.get("temperature") == 1
def test_litellm_chat_model_keeps_reasoning_effort_for_anthropic_when_temperature_omitted(
monkeypatch,
):
"""When `temperature` isn't set explicitly, Anthropic defaults to
1 server-side, which doesn't conflict with thinking mode. The
drop must not fire under that signal-absent state.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="anthropic/claude-haiku-4-5",
reasoning_effort="low",
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
# `reasoning_effort` must reach the outbound call; `temperature`
# was never set, so it must not appear either (Anthropic defaults
# to 1 server-side).
assert kwargs.get("reasoning_effort") == "low"
assert "temperature" not in kwargs
def test_litellm_chat_model_keeps_reasoning_effort_for_openai(monkeypatch):
"""Counterpart to the Anthropic drop tests: for OpenAI (and any
other provider whose litellm support doesn't translate
`reasoning_effort` into a conflicting param), the value must
round-trip so determinism + reasoning callers keep working.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "reasoning_effort", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="gpt-5-mini",
reasoning_effort="low",
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
assert kwargs.get("reasoning_effort") == "low"
def test_litellm_chat_model_keeps_seed_when_provider_supports_it(monkeypatch):
"""Counterpart to the drop test: when `seed` IS in the provider's
supported set (e.g. OpenAI), it must round-trip through to the
completion call so determinism still works on providers that
honor it.
"""
stub = _install_litellm_stub(
monkeypatch,
supported_params=["temperature", "seed", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="gpt-4o-mini",
seed=42,
)
model.generate_string("hello")
_, _, kwargs = stub._calls[-1]
assert kwargs.get("seed") == 42
def test_litellm_chat_model_drops_top_logprobs_for_dashscope(
monkeypatch,
):
stub = _install_litellm_stub(
monkeypatch,
supported_params=["logprobs", "top_logprobs", "response_format"],
)
model = litellm_chat_model.LiteLLMChatModel(
model_name="dashscope/qwen-flash",
logprobs=True,
top_logprobs=10,
)
model.generate_string("hello")
# top_logprobs should not be forwarded to the provider (public
# observable — what `litellm.completion` actually receives).
assert stub._calls, "Expected completion to be invoked"
_, _, kwargs = stub._calls[-1]
assert "top_logprobs" not in kwargs
def test_geval_passes_logprobs_only_when_supported(monkeypatch):
_install_litellm_stub(
monkeypatch, supported_params=["logprobs", "top_logprobs", "response_format"]
)
captured = {}
@contextmanager
def fake_get_provider_response(model_provider, messages, **kwargs):
captured["kwargs"] = kwargs
yield SimpleNamespace(
choices=[
{
"message": {"content": json.dumps({"score": 10, "reason": "ok"})},
"logprobs": {
"content": [
{},
{},
{},
{
"top_logprobs": [
{"token": "10", "logprob": 0.0},
],
"token": "10",
},
]
},
}
]
)
monkeypatch.setattr(base_model, "get_provider_response", fake_get_provider_response)
metric = g_eval.GEval(
task_introduction="intro",
evaluation_criteria="criteria",
model="gpt-4o",
)
metric.score("{}")
assert captured["kwargs"]["logprobs"] is True
assert captured["kwargs"]["top_logprobs"] == 20
# Now simulate model without logprob support
_install_litellm_stub(monkeypatch, supported_params=["response_format"])
captured.clear()
@contextmanager
def fake_response_no_logprobs(model_provider, messages, **kwargs):
captured["kwargs"] = kwargs
yield SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content=json.dumps({"score": 10, "reason": "ok"})
)
)
]
)
monkeypatch.setattr(base_model, "get_provider_response", fake_response_no_logprobs)
metric = g_eval.GEval(
task_introduction="intro",
evaluation_criteria="criteria",
model="gpt-5-nano",
)
# Even if litellm claims logprob support, gpt-5 should drop them
_install_litellm_stub(
monkeypatch, supported_params=["logprobs", "top_logprobs", "response_format"]
)
captured.clear()
monkeypatch.setattr(base_model, "get_provider_response", fake_response_no_logprobs)
metric = g_eval.GEval(
task_introduction="intro",
evaluation_criteria="criteria",
model="gpt-5-nano",
)
metric.score("{}")
assert "logprobs" not in captured["kwargs"]
assert "top_logprobs" not in captured["kwargs"]
@pytest.mark.asyncio
async def test_litellm_chat_model_agenerate_string_supports_dict_choices(monkeypatch):
_install_litellm_stub(monkeypatch)
captured_kwargs = {}
@asynccontextmanager
async def fake_aget_provider_response(model_provider, messages, **kwargs):
captured_kwargs["messages"] = messages
yield SimpleNamespace(
choices=[
{
"message": {"content": "async-ok"},
"logprobs": None,
}
]
)
monkeypatch.setattr(
base_model, "aget_provider_response", fake_aget_provider_response
)
model = litellm_chat_model.LiteLLMChatModel(model_name="gpt-4o")
result = await model.agenerate_string(input="hello async")
assert result == "async-ok"
assert captured_kwargs["messages"][0]["content"] == "hello async"
def test_models_factory_track_parameter_creates_separate_instances(monkeypatch):
"""Test that track parameter creates separate cached instances."""
_install_litellm_stub(monkeypatch)
# Get model with track=True
model_tracked = models_factory.get("gpt-4o", track=True)
# Get model with track=False
model_untracked = models_factory.get("gpt-4o", track=False)
# Get another model with track=True (should reuse first)
model_tracked_2 = models_factory.get("gpt-4o", track=True)
# track=True and track=False should create separate instances
assert model_tracked is not model_untracked
# Same track value should reuse cached instance
assert model_tracked is model_tracked_2
class TestParseAssistantMessage:
def _wrap(self, message):
return SimpleNamespace(choices=[{"message": message}])
def test_returns_content_when_present(self):
message = response_parser.parse_assistant_message(
self._wrap({"content": "hello"})
)
assert message == {"role": "assistant", "content": "hello"}
def test_raises_when_no_content_and_no_tool_calls(self):
from opik import exceptions
with pytest.raises(exceptions.BaseLLMError):
response_parser.parse_assistant_message(self._wrap({"content": None}))
def test_falls_back_to_structured_output_tool_call(self):
message = response_parser.parse_assistant_message(
self._wrap(
{
"content": None,
"tool_calls": [
{
"id": "call_synthetic",
"function": {
"name": "json_tool_call",
"arguments": '{"assertion_1": {"score": true, "reason": "ok", "confidence": 0.9}}',
},
}
],
}
)
)
assert '"assertion_1"' in message["content"]
assert "tool_calls" not in message
def test_falls_back_to_structured_output_tool_call_from_object_message(self):
message = response_parser.parse_assistant_message(
self._wrap(
SimpleNamespace(
content=None,
tool_calls=[
SimpleNamespace(
id="call_synthetic",
function=SimpleNamespace(
name="json_tool_call",
arguments='{"score": true}',
),
)
],
)
)
)
assert message["content"] == '{"score": true}'
assert "tool_calls" not in message
def test_surfaces_real_tool_calls_alongside_text(self):
message = response_parser.parse_assistant_message(
self._wrap(
{
"content": "looking that up",
"tool_calls": [
{
"id": "call_123",
"function": {
"name": "web_search",
"arguments": '{"query": "capital of France"}',
},
}
],
}
)
)
assert message["content"] == "looking that up"
assert message["tool_calls"] == [
{
"id": "call_123",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "capital of France"}',
},
}
]
def test_skips_synthetic_tool_call_when_listed_alongside_real_ones(self):
message = response_parser.parse_assistant_message(
self._wrap(
{
"content": None,
"tool_calls": [
{
"id": "synth",
"function": {
"name": "json_tool_call",
"arguments": '{"score": 5}',
},
},
{
"id": "call_real",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
},
],
}
)
)
assert message["content"] == '{"score": 5}'
assert message["tool_calls"] == [
{
"id": "call_real",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
]
def test_prefers_content_over_synthetic_tool_call_arguments(self):
message = response_parser.parse_assistant_message(
self._wrap(
{
"content": "direct content",
"tool_calls": [
{
"id": "synth",
"function": {
"name": "json_tool_call",
"arguments": "should not use this",
},
}
],
}
)
)
assert message["content"] == "direct content"
assert "tool_calls" not in message
@pytest.mark.parametrize(
"track,expected_calls",
[
(False, 0),
(True, 2), # Once for completion, once for acompletion
],
)
def test_litellm_chat_model_track_parameter_controls_monitoring(
monkeypatch, track, expected_calls
):
"""Test that track parameter controls LiteLLM monitoring when globally enabled."""
_install_litellm_stub(monkeypatch)
monkeypatch.setenv("OPIK_ENABLE_LITELLM_MODELS_MONITORING", "true")
# Track which decorator was used
decorator_calls = 0
def mock_track_completion(project_name=None):
def decorator(func):
nonlocal decorator_calls
decorator_calls += 1
return func
return decorator
# Patch the function on the actual module. Replacing the `sys.modules`
# entry isn't enough because `import opik.integrations.litellm as X`
# resolves via the `opik.integrations` package attribute, which still
# points at the real module once it has been imported earlier in the
# suite (e.g. by any LLM-judge metric default-model instantiation).
import opik.integrations.litellm as _real_litellm_integration
monkeypatch.setattr(
_real_litellm_integration, "track_completion", mock_track_completion
)
# Create model with specified track value
litellm_chat_model.LiteLLMChatModel(model_name="gpt-4o", track=track)
# Verify that track_completion decorator was applied the expected number of times
assert decorator_calls == expected_calls
@@ -0,0 +1,255 @@
import random
import string
from typing import Any, Dict, Optional
import pytest
from opik.api_objects.prompt.chat import chat_prompt_template
from opik.api_objects.prompt.chat.chat_prompt_template import ChatPromptTemplate
from opik.api_objects.prompt.chat.content_renderer_registry import (
ChatContentRendererRegistry,
)
def _render_content(
content: Any,
*,
variables: Optional[Dict[str, Any]] = None,
supported_modalities: Optional[Dict[str, bool]] = None,
registry: Optional[ChatContentRendererRegistry] = None,
) -> Any:
template = ChatPromptTemplate(
messages=[{"role": "user", "content": content}],
registry=registry,
)
rendered = template.format(
variables=variables or {},
supported_modalities=supported_modalities,
)
assert len(rendered) == 1
return rendered[0]["content"]
class TestChatPromptTemplate:
def test_renders_plain_text(self) -> None:
rendered = _render_content(
"Hello {{name}}",
variables={"name": "Opik"},
supported_modalities={"vision": False},
)
assert rendered == "Hello Opik"
def test_preserves_structured_content_for_vision_models(self) -> None:
content = [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
]
rendered = _render_content(
content,
variables={"image_url": "https://example.com/cat.jpg"},
supported_modalities={"vision": True, "video": True},
)
assert isinstance(rendered, list)
assert rendered[0]["text"] == "Describe this image"
assert rendered[1]["image_url"]["url"] == "https://example.com/cat.jpg"
def test_preserves_structured_content_for_video_models(self) -> None:
content = [
{"type": "text", "text": "Watch this video"},
{"type": "video_url", "video_url": {"url": "{{video_url}}"}},
]
rendered = _render_content(
content,
variables={"video_url": "https://example.com/clip.mp4"},
supported_modalities={"vision": True, "video": True},
)
assert isinstance(rendered, list)
assert rendered[0]["text"] == "Watch this video"
assert rendered[1]["video_url"]["url"] == "https://example.com/clip.mp4"
@pytest.mark.parametrize("detail", ["low", "high"])
def test_includes_detail_field_when_present(self, detail: str) -> None:
content = [
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.png",
"detail": detail,
},
}
]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": True},
)
assert rendered[0]["image_url"]["detail"] == detail
@pytest.mark.parametrize(
"data_url_prefix",
["data:image/png;base64,", "data:image/jpeg;base64,"],
)
def test_supports_base64_image_urls(self, data_url_prefix: str) -> None:
data_url = f"{data_url_prefix}iVBORw0KGgoAAAANSUhEUgAAAAUA"
content = [
{"type": "text", "text": "Inline data"},
{"type": "image_url", "image_url": {"url": data_url}},
]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": True},
)
assert rendered[1]["image_url"]["url"] == data_url
def test_flattens_structured_content_when_vision_disabled(self) -> None:
content = [
{"type": "text", "text": "First"},
{"type": "image_url", "image_url": {"url": "https://example.com/one.png"}},
{"type": "text", "text": "Second"},
]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": False, "video": False},
)
assert isinstance(rendered, str)
assert "First" in rendered
assert "Second" in rendered
assert "https://example.com/one.png" in rendered
assert rendered.count("<<<image>>>") == 1
def test_flattens_structured_video_when_video_disabled(self) -> None:
content = [
{"type": "text", "text": "Context"},
{"type": "video_url", "video_url": {"url": "https://example.com/clip.mp4"}},
]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": True, "video": False},
)
assert isinstance(rendered, str)
assert "Context" in rendered
assert "<<<video>>>" in rendered
def test_flattened_placeholder_truncates_large_base64(self) -> None:
random_payload = "".join(
random.choices(string.ascii_letters + string.digits + "+/", k=700)
)
data_url = f"data:image/png;base64,{random_payload}"
content = [
{"type": "image_url", "image_url": {"url": data_url}},
]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": False},
)
assert isinstance(rendered, str)
assert "<<<image>>>" in rendered
inner = rendered.split("<<<image>>>")[1].split("<<</image>>>")[0]
assert len(inner) <= 500
assert inner.endswith("...")
def test_skips_invalid_parts(self) -> None:
content = [{"type": "text", "text": "ok"}, "bad-part", None]
rendered = _render_content(
content,
variables={},
supported_modalities={"vision": True},
)
assert rendered == [{"type": "text", "text": "ok"}]
def test_custom_part_registration_allows_new_parts(self) -> None:
registry = ChatContentRendererRegistry()
registry.register_part_renderer("text", chat_prompt_template.render_text_part)
registry.register_part_renderer(
"image_url",
chat_prompt_template.render_image_url_part,
modality="vision",
placeholder=("<<<image>>>", "<<</image>>>"),
)
custom_part = {"type": "thumbnail", "image_url": {"url": "{{thumb_url}}"}}
def _render_thumbnail(
part: Dict[str, Any], variables: Dict[str, Any], template_type: Any
) -> Dict[str, Any]:
rendered = chat_prompt_template.render_image_url_part(
part, variables, template_type
)
assert rendered is not None
return {"type": "thumbnail", "image_url": rendered["image_url"]}
registry.register_part_renderer(
"thumbnail",
_render_thumbnail,
modality="vision",
)
rendered = _render_content(
[custom_part],
variables={"thumb_url": "https://example.com/thumb.png"},
supported_modalities={"vision": True, "video": True},
registry=registry,
)
assert rendered[0]["type"] == "thumbnail"
assert rendered[0]["image_url"]["url"] == "https://example.com/thumb.png"
flattened = _render_content(
[custom_part],
variables={"thumb_url": "https://example.com/thumb.png"},
supported_modalities={"vision": False, "video": False},
registry=registry,
)
assert isinstance(flattened, str)
assert "thumbnail" in flattened
def test_required_modalities_detects_vision(self) -> None:
template = ChatPromptTemplate(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Describe the image"},
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
],
}
]
)
assert template.required_modalities() == {"vision"}
def test_required_modalities_detects_video(self) -> None:
template = ChatPromptTemplate(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize the video"},
{"type": "video_url", "video_url": {"url": "{{video_url}}"}},
],
}
]
)
assert template.required_modalities() == {"video"}
@@ -0,0 +1,31 @@
from opik.evaluation.models import ModelCapabilities
def test_supports_vision_defaults_to_false_without_name() -> None:
assert ModelCapabilities.supports_vision(None) is False
def test_custom_capability_registration() -> None:
ModelCapabilities.register_capability_detector(
"custom", lambda model: model.startswith("custom-")
)
assert ModelCapabilities.supports("custom", "custom-model") is True
assert ModelCapabilities.supports("custom", "text-model") is False
def test_supports_vision_handles_provider_prefix() -> None:
assert ModelCapabilities.supports_vision("anthropic/claude-3-opus") is True
def test_supports_vision_detects_common_suffixes() -> None:
assert ModelCapabilities.supports_vision("provider/new-model-vision") is True
assert ModelCapabilities.supports_vision("provider/new-model-vl") is True
assert ModelCapabilities.supports_vision("gpt-4.1") is True
assert ModelCapabilities.supports_vision("gpt-4.1-mini") is True
def test_supports_video_detects_keywords() -> None:
assert ModelCapabilities.supports_video("provider/some-video-model") is True
assert ModelCapabilities.supports_video("qwen/qwen2.5-vl-32b-instruct") is True
assert ModelCapabilities.supports_video("text-only-model") is False