rnilkyway commited on
Commit
463da8e
Β·
1 Parent(s): 6b5543c

feat(providers): prefix-based routing with per-model custom aliases

Browse files

- ProviderImportRequest now accepts prefix and model_aliases (subset selection)
- Add ProviderUpdateRequest + PATCH /admin/providers/{id} for editing
- /v1/models lists models as '{prefix}/{custom_alias}' (e.g. deb/gpt-4o)
- _get_upstream_for_model resolves prefix β†’ provider β†’ real upstream model
via model_aliases reverse-lookup
- /admin/providers returns rich models {upstream, alias, full_id} plus
available_models for the editor
- /admin/models surfaces prefixed dynamic models with provider metadata
- _register_provider_in_upstreams no longer pollutes global UPSTREAMS

Dashboard UI:
- New 3-step provider import flow: form (with Prefix input) β†’ discover β†’
select & alias models β†’ import
- Model selector: checkboxes + editable alias inputs, live prefix/alias
preview, search, select-all/none, reset
- Provider cards redesigned with prefix badge, exposed-model chips, and
EDIT button
- Edit Provider modal: rename, change prefix, toggle model selection,
rename aliases (calls new PATCH endpoint)

Files changed (2) hide show
  1. app/index.py +228 -59
  2. app/templates/dashboard.html +636 -123
app/index.py CHANGED
@@ -528,40 +528,68 @@ def normalize_model(model: str) -> str:
528
 
529
 
530
  def parse_provider_prefix(model: str):
531
- """Parse 'provider_name/model_id' format. Returns (provider_name, model_id) or (None, model) if no prefix."""
532
  if "/" in model:
533
  parts = model.split("/", 1)
534
  return parts[0], parts[1]
535
  return None, model
536
 
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  def _get_upstream_for_model(model: str):
539
  """Find upstream URL and API key for a model, handling provider prefix routing."""
540
- provider_name, model_id = parse_provider_prefix(model)
541
-
542
- if provider_name:
543
- # Provider prefix routing: find provider by name, route to its base_url
544
- for pid, prov in _dynamic_providers.items():
545
- if prov.get("name") == provider_name:
546
- base_url = prov["base_url"].rstrip("/")
547
- chat_url = f"{base_url}/chat/completions"
548
- keys = prov.get("keys", [])
549
- api_key = keys[0] if keys else ""
550
- return chat_url, model_id, api_key
551
- # Provider not found, fall through to default routing
552
-
553
- # Default routing: use UPSTREAMS dict
554
- upstreams = UPSTREAMS.get(model_id, [])
555
  if not upstreams:
556
  upstreams = UPSTREAMS.get(model, [])
557
  if not upstreams:
558
  # Fallback to default upstream
559
  return DEFAULT_UPSTREAM_URL, model, ""
560
-
561
  upstream = upstreams[0]
562
  url = upstream.get("url", DEFAULT_UPSTREAM_URL)
563
  api_key = UPSTREAM_API_KEYS.get(url, "")
564
- return url, model_id, api_key
565
 
566
 
567
  def parse_upstream_delta(clean: str) -> dict:
@@ -909,17 +937,34 @@ async def health():
909
 
910
  @app.get("/v1/models")
911
  async def list_models():
912
- """List all available models. Dynamic provider models are listed by raw name; provider prefix routing is still accepted on requests for backward compatibility."""
913
- seen = set(_available_models)
914
- data = [{"id": m, "object": "model", "owned_by": "apiarium"} for m in _available_models]
915
- # Append dynamic provider models by raw name (no provider prefix in the public listing)
 
 
 
 
 
 
 
 
 
 
 
916
  for pid, prov in _dynamic_providers.items():
917
  prov_name = prov.get("name", pid)
918
- for m in prov.get("models", []):
919
- if m in seen:
 
 
 
920
  continue
921
- seen.add(m)
922
- data.append({"id": m, "object": "model", "owned_by": prov_name})
 
 
 
923
  return {"object": "list", "data": data}
924
 
925
 
@@ -1175,21 +1220,29 @@ async def admin_models(request: Request):
1175
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1176
 
1177
  models = []
1178
-
1179
- # Dynamic provider models β€” show raw model name, provider goes in its own field
 
1180
  for pid, prov in _dynamic_providers.items():
1181
  prov_name = prov.get("name", pid)
1182
- for m in prov.get("models", []):
 
 
 
 
 
1183
  models.append({
1184
- "name": m,
 
 
 
1185
  "provider": prov_name,
 
1186
  "type": "dynamic",
1187
  })
1188
-
1189
- # Built-in models (non-prefixed, from UPSTREAMS but not from dynamic providers)
1190
- dynamic_models = set()
1191
- for prov in _dynamic_providers.values():
1192
- dynamic_models.update(prov.get("models", []))
1193
 
1194
  def _provider_from_url(url: str) -> str:
1195
  """Extract provider name from upstream URL."""
@@ -1306,6 +1359,18 @@ class ProviderImportRequest(BaseModel):
1306
  name: str
1307
  base_url: str # e.g. https://api.example.com/v1
1308
  keys: list # list of API keys (strings)
 
 
 
 
 
 
 
 
 
 
 
 
1309
 
1310
 
1311
  def _load_providers():
@@ -1327,22 +1392,18 @@ def _save_providers():
1327
 
1328
 
1329
  def _register_provider_in_upstreams(prov: dict):
1330
- """Inject a provider's models + keys into the global UPSTREAMS/UPSTREAM_API_KEYS."""
 
 
 
 
 
 
1331
  base_url = prov["base_url"].rstrip("/")
1332
  chat_url = f"{base_url}/chat/completions"
1333
  keys = prov.get("keys", [])
1334
- models = prov.get("models", [])
1335
-
1336
- # Register API keys β€” use first key as primary, others rotate via round-robin
1337
- for i, key in enumerate(keys):
1338
- UPSTREAM_API_KEYS.setdefault(chat_url, key)
1339
-
1340
- # Register models β€” each model routes to this provider
1341
- for model_id in models:
1342
- # Don't override built-in models
1343
- if model_id in UPSTREAMS:
1344
- continue
1345
- UPSTREAMS[model_id] = [{"url": chat_url, "model": model_id}]
1346
 
1347
 
1348
  @app.get("/admin/providers")
@@ -1354,12 +1415,26 @@ async def admin_list_providers(request: Request):
1354
 
1355
  out = []
1356
  for pid, prov in _dynamic_providers.items():
 
 
 
 
 
 
 
 
 
 
 
1357
  out.append({
1358
  "id": pid,
1359
  "name": prov.get("name", ""),
 
1360
  "base_url": prov.get("base_url", ""),
1361
  "key_count": len(prov.get("keys", [])),
1362
- "models": prov.get("models", []),
 
 
1363
  "added_at": prov.get("added_at", 0),
1364
  })
1365
  return {"providers": out}
@@ -1442,32 +1517,121 @@ async def admin_import_provider(request: Request, body: ProviderImportRequest):
1442
  else:
1443
  models = []
1444
 
1445
- models = [m for m in models if m]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1446
 
1447
  pid = body.name.lower().replace(" ", "-") + "-" + uuid.uuid4().hex[:6]
1448
  prov = {
1449
  "name": body.name,
 
1450
  "base_url": base_url,
1451
  "keys": list(body.keys),
1452
- "models": models,
 
1453
  "added_at": int(time.time()),
1454
  }
1455
  _dynamic_providers[pid] = prov
1456
  _register_provider_in_upstreams(prov)
1457
  _save_providers()
1458
 
1459
- # Refresh global available models list
 
1460
  global _available_models
1461
  _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1462
- logger.info("Imported provider %s with %d models", pid, len(models))
 
 
 
1463
 
1464
  return {
1465
  "id": pid,
1466
  "name": prov["name"],
 
1467
  "base_url": base_url,
1468
  "key_count": len(body.keys),
1469
- "model_count": len(models),
1470
- "models": models,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1471
  }
1472
 
1473
 
@@ -1485,15 +1649,20 @@ async def admin_delete_provider(request: Request, provider_id: str):
1485
  base_url = prov["base_url"].rstrip("/")
1486
  chat_url = f"{base_url}/chat/completions"
1487
 
1488
- # Remove model routes that pointed to this provider
1489
- for model_id in prov.get("models", []):
 
1490
  if model_id in UPSTREAMS:
1491
  UPSTREAMS[model_id] = [u for u in UPSTREAMS[model_id] if u.get("url") != chat_url]
1492
  if not UPSTREAMS[model_id]:
1493
  del UPSTREAMS[model_id]
1494
 
1495
- # Remove API key mapping if no other provider uses it
1496
- if chat_url in UPSTREAM_API_KEYS:
 
 
 
 
1497
  del UPSTREAM_API_KEYS[chat_url]
1498
 
1499
  _save_providers()
 
528
 
529
 
530
  def parse_provider_prefix(model: str):
531
+ """Parse 'prefix/model_id' format. Returns (prefix, model_id) or (None, model) if no prefix."""
532
  if "/" in model:
533
  parts = model.split("/", 1)
534
  return parts[0], parts[1]
535
  return None, model
536
 
537
 
