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
« 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.
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)
12JSON-persisted at ~/Documents/Nunba/data/provider_registry.json.
13Thread-safe singleton via get_registry().
14"""
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
25logger = logging.getLogger(__name__)
28# ═══════════════════════════════════════════════════════════════════════
29# Provider types
30# ═══════════════════════════════════════════════════════════════════════
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)
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)
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'
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
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
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
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
80 enabled: bool = True
83@dataclass
84class Provider:
85 """A compute provider or business service."""
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
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"
102 # ── Models ───────────────────────────────────────────────────────
103 models: Dict[str, ProviderModel] = field(default_factory=dict)
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}"
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
118 # ── State ────────────────────────────────────────────────────────
119 enabled: bool = True
120 api_key_set: bool = False # Whether user has configured API key
122 # ── Tags ─────────────────────────────────────────────────────────
123 tags: List[str] = field(default_factory=list)
124 categories: List[str] = field(default_factory=list) # "llm", "image", "video", etc.
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
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
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, '')
152 def has_api_key(self) -> bool:
153 return bool(self.get_api_key())
156# ═══════════════════════════════════════════════════════════════════════
157# Built-in providers — raw API providers with OpenAI-compatible endpoints
158# ═══════════════════════════════════════════════════════════════════════
160def _builtin_providers() -> List[Provider]:
161 """Return the built-in provider catalog.
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
443 # ── Affiliate / Commission providers ─────────────────────────
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
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 ),
523 # ── Local provider (built-in) ────────────────────────────────
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 ]
538# ═══════════════════════════════════════════════════════════════════════
539# Registry
540# ═══════════════════════════════════════════════════════════════════════
542class ProviderRegistry:
543 """Central catalog of all providers. JSON-persisted, thread-safe."""
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)
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()
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
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)
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)
603 # ── Query API ─────────────────────────────────────────────────────
605 def get(self, provider_id: str) -> Optional[Provider]:
606 return self._providers.get(provider_id)
608 def list_all(self) -> List[Provider]:
609 return list(self._providers.values())
611 def list_enabled(self) -> List[Provider]:
612 return [p for p in self._providers.values() if p.enabled]
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]
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]
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]
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).
631 Returns (Provider, ProviderModel) or None.
632 """
633 best = None
634 best_cost = float('inf')
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
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
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
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.
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)
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))
696 if not candidates:
697 return None
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
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)
714 candidates.sort(key=_score, reverse=True)
715 return candidates[0]
717 # ── Mutation ──────────────────────────────────────────────────────
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()
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
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
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
765 # ── Summary for agents ────────────────────────────────────────────
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
776# ═══════════════════════════════════════════════════════════════════════
777# Singleton
778# ═══════════════════════════════════════════════════════════════════════
780_registry: Optional[ProviderRegistry] = None
781_registry_lock = threading.Lock()
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