mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 15:56:14 +00:00
Compare commits
24
Commits
lightning
...
embeddings
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0829090e | ||
|
|
4ed86b0091 | ||
|
|
b327c0dee1 | ||
|
|
470c32aebf | ||
|
|
b01c7b2e56 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
8edc3512c1 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
87b1443c23 | ||
|
|
c97c74a2ee | ||
|
|
4c7887fa4e | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 | ||
|
|
6b4b3924a1 | ||
|
|
a226a78222 |
@@ -282,12 +282,8 @@ def create_model_mappings(
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
|
||||
logger.debug(
|
||||
"Created model mappings",
|
||||
extra={
|
||||
"unique_model_count": len(unique_models),
|
||||
"total_alias_count": len(model_instances),
|
||||
"provider_distribution": provider_counts,
|
||||
},
|
||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||
extra={"provider_distribution": provider_counts},
|
||||
)
|
||||
|
||||
return model_instances, provider_map, unique_models
|
||||
|
||||
+121
-1
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
@@ -3165,3 +3165,123 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
class ModelMappingRequest(BaseModel):
|
||||
from_model: str = Field(..., alias="from")
|
||||
to: str
|
||||
|
||||
|
||||
class ModelMappingUpdateRequest(BaseModel):
|
||||
to: str
|
||||
|
||||
|
||||
@admin_router.get("/api/model-mappings", dependencies=[Depends(require_admin_api)])
|
||||
async def get_model_mappings(request: Request) -> dict[str, str]:
|
||||
from ..proxy import _manual_model_mappings
|
||||
return _manual_model_mappings
|
||||
|
||||
|
||||
@admin_router.post("/api/model-mappings", dependencies=[Depends(require_admin_api)])
|
||||
async def create_model_mapping(request: Request, mapping: ModelMappingRequest) -> dict[str, str]:
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..proxy import _manual_model_mappings, load_manual_model_mappings
|
||||
|
||||
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
|
||||
|
||||
try:
|
||||
if os.path.exists(mappings_file):
|
||||
with open(mappings_file, "r") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"manual_model_mappings": {"mappings": {}}}
|
||||
|
||||
data["manual_model_mappings"]["mappings"][mapping.from_model.lower()] = mapping.to.lower()
|
||||
|
||||
with open(mappings_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
load_manual_model_mappings()
|
||||
|
||||
return _manual_model_mappings
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create mapping: {str(e)}")
|
||||
|
||||
|
||||
@admin_router.put("/api/model-mappings/{from_model}", dependencies=[Depends(require_admin_api)])
|
||||
async def update_model_mapping(request: Request, from_model: str, mapping: ModelMappingUpdateRequest) -> dict[str, str]:
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..proxy import _manual_model_mappings, load_manual_model_mappings
|
||||
|
||||
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
|
||||
|
||||
try:
|
||||
if os.path.exists(mappings_file):
|
||||
with open(mappings_file, "r") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"manual_model_mappings": {"mappings": {}}}
|
||||
|
||||
if from_model.lower() not in data["manual_model_mappings"]["mappings"]:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
|
||||
data["manual_model_mappings"]["mappings"][from_model.lower()] = mapping.to.lower()
|
||||
|
||||
with open(mappings_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
load_manual_model_mappings()
|
||||
|
||||
return _manual_model_mappings
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update mapping: {str(e)}")
|
||||
|
||||
|
||||
@admin_router.delete("/api/model-mappings/{from_model}", dependencies=[Depends(require_admin_api)])
|
||||
async def delete_model_mapping(request: Request, from_model: str) -> dict[str, str]:
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..proxy import _manual_model_mappings, load_manual_model_mappings
|
||||
|
||||
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
|
||||
|
||||
try:
|
||||
if os.path.exists(mappings_file):
|
||||
with open(mappings_file, "r") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"manual_model_mappings": {"mappings": {}}}
|
||||
|
||||
if from_model.lower() not in data["manual_model_mappings"]["mappings"]:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
|
||||
del data["manual_model_mappings"]["mappings"][from_model.lower()]
|
||||
|
||||
with open(mappings_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
load_manual_model_mappings()
|
||||
|
||||
return _manual_model_mappings
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete mapping: {str(e)}")
|
||||
|
||||
|
||||
@admin_router.post("/api/model-mappings/reload", dependencies=[Depends(require_admin_api)])
|
||||
async def reload_model_mappings(request: Request) -> dict[str, object]:
|
||||
from ..proxy import _manual_model_mappings, load_manual_model_mappings
|
||||
|
||||
try:
|
||||
load_manual_model_mappings()
|
||||
return {"ok": True, "mappings": _manual_model_mappings}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload mappings: {str(e)}")
|
||||
|
||||
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(
|
||||
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
|
||||
)
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"manual_model_mappings": {
|
||||
"mappings": {
|
||||
"text-embedding-ada-002-v2": "text-embedding-ada-002"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -215,7 +215,7 @@ async def query_nip91_events(
|
||||
continue
|
||||
events_out.append(ev_dict)
|
||||
logger.debug(
|
||||
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
|
||||
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
|
||||
)
|
||||
if drained:
|
||||
last_event_ts = time.time()
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic.v1 import BaseModel
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -64,6 +65,56 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
return cost_data
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
input_msats=-1, # Cost field doesn't break down by token type
|
||||
output_msats=-1,
|
||||
total_msats=cost_in_msats,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error calculating cost from usage data",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"usd_cost": usd_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
@@ -129,10 +180,19 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
|
||||
+26
-227
@@ -1,8 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -89,53 +87,38 @@ def _has_valid_pricing(model: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if "(free)" in model.get("name", ""):
|
||||
continue
|
||||
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(f"{base_url}/models", timeout=30),
|
||||
client.get(f"{base_url}/embeddings/models", timeout=30),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
models_data.extend(process_models_response(models_response))
|
||||
models_data.extend(process_models_response(embeddings_response))
|
||||
|
||||
# Apply source filter and exclusions
|
||||
filtered_models = []
|
||||
for model in models_data:
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
@@ -153,9 +136,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
filtered_models.append(model)
|
||||
|
||||
return models_data
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
@@ -169,69 +152,6 @@ def is_openrouter_upstream() -> bool:
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If a user-provided models.json exists, it will be used. Otherwise, models are
|
||||
automatically fetched from OpenRouter API in memory. If the example file exists
|
||||
and no user file is provided, it will be used as a fallback.
|
||||
"""
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])] # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Only auto-generate from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info(
|
||||
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
|
||||
)
|
||||
return []
|
||||
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
try:
|
||||
source_filter = settings.source or None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
|
||||
valid_models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
model = Model(**model_data) # type: ignore
|
||||
valid_models.append(model)
|
||||
except Exception as e:
|
||||
model_id = model_data.get("id", "unknown")
|
||||
logger.warning(f"Skipping model {model_id} - validation failed: {e}")
|
||||
|
||||
if len(valid_models) != len(models_data):
|
||||
logger.warning(
|
||||
f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data"
|
||||
)
|
||||
|
||||
return valid_models
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -460,57 +380,6 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
return model
|
||||
|
||||
|
||||
async def ensure_models_bootstrapped() -> None:
|
||||
async with create_session() as s:
|
||||
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
|
||||
if existing:
|
||||
return
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
models_to_insert: list[dict] = []
|
||||
if models_path.exists():
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
models_to_insert = data.get("models", [])
|
||||
logger.info(
|
||||
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
|
||||
if not models_to_insert and is_openrouter_upstream():
|
||||
logger.info("Bootstrapping models from OpenRouter API")
|
||||
source_filter = None
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
|
||||
elif not models_to_insert:
|
||||
logger.info(
|
||||
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
|
||||
)
|
||||
|
||||
for m in models_to_insert:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
# Some OpenRouter models include extra fields; only map required ones
|
||||
continue
|
||||
exists = await s.get(ModelRow, model.id)
|
||||
if exists:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
@@ -672,76 +541,6 @@ def _pricing_matches(
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_models_periodically() -> None:
|
||||
"""Background task: periodically fetch OpenRouter models and insert new ones.
|
||||
|
||||
- Respects optional SOURCE filter from settings
|
||||
- Does not overwrite existing rows
|
||||
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
|
||||
"""
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
# Only refresh from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_models_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
async with create_session() as s:
|
||||
result = await s.exec(select(ModelRow.id)) # type: ignore
|
||||
existing_ids = {
|
||||
row[0] if isinstance(row, tuple) else row for row in result.all()
|
||||
}
|
||||
inserted = 0
|
||||
for m in models:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
continue
|
||||
if model.id in existing_ids:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
try:
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
inserted += 1
|
||||
if inserted:
|
||||
await s.commit()
|
||||
logger.info(f"Inserted {inserted} new models from OpenRouter")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during models refresh",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
|
||||
+43
-4
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -33,6 +34,23 @@ _upstreams: list[BaseUpstreamProvider] = []
|
||||
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
||||
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
|
||||
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
||||
_manual_model_mappings: dict[str, str] = {} # Manual model_id mappings loaded from JSON
|
||||
|
||||
|
||||
def load_manual_model_mappings() -> None:
|
||||
"""Load manual model mappings from JSON file."""
|
||||
global _manual_model_mappings
|
||||
try:
|
||||
mappings_file = os.path.join(os.path.dirname(__file__), "model_mappings.json")
|
||||
if os.path.exists(mappings_file):
|
||||
with open(mappings_file, "r") as f:
|
||||
data = json.load(f)
|
||||
_manual_model_mappings = data.get("manual_model_mappings", {}).get("mappings", {})
|
||||
else:
|
||||
_manual_model_mappings = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load manual model mappings: {e}")
|
||||
_manual_model_mappings = {}
|
||||
|
||||
|
||||
async def initialize_upstreams() -> None:
|
||||
@@ -40,6 +58,7 @@ async def initialize_upstreams() -> None:
|
||||
global _upstreams
|
||||
_upstreams = await init_upstreams()
|
||||
logger.info(f"Initialized {len(_upstreams)} upstream providers")
|
||||
load_manual_model_mappings()
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
@@ -51,6 +70,7 @@ async def reinitialize_upstreams() -> None:
|
||||
"Re-initialized upstream providers from admin action",
|
||||
extra={"provider_count": len(_upstreams)},
|
||||
)
|
||||
load_manual_model_mappings()
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
@@ -64,13 +84,32 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
|
||||
def get_model_instance(model_id: str) -> Model | None:
|
||||
"""Get Model instance by ID from global cache."""
|
||||
return _model_instances.get(model_id)
|
||||
"""Get Model instance by ID from global cache, with manual mapping fallback."""
|
||||
model = _model_instances.get(model_id)
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
mapped_model_id = _manual_model_mappings.get(model_id.lower())
|
||||
if mapped_model_id:
|
||||
return _model_instances.get(mapped_model_id.lower())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
||||
"""Get UpstreamProvider for model ID from global cache."""
|
||||
return _provider_map.get(model_id)
|
||||
"""Get UpstreamProvider for model ID from global cache, with manual mapping fallback."""
|
||||
# First try direct lookup
|
||||
provider = _provider_map.get(model_id)
|
||||
if provider is not None:
|
||||
return provider
|
||||
|
||||
# Try manual mapping as fallback
|
||||
mapped_model_id = _manual_model_mappings.get(model_id)
|
||||
if mapped_model_id:
|
||||
logger.debug(f"Using manual mapping for provider: {model_id} -> {mapped_model_id}")
|
||||
return _provider_map.get(mapped_model_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_unique_models() -> list[Model]:
|
||||
|
||||
+71
-51
@@ -734,51 +734,54 @@ class BaseUpstreamProvider:
|
||||
await client.aclose()
|
||||
return mapped_error
|
||||
|
||||
if path.endswith("chat/completions"):
|
||||
client_wants_streaming = False
|
||||
if request_body:
|
||||
try:
|
||||
request_data = json.loads(request_body)
|
||||
client_wants_streaming = request_data.get("stream", False)
|
||||
logger.debug(
|
||||
"Chat completion request analysis",
|
||||
extra={
|
||||
"client_wants_streaming": client_wants_streaming,
|
||||
"model": request_data.get("model", "unknown"),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Failed to parse request body JSON for streaming detection"
|
||||
)
|
||||
# Handle endpoints that require cost calculation and payment adjustment
|
||||
if path.endswith("chat/completions") or path.endswith("embeddings"):
|
||||
if path.endswith("chat/completions"):
|
||||
client_wants_streaming = False
|
||||
if request_body:
|
||||
try:
|
||||
request_data = json.loads(request_body)
|
||||
client_wants_streaming = request_data.get("stream", False)
|
||||
logger.debug(
|
||||
"Chat completion request analysis",
|
||||
extra={
|
||||
"client_wants_streaming": client_wants_streaming,
|
||||
"model": request_data.get("model", "unknown"),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Failed to parse request body JSON for streaming detection"
|
||||
)
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
upstream_is_streaming = "text/event-stream" in content_type
|
||||
is_streaming = client_wants_streaming and upstream_is_streaming
|
||||
content_type = response.headers.get("content-type", "")
|
||||
upstream_is_streaming = "text/event-stream" in content_type
|
||||
is_streaming = client_wants_streaming and upstream_is_streaming
|
||||
|
||||
logger.debug(
|
||||
"Response type analysis",
|
||||
extra={
|
||||
"is_streaming": is_streaming,
|
||||
"client_wants_streaming": client_wants_streaming,
|
||||
"upstream_is_streaming": upstream_is_streaming,
|
||||
"content_type": content_type,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming and response.status_code == 200:
|
||||
result = await self.handle_streaming_chat_completion(
|
||||
response, key, max_cost_for_model
|
||||
logger.debug(
|
||||
"Response type analysis",
|
||||
extra={
|
||||
"is_streaming": is_streaming,
|
||||
"client_wants_streaming": client_wants_streaming,
|
||||
"upstream_is_streaming": upstream_is_streaming,
|
||||
"content_type": content_type,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
elif response.status_code == 200:
|
||||
if is_streaming and response.status_code == 200:
|
||||
result = await self.handle_streaming_chat_completion(
|
||||
response, key, max_cost_for_model
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
# Handle both non-streaming chat completions and embeddings
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return await self.handle_non_streaming_chat_completion(
|
||||
response, key, session, max_cost_for_model
|
||||
@@ -1519,9 +1522,9 @@ class BaseUpstreamProvider:
|
||||
error_response.headers["X-Cashu"] = refund_token
|
||||
return error_response
|
||||
|
||||
if path.endswith("chat/completions"):
|
||||
if path.endswith("chat/completions") or path.endswith("embeddings"):
|
||||
logger.debug(
|
||||
"Processing chat completion response",
|
||||
"Processing completion/embeddings response",
|
||||
extra={"path": path, "amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
@@ -1770,15 +1773,32 @@ class BaseUpstreamProvider:
|
||||
async def _fetch_openrouter_models(self) -> list[dict]:
|
||||
"""Fetch models from OpenRouter API."""
|
||||
url = "https://openrouter.ai/api/v1/models"
|
||||
embeddings_url = "https://openrouter.ai/api/v1/embeddings/models"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
models = response.json()
|
||||
return [
|
||||
model
|
||||
for model in models.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(url), client.get(embeddings_url), return_exceptions=True
|
||||
)
|
||||
|
||||
all_models = []
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
all_models.extend(process_models_response(models_response))
|
||||
all_models.extend(process_models_response(embeddings_response))
|
||||
|
||||
return all_models
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from provider's API."""
|
||||
|
||||
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
|
||||
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
|
||||
# The expiry time and refund address functionality is tested elsewhere
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_token_submissions(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test concurrent submissions of different tokens"""
|
||||
|
||||
# Generate multiple unique tokens with known amounts
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
expected_balances = {}
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
# Store expected balance by token hash
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
expected_balances[hashed_key] = amount * 1000 # msats
|
||||
|
||||
# Create concurrent requests
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
assert len(responses) == num_tokens
|
||||
api_keys = set()
|
||||
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
api_key = data["api_key"]
|
||||
api_keys.add(api_key)
|
||||
|
||||
# Verify balance matches the expected amount
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
assert data["balance"] == expected_balances[hashed_key]
|
||||
|
||||
# Should have created unique API keys
|
||||
assert len(api_keys) == num_tokens
|
||||
|
||||
# Verify all keys exist in database
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_with_cashu_token_directly(
|
||||
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_api_key_consistency_under_load(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test API key generation consistency under concurrent load"""
|
||||
|
||||
# Generate a single token
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
|
||||
# First request to create the API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
initial_response = await integration_client.get("/v1/wallet/info")
|
||||
assert initial_response.status_code == 200
|
||||
expected_api_key = initial_response.json()["api_key"]
|
||||
expected_balance = initial_response.json()["balance"]
|
||||
|
||||
# Try to use the same token concurrently multiple times
|
||||
# All should return the same API key since it's already created
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for _ in range(20) # 20 concurrent attempts
|
||||
]
|
||||
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed and return the same API key
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == expected_api_key
|
||||
assert data["balance"] == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_timestamp_accuracy(
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_access_same_api_key(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test concurrent access with the same API key"""
|
||||
|
||||
# Get the API key from authenticated client
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Create multiple concurrent requests
|
||||
requests = []
|
||||
for i in range(20):
|
||||
# Alternate between both endpoints
|
||||
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
|
||||
requests.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"url": endpoint,
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
)
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed with consistent data
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == api_key
|
||||
assert data["balance"] == initial_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_data_consistency(
|
||||
|
||||
@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test concurrent top-ups to the same API key"""
|
||||
|
||||
# Get API key
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Generate multiple unique tokens
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
total_amount = 0
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10 # Different amounts
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
total_amount += amount
|
||||
|
||||
# Create concurrent top-up requests
|
||||
requests = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "/v1/wallet/topup",
|
||||
"params": {"cashu_token": token},
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
assert "msats" in response.json()
|
||||
|
||||
# Verify final balance is correct
|
||||
final_response = await authenticated_client.get("/v1/wallet/")
|
||||
final_balance = final_response.json()["balance"]
|
||||
expected_balance = initial_balance + (total_amount * 1000)
|
||||
assert final_balance == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
|
||||
|
||||
+222
-2
@@ -10,16 +10,26 @@ import { SiteHeader } from '@/components/site-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { ModelMappingService } from '@/lib/api/services/modelMappings';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCircle, Users, Globe } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Trash2, Plus, Edit2, Save, X } from 'lucide-react';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
||||
const [modelMappings, setModelMappings] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
const [editingMapping, setEditingMapping] = useState<string | null>(null);
|
||||
const [newMapping, setNewMapping] = useState({ from: '', to: '' });
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
@@ -31,6 +41,23 @@ export default function ModelsPage() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: mappingsData,
|
||||
isLoading: isLoadingMappings,
|
||||
error: mappingsError,
|
||||
refetch: refetchMappings,
|
||||
} = useQuery({
|
||||
queryKey: ['model-mappings'],
|
||||
queryFn: () => ModelMappingService.getModelMappings(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mappingsData) {
|
||||
setModelMappings(mappingsData);
|
||||
}
|
||||
}, [mappingsData]);
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
@@ -67,6 +94,40 @@ export default function ModelsPage() {
|
||||
});
|
||||
}, [groupedModels, groupDataMap, groups]);
|
||||
|
||||
const handleAddMapping = async () => {
|
||||
if (!newMapping.from || !newMapping.to) return;
|
||||
|
||||
try {
|
||||
await ModelMappingService.createModelMapping({
|
||||
from: newMapping.from,
|
||||
to: newMapping.to,
|
||||
});
|
||||
setNewMapping({ from: '', to: '' });
|
||||
refetchMappings();
|
||||
} catch (error) {
|
||||
console.error('Failed to add mapping:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMapping = async (from: string) => {
|
||||
try {
|
||||
await ModelMappingService.deleteModelMapping(from);
|
||||
refetchMappings();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mapping:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMapping = async (from: string, to: string) => {
|
||||
try {
|
||||
await ModelMappingService.updateModelMapping(from, { to });
|
||||
setEditingMapping(null);
|
||||
refetchMappings();
|
||||
} catch (error) {
|
||||
console.error('Failed to update mapping:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
@@ -81,8 +142,9 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
|
||||
<TabsTrigger value='mappings'>Model Mappings</TabsTrigger>
|
||||
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
|
||||
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
|
||||
</TabsList>
|
||||
@@ -267,6 +329,164 @@ export default function ModelsPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='mappings' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Manage model ID mappings to redirect requests from one model
|
||||
to another. This is useful for maintaining compatibility with
|
||||
legacy model names or creating aliases.
|
||||
</div>
|
||||
|
||||
{isLoadingMappings ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
</div>
|
||||
) : mappingsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load model mappings. Please try refreshing the
|
||||
page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Plus className='h-5 w-5' />
|
||||
Add New Model Mapping
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<Input
|
||||
placeholder='From model ID'
|
||||
value={newMapping.from}
|
||||
onChange={(e) =>
|
||||
setNewMapping({
|
||||
...newMapping,
|
||||
from: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder='To model ID'
|
||||
value={newMapping.to}
|
||||
onChange={(e) =>
|
||||
setNewMapping({
|
||||
...newMapping,
|
||||
to: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddMapping}
|
||||
disabled={!newMapping.from || !newMapping.to}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add Mapping
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Model Mappings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.keys(modelMappings).length === 0 ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
No model mappings configured
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{Object.entries(modelMappings).map(([from, to]) => (
|
||||
<div
|
||||
key={from}
|
||||
className='flex items-center justify-between gap-4 rounded-lg border p-4'
|
||||
>
|
||||
<div className='grid flex-1 grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='text-muted-foreground text-sm font-medium'>
|
||||
From
|
||||
</label>
|
||||
<div className='font-mono text-sm'>
|
||||
{from}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className='text-muted-foreground text-sm font-medium'>
|
||||
To
|
||||
</label>
|
||||
{editingMapping === from ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
defaultValue={to}
|
||||
id={`edit-${from}`}
|
||||
className='text-sm'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const input =
|
||||
document.getElementById(
|
||||
`edit-${from}`
|
||||
) as HTMLInputElement;
|
||||
handleUpdateMapping(
|
||||
from,
|
||||
input.value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Save className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setEditingMapping(null)
|
||||
}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='font-mono text-sm'>
|
||||
{to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingMapping !== from && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => setEditingMapping(from)}
|
||||
>
|
||||
<Edit2 className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDeleteMapping(from)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Test model credentials and connectivity with basic chat
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface SettingsData {
|
||||
name?: string;
|
||||
@@ -28,9 +29,32 @@ interface SettingsData {
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
cashu_mints?: string[];
|
||||
relays?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HANDLED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'http_url',
|
||||
'onion_url',
|
||||
'npub',
|
||||
'nsec',
|
||||
'cashu_mints',
|
||||
'relays',
|
||||
'admin_password',
|
||||
'id',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
const IGNORED_KEYS = [
|
||||
'upstream_base_url',
|
||||
'upstream_api_key',
|
||||
'upstream_provider_fee',
|
||||
'exchange_fee',
|
||||
'models_path',
|
||||
];
|
||||
|
||||
interface PasswordData {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
@@ -44,6 +68,7 @@ export function AdminSettings() {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showSecrets, setShowSecrets] = useState(false);
|
||||
const [newMint, setNewMint] = useState('');
|
||||
const [newRelay, setNewRelay] = useState('');
|
||||
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
@@ -129,7 +154,7 @@ export function AdminSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
const handleInputChange = (field: string, value: unknown) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
@@ -153,6 +178,23 @@ export function AdminSettings() {
|
||||
}));
|
||||
};
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.trim()) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
relays: [...(prev.relays || []), newRelay.trim()],
|
||||
}));
|
||||
setNewRelay('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRelay = (index: number) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
relays: prev.relays?.filter((_, i) => i !== index) || [],
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSecretField = (
|
||||
field: string,
|
||||
label: string,
|
||||
@@ -162,7 +204,7 @@ export function AdminSettings() {
|
||||
const displayValue = showSecrets ? value : value ? '••••••••' : '';
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div key={field} className='space-y-2'>
|
||||
<Label htmlFor={field}>{label}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
@@ -190,6 +232,89 @@ export function AdminSettings() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderDynamicField = (key: string, value: unknown) => {
|
||||
const label = key
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className='flex items-center justify-between space-y-0 py-4'
|
||||
>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Switch
|
||||
id={key}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => handleInputChange(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
type='number'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
handleInputChange(key, val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const strValue = value.join(', ');
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Textarea
|
||||
id={key}
|
||||
value={strValue}
|
||||
onChange={(e) => {
|
||||
const arr = e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
handleInputChange(key, arr);
|
||||
}}
|
||||
placeholder='Comma separated values'
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSecret =
|
||||
key.includes('key') ||
|
||||
key.includes('password') ||
|
||||
key.includes('secret') ||
|
||||
key.includes('nsec');
|
||||
|
||||
if (isSecret) {
|
||||
return renderSecretField(key, label);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
@@ -337,6 +462,73 @@ export function AdminSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Relays */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nostr Relays</CardTitle>
|
||||
<CardDescription>
|
||||
Configure Nostr relays for communication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='newRelay'>Add Relay URL</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='newRelay'
|
||||
value={newRelay}
|
||||
onChange={(e) => setNewRelay(e.target.value)}
|
||||
placeholder='wss://relay.example.com'
|
||||
/>
|
||||
<Button onClick={addRelay} disabled={!newRelay.trim()}>
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.relays && settings.relays.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Configured Relays</Label>
|
||||
<div className='space-y-2'>
|
||||
{settings.relays.map((relay, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-2 rounded border p-2'
|
||||
>
|
||||
<span className='flex-1 text-sm'>{relay}</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => removeRelay(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Other Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure additional node settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{Object.keys(settings)
|
||||
.filter(
|
||||
(key) =>
|
||||
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
|
||||
)
|
||||
.map((key) => renderDynamicField(key, settings[key]))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='mt-6'>
|
||||
<CardFooter className='flex justify-between'>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { apiClient } from '../client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ModelMappingSchema = z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
export const CreateModelMappingSchema = z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateModelMappingSchema = z.object({
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
export const ModelMappingsResponseSchema = z.record(z.string());
|
||||
|
||||
export const ReloadMappingsResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
mappings: z.record(z.string()),
|
||||
});
|
||||
|
||||
export type ModelMapping = z.infer<typeof ModelMappingSchema>;
|
||||
export type CreateModelMapping = z.infer<typeof CreateModelMappingSchema>;
|
||||
export type UpdateModelMapping = z.infer<typeof UpdateModelMappingSchema>;
|
||||
export type ModelMappingsResponse = z.infer<typeof ModelMappingsResponseSchema>;
|
||||
export type ReloadMappingsResponse = z.infer<
|
||||
typeof ReloadMappingsResponseSchema
|
||||
>;
|
||||
|
||||
export class ModelMappingService {
|
||||
static async getModelMappings(): Promise<ModelMappingsResponse> {
|
||||
return await apiClient.get<ModelMappingsResponse>(
|
||||
'/admin/api/model-mappings'
|
||||
);
|
||||
}
|
||||
|
||||
static async createModelMapping(
|
||||
data: CreateModelMapping
|
||||
): Promise<ModelMappingsResponse> {
|
||||
return await apiClient.post<ModelMappingsResponse>(
|
||||
'/admin/api/model-mappings',
|
||||
{
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static async updateModelMapping(
|
||||
fromModel: string,
|
||||
data: UpdateModelMapping
|
||||
): Promise<ModelMappingsResponse> {
|
||||
return await apiClient.put<ModelMappingsResponse>(
|
||||
`/admin/api/model-mappings/${encodeURIComponent(fromModel)}`,
|
||||
{
|
||||
to: data.to,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static async deleteModelMapping(
|
||||
fromModel: string
|
||||
): Promise<ModelMappingsResponse> {
|
||||
return await apiClient.delete<ModelMappingsResponse>(
|
||||
`/admin/api/model-mappings/${encodeURIComponent(fromModel)}`
|
||||
);
|
||||
}
|
||||
|
||||
static async reloadModelMappings(): Promise<ReloadMappingsResponse> {
|
||||
return await apiClient.post<ReloadMappingsResponse>(
|
||||
'/admin/api/model-mappings/reload',
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user