538
+ def _find_provider_by_prefix(prefix: str):
539
+ """Return (pid, prov) for a given prefix (case-insensitive), or (None, None)."""
540
+ if not prefix:
541
+ return None, None
542
+ pl = prefix.lower()
543
+ for pid, prov in _dynamic_providers.items():
544
+ if (prov.get("prefix") or "").lower() == pl:
545
+ return pid, prov
546
+ # backward-compat: allow matching by provider name as legacy prefix
547
+ if (prov.get("name") or "").lower() == pl:
548
+ return pid, prov
549
+ return None, None
550
+
551
+
552
+ def _resolve_provider_model(prov: dict, alias: str) -> str:
553
+ """Resolve user-facing alias to real upstream model id using provider's model_aliases map."""
554
+ aliases = prov.get("model_aliases") or {}
555
+ # model_aliases is {upstream_model_id: exposed_alias}
556
+ # Reverse-lookup: find upstream id whose exposed alias matches
557
+ for upstream_id, exposed in aliases.items():
558
+ if exposed == alias:
559
+ return upstream_id
560
+ # Fallback: if user passed the raw upstream id, allow it if it's in available_models
561
+ if alias in (prov.get("available_models") or []):
562
+ return alias
563
+ return alias # last-resort passthrough
564
+
565
+
566
  def _get_upstream_for_model(model: str):
567
  """Find upstream URL and API key for a model, handling provider prefix routing."""
568
+ prefix, alias = parse_provider_prefix(model)
569
+
570
+ if prefix:
571
+ pid, prov = _find_provider_by_prefix(prefix)
572
+ if prov:
573
+ base_url = prov["base_url"].rstrip("/")
574
+ chat_url = f"{base_url}/chat/completions"
575
+ keys = prov.get("keys", [])
576
+ api_key = keys[0] if keys else ""
577
+ real_model = _resolve_provider_model(prov, alias)
578
+ return chat_url, real_model, api_key
579
+ # Prefix not found, fall through to default routing
580
+
581
+ # Default routing: use UPSTREAMS dict (built-in aliases)
582
+ upstreams = UPSTREAMS.get(alias, [])
583
  if not upstreams:
584
  upstreams = UPSTREAMS.get(model, [])
585
  if not upstreams:
586
  # Fallback to default upstream
587
  return DEFAULT_UPSTREAM_URL, model, ""
588
+
589
  upstream = upstreams[0]
590
  url = upstream.get("url", DEFAULT_UPSTREAM_URL)
591
  api_key = UPSTREAM_API_KEYS.get(url, "")
592
+ return url, alias, api_key
593
 
594
 
595
  def parse_upstream_delta(clean: str) -> dict:
 
937
 
938
  @app.get("/v1/models")
939
  async def list_models():
940
+ """List all available models.
941
+
942
+ Built-in aliases are listed by their alias id.
943
+ Dynamic provider models are listed as ``{prefix}/{custom_alias}`` if the
944
+ provider has a prefix configured; otherwise the raw model id is used.
945
+ """
946
+ seen = set()
947
+ data = []
948
+ for m in _available_models:
949
+ if m in seen:
950
+ continue
951
+ seen.add(m)
952
+ data.append({"id": m, "object": "model", "owned_by": "apiarium"})
953
+
954
+ # Append dynamic provider models using their custom alias + provider prefix
955
  for pid, prov in _dynamic_providers.items():
956
  prov_name = prov.get("name", pid)
957
+ prefix = (prov.get("prefix") or "").strip()
958
+ aliases = prov.get("model_aliases") or {}
959
+ # Iterate over selected/aliased models only
960
+ for upstream_id, exposed in aliases.items():
961
+ if not exposed:
962
  continue
963
+ full_id = f"{prefix}/{exposed}" if prefix else exposed
964
+ if full_id in seen:
965
+ continue
966
+ seen.add(full_id)
967
+ data.append({"id": full_id, "object": "model", "owned_by": prov_name})
968
  return {"object": "list", "data": data}
969
 
970
 
 
1220
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1221
 
1222
  models = []
1223
+
1224
+ # Dynamic provider models β€” show as `{prefix}/{alias}` when a prefix is set.
1225
+ dynamic_models = set()
1226
  for pid, prov in _dynamic_providers.items():
1227
  prov_name = prov.get("name", pid)
1228
+ prefix = (prov.get("prefix") or "").strip()
1229
+ aliases = prov.get("model_aliases") or {}
1230
+ for upstream_id, exposed in aliases.items():
1231
+ if not exposed:
1232
+ continue
1233
+ full_id = f"{prefix}/{exposed}" if prefix else exposed
1234
  models.append({
1235
+ "name": full_id,
1236
+ "alias": exposed,
1237
+ "upstream": upstream_id,
1238
+ "prefix": prefix,
1239
  "provider": prov_name,
1240
+ "provider_id": pid,
1241
  "type": "dynamic",
1242
  })
1243
+ # Mark the raw upstream id as "owned" by a dynamic provider so we
1244
+ # don't double-list it from the built-in UPSTREAMS dict.
1245
+ dynamic_models.add(upstream_id)
 
 
1246
 
1247
  def _provider_from_url(url: str) -> str:
1248
  """Extract provider name from upstream URL."""
 
1359
  name: str
1360
  base_url: str # e.g. https://api.example.com/v1
1361
  keys: list # list of API keys (strings)
1362
+ prefix: str = "" # public prefix used to namespace this provider's models (e.g. "deb")
1363
+ # Optional explicit model selection + custom aliases.
1364
+ # Map of upstream_model_id -> exposed_custom_alias.
1365
+ # If empty, ALL discovered models from /models are imported with their raw ids as aliases.
1366
+ model_aliases: dict = {}
1367
+
1368
+
1369
+ class ProviderUpdateRequest(BaseModel):
1370
+ name: str | None = None
1371
+ prefix: str | None = None
1372
+ keys: list | None = None
1373
+ model_aliases: dict | None = None # full replacement when provided
1374
 
1375
 
1376
  def _load_providers():
 
1392
 
1393
 
1394
  def _register_provider_in_upstreams(prov: dict):
1395
+ """Register provider's API key for its chat endpoint.
1396
+
1397
+ With prefix-based routing we no longer pollute the global UPSTREAMS dict
1398
+ with raw model ids β€” routing happens via :func:`_get_upstream_for_model`.
1399
+ We still keep the api-key mapping so that downstream proxy code that looks
1400
+ up the key by URL keeps working.
1401
+ """
1402
  base_url = prov["base_url"].rstrip("/")
1403
  chat_url = f"{base_url}/chat/completions"
1404
  keys = prov.get("keys", [])
1405
+ if keys:
1406
+ UPSTREAM_API_KEYS.setdefault(chat_url, keys[0])
 
 
 
 
 
 
 
 
 
 
1407
 
1408
 
1409
  @app.get("/admin/providers")
 
1415
 
1416
  out = []
1417
  for pid, prov in _dynamic_providers.items():
1418
+ aliases = prov.get("model_aliases") or {}
1419
+ prefix = (prov.get("prefix") or "").strip()
1420
+ exposed_models = []
1421
+ for upstream_id, exposed in aliases.items():
1422
+ if not exposed:
1423
+ continue
1424
+ exposed_models.append({
1425
+ "upstream": upstream_id,
1426
+ "alias": exposed,
1427
+ "full_id": f"{prefix}/{exposed}" if prefix else exposed,
1428
+ })
1429
  out.append({
1430
  "id": pid,
1431
  "name": prov.get("name", ""),
1432
+ "prefix": prefix,
1433
  "base_url": prov.get("base_url", ""),
1434
  "key_count": len(prov.get("keys", [])),
1435
+ "available_models": prov.get("available_models", []) or list(aliases.keys()),
1436
+ "models": exposed_models, # rich list (new)
1437
+ "model_aliases": aliases, # raw map for editing
1438
  "added_at": prov.get("added_at", 0),
1439
  })
1440
  return {"providers": out}
 
1517
  else:
1518
  models = []
1519
 
1520
+ available = [m for m in models if m]
1521
+
1522
+ # Validate & build model_aliases. If the client did not provide any,
1523
+ # default to importing every discovered model with its raw id as alias.
1524
+ incoming_aliases = body.model_aliases or {}
1525
+ cleaned_aliases: dict = {}
1526
+ available_set = set(available)
1527
+ for upstream_id, exposed in incoming_aliases.items():
1528
+ if not upstream_id or not exposed:
1529
+ continue
1530
+ if upstream_id not in available_set:
1531
+ # Skip unknown upstream model ids silently β€” UI shouldn't send them
1532
+ continue
1533
+ cleaned_aliases[upstream_id] = str(exposed).strip()
1534
+
1535
+ if not cleaned_aliases:
1536
+ cleaned_aliases = {m: m for m in available}
1537
+
1538
+ # Normalize prefix: strip leading/trailing slashes & spaces
1539
+ prefix = (body.prefix or "").strip().strip("/")
1540
 
1541
  pid = body.name.lower().replace(" ", "-") + "-" + uuid.uuid4().hex[:6]
1542
  prov = {
1543
  "name": body.name,
1544
+ "prefix": prefix,
1545
  "base_url": base_url,
1546
  "keys": list(body.keys),
1547
+ "available_models": available,
1548
+ "model_aliases": cleaned_aliases,
1549
  "added_at": int(time.time()),
1550
  }
1551
  _dynamic_providers[pid] = prov
1552
  _register_provider_in_upstreams(prov)
1553
  _save_providers()
1554
 
1555
+ # Refresh global available models list (built-in only β€” prefixed models
1556
+ # are surfaced separately by /v1/models)
1557
  global _available_models
1558
  _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1559
+ logger.info(
1560
+ "Imported provider %s (prefix=%r) with %d selected models (%d available)",
1561
+ pid, prefix, len(cleaned_aliases), len(available),
1562
+ )
1563
 
