Compare commits

..
Author SHA1 Message Date
Shroominic 6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
37 changed files with 557 additions and 2049 deletions
+2 -6
View File
@@ -62,18 +62,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
node-version: '18'
cache: 'npm'
cache-dependency-path: ui/package-lock.json
- name: Install UI dependencies
working-directory: ./ui
run: npm ci
- name: Run UI format check
working-directory: ./ui
run: npm run format-check
- name: Run UI linting
working-directory: ./ui
run: npm run lint
-1
View File
@@ -20,7 +20,6 @@ dependencies = [
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]
+13 -6
View File
@@ -150,6 +150,18 @@ def should_prefer_model(
# Prefer lower adjusted cost
should_replace = candidate_adjusted < current_adjusted
# Log provider changes when candidate wins
if should_replace:
candidate_provider_name = getattr(
candidate_provider, "provider_type", "unknown"
)
current_provider_name = getattr(current_provider, "provider_type", "unknown")
logger.debug(
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
f"(cost: ${current_adjusted:.6f})"
)
return should_replace
@@ -242,12 +254,7 @@ def create_model_mappings(
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_model = model_to_use.copy(update={"id": base_id})
unique_models[base_id] = unique_model
# Get all aliases for this model
+3
View File
@@ -126,6 +126,8 @@ def run_migrations() -> None:
import pathlib
try:
logger.info("Starting database migrations")
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
@@ -142,6 +144,7 @@ def run_migrations() -> None:
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
logger.info("Running migrations to latest revision")
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
-10
View File
@@ -338,11 +338,6 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
@@ -365,11 +360,6 @@ def setup_logging() -> None:
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {
"level": log_level,
+3 -3
View File
@@ -54,6 +54,9 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
@@ -101,9 +104,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",
+5 -6
View File
@@ -22,7 +22,6 @@ models_router = APIRouter()
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"openrouter/bodybuilder",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
@@ -41,10 +40,10 @@ class Architecture(BaseModel):
class Pricing(BaseModel):
prompt: float
completion: float
request: float = 0.0
image: float = 0.0
web_search: float = 0.0
internal_reasoning: float = 0.0
request: float
image: float
web_search: float
internal_reasoning: float
max_prompt_cost: float = 0.0 # in sats not msats
max_completion_cost: float = 0.0 # in sats not msats
max_cost: float = 0.0 # in sats not msats
@@ -68,7 +67,7 @@ class Model(BaseModel):
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
enabled: bool = True
upstream_provider_id: int | str | None = None
upstream_provider_id: int | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
+4
View File
@@ -110,6 +110,10 @@ async def _update_prices() -> None:
return
BTC_USD_PRICE = btc_price
SATS_USD_PRICE = btc_price / 100_000_000
logger.info(
"Updated BTC/USD price",
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
)
def btc_usd_price() -> float:
-2
View File
@@ -2,7 +2,6 @@ from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .fireworks import FireworksUpstreamProvider
from .gemini import GeminiUpstreamProvider
from .generic import GenericUpstreamProvider
from .groq import GroqUpstreamProvider
from .ollama import OllamaUpstreamProvider
@@ -16,7 +15,6 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
AnthropicUpstreamProvider,
AzureUpstreamProvider,
FireworksUpstreamProvider,
GeminiUpstreamProvider,
GenericUpstreamProvider,
GroqUpstreamProvider,
OllamaUpstreamProvider,
+18 -6
View File
@@ -1726,6 +1726,7 @@ class BaseUpstreamProvider:
Returns:
List of Model objects with pricing
"""
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
try:
or_models, provider_models_response = await asyncio.gather(
@@ -1752,9 +1753,18 @@ class BaseUpstreamProvider:
else:
not_found_models.append(model_id)
logger.info(
"Fetched models for provider",
extra={
"provider": self.provider_type or self.base_url,
"found_count": len(found_models),
"not_found_count": len(not_found_models),
},
)
if not_found_models:
logger.debug(
f"({len(not_found_models)}/{len(provider_model_ids)}) unmatched models for {self.provider_type or self.base_url}",
"Models not found in OpenRouter",
extra={"not_found_models": not_found_models},
)
@@ -1822,7 +1832,10 @@ class BaseUpstreamProvider:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.provider_type or self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
@@ -1891,15 +1904,14 @@ class BaseUpstreamProvider:
f"Provider {self.provider_type} does not support top-up"
)
async def get_balance(self) -> float | None:
async def get_balance(self) -> dict[str, object]:
"""Get the current account balance from the provider.
Returns:
Float representing the balance amount, or None if not supported/available.
Typically in USD or the provider's credit unit.
Dict with balance information
Raises:
NotImplementedError: If provider does not support balance checking (default behavior)
NotImplementedError: If provider does not support balance checking
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support balance checking"
-3
View File
@@ -1,3 +0,0 @@
from .gemini import GeminiClient
__all__ = ["GeminiClient"]
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator
class BaseAPIClient(ABC):
"""Base class for AI provider API clients."""
def __init__(self, api_key: str, base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url
@abstractmethod
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Generate content non-streaming."""
pass
@abstractmethod
def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
pass
@abstractmethod
async def list_models(self) -> list[dict[str, Any]]:
"""List available models."""
pass
-88
View File
@@ -1,88 +0,0 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from openai import AsyncOpenAI
from .base import BaseAPIClient
class GeminiClient(BaseAPIClient):
"""Gemini API client using OpenAI compatibility layer."""
def __init__(self, api_key: str, base_url: str | None = None):
super().__init__(api_key, base_url)
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url
or "https://generativelanguage.googleapis.com/v1beta/openai/",
)
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
from openai import NOT_GIVEN
response = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
return response.model_dump()
async def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
from openai import NOT_GIVEN
usage_callback = kwargs.get("usage_callback")
completion_callback = kwargs.get("completion_callback")
stream = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
stream=True,
stream_options={"include_usage": True},
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
final_usage = None
async for chunk in stream:
chunk_data = chunk.model_dump()
if chunk.usage:
final_usage = chunk.usage.model_dump()
if usage_callback:
usage_callback(final_usage)
yield chunk_data
if completion_callback:
await completion_callback(model, final_usage)
async def list_models(self) -> list[dict[str, Any]]:
"""List available Gemini models."""
try:
response = await self.client.models.list()
return [model.model_dump() for model in response.data]
except Exception as e:
from ...core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to list Gemini models: {e}")
return []
-283
View File
@@ -1,283 +0,0 @@
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
from .clients.gemini import GeminiClient
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
logger = get_logger(__name__)
class GeminiUpstreamProvider(BaseUpstreamProvider):
provider_type = "gemini"
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
platform_url = "https://aistudio.google.com/app/apikey"
def __init__(
self,
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
api_key: str = "",
provider_fee: float = 1.01,
):
super().__init__(
api_key=api_key,
provider_fee=provider_fee,
base_url=base_url,
)
self._client: GeminiClient | None = None
@property
def client(self) -> GeminiClient:
"""Get or create the Gemini API client."""
if self._client is None:
self._client = GeminiClient(api_key=self.api_key)
return self._client
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "GeminiUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Google Gemini",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("gemini/")
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
# Remove provider prefix from model ID for Gemini API
if "/" in model_obj.id:
model_obj.id = model_obj.id.split("/", 1)[1]
if not path.startswith("chat/completions"):
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
if not request_body:
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
openai_data = json.loads(request_body)
messages = openai_data.get("messages", [])
temperature = openai_data.get("temperature")
max_tokens = openai_data.get("max_tokens")
top_p = openai_data.get("top_p")
is_streaming = openai_data.get("stream", False)
logger.info(
"Processing Gemini request with client abstraction",
extra={
"model": model_obj.id,
"is_streaming": is_streaming,
"message_count": len(messages),
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming:
final_usage_data: dict | None = None
def usage_callback(usage_data: dict[str, Any]) -> None:
"""Callback to capture usage data during streaming"""
nonlocal final_usage_data
final_usage_data = usage_data
async def completion_callback(
model: str, usage_data: dict[str, Any] | None
) -> None:
"""Callback to handle payment when streaming completes"""
nonlocal final_usage_data
if usage_data:
final_usage_data = usage_data
payment_data = {
"model": model,
"usage": final_usage_data,
}
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
payment_data,
new_session,
max_cost_for_model,
)
logger.info(
"Gemini streaming payment finalized",
extra={
"cost_data": cost_data,
"usage_data": final_usage_data,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
response_generator = self.client.generate_content_stream(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
usage_callback=usage_callback,
completion_callback=completion_callback,
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
else:
openai_format_response = await self.client.generate_content(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
)
from ..auth import adjust_payment_for_tokens
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
openai_format_response["cost"] = cost_data
logger.info(
"Gemini non-streaming payment completed",
extra={
"cost_data": cost_data,
"model": model_obj.id,
"key_hash": key.hashed_key[:8] + "...",
},
)
return Response(
content=json.dumps(openai_format_response),
media_type="application/json",
headers={"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error(
"Error in Gemini forward_request",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
async def _fetch_provider_models(self) -> dict:
"""Fetch models from Gemini API."""
try:
models_data = await self.client.list_models()
for model in models_data:
if "id" in model and model["id"].startswith("models/"):
model["id"] = model["id"].removeprefix("models/")
return {"data": models_data}
except Exception as e:
logger.error(
f"Failed to fetch models from Gemini API: {e}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"base_url": self.base_url,
},
)
return {"data": []}
+1 -1
View File
@@ -193,7 +193,7 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
logger.info(
f"Initialized {provider_row.provider_type} provider",
extra={
"base_url": provider_row.base_url,
-26
View File
@@ -1,7 +1,5 @@
from typing import TYPE_CHECKING
import httpx
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
@@ -44,33 +42,9 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_show_balance": True,
}
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
Returns:
Float representing the balance amount (in credits/USD), or None if unavailable.
"""
url = f"{self.base_url}/credits"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
credits_data = data.get("data", {})
total_credits = float(credits_data.get("total_credits", 0.0))
total_usage = float(credits_data.get("total_usage", 0.0))
return total_credits - total_usage
except Exception:
return None
+22 -14
View File
@@ -36,7 +36,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
provider_type = "ppqai"
default_base_url = "https://api.ppq.ai"
platform_url = "https://ppq.ai/api-docs"
IGNORED_MODEL_IDS: list[str] = ["auto"]
def __init__(self, api_key: str, provider_fee: float = 1.0):
super().__init__(
@@ -102,6 +101,11 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Fetching models from PPQ.AI",
extra={"url": url, "has_api_key": bool(self.api_key)},
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
@@ -109,6 +113,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
data = response.json()
models_data = data.get("data", [])
logger.info(
"Fetched models from PPQ.AI",
extra={"model_count": len(models_data)},
)
or_models = [
Model(**model) # type: ignore
@@ -119,16 +127,11 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
for model_data in models_data:
try:
ppqai_model = PPQAIModel.parse_obj(model_data)
if ppqai_model.id in self.IGNORED_MODEL_IDS:
continue
or_model = next(
(
model
for model in or_models
if (model.id == ppqai_model.id)
or (model.id.split("/")[-1] == ppqai_model.id)
or (model.id == ppqai_model.id.split("/")[-1])
if model.id == ppqai_model.id
),
None,
)
@@ -189,6 +192,15 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
except httpx.HTTPStatusError as e:
logger.error(
"HTTP error fetching models from PPQ.AI",
extra={
"status_code": e.response.status_code,
"error": str(e),
},
)
return []
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",
@@ -359,20 +371,16 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return topup_data
async def get_balance(self) -> float | None:
async def get_balance(self) -> dict[str, object]:
"""Get the current account balance from PPQ.AI.
Returns:
Float representing the balance amount (in USD), or None if unavailable.
Dict with balance information
Raises:
httpx.HTTPStatusError: If the API request fails
"""
data = await self.check_balance()
balance = data.get("balance")
if isinstance(balance, (int, float)):
return float(balance)
return None
return await self.check_balance()
async def check_balance(self) -> dict[str, object]:
"""Check the account balance for this PPQ.AI account.
@@ -142,6 +142,46 @@ class TestPerformanceBaseline:
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.asyncio
async def test_database_query_performance(
self, integration_session: Any, db_snapshot: Any
) -> None:
"""Test database operation performance"""
from sqlmodel import select
from routstr.core.db import ApiKey
# Create test data
for i in range(100):
key = ApiKey(
hashed_key=f"test_key_{i}",
balance=1000000,
total_spent=0,
total_requests=0,
)
integration_session.add(key)
await integration_session.commit()
# Test query performance
query_times = []
for _ in range(100):
start = time.time()
result = await integration_session.execute(
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
)
_ = result.all()
duration = (time.time() - start) * 1000
query_times.append(duration)
# All queries should complete < 100ms
assert max(query_times) < 100, (
f"Max query time {max(query_times)}ms exceeds 100ms limit"
)
print("\nDatabase query performance:")
print(f" Mean: {statistics.mean(query_times):.2f}ms")
print(f" Max: {max(query_times):.2f}ms")
@pytest.mark.integration
@pytest.mark.slow
+3 -1
View File
@@ -29,7 +29,9 @@ export default function BalancesPage() {
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<h1 className='text-3xl font-bold tracking-tight'>
Balances
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
+2 -2
View File
@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<div className='flex min-h-screen items-center justify-center bg-background px-4 py-12 text-foreground'>
<Card className='w-full max-w-md border border-border/60 bg-card/90 shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
+3 -10
View File
@@ -97,7 +97,7 @@ export function LogDetailsDialog({
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
<pre className='font-mono text-sm break-all whitespace-pre'>
<pre className='font-mono text-sm whitespace-pre break-all'>
{log.message}
</pre>
</div>
@@ -116,12 +116,7 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(
String(log[field as keyof LogEntry] || ''),
field
)
}
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
className='h-6 flex-shrink-0 px-2'
>
{copiedField === field ? (
@@ -180,9 +175,7 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(JSON.stringify(log, null, 2), 'json')
}
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
className='h-6 px-2'
>
{copiedField === 'json' ? (
+18 -30
View File
@@ -204,59 +204,47 @@ export default function ModelsPage() {
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
{totalModels === 0 ? (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<div className='text-sm space-y-1'>
<p className='font-medium'>Common issues:</p>
<ul className='list-disc list-inside space-y-1 ml-2'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
<strong>API credentials:</strong> Check if the API key is correct and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
<strong>Base URL:</strong> Verify the base URL is correct for your provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
<strong>Network access:</strong> Ensure the server can reach the provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
<strong>Provider status:</strong> The upstream provider might be temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
<p className='mt-2 text-xs text-muted-foreground'>
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
) : (
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
+23 -49
View File
@@ -53,7 +53,7 @@ export default function DashboardPage() {
window.removeEventListener('storage', syncAuthState);
};
}, []);
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
@@ -131,13 +131,8 @@ export default function DashboardPage() {
<SiteHeader />
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
Dashboard
</h1>
<DashboardBalanceSummary
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<h1 className='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
</div>
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
@@ -145,9 +140,8 @@ export default function DashboardPage() {
<h2 className='text-xl font-semibold tracking-tight'>
Usage Analytics
</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor requests, errors, and revenue over the last {timeRange}{' '}
hours
<p className='text-muted-foreground text-sm mt-1'>
Monitor requests, errors, and revenue over the last {timeRange} hours
</p>
</div>
<div className='flex items-center gap-4'>
@@ -182,31 +176,27 @@ export default function DashboardPage() {
<div className='space-y-6'>
{summaryLoading ? (
<div className='py-8 text-center'>Loading summary...</div>
<div className='text-center py-8'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='col-span-2 py-8 text-center'>
<div className='text-center py-8 col-span-2'>
Loading metrics...
</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<div className='col-span-full'>
<div className="col-span-full">
<UsageMetricsChart
data={
metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<Record<string, unknown> & { timestamp: string }>}
title='Revenue Over Time (sats)'
dataKeys={[
{
@@ -228,11 +218,7 @@ export default function DashboardPage() {
/>
</div>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Request Volume'
dataKeys={[
{
@@ -253,11 +239,7 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Error Tracking'
dataKeys={[
{
@@ -278,11 +260,7 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Payment Activity'
dataKeys={[
{
@@ -292,7 +270,7 @@ export default function DashboardPage() {
},
]}
/>
<div className='space-y-6'>
<div className="space-y-6">
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
@@ -328,9 +306,7 @@ export default function DashboardPage() {
key={type}
className='flex items-center justify-between'
>
<span className='text-sm font-medium'>
{type}
</span>
<span className='text-sm font-medium'>{type}</span>
<span className='text-muted-foreground text-sm'>
{count}
</span>
@@ -359,18 +335,16 @@ export default function DashboardPage() {
</div>
{revenueByModelLoading ? (
<div className='py-8 text-center'>
Loading revenue by model...
</div>
<div className='text-center py-8'>Loading revenue by model...</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='py-8 text-center'>Loading errors...</div>
<div className='text-center py-8'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
+43 -222
View File
@@ -18,9 +18,7 @@ import {
UpstreamProvider,
CreateUpstreamProvider,
UpdateUpstreamProvider,
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -56,13 +54,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
function ProviderBalance({
providerId,
platformUrl,
}: {
providerId: number;
platformUrl?: string | null;
}) {
function ProviderBalance({ providerId }: { providerId: number }) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
@@ -76,11 +68,7 @@ function ProviderBalance({
);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
const { data: balanceData, isLoading, error } = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
@@ -112,10 +100,7 @@ function ProviderBalance({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
const result = await AdminService.initiateProviderTopup(providerId, amount);
console.log('API returned:', result);
return result;
} catch (err) {
@@ -127,8 +112,11 @@ function ProviderBalance({
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
if (
data?.topup_data?.payment_request &&
data?.topup_data?.invoice_id
) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
@@ -148,10 +136,6 @@ function ProviderBalance({
});
const handleTopup = () => {
// If no dialog open logic (which depends on API implementation),
// we check if we should redirect or open dialog based on available info
// But since this function is called inside the dialog, we might want to change
// how the "Top Up" button behaves instead.
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
@@ -167,35 +151,6 @@ function ProviderBalance({
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
// we should redirect.
// However, the prompt asks to redirect if topup is not implemented.
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
@@ -215,18 +170,12 @@ function ProviderBalance({
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
// Legacy support for object response
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
if (typeof balance.balance === 'number') {
displayValue = `$${balance.balance.toFixed(2)}`;
} else if (typeof balance.balance === 'string') {
displayValue = balance.balance;
} else if (balance.amount !== undefined) {
displayValue = `$${Number(balance.amount).toFixed(2)}`;
}
return (
@@ -234,7 +183,7 @@ function ProviderBalance({
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onClick={() => setIsTopupDialogOpen(true)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full font-mono sm:w-auto'
@@ -274,7 +223,9 @@ function ProviderBalance({
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>Top-up successful!</p>
<p className='text-center font-semibold'>
Top-up successful!
</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
@@ -387,17 +338,6 @@ export default function ProvidersPage() {
);
const [viewingModels, setViewingModels] = useState<number | null>(null);
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -488,9 +428,7 @@ export default function ProvidersPage() {
...formData,
api_key: String(response.account_data.api_key),
});
toast.success(
'Account created successfully! API key has been filled in.'
);
toast.success('Account created successfully! API key has been filled in.');
} else {
toast.success('Account created, but no API key returned.');
}
@@ -595,33 +533,6 @@ export default function ProvidersPage() {
}
};
const handleAddModel = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
const handleEditModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: model,
});
};
const handleOverrideModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: model,
});
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -786,7 +697,8 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
1.01 means +1% e.g. currency exchange, card
fees, etc.
</p>
</div>
</div>
@@ -863,12 +775,7 @@ export default function ProvidersPage() {
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
<ProviderBalance providerId={provider.id} />
)}
<Button
variant='outline'
@@ -932,21 +839,9 @@ export default function ProvidersPage() {
// No provided models - show custom models directly without tabs
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models
to use this provider.
</div>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
<div className='text-muted-foreground py-4 text-center text-sm'>
No models configured. Add custom models to
use this provider.
</div>
) : (
<div className='space-y-2'>
@@ -977,24 +872,9 @@ export default function ProvidersPage() {
{model.description || model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
))}
@@ -1045,24 +925,12 @@ export default function ProvidersPage() {
value='custom'
className='mt-4 space-y-2'
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
</div>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
@@ -1098,24 +966,9 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
)
@@ -1150,25 +1003,9 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
)
@@ -1283,7 +1120,7 @@ export default function ProvidersPage() {
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
<Switch
id='edit_enabled'
checked={formData.enabled}
onCheckedChange={(checked) =>
@@ -1315,7 +1152,8 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
1.01 means +1% e.g. currency exchange, card
fees, etc.
</p>
</div>
</div>
@@ -1335,23 +1173,6 @@ export default function ProvidersPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],
});
}}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}
</SidebarInset>
</SidebarProvider>
);
-956
View File
@@ -1,956 +0,0 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
const listFromString = (value: string): string[] =>
value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const listToString = (value: string[] | undefined | null): string =>
value && value.length > 0 ? value.join(', ') : '';
const FormSchema = z.object({
id: z.string().min(1, 'Model ID is required'),
name: z.string().min(1, 'Name is required'),
description: z.string().default(''),
context_length: z.coerce.number().min(0).default(8192),
modality: z.string().min(1, 'Modality is required'),
input_modalities_raw: z.string().default(''),
output_modalities_raw: z.string().default(''),
tokenizer: z.string().default(''),
instruct_type: z.string().default(''),
canonical_slug: z.string().default(''),
alias_ids_raw: z.string().default(''),
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
request_cost: z.coerce.number().min(0).default(0),
image_cost: z.coerce.number().min(0).default(0),
web_search_cost: z.coerce.number().min(0).default(0),
internal_reasoning_cost: z.coerce.number().min(0).default(0),
max_prompt_cost: z.coerce.number().min(0).default(0),
max_completion_cost: z.coerce.number().min(0).default(0),
max_cost: z.coerce.number().min(0).default(0),
per_request_limits_raw: z.string().default(''),
top_provider_context_length: z.coerce.number().min(0).optional(),
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
top_provider_is_moderated: z.boolean().default(false),
enabled: z.boolean().default(true),
});
type FormData = z.output<typeof FormSchema>;
export interface AddProviderModelDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: AdminModel | null;
mode?: 'create' | 'edit' | 'override';
}
export function AddProviderModelDialog({
providerId,
isOpen,
onClose,
onSuccess,
initialData,
mode = 'create',
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
defaultValues: {
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
},
});
const isOverride = useMemo(() => mode === 'override', [mode]);
const isEdit = useMemo(() => mode === 'edit', [mode]);
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
queryKey: ['openrouter-presets'],
queryFn: () => AdminService.getOpenRouterPresets(),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (initialData) {
const architecture = initialData.architecture as Record<string, unknown>;
const pricing = initialData.pricing as Record<string, number>;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
name: initialData.name,
description: initialData.description,
context_length: initialData.context_length,
modality:
typeof architecture?.modality === 'string'
? architecture.modality
: 'text',
input_modalities_raw: listToString(
(architecture?.input_modalities as string[]) || []
),
output_modalities_raw: listToString(
(architecture?.output_modalities as string[]) || []
),
tokenizer:
typeof architecture?.tokenizer === 'string'
? architecture.tokenizer
: '',
instruct_type:
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: '',
canonical_slug: initialData.canonical_slug || '',
alias_ids_raw: listToString(initialData.alias_ids),
upstream_provider_id:
typeof initialData.upstream_provider_id === 'string'
? initialData.upstream_provider_id
: initialData.upstream_provider_id?.toString() || '',
input_cost: pricing?.prompt ?? 0,
output_cost: pricing?.completion ?? 0,
request_cost: pricing?.request ?? 0,
image_cost: pricing?.image ?? 0,
web_search_cost: pricing?.web_search ?? 0,
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
max_completion_cost: pricing?.max_completion_cost ?? 0,
max_cost: pricing?.max_cost ?? 0,
per_request_limits_raw: initialData.per_request_limits
? JSON.stringify(initialData.per_request_limits, null, 2)
: '',
top_provider_context_length:
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined,
top_provider_max_completion_tokens:
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined,
top_provider_is_moderated:
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false,
enabled: initialData.enabled,
});
} else {
form.reset({
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
});
}
}, [initialData, form, isOpen]);
const applyModelToForm = (model: AdminModel) => {
setSelectedPresetLabel(`${model.id}${model.name}`);
const architecture = model.architecture as Record<string, unknown>;
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
listToString((architecture?.input_modalities as string[]) || [])
);
form.setValue(
'output_modalities_raw',
listToString((architecture?.output_modalities as string[]) || [])
);
form.setValue(
'tokenizer',
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
);
form.setValue(
'instruct_type',
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: ''
);
form.setValue('canonical_slug', model.canonical_slug || '');
form.setValue('alias_ids_raw', listToString(model.alias_ids));
form.setValue(
'upstream_provider_id',
typeof model.upstream_provider_id === 'string'
? model.upstream_provider_id
: model.upstream_provider_id?.toString() || ''
);
form.setValue('input_cost', pricing?.prompt ?? 0);
form.setValue('output_cost', pricing?.completion ?? 0);
form.setValue('request_cost', pricing?.request ?? 0);
form.setValue('image_cost', pricing?.image ?? 0);
form.setValue('web_search_cost', pricing?.web_search ?? 0);
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
form.setValue('max_cost', pricing?.max_cost ?? 0);
form.setValue(
'per_request_limits_raw',
model.per_request_limits
? JSON.stringify(model.per_request_limits, null, 2)
: ''
);
form.setValue(
'top_provider_context_length',
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined
);
form.setValue(
'top_provider_max_completion_tokens',
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined
);
form.setValue(
'top_provider_is_moderated',
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false
);
form.setValue('enabled', model.enabled);
};
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
toast.error('Per-request limits must be valid JSON');
setIsSubmitting(false);
return;
}
}
const adminModel: AdminModel = {
id: data.id,
name: data.name,
description: data.description || '',
created: Math.floor(Date.now() / 1000),
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
tokenizer: data.tokenizer || '',
instruct_type: data.instruct_type?.trim() || null,
},
pricing: {
prompt: data.input_cost,
completion: data.output_cost,
request: data.request_cost,
image: data.image_cost,
web_search: data.web_search_cost,
internal_reasoning: data.internal_reasoning_cost,
max_prompt_cost: data.max_prompt_cost,
max_completion_cost: data.max_completion_cost,
max_cost: data.max_cost,
},
per_request_limits: perRequestLimits,
top_provider:
data.top_provider_context_length ||
data.top_provider_max_completion_tokens ||
data.top_provider_is_moderated
? {
context_length: data.top_provider_context_length ?? null,
max_completion_tokens:
data.top_provider_max_completion_tokens ?? null,
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id: data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
};
if (isEdit) {
await AdminService.updateProviderModel(providerId, data.id, adminModel);
toast.success('Model updated successfully');
} else {
await AdminService.createProviderModel(providerId, adminModel);
toast.success(
isOverride ? 'Model override created' : 'Model created successfully'
);
}
onSuccess();
onClose();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown error saving model';
toast.error(`Failed to save model: ${message}`);
} finally {
setIsSubmitting(false);
}
};
const title = isEdit
? 'Edit Model'
: isOverride
? 'Override Model'
: 'Add Custom Model';
const description = isOverride
? 'Create a custom override for this upstream model'
: 'Add a new model configuration for this provider';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={true}>
<CommandInput placeholder='Search presets...' />
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
{isLoadingPresets ? (
<CommandEmpty>Loading presets...</CommandEmpty>
) : presets.length === 0 ? (
<CommandEmpty>No presets available.</CommandEmpty>
) : (
<CommandGroup heading='Presets'>
{presets.map((preset) => (
<CommandItem
key={preset.id}
value={preset.id}
onSelect={() => {
applyModelToForm(preset);
setIsPresetOpen(false);
}}
>
<div className='flex flex-col text-sm'>
<span className='font-medium'>{preset.id}</span>
<span className='text-muted-foreground text-xs'>
{preset.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='id'
render={({ field }) => (
<FormItem>
<FormLabel>Model ID *</FormLabel>
<FormControl>
<Input
placeholder='e.g., gpt-5.1'
{...field}
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT-5.1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='modality'
render={({ field }) => (
<FormItem>
<FormLabel>Modality *</FormLabel>
<FormControl>
<Input
placeholder='text, text+image->text, image->text, etc.'
{...field}
/>
</FormControl>
<FormDescription>
Composite modality label (e.g., text+image-&gt;text)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input type='number' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='tokenizer'
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT, tiktoken' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='instruct_type'
render={({ field }) => (
<FormItem>
<FormLabel>Instruct Type</FormLabel>
<FormControl>
<Input placeholder='e.g., chat, completion' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='canonical_slug'
render={({ field }) => (
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='alias_ids_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Alias IDs</FormLabel>
<FormControl>
<Input placeholder='alias-1, alias-2' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='upstream_provider_id'
render={({ field }) => (
<FormItem>
<FormLabel>Upstream Provider ID</FormLabel>
<FormControl>
<Input placeholder='gemini' {...field} />
</FormControl>
<FormDescription>
Defaults to current provider if left blank
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='per_request_limits_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Per-request Limits (JSON)</FormLabel>
<FormControl>
<Textarea
placeholder='{"requests_per_min": 60}'
{...field}
rows={4}
/>
</FormControl>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>
Pricing (USD per 1M tokens)
</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='request_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Per Request Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='image_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Image Cost (per image)</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='web_search_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Web Search Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='internal_reasoning_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Internal Reasoning Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
<FormField
control={form.control}
name='top_provider_context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_max_completion_tokens'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Tokens</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_is_moderated'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Enabled</FormLabel>
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+70 -151
View File
@@ -2,14 +2,15 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import {
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type Model,
type ManualModel,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { AddModelForm } from '@/components/AddModelForm';
import { EditModelForm } from '@/components/EditModelForm';
import { EditGroupForm } from '@/components/EditGroupForm';
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
import { formatCost } from '@/lib/services/costValidation';
@@ -79,23 +80,14 @@ export function ModelSelector({
}: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [, setHoveredModelId] = useState<string | null>(null);
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const [editingGroup, setEditingGroup] = useState<{
provider: string;
models: Model[];
groupData: ModelGroup;
} | null>(null);
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
// Bulk selection state
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
@@ -500,104 +492,44 @@ export function ModelSelector({
return hasIndividualApiKey || hasIndividualUrl;
};
const handleAddModelClick = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
// Handle manual model addition
const handleAddModel = async (newModel: ManualModel) => {
try {
// Find or create the provider group
let providerGroup = groups.find((g) => g.provider === newModel.provider);
const handleEditModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
if (!providerGroup) {
providerGroup = await AdminService.createModelGroup({
provider: newModel.provider,
group_api_key: undefined,
});
}
const modelData = {
name: newModel.name,
full_name: newModel.name,
input_cost: newModel.input_cost,
output_cost: newModel.output_cost,
provider: newModel.provider,
modelType: newModel.modelType,
description: newModel.description || undefined,
contextLength: newModel.contextLength,
};
await AdminService.createModel(modelData);
await refetchModels();
toast.success(`Model "${newModel.name}" added successfully!`);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model. Please try again.');
throw error; // Re-throw to let the form handle the error
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: adminModel,
});
};
const handleOverrideModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: adminModel,
});
};
// Handle model update
const handleModelUpdate = async () => {
await refetchModels();
setEditingModel(null);
};
// Handle group update
@@ -900,18 +832,11 @@ export function ModelSelector({
Delete All Overrides Permanently
</Button>
)}
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
</Button>
)}
{/*
{/* Model Management Actions
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
<Plus className='h-4 w-4' />
Add Model
</Button>
<Button
onClick={() => setIsCollectDialogOpen(true)}
variant='outline'
@@ -1155,28 +1080,15 @@ export function ModelSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOverrideModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditingModel(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
@@ -1303,16 +1215,23 @@ export function ModelSelector({
})}
{/* Forms and Dialogs */}
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
<AddModelForm
isOpen={isAddFormOpen}
onModelAdd={handleAddModel}
onCancel={() => setIsAddFormOpen(false)}
/>
{editingModel && (
<EditModelForm
model={editingModel}
providerId={
editingModel.provider_id
? parseInt(editingModel.provider_id)
: undefined
}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
onModelUpdate={handleModelUpdate}
onCancel={() => setEditingModel(null)}
isOpen={!!editingModel}
/>
)}
+7 -12
View File
@@ -48,26 +48,20 @@ export function CurrencyToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
<Coins className="h-4 w-4" />
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
<span className="sm:hidden uppercase">{displayUnit}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
Millisatoshis (mSAT)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
Satoshis (sat)
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={() => setDisplayUnit('usd')}
disabled={!usdPerSat}
>
@@ -77,3 +71,4 @@ export function CurrencyToggle() {
</DropdownMenu>
);
}
@@ -96,3 +96,4 @@ export function DashboardBalanceSummary({
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground py-8 text-center'>
<p className='text-muted-foreground text-center py-8'>
No errors found in the selected time period
</p>
</CardContent>
+41 -45
View File
@@ -116,9 +116,7 @@ export function CheatSheet(): JSX.Element {
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(null);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
@@ -334,21 +332,22 @@ export function CheatSheet(): JSX.Element {
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const showTopupDetails =
hasInteractedTopup || topupToken.trim().length > 0;
const refundToken = refundReceipt?.token ?? null;
const canTopup = Boolean(activeApiKey);
return (
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute top-0 right-0 hidden md:block'>
<div className='absolute right-0 top-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
<Bolt className='text-primary h-4 w-4' />
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
<Bolt className='h-4 w-4 text-primary' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
@@ -366,10 +365,10 @@ export function CheatSheet(): JSX.Element {
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-4 w-4' />
<ShieldCheck className='h-4 w-4 text-primary' />
Node identity
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
/v1/info snapshot
</p>
</div>
@@ -405,7 +404,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -413,7 +412,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -422,7 +421,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -432,10 +431,10 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
{nodeInfo.npub}
<Button
variant='ghost'
@@ -450,7 +449,7 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
@@ -479,10 +478,10 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='text-primary h-4 w-4' />
<Terminal className='h-4 w-4 text-primary' />
Quick docs
</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
curl-ready
</span>
</CardHeader>
@@ -503,10 +502,8 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
{curlSnippet}
</pre>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
</div>
<div className='flex gap-2'>
<Button
@@ -535,16 +532,16 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
<KeyRound className='h-5 w-5 text-primary' />
API key workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
@@ -567,7 +564,7 @@ export function CheatSheet(): JSX.Element {
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-muted-foreground text-xs'>
<span className='text-xs text-muted-foreground'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
@@ -577,7 +574,7 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
@@ -621,7 +618,7 @@ export function CheatSheet(): JSX.Element {
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Spendable
</p>
<p className='text-xl font-semibold'>
@@ -636,7 +633,7 @@ export function CheatSheet(): JSX.Element {
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Reserved
</p>
<p className='text-xl font-semibold'>
@@ -657,12 +654,10 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>3 · Top up</span>
{showTopupDetails && (
<span
className={canTopup ? 'text-primary' : 'text-destructive'}
>
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
@@ -686,14 +681,14 @@ export function CheatSheet(): JSX.Element {
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-muted-foreground text-xs'>
{canTopup ? (
<>
Adds balance to the same <code>sk-</code> token.
</>
) : (
'Enter your sk- key above to unlock top ups.'
)}
<span className='text-xs text-muted-foreground'>
{canTopup
? (
<>
Adds balance to the same <code>sk-</code> token.
</>
)
: 'Enter your sk- key above to unlock top ups.'}
</span>
</div>
)}
@@ -702,7 +697,7 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
@@ -715,13 +710,13 @@ export function CheatSheet(): JSX.Element {
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-muted-foreground text-xs'>
<span className='text-xs text-muted-foreground'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>Cashu refund token</span>
<Button
variant='outline'
@@ -748,3 +743,4 @@ export function CheatSheet(): JSX.Element {
</div>
);
}
+23 -40
View File
@@ -41,11 +41,8 @@ export function RevenueByModelTable({
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
<p className="text-sm text-muted-foreground">
Total Revenue: <span className="font-mono font-medium text-foreground">{formatAmount(totalRevenue)}</span>
</p>
</CardHeader>
<CardContent>
@@ -53,62 +50,48 @@ export function RevenueByModelTable({
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[100px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Successful</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="w-[100px]">Share</TableHead>
<TableHead className="text-right">Refunds</TableHead>
<TableHead className="text-right">Net Revenue</TableHead>
<TableHead className="text-right">Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
<TableCell colSpan={9} className="text-center text-muted-foreground">
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
const share = totalRevenue > 0 ? (model.revenue_sats / totalRevenue) * 100 : 0;
return (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right font-mono text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
<TableCell className="font-medium">{model.model}</TableCell>
<TableCell className="text-right font-mono">{model.requests}</TableCell>
<TableCell className="text-right text-green-600 font-mono">{model.successful}</TableCell>
<TableCell className="text-right text-red-600 font-mono">{model.failed}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
</span>
<div className="flex items-center gap-2">
<Progress value={share} className="h-2" />
<span className="text-xs text-muted-foreground w-8 text-right">{share.toFixed(0)}%</span>
</div>
</TableCell>
<TableCell className='text-right font-mono text-red-500'>
<TableCell className="text-right text-red-500 font-mono">
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className='text-right font-mono font-semibold'>
<TableCell className="text-right font-semibold font-mono">
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className='text-muted-foreground text-right font-mono'>
<TableCell className="text-right text-muted-foreground font-mono">
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
+194 -2
View File
@@ -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
+1 -5
View File
@@ -90,11 +90,7 @@ export function UsageMetricsChart({
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
itemStyle={{ fontSize: '12px' }}
labelStyle={{
fontSize: '12px',
color: 'hsl(var(--muted-foreground))',
marginBottom: '8px',
}}
labelStyle={{ fontSize: '12px', color: 'hsl(var(--muted-foreground))', marginBottom: '8px' }}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} />
{dataKeys.map((dataKey) => (
+5 -9
View File
@@ -114,19 +114,15 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
return (
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title} className='hover:bg-muted/50 transition-colors'>
<Card key={card.title} className="hover:bg-muted/50 transition-colors">
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
{card.title}
</CardTitle>
<div className={`bg-secondary rounded-full p-2`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
<CardTitle className='text-sm font-medium text-muted-foreground'>{card.title}</CardTitle>
<div className={`p-2 rounded-full bg-secondary`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold tracking-tight'>
{card.value}
</div>
<div className='text-2xl font-bold tracking-tight'>{card.value}</div>
</CardContent>
</Card>
))}
+10 -17
View File
@@ -67,9 +67,7 @@ export const AdminModelSchema = z.object({
pricing: AdminModelPricingSchema.or(z.record(z.any())),
per_request_limits: z.record(z.any()).nullable().optional(),
top_provider: z.record(z.any()).nullable().optional(),
upstream_provider_id: z.union([z.string(), z.number()]).nullable().optional(),
canonical_slug: z.string().nullable().optional(),
alias_ids: z.array(z.string()).nullable().optional(),
upstream_provider_id: z.number().nullable().optional(),
enabled: z.boolean().default(true),
});
@@ -816,9 +814,7 @@ export class AdminService {
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<LogResponse>(
`/admin/api/logs?${params.toString()}`
);
return await apiClient.get<LogResponse>(`/admin/api/logs?${params.toString()}`);
}
static async getLogDates(): Promise<{ dates: string[] }> {
@@ -864,7 +860,9 @@ export class AdminService {
);
}
static async createProviderAccountByType(providerType: string): Promise<{
static async createProviderAccountByType(
providerType: string
): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
@@ -881,11 +879,7 @@ export class AdminService {
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
@@ -905,13 +899,12 @@ export class AdminService {
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(providerId: number): Promise<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}> {
static async getProviderBalance(
providerId: number
): Promise<{ ok: boolean; balance_data: Record<string, unknown> }> {
return await apiClient.get<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
balance_data: Record<string, unknown>;
}>(`/admin/api/upstream-providers/${providerId}/balance`);
}
}
+1
View File
@@ -18,3 +18,4 @@ export const useCurrencyStore = create<CurrencyState>()(
}
)
);
Generated
-2
View File
@@ -1890,7 +1890,6 @@ dependencies = [
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "openai" },
{ name = "pillow" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
@@ -1924,7 +1923,6 @@ requires-dist = [
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "openai", specifier = ">=1.98.0" },
{ name = "pillow", specifier = ">=10" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },