Skip to content

Commit 3968d27

Browse files
committed
fix(webui): hot-load custom providers and API keys without restart
The add_custom_provider and add_api_key admin API endpoints previously only persisted credentials to .env — a restart was required for the running proxy to pick them up. Now both endpoints hot-load into the running RotatingClient: registering the dynamic plugin, updating credential maps, creating usage managers, and triggering model discovery. Closes #61
1 parent c7c7a65 commit 3968d27

2 files changed

Lines changed: 158 additions & 30 deletions

File tree

.fork/stack.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ rules:
2828
umans:
2929
- "feat(umans): add Umans provider with request-based quota tracking"
3030
- "fix(umans): normalize API base and show quota without proxy requests"
31+
webui:
32+
- "feat(webui): add React web UI with admin dashboard, quota viewer, log explorer, and settings"
33+
- "fix(webui): hot-load custom providers and API keys without restart"
3134

3235
features:
3336
- id: lightning-ai

src/proxy_app/api/config.py

Lines changed: 155 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)