Coverage for integrations / providers / registry.py: 87.1%

256 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2ProviderRegistry — catalog of all compute providers and business services. 

3 

4Each provider has: 

5 - API endpoint format + auth method 

6 - Supported models + capabilities 

7 - Pricing (per token / per second / per image / flat) 

8 - Latency/throughput from efficiency matrix 

9 - Commission rate (for affiliate services) 

10 - Health status (last check, error rate) 

11 

12JSON-persisted at ~/Documents/Nunba/data/provider_registry.json. 

13Thread-safe singleton via get_registry(). 

14""" 

15 

16import json 

17import logging 

18import os 

19import threading 

20import time 

21from dataclasses import dataclass, field, asdict 

22from pathlib import Path 

23from typing import Any, Dict, List, Optional 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28# ═══════════════════════════════════════════════════════════════════════ 

29# Provider types 

30# ═══════════════════════════════════════════════════════════════════════ 

31 

32PROVIDER_TYPE_API = 'api' # Raw API — we call it, we pay 

33PROVIDER_TYPE_AFFILIATE = 'affiliate' # Business service — redirect, earn commission 

34PROVIDER_TYPE_LOCAL = 'local' # Local model (llama.cpp, torch, piper) 

35 

36# Auth methods 

37AUTH_BEARER = 'bearer' # Authorization: Bearer <key> 

38AUTH_HEADER = 'header' # Custom header (e.g. x-api-key) 

39AUTH_QUERY = 'query' # API key in query string 

40AUTH_NONE = 'none' # No auth (local, free tiers) 

41 

42# Pricing units 

43PRICE_PER_1K_TOKENS = 'per_1k_tokens' 

44PRICE_PER_1M_TOKENS = 'per_1m_tokens' 

45PRICE_PER_SECOND = 'per_second' # Video/audio generation 

46PRICE_PER_IMAGE = 'per_image' 

47PRICE_PER_REQUEST = 'per_request' 

48PRICE_FLAT_MONTHLY = 'flat_monthly' 

49PRICE_FREE = 'free' 

50 

51 

52@dataclass 

53class ProviderModel: 

54 """A model available on a specific provider.""" 

55 model_id: str # Provider's model ID (e.g. "meta/llama-3.1-70b") 

56 canonical_id: str = '' # Our catalog ID for cross-provider matching 

57 model_type: str = 'llm' # llm, tts, stt, vlm, image_gen, video_gen, etc. 

58 context_length: int = 0 

59 max_output_tokens: int = 0 

60 

61 # Pricing (provider-specific) 

62 input_price: float = 0.0 # Cost per pricing_unit for input 

63 output_price: float = 0.0 # Cost per pricing_unit for output 

64 pricing_unit: str = PRICE_PER_1M_TOKENS 

65 

66 # Capabilities 

67 supports_streaming: bool = True 

68 supports_tools: bool = False 

69 supports_vision: bool = False 

70 supports_json_mode: bool = False 

71 max_images: int = 0 # For image/video gen: max batch size 

72 max_duration_s: float = 0 # For video/audio gen: max duration 

73 

74 # Performance (populated by efficiency matrix) 

75 avg_tok_per_s: float = 0.0 

76 avg_latency_ms: float = 0.0 

77 quality_score: float = 0.5 # 0-1, from benchmarks 

78 reliability: float = 1.0 # 0-1, success rate 

79 

80 enabled: bool = True 

81 

82 

83@dataclass 

84class Provider: 

85 """A compute provider or business service.""" 

86 

87 # ── Identity ───────────────────────────────────────────────────── 

88 id: str # Unique slug: "together", "replicate", "seedance" 

89 name: str # Display name: "Together AI" 

90 provider_type: str = PROVIDER_TYPE_API # api, affiliate, local 

91 url: str = '' # Base URL or website 

92 docs_url: str = '' # API docs URL 

93 logo_url: str = '' # Logo for UI 

94 

95 # ── API Configuration ──────────────────────────────────────────── 

96 base_url: str = '' # API base: "https://api.together.xyz/v1" 

97 api_format: str = 'openai' # openai, replicate, custom 

98 auth_method: str = AUTH_BEARER 

99 auth_header: str = 'Authorization' # Header name for custom auth 

100 env_key: str = '' # Env var for API key: "TOGETHER_API_KEY" 

101 

102 # ── Models ─────────────────────────────────────────────────────── 

103 models: Dict[str, ProviderModel] = field(default_factory=dict) 

104 

105 # ── Affiliate / Commission ─────────────────────────────────────── 

106 affiliate_url: str = '' # Affiliate signup URL 

107 affiliate_tag: str = '' # Our affiliate ID/tag 

108 commission_pct: float = 0.0 # Commission percentage (e.g. 20.0 = 20%) 

109 commission_type: str = '' # 'recurring', 'one_time', 'revenue_share' 

110 referral_url_template: str = '' # Template: "https://site.com/?ref={tag}" 

111 

112 # ── Health ─────────────────────────────────────────────────────── 

113 healthy: bool = True 

114 last_health_check: float = 0.0 

115 error_rate_24h: float = 0.0 

116 avg_latency_ms: float = 0.0 

117 

118 # ── State ──────────────────────────────────────────────────────── 

119 enabled: bool = True 

120 api_key_set: bool = False # Whether user has configured API key 

121 

122 # ── Tags ───────────────────────────────────────────────────────── 

123 tags: List[str] = field(default_factory=list) 

124 categories: List[str] = field(default_factory=list) # "llm", "image", "video", etc. 

125 

126 def to_dict(self) -> dict: 

127 d = asdict(self) 

128 d['models'] = {k: asdict(v) for k, v in self.models.items()} 

129 # api_key_set is runtime-only — compute from env, never persist 

130 d.pop('api_key_set', None) 

131 return d 

132 

133 @classmethod 

134 def from_dict(cls, d: dict) -> 'Provider': 

135 d = dict(d) # Don't mutate caller's dict 

136 models_raw = d.pop('models', {}) 

137 known = {f.name for f in cls.__dataclass_fields__.values()} 

138 filtered = {k: v for k, v in d.items() if k in known} 

139 p = cls(**filtered) 

140 for k, v in models_raw.items(): 

141 if isinstance(v, dict): 

142 pm_known = {f.name for f in ProviderModel.__dataclass_fields__.values()} 

143 p.models[k] = ProviderModel(**{fk: fv for fk, fv in v.items() if fk in pm_known}) 

144 return p 

145 

146 def get_api_key(self) -> str: 

147 """Resolve API key from env var.""" 

148 if not self.env_key: 

149 return '' 

150 return os.environ.get(self.env_key, '') 

151 

152 def has_api_key(self) -> bool: 

153 return bool(self.get_api_key()) 

154 

155 

156# ═══════════════════════════════════════════════════════════════════════ 

157# Built-in providers — raw API providers with OpenAI-compatible endpoints 

158# ═══════════════════════════════════════════════════════════════════════ 

159 

160def _builtin_providers() -> List[Provider]: 

161 """Return the built-in provider catalog. 

