One choke point in the sanitized client: the flavor is probed once (the boilerroom wrapper names itself on /health, a bare llama-server serves /props, Ollama answers neither) and every completion adapts. The agents' tool_choice "required" survives only on Ollama — advisory there, enforced by llama-server, an unbreakable tool loop through the wrapper. Through the wrapper every completion carries webber's session identity: session webber, eviction_order 20 in the decided ranking, configurable via settings. The wrapper's balancing and compaction signals are read from the response body's extra fields — an httpx event-hook variant was tried and never fires under the openai SDK. The enabling fix: the sanitized client was never in the request path. The provider assigned self._openai_client, an attribute nobody reads — OllamaProvider.client serves self._client — so every completion has bypassed the null-content sanitizer since the class was introduced. Exposed when the wrapper 503'd a session-less request the choke point should have named; the client now goes through the constructor's official openai_client parameter, and a wiring test pins provider.client to the sanitized type. The same bug exists in tatlock (its T-6, filed). Verified against the live wrapper from the dev server: flavor boilerroom detected, a tool-using explore run answered in 4.8 s with no tool loop, webber resident at rank 20, and the wrapper parked librarian and tatlock-experts to seat it — the ranking doing exactly its job. Six new tests (227 green), five mutation-checked: the rank default, the strip condition, the session-add condition, the no-cache-on-failure rule, and the client wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
62 lines
2.8 KiB
Python
62 lines
2.8 KiB
Python
"""The sanitising Ollama client must actually be reachable.
|
|
|
|
src/ollama/provider.py exists to work around Ollama rejecting assistant
|
|
messages that carry `content: null` alongside `tool_calls`. On 2026-08-11 it
|
|
raised AttributeError the moment anything touched `.chat`: it fetched the
|
|
parent's getter via `AsyncOpenAI.chat.fget`, and openai had made `chat` a
|
|
functools.cached_property, whose getter is `.func`.
|
|
|
|
Nothing caught it. The line carried a bare `# type: ignore`, so mypy stayed
|
|
quiet, and the endpoints that reach this code had served no requests in 30 days,
|
|
so no user hit it either. The mitigation was dead and everything looked fine.
|
|
|
|
These tests exercise the path rather than the types, because the failure was a
|
|
runtime attribute lookup that no annotation would have caught.
|
|
"""
|
|
from openai import AsyncOpenAI
|
|
|
|
from src.ollama.provider import _parent_chat, _SanitizedAsyncOpenAI, get_ollama_provider
|
|
|
|
|
|
class TestSanitizedClientIsReachable:
|
|
|
|
def test_chat_can_be_accessed(self):
|
|
"""The regression: this raised AttributeError, not a type error."""
|
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
|
chat = client.chat
|
|
assert chat is not None
|
|
assert chat.completions is not None
|
|
|
|
def test_provider_reaches_completions(self):
|
|
"""The full chain an agent request walks, short of the network call.
|
|
|
|
Through `.client` — the property pydantic_ai actually reads. The
|
|
old assertion walked `_openai_client`, a lookalike attribute
|
|
nobody read, which is exactly how the sanitizer sat bypassed in
|
|
production until 2026-09-13.
|
|
"""
|
|
provider = get_ollama_provider()
|
|
assert provider.client.chat.completions is not None
|
|
|
|
def test_parent_lookup_survives_either_descriptor_shape(self):
|
|
"""openai has used both property and cached_property for `chat`.
|
|
|
|
Whichever it is, the parent's own getter must be found — the previous
|
|
code hardcoded `.fget` and broke on the switch to cached_property.
|
|
"""
|
|
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")
|
|
assert _parent_chat(client) is not None
|
|
|
|
def test_parent_chat_is_not_the_override(self):
|
|
"""It must return openai's Chat, not recurse into the subclass property.
|
|
|
|
Returning the subclass's own `chat` would be infinite recursion, and the
|
|
sanitiser would wrap itself instead of the real completions resource.
|
|
"""
|
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
|
assert type(_parent_chat(client)).__name__ != "_SanitizedChat"
|