1564
  return {
1565
  "id": pid,
1566
  "name": prov["name"],
1567
+ "prefix": prefix,
1568
  "base_url": base_url,
1569
  "key_count": len(body.keys),
1570
+ "model_count": len(cleaned_aliases),
1571
+ "available_count": len(available),
1572
+ "models": [
1573
+ {"upstream": k, "alias": v, "full_id": f"{prefix}/{v}" if prefix else v}
1574
+ for k, v in cleaned_aliases.items()
1575
+ ],
1576
+ }
1577
+
1578
+
1579
+ @app.patch("/admin/providers/{provider_id}")
1580
+ async def admin_update_provider(request: Request, provider_id: str, body: ProviderUpdateRequest):
1581
+ """Update an existing dynamic provider: rename, change prefix, edit model selection/aliases, or replace keys."""
1582
+ auth = request.headers.get("authorization", "")
1583
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1584
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1585
+
1586
+ if provider_id not in _dynamic_providers:
1587
+ raise HTTPException(status_code=404, detail={"error": {"message": "Provider not found"}})
1588
+
1589
+ prov = _dynamic_providers[provider_id]
1590
+
1591
+ if body.name is not None:
1592
+ new_name = body.name.strip()
1593
+ if new_name:
1594
+ prov["name"] = new_name
1595
+
1596
+ if body.prefix is not None:
1597
+ prov["prefix"] = body.prefix.strip().strip("/")
1598
+
1599
+ if body.keys is not None:
1600
+ new_keys = [k for k in body.keys if k]
1601
+ prov["keys"] = new_keys
1602
+ base_url = prov["base_url"].rstrip("/")
1603
+ chat_url = f"{base_url}/chat/completions"
1604
+ if new_keys:
1605
+ UPSTREAM_API_KEYS[chat_url] = new_keys[0]
1606
+ elif chat_url in UPSTREAM_API_KEYS:
1607
+ del UPSTREAM_API_KEYS[chat_url]
1608
+
1609
+ if body.model_aliases is not None:
1610
+ available = set(prov.get("available_models") or [])
1611
+ cleaned: dict = {}
1612
+ for upstream_id, exposed in body.model_aliases.items():
1613
+ if not upstream_id or not exposed:
1614
+ continue
1615
+ # If we know the available set, restrict to it; otherwise accept.
1616
+ if available and upstream_id not in available:
1617
+ continue
1618
+ cleaned[upstream_id] = str(exposed).strip()
1619
+ prov["model_aliases"] = cleaned
1620
+
1621
+ _save_providers()
1622
+
1623
+ aliases = prov.get("model_aliases") or {}
1624
+ return {
1625
+ "success": True,
1626
+ "id": provider_id,
1627
+ "name": prov.get("name", ""),
1628
+ "prefix": prov.get("prefix", ""),
1629
+ "key_count": len(prov.get("keys", [])),
1630
+ "model_count": len(aliases),
1631
+ "models": [
1632
+ {"upstream": k, "alias": v, "full_id": f"{prov.get('prefix','')}/{v}" if prov.get('prefix') else v}
1633
+ for k, v in aliases.items()
1634
+ ],
1635
  }
1636
 
1637
 
 
1649
  base_url = prov["base_url"].rstrip("/")
1650
  chat_url = f"{base_url}/chat/completions"
1651
 
1652
+ # Backward-compat clean-up: older imports registered raw model ids in UPSTREAMS.
1653
+ legacy_models = list((prov.get("model_aliases") or {}).keys()) + (prov.get("available_models") or [])
1654
+ for model_id in legacy_models:
1655
  if model_id in UPSTREAMS:
1656
  UPSTREAMS[model_id] = [u for u in UPSTREAMS[model_id] if u.get("url") != chat_url]
1657
  if not UPSTREAMS[model_id]:
1658
  del UPSTREAMS[model_id]
1659
 
1660
+ # Remove API key mapping (no other provider is sharing this exact url)
1661
+ sharing = any(
1662
+ p.get("base_url", "").rstrip("/") == base_url
1663
+ for p in _dynamic_providers.values()
1664
+ )
1665
+ if not sharing and chat_url in UPSTREAM_API_KEYS:
1666
  del UPSTREAM_API_KEYS[chat_url]
1667
 
1668
  _save_providers()
app/templates/dashboard.html CHANGED
@@ -405,48 +405,105 @@
405
  </div>
406
 
407
  <div class="space-y-3.5">
408
- <div>
409
- <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Friendly Provider Name</label>
410
- <input id="prov-name" type="text" placeholder="e.g. Hermit AI Cloud"
411
- class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-sans text-sm focus:outline-none focus:border-amber-500/60 transition-all">
 
 
 
 
 
 
 
 
 
 
412
  </div>
413
  <div>
414
  <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Upstream Endpoint URL</label>
415
- <input id="prov-url" type="url" placeholder="https://api.yourprovider.com/v1"
416
  class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all">
417
  </div>
418
  <div>
419
  <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Bearer Access Keys (one per line)</label>
420
- <textarea id="prov-keys" rows="3" placeholder="sk-provider-..."
421
  class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all resize-none"></textarea>
422
  </div>
423
 
424
- <!-- Action Controls -->
425
  <div class="flex gap-2 pt-2">
426
- <button id="prov-preview-btn" class="flex-1 bg-white/5 hover:bg-white/10 text-slate-200 text-xs font-semibold py-2.5 px-4 rounded-xl transition border border-white/15 flex items-center justify-center gap-1.5">
427
  <i data-lucide="stethoscope" class="w-3.5 h-3.5"></i>
428
- <span>Preview Models</span>
429
  </button>
430
- <button id="prov-import-btn" class="flex-1 bg-amber-500 hover:bg-amber-400 text-black text-xs font-bold py-2.5 px-4 rounded-xl transition flex items-center justify-center gap-1.5">
431
- <i data-lucide="import" class="w-3.5 h-3.5"></i>
432
- <span>Import Provider</span>
433
  </button>
434
  </div>
435
 
436
- <!-- Provider probe output state -->
437
  <div id="prov-status" class="text-xs font-mono hidden"></div>
 
 
438
 
439
- <!-- Live Interactive preview block -->
440
- <div id="prov-preview" class="hidden bg-black/45 rounded-xl p-4 border border-white/5 space-y-2">
441
- <div class="flex items-center justify-between">
442
- <span class="text-[11px] text-slate-400 uppercase font-mono">Auto-probed routes:</span>
443
- <span id="prov-preview-count" class="text-xs font-mono text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full">0 models</span>
444
- </div>
445
- <div id="prov-preview-list" class="max-h-[140px] overflow-y-auto flex flex-wrap gap-1.5 pt-1">
446
- <!-- Dynamic dynamic previews loaded here -->
447
- </div>
 
 
 
448
  </div>
449
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  </div>
451
 
452
  <!-- Existing active dynamic upstreams -->
@@ -676,8 +733,26 @@
676
  };
677
 
678
  const mockProviders = [
679
- { id: "prov_hermit", name: "Hermit Enterprise Cloud", base_url: "https://api.hermit-ai.com/v1", key_count: 3, models: ["gpt-4o-mini", "gpt-4o", "text-embedding-3"] },
680
- { id: "prov_gemini_ext", name: "Gemini Reverse Endpoints", base_url: "https://gateway.internal-ai.net/gemini", key_count: 2, models: ["gemini-2.5-pro", "gemini-2.5-flash"] }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  ];
682
 
683
  const mockModels = [
@@ -1146,7 +1221,122 @@
1146
 
1147
  // ── Dynamic Provider Management ─────────────────────────────────────
1148
  function escapeHtml(s) {
1149
- return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  }
1151
 
1152
  async function loadProviders() {
@@ -1173,165 +1363,198 @@
1173
  lucide.createIcons();
1174
  return;
1175
  }
1176
-
1177
- list.innerHTML = providers.map(p => `
1178
- <div class="bg-black/35 rounded-xl p-4 flex items-center justify-between border border-white/5 hover:border-amber-500/20 transition-all">
1179
- <div class="flex-1 min-w-0 pr-4">
1180
- <div class="flex items-center gap-2">
1181
- <span class="font-bold text-slate-200 text-sm tracking-tight">${escapeHtml(p.name)}</span>
1182
- <span class="text-[10px] font-mono bg-amber-500/10 border border-amber-500/20 text-amber-400 px-2 py-0.2 rounded-md">${p.id}</span>
1183
- </div>
1184
- <div class="text-[11px] text-slate-400 font-mono mt-1.5 truncate flex items-center gap-1.5">
1185
- <i data-lucide="link-2" class="w-3.5 h-3.5 text-slate-500"></i>
1186
- <span>${escapeHtml(p.base_url)}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
  </div>
1188
- <div class="flex gap-4 mt-3">
1189
- <span class="text-[11px] text-slate-500 font-mono flex items-center gap-1">
1190
- <i data-lucide="key-round" class="w-3.5 h-3.5"></i> ${p.key_count} dynamic keys
1191
- </span>
1192
- <span class="text-[11px] text-slate-500 font-mono flex items-center gap-1">
1193
- <i data-lucide="box" class="w-3.5 h-3.5"></i> ${(p.models || []).length} route models
1194
- </span>
1195
  </div>
1196
  </div>
1197
- <button onclick="window.__deleteProvider('${p.id}')" class="ml-3 text-red-400/80 hover:text-red-400 bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 text-xs font-mono font-bold px-3 py-1.5 rounded-lg transition flex items-center gap-1">
1198
- <i data-lucide="trash-2" class="w-3.5 h-3.5"></i> DELETE
1199
- </button>
1200
- </div>
1201
- `).join('');
 
 
 
 
 
 
 
1202
  lucide.createIcons();
1203
  }
1204
 
1205
  window.__deleteProvider = async function(id) {
1206
- if (!confirm('Warning: Delete this AI provider and instantly un-route all matched downstream nodes?')) return;
1207
  try {
1208
  const r = await api('/admin/providers/' + id, { method: 'DELETE' });
1209
  if (!r || !r.ok) throw new Error('Delete failed on live server');
1210
  toast('Provider removed from routing list', 'success');
1211
  await loadProviders();
 
1212
  } catch (e) {
1213
- // Simulated playground deletions
1214
- mockProviders.splice(mockProviders.findIndex(p => p.id === id), 1);
1215
  renderProvidersList([...mockProviders]);
1216
- toast('Removed provider from simulated interface routes', 'success');
1217
  }
1218
  };
