services.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
  2. from collections.abc import Iterator
  3. from app.bootstrap.settings import ModelGatewayServiceSettings
  4. from app.db.models import ModelDefinition, ModelProviderDefinition
  5. from app.domain.repositories import ModelDefinitionRepository, ModelProviderDefinitionRepository
  6. from app.infrastructure.provider import ModelProviderClient, ModelProviderClientError
  7. from app.schemas.model import (
  8. DiscoverModelsData,
  9. DiscoverModelsRequestDto,
  10. ModelCreateRequest,
  11. ModelCreateRequestDto,
  12. ModelDeleteRequestDto,
  13. ModelDto,
  14. ModelItemDto,
  15. ModelProviderCreateRequestDto,
  16. ModelProviderDeleteRequestDto,
  17. ModelProviderDto,
  18. ModelProviderTestData,
  19. ModelProviderTestRequestDto,
  20. ModelProviderUpdateRequestDto,
  21. ModelStatusUpdateRequest,
  22. ModelTestData,
  23. ModelTestRequest,
  24. ModelTestRequestDto,
  25. ModelTestResponse,
  26. ModelUpdateRequest,
  27. ModelUpdateRequestDto,
  28. _to_snake_model_item,
  29. )
  30. class ModelGatewayApplicationService:
  31. def __init__(
  32. self,
  33. *,
  34. model_repository: ModelDefinitionRepository,
  35. provider_repository: ModelProviderDefinitionRepository,
  36. provider_client: ModelProviderClient,
  37. settings: ModelGatewayServiceSettings) -> None:
  38. self.model_repository = model_repository
  39. self.provider_repository = provider_repository
  40. self.provider_client = provider_client
  41. self.settings = settings
  42. def create_model(self, payload: ModelCreateRequest) -> ModelDefinition:
  43. provider = self._get_provider_or_raise(payload.provider_id)
  44. if provider is not None:
  45. existing = self.model_repository.get_by_provider_model(
  46. provider_id=provider.id,
  47. model_name=payload.model_name)
  48. if existing is not None:
  49. existing.name = payload.name
  50. existing.provider_type = provider.provider_type
  51. existing.provider_base_url = provider.base_url
  52. existing.provider_api_key = provider.api_key
  53. existing.description = payload.description
  54. existing.capabilities_json = payload.capabilities_json
  55. existing.context_window = payload.context_window or existing.context_window
  56. existing.max_output_tokens = payload.max_output_tokens
  57. existing.default_temperature = payload.default_temperature
  58. existing.timeout_seconds = payload.timeout_seconds
  59. existing.metadata_json = {
  60. **(existing.metadata_json or {}),
  61. **payload.metadata_json,
  62. }
  63. return self.model_repository.update(existing)
  64. code = payload.code or self._build_model_code(payload.name, payload.model_name)
  65. if self.model_repository.get_by_code(code) is not None:
  66. raise ValueError(f"model code already exists: {code}")
  67. return self.model_repository.create(
  68. ModelDefinition(
  69. code=code,
  70. name=payload.name,
  71. provider_id=provider.id if provider is not None else None,
  72. provider_type=provider.provider_type if provider is not None else payload.provider_type,
  73. provider_base_url=provider.base_url if provider is not None else str(payload.provider_base_url),
  74. provider_api_key=provider.api_key if provider is not None else payload.provider_api_key,
  75. model_name=payload.model_name,
  76. status=payload.status,
  77. description=payload.description,
  78. capabilities_json=payload.capabilities_json,
  79. context_window=payload.context_window,
  80. max_output_tokens=payload.max_output_tokens,
  81. default_temperature=payload.default_temperature,
  82. timeout_seconds=payload.timeout_seconds,
  83. metadata_json=payload.metadata_json,
  84. )
  85. )
  86. def list_models(self) -> list[ModelDefinition]:
  87. return self.model_repository.list_all()
  88. def create_model_from_contract(self, payload: ModelCreateRequestDto) -> ModelDefinition:
  89. return self.create_model(
  90. ModelCreateRequest(
  91. name=payload.name,
  92. provider_id=payload.providerId,
  93. provider_type=payload.providerType,
  94. provider_base_url=payload.providerBaseUrl or "",
  95. provider_api_key=payload.providerApiKey,
  96. model_name=payload.modelName,
  97. description=payload.description,
  98. capabilities_json=payload.capabilities,
  99. context_window=payload.contextWindow,
  100. max_output_tokens=payload.maxOutputTokens,
  101. default_temperature=payload.defaultTemperature,
  102. timeout_seconds=payload.timeoutSeconds,
  103. metadata_json=payload.metadata))
  104. def update_model_from_contract(self, payload: ModelUpdateRequestDto) -> ModelDefinition | None:
  105. updates = payload.model_dump(exclude_unset=True)
  106. updates.pop("modelId", None)
  107. mapped_updates = {
  108. "name": updates.get("name"),
  109. "provider_id": updates.get("providerId"),
  110. "provider_type": updates.get("providerType"),
  111. "provider_base_url": updates.get("providerBaseUrl"),
  112. "provider_api_key": updates.get("providerApiKey"),
  113. "model_name": updates.get("modelName"),
  114. "description": updates.get("description"),
  115. "capabilities_json": updates.get("capabilities"),
  116. "context_window": updates.get("contextWindow"),
  117. "max_output_tokens": updates.get("maxOutputTokens"),
  118. "default_temperature": updates.get("defaultTemperature"),
  119. "timeout_seconds": updates.get("timeoutSeconds"),
  120. "metadata_json": updates.get("metadata"),
  121. }
  122. return self.update_model(
  123. model_id=payload.modelId,
  124. payload=ModelUpdateRequest(
  125. **{
  126. key: value
  127. for key, value in mapped_updates.items()
  128. if value is not None
  129. }))
  130. def update_model(self, model_id: str, payload: ModelUpdateRequest) -> ModelDefinition | None:
  131. entity = self.model_repository.get_by_id(model_id)
  132. if entity is None:
  133. return None
  134. updates = payload.model_dump(exclude_unset=True)
  135. if "code" in updates and updates["code"] != entity.code:
  136. existing = self.model_repository.get_by_code(str(updates["code"]))
  137. if existing is not None and existing.id != entity.id:
  138. raise ValueError(f"model code already exists: {updates['code']}")
  139. provider = self._get_provider_or_raise(updates.get("provider_id"))
  140. if provider is not None:
  141. updates["provider_id"] = provider.id
  142. updates["provider_type"] = provider.provider_type
  143. updates["provider_base_url"] = provider.base_url
  144. updates["provider_api_key"] = provider.api_key
  145. for key, value in updates.items():
  146. if key == "provider_base_url" and value is not None:
  147. value = str(value)
  148. setattr(entity, key, value)
  149. return self.model_repository.update(entity)
  150. def update_model_status(
  151. self,
  152. model_id: str,
  153. payload: ModelStatusUpdateRequest,
  154. ) -> ModelDefinition | None:
  155. entity = self.model_repository.get_by_id(model_id)
  156. if entity is None:
  157. return None
  158. entity.status = payload.status
  159. return self.model_repository.update(entity)
  160. def delete_model(self, model_id: str) -> bool:
  161. entity = self.model_repository.get_by_id(model_id)
  162. if entity is None:
  163. return False
  164. self.model_repository.delete(entity)
  165. return True
  166. def delete_model_from_contract(self, payload: ModelDeleteRequestDto) -> bool:
  167. return self.delete_model(payload.modelId)
  168. def create_chat_completion(
  169. self,
  170. payload: ChatCompletionRequestContract) -> ChatCompletionResponseContract:
  171. configured_model = None
  172. if payload.model:
  173. configured_model = self.model_repository.get_active_for_request(payload.model)
  174. if configured_model is not None:
  175. configured_provider = self._resolve_model_provider(configured_model)
  176. resolved_payload = payload.model_copy(
  177. update={
  178. "model": configured_model.model_name,
  179. "temperature": payload.temperature
  180. if payload.temperature is not None
  181. else configured_model.default_temperature,
  182. "max_tokens": payload.max_tokens or configured_model.max_output_tokens,
  183. }
  184. )
  185. return self.provider_client.create_chat_completion(
  186. resolved_payload,
  187. provider_type=configured_provider.provider_type,
  188. provider_base_url=configured_provider.provider_base_url,
  189. provider_api_key=configured_provider.provider_api_key,
  190. timeout_seconds=configured_model.timeout_seconds,
  191. )
  192. resolved_payload = payload.model_copy(
  193. update={"model": payload.model or self.settings.default_model}
  194. )
  195. return self.provider_client.create_chat_completion(
  196. resolved_payload,
  197. provider_type=self.settings.provider_type)
  198. def stream_chat_completion(
  199. self,
  200. payload: ChatCompletionRequestContract) -> Iterator[str]:
  201. configured_model = None
  202. if payload.model:
  203. configured_model = self.model_repository.get_active_for_request(payload.model)
  204. if configured_model is not None:
  205. configured_provider = self._resolve_model_provider(configured_model)
  206. resolved_payload = payload.model_copy(
  207. update={
  208. "model": configured_model.model_name,
  209. "temperature": payload.temperature
  210. if payload.temperature is not None
  211. else configured_model.default_temperature,
  212. "max_tokens": payload.max_tokens or configured_model.max_output_tokens,
  213. }
  214. )
  215. yield from self.provider_client.stream_chat_completion(
  216. resolved_payload,
  217. provider_type=configured_provider.provider_type,
  218. provider_base_url=configured_provider.provider_base_url,
  219. provider_api_key=configured_provider.provider_api_key,
  220. timeout_seconds=configured_model.timeout_seconds,
  221. )
  222. return
  223. resolved_payload = payload.model_copy(
  224. update={"model": payload.model or self.settings.default_model}
  225. )
  226. yield from self.provider_client.stream_chat_completion(
  227. resolved_payload,
  228. provider_type=self.settings.provider_type)
  229. def test_model(self, model_id: str, payload: ModelTestRequest) -> ModelTestResponse | None:
  230. entity = self.model_repository.get_by_id(model_id)
  231. if entity is None:
  232. return None
  233. provider = self._resolve_model_provider(entity)
  234. messages = []
  235. if payload.system_prompt:
  236. messages.append({"role": "system", "content": payload.system_prompt})
  237. messages.append({"role": "user", "content": payload.prompt})
  238. response = self.provider_client.create_chat_completion(
  239. ChatCompletionRequestContract(
  240. model=entity.model_name,
  241. messages=messages,
  242. temperature=payload.temperature
  243. if payload.temperature is not None
  244. else entity.default_temperature,
  245. max_tokens=payload.max_tokens or entity.max_output_tokens,
  246. ),
  247. provider_type=provider.provider_type,
  248. provider_base_url=provider.provider_base_url,
  249. provider_api_key=provider.provider_api_key,
  250. timeout_seconds=entity.timeout_seconds,
  251. )
  252. from app.schemas.model import ModelResponse
  253. return ModelTestResponse(model=ModelResponse.from_entity(entity), response=response)
  254. def test_model_from_contract(self, payload: ModelTestRequestDto) -> ModelTestData | None:
  255. result = self.test_model(
  256. model_id=payload.modelId,
  257. payload=ModelTestRequest(
  258. prompt=payload.prompt,
  259. system_prompt=payload.systemPrompt,
  260. temperature=payload.temperature,
  261. max_tokens=payload.maxTokens))
  262. if result is None:
  263. return None
  264. entity = self.model_repository.get_by_id(payload.modelId)
  265. if entity is None:
  266. return None
  267. return ModelTestData(model=ModelDto.from_entity(entity), response=result.response)
  268. def list_providers(self) -> list[ModelProviderDefinition]:
  269. self._ensure_legacy_model_providers()
  270. return self.provider_repository.list_all()
  271. def create_provider(self, payload: ModelProviderCreateRequestDto) -> ModelProviderDefinition:
  272. provider = self.provider_repository.create(
  273. ModelProviderDefinition(
  274. name=payload.name,
  275. provider_type=payload.providerType,
  276. base_url=str(payload.baseUrl),
  277. api_key=payload.apiKey,
  278. models_json=[_to_snake_model_item(item) for item in payload.models],
  279. default_model=payload.defaultModel,
  280. extra_config_json=payload.extraConfig))
  281. self._refresh_and_sync_provider_models(provider, raise_on_empty=False)
  282. return provider
  283. def update_provider(
  284. self,
  285. payload: ModelProviderUpdateRequestDto) -> ModelProviderDefinition | None:
  286. entity = self.provider_repository.get_by_id(payload.providerId)
  287. if entity is None:
  288. return None
  289. updates = payload.model_dump(exclude_unset=True)
  290. updates.pop("providerId", None)
  291. for key, value in updates.items():
  292. if key == "baseUrl":
  293. entity.base_url = str(value) if value is not None else entity.base_url
  294. elif key == "apiKey":
  295. entity.api_key = value
  296. elif key == "defaultModel":
  297. entity.default_model = value
  298. elif key == "extraConfig":
  299. entity.extra_config_json = value
  300. elif key == "models":
  301. entity.models_json = [
  302. _to_snake_model_item(ModelItemDto(**item))
  303. if isinstance(item, dict)
  304. else _to_snake_model_item(item)
  305. for item in value or []
  306. ]
  307. elif key == "name":
  308. entity.name = value
  309. updated = self.provider_repository.update(entity)
  310. self._refresh_and_sync_provider_models(updated, raise_on_empty=False)
  311. return updated
  312. def delete_provider(self, payload: ModelProviderDeleteRequestDto) -> bool:
  313. entity = self.provider_repository.get_by_id(payload.providerId)
  314. if entity is None:
  315. return False
  316. self.provider_repository.delete(entity)
  317. return True
  318. def test_provider(self, payload: ModelProviderTestRequestDto) -> ModelProviderTestData | None:
  319. entity = self.provider_repository.get_by_id(payload.providerId)
  320. if entity is None:
  321. return None
  322. try:
  323. models = self.provider_client.list_models(
  324. provider_type=entity.provider_type,
  325. provider_base_url=entity.base_url,
  326. provider_api_key=entity.api_key)
  327. except ModelProviderClientError:
  328. models = list(entity.models_json or [])
  329. if not models:
  330. raise
  331. return ModelProviderTestData(
  332. success=True,
  333. message="Connection configuration is available.",
  334. latencyMs=0,
  335. modelList=[
  336. str(item.get("modelId") or item.get("model_id"))
  337. for item in models
  338. if item.get("modelId") or item.get("model_id")
  339. ])
  340. def discover_models(self, payload: DiscoverModelsRequestDto) -> DiscoverModelsData:
  341. provider_type = payload.providerType
  342. if payload.providerId:
  343. provider = self.provider_repository.get_by_id(payload.providerId)
  344. if provider is not None:
  345. discovered = self._refresh_and_sync_provider_models(
  346. provider,
  347. raise_on_empty=True)
  348. return DiscoverModelsData(
  349. providerType=provider.provider_type,
  350. models=discovered)
  351. if payload.baseUrl:
  352. discovered = [
  353. ModelItemDto(**item)
  354. for item in self.provider_client.list_models(
  355. provider_type=provider_type,
  356. provider_base_url=str(payload.baseUrl),
  357. provider_api_key=payload.apiKey)
  358. ]
  359. return DiscoverModelsData(
  360. providerType=provider_type or self.settings.provider_type,
  361. models=discovered)
  362. return DiscoverModelsData(
  363. providerType=provider_type or self.settings.provider_type,
  364. models=self._default_model_catalog(provider_type or self.settings.provider_type))
  365. def _default_model_catalog(self, provider_type: str) -> list[ModelItemDto]:
  366. catalogs = {
  367. "openai": [
  368. ModelItemDto(
  369. modelId="gpt-4.1-mini",
  370. displayName="GPT-4.1 Mini",
  371. modelType="chat",
  372. ownedBy="openai",
  373. contextWindow=1047576),
  374. ModelItemDto(
  375. modelId="text-embedding-3-small",
  376. displayName="Text Embedding 3 Small",
  377. modelType="embedding",
  378. ownedBy="openai"),
  379. ],
  380. "openai_compatible": [
  381. ModelItemDto(
  382. modelId="gpt-4.1-mini",
  383. displayName="OpenAI Compatible Chat",
  384. modelType="chat",
  385. contextWindow=128000),
  386. ModelItemDto(
  387. modelId="text-embedding-3-small",
  388. displayName="OpenAI Compatible Embedding",
  389. modelType="embedding"),
  390. ],
  391. "ollama": [
  392. ModelItemDto(
  393. modelId="llama3.1:8b",
  394. displayName="LLaMA 3.1 8B",
  395. modelType="chat",
  396. ownedBy="meta",
  397. contextWindow=131072),
  398. ModelItemDto(
  399. modelId="nomic-embed-text",
  400. displayName="Nomic Embed Text",
  401. modelType="embedding",
  402. ownedBy="nomic"),
  403. ],
  404. "deepseek": [
  405. ModelItemDto(
  406. modelId="deepseek-chat",
  407. displayName="DeepSeek Chat",
  408. modelType="chat",
  409. ownedBy="deepseek",
  410. contextWindow=64000),
  411. ModelItemDto(
  412. modelId="deepseek-reasoner",
  413. displayName="DeepSeek Reasoner",
  414. modelType="reasoning",
  415. ownedBy="deepseek",
  416. contextWindow=64000),
  417. ],
  418. }
  419. return catalogs.get(provider_type, [])
  420. def _build_model_code(self, name: str, model_name: str) -> str:
  421. base = "".join(
  422. char.lower() if char.isalnum() else "_"
  423. for char in f"{name}_{model_name}"
  424. ).strip("_") or "model"
  425. candidate = base[:64]
  426. suffix = 1
  427. while self.model_repository.get_by_code(candidate) is not None:
  428. suffix_text = f"_{suffix}"
  429. candidate = f"{base[:64 - len(suffix_text)]}{suffix_text}"
  430. suffix += 1
  431. return candidate
  432. def _get_provider_or_raise(self, provider_id: str | None) -> ModelProviderDefinition | None:
  433. if provider_id is None:
  434. return None
  435. provider = self.provider_repository.get_by_id(provider_id)
  436. if provider is None:
  437. raise ValueError(f"model provider not found: {provider_id}")
  438. return provider
  439. def _resolve_model_provider(self, model: ModelDefinition) -> "_ResolvedModelProvider":
  440. provider = self.provider_repository.get_by_id(model.provider_id) if model.provider_id else None
  441. if provider is not None:
  442. return _ResolvedModelProvider(
  443. provider_type=provider.provider_type,
  444. provider_base_url=provider.base_url,
  445. provider_api_key=provider.api_key)
  446. return _ResolvedModelProvider(
  447. provider_type=model.provider_type,
  448. provider_base_url=model.provider_base_url,
  449. provider_api_key=model.provider_api_key)
  450. def _ensure_legacy_model_providers(self) -> None:
  451. legacy_models = [
  452. model
  453. for model in self.model_repository.list_all()
  454. if model.provider_id is None and model.provider_base_url
  455. ]
  456. for model in legacy_models:
  457. provider = self.provider_repository.get_by_connection(
  458. provider_type=model.provider_type,
  459. base_url=model.provider_base_url)
  460. if provider is None:
  461. provider = self.provider_repository.create(
  462. ModelProviderDefinition(
  463. name=self._build_provider_name(model.provider_type, model.provider_base_url),
  464. provider_type=model.provider_type,
  465. base_url=model.provider_base_url,
  466. api_key=model.provider_api_key,
  467. models_json=[],
  468. default_model=model.model_name,
  469. extra_config_json={"source": "legacy_model_backfill"}))
  470. model.provider_id = provider.id
  471. self._append_provider_model(provider=provider, model=model)
  472. self.model_repository.update(model)
  473. self.provider_repository.update(provider)
  474. def _append_provider_model(
  475. self,
  476. *,
  477. provider: ModelProviderDefinition,
  478. model: ModelDefinition) -> None:
  479. existing_items = list(provider.models_json or [])
  480. if any(item.get("model_id") == model.model_name for item in existing_items):
  481. return
  482. existing_items.append(
  483. {
  484. "model_id": model.model_name,
  485. "display_name": model.name,
  486. "model_type": "chat",
  487. })
  488. provider.models_json = existing_items
  489. def _sync_provider_models(
  490. self,
  491. *,
  492. provider: ModelProviderDefinition,
  493. models: list[ModelItemDto]) -> None:
  494. for item in models:
  495. model_name = item.modelId.strip()
  496. if not model_name:
  497. continue
  498. existing = self.model_repository.get_by_provider_model(
  499. provider_id=provider.id,
  500. model_name=model_name)
  501. capabilities = self._capabilities_for_model_item(item, provider.provider_type)
  502. if existing is None:
  503. self.model_repository.create(
  504. ModelDefinition(
  505. code=self._build_model_code(item.displayName or model_name, model_name),
  506. name=item.displayName or model_name,
  507. provider_id=provider.id,
  508. provider_type=provider.provider_type,
  509. provider_base_url=provider.base_url,
  510. provider_api_key=provider.api_key,
  511. model_name=model_name,
  512. status="active",
  513. description=None,
  514. capabilities_json=capabilities,
  515. context_window=item.contextWindow,
  516. max_output_tokens=None,
  517. default_temperature=None,
  518. timeout_seconds=60.0,
  519. metadata_json={"source": "provider_discovery"},
  520. )
  521. )
  522. continue
  523. existing.name = item.displayName or existing.name
  524. existing.provider_type = provider.provider_type
  525. existing.provider_base_url = provider.base_url
  526. existing.provider_api_key = provider.api_key
  527. existing.capabilities_json = capabilities
  528. existing.context_window = item.contextWindow or existing.context_window
  529. existing.metadata_json = {
  530. **(existing.metadata_json or {}),
  531. "source": "provider_discovery",
  532. }
  533. self.model_repository.update(existing)
  534. def _refresh_and_sync_provider_models(
  535. self,
  536. provider: ModelProviderDefinition,
  537. *,
  538. raise_on_empty: bool) -> list[ModelItemDto]:
  539. try:
  540. discovered = [
  541. ModelItemDto(**item)
  542. for item in self.provider_client.list_models(
  543. provider_type=provider.provider_type,
  544. provider_base_url=provider.base_url,
  545. provider_api_key=provider.api_key)
  546. ]
  547. except ModelProviderClientError:
  548. discovered = ModelProviderDto.from_entity(provider).models
  549. if not discovered and provider.provider_type == "deepseek":
  550. discovered = self._default_model_catalog("deepseek")
  551. if not discovered and raise_on_empty:
  552. raise
  553. if not discovered:
  554. return []
  555. provider.models_json = [_to_snake_model_item(item) for item in discovered]
  556. if provider.default_model is None:
  557. provider.default_model = discovered[0].modelId
  558. self.provider_repository.update(provider)
  559. self._sync_provider_models(provider=provider, models=discovered)
  560. return discovered
  561. def _capabilities_for_model_item(
  562. self,
  563. item: ModelItemDto,
  564. provider_type: str) -> list[str]:
  565. model_type = item.modelType
  566. capabilities: set[str] = set()
  567. if model_type == "reasoning":
  568. capabilities.update(["chat", "reasoning"])
  569. elif model_type in {"embedding", "image", "audio", "video", "rerank", "moderation"}:
  570. capabilities.add(model_type)
  571. else:
  572. capabilities.add("chat")
  573. if provider_type in {"openai", "anthropic", "deepseek", "openai_compatible"} and "chat" in capabilities:
  574. capabilities.add("tools")
  575. return sorted(capabilities)
  576. def _build_provider_name(self, provider_type: str, base_url: str) -> str:
  577. label = provider_type.replace("_", " ").title()
  578. host = base_url.split("//")[-1].split("/")[0]
  579. return f"{label} - {host}" if host else label
  580. class _ResolvedModelProvider:
  581. def __init__(
  582. self,
  583. *,
  584. provider_type: str,
  585. provider_base_url: str,
  586. provider_api_key: str | None) -> None:
  587. self.provider_type = provider_type
  588. self.provider_base_url = provider_base_url
  589. self.provider_api_key = provider_api_key