162 

163 Pricing as of 2025-Q2. Updated by efficiency matrix at runtime. 

164 """ 

165 return [ 

166 # ── LLM / Multi-modal API providers ────────────────────────── 

167 Provider( 

168 id='together', name='Together AI', 

169 provider_type=PROVIDER_TYPE_API, 

170 url='https://together.ai', 

171 base_url='https://api.together.xyz/v1', 

172 api_format='openai', 

173 env_key='TOGETHER_API_KEY', 

174 categories=['llm', 'image_gen', 'embedding'], 

175 tags=['fast', 'cheap', 'openai-compatible'], 

176 models={ 

177 'meta-llama/Llama-3.3-70B-Instruct-Turbo': ProviderModel( 

178 model_id='meta-llama/Llama-3.3-70B-Instruct-Turbo', 

179 canonical_id='llama-3.3-70b', model_type='llm', 

180 context_length=131072, max_output_tokens=4096, 

181 input_price=0.88, output_price=0.88, 

182 pricing_unit=PRICE_PER_1M_TOKENS, 

183 supports_tools=True, supports_streaming=True, 

184 ), 

185 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8': ProviderModel( 

186 model_id='meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8', 

187 canonical_id='llama-4-maverick', model_type='llm', 

188 context_length=1048576, max_output_tokens=4096, 

189 input_price=0.27, output_price=0.85, 

190 pricing_unit=PRICE_PER_1M_TOKENS, 

191 supports_tools=True, supports_vision=True, 

192 ), 

193 'Qwen/Qwen2.5-72B-Instruct-Turbo': ProviderModel( 

194 model_id='Qwen/Qwen2.5-72B-Instruct-Turbo', 

195 canonical_id='qwen2.5-72b', model_type='llm', 

196 context_length=131072, max_output_tokens=4096, 

197 input_price=1.20, output_price=1.20, 

198 pricing_unit=PRICE_PER_1M_TOKENS, 

199 supports_tools=True, 

200 ), 

201 'deepseek-ai/DeepSeek-V3': ProviderModel( 

202 model_id='deepseek-ai/DeepSeek-V3', 

203 canonical_id='deepseek-v3', model_type='llm', 

204 context_length=131072, max_output_tokens=4096, 

205 input_price=0.90, output_price=0.90, 

206 pricing_unit=PRICE_PER_1M_TOKENS, 

207 supports_tools=True, 

208 ), 

209 }, 

210 ), 

211 

212 Provider( 

213 id='fireworks', name='Fireworks AI', 

214 provider_type=PROVIDER_TYPE_API, 

215 url='https://fireworks.ai', 

216 base_url='https://api.fireworks.ai/inference/v1', 

217 api_format='openai', 

218 env_key='FIREWORKS_API_KEY', 

219 categories=['llm', 'image_gen', 'embedding'], 

220 tags=['fast', 'cheap', 'openai-compatible', 'function-calling'], 

221 models={ 

222 'accounts/fireworks/models/llama-v3p3-70b-instruct': ProviderModel( 

223 model_id='accounts/fireworks/models/llama-v3p3-70b-instruct', 

224 canonical_id='llama-3.3-70b', model_type='llm', 

225 context_length=131072, max_output_tokens=4096, 

226 input_price=0.90, output_price=0.90, 

227 pricing_unit=PRICE_PER_1M_TOKENS, 

228 supports_tools=True, supports_json_mode=True, 

229 ), 

230 'accounts/fireworks/models/deepseek-v3': ProviderModel( 

231 model_id='accounts/fireworks/models/deepseek-v3', 

232 canonical_id='deepseek-v3', model_type='llm', 

233 context_length=131072, max_output_tokens=4096, 

234 input_price=0.90, output_price=0.90, 

235 pricing_unit=PRICE_PER_1M_TOKENS, 

236 supports_tools=True, 

237 ), 

238 }, 

239 ), 

240 

241 Provider( 

242 id='groq', name='Groq', 

243 provider_type=PROVIDER_TYPE_API, 

244 url='https://groq.com', 

245 base_url='https://api.groq.com/openai/v1', 

246 api_format='openai', 

247 env_key='GROQ_API_KEY', 

248 categories=['llm'], 

249 tags=['fastest', 'openai-compatible', 'free-tier'], 

250 models={ 

251 'llama-3.3-70b-versatile': ProviderModel( 

252 model_id='llama-3.3-70b-versatile', 

253 canonical_id='llama-3.3-70b', model_type='llm', 

254 context_length=131072, max_output_tokens=32768, 

255 input_price=0.59, output_price=0.79, 

256 pricing_unit=PRICE_PER_1M_TOKENS, 

257 supports_tools=True, 

258 ), 

259 'deepseek-r1-distill-llama-70b': ProviderModel( 

260 model_id='deepseek-r1-distill-llama-70b', 

261 canonical_id='deepseek-r1-70b', model_type='llm', 

262 context_length=131072, max_output_tokens=16384, 

263 input_price=0.75, output_price=0.99, 

264 pricing_unit=PRICE_PER_1M_TOKENS, 

265 ), 

266 'llama-3.2-90b-vision-preview': ProviderModel( 

267 model_id='llama-3.2-90b-vision-preview', 

268 canonical_id='llama-3.2-90b-vision', model_type='vlm', 

269 context_length=8192, max_output_tokens=8192, 

270 input_price=0.90, output_price=0.90, 

271 pricing_unit=PRICE_PER_1M_TOKENS, 

272 supports_vision=True, 

273 ), 

274 }, 

275 ), 

276 

277 Provider( 

278 id='deepinfra', name='DeepInfra', 

279 provider_type=PROVIDER_TYPE_API, 

280 url='https://deepinfra.com', 

281 base_url='https://api.deepinfra.com/v1/openai', 

282 api_format='openai', 

283 env_key='DEEPINFRA_API_KEY', 

284 categories=['llm', 'embedding', 'image_gen'], 

285 tags=['cheap', 'openai-compatible'], 

286 models={ 

287 'meta-llama/Llama-3.3-70B-Instruct-Turbo': ProviderModel( 

288 model_id='meta-llama/Llama-3.3-70B-Instruct-Turbo', 

289 canonical_id='llama-3.3-70b', model_type='llm', 

290 context_length=131072, max_output_tokens=4096, 

291 input_price=0.35, output_price=0.40, 

292 pricing_unit=PRICE_PER_1M_TOKENS, 

293 supports_tools=True, 

294 ), 

295 'Qwen/QwQ-32B': ProviderModel( 

296 model_id='Qwen/QwQ-32B', 

297 canonical_id='qwq-32b', model_type='llm', 

298 context_length=131072, max_output_tokens=32768, 

299 input_price=0.15, output_price=0.60, 

300 pricing_unit=PRICE_PER_1M_TOKENS, 

301 supports_tools=True, 

302 ), 

303 }, 

304 ), 

305 

306 Provider( 

307 id='cerebras', name='Cerebras', 

308 provider_type=PROVIDER_TYPE_API, 

309 url='https://cerebras.ai', 

310 base_url='https://api.cerebras.ai/v1', 

311 api_format='openai', 

312 env_key='CEREBRAS_API_KEY', 

313 categories=['llm'], 

314 tags=['fastest-inference', 'openai-compatible'], 

315 models={ 

316 'llama-3.3-70b': ProviderModel( 

317 model_id='llama-3.3-70b', 

318 canonical_id='llama-3.3-70b', model_type='llm', 

319 context_length=8192, max_output_tokens=8192, 

320 input_price=0.60, output_price=0.60, 

321 pricing_unit=PRICE_PER_1M_TOKENS, 

322 supports_tools=True, 

323 ), 

324 }, 

325 ), 

326 

327 Provider( 

328 id='sambanova', name='SambaNova', 

329 provider_type=PROVIDER_TYPE_API, 

330 url='https://sambanova.ai', 

331 base_url='https://api.sambanova.ai/v1', 

332 api_format='openai', 

333 env_key='SAMBANOVA_API_KEY', 

334 categories=['llm'], 

335 tags=['fast', 'openai-compatible', 'free-tier'], 

336 models={ 

337 'Meta-Llama-3.3-70B-Instruct': ProviderModel( 

338 model_id='Meta-Llama-3.3-70B-Instruct', 

339 canonical_id='llama-3.3-70b', model_type='llm', 

340 context_length=131072, max_output_tokens=4096, 

341 input_price=0.60, output_price=0.60, 

342 pricing_unit=PRICE_PER_1M_TOKENS, 

343 ), 

344 'DeepSeek-R1': ProviderModel( 

345 model_id='DeepSeek-R1', 

346 canonical_id='deepseek-r1', model_type='llm', 

347 context_length=131072, max_output_tokens=16384, 

348 input_price=2.50, output_price=10.00, 

349 pricing_unit=PRICE_PER_1M_TOKENS, 

350 ), 

351 }, 

352 ), 

353 

354 Provider( 

355 id='openrouter', name='OpenRouter', 

356 provider_type=PROVIDER_TYPE_API, 

357 url='https://openrouter.ai', 

358 base_url='https://openrouter.ai/api/v1', 

359 api_format='openai', 

360 env_key='OPENROUTER_API_KEY', 

361 categories=['llm', 'vlm', 'image_gen'], 

362 tags=['aggregator', 'openai-compatible', 'all-models'], 

363 commission_pct=0.0, # OpenRouter takes margin, we use as fallback 

364 ), 

365 

366 Provider( 

367 id='replicate', name='Replicate', 

368 provider_type=PROVIDER_TYPE_API, 

369 url='https://replicate.com', 

370 base_url='https://api.replicate.com/v1', 

371 api_format='replicate', 

372 env_key='REPLICATE_API_TOKEN', 

373 categories=['llm', 'image_gen', 'video_gen', 'audio_gen', 'vlm'], 

374 tags=['serverless', 'gpu-on-demand', 'all-model-types'], 

375 models={ 

376 'meta/meta-llama-3-70b-instruct': ProviderModel( 

377 model_id='meta/meta-llama-3-70b-instruct', 

378 canonical_id='llama-3-70b', model_type='llm', 

379 input_price=0.65, output_price=2.75, 

380 pricing_unit=PRICE_PER_1M_TOKENS, 

381 ), 

382 'black-forest-labs/flux-1.1-pro': ProviderModel( 

383 model_id='black-forest-labs/flux-1.1-pro', 

384 canonical_id='flux-1.1-pro', model_type='image_gen', 

385 input_price=0.04, output_price=0.0, 

386 pricing_unit=PRICE_PER_IMAGE, 

387 ), 

388 'minimax/video-01': ProviderModel( 

389 model_id='minimax/video-01', 

390 canonical_id='video-01', model_type='video_gen', 

391 input_price=0.25, output_price=0.0, 

392 pricing_unit=PRICE_PER_SECOND, 

393 max_duration_s=6, 

394 ), 

395 }, 

396 ), 

397 

398 Provider( 

399 id='fal', name='fal.ai', 

400 provider_type=PROVIDER_TYPE_API, 

401 url='https://fal.ai', 

402 base_url='https://fal.run', 

403 api_format='custom', 

404 auth_method=AUTH_HEADER, 

405 auth_header='Authorization', 

406 env_key='FAL_KEY', 

407 categories=['image_gen', 'video_gen', 'audio_gen', 'llm'], 

408 tags=['serverless', 'fast', 'media-generation'], 

409 models={ 

410 'fal-ai/flux-pro/v1.1': ProviderModel( 

411 model_id='fal-ai/flux-pro/v1.1', 

412 canonical_id='flux-1.1-pro', model_type='image_gen', 

413 input_price=0.04, output_price=0.0, 

414 pricing_unit=PRICE_PER_IMAGE, 

415 ), 

416 'fal-ai/minimax/video-01': ProviderModel( 

417 model_id='fal-ai/minimax/video-01', 

418 canonical_id='video-01', model_type='video_gen', 

419 input_price=0.50, output_price=0.0, 

420 pricing_unit=PRICE_PER_SECOND, 

421 max_duration_s=6, 

422 ), 

423 'fal-ai/stable-audio': ProviderModel( 

424 model_id='fal-ai/stable-audio', 

425 canonical_id='stable-audio', model_type='audio_gen', 

426 input_price=0.04, output_price=0.0, 

427 pricing_unit=PRICE_PER_REQUEST, 

428 ), 

429 }, 

430 ), 

431 

432 Provider( 

433 id='huggingface', name='HuggingFace Inference', 

434 provider_type=PROVIDER_TYPE_API, 

435 url='https://huggingface.co', 

436 base_url='https://api-inference.huggingface.co', 

437 api_format='custom', 

438 env_key='HF_TOKEN', 

439 categories=['llm', 'image_gen', 'embedding', 'stt', 'tts'], 

440 tags=['free-tier', 'all-model-types', 'open-source'], 

441 ), 

442 

443 # ── Affiliate / Commission providers ───────────────────────── 

444 

445 Provider( 

446 id='runwayml', name='RunwayML', 

447 provider_type=PROVIDER_TYPE_AFFILIATE, 

448 url='https://runwayml.com', 

449 categories=['video_gen', 'image_gen'], 

450 tags=['video', 'professional', 'gen-3'], 

451 affiliate_url='https://runwayml.com/affiliates', 

452 commission_pct=20.0, 

453 commission_type='recurring', 

454 referral_url_template='https://runwayml.com/?ref={tag}', 

455 ), 

456 

457 Provider( 

458 id='elevenlabs', name='ElevenLabs', 

459 provider_type=PROVIDER_TYPE_AFFILIATE, 

460 url='https://elevenlabs.io', 

461 categories=['tts', 'audio_gen', 'stt'], 

462 tags=['voice', 'tts', 'voice-cloning'], 

463 affiliate_url='https://elevenlabs.io/affiliates', 

464 commission_pct=22.0, 

465 commission_type='recurring', 

466 referral_url_template='https://elevenlabs.io/?via={tag}', 

467 ), 

468 

469 Provider( 

470 id='midjourney', name='Midjourney', 

471 provider_type=PROVIDER_TYPE_AFFILIATE, 

472 url='https://midjourney.com', 

473 categories=['image_gen'], 

474 tags=['image', 'art', 'creative'], 

475 commission_pct=0.0, # No public affiliate program 

476 ), 

477 

478 Provider( 

479 id='pika', name='Pika', 

480 provider_type=PROVIDER_TYPE_AFFILIATE, 

481 url='https://pika.art', 

482 categories=['video_gen'], 

483 tags=['video', 'consumer'], 

484 commission_pct=0.0, 

485 ), 

486 

487 Provider( 

488 id='kling', name='Kling AI', 

489 provider_type=PROVIDER_TYPE_AFFILIATE, 

490 url='https://klingai.com', 

491 categories=['video_gen', 'image_gen'], 

492 tags=['video', 'chinese-ai'], 

493 commission_pct=0.0, 

494 ), 

495 

496 Provider( 

497 id='luma', name='Luma AI', 

498 provider_type=PROVIDER_TYPE_AFFILIATE, 

499 url='https://lumalabs.ai', 

500 categories=['video_gen', '3d_gen'], 

501 tags=['video', 'dream-machine', '3d'], 

502 commission_pct=0.0, 

503 ), 

504 

505 Provider( 

506 id='seedance', name='Seedance AI', 

507 provider_type=PROVIDER_TYPE_AFFILIATE, 

508 url='https://www.seedance2ai.io', 

509 categories=['video_gen', 'image_gen', 'audio_gen'], 

510 tags=['video', 'multi-model', 'cheap'], 

511 commission_pct=0.0, 

512 ), 

513 

514 Provider( 

515 id='sora', name='OpenAI Sora', 

516 provider_type=PROVIDER_TYPE_AFFILIATE, 

517 url='https://sora.com', 

518 categories=['video_gen'], 

519 tags=['video', 'openai'], 

520 commission_pct=0.0, 

521 ), 

522 

523 # ── Local provider (built-in) ──────────────────────────────── 

524 

525 Provider( 

526 id='local', name='Local (on-device)', 

527 provider_type=PROVIDER_TYPE_LOCAL, 

528 url='', 

529 base_url='http://localhost:8080', 

530 api_format='openai', 

531 categories=['llm', 'tts', 'stt', 'vlm', 'image_gen'], 

532 tags=['free', 'private', 'offline', 'no-api-key'], 

533 enabled=True, 

534 ), 

535 ] 

536 

537 

538# ═══════════════════════════════════════════════════════════════════════ 

539# Registry 

540# ═══════════════════════════════════════════════════════════════════════ 

541 

542class ProviderRegistry: 

543 """Central catalog of all providers. JSON-persisted, thread-safe.""" 

544 

545 def __init__(self, registry_path: Optional[str] = None): 

546 try: 

547 from core.platform_paths import get_db_dir 

548 data_dir = Path(get_db_dir()) 

549 except ImportError: 

550 data_dir = Path.home() / 'Documents' / 'Nunba' / 'data' 

551 data_dir.mkdir(parents=True, exist_ok=True) 

552 

553 self._path = Path(registry_path) if registry_path else data_dir / 'provider_registry.json' 

554 self._providers: Dict[str, Provider] = {} 

555 self._lock = threading.Lock() 

556 self._load() 

557 

558 def _load(self): 

559 """Load from JSON, merge with builtins.""" 

560 # Start with builtins 

561 for p in _builtin_providers(): 

562 self._providers[p.id] = p 

563 

564 # Overlay user customizations from JSON 

565 if self._path.exists(): 

566 try: 

567 with open(self._path, 'r') as f: 

568 data = json.load(f) 

569 for pid, pdata in data.items(): 

570 if pid in self._providers: 

571 # Merge user config over builtin (preserve API keys, enable state) 

572 existing = self._providers[pid] 

573 for k, v in pdata.items(): 

574 if k == 'models': 

575 for mk, mv in v.items(): 

576 if isinstance(mv, dict): 

577 pm_known = {fn.name for fn in ProviderModel.__dataclass_fields__.values()} 

578 existing.models[mk] = ProviderModel( 

579 **{fk: fv for fk, fv in mv.items() if fk in pm_known}) 

580 elif hasattr(existing, k): 

581 setattr(existing, k, v) 

582 else: 

583 # User-added provider 

584 self._providers[pid] = Provider.from_dict(pdata) 

585 logger.info("Provider registry loaded: %d providers (%s)", 

586 len(self._providers), self._path) 

587 except Exception as e: 

588 logger.warning("Failed to load provider registry: %s", e) 

589 

590 def save(self): 

591 """Persist to JSON.""" 

592 with self._lock: 

593 data = {} 

594 for pid, p in self._providers.items(): 

595 data[pid] = p.to_dict() 

596 try: 

597 self._path.parent.mkdir(parents=True, exist_ok=True) 

598 with open(self._path, 'w') as f: 

599 json.dump(data, f, indent=2, default=str) 

600 except Exception as e: 

601 logger.error("Failed to save provider registry: %s", e) 

602 

603 # ── Query API ───────────────────────────────────────────────────── 

604 

605 def get(self, provider_id: str) -> Optional[Provider]: 

606 return self._providers.get(provider_id) 

607 

608 def list_all(self) -> List[Provider]: 

609 return list(self._providers.values()) 

610 

611 def list_enabled(self) -> List[Provider]: 

612 return [p for p in self._providers.values() if p.enabled] 

613 

614 def list_by_category(self, category: str) -> List[Provider]: 

615 """List providers supporting a category (llm, image_gen, video_gen, etc.).""" 

616 return [p for p in self._providers.values() 

617 if p.enabled and category in p.categories] 

618 

619 def list_api_providers(self) -> List[Provider]: 

620 """List raw API providers (not affiliate, not local).""" 

621 return [p for p in self._providers.values() 

622 if p.enabled and p.provider_type == PROVIDER_TYPE_API] 

623 

624 def list_affiliate_providers(self) -> List[Provider]: 

625 return [p for p in self._providers.values() 

626 if p.enabled and p.provider_type == PROVIDER_TYPE_AFFILIATE] 

627 

628 def find_cheapest(self, model_type: str, canonical_id: str = '') -> Optional[tuple]: 

629 """Find the cheapest provider for a model type (or specific canonical model). 