1219
 
1220
- async function previewProvider() {
1221
- const name = $('prov-name').value.trim();
1222
- const baseUrl = $('prov-url').value.trim();
1223
- const keysText = $('prov-keys').value.trim();
 
 
1224
  const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
1225
  const status = $('prov-status');
1226
 
1227
- if (!name || !baseUrl) {
1228
- toast('Please enter Provider Name and Base URL', 'error');
1229
- return;
 
 
 
 
1230
  }
1231
 
1232
  const btn = $('prov-preview-btn');
1233
- btn.disabled = true;
1234
- btn.innerHTML = `<span>Probing...</span> <div class="animate-spin rounded-full h-3 w-3 border-b-2 border-slate-300"></div>`;
1235
- status.textContent = '';
1236
  status.classList.remove('hidden');
1237
-
 
 
 
1238
  try {
1239
  const r = await api('/admin/providers/preview', {
1240
  method: 'POST',
1241
- body: JSON.stringify({ name, base_url: baseUrl, keys })
1242
  });
1243
-
1244
  if (!r || !r.ok) {
1245
  const d = await r.json().catch(() => ({}));
1246
- throw new Error(d?.error?.message || 'Upstream probe timed out');
1247
  }
1248
-
1249
  const data = await r.json();
1250
- const preview = $('prov-preview');
1251
- preview.classList.remove('hidden');
1252
- $('prov-preview-count').textContent = `${data.models.length} models`;
1253
- $('prov-preview-list').innerHTML = data.models.map(m =>
1254
- `<span class="bg-indigo-950/40 border border-indigo-500/20 text-indigo-300 text-[10px] font-mono px-2 py-0.5 rounded">${escapeHtml(m)}</span>`
1255
- ).join('');
1256
- status.textContent = `βœ“ Active connection confirmed: ${data.models.length} models resolved`;
1257
  status.className = 'text-xs font-mono text-emerald-400 bg-emerald-500/5 px-2.5 py-1.5 rounded-lg border border-emerald-500/10';
1258
  } catch (e) {
1259
- // Simulate probe models on fallback sandbox automatically
1260
- const simulatedModels = ["custom-llama-3-extra", "custom-embedding-model", "deepseek-pro-r1", "qwq-32b-preview"];
1261
- const preview = $('prov-preview');
1262
- preview.classList.remove('hidden');
1263
- $('prov-preview-count').textContent = `${simulatedModels.length} models discovered`;
1264
- $('prov-preview-list').innerHTML = simulatedModels.map(m =>
1265
- `<span class="bg-indigo-950/40 border border-indigo-500/20 text-indigo-300 text-[10px] font-mono px-2.5 py-1 rounded-lg">${escapeHtml(m)}</span>`
1266
- ).join('');
1267
- status.textContent = `βœ“ Dynamic probe simulation success: resolved 4 model-paths`;
1268
- status.className = 'text-xs font-mono text-emerald-400 bg-emerald-500/5 px-2.5 py-1.5 rounded-lg border border-emerald-500/10';
1269
  } finally {
1270
- btn.disabled = false;
1271
- btn.innerHTML = `<i data-lucide="stethoscope" class="w-3.5 h-3.5"></i> <span>Preview Models</span>`;
1272
  lucide.createIcons();
1273
  }
 
 
1274
  }
1275
 
 
1276
  async function importProvider() {
1277
- const name = $('prov-name').value.trim();
1278
- const baseUrl = $('prov-url').value.trim();
1279
- const keysText = $('prov-keys').value.trim();
 
 
 
 
 
1280
  const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
1281
- const status = $('prov-status');
1282
 
1283
  if (!name || !baseUrl || keys.length === 0) {
1284
- toast('Name, URL and at least one credential token required to import', 'error');
1285
  return;
1286
  }
1287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1288
  const btn = $('prov-import-btn');
1289
- btn.disabled = true;
1290
- btn.innerHTML = `<span>Importing...</span> <div class="animate-spin rounded-full h-3 w-3 border-b-2 border-slate-900"></div>`;
1291
- status.textContent = '';
1292
-
1293
  try {
1294
  const r = await api('/admin/providers/import', {
1295
  method: 'POST',
1296
- body: JSON.stringify({ name, base_url: baseUrl, keys })
1297
  });
1298
  if (!r || !r.ok) {
1299
  const d = await r.json().catch(() => ({}));
1300
- throw new Error(d?.error?.message || 'Import process aborted');
1301
  }
1302
  const data = await r.json();
1303
- toast(`Imported ${data.model_count} models from ${data.name}`, 'success');
 
1304
  resetProviderForm();
1305
  await loadProviders();
 
1306
  } catch (e) {
1307
- // Simulated local add to active list
1308
- const mockId = "prov_" + name.toLowerCase().replace(/\s+/g, '_');
1309
- const newSimulatedProv = {
1310
- id: mockId,
1311
- name: name,
1312
- base_url: baseUrl,
1313
- key_count: keys.length,
1314
- models: ["custom-llama-3-extra", "deepseek-pro-r1", "qwq-32b-preview"]
1315
- };
1316
- mockProviders.push(newSimulatedProv);
1317
-
1318
- // Inject into catalog
1319
- newSimulatedProv.models.forEach(modelName => {
1320
- mockModels.push({
1321
- id: Math.random().toString(),
1322
- name: modelName,
1323
- provider: name,
1324
- upstreams: 1
1325
- });
1326
  });
1327
-
1328
- toast(`Simulated dynamic import successful for ${name}`, 'success');
 
 
1329
  resetProviderForm();
1330
  renderProvidersList([...mockProviders]);
1331
  renderModels('');
1332
  } finally {
1333
- btn.disabled = false;
1334
- btn.innerHTML = `<i data-lucide="import" class="w-3.5 h-3.5"></i> <span>Import Provider</span>`;
1335
  lucide.createIcons();
1336
  }
1337
  }
@@ -1340,12 +1563,226 @@
1340
  $('prov-name').value = '';
1341
  $('prov-url').value = '';
1342
  $('prov-keys').value = '';
1343
- $('prov-preview').classList.add('hidden');
1344
  $('prov-status').classList.add('hidden');
 
1345
  }
1346
 
1347
- if ($('prov-preview-btn')) $('prov-preview-btn').addEventListener('click', previewProvider);
 
1348
  if ($('prov-import-btn')) $('prov-import-btn').addEventListener('click', importProvider);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1349
 
1350
  // ── Models Catalog ─────────────────────────────────────────
1351
  let _modelsCache = [];
@@ -1424,5 +1861,81 @@
1424
  initAuth();
1425
  })();
1426
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1427
  </body>
1428
  </html>
 
405
  </div>
406
 
407
  <div class="space-y-3.5">
408
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
409
+ <div>
410
+ <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Provider Name</label>
411
+ <input id="prov-name" type="text" placeholder="e.g. DeepBricks"
412
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-sans text-sm focus:outline-none focus:border-amber-500/60 transition-all">
413
+ </div>
414
+ <div>
415
+ <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono flex items-center gap-1.5">
416
+ Prefix
417
+ <span class="text-[9px] font-mono text-amber-400/80 bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">{prefix}/model</span>
418
+ </label>
419
+ <input id="prov-prefix" type="text" placeholder="e.g. deb"
420
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-amber-200 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all">
421
+ </div>
422
  </div>
423
  <div>
424
  <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Upstream Endpoint URL</label>
425
+ <input id="prov-url" type="url" placeholder="https://api.yourprovider.com/v1"
426
  class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all">
427
  </div>
428
  <div>
429
  <label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Bearer Access Keys (one per line)</label>
430
+ <textarea id="prov-keys" rows="3" placeholder="sk-provider-..."
431
  class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-slate-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all resize-none"></textarea>
432
  </div>
433
 
434
+ <!-- Step 1: Discover Models -->
435
  <div class="flex gap-2 pt-2">
436
+ <button id="prov-preview-btn" class="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black text-xs font-bold py-2.5 px-4 rounded-xl transition shadow-md shadow-amber-500/10 flex items-center justify-center gap-1.5">
437
  <i data-lucide="stethoscope" class="w-3.5 h-3.5"></i>
438
+ <span>Discover Models</span>
439
  </button>
440
+ <button id="prov-reset-btn" class="bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-semibold py-2.5 px-3 rounded-xl transition border border-white/10 flex items-center justify-center" title="Reset form">
441
+ <i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
 
442
  </button>
443
  </div>
444
 
445
+ <!-- Status line -->
446
  <div id="prov-status" class="text-xs font-mono hidden"></div>
447
+ </div>
448
+ </div>
449
 
450
+ <!-- Step 2: Model Selection + Alias Editor (full width when active) -->
451
+ <div id="prov-selector" class="hidden lg:col-span-12 glass rounded-2xl p-6 space-y-4 border border-amber-500/20 shadow-amber-glow">
452
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-white/5 pb-3">
453
+ <div>
454
+ <h3 class="text-sm font-semibold text-amber-300 flex items-center gap-2">
455
+ <i data-lucide="list-checks" class="w-4 h-4"></i>
456
+ Step 2 β€” Choose & Alias Models
457
+ </h3>
458
+ <p class="text-[11px] text-slate-400 mt-1">Tick the models you want to expose. Edit the right column to set custom IDs. They'll show in <code class="font-mono text-amber-400">/v1/models</code> as <code class="font-mono text-amber-400" id="prov-selector-prefix-preview">{prefix}/&lt;alias&gt;</code>.</p>
459
+ </div>
460
+ <div class="flex items-center gap-2">
461
+ <span id="prov-selected-count" class="text-xs font-mono text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1 rounded-full">0 / 0 selected</span>
462
  </div>
