The provider assigned self._openai_client, an attribute nobody reads — OllamaProvider.client serves self._client — so the null-content sanitizer, the wrapper signal reading and the OLLAMA_TIMEOUT cap were all silently dead in production (sessions unaffected; they ride ModelSettings). Found via webber's identical bug on 2026-09-13. Three fixes travel together, because wiring alone would have traded a silent bypass for a loud crash: the client goes through the official openai_client constructor parameter; the parent-chat lookup survives openai's property/cached_property descriptor change (this venv's 2.11.0 is the latter — the old hardcoded .fget would have raised AttributeError on the first wired completion; ported from webber's 2026-08-11 fix); and the balancing/compaction signals are read from the response body's model_extra inside create() — the httpx event-hook approach demonstrably never fires under the openai SDK. The tests now assert through provider.client, the property pydantic_ai actually reads. The old ones asserted on the dead attribute and stayed green for the entire bypass — a check whose subject is not the real thing cannot fail for the real reason (workspace D-24's shape). Three mutations shown to fail their tests: the dead-attribute wiring, the .fget-only lookup, and removing the signal read from the request path. 683 tests green; a live dev turn through the wired client answers normally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.2 KiB
Python
162 lines
5.2 KiB
Python
"""
|
|
Tests for TatlockOllamaProvider configuration and wiring.
|
|
|
|
Every assertion here goes through `provider.client` — the property
|
|
pydantic_ai actually reads. The previous version asserted on
|
|
`_openai_client`, a lookalike attribute nobody read, and stayed green
|
|
for the whole period the sanitizer was bypassed in production (T-6):
|
|
a test whose subject is not the real thing cannot fail for the real
|
|
reason.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from src.core.config import config
|
|
from src.ollama import provider as provider_module
|
|
from src.ollama.provider import (
|
|
TatlockOllamaProvider,
|
|
_parent_chat,
|
|
_sanitize_messages,
|
|
_SanitizedAsyncOpenAI,
|
|
_SanitizedCompletions,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestClientWiring:
|
|
"""The sanitized client must be the one the provider serves (T-6)."""
|
|
|
|
def test_provider_serves_the_sanitized_client(self):
|
|
provider = TatlockOllamaProvider(base_url="http://localhost:11434/v1")
|
|
|
|
assert isinstance(provider.client, _SanitizedAsyncOpenAI)
|
|
|
|
def test_the_full_chain_reaches_completions(self):
|
|
# Walks provider.client -> sanitized chat -> parent lookup ->
|
|
# completions. Under openai's cached_property `chat`, the old
|
|
# hardcoded `.fget` access raised AttributeError right here.
|
|
provider = TatlockOllamaProvider(base_url="http://localhost:11434/v1")
|
|
|
|
assert provider.client.chat.completions is not None
|
|
|
|
def test_parent_lookup_survives_either_descriptor_shape(self):
|
|
from openai import AsyncOpenAI
|
|
|
|
descriptor = AsyncOpenAI.__dict__["chat"]
|
|
assert hasattr(descriptor, "func") or hasattr(
|
|
descriptor, "fget"
|
|
), "AsyncOpenAI.chat exposes neither getter; _parent_chat needs updating"
|
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
|
parent = _parent_chat(client)
|
|
assert parent is not None
|
|
assert (
|
|
type(parent).__name__ != "_SanitizedChat"
|
|
), "parent lookup must not recurse into the override"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestProviderTimeout:
|
|
"""Timeout configuration on the client the provider actually serves."""
|
|
|
|
def test_openai_client_timeout_from_config(self):
|
|
provider = TatlockOllamaProvider(base_url="http://localhost:11434/v1")
|
|
|
|
assert provider.client.timeout == float(config.OLLAMA_TIMEOUT)
|
|
|
|
def test_timeout_is_not_sdk_default(self):
|
|
provider = TatlockOllamaProvider(base_url="http://localhost:11434/v1")
|
|
|
|
# The OpenAI SDK defaults to 600s; the configured cap must win
|
|
assert provider.client.timeout < 600
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessageSanitization:
|
|
"""Null content sanitization for Ollama compatibility."""
|
|
|
|
def test_null_content_with_tool_calls_becomes_empty_string(self):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [{"id": "call_1", "type": "function"}],
|
|
}
|
|
]
|
|
|
|
sanitized = _sanitize_messages(messages)
|
|
|
|
assert sanitized[0]["content"] == ""
|
|
|
|
def test_regular_messages_unchanged(self):
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Good day, sir."},
|
|
]
|
|
|
|
assert _sanitize_messages(messages) == messages
|
|
|
|
|
|
class _StubLogger:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def info(self, event, **kwargs):
|
|
self.calls.append(("info", event, kwargs))
|
|
|
|
def warning(self, event, **kwargs):
|
|
self.calls.append(("warning", event, kwargs))
|
|
|
|
|
|
class _BusyResponse:
|
|
def __init__(self):
|
|
self.model_extra = {
|
|
"balancing": [{"parked": "librarian"}],
|
|
"compaction_due": True,
|
|
}
|
|
|
|
|
|
class _QuietResponse:
|
|
def __init__(self):
|
|
self.model_extra = {"balancing": [], "compaction_due": False}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWrapperSignals:
|
|
"""The wrapper's body signals must not pass silently (T-4/T-6).
|
|
|
|
Read from the parsed body's model_extra: an httpx event-hook
|
|
variant demonstrably never fires under the openai SDK.
|
|
"""
|
|
|
|
def test_busy_response_logs_balancing_and_compaction(self, monkeypatch):
|
|
stub = _StubLogger()
|
|
monkeypatch.setattr(provider_module, "logger", stub)
|
|
|
|
provider_module._log_wrapper_signals(_BusyResponse())
|
|
|
|
assert any(kind == "info" and event == "backend_balancing" for kind, event, _ in stub.calls)
|
|
assert any(kind == "warning" for kind, _, _ in stub.calls)
|
|
|
|
def test_quiet_response_logs_nothing(self, monkeypatch):
|
|
stub = _StubLogger()
|
|
monkeypatch.setattr(provider_module, "logger", stub)
|
|
|
|
provider_module._log_wrapper_signals(_QuietResponse())
|
|
|
|
assert stub.calls == []
|
|
|
|
async def test_create_surfaces_the_signals(self, monkeypatch):
|
|
# The read must sit in the request path, not merely exist:
|
|
# removing the call from create() has to fail this test.
|
|
stub = _StubLogger()
|
|
monkeypatch.setattr(provider_module, "logger", stub)
|
|
|
|
class FakeOriginal:
|
|
async def create(self, **kwargs):
|
|
return _BusyResponse()
|
|
|
|
completions = _SanitizedCompletions(FakeOriginal())
|
|
await completions.create(messages=[{"role": "user", "content": "hi"}])
|
|
|
|
assert any(event == "backend_balancing" for _, event, _ in stub.calls)
|