@@ -364,31 +364,100 @@ class AddApiKeyRequest(BaseModel):
364364 key : str = Field (min_length = 1 , max_length = 500 )
365365
366366
367+ async def _ensure_usage_manager (client , provider : str , credentials : list ) -> None :
368+ """Create a usage manager if missing, then (re-)initialize with current credentials."""
369+ from rotator_library .usage .config import load_provider_usage_config
370+ from rotator_library .usage import UsageManager as NewUsageManager
371+
372+ usage_manager = client .get_usage_manager (provider )
373+ if usage_manager is None :
374+ reg = client ._usage_registry
375+ config = load_provider_usage_config (provider , client ._provider_plugins )
376+ config .rotation_tolerance = reg ._rotation_tolerance
377+ reg .apply_usage_reset_config (provider , credentials , config )
378+ mode = config .rotation_mode .value
379+ max_c , opt_c = reg .get_concurrency_settings (provider , mode )
380+ usage_manager = NewUsageManager (
381+ provider = provider ,
382+ file_path = client ._usage_base_path / f"usage_{ provider } .json" ,
383+ provider_plugins = client ._provider_plugins ,
384+ config = config ,
385+ max_concurrent_per_key = max_c ,
386+ optimal_concurrent_per_key = opt_c ,
387+ )
388+ reg .managers [provider ] = usage_manager
389+
390+ priorities , tiers = client ._usage_registry .get_credential_metadata (
391+ provider , credentials
392+ )
393+ await usage_manager .initialize (
394+ credentials , priorities = priorities , tiers = tiers
395+ )
396+
397+ plugin = client ._get_provider_instance (provider )
398+ if plugin and hasattr (plugin , "set_usage_manager" ):
399+ plugin .set_usage_manager (usage_manager )
400+
401+
402+ async def _hot_load_api_key (client , provider : str , api_key : str ) -> bool :
403+ """Hot-load a new API key into the running client's credential maps.
404+
405+ Returns True if the credential was newly added, False if it was already present.
406+ """
407+ provider = provider .lower ()
408+ added = False
409+
410+ client .api_keys .setdefault (provider , [])
411+ if api_key not in client .api_keys [provider ]:
412+ client .api_keys [provider ].append (api_key )
413+ added = True
414+
415+ client .all_credentials .setdefault (provider , [])
416+ if api_key not in client .all_credentials [provider ]:
417+ client .all_credentials [provider ].append (api_key )
418+ added = True
419+
420+ if added :
421+ await _ensure_usage_manager (client , provider , client .all_credentials [provider ])
422+
423+ return added
424+
425+
367426@router .post ("/credentials/api-key" )
368- async def add_api_key (req : AddApiKeyRequest ):
369- env_file = str (_env_path ())
370- env_vars = _get_env_vars ()
427+ async def add_api_key (req : AddApiKeyRequest , request : Request ):
428+ async with _credential_lock :
429+ env_file = str (_env_path ())
430+ env_vars = _get_env_vars ()
371431
372- provider_upper = req .provider .upper ()
373- existing = [k for k in env_vars if k .startswith (f"{ provider_upper } _API_KEY" )]
374- if existing :
375- nums = []
376- for k in existing :
377- suffix = k .replace (f"{ provider_upper } _API_KEY" , "" )
378- if suffix .startswith ("_" ) and suffix [1 :].isdigit ():
379- nums .append (int (suffix [1 :]))
380- elif not suffix :
381- nums .append (0 )
382- next_num = max (nums ) + 1 if nums else 1
383- key_name = f"{ provider_upper } _API_KEY_{ next_num } "
384- else :
385- key_name = f"{ provider_upper } _API_KEY"
432+ provider_upper = req .provider .upper ()
433+ existing = [k for k in env_vars if k .startswith (f"{ provider_upper } _API_KEY" )]
434+ if existing :
435+ nums = []
436+ for k in existing :
437+ suffix = k .replace (f"{ provider_upper } _API_KEY" , "" )
438+ if suffix .startswith ("_" ) and suffix [1 :].isdigit ():
439+ nums .append (int (suffix [1 :]))
440+ elif not suffix :
441+ nums .append (0 )
442+ next_num = max (nums ) + 1 if nums else 1
443+ key_name = f"{ provider_upper } _API_KEY_{ next_num } "
444+ else :
445+ key_name = f"{ provider_upper } _API_KEY"
386446
387- _inplace_set_key (env_file , key_name , req .key )
388- os .environ [key_name ] = req .key
389- load_dotenv (env_file , override = True )
447+ _inplace_set_key (env_file , key_name , req .key )
448+ os .environ [key_name ] = req .key
449+ load_dotenv (env_file , override = True )
390450
391- return {"key_name" : key_name }
451+ hot_loaded = False
452+ try :
453+ client = request .app .state .rotating_client
454+ hot_loaded = await _hot_load_api_key (client , req .provider , req .key )
455+ except Exception as exc :
456+ logger .warning (
457+ f"Could not hot-load API key for { req .provider } " , exc_info = True
458+ )
459+
460+ return {"key_name" : key_name , "hot_loaded" : hot_loaded }
392461
393462
394463@router .delete ("/credentials/api-key/{provider}/{key_name}" )
@@ -475,18 +544,74 @@ class AddCustomProviderRequest(BaseModel):
475544 api_key : str
476545
477546
547+ async def _hot_load_custom_provider (client , provider_name : str , api_key : str ) -> dict :
548+ """Register a new custom OpenAI-compatible provider at runtime."""
549+ from rotator_library .providers import PROVIDER_PLUGINS , DynamicOpenAICompatibleProvider
550+ from rotator_library .provider_config import KNOWN_PROVIDERS
551+
552+ provider = provider_name .lower ()
553+ result = {"plugin_registered" : False , "models_discovered" : 0 }
554+
555+ if provider not in KNOWN_PROVIDERS and provider not in PROVIDER_PLUGINS :
556+ def _make_plugin (name ):
557+ class _Plug (DynamicOpenAICompatibleProvider ):
558+ def __init__ (self ):
559+ super ().__init__ (name )
560+ return _Plug
561+
562+ PROVIDER_PLUGINS [provider ] = _make_plugin (provider )
563+ result ["plugin_registered" ] = True
564+
565+ client .provider_config ._load_api_bases ()
566+
567+ client .api_keys .setdefault (provider , [])
568+ if api_key not in client .api_keys [provider ]:
569+ client .api_keys [provider ].append (api_key )
570+
571+ client .all_credentials .setdefault (provider , [])
572+ if api_key not in client .all_credentials [provider ]:
573+ client .all_credentials [provider ].append (api_key )
574+
575+ await _ensure_usage_manager (client , provider , client .all_credentials [provider ])
576+
577+ try :
578+ models = await client .get_available_models (provider , force_refresh = True )
579+ result ["models_discovered" ] = len (models )
580+ except Exception as exc :
581+ logger .warning (f"Model discovery failed for { provider } : { exc } " )
582+ result ["model_discovery_error" ] = str (exc )
583+
584+ return result
585+
586+
478587@router .post ("/credentials/custom-provider" )
479- async def add_custom_provider (req : AddCustomProviderRequest ):
480- env_file = str (_env_path ())
481- provider_upper = req .name .upper ()
588+ async def add_custom_provider (req : AddCustomProviderRequest , request : Request ):
589+ async with _credential_lock :
590+ env_file = str (_env_path ())
591+ provider_upper = req .name .upper ()
482592
483- _inplace_set_key (env_file , f"{ provider_upper } _API_BASE" , req .base_url )
484- _inplace_set_key (env_file , f"{ provider_upper } _API_KEY" , req .api_key )
485- os .environ [f"{ provider_upper } _API_BASE" ] = req .base_url
486- os .environ [f"{ provider_upper } _API_KEY" ] = req .api_key
487- load_dotenv (env_file , override = True )
593+ _inplace_set_key (env_file , f"{ provider_upper } _API_BASE" , req .base_url )
594+ _inplace_set_key (env_file , f"{ provider_upper } _API_KEY" , req .api_key )
595+ os .environ [f"{ provider_upper } _API_BASE" ] = req .base_url
596+ os .environ [f"{ provider_upper } _API_KEY" ] = req .api_key
597+ load_dotenv (env_file , override = True )
488598
489- return {"provider" : req .name }
599+ hot_load_info = {}
600+ try :
601+ client = request .app .state .rotating_client
602+ hot_load_info = await _hot_load_custom_provider (
603+ client , req .name , req .api_key
604+ )
605+ logger .info (
606+ f"Hot-loaded custom provider { req .name } : "
607+ f"plugin={ 'new' if hot_load_info .get ('plugin_registered' ) else 'existing' } , "
608+ f"models={ hot_load_info .get ('models_discovered' , 0 )} "
609+ )
610+ except Exception :
611+ logger .warning (f"Hot-load failed for { req .name } " , exc_info = True )
612+ hot_load_info ["error" ] = "hot_load_failed"
613+
614+ return {"provider" : req .name , ** hot_load_info }
490615
491616
492617@router .get ("/config/model-filters/{provider}" )
0 commit comments