463
  </div>
464
+
465
+ <div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
466
+ <div class="relative flex-1">
467
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
468
+ <i data-lucide="search" class="w-4 h-4"></i>
469
+ </span>
470
+ <input id="prov-selector-search" type="text" placeholder="Filter models..."
471
+ class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-slate-100 font-mono focus:outline-none focus:border-amber-500/50 transition-all">
472
+ </div>
473
+ <div class="flex gap-2 text-[11px] font-mono">
474
+ <button id="prov-select-all" class="bg-white/5 hover:bg-white/10 text-slate-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1">
475
+ <i data-lucide="check-square" class="w-3 h-3"></i> All
476
+ </button>
477
+ <button id="prov-select-none" class="bg-white/5 hover:bg-white/10 text-slate-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1">
478
+ <i data-lucide="square" class="w-3 h-3"></i> None
479
+ </button>
480
+ <button id="prov-reset-aliases" class="bg-white/5 hover:bg-white/10 text-slate-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1" title="Reset aliases to upstream id">
481
+ <i data-lucide="undo-2" class="w-3 h-3"></i> Reset
482
+ </button>
483
+ </div>
484
+ </div>
485
+
486
+ <!-- Table header -->
487
+ <div class="grid grid-cols-12 gap-3 text-[10px] uppercase font-mono text-slate-500 px-3">
488
+ <div class="col-span-1"></div>
489
+ <div class="col-span-5">Upstream Model ID</div>
490
+ <div class="col-span-6">Exposed Alias <span class="text-amber-400/70">(editable)</span></div>
491
+ </div>
492
+
493
+ <div id="prov-selector-list" class="max-h-[420px] overflow-y-auto pr-1 space-y-1.5">
494
+ <!-- Rows injected here -->
495
+ </div>
496
+
497
+ <div class="flex flex-col sm:flex-row gap-2 pt-3 border-t border-white/5">
498
+ <button id="prov-cancel-import" class="flex-1 sm:flex-none bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-semibold py-2.5 px-5 rounded-xl transition border border-white/10 flex items-center justify-center gap-1.5">
499
+ <i data-lucide="x" class="w-3.5 h-3.5"></i>
500
+ <span>Cancel</span>
501
+ </button>
502
+ <button id="prov-import-btn" class="flex-1 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-400 hover:to-emerald-500 text-black text-xs font-bold py-2.5 px-5 rounded-xl transition shadow-md shadow-emerald-500/10 flex items-center justify-center gap-1.5">
503
+ <i data-lucide="import" class="w-3.5 h-3.5"></i>
504
+ <span>Import Selected Models</span>
505
+ </button>
506
+ </div>
507
  </div>
508
 
509
  <!-- Existing active dynamic upstreams -->
 
733
  };
734
 
735
  const mockProviders = [
736
+ {
737
+ id: "prov_deepbricks", name: "DeepBricks", prefix: "deb",
738
+ base_url: "https://api.deepbricks.ai/v1", key_count: 2,
739
+ available_models: ["gpt-4o", "gpt-4o-mini", "claude-3-5-sonnet", "text-embedding-3"],
740
+ model_aliases: { "gpt-4o": "gpt-4o", "gpt-4o-mini": "mini" },
741
+ models: [
742
+ { upstream: "gpt-4o", alias: "gpt-4o", full_id: "deb/gpt-4o" },
743
+ { upstream: "gpt-4o-mini", alias: "mini", full_id: "deb/mini" }
744
+ ]
745
+ },
746
+ {
747
+ id: "prov_gemini_ext", name: "Gemini Reverse", prefix: "rev",
748
+ base_url: "https://gateway.internal-ai.net/gemini", key_count: 1,
749
+ available_models: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-1.5-pro"],
750
+ model_aliases: { "gemini-2.5-pro": "pro", "gemini-2.5-flash": "flash" },
751
+ models: [
752
+ { upstream: "gemini-2.5-pro", alias: "pro", full_id: "rev/pro" },
753
+ { upstream: "gemini-2.5-flash", alias: "flash", full_id: "rev/flash" }
754
+ ]
755
+ }
756
  ];
757
 
758
  const mockModels = [
 
1221
 
1222
  // ── Dynamic Provider Management ─────────────────────────────────────
1223
  function escapeHtml(s) {
1224
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
1225
+ }
1226
+ function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '&#96;'); }
1227
+ function slugifyAlias(s) {
1228
+ return String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
1229
+ }
1230
+
1231
+ // ── Selector State (Step 2: choose & alias models before import) ─────
1232
+ const selectorState = {
1233
+ active: false,
1234
+ available: [], // array of upstream model ids
1235
+ selected: new Set(), // selected upstream ids
1236
+ aliases: {}, // upstream_id -> custom alias
1237
+ filter: '',
1238
+ };
1239
+
1240
+ function updateSelectorPrefixPreview() {
1241
+ const p = (($('prov-prefix') || {}).value || '').trim();
1242
+ const el = $('prov-selector-prefix-preview');
1243
+ if (!el) return;
1244
+ el.textContent = p ? `${p}/<alias>` : `<alias> (no prefix)`;
1245
+ }
1246
+
1247
+ function renderSelectorList() {
1248
+ const list = $('prov-selector-list');
1249
+ if (!list) return;
1250
+ const q = (selectorState.filter || '').toLowerCase();
1251
+ const items = selectorState.available
1252
+ .filter(id => !q || id.toLowerCase().includes(q));
1253
+ if (items.length === 0) {
1254
+ list.innerHTML = `<div class="text-center text-slate-500 text-xs font-mono py-8">No models match this filter.</div>`;
1255
+ return;
1256
+ }
1257
+ const prefix = (($('prov-prefix') || {}).value || '').trim();
1258
+ list.innerHTML = items.map(id => {
1259
+ const isSel = selectorState.selected.has(id);
1260
+ const alias = selectorState.aliases[id] != null ? selectorState.aliases[id] : id;
1261
+ const full = prefix ? `${prefix}/${alias}` : alias;
1262
+ return `
1263
+ <div class="grid grid-cols-12 gap-3 items-center bg-black/30 hover:bg-black/50 rounded-lg border ${isSel ? 'border-amber-500/30' : 'border-white/5'} px-3 py-2 transition-all" data-upstream="${escapeAttr(id)}">
1264
+ <div class="col-span-1 flex justify-center">
1265
+ <label class="relative inline-flex items-center cursor-pointer">
1266
+ <input type="checkbox" class="prov-sel-check sr-only peer" ${isSel ? 'checked' : ''} data-upstream="${escapeAttr(id)}">
1267
+ <span class="w-4 h-4 rounded border ${isSel ? 'bg-amber-500 border-amber-500' : 'bg-black/40 border-white/20'} flex items-center justify-center transition-all">
1268
+ ${isSel ? '<svg class="w-3 h-3 text-black" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>' : ''}
1269
+ </span>
1270
+ </label>
1271
+ </div>
1272
+ <div class="col-span-5 min-w-0">
1273
+ <div class="font-mono text-[11px] text-slate-200 truncate" title="${escapeAttr(id)}">${escapeHtml(id)}</div>
1274
+ </div>
1275
+ <div class="col-span-6">
1276
+ <div class="flex items-center gap-2">
1277
+ <input type="text"
1278
+ class="prov-sel-alias flex-1 bg-black/40 border border-white/10 focus:border-amber-500/50 rounded-md px-2 py-1 text-[11px] font-mono text-amber-200 focus:outline-none ${isSel ? '' : 'opacity-40'}"
1279
+ value="${escapeAttr(alias)}"
1280
+ data-upstream="${escapeAttr(id)}"
1281
+ ${isSel ? '' : 'disabled'}
1282
+ placeholder="alias">
1283
+ <span class="text-[10px] font-mono text-slate-500 truncate flex-shrink-0 min-w-0" title="${escapeAttr(full)}">β†’ ${escapeHtml(full)}</span>
1284
+ </div>
1285
+ </div>
1286
+ </div>`;
1287
+ }).join('');
1288
+
1289
+ // Bind checkbox events
1290
+ list.querySelectorAll('.prov-sel-check').forEach(cb => {
1291
+ cb.addEventListener('change', (e) => {
1292
+ const id = e.target.getAttribute('data-upstream');
1293
+ if (e.target.checked) selectorState.selected.add(id);
1294
+ else selectorState.selected.delete(id);
1295
+ renderSelectorList();
1296
+ updateSelectedCount();
1297
+ });
1298
+ });
1299
+ list.querySelectorAll('.prov-sel-alias').forEach(inp => {
1300
+ inp.addEventListener('input', (e) => {
1301
+ const id = e.target.getAttribute('data-upstream');
1302
+ selectorState.aliases[id] = e.target.value;
1303
+ // Live-update the visible "β†’ prefix/alias" text without full re-render
1304
+ const row = e.target.closest('[data-upstream]');
1305
+ const prefix = (($('prov-prefix') || {}).value || '').trim();
1306
+ const alias = e.target.value || id;
1307
+ const full = prefix ? `${prefix}/${alias}` : alias;
1308
+ const tail = row.querySelector('span[title]');
1309
+ if (tail) { tail.textContent = 'β†’ ' + full; tail.title = full; }
1310
+ });
1311
+ });
1312
+ }
1313
+
1314
+ function updateSelectedCount() {
1315
+ const el = $('prov-selected-count');
1316
+ if (el) el.textContent = `${selectorState.selected.size} / ${selectorState.available.length} selected`;
1317
+ }
1318
+
1319
+ function openSelector(models) {
1320
+ selectorState.active = true;
1321
+ selectorState.available = [...models];
1322
+ selectorState.selected = new Set(models); // default: all selected
1323
+ selectorState.aliases = {};
1324
+ models.forEach(m => { selectorState.aliases[m] = m; });
1325
+ selectorState.filter = '';
1326
+ const search = $('prov-selector-search');
1327
+ if (search) search.value = '';
1328
+ const sel = $('prov-selector');
1329
+ if (sel) sel.classList.remove('hidden');
1330
+ updateSelectorPrefixPreview();
1331
+ renderSelectorList();
1332
+ updateSelectedCount();
1333
+ if (sel) sel.scrollIntoView({ behavior: 'smooth', block: 'start' });
1334
+ lucide.createIcons();
1335
+ }
1336
+ function closeSelector() {
1337
+ selectorState.active = false;
1338
+ const sel = $('prov-selector');
1339
+ if (sel) sel.classList.add('hidden');
1340
  }
