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)
- app/index.py +228 -59
- app/templates/dashboard.html +636 -123
|
@@ -528,40 +528,68 @@ def normalize_model(model: str) -> str:
|
|
| 528 |
|
| 529 |
|
| 530 |
def parse_provider_prefix(model: str):
|
| 531 |
-
"""Parse '
|
| 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 |
-
|
| 541 |
-
|
| 542 |
-
if
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
#
|
| 552 |
-
|
| 553 |
-
# Default routing: use UPSTREAMS dict
|
| 554 |
-
upstreams = UPSTREAMS.get(
|
| 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,
|
| 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.
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
for pid, prov in _dynamic_providers.items():
|
| 917 |
prov_name = prov.get("name", pid)
|
| 918 |
-
|
| 919 |
-
|
|
|
|
|
|
|
|
|
|
| 920 |
continue
|
| 921 |
-
|
| 922 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 1180 |
for pid, prov in _dynamic_providers.items():
|
| 1181 |
prov_name = prov.get("name", pid)
|
| 1182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
models.append({
|
| 1184 |
-
"name":
|
|
|
|
|
|
|
|
|
|
| 1185 |
"provider": prov_name,
|
|
|
|
| 1186 |
"type": "dynamic",
|
| 1187 |
})
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
base_url = prov["base_url"].rstrip("/")
|
| 1332 |
chat_url = f"{base_url}/chat/completions"
|
| 1333 |
keys = prov.get("keys", [])
|
| 1334 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 1470 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1489 |
-
|
|
|
|
| 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
|
| 1496 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -405,48 +405,105 @@
|
|
| 405 |
</div>
|
| 406 |
|
| 407 |
<div class="space-y-3.5">
|
| 408 |
-
<div>
|
| 409 |
-
<
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<!--
|
| 425 |
<div class="flex gap-2 pt-2">
|
| 426 |
-
<button id="prov-preview-btn" class="flex-1 bg-
|
| 427 |
<i data-lucide="stethoscope" class="w-3.5 h-3.5"></i>
|
| 428 |
-
<span>
|
| 429 |
</button>
|
| 430 |
-
<button id="prov-
|
| 431 |
-
<i data-lucide="
|
| 432 |
-
<span>Import Provider</span>
|
| 433 |
</button>
|
| 434 |
</div>
|
| 435 |
|
| 436 |
-
<!--
|
| 437 |
<div id="prov-status" class="text-xs font-mono hidden"></div>
|
|
|
|
|
|
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
</
|
|
|
|
|
|
|
|
|
|
| 448 |
</div>
|
| 449 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
</div>
|
| 451 |
|
| 452 |
<!-- Existing active dynamic upstreams -->
|
|
@@ -676,8 +733,26 @@
|
|
| 676 |
};
|
| 677 |
|
| 678 |
const mockProviders = [
|
| 679 |
-
{
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
];
|
| 682 |
|
| 683 |
const mockModels = [
|
|
@@ -1146,7 +1221,122 @@
|
|
| 1146 |
|
| 1147 |
// ββ Dynamic Provider Management βββββββββββββββββββββββββββββββββββββ
|
| 1148 |
function escapeHtml(s) {
|
| 1149 |
-
return String(s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
}
|
| 1151 |
|
| 1152 |
async function loadProviders() {
|
|
@@ -1173,165 +1363,198 @@
|
|
| 1173 |
lucide.createIcons();
|
| 1174 |
return;
|
| 1175 |
}
|
| 1176 |
-
|
| 1177 |
-
list.innerHTML = providers.map(p =>
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1187 |
</div>
|
| 1188 |
-
<div class="flex gap-
|
| 1189 |
-
<
|
| 1190 |
-
<i data-lucide="
|
| 1191 |
-
</
|
| 1192 |
-
<
|
| 1193 |
-
<i data-lucide="
|
| 1194 |
-
</
|
| 1195 |
</div>
|
| 1196 |
</div>
|
| 1197 |
-
<
|
| 1198 |
-
<i data-lucide="
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
lucide.createIcons();
|
| 1203 |
}
|
| 1204 |
|
| 1205 |
window.__deleteProvider = async function(id) {
|
| 1206 |
-
if (!confirm('
|
| 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 |
-
|
| 1214 |
-
|
| 1215 |
renderProvidersList([...mockProviders]);
|
| 1216 |
-
toast('Removed provider
|
| 1217 |
}
|
| 1218 |
};
|
| 1219 |
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
const
|
| 1223 |
-
const
|
|
|
|
|
|
|
| 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('
|
| 1229 |
-
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
}
|
| 1231 |
|
| 1232 |
const btn = $('prov-preview-btn');
|
| 1233 |
-
btn.disabled = true;
|
| 1234 |
-
btn.innerHTML = `<
|
| 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 || '
|
| 1247 |
}
|
| 1248 |
-
|
| 1249 |
const data = await r.json();
|
| 1250 |
-
|
| 1251 |
-
|
| 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 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 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>
|
| 1272 |
lucide.createIcons();
|
| 1273 |
}
|
|
|
|
|
|
|
| 1274 |
}
|
| 1275 |
|
|
|
|
| 1276 |
async function importProvider() {
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 1285 |
return;
|
| 1286 |
}
|
| 1287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
const btn = $('prov-import-btn');
|
| 1289 |
-
btn.disabled = true;
|
| 1290 |
-
btn.innerHTML = `<
|
| 1291 |
-
|
| 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
|
| 1301 |
}
|
| 1302 |
const data = await r.json();
|
| 1303 |
-
|
|
|
|
| 1304 |
resetProviderForm();
|
| 1305 |
await loadProviders();
|
|
|
|
| 1306 |
} catch (e) {
|
| 1307 |
-
//
|
| 1308 |
-
const mockId = "prov_" +
|
| 1309 |
-
const
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
key_count: keys.length,
|
| 1314 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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>
|
| 1335 |
lucide.createIcons();
|
| 1336 |
}
|
| 1337 |
}
|
|
@@ -1340,12 +1563,226 @@
|
|
| 1340 |
$('prov-name').value = '';
|
| 1341 |
$('prov-url').value = '';
|
| 1342 |
$('prov-keys').value = '';
|
| 1343 |
-
$('prov-
|
| 1344 |
$('prov-status').classList.add('hidden');
|
|
|
|
| 1345 |
}
|
| 1346 |
|
| 1347 |
-
|
|
|
|
| 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}/<alias></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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 1225 |
+
}
|
| 1226 |
+
function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '`'); }
|
| 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>
|