fix(provider): T-6 — the sanitized client is wired for real
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>
This commit is contained in:
@@ -1,31 +1,73 @@
|
||||
"""
|
||||
Tests for TatlockOllamaProvider configuration.
|
||||
Tests for TatlockOllamaProvider configuration and wiring.
|
||||
|
||||
The AsyncOpenAI client must carry an explicit timeout from
|
||||
config.OLLAMA_TIMEOUT instead of the SDK default (~600s), so a stuck
|
||||
LLM call cannot consume the whole delegation budget.
|
||||
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.provider import TatlockOllamaProvider, _sanitize_messages
|
||||
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 underlying AsyncOpenAI client."""
|
||||
"""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._openai_client.timeout == float(config.OLLAMA_TIMEOUT)
|
||||
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._openai_client.timeout < 600
|
||||
assert provider.client.timeout < 600
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -52,3 +94,68 @@ class TestMessageSanitization:
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user