1341
 
1342
  async function loadProviders() {
 
1363
  lucide.createIcons();
1364
  return;
1365
  }
1366
+
1367
+ list.innerHTML = providers.map(p => {
1368
+ const prefix = p.prefix || '';
1369
+ // p.models can be either rich objects {upstream,alias,full_id} (new) or plain strings (legacy)
1370
+ const richModels = (p.models || []).map(m => typeof m === 'string'
1371
+ ? { upstream: m, alias: m, full_id: prefix ? `${prefix}/${m}` : m }
1372
+ : m);
1373
+ const previewChips = richModels.slice(0, 6).map(m => `
1374
+ <span class="text-[10px] font-mono bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded-md truncate max-w-[200px]" title="upstream: ${escapeAttr(m.upstream)}">
1375
+ ${escapeHtml(m.full_id)}
1376
+ </span>`).join('');
1377
+ const moreCount = Math.max(0, richModels.length - 6);
1378
+ const prefixBadge = prefix
1379
+ ? `<span class="text-[10px] font-mono bg-amber-500/15 border border-amber-500/30 text-amber-300 px-2 py-0.5 rounded-md tracking-wider">${escapeHtml(prefix)}/</span>`
1380
+ : `<span class="text-[10px] font-mono bg-slate-500/10 border border-slate-500/20 text-slate-400 px-2 py-0.5 rounded-md">no prefix</span>`;
1381
+ return `
1382
+ <div class="bg-gradient-to-br from-black/45 to-black/25 rounded-2xl p-4 border border-white/5 hover:border-amber-500/25 transition-all group">
1383
+ <div class="flex items-start justify-between gap-3">
1384
+ <div class="flex-1 min-w-0">
1385
+ <div class="flex items-center gap-2 flex-wrap">
1386
+ <span class="font-bold text-slate-100 text-sm tracking-tight">${escapeHtml(p.name)}</span>
1387
+ ${prefixBadge}
1388
+ <span class="text-[9px] font-mono bg-white/5 border border-white/10 text-slate-400 px-1.5 py-0.5 rounded">${escapeHtml(p.id)}</span>
1389
+ </div>
1390
+ <div class="text-[11px] text-slate-400 font-mono mt-1.5 truncate flex items-center gap-1.5">
1391
+ <i data-lucide="link-2" class="w-3 h-3 text-slate-500"></i>
1392
+ <span class="truncate">${escapeHtml(p.base_url)}</span>
1393
+ </div>
1394
  </div>
1395
+ <div class="flex flex-col sm:flex-row gap-1.5 flex-shrink-0">
1396
+ <button onclick="window.__editProvider('${escapeAttr(p.id)}')" class="text-amber-300/80 hover:text-amber-300 bg-amber-500/5 hover:bg-amber-500/15 border border-amber-500/20 text-[10px] font-mono font-bold px-2.5 py-1.5 rounded-lg transition flex items-center gap-1" title="Edit prefix & models">
1397
+ <i data-lucide="settings-2" class="w-3 h-3"></i> EDIT
1398
+ </button>
1399
+ <button onclick="window.__deleteProvider('${escapeAttr(p.id)}')" class="text-red-400/80 hover:text-red-400 bg-red-500/5 hover:bg-red-500/15 border border-red-500/20 text-[10px] font-mono font-bold px-2.5 py-1.5 rounded-lg transition flex items-center gap-1">
1400
+ <i data-lucide="trash-2" class="w-3 h-3"></i> DEL
1401
+ </button>
1402
  </div>
1403
  </div>
1404
+ <div class="flex flex-wrap gap-3 mt-3 text-[11px] text-slate-500 font-mono">
1405
+ <span class="flex items-center gap-1"><i data-lucide="key-round" class="w-3 h-3"></i> ${p.key_count} keys</span>
1406
+ <span class="flex items-center gap-1"><i data-lucide="box" class="w-3 h-3"></i> ${richModels.length} exposed</span>
1407
+ <span class="flex items-center gap-1 text-slate-600"><i data-lucide="database" class="w-3 h-3"></i> ${(p.available_models || []).length} available upstream</span>
1408
+ </div>
1409
+ ${richModels.length > 0 ? `
1410
+ <div class="mt-3 pt-3 border-t border-white/5 flex flex-wrap gap-1.5">
1411
+ ${previewChips}
1412
+ ${moreCount > 0 ? `<span class="text-[10px] font-mono text-slate-500 px-2 py-0.5">+${moreCount} more</span>` : ''}
1413
+ </div>` : ''}
1414
+ </div>`;
1415
+ }).join('');
1416
  lucide.createIcons();
1417
  }
1418
 
1419
  window.__deleteProvider = async function(id) {
1420
+ if (!confirm('Delete this provider? All its prefixed models (e.g. prefix/alias) will stop routing immediately.')) return;
1421
  try {
1422
  const r = await api('/admin/providers/' + id, { method: 'DELETE' });
1423
  if (!r || !r.ok) throw new Error('Delete failed on live server');
1424
  toast('Provider removed from routing list', 'success');
1425
  await loadProviders();
1426
+ await loadModels();
1427
  } catch (e) {
1428
+ const idx = mockProviders.findIndex(p => p.id === id);
1429
+ if (idx >= 0) mockProviders.splice(idx, 1);
1430
  renderProvidersList([...mockProviders]);
1431
+ toast('Removed provider (offline mode)', 'success');
1432
  }
1433
  };
1434
 
1435
+ // ── Step 1: Discover models, then open Step 2 selector ─────────────
1436
+ async function discoverProvider() {
1437
+ const name = ($('prov-name').value || '').trim();
1438
+ const baseUrl = ($('prov-url').value || '').trim();
1439
+ const prefix = (($('prov-prefix') || {}).value || '').trim();
1440
+ const keysText = ($('prov-keys').value || '').trim();
1441
  const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
1442
  const status = $('prov-status');
1443
 
1444
+ if (!name || !baseUrl) {
1445
+ toast('Provider Name and Base URL are required', 'error');
1446
+ return;
1447
+ }
1448
+ if (keys.length === 0) {
1449
+ toast('At least one API key is required', 'error');
1450
+ return;
1451
  }
1452
 
1453
  const btn = $('prov-preview-btn');
1454
+ btn.disabled = true;
1455
+ btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Discovering...</span>`;
 
1456
  status.classList.remove('hidden');
1457
+ status.textContent = 'Probing upstream /models endpoint...';
1458
+ status.className = 'text-xs font-mono text-slate-400 bg-white/5 px-2.5 py-1.5 rounded-lg border border-white/10';
1459
+
1460
+ let discovered = [];
1461
  try {
1462
  const r = await api('/admin/providers/preview', {
1463
  method: 'POST',
1464
+ body: JSON.stringify({ name, base_url: baseUrl, keys, prefix })
1465
  });
 
1466
  if (!r || !r.ok) {
1467
  const d = await r.json().catch(() => ({}));
1468
+ throw new Error(d?.error?.message || 'Probe failed');
1469
  }
 
1470
  const data = await r.json();
1471
+ discovered = (data.models || []).filter(Boolean);
1472
+ status.textContent = `βœ“ Discovered ${discovered.length} models from ${name}. Pick the ones to expose below.`;
 
 
 
 
 
1473
  status.className = 'text-xs font-mono text-emerald-400 bg-emerald-500/5 px-2.5 py-1.5 rounded-lg border border-emerald-500/10';
1474
  } catch (e) {
1475
+ discovered = ["gpt-4o", "gpt-4o-mini", "claude-3-5-sonnet", "text-embedding-3", "deepseek-coder-v2"];
1476
+ status.textContent = `(offline preview) Using ${discovered.length} simulated models.`;
1477
+ status.className = 'text-xs font-mono text-amber-400 bg-amber-500/5 px-2.5 py-1.5 rounded-lg border border-amber-500/10';
 
 
 
 
 
 
 
1478
  } finally {
1479
+ btn.disabled = false;
1480
+ btn.innerHTML = `<i data-lucide="stethoscope" class="w-3.5 h-3.5"></i><span>Discover Models</span>`;
1481
  lucide.createIcons();
1482
  }
1483
+
1484
+ if (discovered.length > 0) openSelector(discovered);
1485
  }
1486
 
1487
+ // ── Step 3: Import with selected models + custom aliases ───────────
1488
  async function importProvider() {
1489
+ if (!selectorState.active || selectorState.selected.size === 0) {
1490
+ toast('Select at least one model to import', 'error');
1491
+ return;
1492
+ }
1493
+ const name = ($('prov-name').value || '').trim();
1494
+ const baseUrl = ($('prov-url').value || '').trim();
1495
+ const prefix = (($('prov-prefix') || {}).value || '').trim();
1496
+ const keysText = ($('prov-keys').value || '').trim();
1497
  const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
 
1498
 
1499
  if (!name || !baseUrl || keys.length === 0) {
1500
+ toast('Form is incomplete', 'error');
1501
  return;
1502
  }
1503
 
1504
+ // Build model_aliases map (only for selected models)
1505
+ const model_aliases = {};
1506
+ const seenAliases = new Set();
1507
+ for (const id of selectorState.selected) {
1508
+ let alias = (selectorState.aliases[id] || id).trim();
1509
+ alias = alias || id;
1510
+ if (seenAliases.has(alias)) {
1511
+ toast(`Duplicate alias "${alias}" β€” aliases must be unique within a provider`, 'error');
1512
+ return;
1513
+ }
1514
+ seenAliases.add(alias);
1515
+ model_aliases[id] = alias;
1516
+ }
1517
+
1518
  const btn = $('prov-import-btn');
1519
+ btn.disabled = true;
1520
+ btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Importing...</span>`;
1521
+
 
1522
  try {
1523
  const r = await api('/admin/providers/import', {
1524
  method: 'POST',
1525
+ body: JSON.stringify({ name, base_url: baseUrl, keys, prefix, model_aliases })
1526
  });
1527
  if (!r || !r.ok) {
1528
  const d = await r.json().catch(() => ({}));
1529
+ throw new Error(d?.error?.message || 'Import failed');
1530
  }
1531
  const data = await r.json();
1532
+ const exampleId = prefix ? `${prefix}/${Object.values(model_aliases)[0]}` : Object.values(model_aliases)[0];
1533
+ toast(`Imported ${data.model_count} models. Try: ${exampleId}`, 'success');
1534
  resetProviderForm();
1535
  await loadProviders();
1536
+ await loadModels();
1537
  } catch (e) {
1538
+ // Offline fallback
1539
+ const mockId = "prov_" + slugifyAlias(name);
1540
+ const richModels = Object.entries(model_aliases).map(([up, al]) => ({
1541
+ upstream: up, alias: al, full_id: prefix ? `${prefix}/${al}` : al
1542
+ }));
1543
+ mockProviders.push({
1544
+ id: mockId, name, prefix, base_url: baseUrl, key_count: keys.length,
1545
+ available_models: selectorState.available,
1546
+ model_aliases, models: richModels,
 
 
 
 
 
 
 
 
 
 
1547
  });
1548
+ richModels.forEach(m => mockModels.push({
1549
+ id: Math.random().toString(), name: m.full_id, provider: name, upstreams: 1
1550
+ }));
1551
+ toast(`Imported ${richModels.length} models (offline mode)`, 'success');
1552
  resetProviderForm();
1553
  renderProvidersList([...mockProviders]);
1554
  renderModels('');
1555
  } finally {
1556
+ btn.disabled = false;
1557
+ btn.innerHTML = `<i data-lucide="import" class="w-3.5 h-3.5"></i><span>Import Selected Models</span>`;
1558
  lucide.createIcons();
1559
  }
1560
  }
 
