service.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. from dataclasses import asdict, dataclass
  5. from pathlib import Path
  6. from typing import Any
  7. from .cache import SnapshotCache, now_iso
  8. from .config import OpenClawConfigRepository
  9. from .providers import (
  10. ProviderModel,
  11. ProviderAdapter,
  12. build_provider_adapters,
  13. fetch_with_timeout,
  14. provider_model_to_config_entry,
  15. )
  16. @dataclass
  17. class ProviderState:
  18. provider_key: str
  19. provider_label: str
  20. updated_at: str | None
  21. source: str
  22. error: str | None
  23. models: list[ProviderModel]
  24. class ModelDashboardService:
  25. def __init__(
  26. self,
  27. config_repo: OpenClawConfigRepository,
  28. cache: SnapshotCache,
  29. adapters: dict[str, ProviderAdapter] | None = None,
  30. ) -> None:
  31. self.config_repo = config_repo
  32. self.cache = cache
  33. self.adapters = adapters or build_provider_adapters()
  34. self._lock = asyncio.Lock()
  35. def configured_models(self) -> list[dict[str, Any]]:
  36. config = self.config_repo.load()
  37. items = []
  38. for model in self.config_repo.configured_models(config):
  39. items.append(
  40. {
  41. "provider_key": model.provider_key,
  42. "provider_label": model.provider_label,
  43. "model_key": model.model_key,
  44. "model_id": model.model_id,
  45. "name": model.name,
  46. "reasoning": model.reasoning,
  47. "inputs": list(model.inputs),
  48. "context_window": model.context_window,
  49. "max_tokens": model.max_tokens,
  50. "alias": model.alias,
  51. }
  52. )
  53. return items
  54. def configured_model_keys(self) -> list[str]:
  55. return [model["model_key"] for model in self.configured_models()]
  56. def load_provider_state_from_cache(self, provider_key: str) -> ProviderState | None:
  57. cached = self.cache.read(provider_key)
  58. if not cached:
  59. return None
  60. provider_models = []
  61. for item in cached.data["models"]:
  62. provider_models.append(
  63. ProviderModel(
  64. provider_key=item["provider_key"],
  65. provider_label=item["provider_label"],
  66. model_id=item["model_id"],
  67. name=item["name"],
  68. source=item["source"],
  69. reasoning=item.get("reasoning"),
  70. context_window=item.get("context_window"),
  71. max_tokens=item.get("max_tokens"),
  72. pricing_prompt=item.get("pricing_prompt"),
  73. pricing_completion=item.get("pricing_completion"),
  74. pricing_request=item.get("pricing_request"),
  75. input_modalities=tuple(item.get("input_modalities", ())),
  76. output_modalities=tuple(item.get("output_modalities", ())),
  77. supported_parameters=tuple(item.get("supported_parameters", ())),
  78. active=item.get("active"),
  79. features=tuple(item.get("features", ())),
  80. raw=item.get("raw", {}),
  81. default_parameters=item.get("default_parameters"),
  82. )
  83. )
  84. return ProviderState(
  85. provider_key=provider_key,
  86. provider_label=provider_models[0].provider_label if provider_models else provider_key,
  87. updated_at=cached.updated_at,
  88. source=cached.data.get("source", "cache"),
  89. error=cached.data.get("error"),
  90. models=provider_models,
  91. )
  92. def _configured_provider_models(self, provider_key: str) -> list[ProviderModel]:
  93. models: list[ProviderModel] = []
  94. for model in self.config_repo.configured_models():
  95. if model.provider_key != provider_key:
  96. continue
  97. raw = {
  98. "id": model.model_id,
  99. "name": model.name,
  100. "context_window": model.context_window,
  101. "max_tokens": model.max_tokens,
  102. }
  103. models.append(
  104. ProviderModel(
  105. provider_key=provider_key,
  106. provider_label=model.provider_label,
  107. model_id=model.model_id,
  108. name=model.name,
  109. source="config",
  110. reasoning=model.reasoning,
  111. context_window=model.context_window,
  112. max_tokens=model.max_tokens,
  113. pricing_prompt=None,
  114. pricing_completion=None,
  115. pricing_request=None,
  116. input_modalities=model.inputs or ("text",),
  117. output_modalities=("text",),
  118. supported_parameters=(),
  119. active=True,
  120. features=("configured",),
  121. raw=raw,
  122. )
  123. )
  124. return models
  125. async def refresh_provider(self, provider_key: str) -> ProviderState:
  126. adapter = self.adapters[provider_key]
  127. error: str | None = None
  128. source = "live"
  129. updated_at = now_iso()
  130. models: list[ProviderModel] = []
  131. try:
  132. models = await fetch_with_timeout(adapter)
  133. except Exception as exc: # pragma: no cover - network failure fallback
  134. error = str(exc)
  135. source = "config"
  136. models = self._configured_provider_models(provider_key)
  137. cached = self.load_provider_state_from_cache(provider_key)
  138. if cached:
  139. return cached
  140. payload = {
  141. "source": source,
  142. "error": error,
  143. "models": [asdict(model) for model in models],
  144. }
  145. self.cache.write(provider_key, payload)
  146. return ProviderState(
  147. provider_key=provider_key,
  148. provider_label=models[0].provider_label if models else provider_key,
  149. updated_at=updated_at,
  150. source=source,
  151. error=error,
  152. models=models,
  153. )
  154. async def add_provider_model(self, provider_key: str, model_id: str) -> dict[str, Any]:
  155. state = self.load_provider_state_from_cache(provider_key)
  156. if state is None or not any(model.model_id == model_id for model in state.models):
  157. state = await self.refresh_provider(provider_key)
  158. model = next((item for item in state.models if item.model_id == model_id), None)
  159. if model is None:
  160. raise ValueError(f"model {provider_key}/{model_id} is not available")
  161. config_entry = provider_model_to_config_entry(model)
  162. return self.config_repo.add_model(provider_key, config_entry)
  163. def delete_configured_model(self, full_key: str) -> dict[str, Any]:
  164. return self.config_repo.remove_model(full_key)
  165. async def refresh_all(self) -> dict[str, ProviderState]:
  166. states: dict[str, ProviderState] = {}
  167. for provider_key in self.adapters:
  168. states[provider_key] = await self.refresh_provider(provider_key)
  169. return states
  170. async def refresh_periodically(self, stop_event: asyncio.Event, interval_seconds: int) -> None:
  171. while not stop_event.is_set():
  172. async with self._lock:
  173. await self.refresh_all()
  174. try:
  175. await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
  176. except asyncio.TimeoutError:
  177. continue
  178. def state_payload(self) -> dict[str, Any]:
  179. providers = {}
  180. for provider_key in self.adapters:
  181. cached = self.load_provider_state_from_cache(provider_key)
  182. providers[provider_key] = self._state_to_payload(cached)
  183. return {
  184. "updated_at": now_iso(),
  185. "configured_models": self.configured_models(),
  186. "configured_model_keys": self.configured_model_keys(),
  187. "providers": providers,
  188. }
  189. def _state_to_payload(self, state: ProviderState | None) -> dict[str, Any]:
  190. if not state:
  191. return {
  192. "provider_key": None,
  193. "provider_label": None,
  194. "updated_at": None,
  195. "source": "missing",
  196. "error": "No cached data yet.",
  197. "models": [],
  198. }
  199. return {
  200. "provider_key": state.provider_key,
  201. "provider_label": state.provider_label,
  202. "updated_at": state.updated_at,
  203. "source": state.source,
  204. "error": state.error,
  205. "models": [asdict(model) for model in state.models],
  206. }
  207. def update_alias(self, full_key: str, alias: str | None) -> dict[str, Any]:
  208. return self.config_repo.update_alias(full_key, alias)
  209. def dump_state(self) -> str:
  210. return json.dumps(self.state_payload(), indent=2, ensure_ascii=False)