630 

631 Returns (Provider, ProviderModel) or None. 

632 """ 

633 best = None 

634 best_cost = float('inf') 

635 

636 for p in self.list_api_providers(): 

637 if not p.has_api_key(): 

638 continue 

639 for pm in p.models.values(): 

640 if pm.model_type != model_type or not pm.enabled: 

641 continue 

642 if canonical_id and pm.canonical_id != canonical_id: 

643 continue 

644 cost = pm.input_price + pm.output_price 

645 if cost < best_cost: 

646 best_cost = cost 

647 best = (p, pm) 

648 return best 

649 

650 def find_fastest(self, model_type: str, canonical_id: str = '') -> Optional[tuple]: 

651 """Find the fastest provider (by avg_tok_per_s or avg_latency_ms).""" 

652 best = None 

653 best_speed = 0.0 

654 

655 for p in self.list_api_providers(): 

656 if not p.has_api_key(): 

657 continue 

658 for pm in p.models.values(): 

659 if pm.model_type != model_type or not pm.enabled: 

660 continue 

661 if canonical_id and pm.canonical_id != canonical_id: 

662 continue 

663 speed = pm.avg_tok_per_s if pm.avg_tok_per_s > 0 else ( 

664 1000.0 / pm.avg_latency_ms if pm.avg_latency_ms > 0 else 0.5) 

665 if speed > best_speed: 

666 best_speed = speed 

667 best = (p, pm) 

668 return best 

669 

670 def find_best(self, model_type: str, canonical_id: str = '', 

671 strategy: str = 'balanced') -> Optional[tuple]: 

672 """Find the best provider using a weighted strategy. 