1563
  $('prov-name').value = '';
1564
  $('prov-url').value = '';
1565
  $('prov-keys').value = '';
1566
+ const px = $('prov-prefix'); if (px) px.value = '';
1567
  $('prov-status').classList.add('hidden');
1568
+ closeSelector();
1569
  }
1570
 
1571
+ // Wire up Step 1/2 controls
1572
+ if ($('prov-preview-btn')) $('prov-preview-btn').addEventListener('click', discoverProvider);
1573
  if ($('prov-import-btn')) $('prov-import-btn').addEventListener('click', importProvider);
1574
+ if ($('prov-cancel-import')) $('prov-cancel-import').addEventListener('click', () => { closeSelector(); toast('Import cancelled'); });
1575
+ if ($('prov-reset-btn')) $('prov-reset-btn').addEventListener('click', () => { resetProviderForm(); toast('Form reset'); });
1576
+ if ($('prov-prefix')) $('prov-prefix').addEventListener('input', () => {
1577
+ updateSelectorPrefixPreview();
1578
+ if (selectorState.active) renderSelectorList();
1579
+ });
1580
+ if ($('prov-selector-search')) $('prov-selector-search').addEventListener('input', (e) => {
1581
+ selectorState.filter = e.target.value || '';
1582
+ renderSelectorList();
1583
+ });
1584
+ if ($('prov-select-all')) $('prov-select-all').addEventListener('click', () => {
1585
+ selectorState.available.forEach(id => selectorState.selected.add(id));
1586
+ renderSelectorList(); updateSelectedCount();
1587
+ });
1588
+ if ($('prov-select-none')) $('prov-select-none').addEventListener('click', () => {
1589
+ selectorState.selected.clear(); renderSelectorList(); updateSelectedCount();
1590
+ });
1591
+ if ($('prov-reset-aliases')) $('prov-reset-aliases').addEventListener('click', () => {
1592
+ selectorState.available.forEach(id => { selectorState.aliases[id] = id; });
1593
+ renderSelectorList();
1594
+ toast('Aliases reset to upstream IDs');
1595
+ });
1596
+
1597
+ // ── Edit Provider Modal ─────────────────────────────────────────────
1598
+ const editState = {
1599
+ providerId: null,
1600
+ original: null,
1601
+ prefix: '',
1602
+ name: '',
1603
+ available: [],
1604
+ selected: new Set(),
1605
+ aliases: {},
1606
+ filter: '',
1607
+ };
1608
+
1609
+ function renderEditList() {
1610
+ const list = $('edit-prov-list');
1611
+ if (!list) return;
1612
+ const q = (editState.filter || '').toLowerCase();
1613
+ const items = editState.available.filter(id => !q || id.toLowerCase().includes(q));
1614
+ if (items.length === 0) {
1615
+ list.innerHTML = `<div class="text-center text-slate-500 text-xs font-mono py-8">No models match this filter.</div>`;
1616
+ return;
1617
+ }
1618
+ list.innerHTML = items.map(id => {
1619
+ const isSel = editState.selected.has(id);
1620
+ const alias = editState.aliases[id] != null ? editState.aliases[id] : id;
1621
+ const full = editState.prefix ? `${editState.prefix}/${alias}` : alias;
1622
+ return `
1623
+ <div class="grid grid-cols-12 gap-3 items-center bg-black/30 hover:bg-black/50 rounded-lg border ${isSel ? 'border-amber-500/30' : 'border-white/5'} px-3 py-2 transition-all" data-upstream="${escapeAttr(id)}">
1624
+ <div class="col-span-1 flex justify-center">
1625
+ <label class="relative inline-flex items-center cursor-pointer">
1626
+ <input type="checkbox" class="edit-prov-check sr-only peer" ${isSel ? 'checked' : ''} data-upstream="${escapeAttr(id)}">
1627
+ <span class="w-4 h-4 rounded border ${isSel ? 'bg-amber-500 border-amber-500' : 'bg-black/40 border-white/20'} flex items-center justify-center transition-all">
1628
+ ${isSel ? '<svg class="w-3 h-3 text-black" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>' : ''}
1629
+ </span>
1630
+ </label>
1631
+ </div>
1632
+ <div class="col-span-5 min-w-0">
1633
+ <div class="font-mono text-[11px] text-slate-200 truncate" title="${escapeAttr(id)}">${escapeHtml(id)}</div>
1634
+ </div>
1635
+ <div class="col-span-6">
1636
+ <div class="flex items-center gap-2">
1637
+ <input type="text"
1638
+ class="edit-prov-alias flex-1 bg-black/40 border border-white/10 focus:border-amber-500/50 rounded-md px-2 py-1 text-[11px] font-mono text-amber-200 focus:outline-none ${isSel ? '' : 'opacity-40'}"
1639
+ value="${escapeAttr(alias)}"
1640
+ data-upstream="${escapeAttr(id)}"
1641
+ ${isSel ? '' : 'disabled'}>
1642
+ <span class="text-[10px] font-mono text-slate-500 truncate flex-shrink-0 min-w-0" title="${escapeAttr(full)}">β†’ ${escapeHtml(full)}</span>
1643
+ </div>
1644
+ </div>
1645
+ </div>`;
1646
+ }).join('');
1647
+ list.querySelectorAll('.edit-prov-check').forEach(cb => {
1648
+ cb.addEventListener('change', (e) => {
1649
+ const id = e.target.getAttribute('data-upstream');
1650
+ if (e.target.checked) editState.selected.add(id);
1651
+ else editState.selected.delete(id);
1652
+ renderEditList();
1653
+ $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`;
1654
+ });
1655
+ });
1656
+ list.querySelectorAll('.edit-prov-alias').forEach(inp => {
1657
+ inp.addEventListener('input', (e) => {
1658
+ const id = e.target.getAttribute('data-upstream');
1659
+ editState.aliases[id] = e.target.value;
1660
+ const row = e.target.closest('[data-upstream]');
1661
+ const alias = e.target.value || id;
1662
+ const full = editState.prefix ? `${editState.prefix}/${alias}` : alias;
1663
+ const tail = row.querySelector('span[title]');
1664
+ if (tail) { tail.textContent = 'β†’ ' + full; tail.title = full; }
1665
+ });
1666
+ });
1667
+ }
1668
+
1669
+ window.__editProvider = async function(id) {
1670
+ // Resolve provider snapshot. Try API first, then mock.
1671
+ let prov = null;
1672
+ try {
1673
+ const r = await api('/admin/providers');
1674
+ if (r && r.ok) {
1675
+ const data = await r.json();
1676
+ prov = (data.providers || []).find(p => p.id === id);
1677
+ }
1678
+ } catch (_) {}
1679
+ if (!prov) prov = mockProviders.find(p => p.id === id);
1680
+ if (!prov) { toast('Provider not found', 'error'); return; }
1681
+
1682
+ editState.providerId = prov.id;
1683
+ editState.original = prov;
1684
+ editState.name = prov.name || '';
1685
+ editState.prefix = prov.prefix || '';
1686
+ editState.available = [...(prov.available_models || Object.keys(prov.model_aliases || {}))];
1687
+ editState.aliases = Object.assign({}, prov.model_aliases || {});
1688
+ editState.selected = new Set(Object.keys(editState.aliases));
1689
+ editState.available.forEach(m => { if (!(m in editState.aliases)) editState.aliases[m] = m; });
1690
+ editState.filter = '';
1691
+
1692
+ $('edit-prov-modal-title').textContent = `Edit: ${prov.name}`;
1693
+ $('edit-prov-name').value = editState.name;
1694
+ $('edit-prov-prefix').value = editState.prefix;
1695
+ $('edit-prov-search').value = '';
1696
+ $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`;
1697
+ renderEditList();
1698
+
1699
+ $('edit-prov-modal').classList.remove('hidden');
1700
+ lucide.createIcons();
1701
+ };
1702
+
1703
+ function closeEditModal() {
1704
+ $('edit-prov-modal').classList.add('hidden');
1705
+ editState.providerId = null;
1706
+ }
1707
+
1708
+ async function saveEditProvider() {
1709
+ if (!editState.providerId) return;
1710
+ const newName = ($('edit-prov-name').value || '').trim() || editState.name;
1711
+ const newPrefix = ($('edit-prov-prefix').value || '').trim();
1712
+
1713
+ // Build model_aliases map from selection
1714
+ const model_aliases = {};
1715
+ const seen = new Set();
1716
+ for (const id of editState.selected) {
1717
+ let alias = (editState.aliases[id] || id).trim() || id;
1718
+ if (seen.has(alias)) {
1719
+ toast(`Duplicate alias "${alias}"`, 'error');
1720
+ return;
1721
+ }
1722
+ seen.add(alias);
1723
+ model_aliases[id] = alias;
1724
+ }
1725
+
1726
+ const btn = $('edit-prov-save');
1727
+ btn.disabled = true;
1728
+ const oldHtml = btn.innerHTML;
1729
+ btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Saving...</span>`;
1730
+
1731
+ try {
1732
+ const r = await api('/admin/providers/' + editState.providerId, {
1733
+ method: 'PATCH',
1734
+ body: JSON.stringify({ name: newName, prefix: newPrefix, model_aliases })
1735
+ });
1736
+ if (!r || !r.ok) {
1737
+ const d = await r.json().catch(() => ({}));
1738
+ throw new Error(d?.error?.message || 'Save failed');
1739
+ }
1740
+ toast('Provider updated', 'success');
1741
+ closeEditModal();
1742
+ await loadProviders();
1743
+ await loadModels();
1744
+ } catch (e) {
1745
+ // Offline: mutate mock
1746
+ const p = mockProviders.find(x => x.id === editState.providerId);
1747
+ if (p) {
1748
+ p.name = newName;
1749
+ p.prefix = newPrefix;
1750
+ p.model_aliases = model_aliases;
1751
+ p.models = Object.entries(model_aliases).map(([up, al]) => ({
1752
+ upstream: up, alias: al, full_id: newPrefix ? `${newPrefix}/${al}` : al
1753
+ }));
1754
+ }
1755
+ renderProvidersList([...mockProviders]);
1756
+ toast('Updated provider (offline mode)', 'success');
1757
+ closeEditModal();
1758
+ } finally {
1759
+ btn.disabled = false;
1760
+ btn.innerHTML = oldHtml;
1761
+ lucide.createIcons();
1762
+ }
1763
+ }
1764
+
1765
+ if ($('edit-prov-close')) $('edit-prov-close').addEventListener('click', closeEditModal);
1766
+ if ($('edit-prov-cancel')) $('edit-prov-cancel').addEventListener('click', closeEditModal);
1767
+ if ($('edit-prov-save')) $('edit-prov-save').addEventListener('click', saveEditProvider);
1768
+ if ($('edit-prov-search')) $('edit-prov-search').addEventListener('input', (e) => {
1769
+ editState.filter = e.target.value || '';
1770
+ renderEditList();
1771
+ });
1772
+ if ($('edit-prov-prefix')) $('edit-prov-prefix').addEventListener('input', (e) => {
1773
+ editState.prefix = (e.target.value || '').trim();
1774
+ renderEditList();
1775
+ });
1776
+ if ($('edit-prov-select-all')) $('edit-prov-select-all').addEventListener('click', () => {
1777
+ editState.available.forEach(id => editState.selected.add(id));
1778
+ renderEditList();
1779
+ $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`;
1780
+ });
1781
+ if ($('edit-prov-select-none')) $('edit-prov-select-none').addEventListener('click', () => {
1782
+ editState.selected.clear();
1783
+ renderEditList();
1784
+ $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`;
1785
+ });
1786
 
1787
  // ── Models Catalog ─────────────────────────────────────────
1788
  let _modelsCache = [];
 
1861
  initAuth();
1862
  })();
1863
  </script>
1864
+
1865
+ <!-- ── Edit Provider Modal ─────────────────────────────────────────── -->
1866
+ <div id="edit-prov-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
1867
+ <div class="glass-bright rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl border border-amber-500/20">
1868
+ <!-- Header -->
1869
+ <div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
1870
+ <div class="flex items-center gap-3 min-w-0">
1871
+ <div class="p-2 bg-amber-500/15 text-amber-400 rounded-lg flex-shrink-0">
1872
+ <i data-lucide="settings-2" class="w-4 h-4"></i>
1873
+ </div>
1874
+ <div class="min-w-0">
1875
+ <h3 id="edit-prov-modal-title" class="font-bold text-slate-100 truncate">Edit Provider</h3>
1876
+ <p class="text-[11px] text-slate-400 font-mono">Update prefix, model selection, and aliases</p>
1877
+ </div>
1878
+ </div>
1879
+ <button id="edit-prov-close" class="text-slate-400 hover:text-slate-100 bg-white/5 hover:bg-white/10 rounded-lg p-1.5 transition">
1880
+ <i data-lucide="x" class="w-4 h-4"></i>
1881
+ </button>
1882
+ </div>
1883
+
1884
+ <!-- Top: name + prefix -->
1885
+ <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4 border-b border-white/5">
1886
+ <div>
1887
+ <label class="block text-[10px] font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono">Display Name</label>
1888
+ <input id="edit-prov-name" type="text" class="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-amber-500/60 transition-all">
1889
+ </div>
1890
+ <div>
1891
+ <label class="block text-[10px] font-semibold text-slate-400 uppercase tracking-wider mb-1.5 font-mono flex items-center gap-1.5">
1892
+ Prefix <span class="text-[9px] text-amber-400/80 bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">{prefix}/model</span>
1893
+ </label>
1894
+ <input id="edit-prov-prefix" type="text" class="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-amber-200 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all" placeholder="e.g. deb">
1895
+ </div>
1896
+ </div>
1897
+
1898
+ <!-- Filter & bulk -->
1899
+ <div class="px-6 py-3 flex flex-col sm:flex-row gap-3 items-stretch sm:items-center border-b border-white/5">
1900
+ <div class="relative flex-1">
1901
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
1902
+ <i data-lucide="search" class="w-4 h-4"></i>
1903
+ </span>
1904
+ <input id="edit-prov-search" type="text" placeholder="Filter models..."
1905
+ class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-slate-100 font-mono focus:outline-none focus:border-amber-500/50 transition-all">
1906
+ </div>
1907
+ <div class="flex gap-2 text-[11px] font-mono">
1908
+ <button id="edit-prov-select-all" class="bg-white/5 hover:bg-white/10 text-slate-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1">
1909
+ <i data-lucide="check-square" class="w-3 h-3"></i> All
1910
+ </button>
1911
+ <button id="edit-prov-select-none" class="bg-white/5 hover:bg-white/10 text-slate-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1">
1912
+ <i data-lucide="square" class="w-3 h-3"></i> None
1913
+ </button>
1914
+ <span id="edit-prov-count" class="text-xs font-mono text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1.5 rounded-lg">0 / 0 selected</span>
1915
+ </div>
1916
+ </div>
1917
+
1918
+ <!-- Table -->
1919
+ <div class="px-6 pt-3 grid grid-cols-12 gap-3 text-[10px] uppercase font-mono text-slate-500">
1920
+ <div class="col-span-1"></div>
1921
+ <div class="col-span-5">Upstream Model ID</div>
1922
+ <div class="col-span-6">Exposed Alias <span class="text-amber-400/70">(editable)</span></div>
1923
+ </div>
1924
+ <div id="edit-prov-list" class="px-6 py-3 flex-1 overflow-y-auto space-y-1.5">
1925
+ <!-- Rows injected -->
1926
+ </div>
1927
+
1928
+ <!-- Footer -->
1929
+ <div class="px-6 py-4 border-t border-white/10 flex flex-col sm:flex-row gap-2 justify-end">
1930
+ <button id="edit-prov-cancel" class="bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-semibold py-2.5 px-5 rounded-xl transition border border-white/10 flex items-center justify-center gap-1.5">
1931
+ <i data-lucide="x" class="w-3.5 h-3.5"></i><span>Cancel</span>
1932
+ </button>
1933
+ <button id="edit-prov-save" class="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black text-xs font-bold py-2.5 px-5 rounded-xl transition shadow-md shadow-amber-500/10 flex items-center justify-center gap-1.5">
1934
+ <i data-lucide="save" class="w-3.5 h-3.5"></i><span>Save Changes</span>
1935
+ </button>
1936
+ </div>
1937
+ </div>
1938
+ </div>
1939
+
1940
  </body>
1941
  </html>