"""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"]