EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.
R03-T02 — Provider catalogue
domain/models/provider_descriptor.py ProviderDescriptor (frozen), WireProtocol, AuthKind
infrastructure/providers/provider_registry.py
thread-safe registry: id/alias lookup, dynamic
lookup by model id, adapter selection by protocol
providers/factory.py drops its own _REGISTRY table and delegates to the
registry, still raising ProviderError for callers
R03-T03 — RoutingApplicationService (pure Python, 4 modes)
application/model_routing/routing_models.py
RoutingMode (off/auto/manual/fallback),
RoutingRequest (immutable snapshot), RouteEvaluation,
RoutingOutcome
application/model_routing/routing_application_service.py
the single decision flow, reached through two narrow
ports plus a caller-supplied confirm callback, so no
Qt import is needed
application/model_routing/core_routing_adapter.py
binds the ports to core/routing and AppContext
Fallback is a new resilience mode: keep the selected model while it can serve the turn,
re-route only when it cannot. Wired end to end through config.py, state.py,
ui/routing_toggle.py and i18n.py (EN/JA/VI).
R03-T04 / T05 — Remove the duplicated routing flow
ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
logic and call the shared service; the widgets now only build a RoutingRequest, host the
Manual-mode modal and render the outcome.
R03-T06 — Token usage as an event
infrastructure/telemetry/usage_sink.py UsageEvent + UsageEventSink protocol, with tracker,
in-memory and composite sinks
providers/openai_compat.py, providers/anthropic.py
publish a UsageEvent instead of writing to the
usage tracker themselves
core/usage_tracker.py adds current_context() so a sink can borrow and
restore a thread's attribution
R03-T01 — Contract tests
tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
signature, canonical assistant message, normalised tool calls, response closed, tool schema
translation, ProviderError, list_models/test_connection, one UsageEvent per turn.
Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.
Verification
pytest tests/ 236 passed in ~1.8s (102 before this change)
scripts/check_imports.py PASS, 0 forbidden imports in domain/ and application/
new production files largest is 288 lines, all under the 400 LOC ceiling
new tests 134 (50 contract, 70 unit, 14 integration), all offline
scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""Offline transport doubles + per-protocol stream scripts for the provider contract tests.
|
|
|
|
Kept in its own module so ``test_providers.py`` stays a readable list of
|
|
assertions instead of a wall of SSE fixtures, and so the LOC ceiling (400 lines
|
|
per production file, applied here too) is comfortably met by both halves.
|
|
|
|
Nothing in here touches the network: :class:`FakeStreamResponse` mimics just
|
|
enough of ``requests.Response`` for the streaming loops in
|
|
``providers/openai_compat.py`` and ``providers/anthropic.py`` — status code,
|
|
mutable ``encoding``, ``iter_lines`` and ``close``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
# Canonical turn every protocol script below must produce, so the contract test
|
|
# can assert one expected result no matter which provider produced it.
|
|
EXPECTED_TEXT = "Hello world"
|
|
EXPECTED_TOOL_CALL = {"id": "call-1", "name": "read_file", "arguments": {"path": "a.txt"}}
|
|
EXPECTED_INPUT_TOKENS = 11
|
|
EXPECTED_OUTPUT_TOKENS = 7
|
|
EXPECTED_CACHED_TOKENS = 3
|
|
|
|
|
|
class FakeStreamResponse:
|
|
"""A minimal stand-in for a streaming ``requests.Response``.
|
|
|
|
``iter_lines`` replays pre-baked SSE lines; ``closed`` records that the
|
|
provider released the connection, which the contract asserts because a
|
|
provider that leaks the response leaks a socket per turn.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
lines: Optional[List[str]] = None,
|
|
status_code: int = 200,
|
|
body: str = "",
|
|
headers: Optional[Dict[str, str]] = None,
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
self.status_code = status_code
|
|
self._lines = list(lines or ())
|
|
self.text = body
|
|
self.headers = dict(headers or {})
|
|
self._payload = payload
|
|
self.closed = False
|
|
# Providers force UTF-8 on the response before reading it; the attribute
|
|
# simply has to exist and be writable.
|
|
self.encoding = None
|
|
|
|
def iter_lines(self, decode_unicode: bool = False):
|
|
for line in self._lines:
|
|
yield line
|
|
|
|
def json(self) -> Any:
|
|
if self._payload is None:
|
|
raise ValueError("no JSON payload configured on this fake response")
|
|
return self._payload
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _sse(payload: Dict[str, Any]) -> str:
|
|
"""One SSE ``data:`` line carrying a JSON event."""
|
|
return "data: " + json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
def openai_stream_lines() -> List[str]:
|
|
"""A complete OpenAI Chat Completions stream: text, one tool call, usage.
|
|
|
|
Split across several deltas on purpose — chunk boundaries are where naive
|
|
stream parsers break, so the contract exercises them.
|
|
"""
|
|
return [
|
|
_sse({"choices": [{"delta": {"content": "Hello "}}]}),
|
|
_sse({"choices": [{"delta": {"content": "world"}}]}),
|
|
_sse({"choices": [{"delta": {"tool_calls": [{
|
|
"index": 0, "id": "call-1",
|
|
"function": {"name": "read_file", "arguments": '{"path":'},
|
|
}]}}]}),
|
|
# Arguments arrive fragmented; the provider must concatenate before parsing.
|
|
_sse({"choices": [{"delta": {"tool_calls": [{
|
|
"index": 0, "function": {"arguments": '"a.txt"}'},
|
|
}]}}]}),
|
|
_sse({
|
|
"choices": [{"delta": {}}],
|
|
"usage": {
|
|
"prompt_tokens": EXPECTED_INPUT_TOKENS,
|
|
"completion_tokens": EXPECTED_OUTPUT_TOKENS,
|
|
"prompt_tokens_details": {"cached_tokens": EXPECTED_CACHED_TOKENS},
|
|
},
|
|
}),
|
|
"data: [DONE]",
|
|
]
|
|
|
|
|
|
def anthropic_stream_lines() -> List[str]:
|
|
"""The same canonical turn expressed as an Anthropic Messages stream."""
|
|
return [
|
|
_sse({"type": "message_start", "message": {"usage": {
|
|
"input_tokens": EXPECTED_INPUT_TOKENS,
|
|
"cache_read_input_tokens": EXPECTED_CACHED_TOKENS,
|
|
}}}),
|
|
_sse({"type": "content_block_start", "index": 0,
|
|
"content_block": {"type": "text"}}),
|
|
_sse({"type": "content_block_delta", "index": 0,
|
|
"delta": {"type": "text_delta", "text": "Hello "}}),
|
|
_sse({"type": "content_block_delta", "index": 0,
|
|
"delta": {"type": "text_delta", "text": "world"}}),
|
|
_sse({"type": "content_block_start", "index": 1, "content_block": {
|
|
"type": "tool_use", "id": "call-1", "name": "read_file"}}),
|
|
_sse({"type": "content_block_delta", "index": 1,
|
|
"delta": {"type": "input_json_delta", "partial_json": '{"path":'}}),
|
|
_sse({"type": "content_block_delta", "index": 1,
|
|
"delta": {"type": "input_json_delta", "partial_json": '"a.txt"}'}}),
|
|
_sse({"type": "message_delta",
|
|
"usage": {"output_tokens": EXPECTED_OUTPUT_TOKENS}}),
|
|
_sse({"type": "message_stop"}),
|
|
]
|
|
|
|
|
|
# Per wire protocol: how to script a successful turn, and the model-list payload
|
|
# ``list_models()`` expects. Keyed by the descriptor's wire protocol value so a
|
|
# new provider that reuses an existing protocol needs no new entry here.
|
|
PROTOCOL_FIXTURES = {
|
|
"openai_compat": {
|
|
"stream_lines": openai_stream_lines,
|
|
"models_payload": {"data": [{"id": "gpt-4o-mini"}, {"id": "gpt-4o"}]},
|
|
"expected_models": ["gpt-4o-mini", "gpt-4o"],
|
|
},
|
|
"anthropic": {
|
|
"stream_lines": anthropic_stream_lines,
|
|
"models_payload": {"data": [{"id": "claude-sonnet-4-6"}]},
|
|
"expected_models": ["claude-sonnet-4-6"],
|
|
},
|
|
}
|
|
|
|
|
|
class ScriptedTransport:
|
|
"""Replaces ``Provider._request`` and hands back scripted responses.
|
|
|
|
Records every call so a test can assert *how* the provider talked to the
|
|
endpoint (method, url, JSON payload) without a socket ever being opened.
|
|
"""
|
|
|
|
def __init__(self, responses: List[FakeStreamResponse]) -> None:
|
|
self._responses = list(responses)
|
|
self.calls: List[Dict[str, Any]] = []
|
|
|
|
def __call__(self, method: str, url: str, **kwargs) -> FakeStreamResponse:
|
|
self.calls.append({"method": method, "url": url, **kwargs})
|
|
if not self._responses:
|
|
raise AssertionError(f"unexpected extra request: {method} {url}")
|
|
# Pop in order: a provider that retries gets the NEXT scripted response,
|
|
# which is how the retry/error paths are driven.
|
|
return self._responses.pop(0)
|
|
|
|
@property
|
|
def last_payload(self) -> Dict[str, Any]:
|
|
"""The JSON body of the most recent request."""
|
|
return self.calls[-1].get("json") or {}
|
|
|
|
|
|
__all__ = [
|
|
"EXPECTED_CACHED_TOKENS",
|
|
"EXPECTED_INPUT_TOKENS",
|
|
"EXPECTED_OUTPUT_TOKENS",
|
|
"EXPECTED_TEXT",
|
|
"EXPECTED_TOOL_CALL",
|
|
"FakeStreamResponse",
|
|
"PROTOCOL_FIXTURES",
|
|
"ScriptedTransport",
|
|
"anthropic_stream_lines",
|
|
"openai_stream_lines",
|
|
]
|