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>
172 lines
7.7 KiB
Python
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"]
|