| from typing import Any, Protocol |
|
|
| from app.config import Settings |
| from app.models.domain import AIProviderResponse, ToolCall |
|
|
|
|
| class ProviderError(RuntimeError): |
| pass |
|
|
|
|
| class RetryableProviderError(ProviderError): |
| pass |
|
|
|
|
| class InvalidToolCallGenerationError(RetryableProviderError): |
| pass |
|
|
|
|
| class ChatProvider(Protocol): |
| name: str |
|
|
| async def chat( |
| self, |
| messages: list[dict[str, Any]], |
| *, |
| tools: list[dict[str, Any]] | None = None, |
| tool_choice: str | dict[str, Any] | None = "auto", |
| temperature: float = 0.2, |
| ) -> AIProviderResponse: |
| ... |
|
|
|
|
| class OpenAICompatibleChatProvider: |
| name = "openai-compatible" |
|
|
| def __init__( |
| self, |
| *, |
| api_key: str, |
| base_url: str, |
| model: str, |
| timeout: float, |
| name: str, |
| ) -> None: |
| self.api_key = api_key |
| self.base_url = base_url |
| self.model = model |
| self.timeout = timeout |
| self.name = name |
| self._client: Any | None = None |
|
|
| @property |
| def client(self) -> Any: |
| if self._client is None: |
| from openai import AsyncOpenAI |
|
|
| self._client = AsyncOpenAI( |
| api_key=self.api_key, |
| base_url=self.base_url, |
| timeout=self.timeout, |
| ) |
| return self._client |
|
|
| async def chat( |
| self, |
| messages: list[dict[str, Any]], |
| *, |
| tools: list[dict[str, Any]] | None = None, |
| tool_choice: str | dict[str, Any] | None = "auto", |
| temperature: float = 0.2, |
| ) -> AIProviderResponse: |
| try: |
| kwargs: dict[str, Any] = { |
| "model": self.model, |
| "messages": messages, |
| "temperature": temperature, |
| } |
| if tools: |
| kwargs["tools"] = tools |
| kwargs["tool_choice"] = tool_choice |
|
|
| completion = await self.client.chat.completions.create(**kwargs) |
| message = completion.choices[0].message |
| return _normalize_openai_message(message) |
| except Exception as exc: |
| raise _provider_error_from_exception(exc, self.name) from exc |
|
|
|
|
| class GroqChatProvider(OpenAICompatibleChatProvider): |
| def __init__(self, settings: Settings) -> None: |
| super().__init__( |
| api_key=settings.groq_api_key, |
| base_url="https://api.groq.com/openai/v1", |
| model=settings.groq_model, |
| timeout=settings.request_timeout_seconds, |
| name="groq", |
| ) |
|
|
|
|
| class OpenRouterChatProvider(OpenAICompatibleChatProvider): |
| def __init__(self, settings: Settings) -> None: |
| super().__init__( |
| api_key=settings.openrouter_api_key, |
| base_url="https://openrouter.ai/api/v1", |
| model=settings.openrouter_model, |
| timeout=settings.request_timeout_seconds, |
| name="openrouter", |
| ) |
|
|
|
|
| def _normalize_openai_message(message: Any) -> AIProviderResponse: |
| raw_message: dict[str, Any] |
| if hasattr(message, "model_dump"): |
| raw_message = message.model_dump(exclude_none=True) |
| elif isinstance(message, dict): |
| raw_message = message |
| else: |
| raw_message = {} |
|
|
| tool_calls = [] |
| for tool_call in raw_message.get("tool_calls") or []: |
| function = tool_call.get("function") or {} |
| tool_calls.append( |
| ToolCall( |
| id=tool_call.get("id") or function.get("name", "tool-call"), |
| name=function.get("name", ""), |
| arguments=function.get("arguments") or "{}", |
| ) |
| ) |
|
|
| return AIProviderResponse( |
| content=raw_message.get("content"), |
| tool_calls=tool_calls, |
| raw_message=raw_message, |
| ) |
|
|
|
|
| def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError: |
| status_code = getattr(exc, "status_code", None) |
| body = getattr(exc, "body", None) |
| message = str(exc) |
| if body: |
| message = f"{message} {body}" |
|
|
| if status_code == 400 and "failed_generation" in message: |
| return InvalidToolCallGenerationError(f"{provider_name} generated an invalid tool call") |
|
|
| if status_code in {408, 409, 429} or (isinstance(status_code, int) and status_code >= 500): |
| return RetryableProviderError(f"{provider_name} retryable failure: {message}") |
|
|
| if exc.__class__.__name__ in {"APITimeoutError", "APIConnectionError", "RateLimitError"}: |
| return RetryableProviderError(f"{provider_name} network/rate failure: {message}") |
|
|
| return ProviderError(f"{provider_name} failure: {message}") |
|
|