Files
cowork-local/domain/models/provider_descriptor.py
T
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:22:28 +09:00

172 lines
7.7 KiB
Python

"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
Today the knowledge of "what a provider is" is scattered across three places
that must be edited together and can silently drift apart:
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
Adding a provider means remembering all three; forgetting one produces a
provider that exists but has no label, or a label with no implementation. This
value object folds those facts into a single immutable description that the
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
read, so a new provider is declared once.
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
a provider; building one is infrastructure's job.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
class ProviderCapability(str, Enum):
"""What a provider can do, as advertised by its descriptor.
Kept as a closed enum rather than free-form strings so a typo
(``"vison"``) fails at import time instead of silently disabling a feature
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
plain strings keeps working during the migration.
"""
STREAMING = "streaming" # can stream answer fragments through on_text
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
VISION = "vision" # accepts image content blocks (see providers/base.py)
REASONING = "reasoning" # emits a separate private "thinking" stream
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
@dataclass(frozen=True)
class ProviderDescriptor:
"""An immutable description of one provider the app can talk to.
Attributes:
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
of a routing candidate key (``provider/model_id``).
label: human-readable name for Settings and the model picker.
protocol: which wire format this provider speaks. Several ids share one
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
OpenAI-compatible endpoints - which is exactly why protocol and id
must be separate fields.
default_model: the model used when the user has not chosen one.
capabilities: what the provider supports (see :class:`ProviderCapability`).
requires_api_key: whether an empty ``api_key`` makes it unusable.
requires_base_url: whether an empty ``base_url`` makes it unusable.
local: True when the endpoint runs on the user's own machine. Routing
treats local models as zero-cost, and the security layer treats them
as not leaving the machine, so this is a real behavioural flag and
not just documentation.
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
"""
id: str
label: str
protocol: str
default_model: str = ""
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
requires_api_key: bool = True
requires_base_url: bool = True
local: bool = False
notes: str = ""
# -- capability queries ---------------------------------------------- #
def supports(self, capability: ProviderCapability) -> bool:
"""True when this provider advertises ``capability``."""
return capability in self.capabilities
@property
def supports_vision(self) -> bool:
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
the descriptor (no instance, no network) before building a provider."""
return self.supports(ProviderCapability.VISION)
@property
def supports_tools(self) -> bool:
"""True when this provider can run an agent turn with tools. A provider
without it can still chat, but must never be routed a tool-using task."""
return self.supports(ProviderCapability.TOOLS)
def capability_names(self) -> List[str]:
"""Capabilities as sorted plain strings - the shape the routing layer's
``required_capabilities`` filter and the assessment store both use."""
return sorted(c.value for c in self.capabilities)
# -- configuration validation ---------------------------------------- #
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
"""Which required config keys are absent or blank in ``conf``.
Returned as a list (not a bool) so Settings can tell the user exactly
what to fill in, instead of a generic "not configured". A provider that
needs nothing returns an empty list.
"""
missing: List[str] = []
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
missing.append("api_key")
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
missing.append("base_url")
return missing
def is_configured(self, conf: Mapping[str, Any]) -> bool:
"""True when ``conf`` carries everything this provider needs to run."""
return not self.missing_settings(conf)
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
requested: str = "") -> str:
"""Pick the model id for a call: explicit request, else configured, else
this descriptor's default.
Centralised here because the same three-step fallback is currently
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
and each of them gets the precedence subtly different.
"""
if requested:
return requested
configured = str((conf or {}).get("model", "") or "").strip()
return configured or self.default_model
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line summary for logs and the Settings row, e.g.
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
def candidate_key(self, model_id: str) -> str:
"""The ``provider/model_id`` identity the routing layer keys on.
Defined here so the domain owns the format; ``core.routing.models`` has
its own ``candidate_key()`` helper producing the identical string, and
keeping them equal is what lets the new registry and the existing
assessment store share one keyspace during the migration.
"""
return f"{self.id}/{model_id}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for persisting a catalogue snapshot or sending
the descriptor to a UI layer that must not import domain types."""
return {
"id": self.id,
"label": self.label,
"protocol": self.protocol,
"default_model": self.default_model,
"capabilities": self.capability_names(),
"requires_api_key": self.requires_api_key,
"requires_base_url": self.requires_base_url,
"local": self.local,
"notes": self.notes,
}
def split_candidate_key(key: str) -> Tuple[str, str]:
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
a slash (``org/model``), and splitting on the last one would corrupt them.
"""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]