mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4b3924a1 |
@@ -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
|
||||
|
||||
@@ -20,7 +20,6 @@ dependencies = [
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+13
-6
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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": []}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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's API endpoint
|
||||
<strong>Network access:</strong> Ensure the server can reach the provider'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
@@ -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
@@ -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'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'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>
|
||||
);
|
||||
|
||||
@@ -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->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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,4 @@ export const useCurrencyStore = create<CurrencyState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user