673 

674 Strategies: 

675 'cheapest' — minimize cost 

676 'fastest' — maximize speed 

677 'quality' — maximize quality score 

678 'balanced' — weighted: 40% quality, 30% speed, 20% reliability, 10% cost 

679 """ 

680 if strategy == 'cheapest': 

681 return self.find_cheapest(model_type, canonical_id) 

682 if strategy == 'fastest': 

683 return self.find_fastest(model_type, canonical_id) 

684 

685 candidates = [] 

686 for p in self.list_api_providers(): 

687 if not p.has_api_key(): 

688 continue 

689 for pm in p.models.values(): 

690 if pm.model_type != model_type or not pm.enabled: 

691 continue 

692 if canonical_id and pm.canonical_id != canonical_id: 

693 continue 

694 candidates.append((p, pm)) 

695 

696 if not candidates: 

697 return None 

698 

699 def _score(pair): 

700 _, pm = pair 

701 cost = pm.input_price + pm.output_price 

702 # Normalize: lower cost = higher score (invert, cap at 10) 

703 cost_score = max(0, 1.0 - cost / 10.0) 

704 speed_score = min(1.0, pm.avg_tok_per_s / 200.0) if pm.avg_tok_per_s > 0 else 0.5 

705 

706 if strategy == 'quality': 

707 return pm.quality_score 

708 # balanced 

709 return (0.40 * pm.quality_score + 

710 0.30 * speed_score + 

711 0.20 * pm.reliability + 

712 0.10 * cost_score) 

713 

714 candidates.sort(key=_score, reverse=True) 

715 return candidates[0] 

716 

717 # ── Mutation ────────────────────────────────────────────────────── 

718 

719 def register(self, provider: Provider, persist: bool = True): 

720 with self._lock: 

721 self._providers[provider.id] = provider 

722 if persist: 

723 self.save() 

724 

725 def update_model_stats(self, provider_id: str, model_id: str, 

726 tok_per_s: float = 0, latency_ms: float = 0, 

727 quality: float = 0, success: bool = True): 

728 """Update efficiency stats for a provider model (called by gateway after each request).""" 

729 p = self._providers.get(provider_id) 

730 if not p: 

731 return 

732 pm = p.models.get(model_id) 

733 if not pm: 

734 return 

735 # Exponential moving average (alpha=0.1 for smooth updates) 

736 a = 0.1 

737 if tok_per_s > 0: 

738 pm.avg_tok_per_s = pm.avg_tok_per_s * (1 - a) + tok_per_s * a if pm.avg_tok_per_s > 0 else tok_per_s 

739 if latency_ms > 0: 

740 pm.avg_latency_ms = pm.avg_latency_ms * (1 - a) + latency_ms * a if pm.avg_latency_ms > 0 else latency_ms 

741 if quality > 0: 

742 pm.quality_score = pm.quality_score * (1 - a) + quality * a 

743 pm.reliability = pm.reliability * (1 - a) + (1.0 if success else 0.0) * a 

744 

745 def update_health(self, provider_id: str, healthy: bool, 

746 latency_ms: float = 0, error_rate: float = 0): 

747 p = self._providers.get(provider_id) 

748 if not p: 

749 return 

750 p.healthy = healthy 

751 p.last_health_check = time.time() 

752 p.avg_latency_ms = latency_ms 

753 p.error_rate_24h = error_rate 

754 

755 def set_api_key(self, provider_id: str, api_key: str): 

756 """Set API key in environment for a provider.""" 

757 p = self._providers.get(provider_id) 

758 if not p or not p.env_key: 

759 return False 

760 os.environ[p.env_key] = api_key 

761 p.api_key_set = True 

762 self.save() 

763 return True 

764 

765 # ── Summary for agents ──────────────────────────────────────────── 

766 

767 def get_capabilities_summary(self) -> Dict[str, List[str]]: 

768 """Return {model_type: [provider_ids]} — what Nunba can do right now.""" 

769 result: Dict[str, List[str]] = {} 

770 for p in self.list_enabled(): 

771 for cat in p.categories: 

772 result.setdefault(cat, []).append(p.id) 

773 return result 

774 

775 

776# ═══════════════════════════════════════════════════════════════════════ 

777# Singleton 

778# ═══════════════════════════════════════════════════════════════════════ 

779 

780_registry: Optional[ProviderRegistry] = None 

781_registry_lock = threading.Lock() 

782 

783 

784def get_registry() -> ProviderRegistry: 

785 global _registry 

786 if _registry is None: 

787 with _registry_lock: 

788 if _registry is None: 

789 _registry = ProviderRegistry() 

790 return _registry