whFalsa / app /ai /providers.py
codeBOKER's picture
feat: replace Gemini with OpenRouter as fallback AI provider
a54f188
Raw
History Blame Contribute Delete
4.65 kB
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: # noqa: BLE001
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}")