mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge pull request #447 from Routstr/436-update-model-id
add support to using custom model ids
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
"""add forwarded_model_id to models
|
||||||
|
|
||||||
|
Revision ID: b1c2d3e4f5a6
|
||||||
|
Revises: a776ca70e5fe
|
||||||
|
Create Date: 2026-04-05 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b1c2d3e4f5a6"
|
||||||
|
down_revision = "a776ca70e5fe"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"models",
|
||||||
|
sa.Column(
|
||||||
|
"forwarded_model_id",
|
||||||
|
sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Backfill: set forwarded_model_id = id for all existing rows
|
||||||
|
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("models", "forwarded_model_id")
|
||||||
@@ -217,6 +217,10 @@ def create_model_mappings(
|
|||||||
if prefixed_id not in aliases:
|
if prefixed_id not in aliases:
|
||||||
aliases.append(prefixed_id)
|
aliases.append(prefixed_id)
|
||||||
|
|
||||||
|
# Register forwarded_model_id as a routable alias
|
||||||
|
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||||
|
aliases.append(model_to_use.forwarded_model_id)
|
||||||
|
|
||||||
# Try to set each alias
|
# Try to set each alias
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_add_candidate(alias, model_to_use, upstream)
|
_add_candidate(alias, model_to_use, upstream)
|
||||||
@@ -305,6 +309,10 @@ def create_model_mappings(
|
|||||||
if prefixed_id not in aliases:
|
if prefixed_id not in aliases:
|
||||||
aliases.append(prefixed_id)
|
aliases.append(prefixed_id)
|
||||||
|
|
||||||
|
# Register forwarded_model_id as a routable alias
|
||||||
|
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||||
|
aliases.append(model_to_use.forwarded_model_id)
|
||||||
|
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||||
seen_model_provider.add(dedupe_key)
|
seen_model_provider.add(dedupe_key)
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ class ModelCreate(BaseModel):
|
|||||||
canonical_slug: str | None = None
|
canonical_slug: str | None = None
|
||||||
alias_ids: list[str] | None = None
|
alias_ids: list[str] | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
forwarded_model_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post(
|
@admin_router.post(
|
||||||
@@ -339,6 +340,7 @@ async def upsert_provider_model(
|
|||||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||||
)
|
)
|
||||||
existing_row.enabled = payload.enabled
|
existing_row.enabled = payload.enabled
|
||||||
|
existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id
|
||||||
|
|
||||||
session.add(existing_row)
|
session.add(existing_row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -371,6 +373,7 @@ async def upsert_provider_model(
|
|||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_id,
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
|
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
|||||||
default=None, description="JSON array of model alias IDs"
|
default=None, description="JSON array of model alias IDs"
|
||||||
)
|
)
|
||||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||||
|
forwarded_model_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
|
||||||
|
)
|
||||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class Model(BaseModel):
|
|||||||
upstream_provider_id: int | str | None = None
|
upstream_provider_id: int | str | None = None
|
||||||
canonical_slug: str | None = None
|
canonical_slug: str | None = None
|
||||||
alias_ids: list[str] | None = None
|
alias_ids: list[str] | None = None
|
||||||
|
forwarded_model_id: str | None = None
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.id)
|
return hash(self.id)
|
||||||
@@ -177,6 +178,7 @@ def _row_to_model(
|
|||||||
upstream_provider_id=row.upstream_provider_id,
|
upstream_provider_id=row.upstream_provider_id,
|
||||||
canonical_slug=getattr(row, "canonical_slug", None),
|
canonical_slug=getattr(row, "canonical_slug", None),
|
||||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||||
|
forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if apply_provider_fee:
|
if apply_provider_fee:
|
||||||
@@ -329,6 +331,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -411,4 +414,10 @@ async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
|||||||
from ..proxy import get_unique_models
|
from ..proxy import get_unique_models
|
||||||
|
|
||||||
items = get_unique_models()
|
items = get_unique_models()
|
||||||
return {"data": items}
|
data = []
|
||||||
|
for model in items:
|
||||||
|
m = model.dict()
|
||||||
|
if model.forwarded_model_id:
|
||||||
|
m["id"] = model.forwarded_model_id
|
||||||
|
data.append(m)
|
||||||
|
return {"data": data}
|
||||||
|
|||||||
@@ -661,6 +661,9 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
response_json["model"] = requested_model
|
||||||
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
@@ -981,6 +984,9 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
response_json["model"] = requested_model
|
||||||
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
@@ -1303,7 +1309,7 @@ class BaseUpstreamProvider:
|
|||||||
path = self.normalize_request_path(path, model_obj)
|
path = self.normalize_request_path(path, model_obj)
|
||||||
url = self.build_request_url(path, model_obj)
|
url = self.build_request_url(path, model_obj)
|
||||||
|
|
||||||
original_model_id = model_obj.id if model_obj else None
|
original_model_id = (model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||||
|
|
||||||
transformed_body = self.prepare_request_body(request_body, model_obj)
|
transformed_body = self.prepare_request_body(request_body, model_obj)
|
||||||
|
|
||||||
@@ -1589,7 +1595,7 @@ class BaseUpstreamProvider:
|
|||||||
path = self.normalize_request_path(path, model_obj)
|
path = self.normalize_request_path(path, model_obj)
|
||||||
url = self.build_request_url(path, model_obj)
|
url = self.build_request_url(path, model_obj)
|
||||||
|
|
||||||
original_model_id = model_obj.id if model_obj else None
|
original_model_id = (model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||||
|
|
||||||
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
|
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
|
||||||
|
|
||||||
@@ -3384,6 +3390,7 @@ class BaseUpstreamProvider:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -3407,6 +3414,7 @@ class BaseUpstreamProvider:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fetch_models(self) -> list[Model]:
|
async def fetch_models(self) -> list[Model]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Loader2, Plus } from 'lucide-react';
|
import { Check, Copy, Loader2, Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ const FormSchema = z.object({
|
|||||||
instruct_type: z.string().default(''),
|
instruct_type: z.string().default(''),
|
||||||
canonical_slug: z.string().default(''),
|
canonical_slug: z.string().default(''),
|
||||||
alias_ids_raw: z.string().default(''),
|
alias_ids_raw: z.string().default(''),
|
||||||
|
forwarded_model_id: z.string().default(''),
|
||||||
upstream_provider_id: z.string().default(''),
|
upstream_provider_id: z.string().default(''),
|
||||||
input_cost: z.coerce.number().min(0).default(0),
|
input_cost: z.coerce.number().min(0).default(0),
|
||||||
output_cost: z.coerce.number().min(0).default(0),
|
output_cost: z.coerce.number().min(0).default(0),
|
||||||
@@ -104,6 +105,7 @@ export function AddProviderModelDialog({
|
|||||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||||
useState('Select a preset');
|
useState('Select a preset');
|
||||||
|
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(FormSchema) as never,
|
resolver: zodResolver(FormSchema) as never,
|
||||||
@@ -119,6 +121,7 @@ export function AddProviderModelDialog({
|
|||||||
instruct_type: '',
|
instruct_type: '',
|
||||||
canonical_slug: '',
|
canonical_slug: '',
|
||||||
alias_ids_raw: '',
|
alias_ids_raw: '',
|
||||||
|
forwarded_model_id: '',
|
||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
@@ -180,6 +183,7 @@ export function AddProviderModelDialog({
|
|||||||
: '',
|
: '',
|
||||||
canonical_slug: initialData.canonical_slug || '',
|
canonical_slug: initialData.canonical_slug || '',
|
||||||
alias_ids_raw: listToString(initialData.alias_ids),
|
alias_ids_raw: listToString(initialData.alias_ids),
|
||||||
|
forwarded_model_id: initialData.forwarded_model_id || initialData.id,
|
||||||
upstream_provider_id:
|
upstream_provider_id:
|
||||||
typeof initialData.upstream_provider_id === 'string'
|
typeof initialData.upstream_provider_id === 'string'
|
||||||
? initialData.upstream_provider_id
|
? initialData.upstream_provider_id
|
||||||
@@ -223,6 +227,7 @@ export function AddProviderModelDialog({
|
|||||||
instruct_type: '',
|
instruct_type: '',
|
||||||
canonical_slug: '',
|
canonical_slug: '',
|
||||||
alias_ids_raw: '',
|
alias_ids_raw: '',
|
||||||
|
forwarded_model_id: '',
|
||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
@@ -280,6 +285,7 @@ export function AddProviderModelDialog({
|
|||||||
);
|
);
|
||||||
form.setValue('canonical_slug', model.canonical_slug || '');
|
form.setValue('canonical_slug', model.canonical_slug || '');
|
||||||
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
||||||
|
form.setValue('forwarded_model_id', model.forwarded_model_id || model.id);
|
||||||
form.setValue(
|
form.setValue(
|
||||||
'upstream_provider_id',
|
'upstream_provider_id',
|
||||||
typeof model.upstream_provider_id === 'string'
|
typeof model.upstream_provider_id === 'string'
|
||||||
@@ -385,6 +391,7 @@ export function AddProviderModelDialog({
|
|||||||
canonical_slug: data.canonical_slug?.trim() || null,
|
canonical_slug: data.canonical_slug?.trim() || null,
|
||||||
alias_ids: listFromString(data.alias_ids_raw || ''),
|
alias_ids: listFromString(data.alias_ids_raw || ''),
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
forwarded_model_id: data.forwarded_model_id?.trim() || data.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -520,6 +527,53 @@ export function AddProviderModelDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='forwarded_model_id'
|
||||||
|
render={({ field }) => {
|
||||||
|
const handleCopy = () => {
|
||||||
|
const value = field.value || form.getValues('id');
|
||||||
|
if (!value) return;
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
setForwardedModelIdCopied(true);
|
||||||
|
setTimeout(() => setForwardedModelIdCopied(false), 1500);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Upstream Model ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
form.watch('id') || 'e.g., openai/gpt-4o'
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
onClick={handleCopy}
|
||||||
|
title='Copy model ID'
|
||||||
|
>
|
||||||
|
{forwardedModelIdCopied ? (
|
||||||
|
<Check className='h-4 w-4 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Model ID sent to the upstream provider. Defaults to the
|
||||||
|
model's own ID.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='name'
|
name='name'
|
||||||
|
|||||||
@@ -1,577 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { type Model } from '@/lib/api/schemas/models';
|
|
||||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form';
|
|
||||||
import { Edit3, Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
const EditModelFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
description: z.string().optional(),
|
|
||||||
context_length: z.number().min(0),
|
|
||||||
prompt: z.number().min(0),
|
|
||||||
completion: z.number().min(0),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EditModelFormData = z.infer<typeof EditModelFormSchema>;
|
|
||||||
|
|
||||||
const roundToFiveDecimals = (value: number | undefined | null): number => {
|
|
||||||
if (value === undefined || value === null || isNaN(value)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.round(value * 100000) / 100000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNumber = (value: unknown, fallback = 0): number => {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toStringArray = (value: unknown, fallback: string[]): string[] => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
const filtered = value.filter(
|
|
||||||
(item): item is string => typeof item === 'string'
|
|
||||||
);
|
|
||||||
return filtered.length > 0 ? filtered : fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditModelFormProps {
|
|
||||||
model: Model;
|
|
||||||
providerId?: number;
|
|
||||||
onModelUpdate?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminModelData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
created: number;
|
|
||||||
context_length: number;
|
|
||||||
architecture: {
|
|
||||||
modality: string;
|
|
||||||
input_modalities: string[];
|
|
||||||
output_modalities: string[];
|
|
||||||
tokenizer: string;
|
|
||||||
instruct_type: string | null;
|
|
||||||
};
|
|
||||||
pricing: {
|
|
||||||
prompt: number;
|
|
||||||
completion: number;
|
|
||||||
request: number;
|
|
||||||
image: number;
|
|
||||||
web_search: number;
|
|
||||||
internal_reasoning: number;
|
|
||||||
};
|
|
||||||
per_request_limits: null | undefined;
|
|
||||||
top_provider: null | undefined;
|
|
||||||
upstream_provider_id: number;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeAdminModelData = (
|
|
||||||
adminModel: AdminModel,
|
|
||||||
fallbackModel: Model,
|
|
||||||
providerId: number
|
|
||||||
): AdminModelData => {
|
|
||||||
const pricingRecord =
|
|
||||||
adminModel.pricing && typeof adminModel.pricing === 'object'
|
|
||||||
? (adminModel.pricing as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const architectureRecord =
|
|
||||||
adminModel.architecture && typeof adminModel.architecture === 'object'
|
|
||||||
? (adminModel.architecture as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: adminModel.id,
|
|
||||||
name: adminModel.name,
|
|
||||||
description: adminModel.description || '',
|
|
||||||
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
|
|
||||||
context_length: Math.max(
|
|
||||||
0,
|
|
||||||
Math.trunc(
|
|
||||||
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
architecture: {
|
|
||||||
modality:
|
|
||||||
typeof architectureRecord.modality === 'string'
|
|
||||||
? architectureRecord.modality
|
|
||||||
: fallbackModel.modelType || 'text',
|
|
||||||
input_modalities: toStringArray(architectureRecord.input_modalities, [
|
|
||||||
fallbackModel.modelType || 'text',
|
|
||||||
]),
|
|
||||||
output_modalities: toStringArray(architectureRecord.output_modalities, [
|
|
||||||
fallbackModel.modelType || 'text',
|
|
||||||
]),
|
|
||||||
tokenizer:
|
|
||||||
typeof architectureRecord.tokenizer === 'string'
|
|
||||||
? architectureRecord.tokenizer
|
|
||||||
: '',
|
|
||||||
instruct_type:
|
|
||||||
typeof architectureRecord.instruct_type === 'string'
|
|
||||||
? architectureRecord.instruct_type
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(
|
|
||||||
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
|
|
||||||
),
|
|
||||||
completion: roundToFiveDecimals(
|
|
||||||
toNumber(pricingRecord.completion, fallbackModel.output_cost)
|
|
||||||
),
|
|
||||||
request: toNumber(pricingRecord.request, 0),
|
|
||||||
image: toNumber(pricingRecord.image, 0),
|
|
||||||
web_search: toNumber(pricingRecord.web_search, 0),
|
|
||||||
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
|
|
||||||
},
|
|
||||||
per_request_limits:
|
|
||||||
adminModel.per_request_limits === null ||
|
|
||||||
adminModel.per_request_limits === undefined
|
|
||||||
? adminModel.per_request_limits
|
|
||||||
: null,
|
|
||||||
top_provider:
|
|
||||||
adminModel.top_provider === null || adminModel.top_provider === undefined
|
|
||||||
? adminModel.top_provider
|
|
||||||
: null,
|
|
||||||
upstream_provider_id:
|
|
||||||
typeof adminModel.upstream_provider_id === 'number'
|
|
||||||
? adminModel.upstream_provider_id
|
|
||||||
: providerId,
|
|
||||||
enabled: adminModel.enabled !== false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditModelForm({
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
onModelUpdate,
|
|
||||||
onCancel,
|
|
||||||
isOpen,
|
|
||||||
}: EditModelFormProps) {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [adminModelData, setAdminModelData] = useState<AdminModelData | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isNewOverride, setIsNewOverride] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<EditModelFormData>({
|
|
||||||
resolver: zodResolver(EditModelFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadAdminModel = useCallback(async () => {
|
|
||||||
if (!providerId) {
|
|
||||||
console.error('loadAdminModel called without providerId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const adminModel = await AdminService.getProviderModel(
|
|
||||||
providerId,
|
|
||||||
model.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const normalizedAdminModel = normalizeAdminModelData(
|
|
||||||
adminModel,
|
|
||||||
model,
|
|
||||||
providerId
|
|
||||||
);
|
|
||||||
setAdminModelData(normalizedAdminModel);
|
|
||||||
setIsNewOverride(false);
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: normalizedAdminModel.name,
|
|
||||||
description: normalizedAdminModel.description || '',
|
|
||||||
context_length: normalizedAdminModel.context_length,
|
|
||||||
prompt: normalizedAdminModel.pricing.prompt,
|
|
||||||
completion: normalizedAdminModel.pricing.completion,
|
|
||||||
enabled: normalizedAdminModel.enabled !== false,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setIsNewOverride(true);
|
|
||||||
setAdminModelData({
|
|
||||||
id: model.full_name,
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
architecture: {
|
|
||||||
modality: model.modelType || 'text',
|
|
||||||
input_modalities: [model.modelType || 'text'],
|
|
||||||
output_modalities: [model.modelType || 'text'],
|
|
||||||
tokenizer: '',
|
|
||||||
instruct_type: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
request: 0,
|
|
||||||
image: 0,
|
|
||||||
web_search: 0,
|
|
||||||
internal_reasoning: 0,
|
|
||||||
},
|
|
||||||
per_request_limits: null,
|
|
||||||
top_provider: null,
|
|
||||||
upstream_provider_id: providerId,
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [providerId, model, form]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && providerId) {
|
|
||||||
loadAdminModel();
|
|
||||||
} else if (isOpen && !providerId) {
|
|
||||||
console.error('EditModelForm opened without providerId', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
});
|
|
||||||
toast.error('Missing provider information for this model');
|
|
||||||
}
|
|
||||||
}, [isOpen, providerId, model, loadAdminModel]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: EditModelFormData) => {
|
|
||||||
if (!providerId) {
|
|
||||||
console.error('onSubmit called without providerId', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
});
|
|
||||||
toast.error('Missing provider ID - cannot update model');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adminModelData) {
|
|
||||||
console.error('onSubmit called without adminModelData', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
adminModelData,
|
|
||||||
});
|
|
||||||
toast.error('Model data not loaded - please try reopening the form');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: adminModelData.id,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || '',
|
|
||||||
created: adminModelData.created || Math.floor(Date.now() / 1000),
|
|
||||||
context_length: data.context_length,
|
|
||||||
architecture: adminModelData.architecture || {
|
|
||||||
modality: 'text',
|
|
||||||
input_modalities: ['text'],
|
|
||||||
output_modalities: ['text'],
|
|
||||||
tokenizer: '',
|
|
||||||
instruct_type: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(data.prompt),
|
|
||||||
completion: roundToFiveDecimals(data.completion),
|
|
||||||
request: 0,
|
|
||||||
image: 0,
|
|
||||||
web_search: 0,
|
|
||||||
internal_reasoning: 0,
|
|
||||||
},
|
|
||||||
per_request_limits: adminModelData.per_request_limits,
|
|
||||||
top_provider: adminModelData.top_provider,
|
|
||||||
upstream_provider_id: providerId,
|
|
||||||
enabled: data.enabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNewOverride) {
|
|
||||||
await AdminService.createProviderModel(providerId, payload);
|
|
||||||
toast.success('Model override created successfully!');
|
|
||||||
} else {
|
|
||||||
await AdminService.updateProviderModel(
|
|
||||||
providerId,
|
|
||||||
adminModelData.id,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
toast.success('Model updated successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
onModelUpdate?.();
|
|
||||||
onCancel?.();
|
|
||||||
} catch (error) {
|
|
||||||
const action = isNewOverride ? 'create' : 'update';
|
|
||||||
toast.error(`Failed to ${action} model. Please try again.`);
|
|
||||||
console.error(`Error ${action}ing model:`, error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
onCancel?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className='flex items-center gap-2'>
|
|
||||||
<Edit3 className='h-5 w-5' />
|
|
||||||
{isNewOverride ? 'Create Model Override' : 'Edit Model Override'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isNewOverride
|
|
||||||
? `Create an override for "${model.name}"`
|
|
||||||
: `Update the model override for "${model.name}"`}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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='name'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Display Name *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder='e.g., GPT-4'
|
|
||||||
{...field}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Custom display name for the model
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='context_length'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Context Length *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
min='0'
|
|
||||||
placeholder='4096'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(
|
|
||||||
value === '' ? 0 : parseInt(value, 10) || 0
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
field.onChange(
|
|
||||||
Number.isNaN(value) ? 0 : Math.max(0, value)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Maximum context window size
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='description'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder='Brief description of the model...'
|
|
||||||
{...field}
|
|
||||||
rows={3}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional description or notes about the model
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='prompt'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
step='0.00001'
|
|
||||||
min='0'
|
|
||||||
placeholder='5.00000'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseFloat(e.target.value);
|
|
||||||
field.onChange(roundToFiveDecimals(value));
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Cost in USD per 1,000,000 input tokens (max 5 decimals)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='completion'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
step='0.00001'
|
|
||||||
min='0'
|
|
||||||
placeholder='15.00000'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseFloat(e.target.value);
|
|
||||||
field.onChange(roundToFiveDecimals(value));
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Cost in USD per 1,000,000 output tokens (max 5 decimals)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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'>Model Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable this model override
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='flex justify-end gap-2 pt-4'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type='submit' disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
||||||
{isNewOverride ? 'Creating...' : 'Updating...'}
|
|
||||||
</>
|
|
||||||
) : isNewOverride ? (
|
|
||||||
'Create Override'
|
|
||||||
) : (
|
|
||||||
'Update Model'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -10,7 +10,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
interface RoutstrCreateKeySectionProps {
|
interface RoutstrCreateKeySectionProps {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const AdminModelSchema = z.object({
|
|||||||
canonical_slug: z.string().nullable().optional(),
|
canonical_slug: z.string().nullable().optional(),
|
||||||
alias_ids: z.array(z.string()).nullable().optional(),
|
alias_ids: z.array(z.string()).nullable().optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
|
forwarded_model_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProviderModelsSchema = z.object({
|
export const ProviderModelsSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user