Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbec26835d | ||
|
|
1139d8ca38 | ||
|
|
d36d2478c0 | ||
|
|
fd2cd55747 | ||
|
|
5b141669ce | ||
|
|
a5681e9511 | ||
|
|
401533d0af |
+6
-1
@@ -8,11 +8,16 @@ API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_PREFIX=/v1
|
||||
|
||||
# Ollama Configuration (local - primary backend)
|
||||
# Local backend (primary). The var name is legacy: any OpenAI-compatible
|
||||
# server works — Ollama, a bare llama-server, or the boilerroom wrapper
|
||||
# (http://127.0.0.1:8602 on tower-of-joy). The flavor is probed at
|
||||
# startup; through the wrapper, BACKEND_SLOT_PINNING=true sends named
|
||||
# sessions with the T-4 eviction ranking instead of raw slot pins.
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
OLLAMA_DEFAULT_MODEL=gemma4:e2b
|
||||
OLLAMA_TIMEOUT=120
|
||||
STEWARD_TIMEOUT=60
|
||||
BACKEND_SLOT_PINNING=false
|
||||
|
||||
# Anthropic Configuration (Claude - cloud fallback)
|
||||
# Set ANTHROPIC_API_KEY to keep the Claude fallback available: it is used
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'status', 'backlog', 'in_progress', NULL, '2026-09-12 13:50:48', '2026-09-12 13:50:48.289', '2026-09-12 13:50:48.289', NULL, '2e88d155325b3e704787f48b2aa97591', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'status', 'in_progress', 'done', NULL, '2026-09-12 14:13:29', '2026-09-12 14:13:29.027', '2026-09-12 14:13:29.027', NULL, '5b9dd9a62002db0fb4dc58f7f9ffcbb6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'description', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.
|
||||
|
||||
Second finding 2026-09-13, from webber''s twin fix: an httpx event-hook on the client (the T-4 signal-reading approach) never fires under the openai SDK even when correctly wired — webber verified headers arriving while the hook stayed silent. When rewiring the client, read the signals from the parsed body''s model_extra in the sanitized create() instead (the wrapper injects balancing/compaction_due into non-streamed JSON bodies; openai pydantic models retain unknown fields). The steward''s raw-call body read is unaffected and already correct.', NULL, '2026-09-13 08:00:17', '2026-09-13 08:00:17.002', '2026-09-13 08:00:17.002', NULL, '6fb4cc1d7d7222e3d570cd8c7fed4fd9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'status', 'backlog', 'in_progress', NULL, '2026-09-13 08:19:35', '2026-09-13 08:19:35.028', '2026-09-13 08:19:35.028', NULL, '5bcf19379361a2f766cd4787ff734611', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'status', 'in_progress', 'done', NULL, '2026-09-13 08:27:21', '2026-09-13 08:27:21.779', '2026-09-13 08:27:21.779', NULL, '1c59422336b88a76c6f1c3720916d7d5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G94KC9XM3PW91C6B7VJA6VRM', 'T-3', '2026-09-11 21:09:10.267', '2026-09-11 21:09:10.267', NULL, 'a1ccf4d118877c230dffb961a7531cb4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'T-4', '2026-09-12 08:20:21.494', '2026-09-12 08:20:21.494', NULL, '5b28e07bdc3be1f9ed0ed71d2da60c72', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07Z3FZY8EBT9PJ1RCGHW', 'T-5', '2026-09-12 08:20:21.625', '2026-09-12 08:20:21.625', NULL, '0a417012779f612a4dad1a7dbd0787e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'T-6', '2026-09-13 07:57:01.782', '2026-09-13 07:57:01.782', NULL, 'd6c184ba6d24e03c0dc77064bf71fead', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
@@ -0,0 +1,15 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G94KC9XM3PW91C6B7VJA6VRM', 'story', NULL, 'Stream the synthesis phase — TTFT equals wall time today', 'Nothing streams until steward-orchestrate-synthesize completes, so desklock''s time-to-first-word is the full pipeline (TTFT mean 14.9s, max 26.2s — measured 2026-09-11, workspace note cladmin/agent/notes/2026-09-11-serving-baseline.md). Stream the synthesis phase out as it generates: a first-class goal of the serving rework independent of KV reuse (workspace D-60). The backend now streams /v1 SSE natively (llama-gen).', 'backlog', 'high', NULL, NULL, NULL, '2026-09-11 21:09:10.253', '2026-09-11 21:09:10.253', NULL, 'f7d31ca4fe747835a32744c310f51674', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'story', NULL, 'Adopt boilerroom named sessions with the usage ranking', 'Replace raw id_slot pinning (BACKEND_SLOT_PINNING) with the wrapper''s session extension fields, now that tatlock routes through boilerroom:8602 (since 2026-09-12). Ranking by likelihood of use, decided 2026-09-12 — lower eviction_order parks sooner: tatlock pipeline phases highest (eviction_order 40; e.g. sessions tatlock-steward/tatlock-orchestrate/tatlock-synthesize), librarian-routed calls as session librarian (30), webber will sit at 20 (workspace T-137), Open WebUI stays session-less by design (idle slots only, can never evict — and it is being deprecated for an own-webui build). Read the x-boilerroom-balancing and compaction_due signals; the compaction endpoint expects the client transcript (tatlock''s Redis/Qdrant context is the natural producer — boilerroom D-5 names tatlock the first producer).', 'backlog', 'high', NULL, NULL, NULL, '2026-09-12 08:20:21.493', '2026-09-12 08:20:21.493', NULL, '3fb8c350876fc056648a19e0f2b7718c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07Z3FZY8EBT9PJ1RCGHW', 'bug', NULL, 'The container logs nowhere: stdout empty, /app/logs mount untouched', 'docker logs tatlock is 0 lines and the mounted ~/docker-data/tatlock/logs has been empty since 2025-12 — the running app writes logs to neither, which made the 2026-09-12 wrapper-cutover verification blind (flavor detection had to be proven behaviorally). Dev mode logs to build/logs/server.log via make run; production should log to stdout for docker logs at minimum.', 'backlog', 'medium', NULL, NULL, NULL, '2026-09-12 08:20:21.624', '2026-09-12 08:20:21.624', NULL, '3fd294efc9afa857aef15810d9003298', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'story', NULL, 'Adopt boilerroom named sessions with the usage ranking', 'Replace raw id_slot pinning (BACKEND_SLOT_PINNING) with the wrapper''s session extension fields, now that tatlock routes through boilerroom:8602 (since 2026-09-12). Ranking by likelihood of use, decided 2026-09-12 — lower eviction_order parks sooner: tatlock pipeline phases highest (eviction_order 40; e.g. sessions tatlock-steward/tatlock-orchestrate/tatlock-synthesize), librarian-routed calls as session librarian (30), webber will sit at 20 (workspace T-137), Open WebUI stays session-less by design (idle slots only, can never evict — and it is being deprecated for an own-webui build). Read the x-boilerroom-balancing and compaction_due signals; the compaction endpoint expects the client transcript (tatlock''s Redis/Qdrant context is the natural producer — boilerroom D-5 names tatlock the first producer).', 'in_progress', 'high', NULL, NULL, NULL, '2026-09-12 08:20:21.493', '2026-09-12 13:50:48.289', NULL, '62abeb4da2f1f7aedb8d92884e7e148c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G99D07EM5DZWTS0WD9V8PSKM', 'story', NULL, 'Adopt boilerroom named sessions with the usage ranking', 'Replace raw id_slot pinning (BACKEND_SLOT_PINNING) with the wrapper''s session extension fields, now that tatlock routes through boilerroom:8602 (since 2026-09-12). Ranking by likelihood of use, decided 2026-09-12 — lower eviction_order parks sooner: tatlock pipeline phases highest (eviction_order 40; e.g. sessions tatlock-steward/tatlock-orchestrate/tatlock-synthesize), librarian-routed calls as session librarian (30), webber will sit at 20 (workspace T-137), Open WebUI stays session-less by design (idle slots only, can never evict — and it is being deprecated for an own-webui build). Read the x-boilerroom-balancing and compaction_due signals; the compaction endpoint expects the client transcript (tatlock''s Redis/Qdrant context is the natural producer — boilerroom D-5 names tatlock the first producer).', 'done', 'high', NULL, NULL, NULL, '2026-09-12 08:20:21.493', '2026-09-12 14:13:29.027', NULL, '89ce1be323ebb26f00c63569f197cab1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'bug', NULL, 'The sanitized OpenAI client is never used: the override pokes an attribute nobody reads', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.', 'backlog', 'high', NULL, NULL, NULL, '2026-09-13 07:57:01.782', '2026-09-13 07:57:01.782', NULL, '5e8ea3bf2d18ade9d17f0044ae1ff96b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'bug', NULL, 'The sanitized OpenAI client is never used: the override pokes an attribute nobody reads', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.
|
||||
|
||||
Second finding 2026-09-13, from webber''s twin fix: an httpx event-hook on the client (the T-4 signal-reading approach) never fires under the openai SDK even when correctly wired — webber verified headers arriving while the hook stayed silent. When rewiring the client, read the signals from the parsed body''s model_extra in the sanitized create() instead (the wrapper injects balancing/compaction_due into non-streamed JSON bodies; openai pydantic models retain unknown fields). The steward''s raw-call body read is unaffected and already correct.', 'backlog', 'high', NULL, NULL, NULL, '2026-09-13 07:57:01.782', '2026-09-13 08:00:17.002', NULL, '755abc3689e8ff4fc511deb164137fe5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'bug', NULL, 'The sanitized OpenAI client is never used: the override pokes an attribute nobody reads', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.
|
||||
|
||||
Second finding 2026-09-13, from webber''s twin fix: an httpx event-hook on the client (the T-4 signal-reading approach) never fires under the openai SDK even when correctly wired — webber verified headers arriving while the hook stayed silent. When rewiring the client, read the signals from the parsed body''s model_extra in the sanitized create() instead (the wrapper injects balancing/compaction_due into non-streamed JSON bodies; openai pydantic models retain unknown fields). The steward''s raw-call body read is unaffected and already correct.', 'in_progress', 'high', NULL, NULL, NULL, '2026-09-13 07:57:01.782', '2026-09-13 08:19:35.028', NULL, '5af3c96930a30bbe4bb64d13ddbc509e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G9KH87TRDGW45GMDWVDRPABR', 'bug', NULL, 'The sanitized OpenAI client is never used: the override pokes an attribute nobody reads', 'src/ollama/provider.py sets self._openai_client after super().__init__, but OllamaProvider.client serves self._client (pydantic_ai 1.27''s official openai_client= constructor param is the right wiring — webber hit the identical bug and fixed it 2026-09-13, exposed by the wrapper 503ing a session-less request). Silently dead in production as a result: the content-null sanitizer (moot on llama-server, live hazard if Ollama ever returns), the T-4 x-boilerroom-* signal hook (never logs), and the OLLAMA_TIMEOUT=120 cap (SDK default ~600s applies). Sessions were unaffected — they ride ModelSettings extra_body. Fix like webber: pass _SanitizedAsyncOpenAI through openai_client=, add a wiring test asserting provider.client is the sanitized type, then verify balancing/compaction logs actually appear.
|
||||
|
||||
Second finding 2026-09-13, from webber''s twin fix: an httpx event-hook on the client (the T-4 signal-reading approach) never fires under the openai SDK even when correctly wired — webber verified headers arriving while the hook stayed silent. When rewiring the client, read the signals from the parsed body''s model_extra in the sanitized create() instead (the wrapper injects balancing/compaction_due into non-streamed JSON bodies; openai pydantic models retain unknown fields). The steward''s raw-call body read is unaffected and already correct.', 'done', 'high', NULL, NULL, NULL, '2026-09-13 07:57:01.782', '2026-09-13 08:27:21.778', NULL, 'ce3151ff27c70a1028ba7c03b07119a2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.7.1] - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- The sanitized OpenAI client was never used (T-6): 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 (sessions were unaffected; they ride
|
||||
ModelSettings). Found via webber's identical bug. The client now
|
||||
goes through the constructor's official `openai_client` parameter,
|
||||
the parent-chat lookup survives openai's property/cached_property
|
||||
descriptor change (ported from webber's 2026-08-11 fix — the old
|
||||
`.fget` access would have crashed the first wired completion on
|
||||
openai 2.11), and the wrapper's `balancing`/`compaction_due`
|
||||
signals are read from the response body's `model_extra` in the
|
||||
request path — the httpx event-hook approach demonstrably never
|
||||
fires under the openai SDK. The provider tests now assert through
|
||||
`provider.client`, the property pydantic_ai actually reads; the
|
||||
old ones asserted on the dead attribute and stayed green
|
||||
throughout.
|
||||
|
||||
## [2.7.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- Named sessions through the boilerroom wrapper (T-4): the backend
|
||||
flavor probe now recognizes the wrapper by its `/health` service
|
||||
name, and each pipeline phase sends `session`/`eviction_order`
|
||||
extension fields instead of raw `id_slot` — `tatlock-steward`,
|
||||
`tatlock-orchestrate`, `tatlock-synthesize` at rank 40 and the
|
||||
librarian at 30, per the 2026-09-12 usage ranking (lower parks
|
||||
sooner; webber will sit at 20, Open WebUI stays session-less).
|
||||
Against a bare llama-server the old slot pins still apply, and
|
||||
Ollama gets neither, so the backend remains swappable by env alone.
|
||||
The wrapper's `x-boilerroom-balancing` and compaction-due signals
|
||||
are read on every response (an httpx hook on the provider, body
|
||||
extras on the steward's raw call) and logged — acting on
|
||||
`compaction_due` is a future ticket. `get_model_info()` reports the
|
||||
probed `local_flavor`. Wire-pinned by a new boilerroom wrapper
|
||||
contract class in `make test-contracts`.
|
||||
- Every generation call is named: the streaming orchestrate paths and
|
||||
the biographer/housekeeper experts (shared session `tatlock-experts`,
|
||||
rank 35) — through the wrapper a session-less call takes idle slots
|
||||
only and would 503 once four sessions are resident, so the release
|
||||
audit swept every `agent.run` and raw chat call under a session.
|
||||
|
||||
## [2.6.0] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tatlock"
|
||||
version = "2.6.0"
|
||||
version = "2.7.1"
|
||||
description = "OpenAI-compatible API with Ollama backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -191,9 +191,12 @@ async def run_biographer(
|
||||
)
|
||||
|
||||
try:
|
||||
from src.anthropic.model_selector import with_phase_binding
|
||||
|
||||
result = await agent.run(
|
||||
prompt,
|
||||
message_history=message_history,
|
||||
model_settings=with_phase_binding(None, "experts"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -206,12 +206,12 @@ async def run_housekeeper(
|
||||
|
||||
try:
|
||||
# Temperature 0.1 for slight exploration (skipped on Claude backend)
|
||||
from src.anthropic.model_selector import get_sampling_settings
|
||||
from src.anthropic.model_selector import get_sampling_settings, with_phase_binding
|
||||
|
||||
result = await agent.run(
|
||||
prompt,
|
||||
message_history=message_history,
|
||||
model_settings=get_sampling_settings(0.1),
|
||||
model_settings=with_phase_binding(get_sampling_settings(0.1), "experts"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -272,11 +272,14 @@ async def run_librarian(
|
||||
)
|
||||
|
||||
try:
|
||||
from src.anthropic.model_selector import with_phase_binding
|
||||
|
||||
# One shared library-desk connection for all tool calls in this run
|
||||
async with library_client_session():
|
||||
result = await agent.run(
|
||||
prompt,
|
||||
message_history=message_history,
|
||||
model_settings=with_phase_binding(None, "librarian"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -162,15 +162,17 @@ class StewardAgent:
|
||||
OpenAI-compatible server (Ollama, llama-server) can sit behind
|
||||
OLLAMA_HOST without this method knowing which.
|
||||
"""
|
||||
from src.anthropic.model_selector import phase_extra_body
|
||||
|
||||
payload: dict = {
|
||||
"model": self.ollama_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9,
|
||||
}
|
||||
if config.BACKEND_SLOT_PINNING:
|
||||
# The steward owns engine slot 0 (see BACKEND_SLOT_PINNING)
|
||||
payload["id_slot"] = 0
|
||||
# Session through the wrapper, id_slot 0 against a bare
|
||||
# llama-server, nothing elsewhere (repo T-4).
|
||||
payload.update(phase_extra_body("steward"))
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
@@ -180,6 +182,17 @@ class StewardAgent:
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# The wrapper reports on session-carrying responses (its
|
||||
# D-4/D-5); this raw call bypasses the provider hook, so
|
||||
# the body extras are read here. Acting on compaction_due
|
||||
# is a later ticket — the signal must not pass silently.
|
||||
balancing = result.get("balancing")
|
||||
if balancing:
|
||||
logger.info("backend_balancing", session="tatlock-steward", moves=balancing)
|
||||
if result.get("compaction_due"):
|
||||
logger.warning("backend_compaction_due", session="tatlock-steward")
|
||||
|
||||
return result["choices"][0]["message"]["content"].strip()
|
||||
|
||||
async def analyze(self, query: str, conversation_history: list[dict] | None = None) -> str:
|
||||
|
||||
+12
-6
@@ -404,10 +404,13 @@ class TatlockAgent(AgentInterface):
|
||||
# with async context managers inside generators
|
||||
# The StreamingCoordinator will handle word-by-word streaming
|
||||
# Pass message_history to maintain conversation context and tracker for tool logging
|
||||
from src.anthropic.model_selector import with_phase_binding
|
||||
|
||||
result = await self.agent.run(
|
||||
user_message,
|
||||
message_history=message_history if message_history else None,
|
||||
deps=tracker,
|
||||
model_settings=with_phase_binding(None, "orchestrate"),
|
||||
)
|
||||
final_text = result.output
|
||||
|
||||
@@ -536,13 +539,13 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
# Force tool_choice to make LLM actually call tools
|
||||
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
|
||||
from src.anthropic.model_selector import get_tool_choice_settings, with_phase_binding
|
||||
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker,
|
||||
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
|
||||
model_settings=with_phase_binding(get_tool_choice_settings(), "orchestrate"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -625,10 +628,13 @@ class TatlockAgent(AgentInterface):
|
||||
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
||||
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
||||
# We yield the final response in chunks to maintain streaming interface
|
||||
from src.anthropic.model_selector import with_phase_binding
|
||||
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker,
|
||||
model_settings=with_phase_binding(None, "orchestrate"),
|
||||
)
|
||||
|
||||
# Stream the final response in chunks to maintain UX
|
||||
@@ -725,13 +731,13 @@ class TatlockAgent(AgentInterface):
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
|
||||
from src.anthropic.model_selector import get_tool_choice_settings, with_phase_binding
|
||||
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker,
|
||||
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
|
||||
model_settings=with_phase_binding(get_tool_choice_settings(), "orchestrate"),
|
||||
)
|
||||
|
||||
# Extract tool calls and results from the agent's messages
|
||||
@@ -888,12 +894,12 @@ class TatlockAgent(AgentInterface):
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Run synthesis
|
||||
from src.anthropic.model_selector import with_slot_pinning
|
||||
from src.anthropic.model_selector import with_phase_binding
|
||||
|
||||
result = await synthesis_agent.run(
|
||||
synthesis_prompt,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
model_settings=with_slot_pinning(None, slot=2),
|
||||
model_settings=with_phase_binding(None, "synthesize"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -28,9 +28,11 @@ logger = get_logger(__name__)
|
||||
# Cached health check results (set once at startup)
|
||||
_claude_available: bool | None = None
|
||||
_ollama_available: bool | None = None
|
||||
# Which server answers behind OLLAMA_HOST: "llama-server" or "ollama".
|
||||
# They disagree on tool_choice semantics (see get_tool_choice_settings),
|
||||
# so the health check probes /props — served by llama-server only — and
|
||||
# Which server answers behind OLLAMA_HOST: "boilerroom", "llama-server"
|
||||
# or "ollama". They disagree on tool_choice semantics and on which
|
||||
# extension fields do anything (see get_tool_choice_settings and
|
||||
# phase_extra_body), so the health check probes for the wrapper's name
|
||||
# on /health, then for /props — served by llama-server only — and
|
||||
# caches the answer here.
|
||||
_local_flavor: str | None = None
|
||||
|
||||
@@ -58,12 +60,26 @@ async def check_ollama_health() -> bool:
|
||||
response.raise_for_status()
|
||||
names = [m.get("id", "") for m in response.json().get("data", [])]
|
||||
|
||||
# /props is llama-server's own surface; Ollama 404s it.
|
||||
# The boilerroom wrapper names itself on /health; a bare
|
||||
# llama-server answers /health without a service field and
|
||||
# serves /props (its own surface); Ollama answers neither.
|
||||
is_wrapper = False
|
||||
try:
|
||||
props = await client.get(f"{host}/props")
|
||||
_local_flavor = "llama-server" if props.status_code == 200 else "ollama"
|
||||
except httpx.HTTPError:
|
||||
_local_flavor = "ollama"
|
||||
health = await client.get(f"{host}/health")
|
||||
is_wrapper = (
|
||||
health.status_code == 200 and health.json().get("service") == "boilerroom"
|
||||
)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
is_wrapper = False
|
||||
|
||||
if is_wrapper:
|
||||
_local_flavor = "boilerroom"
|
||||
else:
|
||||
try:
|
||||
props = await client.get(f"{host}/props")
|
||||
_local_flavor = "llama-server" if props.status_code == 200 else "ollama"
|
||||
except httpx.HTTPError:
|
||||
_local_flavor = "ollama"
|
||||
|
||||
if model in names or f"{model}:latest" in names:
|
||||
_ollama_available = True
|
||||
@@ -271,36 +287,81 @@ def get_tool_choice_settings() -> ModelSettings:
|
||||
observed at cutover). Its --jinja template renders tool definitions
|
||||
the way gemma4 was trained, and the model calls tools reliably
|
||||
unforced (probed 3/3).
|
||||
|
||||
boilerroom: same as llama-server — the wrapper forwards to it, and
|
||||
its /props passthrough is what lets this detection keep working.
|
||||
"""
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
if resolve_backend() == "claude":
|
||||
# PydanticAI's Anthropic model handles tool_choice internally
|
||||
return ModelSettings()
|
||||
if _local_flavor == "llama-server":
|
||||
if _local_flavor in ("llama-server", "boilerroom"):
|
||||
return ModelSettings()
|
||||
# Ollama needs explicit tool_choice via extra_body
|
||||
return ModelSettings(extra_body={"tool_choice": "required"})
|
||||
|
||||
|
||||
def with_slot_pinning(settings: ModelSettings | None, slot: int) -> ModelSettings | None:
|
||||
"""
|
||||
Merge llama-server slot pinning into model settings when enabled.
|
||||
# Phase bindings for the local backend (this repo's T-4, decided
|
||||
# 2026-09-12): through the boilerroom wrapper each phase is a named
|
||||
# session with an eviction rank — lower parks sooner, so the most-used
|
||||
# context keeps its KV warm. The pipeline phases outrank the librarian,
|
||||
# which outranks webber (20, filed in its own repo); Open WebUI stays
|
||||
# session-less and can never evict anyone (wrapper D-4).
|
||||
# phase -> (direct llama-server slot, wrapper session name, eviction rank)
|
||||
_PHASE_BINDINGS: dict[str, tuple[int | None, str, int]] = {
|
||||
"steward": (0, "tatlock-steward", 40),
|
||||
"orchestrate": (1, "tatlock-orchestrate", 40),
|
||||
"synthesize": (2, "tatlock-synthesize", 40),
|
||||
# Experts (biographer, housekeeper) share one session between the
|
||||
# librarian and the phases: the two least-used consumers trade the
|
||||
# spare slot by rank instead of anyone hitting an empty pool — a
|
||||
# session-less call 503s once four sessions are resident (D-4's
|
||||
# idle-only rule), so every generation call here must carry a name.
|
||||
"experts": (None, "tatlock-experts", 35),
|
||||
"librarian": (None, "librarian", 30),
|
||||
}
|
||||
|
||||
Each pipeline phase owns one engine slot (steward=0, orchestrator=1,
|
||||
synthesizer=2), so the phase's stable prompt prefix stays in that
|
||||
slot's KV cache and a turn re-prefills only its new tokens. Off by
|
||||
default (BACKEND_SLOT_PINNING); a no-op on the Claude backend, and
|
||||
Ollama ignores the field, so enabling it is safe on any backend.
|
||||
|
||||
def phase_extra_body(phase: str) -> dict[str, str | int]:
|
||||
"""
|
||||
The extension fields a pipeline phase sends to the local backend.
|
||||
|
||||
Through the boilerroom wrapper: a named session with its eviction
|
||||
rank — the wrapper owns the name-to-slot map and reports parks and
|
||||
restores on the response. Against a bare llama-server: the raw
|
||||
id_slot pin this replaces (the librarian floats there, as it always
|
||||
did). Ollama and Claude get nothing, and an unprobed flavor sends
|
||||
nothing rather than guessing. Gated by BACKEND_SLOT_PINNING like
|
||||
the pinning it grew out of.
|
||||
"""
|
||||
if not config.BACKEND_SLOT_PINNING or resolve_backend() == "claude":
|
||||
return {}
|
||||
slot, session, rank = _PHASE_BINDINGS[phase]
|
||||
if _local_flavor == "boilerroom":
|
||||
return {"session": session, "eviction_order": rank}
|
||||
if _local_flavor == "llama-server" and slot is not None:
|
||||
return {"id_slot": slot}
|
||||
return {}
|
||||
|
||||
|
||||
def with_phase_binding(settings: ModelSettings | None, phase: str) -> ModelSettings | None:
|
||||
"""
|
||||
Merge a phase's local-backend binding into model settings.
|
||||
|
||||
Session fields through the wrapper, id_slot against a bare
|
||||
llama-server, settings untouched everywhere else — one call site
|
||||
stays correct on any backend (see phase_extra_body).
|
||||
"""
|
||||
extra = phase_extra_body(phase)
|
||||
if not extra:
|
||||
return settings
|
||||
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
merged = dict(settings or {})
|
||||
extra_body = dict(merged.get("extra_body") or {})
|
||||
extra_body["id_slot"] = slot
|
||||
extra_body.update(extra)
|
||||
merged["extra_body"] = extra_body
|
||||
return ModelSettings(**merged)
|
||||
|
||||
@@ -337,5 +398,6 @@ def get_model_info() -> dict:
|
||||
"claude_configured": bool(config.ANTHROPIC_API_KEY),
|
||||
"ollama_available": is_ollama_available(),
|
||||
"ollama_model": config.OLLAMA_DEFAULT_MODEL,
|
||||
"local_flavor": _local_flavor,
|
||||
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
|
||||
}
|
||||
|
||||
+77
-8
@@ -4,6 +4,17 @@ PydanticAI provider for Ollama with message sanitization.
|
||||
Ollama's OpenAI-compatible API rejects messages with `content: null`,
|
||||
which PydanticAI sends for assistant messages that only contain tool calls.
|
||||
This provider sanitizes messages to use empty strings instead of null.
|
||||
|
||||
History worth keeping (repo T-6): until 2026-09-13 the sanitized client
|
||||
was never in the request path — the provider assigned
|
||||
`self._openai_client`, an attribute nobody reads, while
|
||||
`OllamaProvider.client` serves `self._client`. The sanitizer, the
|
||||
wrapper-signal reading and the OLLAMA_TIMEOUT cap were all silently
|
||||
dead (sessions were unaffected — they ride ModelSettings). webber hit
|
||||
the identical bug the same day; both now wire the client through the
|
||||
constructor's official `openai_client` parameter, and the signals are
|
||||
read from the response body — an httpx event-hook variant demonstrably
|
||||
never fires under the openai SDK.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -17,6 +28,31 @@ from src.core.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _log_wrapper_signals(response: Any) -> None:
|
||||
"""
|
||||
Surface the boilerroom wrapper's per-response signals (repo T-4).
|
||||
|
||||
balancing names the sessions parked or restored to serve this
|
||||
request (wrapper D-4 — never silent); compaction-due says a session
|
||||
crossed its authored context budget (wrapper D-5 — a signal, not an
|
||||
interrupt). Read from the parsed body's extra fields: the wrapper
|
||||
injects them into non-streamed JSON answers and openai's pydantic
|
||||
models retain unknown fields in `model_extra`. Absent on any other
|
||||
backend, so this costs nothing there. Streams carry the signals in
|
||||
headers only and go unlogged here; the steward's raw call reads its
|
||||
own body.
|
||||
"""
|
||||
extra = getattr(response, "model_extra", None) or {}
|
||||
balancing = extra.get("balancing")
|
||||
if balancing:
|
||||
logger.info("backend_balancing", moves=balancing)
|
||||
if extra.get("compaction_due"):
|
||||
logger.warning(
|
||||
"backend_compaction_due",
|
||||
hint="GET /sessions on the wrapper names the session over budget",
|
||||
)
|
||||
|
||||
|
||||
class TatlockOllamaProvider(OllamaProvider):
|
||||
"""
|
||||
Custom OllamaProvider with message sanitization for Tatlock agents.
|
||||
@@ -36,10 +72,12 @@ class TatlockOllamaProvider(OllamaProvider):
|
||||
clean_host = str(config.OLLAMA_HOST).rstrip("/")
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
super().__init__(base_url=base_url)
|
||||
|
||||
# Override the client with our sanitized version
|
||||
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
|
||||
# The sanitized client goes through the official constructor
|
||||
# parameter: the provider's `.client` property serves `_client`,
|
||||
# and poking a lookalike attribute after super().__init__ had
|
||||
# built its own client is how the sanitizer sat bypassed in
|
||||
# production (T-6).
|
||||
super().__init__(openai_client=_SanitizedAsyncOpenAI(base_url=base_url))
|
||||
|
||||
logger.debug(
|
||||
"tatlock_ollama_provider_created",
|
||||
@@ -59,17 +97,46 @@ class _SanitizedAsyncOpenAI(AsyncOpenAI):
|
||||
super().__init__(api_key="ollama", **kwargs)
|
||||
|
||||
@property
|
||||
def chat(self) -> "_SanitizedChat":
|
||||
"""Return sanitized chat interface."""
|
||||
def chat(self) -> "_SanitizedChat": # type: ignore[override]
|
||||
"""Return sanitized chat interface.
|
||||
|
||||
Deliberately incompatible with AsyncOpenAI.chat, which is a Chat
|
||||
resource. Replacing it is the entire mechanism of this class;
|
||||
typing it as the parent's Chat would describe an object this
|
||||
class does not return. The suppression is on this member alone.
|
||||
"""
|
||||
return _SanitizedChat(self)
|
||||
|
||||
|
||||
def _parent_chat(client: AsyncOpenAI) -> Any:
|
||||
"""Get AsyncOpenAI's own `chat`, bypassing the subclass override.
|
||||
|
||||
openai has shipped `chat` as both a property (getter `.fget`) and a
|
||||
functools.cached_property (getter `.func`) — this venv's 2.11.0 is
|
||||
the latter, so the old hardcoded `.fget` access raised
|
||||
AttributeError the moment anything touched `.chat`. It went
|
||||
unnoticed here for the same reason the whole class did: the client
|
||||
was never wired (T-6). Reading whichever getter the descriptor
|
||||
exposes keeps this working across both shapes, and raises something
|
||||
legible if openai adopts a third. Ported from webber's 2026-08-11
|
||||
fix.
|
||||
"""
|
||||
descriptor = AsyncOpenAI.__dict__["chat"]
|
||||
getter = getattr(descriptor, "func", None) or getattr(descriptor, "fget", None)
|
||||
if getter is None: # pragma: no cover - defensive
|
||||
raise TypeError(
|
||||
f"AsyncOpenAI.chat is a {type(descriptor).__name__} with neither "
|
||||
"'func' nor 'fget'; the sanitising wrapper needs updating"
|
||||
)
|
||||
return getter(client)
|
||||
|
||||
|
||||
class _SanitizedChat:
|
||||
"""Chat interface wrapper with sanitized completions."""
|
||||
|
||||
def __init__(self, client: _SanitizedAsyncOpenAI):
|
||||
self._client = client
|
||||
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
|
||||
self._original_chat = _parent_chat(client)
|
||||
|
||||
@property
|
||||
def completions(self) -> "_SanitizedCompletions":
|
||||
@@ -93,7 +160,9 @@ class _SanitizedCompletions:
|
||||
if "messages" in kwargs:
|
||||
kwargs["messages"] = _sanitize_messages(kwargs["messages"])
|
||||
|
||||
return await self._original.create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
_log_wrapper_signals(response)
|
||||
return response
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -67,7 +67,10 @@ class TestGetModel:
|
||||
|
||||
|
||||
class TestToolChoiceSettings:
|
||||
def test_ollama_forces_tool_choice(self, local_first):
|
||||
def test_ollama_forces_tool_choice(self, local_first, monkeypatch):
|
||||
# Pin the flavor: the session-scoped app init probes the real
|
||||
# dev backend, and this test's answer must not depend on it.
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
|
||||
settings = model_selector.get_tool_choice_settings()
|
||||
assert settings.get("extra_body") == {"tool_choice": "required"}
|
||||
|
||||
@@ -93,33 +96,91 @@ class TestGetModelInfo:
|
||||
assert info["model"] == config.ANTHROPIC_MODEL
|
||||
|
||||
|
||||
class TestWithSlotPinning:
|
||||
class TestPhaseBinding:
|
||||
def test_disabled_returns_settings_unchanged(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", False)
|
||||
base = model_selector.get_tool_choice_settings()
|
||||
assert model_selector.with_slot_pinning(base, slot=1) is base
|
||||
assert model_selector.with_slot_pinning(None, slot=2) is None
|
||||
assert model_selector.with_phase_binding(base, "orchestrate") is base
|
||||
assert model_selector.with_phase_binding(None, "synthesize") is None
|
||||
|
||||
def test_enabled_merges_slot_with_existing_extra_body(self, local_first, monkeypatch):
|
||||
def test_wrapper_flavor_sends_session_and_rank(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
settings = model_selector.with_slot_pinning(
|
||||
model_selector.get_tool_choice_settings(), slot=1
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
|
||||
settings = model_selector.with_phase_binding(
|
||||
model_selector.get_tool_choice_settings(), "orchestrate"
|
||||
)
|
||||
extra_body = settings["extra_body"]
|
||||
assert extra_body["id_slot"] == 1
|
||||
# tool_choice from the base settings survives the merge
|
||||
assert extra_body["tool_choice"] == "required"
|
||||
assert extra_body["session"] == "tatlock-orchestrate"
|
||||
assert extra_body["eviction_order"] == 40
|
||||
assert "id_slot" not in extra_body
|
||||
|
||||
def test_enabled_pins_bare_settings(self, local_first, monkeypatch):
|
||||
def test_wrapper_ranking_matches_the_decision(self, local_first, monkeypatch):
|
||||
# Decided 2026-09-12: pipeline over librarian over webber (20,
|
||||
# filed in its own repo); lower eviction_order parks sooner.
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
settings = model_selector.with_slot_pinning(None, slot=2)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
|
||||
ranks = {
|
||||
phase: model_selector.phase_extra_body(phase)["eviction_order"]
|
||||
for phase in ("steward", "orchestrate", "synthesize", "experts", "librarian")
|
||||
}
|
||||
assert ranks["steward"] == ranks["orchestrate"] == ranks["synthesize"] == 40
|
||||
assert ranks["experts"] == 35
|
||||
assert ranks["librarian"] == 30
|
||||
assert ranks["librarian"] < ranks["experts"] < ranks["steward"]
|
||||
assert ranks["librarian"] > 20 # webber's rank stays below
|
||||
|
||||
def test_wrapper_session_names_are_stable(self, local_first, monkeypatch):
|
||||
# The wrapper's map keys on these; renaming one orphans its slot.
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
|
||||
names = {
|
||||
phase: model_selector.phase_extra_body(phase)["session"]
|
||||
for phase in ("steward", "orchestrate", "synthesize", "experts", "librarian")
|
||||
}
|
||||
assert names == {
|
||||
"steward": "tatlock-steward",
|
||||
"orchestrate": "tatlock-orchestrate",
|
||||
"synthesize": "tatlock-synthesize",
|
||||
"experts": "tatlock-experts",
|
||||
"librarian": "librarian",
|
||||
}
|
||||
|
||||
def test_direct_llama_server_keeps_id_slot(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "llama-server")
|
||||
settings = model_selector.with_phase_binding(None, "synthesize")
|
||||
assert settings["extra_body"] == {"id_slot": 2}
|
||||
|
||||
def test_claude_backend_never_pinned(self, local_first, monkeypatch):
|
||||
def test_librarian_floats_on_direct_llama_server(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "llama-server")
|
||||
assert model_selector.phase_extra_body("librarian") == {}
|
||||
|
||||
def test_ollama_flavor_gets_no_fields(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
|
||||
assert model_selector.phase_extra_body("steward") == {}
|
||||
|
||||
def test_unprobed_flavor_sends_nothing_rather_than_guessing(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", None)
|
||||
assert model_selector.phase_extra_body("orchestrate") == {}
|
||||
|
||||
def test_merge_preserves_existing_extra_body(self, local_first, monkeypatch):
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
|
||||
base = ModelSettings(extra_body={"marker": 1})
|
||||
merged = model_selector.with_phase_binding(base, "steward")
|
||||
assert merged["extra_body"]["marker"] == 1
|
||||
assert merged["extra_body"]["session"] == "tatlock-steward"
|
||||
|
||||
def test_claude_backend_never_bound(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
|
||||
base = model_selector.get_tool_choice_settings()
|
||||
assert model_selector.with_slot_pinning(base, slot=1) is base
|
||||
assert model_selector.with_phase_binding(base, "orchestrate") is base
|
||||
|
||||
|
||||
class TestLocalFlavorToolChoice:
|
||||
@@ -128,6 +189,13 @@ class TestLocalFlavorToolChoice:
|
||||
settings = model_selector.get_tool_choice_settings()
|
||||
assert not settings.get("extra_body")
|
||||
|
||||
def test_boilerroom_flavor_sends_no_tool_choice(self, local_first, monkeypatch):
|
||||
# The wrapper forwards to llama-server, which enforces
|
||||
# tool_choice — the nudge through it is the tool-loop incident.
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
|
||||
settings = model_selector.get_tool_choice_settings()
|
||||
assert not settings.get("extra_body")
|
||||
|
||||
def test_ollama_flavor_keeps_advisory_required(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
|
||||
settings = model_selector.get_tool_choice_settings()
|
||||
|
||||
@@ -254,3 +254,62 @@ class TestRedisContract:
|
||||
await client.delete("contract-test-key")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestBoilerroomWrapperContract:
|
||||
"""Boundary: the boilerroom wrapper surface session adoption rides on
|
||||
(repo T-4). Skips when OLLAMA_HOST is not the wrapper — a direct
|
||||
engine or Ollama is a valid deployment, not a violation.
|
||||
"""
|
||||
|
||||
async def _skip_unless_wrapper(self) -> None:
|
||||
response = await _get_or_skip(f"{OLLAMA}/health", "local-backend")
|
||||
try:
|
||||
service = response.json().get("service")
|
||||
except ValueError:
|
||||
service = None
|
||||
if service != "boilerroom":
|
||||
pytest.skip("OLLAMA_HOST is not the boilerroom wrapper")
|
||||
|
||||
async def test_wrapper_names_itself_on_health(self):
|
||||
# Mirrors check_ollama_health()'s wrapper probe: the flavor
|
||||
# detection keys on this exact field.
|
||||
await self._skip_unless_wrapper()
|
||||
|
||||
async def test_props_passes_through_for_flavor_detection(self):
|
||||
# tatlock treats a non-200 /props as Ollama and applies the
|
||||
# tool_choice nudge — enforced by llama-server into a tool
|
||||
# loop. The wrapper passing /props through is what keeps that
|
||||
# detection safe (boilerroom T-10).
|
||||
await self._skip_unless_wrapper()
|
||||
response = await _get_or_skip(f"{OLLAMA}/props", "local-backend")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_session_fields_answer_with_signals(self):
|
||||
# Mirrors phase_extra_body() through the wrapper: a session-
|
||||
# carrying request must come back with the balancing header and
|
||||
# the compaction signal in the body (wrapper D-3/D-4/D-5).
|
||||
await self._skip_unless_wrapper()
|
||||
response = await _post_or_skip(
|
||||
f"{OLLAMA}/v1/chat/completions",
|
||||
"local-backend",
|
||||
{
|
||||
"model": config.OLLAMA_DEFAULT_MODEL,
|
||||
# No max_tokens: gemma4 spends its first tokens thinking,
|
||||
# and a tight cap returns empty content (not a wrapper
|
||||
# fault — the sibling passthrough test runs uncapped too).
|
||||
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
"session": "contract-probe",
|
||||
"eviction_order": 1,
|
||||
},
|
||||
timeout=config.OLLAMA_TIMEOUT,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "x-boilerroom-balancing" in response.headers
|
||||
assert response.headers.get("x-boilerroom-compaction-due") in ("true", "false")
|
||||
body = response.json()
|
||||
assert isinstance(body.get("compaction_due"), bool)
|
||||
assert isinstance(body.get("balancing"), list)
|
||||
# The extension fields must not leak into the engine's answer
|
||||
assert body["choices"][0]["message"]["content"].strip()
|
||||
|
||||
@@ -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