oterm/tests/unit/test_agent.py
Yiorgis Gozadinos c4d46ce293
Size the summarize tail from the built model and write summaries with the chat's settings
The context window only reached the summarize capability when a local
server reported one, so cloud models always fell back to keeping the
last 20 messages. get_agent now resolves the model before building the
agent and reads the window from its profile.

The summary run built its own agent with no model settings, so the
chat's temperature, thinking switch and openai-compat chat template
switch did not apply. The summary now uses the chat's parameters with
thinking off and no max_tokens, so a user cap cannot cut it short.
2026-09-27 13:56:10 +03:00

488 lines
18 KiB
Python

import pytest
from pydantic_ai import Agent
from pydantic_ai.models import override_allow_model_requests
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai_harness.compaction import SummarizingCompaction
from oterm.agent import _build_model_settings, get_agent
from oterm.providers import UNRESOLVED_API_KEY
from oterm.providers.capabilities import ModelCapabilities
from oterm.tools.capabilities import _summarize
@pytest.fixture
def ollama_thinking(monkeypatch):
"""Stub Ollama capability detection so get_agent stays offline."""
def _set(supports_thinking: bool) -> None:
import oterm.agent as agent_mod
monkeypatch.setattr(
agent_mod,
"get_capabilities",
lambda provider, model: ModelCapabilities(
supports_thinking=supports_thinking
),
)
return _set
class TestBuildModelSettings:
def test_none_parameters_only_sets_thinking(self):
settings = _build_model_settings(None, thinking=True, provider="ollama")
assert settings is not None
assert settings.get("thinking") is True # pydantic-ai dict-like settings
assert "temperature" not in settings
def test_empty_parameters_only_sets_thinking(self):
settings = _build_model_settings({}, thinking=False, provider="ollama")
assert settings is not None
assert settings.get("thinking") is False
assert "temperature" not in settings
def test_picks_known_keys(self):
settings = _build_model_settings(
{
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 128,
"ignored": "nope",
},
thinking=False,
provider="ollama",
)
assert settings is not None
assert settings["temperature"] == 0.7
assert settings["top_p"] == 0.9
assert settings["max_tokens"] == 128
assert "ignored" not in settings
@pytest.mark.parametrize("provider", ["openai-chat", "anthropic", "groq", "ollama"])
def test_seed_roundtrips_for_supported_providers(self, provider):
settings = _build_model_settings(
{"seed": 42}, thinking=False, provider=provider
)
assert settings is not None
assert settings["seed"] == 42
def test_unknown_keys_are_dropped_silently(self):
"""Stale Ollama-native keys from older oterm chats must not raise."""
settings = _build_model_settings(
{
"num_ctx": 8192,
"repeat_penalty": 1.1,
"made_up": True,
"temperature": 0.3,
},
thinking=False,
provider="ollama",
)
assert settings is not None
assert settings["temperature"] == 0.3
assert "num_ctx" not in settings
assert "repeat_penalty" not in settings
assert "made_up" not in settings
def test_anthropic_thinking_drops_sampling_params_and_bumps_max_tokens(self):
settings = _build_model_settings(
{"temperature": 0.7, "top_p": 0.9, "max_tokens": 128},
thinking=True,
provider="anthropic",
)
assert settings is not None
assert "temperature" not in settings
assert "top_p" not in settings
# Anthropic requires max_tokens > thinking.budget_tokens (10000).
assert settings["max_tokens"] > 10000
assert settings.get("thinking") is True
def test_anthropic_thinking_keeps_high_user_max_tokens(self):
settings = _build_model_settings(
{"max_tokens": 32000},
thinking=True,
provider="anthropic",
)
assert settings is not None
assert settings["max_tokens"] == 32000
def test_anthropic_thinking_off_keeps_sampling_params(self):
settings = _build_model_settings(
{"temperature": 0.7, "top_p": 0.9},
thinking=False,
provider="anthropic",
)
assert settings is not None
assert settings["temperature"] == 0.7
assert settings["top_p"] == 0.9
class TestGetAgent:
def test_ollama_provider(self, monkeypatch, ollama_thinking):
import oterm.config
ollama_thinking(False)
monkeypatch.setattr(
oterm.config.envConfig, "OLLAMA_URL", "http://localhost:11434"
)
agent = get_agent(provider="ollama", model="llama3")
assert isinstance(agent, Agent)
assert isinstance(agent.model, OpenAIChatModel)
assert isinstance(agent.model._provider, OllamaProvider)
assert (
str(agent.model.client.base_url).rstrip("/") == "http://localhost:11434/v1"
)
def test_ollama_provider_w_api_key(self, monkeypatch, ollama_thinking):
import oterm.config
OLLAMA_URL = "https://ollama.example.com"
OLLAMA_API_KEY = "TEST_KEY"
ollama_thinking(False)
monkeypatch.setattr(
oterm.config.envConfig,
"OLLAMA_URL",
OLLAMA_URL,
)
monkeypatch.setattr(oterm.config.envConfig, "OLLAMA_API_KEY", OLLAMA_API_KEY)
agent = get_agent(provider="ollama", model="llama3")
assert isinstance(agent, Agent)
assert isinstance(agent.model, OpenAIChatModel)
assert isinstance(agent.model._provider, OllamaProvider)
assert str(agent.model.client.base_url).rstrip("/") == f"{OLLAMA_URL}/v1"
assert agent.model.client.api_key == OLLAMA_API_KEY
def test_ollama_thinking_capable_model_enables_thinking_in_profile(
self, monkeypatch, ollama_thinking
):
"""A thinking-capable Ollama model must report supports_thinking so
pydantic-ai forwards the unified thinking setting (and can disable it)."""
import oterm.config
ollama_thinking(True)
monkeypatch.setattr(
oterm.config.envConfig, "OLLAMA_URL", "http://localhost:11434"
)
agent = get_agent(provider="ollama", model="qwen3.6")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.profile.get("supports_thinking", False) is True
def test_ollama_non_thinking_model_leaves_thinking_disabled_in_profile(
self, monkeypatch, ollama_thinking
):
import oterm.config
ollama_thinking(False)
monkeypatch.setattr(
oterm.config.envConfig, "OLLAMA_URL", "http://localhost:11434"
)
agent = get_agent(provider="ollama", model="devstral")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.profile.get("supports_thinking", False) is False
def test_openai_responses_provider_enables_image_generation_tool(self, monkeypatch):
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.native_tools import ImageGenerationTool
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
agent = get_agent(provider="openai-responses", model="gpt-5.4")
assert isinstance(agent, Agent)
assert isinstance(agent.model, OpenAIResponsesModel)
assert any(isinstance(t, ImageGenerationTool) for t in agent._cap_native_tools)
def test_openai_compat_missing_endpoint_raises(self, app_config):
with pytest.raises(ValueError, match="not configured"):
get_agent(provider="openai-compat/ghost", model="x")
def test_openai_compat_resolves_literal_api_key(self, app_config):
app_config.set(
"openaiCompatible",
{
"lmstudio": {
"base_url": "http://localhost:1234/v1",
"api_key": "literal-key",
}
},
)
agent = get_agent(provider="openai-compat/lmstudio", model="q")
assert isinstance(agent.model, OpenAIChatModel)
assert isinstance(agent.model._provider, OpenAIProvider)
assert agent.model.client.api_key == "literal-key"
def test_openai_compat_env_api_key(self, app_config, monkeypatch):
monkeypatch.setenv("MY_LOCAL_KEY", "env-secret")
app_config.set(
"openaiCompatible",
{
"local": {
"base_url": "http://localhost:1234/v1",
"api_key": "${MY_LOCAL_KEY}",
}
},
)
agent = get_agent(provider="openai-compat/local", model="m")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.client.api_key == "env-secret"
def test_openai_compat_missing_env_falls_back_to_unresolved(
self, app_config, monkeypatch
):
"""Unresolved env vars must not leak OPENAI_API_KEY to third-party endpoints."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-real")
monkeypatch.delenv("MY_MISSING_VAR", raising=False)
app_config.set(
"openaiCompatible",
{
"sketchy": {
"base_url": "http://localhost:1234/v1",
"api_key": "${MY_MISSING_VAR}",
}
},
)
agent = get_agent(provider="openai-compat/sketchy", model="m")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.client.api_key == UNRESOLVED_API_KEY
assert agent.model.client.api_key != "sk-real"
def test_openai_compat_no_api_key_configured(self, app_config):
app_config.set(
"openaiCompatible",
{"open": {"base_url": "http://localhost:1234/v1"}},
)
agent = get_agent(provider="openai-compat/open", model="m")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.client.api_key == UNRESOLVED_API_KEY
def test_grok_routes_through_xai_openai_compatible_endpoint(self, monkeypatch):
monkeypatch.setenv("GROK_API_KEY", "xai-secret")
agent = get_agent(provider="grok", model="grok-4")
assert isinstance(agent.model, OpenAIChatModel)
assert isinstance(agent.model._provider, OpenAIProvider)
assert str(agent.model.client.base_url).rstrip("/") == "https://api.x.ai/v1"
assert agent.model.client.api_key == "xai-secret"
def test_grok_missing_key_falls_back_to_unresolved(self, monkeypatch):
"""A missing GROK_API_KEY must not leak OPENAI_API_KEY to x.ai."""
monkeypatch.delenv("GROK_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-real")
agent = get_agent(provider="grok", model="grok-4")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.client.api_key == UNRESOLVED_API_KEY
assert agent.model.client.api_key != "sk-real"
def test_fallthrough_provider_uses_prefix_string(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
agent = get_agent(provider="anthropic", model="claude-4")
model = agent.model
assert not isinstance(model, str) and model is not None
assert model.model_name == "claude-4"
def test_tools_and_system_passed_through(self):
from pydantic_ai import Tool as PydanticTool
def hello() -> str:
return "hi"
tool = PydanticTool(hello, takes_ctx=False)
agent = get_agent(
provider="ollama",
model="llama3",
system="be brief",
tools=[tool],
)
tool_names = [t.name for t in agent._function_toolset.tools.values()]
assert "hello" in tool_names
def test_capabilities_passed_through(self):
from pydantic_ai.capabilities import WebSearch
capability = WebSearch(local="duckduckgo")
agent = get_agent(provider="ollama", model="llama3", capabilities=[capability])
applied = []
agent._root_capability.apply(applied.append)
assert capability in applied
def test_capabilities_merge_with_provider_capabilities(self, monkeypatch):
from pydantic_ai.capabilities import WebSearch
from pydantic_ai.native_tools import ImageGenerationTool
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
capability = WebSearch(local="duckduckgo")
agent = get_agent(
provider="openai-responses", model="gpt-5.4", capabilities=[capability]
)
applied = []
agent._root_capability.apply(applied.append)
assert capability in applied
assert any(isinstance(t, ImageGenerationTool) for t in agent._cap_native_tools)
class _RequestSent(Exception):
pass
async def _sent_request(agent: Agent[None, str]) -> dict:
"""Run the agent and return the keyword arguments its OpenAI client was called with."""
assert isinstance(agent.model, OpenAIChatModel)
sent: dict = {}
async def record(**kwargs):
sent.update(kwargs)
raise _RequestSent
agent.model.client.chat.completions.create = record # ty: ignore[invalid-assignment]
with override_allow_model_requests(True), pytest.raises(_RequestSent):
await agent.run("hi")
return sent
class TestOpenAICompatThinking:
@pytest.fixture(autouse=True)
def endpoint(self, app_config):
app_config.set(
"openaiCompatible",
{"local": {"base_url": "http://localhost:8000/v1"}},
)
async def test_thinking_off_disables_it_in_the_chat_template(self):
agent = get_agent(provider="openai-compat/local", model="Custom/Q-27B")
sent = await _sent_request(agent)
assert sent["extra_body"]["chat_template_kwargs"] == {"enable_thinking": False}
async def test_thinking_on_leaves_the_server_default(self):
agent = get_agent(
provider="openai-compat/local", model="Custom/Q-27B", thinking=True
)
sent = await _sent_request(agent)
assert "chat_template_kwargs" not in (sent.get("extra_body") or {})
async def test_thinking_off_keeps_user_extra_body(self):
agent = get_agent(
provider="openai-compat/local",
model="Custom/Q-27B",
parameters={"extra_body": {"top_k": 20}},
)
sent = await _sent_request(agent)
assert sent["extra_body"] == {
"top_k": 20,
"chat_template_kwargs": {"enable_thinking": False},
}
class TestContextWindowInProfile:
def test_ollama_model_reports_the_given_window(self, monkeypatch, ollama_thinking):
import oterm.config
ollama_thinking(True)
monkeypatch.setattr(
oterm.config.envConfig, "OLLAMA_URL", "http://localhost:11434"
)
agent = get_agent(provider="ollama", model="qwen3.8:27b", context_window=8192)
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.context_window == 8192
assert agent.model.profile.get("supports_thinking") is True
def test_openai_compat_model_reports_the_given_window(self, app_config):
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:8000/v1"}}
)
agent = get_agent(
provider="openai-compat/local", model="Custom/Q-27B", context_window=4096
)
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.context_window == 4096
def test_no_window_leaves_the_profile_alone(self, app_config):
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:8000/v1"}}
)
agent = get_agent(provider="openai-compat/local", model="Custom/Q-27B")
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.context_window is None
def _summarizing(agent: Agent[None, str]) -> "SummarizingCompaction":
applied: list = []
agent._root_capability.apply(applied.append)
return next(c for c in applied if isinstance(c, SummarizingCompaction))
class TestSummarizeFitsTheWindow:
def test_known_window_keeps_a_share_of_it(self, app_config):
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:1234/v1"}}
)
agent = get_agent(
provider="openai-compat/local",
model="gemma",
capabilities=[_summarize()],
context_window=4096,
)
assert _summarizing(agent).keep_tokens == 1638
def test_other_capabilities_pass_through_unchanged(self, app_config):
from pydantic_ai.capabilities import WebSearch
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:1234/v1"}}
)
capability = WebSearch(local="duckduckgo")
agent = get_agent(
provider="openai-compat/local",
model="gemma",
capabilities=[capability],
context_window=4096,
)
applied: list = []
agent._root_capability.apply(applied.append)
assert capability in applied
def test_unknown_window_keeps_the_last_messages(self, app_config):
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:1234/v1"}}
)
agent = get_agent(
provider="openai-compat/local", model="gemma", capabilities=[_summarize()]
)
summarizing = _summarizing(agent)
assert summarizing.keep_tokens is None
assert summarizing.keep_messages == 20
def test_cloud_window_keeps_a_share_of_it(self, monkeypatch):
from pydantic_ai.models import Model
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
agent = get_agent(
provider="anthropic", model="claude-sonnet-4-5", capabilities=[_summarize()]
)
assert isinstance(agent.model, Model)
window = agent.model.context_window
assert window
assert _summarizing(agent).keep_tokens == int(window * 0.4)
class TestSummaryModelSettings:
def test_summary_keeps_sampling_params_without_thinking_or_a_cap(self, app_config):
app_config.set(
"openaiCompatible", {"local": {"base_url": "http://localhost:1234/v1"}}
)
agent = get_agent(
provider="openai-compat/local",
model="gemma",
capabilities=[_summarize()],
parameters={"temperature": 0.2, "max_tokens": 256},
thinking=True,
)
assert _summarizing(agent).model_settings == {
"temperature": 0.2,
"thinking": False,
"extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}
assert agent.model_settings == {
"temperature": 0.2,
"max_tokens": 256,
"thinking": True,
}