"""Dependency-inversion ports for external model providers. The domain and service layers depend on these provider-neutral value objects and protocols. Vendor SDK/HTTP response objects must not cross this boundary. """ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Sequence from dataclasses import dataclass from enum import StrEnum from typing import Protocol TokenCounter = Callable[[str], int] class ProviderErrorKind(StrEnum): """Stable, provider-neutral error categories.""" INVALID_REQUEST = "invalid_request" AUTHENTICATION = "authentication" PERMISSION_DENIED = "permission_denied" NOT_FOUND = "not_found" RATE_LIMITED = "rate_limited" TIMEOUT = "timeout" TRANSPORT = "transport" UPSTREAM = "upstream" INVALID_RESPONSE = "invalid_response" class ModelProviderError(RuntimeError): """Sanitized provider failure safe for structured application handling. The exception intentionally never stores request payloads, response bodies, HTTP request objects, headers, or credentials. """ def __init__( self, *, operation: str, kind: ProviderErrorKind, status_code: int | None = None, provider_code: str | None = None, request_id: str | None = None, retryable: bool = False, ) -> None: self.operation = operation self.kind = kind self.status_code = status_code self.provider_code = provider_code self.request_id = request_id self.retryable = retryable details = [f"kind={kind.value}"] if status_code is not None: details.append(f"status={status_code}") if provider_code is not None: details.append(f"code={provider_code}") super().__init__(f"model provider {operation} failed ({', '.join(details)})") @dataclass(frozen=True, slots=True) class ProviderUsage: input_tokens: int | None = None output_tokens: int | None = None total_tokens: int | None = None @dataclass(frozen=True, slots=True) class EmbeddingResult: vectors: tuple[tuple[float, ...], ...] model: str request_id: str | None usage: ProviderUsage elapsed_ms: float @dataclass(frozen=True, slots=True) class RankedItem: index: int relevance_score: float document: str @dataclass(frozen=True, slots=True) class RerankResult: items: tuple[RankedItem, ...] model: str request_id: str | None usage: ProviderUsage elapsed_ms: float @dataclass(frozen=True, slots=True) class ChatMessage: role: str content: str @dataclass(frozen=True, slots=True) class ChatCompletionResult: content: str finish_reason: str | None model: str request_id: str | None usage: ProviderUsage elapsed_ms: float @dataclass(frozen=True, slots=True) class ChatStreamEvent: delta: str finish_reason: str | None model: str request_id: str | None usage: ProviderUsage elapsed_ms: float class EmbeddingProvider(Protocol): async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult: ... async def embed_query(self, text: str) -> EmbeddingResult: ... class Reranker(Protocol): async def rerank( self, query: str, documents: Sequence[str], *, top_n: int, instruct: str | None = None, ) -> RerankResult: ... class ChatProvider(Protocol): async def complete( self, messages: Sequence[ChatMessage], *, max_tokens: int, ) -> ChatCompletionResult: ... def stream( self, messages: Sequence[ChatMessage], *, max_tokens: int, ) -> AsyncIterator[ChatStreamEvent]: ...