Files
2026-07-13 13:08:41 +08:00

203 lines
6.3 KiB
Python

import json
from typing import Any
import aiohttp
from openai import APIError as OpenAIAPIError
from openai import AsyncOpenAI
from google import genai
from google.genai.errors import APIError as GoogleAPIError
from utils.provider_error_messages import safe_provider_error_detail
class ModelAvailabilityError(Exception):
def __init__(self, provider: str, message: str, *, provider_status_code: Any):
try:
status_code = int(provider_status_code)
except (TypeError, ValueError):
status_code = 500
self.provider = provider
self.raw_message = message
self.provider_status_code = status_code
self.status_code = 400 if 400 <= status_code < 500 else 500
super().__init__(
safe_provider_error_detail(
provider,
"model validation",
status_code=status_code,
message=message,
)
)
def _payload_error_message(payload: Any) -> str | None:
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
message = error.get("message") or error.get("detail") or error.get("code")
if message:
return str(message)
if isinstance(error, str) and error:
return error
message = payload.get("message") or payload.get("detail")
if message:
return str(message)
return None
if isinstance(payload, str) and payload:
return payload
return None
async def _aiohttp_error_message(response: aiohttp.ClientResponse) -> str:
text = await response.text()
if text:
try:
message = _payload_error_message(json.loads(text))
if message:
return message
except json.JSONDecodeError:
return text
return response.reason or f"Provider returned HTTP {response.status}"
async def _raise_for_model_response(
response: aiohttp.ClientResponse, *, provider: str
) -> None:
if response.status < 400:
return
raise ModelAvailabilityError(
provider,
await _aiohttp_error_message(response),
provider_status_code=response.status,
)
def _openai_error_message(error: OpenAIAPIError) -> str:
message = _payload_error_message(getattr(error, "body", None))
if message:
return message
return getattr(error, "message", None) or str(error)
def _google_error_message(error: GoogleAPIError) -> str:
return getattr(error, "message", None) or str(error)
def normalize_openai_compatible_base_url(url: str) -> str:
"""Ensure base URL targets the OpenAI-compatible /v1 root (LiteLLM, vLLM, etc.)."""
u = (url or "").strip().rstrip("/")
if not u:
return u
if u.endswith("/v1"):
return u
base = u.split("?", 1)[0]
if "/v1" in base:
return u
return f"{u}/v1"
def is_together_api_base_url(url: str) -> bool:
normalized = normalize_openai_compatible_base_url(url).lower()
return "api.together.ai" in normalized or "api.together.xyz" in normalized
def _model_ids_from_openai_compatible_payload(data: object) -> list[str]:
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("data", [])
if not isinstance(items, list):
return []
else:
return []
model_ids: list[str] = []
for item in items:
if isinstance(item, str) and item:
model_ids.append(item)
continue
if not isinstance(item, dict):
continue
model_id = item.get("id") or item.get("name")
if isinstance(model_id, str) and model_id:
model_ids.append(model_id)
return model_ids
async def list_together_models(url: str, api_key: str) -> list[str]:
"""
Together's /v1/models payload is not always parsed by the OpenAI Python SDK
(which expects a paginated object and calls _set_private_attributes on it).
"""
base_url = normalize_openai_compatible_base_url(url)
models_url = f"{base_url.rstrip('/')}/models"
headers = {"Authorization": f"Bearer {(api_key or '').strip()}"}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(models_url) as response:
await _raise_for_model_response(response, provider="Together")
data = await response.json()
return _model_ids_from_openai_compatible_payload(data)
async def list_available_openai_compatible_models(url: str, api_key: str) -> list[str]:
url = normalize_openai_compatible_base_url(url)
if is_together_api_base_url(url):
return await list_together_models(url, api_key)
# Local LiteLLM / OpenAI-compatible proxies often omit auth; SDK rejects a blank key.
effective_key = (api_key or "").strip() or "EMPTY"
client = AsyncOpenAI(api_key=effective_key, base_url=url)
try:
models = (await client.models.list()).data
except OpenAIAPIError as e:
raise ModelAvailabilityError(
"OpenAI-compatible provider",
_openai_error_message(e),
provider_status_code=getattr(e, "status_code", None) or 500,
) from e
if models:
return [m.id for m in models if m.id]
return []
async def list_available_anthropic_models(api_key: str) -> list[str]:
async with aiohttp.ClientSession(
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
) as session:
async with session.get(
"https://api.anthropic.com/v1/models",
params={"limit": 50},
) as response:
await _raise_for_model_response(response, provider="Anthropic")
data = await response.json()
models = data.get("data", [])
return [model.get("id") for model in models if model.get("id")]
async def list_available_google_models(api_key: str) -> list[str]:
try:
client = genai.Client(api_key=api_key)
return [x.name for x in client.models.list(config={"page_size": 50}) if x.name]
except GoogleAPIError as e:
raise ModelAvailabilityError(
"Google",
_google_error_message(e),
provider_status_code=getattr(e, "code", None)
or getattr(e, "status_code", None)
or 500,
) from e