providers.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. from __future__ import annotations
  2. import asyncio
  3. from dataclasses import dataclass
  4. from typing import Any
  5. import httpx
  6. from .config import provider_label
  7. _VALID_INPUT_MODALITIES = {"text", "image", "video", "audio"}
  8. @dataclass(frozen=True)
  9. class ProviderModel:
  10. provider_key: str
  11. provider_label: str
  12. model_id: str
  13. name: str
  14. source: str
  15. reasoning: bool | None
  16. context_window: int | None
  17. max_tokens: int | None
  18. pricing_prompt: str | None
  19. pricing_completion: str | None
  20. pricing_request: str | None
  21. input_modalities: tuple[str, ...]
  22. output_modalities: tuple[str, ...]
  23. supported_parameters: tuple[str, ...]
  24. active: bool | None
  25. features: tuple[str, ...]
  26. raw: dict[str, Any]
  27. default_parameters: dict[str, Any] | None = None
  28. @property
  29. def key(self) -> str:
  30. return f"{self.provider_key}/{self.model_id}"
  31. def _feature_set(*values: str) -> tuple[str, ...]:
  32. seen: list[str] = []
  33. for value in values:
  34. if value and value not in seen:
  35. seen.append(value)
  36. return tuple(seen)
  37. def _format_price(value: Any) -> str | None:
  38. if value is None:
  39. return None
  40. return str(value)
  41. def infer_reasoning(raw: dict[str, Any], features: tuple[str, ...]) -> bool | None:
  42. if "reasoning" in raw:
  43. return bool(raw.get("reasoning"))
  44. if "reasoning" in features:
  45. return True
  46. default_parameters = raw.get("default_parameters")
  47. if isinstance(default_parameters, dict) and default_parameters.get("reasoning") is not None:
  48. return True
  49. return None
  50. def provider_model_to_config_entry(model: ProviderModel) -> dict[str, Any]:
  51. input_modalities = _sanitize_input_modalities(model.input_modalities)
  52. entry = {
  53. "id": model.model_id,
  54. "name": model.name,
  55. "input": list(input_modalities),
  56. "contextWindow": model.context_window,
  57. "maxTokens": model.max_tokens,
  58. }
  59. if model.reasoning is True:
  60. entry["reasoning"] = True
  61. thinking = _openrouter_thinking_parameters(model)
  62. if thinking:
  63. entry["thinking"] = thinking
  64. return entry
  65. def _sanitize_input_modalities(modalities: tuple[str, ...] | list[str] | None) -> tuple[str, ...]:
  66. filtered = tuple(modality for modality in (modalities or ()) if modality in _VALID_INPUT_MODALITIES)
  67. return filtered or ("text",)
  68. def _openrouter_thinking_parameters(model: ProviderModel) -> dict[str, Any] | None:
  69. if model.provider_key != "openrouter":
  70. return None
  71. default_parameters = model.default_parameters or {}
  72. for key in ("reasoning", "thinking"):
  73. value = default_parameters.get(key)
  74. if isinstance(value, dict) and value:
  75. return dict(value)
  76. return None
  77. def derive_features(provider_key: str, raw: dict[str, Any]) -> tuple[str, ...]:
  78. features: list[str] = []
  79. pricing = raw.get("pricing") or {}
  80. zero_pricing = False
  81. if pricing:
  82. price_values = [pricing.get("prompt"), pricing.get("completion"), pricing.get("request")]
  83. zero_pricing = all(str(item) in {"0", "0.0", "0.00000", "0.000000"} for item in price_values if item is not None)
  84. if raw.get("id", "").endswith(":free") or zero_pricing:
  85. features.append("free")
  86. supported_parameters = raw.get("supported_parameters") or []
  87. if any(param in supported_parameters for param in ("response_format", "json_schema")):
  88. features.append("structured output")
  89. if "tools" in supported_parameters:
  90. features.append("tool use")
  91. architecture = raw.get("architecture") or {}
  92. if architecture.get("input_modalities"):
  93. for modality in architecture.get("input_modalities", []):
  94. features.append(str(modality))
  95. if architecture.get("output_modalities"):
  96. for modality in architecture.get("output_modalities", []):
  97. features.append(str(modality))
  98. provider_hints = {
  99. "groq-cloud": {
  100. "openai/gpt-oss-20b": [
  101. "structured output",
  102. "browser search",
  103. "code execution",
  104. "reasoning",
  105. ],
  106. "openai/gpt-oss-120b": [
  107. "structured output",
  108. "browser search",
  109. "code execution",
  110. "reasoning",
  111. ],
  112. "openai/gpt-oss-safeguard-20b": [
  113. "structured output",
  114. "safety",
  115. ],
  116. "meta-llama/llama-4-scout-17b-16e-instruct": [
  117. "structured output",
  118. "reasoning",
  119. ],
  120. },
  121. "openai": {
  122. "gpt-5.4": ["structured output", "reasoning"],
  123. "gpt-5.4-mini": ["structured output", "reasoning"],
  124. "gpt-5.4-nano": ["structured output", "reasoning"],
  125. "gpt-5.1-codex": ["structured output", "reasoning", "code"],
  126. "gpt-5.1-codex-mini": ["structured output", "reasoning", "code"],
  127. "gpt-5.3-chat-latest": ["structured output", "reasoning"],
  128. "gpt-5-nano": ["structured output", "reasoning"],
  129. "gpt-4.1-nano-2025-04-14": ["structured output"],
  130. },
  131. }
  132. features.extend(provider_hints.get(provider_key, {}).get(raw.get("id", ""), []))
  133. if provider_key == "openrouter":
  134. for param in supported_parameters:
  135. if param == "response_format":
  136. features.append("structured output")
  137. elif param in {"tools", "tool_choice"}:
  138. features.append("tool use")
  139. elif param in {"reasoning", "thinking"} or param.startswith("reasoning"):
  140. features.append("reasoning")
  141. elif param == "seed":
  142. features.append("seed")
  143. if raw.get("active") is False:
  144. features.append("inactive")
  145. return _feature_set(*features)
  146. class ProviderAdapter:
  147. provider_key: str
  148. base_url: str
  149. api_key_env: str
  150. model_path: str
  151. def __init__(self, base_url: str, api_key_env: str) -> None:
  152. self.base_url = base_url.rstrip("/")
  153. self.api_key_env = api_key_env
  154. def api_key(self) -> str:
  155. import os
  156. return os.environ.get(self.api_key_env, "").strip()
  157. def headers(self) -> dict[str, str]:
  158. headers = {"Content-Type": "application/json"}
  159. api_key = self.api_key()
  160. if api_key:
  161. headers["Authorization"] = f"Bearer {api_key}"
  162. return headers
  163. async def fetch(self) -> list[ProviderModel]:
  164. raise NotImplementedError
  165. async def _get_json(self, path: str) -> dict[str, Any]:
  166. async with httpx.AsyncClient(timeout=20.0) as client:
  167. response = await client.get(f"{self.base_url}{path}", headers=self.headers())
  168. response.raise_for_status()
  169. return response.json()
  170. class OpenRouterAdapter(ProviderAdapter):
  171. provider_key = "openrouter"
  172. model_path = "/models"
  173. async def fetch(self) -> list[ProviderModel]:
  174. if not self.api_key():
  175. raise RuntimeError("missing OPENROUTER_API_KEY")
  176. payload = await self._get_json(self.model_path)
  177. models: list[ProviderModel] = []
  178. for item in payload.get("data", []):
  179. pricing = item.get("pricing") or {}
  180. architecture = item.get("architecture") or {}
  181. default_parameters = item.get("default_parameters") or {}
  182. raw = dict(item)
  183. raw["supported_parameters"] = item.get("supported_parameters") or []
  184. raw["pricing"] = pricing
  185. raw["architecture"] = architecture
  186. raw["default_parameters"] = default_parameters
  187. features = derive_features(self.provider_key, raw)
  188. models.append(
  189. ProviderModel(
  190. provider_key=self.provider_key,
  191. provider_label=provider_label(self.provider_key),
  192. model_id=str(item.get("id")),
  193. name=str(item.get("name", item.get("id"))),
  194. source="live",
  195. reasoning=infer_reasoning(raw, features),
  196. context_window=item.get("context_length"),
  197. max_tokens=(item.get("top_provider") or {}).get("max_completion_tokens"),
  198. pricing_prompt=_format_price(pricing.get("prompt")),
  199. pricing_completion=_format_price(pricing.get("completion")),
  200. pricing_request=_format_price(pricing.get("request")),
  201. input_modalities=_sanitize_input_modalities(architecture.get("input_modalities")),
  202. output_modalities=tuple(architecture.get("output_modalities") or ()),
  203. supported_parameters=tuple(item.get("supported_parameters") or ()),
  204. active=None,
  205. features=features,
  206. raw=raw,
  207. default_parameters=default_parameters if isinstance(default_parameters, dict) else None,
  208. )
  209. )
  210. return models
  211. class GroqAdapter(ProviderAdapter):
  212. provider_key = "groq-cloud"
  213. model_path = "/models"
  214. async def fetch(self) -> list[ProviderModel]:
  215. if not self.api_key():
  216. raise RuntimeError("missing GROQ_API_KEY")
  217. payload = await self._get_json(self.model_path)
  218. models: list[ProviderModel] = []
  219. for item in payload.get("data", []):
  220. raw = dict(item)
  221. features = derive_features(self.provider_key, raw)
  222. models.append(
  223. ProviderModel(
  224. provider_key=self.provider_key,
  225. provider_label=provider_label(self.provider_key),
  226. model_id=str(item.get("id")),
  227. name=str(item.get("id")),
  228. source="live",
  229. reasoning=infer_reasoning(raw, features),
  230. context_window=item.get("context_window"),
  231. max_tokens=None,
  232. pricing_prompt=None,
  233. pricing_completion=None,
  234. pricing_request=None,
  235. input_modalities=("text",),
  236. output_modalities=("text",),
  237. supported_parameters=(),
  238. active=item.get("active"),
  239. features=features,
  240. raw=raw,
  241. )
  242. )
  243. return models
  244. class OpenAIAdapter(ProviderAdapter):
  245. provider_key = "openai"
  246. model_path = "/models"
  247. async def fetch(self) -> list[ProviderModel]:
  248. if not self.api_key():
  249. raise RuntimeError("missing OPENAI_API_KEY")
  250. payload = await self._get_json(self.model_path)
  251. models: list[ProviderModel] = []
  252. for item in payload.get("data", []):
  253. raw = dict(item)
  254. features = derive_features(self.provider_key, raw)
  255. models.append(
  256. ProviderModel(
  257. provider_key=self.provider_key,
  258. provider_label=provider_label(self.provider_key),
  259. model_id=str(item.get("id")),
  260. name=str(item.get("id")),
  261. source="live",
  262. reasoning=infer_reasoning(raw, features),
  263. context_window=None,
  264. max_tokens=None,
  265. pricing_prompt=None,
  266. pricing_completion=None,
  267. pricing_request=None,
  268. input_modalities=("text",),
  269. output_modalities=("text",),
  270. supported_parameters=(),
  271. active=None,
  272. features=features,
  273. raw=raw,
  274. )
  275. )
  276. return models
  277. def build_provider_adapters() -> dict[str, ProviderAdapter]:
  278. return {
  279. "openai": OpenAIAdapter("https://api.openai.com/v1", "OPENAI_API_KEY"),
  280. "groq-cloud": GroqAdapter("https://api.groq.com/openai/v1", "GROQ_API_KEY"),
  281. "openrouter": OpenRouterAdapter("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
  282. }
  283. async def fetch_with_timeout(adapter: ProviderAdapter) -> list[ProviderModel]:
  284. return await asyncio.wait_for(adapter.fetch(), timeout=30.0)