mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 15:56:14 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31898192b2 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 |
@@ -0,0 +1,118 @@
|
||||
"""make upstream provider base_url + api_key unique
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: a86e5348850b
|
||||
Create Date: 2026-01-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "a86e5348850b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = {
|
||||
row[0]
|
||||
for row in conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "upstream_providers_old" in existing_tables:
|
||||
if "upstream_providers" in existing_tables:
|
||||
op.drop_table("upstream_providers_old")
|
||||
else:
|
||||
op.execute(
|
||||
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||
)
|
||||
existing_tables.add("upstream_providers")
|
||||
if "upstream_providers" not in existing_tables:
|
||||
return
|
||||
|
||||
constraints = [
|
||||
sa.UniqueConstraint(
|
||||
"base_url",
|
||||
"api_key",
|
||||
name="uq_upstream_providers_base_url_api_key",
|
||||
)
|
||||
]
|
||||
if add_base_url_unique:
|
||||
constraints.append(
|
||||
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
*constraints,
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||
"FROM upstream_providers_old"
|
||||
)
|
||||
op.drop_table("upstream_providers_old")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=False)
|
||||
return
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||
name = constraint.get("name")
|
||||
if constraint.get("column_names") == ["base_url"] and name:
|
||||
op.drop_constraint(
|
||||
name,
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_base_url" in index_names:
|
||||
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
["base_url", "api_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=True)
|
||||
return
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
+10
-8
@@ -2516,7 +2516,7 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
row, apply_provider_fee=False, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@@ -2598,12 +2598,14 @@ async def create_upstream_provider(
|
||||
async with create_session() as session:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == payload.base_url
|
||||
UpstreamProviderRow.base_url == payload.base_url,
|
||||
UpstreamProviderRow.api_key == payload.api_key,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Provider with this base URL already exists"
|
||||
status_code=409,
|
||||
detail="Provider with this base URL and API key already exists",
|
||||
)
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
@@ -2727,7 +2729,10 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
db_models = await list_models(
|
||||
session=session, upstream_id=provider_id, include_disabled=True
|
||||
session=session,
|
||||
upstream_id=provider_id,
|
||||
include_disabled=True,
|
||||
apply_fees=False,
|
||||
)
|
||||
|
||||
upstream_models = []
|
||||
@@ -2735,10 +2740,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
if upstream_instance:
|
||||
try:
|
||||
raw_models = await upstream_instance.fetch_models()
|
||||
upstream_models = [
|
||||
upstream_instance._apply_provider_fee_to_model(m)
|
||||
for m in raw_models
|
||||
]
|
||||
upstream_models = raw_models
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from {provider.provider_type}: {e}"
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -111,11 +112,14 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
||||
base_url: str = Field(description="Base URL of the upstream API")
|
||||
api_key: str = Field(description="API key for the upstream provider")
|
||||
api_version: str | None = Field(
|
||||
default=None, description="API version for Azure OpenAI"
|
||||
|
||||
@@ -230,6 +230,7 @@ async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
apply_fees: bool = True,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
@@ -247,7 +248,7 @@ async def list_models(
|
||||
return [
|
||||
_row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
apply_provider_fee=apply_fees,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
|
||||
+21
-14
@@ -236,7 +236,7 @@ async def _seed_providers_from_settings(
|
||||
from . import upstream_provider_classes
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_base_urls: set[str] = set()
|
||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
@@ -261,7 +261,8 @@ async def _seed_providers_from_settings(
|
||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -273,13 +274,15 @@ async def _seed_providers_from_settings(
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||
if ollama_base_url:
|
||||
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == ollama_base_url
|
||||
UpstreamProviderRow.base_url == ollama_base_url,
|
||||
UpstreamProviderRow.api_key == ollama_api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -287,18 +290,20 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="ollama",
|
||||
base_url=ollama_base_url,
|
||||
api_key=os.environ.get("OLLAMA_API_KEY", ""),
|
||||
api_key=ollama_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(ollama_base_url)
|
||||
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||
|
||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -306,19 +311,21 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="azure",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
api_key=api_key,
|
||||
api_version=settings.chat_completions_api_version,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
if settings.upstream_base_url and settings.upstream_api_key:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -326,11 +333,11 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
for provider in providers_to_add:
|
||||
session.add(provider)
|
||||
|
||||
@@ -210,8 +210,8 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
async with create_session() as session:
|
||||
statement = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url
|
||||
and UpstreamProviderRow.api_key == self.api_key
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key,
|
||||
)
|
||||
result = await session.exec(statement)
|
||||
provider = result.first()
|
||||
|
||||
+132
-161
@@ -286,7 +286,9 @@ function ProviderBalance({
|
||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
|
||||
invoiceData.payment_request
|
||||
)}`}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-64 w-64'
|
||||
/>
|
||||
@@ -482,6 +484,25 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteModelMutation = useMutation({
|
||||
mutationFn: ({
|
||||
providerId,
|
||||
modelId,
|
||||
}: {
|
||||
providerId: number;
|
||||
modelId: string;
|
||||
}) => AdminService.deleteProviderModel(providerId, modelId),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-models', variables.providerId],
|
||||
});
|
||||
toast.success('Model deleted successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to delete model: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
@@ -560,6 +581,12 @@ export default function ProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = (providerId: number, modelId: string) => {
|
||||
if (confirm('Are you sure you want to delete this model?')) {
|
||||
deleteModelMutation.mutate({ providerId, modelId });
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultBaseUrl = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.default_base_url || '';
|
||||
@@ -933,25 +960,71 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
) : providerModels &&
|
||||
viewingModels === provider.id ? (
|
||||
providerModels.remote_models.length === 0 ? (
|
||||
// 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'>
|
||||
<Tabs
|
||||
defaultValue={
|
||||
providerModels.remote_models.length > 0
|
||||
? 'provided'
|
||||
: 'custom'
|
||||
}
|
||||
className='w-full'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger
|
||||
value='provided'
|
||||
className='text-xs sm:text-sm'
|
||||
>
|
||||
<span className='hidden sm:inline'>
|
||||
Provided Models
|
||||
</span>
|
||||
<span className='sm:hidden'>Provided</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs sm:ml-2'
|
||||
>
|
||||
{providerModels.remote_models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='custom'
|
||||
className='text-xs sm:text-sm'
|
||||
>
|
||||
<span className='hidden sm:inline'>
|
||||
Custom Models
|
||||
</span>
|
||||
<span className='sm:hidden'>Custom</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs sm:ml-2'
|
||||
>
|
||||
{providerModels.db_models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
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'>
|
||||
No models configured. Add custom models
|
||||
to use this provider.
|
||||
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 Custom Model
|
||||
</Button>
|
||||
)}
|
||||
<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 py-4 text-center text-sm'>
|
||||
No custom models configured
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
@@ -1000,103 +1073,48 @@ export default function ProvidersPage() {
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive hover:text-destructive h-8 w-8'
|
||||
onClick={() =>
|
||||
handleDeleteModel(
|
||||
provider.id,
|
||||
model.id
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
deleteModelMutation.isPending
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Has provided models - show tabs
|
||||
<Tabs
|
||||
defaultValue='provided'
|
||||
className='w-full'
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='provided'
|
||||
className='mt-4 space-y-2'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger
|
||||
value='provided'
|
||||
className='text-xs sm:text-sm'
|
||||
>
|
||||
<span className='hidden sm:inline'>
|
||||
Provided Models
|
||||
</span>
|
||||
<span className='sm:hidden'>
|
||||
Provided
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs sm:ml-2'
|
||||
>
|
||||
{providerModels.remote_models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='custom'
|
||||
className='text-xs sm:text-sm'
|
||||
>
|
||||
<span className='hidden sm:inline'>
|
||||
Custom Models
|
||||
</span>
|
||||
<span className='sm:hidden'>Custom</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs sm:ml-2'
|
||||
>
|
||||
{providerModels.db_models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
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 py-4 text-center text-sm'>
|
||||
No custom models configured
|
||||
{providerModels.remote_models.length > 0 ? (
|
||||
<>
|
||||
<div className='text-muted-foreground mb-3 text-sm'>
|
||||
Models automatically discovered from the
|
||||
provider's catalog.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{providerModels.db_models.map(
|
||||
{providerModels.remote_models.map(
|
||||
(model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
|
||||
<span className='truncate font-mono text-sm font-medium'>
|
||||
{model.id}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
model.enabled
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className='w-fit text-xs'
|
||||
>
|
||||
{model.enabled
|
||||
? 'Enabled'
|
||||
: 'Disabled'}
|
||||
</Badge>
|
||||
<div className='truncate font-mono text-sm font-medium'>
|
||||
{model.id}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
||||
{model.description ||
|
||||
@@ -1109,79 +1127,32 @@ export default function ProvidersPage() {
|
||||
tokens
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
handleEditModel(
|
||||
handleOverrideModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='provided'
|
||||
className='mt-4 space-y-2'
|
||||
>
|
||||
{providerModels.remote_models.length >
|
||||
0 && (
|
||||
<div className='text-muted-foreground mb-3 text-sm'>
|
||||
Models automatically discovered from the
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
{providerModels.remote_models.map(
|
||||
(model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-mono text-sm font-medium'>
|
||||
{model.id}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
||||
{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='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
handleOverrideModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||
No provided models available
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
|
||||
const pricing = model.pricing as Record<string, number>;
|
||||
const topProvider = model.top_provider as Record<string, unknown> | null;
|
||||
|
||||
form.setValue('id', model.id);
|
||||
if (!isOverride) {
|
||||
form.setValue('id', model.id);
|
||||
}
|
||||
form.setValue('name', model.name);
|
||||
form.setValue('description', model.description || '');
|
||||
form.setValue('context_length', model.context_length);
|
||||
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!isEdit && !isOverride && (
|
||||
{!isEdit && (
|
||||
<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'>
|
||||
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
|
||||
</Popover>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
Prefill fields from a preset model definition, then adjust as
|
||||
needed.
|
||||
{isOverride
|
||||
? 'Apply pricing and settings from a preset model (keeping the model ID unchanged).'
|
||||
: 'Prefill fields from a preset model definition, then adjust as needed.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
|
||||
</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>
|
||||
|
||||
|
||||
@@ -139,18 +139,26 @@ export class AdminService {
|
||||
): Record<string, unknown> {
|
||||
if (!pricing) return pricing;
|
||||
const result = { ...pricing };
|
||||
if (typeof result.prompt === 'number') {
|
||||
result.prompt = result.prompt * 1000000;
|
||||
}
|
||||
if (typeof result.completion === 'number') {
|
||||
result.completion = result.completion * 1000000;
|
||||
}
|
||||
if (typeof result.request === 'number') {
|
||||
result.request = result.request * 1000000;
|
||||
}
|
||||
if (typeof result.image === 'number') {
|
||||
result.image = result.image * 1000000;
|
||||
}
|
||||
|
||||
// Only prompt and completion are per-token and need scaling to per-1M
|
||||
const convertField = (field: string) => {
|
||||
const val = result[field];
|
||||
if (val !== undefined && val !== null) {
|
||||
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||
if (!isNaN(num)) {
|
||||
// Multiply by 1M and round to avoid floating point artifacts (e.g. 0.40399999999999997)
|
||||
// 9 decimals is plenty for USD/1M tokens (0.000000001)
|
||||
result[field] = parseFloat((num * 1000000).toFixed(9));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
convertField('prompt');
|
||||
convertField('completion');
|
||||
|
||||
// Other fields (request, image, etc.) are already flat fees (per item)
|
||||
// so we do NOT scale them.
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -159,18 +167,23 @@ export class AdminService {
|
||||
): Record<string, unknown> {
|
||||
if (!pricing) return pricing;
|
||||
const result = { ...pricing };
|
||||
if (typeof result.prompt === 'number') {
|
||||
result.prompt = result.prompt / 1000000;
|
||||
}
|
||||
if (typeof result.completion === 'number') {
|
||||
result.completion = result.completion / 1000000;
|
||||
}
|
||||
if (typeof result.request === 'number') {
|
||||
result.request = result.request / 1000000;
|
||||
}
|
||||
if (typeof result.image === 'number') {
|
||||
result.image = result.image / 1000000;
|
||||
}
|
||||
|
||||
// Only prompt and completion are per-1M in UI and need scaling down to per-token
|
||||
const convertField = (field: string) => {
|
||||
const val = result[field];
|
||||
if (val !== undefined && val !== null) {
|
||||
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||
if (!isNaN(num)) {
|
||||
result[field] = num / 1000000;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
convertField('prompt');
|
||||
convertField('completion');
|
||||
|
||||
// Other fields stay as flat fees
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -291,7 +304,19 @@ export class AdminService {
|
||||
const data = await apiClient.get<ProviderModels>(
|
||||
`/admin/api/upstream-providers/${providerId}/models`
|
||||
);
|
||||
return data;
|
||||
|
||||
// Convert pricing for all models in the list so the UI receives "per 1M tokens" values
|
||||
return {
|
||||
...data,
|
||||
db_models: data.db_models.map((m) => ({
|
||||
...m,
|
||||
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||
})),
|
||||
remote_models: data.remote_models.map((m) => ({
|
||||
...m,
|
||||
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
static async createProviderModel(
|
||||
@@ -498,8 +523,8 @@ export class AdminService {
|
||||
: null;
|
||||
|
||||
const pricing = {
|
||||
prompt: (data.input_cost as number) / 1000000,
|
||||
completion: (data.output_cost as number) / 1000000,
|
||||
prompt: data.input_cost as number,
|
||||
completion: data.output_cost as number,
|
||||
request: (data.min_cost_per_request as number) || 0,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
@@ -553,8 +578,8 @@ export class AdminService {
|
||||
const existingModel = await this.getModel(modelId, providerId);
|
||||
|
||||
const pricing = {
|
||||
prompt: (data.input_cost as number) / 1000000,
|
||||
completion: (data.output_cost as number) / 1000000,
|
||||
prompt: data.input_cost as number,
|
||||
completion: data.output_cost as number,
|
||||
request: (data.min_cost_per_request as number) || 0,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
|
||||
Reference in New Issue
Block a user