providers.py 11 KB

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