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
18 changed files with 332 additions and 1770 deletions
-1
View File
@@ -20,7 +20,6 @@ dependencies = [
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]
+1 -6
View File
@@ -254,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
+23 -52
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import random
from pathlib import Path
from typing import Final
from urllib.request import urlopen
import httpx
@@ -19,6 +20,14 @@ logger = get_logger(__name__)
models_router = APIRouter()
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
"openrouter/sonoma-sky-alpha",
}
class Architecture(BaseModel):
modality: str
@@ -31,12 +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
input_cache_read: float = 0.0
input_cache_write: 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
@@ -60,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
@@ -68,27 +75,6 @@ class Model(BaseModel):
return hash(self.id)
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
if not pricing:
return False
try:
prompt = float(pricing.get("prompt", 0))
completion = float(pricing.get("completion", 0))
except (ValueError, TypeError):
return False
if prompt < 0 or completion < 0:
return False
if prompt == 0 and completion == 0:
return False
return True
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
@@ -110,10 +96,10 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
if (
"(free)" in model.get("name", "")
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
models_data.append(model)
@@ -147,10 +133,10 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
if (
"(free)" in model.get("name", "")
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
models_data.append(model)
@@ -214,22 +200,7 @@ def load_models() -> list[Model]:
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
valid_models = []
for model_data in models_data:
try:
model = Model(**model_data) # type: ignore
valid_models.append(model)
except Exception as e:
model_id = model_data.get("id", "unknown")
logger.warning(f"Skipping model {model_id} - validation failed: {e}")
if len(valid_models) != len(models_data):
logger.warning(
f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data"
)
return valid_models
return [Model(**model) for model in models_data] # type: ignore
def _row_to_model(
-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,
+3 -4
View File
@@ -1904,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": []}
-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
+4 -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__(
@@ -128,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,
)
@@ -377,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.
+9 -8
View File
@@ -204,8 +204,8 @@ 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'>
@@ -237,13 +237,14 @@ export default function ModelsPage() {
</div>
</AlertDescription>
</Alert>
) : (
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
+27 -167
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('');
@@ -144,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)) {
@@ -163,31 +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('');
@@ -207,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 (
@@ -226,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'
@@ -381,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',
@@ -587,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' />
@@ -856,10 +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'
@@ -923,14 +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'>
@@ -961,19 +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>
))}
@@ -1024,17 +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
@@ -1070,19 +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>
)
@@ -1117,20 +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>
)
@@ -1298,21 +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>
);
-921
View File
@@ -1,921 +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='rounded-md border bg-muted/30 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 text-left text-sm overflow-hidden'
>
<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 text-xs mt-1'>
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='space-y-4 rounded-md border bg-muted/20 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='space-y-4 rounded-md border bg-muted/20 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>
);
}
+68 -146
View File
@@ -4,11 +4,13 @@ import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
type Model,
type ManualModel,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup, type AdminModel } from '@/lib/api/services/admin';
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';
@@ -78,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());
@@ -499,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
@@ -899,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'
@@ -1154,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
@@ -1302,14 +1215,23 @@ export function ModelSelector({
})}
{/* Forms and Dialogs */}
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() => setModelDialogState((prev) => ({ ...prev, isOpen: false }))}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
<AddModelForm
isOpen={isAddFormOpen}
onModelAdd={handleAddModel}
onCancel={() => setIsAddFormOpen(false)}
/>
{editingModel && (
<EditModelForm
model={editingModel}
providerId={
editingModel.provider_id
? parseInt(editingModel.provider_id)
: undefined
}
onModelUpdate={handleModelUpdate}
onCancel={() => setEditingModel(null)}
isOpen={!!editingModel}
/>
)}
+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
+3 -5
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),
});
@@ -903,10 +901,10 @@ export class AdminService {
static async getProviderBalance(
providerId: number
): Promise<{ ok: boolean; balance_data: number | null | Record<string, unknown> }> {
): 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`);
}
}
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" },