| """Provider-neutral model interface and retry helpers.""" |
|
|
| from __future__ import annotations |
|
|
| import time |
| from collections.abc import Mapping |
| from dataclasses import dataclass, field, replace |
| from enum import Enum |
| from typing import Any, Generic, Protocol, TypeVar |
|
|
| from pydantic import BaseModel |
|
|
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.errors import ModelRetriesExhaustedError, RetryableModelError |
|
|
| StructuredResponseT = TypeVar("StructuredResponseT", bound=BaseModel) |
|
|
|
|
| class ModelStage(str, Enum): |
| """Supported model-call stages for the MVP classifier.""" |
|
|
| TOPIC = "topic" |
| TERM = "term" |
| VARIABLE = "variable" |
|
|
|
|
| @dataclass(frozen=True) |
| class TokenUsage: |
| """Token usage metadata returned by a provider when available.""" |
|
|
| input_tokens: int | None = None |
| output_tokens: int | None = None |
| total_tokens: int | None = None |
|
|
|
|
| @dataclass(frozen=True) |
| class ModelRequest(Generic[StructuredResponseT]): |
| """Provider-neutral structured model request.""" |
|
|
| stage: ModelStage |
| prompt: str |
| response_schema: type[StructuredResponseT] |
| provider: str |
| model_name: str |
| prompt_version: str |
| temperature: float |
| timeout_seconds: float |
| metadata: Mapping[str, Any] = field(default_factory=dict) |
|
|
| @classmethod |
| def from_settings( |
| cls, |
| *, |
| stage: ModelStage, |
| prompt: str, |
| response_schema: type[StructuredResponseT], |
| settings: ModelSettings, |
| metadata: Mapping[str, Any] | None = None, |
| ) -> ModelRequest[StructuredResponseT]: |
| """Create a request from model settings for a specific stage.""" |
| return cls( |
| stage=stage, |
| prompt=prompt, |
| response_schema=response_schema, |
| provider=settings.provider, |
| model_name=settings.model_name, |
| prompt_version=settings.prompt_version_for_stage(stage.value), |
| temperature=settings.temperature, |
| timeout_seconds=settings.timeout_seconds, |
| metadata={} if metadata is None else dict(metadata), |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class ModelResponse(Generic[StructuredResponseT]): |
| """Provider-neutral structured model response.""" |
|
|
| parsed: StructuredResponseT |
| provider: str |
| model_name: str |
| prompt_version: str |
| retry_count: int = 0 |
| duration_seconds: float | None = None |
| token_usage: TokenUsage | None = None |
| estimated_cost: float | None = None |
| raw_response: str | None = None |
| warnings: tuple[str, ...] = () |
| diagnostics: Mapping[str, Any] = field(default_factory=dict) |
|
|
|
|
| class ModelClient(Protocol): |
| """Provider-neutral interface used by future classification components.""" |
|
|
| def generate_structured( |
| self, |
| request: ModelRequest[StructuredResponseT], |
| ) -> ModelResponse[StructuredResponseT]: |
| """Return a typed structured response for a model request.""" |
|
|
|
|
| @dataclass(frozen=True) |
| class RetryPolicy: |
| """Retry settings for temporary model failures.""" |
|
|
| max_retries: int = 2 |
|
|
| @classmethod |
| def from_settings(cls, settings: ModelSettings) -> RetryPolicy: |
| """Create a retry policy from model settings.""" |
| return cls(max_retries=settings.max_retries) |
|
|
|
|
| def generate_with_retries( |
| client: ModelClient, |
| request: ModelRequest[StructuredResponseT], |
| policy: RetryPolicy, |
| ) -> ModelResponse[StructuredResponseT]: |
| """Call a model client, retrying only explicitly retryable failures.""" |
| retry_count = 0 |
| started = time.perf_counter() |
| while True: |
| try: |
| response = client.generate_structured(request) |
| duration = response.duration_seconds |
| if duration is None: |
| duration = time.perf_counter() - started |
| return replace(response, retry_count=retry_count, duration_seconds=duration) |
| except RetryableModelError as exc: |
| if retry_count >= policy.max_retries: |
| raise ModelRetriesExhaustedError( |
| "Retryable model failure exceeded configured max retries.", |
| retry_count=retry_count, |
| ) from exc |
| retry_count += 1 |
|
|