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:
2026-09-13 10:27:21 +02:00
co-authored by Claude Fable 5
parent d36d2478c0
commit 1139d8ca38
6 changed files with 214 additions and 33 deletions
+115 -8
View File
@@ -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)