Compare commits

...
Author SHA1 Message Date
Shroominic e50facc835 prettier 2026-01-29 11:05:46 +08:00
Shroominic 55dc485705 Merge branch 'v0.3.0' into batch-override-models 2026-01-29 11:03:43 +08:00
shroominicandGitHub 855d60b4a5 Merge pull request #330 from Routstr/fix-provider-model-pricing
fix provider model pricing
2026-01-29 10:58:27 +08:00
shroominicandGitHub 27ace348b5 Merge pull request #329 from Routstr/fix-multiple-custom-models
fix: unable to add multiple custom models
2026-01-29 10:58:14 +08:00
Shroominic 80559a57d5 fix linting 2026-01-29 10:55:07 +08:00
Shroominic 8e9f6647e7 batch override models 2026-01-29 09:08:26 +08:00
Shroominic 73e3d34623 fix provider model pricing 2026-01-29 08:50:17 +08:00
6 changed files with 353 additions and 89 deletions
+93 -10
View File
@@ -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
@@ -2554,6 +2554,91 @@ async def delete_all_provider_models(provider_id: int) -> dict[str, object]:
return {"ok": True, "deleted": len(rows)}
class BatchOverrideRequest(BaseModel):
models: list[ModelCreate]
@admin_router.post(
"/api/upstream-providers/{provider_id}/batch-override",
dependencies=[Depends(require_admin_api)],
)
async def batch_override_provider_models(
provider_id: int, payload: BatchOverrideRequest
) -> dict[str, object]:
"""Batch override models for a specific provider."""
logger.info(
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
overridden_count = 0
for model_data in payload.models:
# Try to get existing model regardless of whether it's enabled or not
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
if existing_row:
# Update existing
existing_row.name = model_data.name
existing_row.description = model_data.description
existing_row.created = int(model_data.created)
existing_row.context_length = int(model_data.context_length)
existing_row.architecture = json.dumps(model_data.architecture)
existing_row.pricing = json.dumps(model_data.pricing)
existing_row.sats_pricing = None
existing_row.per_request_limits = (
json.dumps(model_data.per_request_limits)
if model_data.per_request_limits is not None
else None
)
existing_row.top_provider = (
json.dumps(model_data.top_provider) if model_data.top_provider else None
)
existing_row.canonical_slug = model_data.canonical_slug
existing_row.alias_ids = (
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
)
existing_row.enabled = model_data.enabled
session.add(existing_row)
else:
# Create new
row = ModelRow(
id=model_data.id,
name=model_data.name,
description=model_data.description,
created=int(model_data.created),
context_length=int(model_data.context_length),
architecture=json.dumps(model_data.architecture),
pricing=json.dumps(model_data.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(model_data.per_request_limits)
if model_data.per_request_limits is not None
else None
),
top_provider=(
json.dumps(model_data.top_provider) if model_data.top_provider else None
),
canonical_slug=model_data.canonical_slug,
alias_ids=(
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
),
upstream_provider_id=provider_id,
enabled=model_data.enabled,
)
session.add(row)
overridden_count += 1
await session.commit()
await refresh_model_maps()
return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"}
class UpstreamProviderCreate(BaseModel):
provider_type: str
base_url: str
@@ -2598,14 +2683,12 @@ 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.api_key == payload.api_key,
UpstreamProviderRow.base_url == payload.base_url
)
)
if result.first():
raise HTTPException(
status_code=409,
detail="Provider with this base URL and API key already exists",
status_code=409, detail="Provider with this base URL already exists"
)
provider = UpstreamProviderRow(
@@ -2729,7 +2812,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 = []
@@ -2737,10 +2823,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}"
+2 -1
View File
@@ -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,
+43 -10
View File
@@ -21,6 +21,7 @@ import {
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { BatchOverrideDialog } from '@/components/BatchOverrideDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -405,6 +406,9 @@ export default function ProvidersPage() {
mode: 'create',
initialData: null,
});
const [batchOverrideProviderId, setBatchOverrideProviderId] = useState<
number | null
>(null);
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -629,6 +633,10 @@ export default function ProvidersPage() {
});
};
const handleBatchOverride = (providerId: number) => {
setBatchOverrideProviderId(providerId);
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -986,16 +994,28 @@ export default function ProvidersPage() {
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() =>
handleBatchOverride(provider.id)
}
>
<Database className='mr-2 h-4 w-4' />
Batch Override
</Button>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
@@ -1287,6 +1307,19 @@ export default function ProvidersPage() {
mode={modelDialogState.mode}
/>
)}
{batchOverrideProviderId && (
<BatchOverrideDialog
providerId={batchOverrideProviderId}
isOpen={!!batchOverrideProviderId}
onClose={() => setBatchOverrideProviderId(null)}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', batchOverrideProviderId],
});
}}
/>
)}
</SidebarInset>
</SidebarProvider>
);
-39
View File
@@ -808,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>
+144
View File
@@ -0,0 +1,144 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Loader2, Database } from 'lucide-react';
export interface BatchOverrideDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function BatchOverrideDialog({
providerId,
isOpen,
onClose,
onSuccess,
}: BatchOverrideDialogProps) {
const [jsonInput, setJsonInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const sampleJson = {
models: [
{
id: 'model-id-1',
name: 'Model Name 1',
description: 'Description...',
created: Math.floor(Date.now() / 1000),
context_length: 8192,
architecture: {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0,
},
enabled: true,
},
],
};
const handleBatchOverride = async () => {
if (!jsonInput.trim()) {
toast.error('Please enter JSON content');
return;
}
setIsSubmitting(true);
try {
let data;
try {
data = JSON.parse(jsonInput);
} catch {
throw new Error('Invalid JSON format');
}
if (!data.models || !Array.isArray(data.models)) {
throw new Error('JSON match follow structure: { "models": [...] }');
}
const result = await AdminService.batchOverrideProviderModels(
providerId,
data.models
);
if (result.ok) {
toast.success(result.message || 'Batch override successful');
onSuccess();
onClose();
setJsonInput('');
} else {
throw new Error('Batch override failed');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Batch override failed';
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-[800px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Database className='h-4 w-4' />
Batch Override Models
</DialogTitle>
<DialogDescription>
Paste a JSON object with a &quot;models&quot; array containing model
definitions. Existing models with the same ID will be updated.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[400px] font-mono text-xs'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Processing...
</>
) : (
'Batch Override'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+71 -29
View File
@@ -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(
@@ -316,6 +341,23 @@ export class AdminService {
};
}
static async batchOverrideProviderModels(
providerId: number,
models: AdminModel[]
): Promise<{ ok: boolean; count: number; message: string }> {
const payload = {
models: models.map((m) => ({
...m,
pricing: this.convertPricingToPerToken(m.pricing),
})),
};
return await apiClient.post<{
ok: boolean;
count: number;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/batch-override`, payload);
}
static async getProviderModel(
providerId: number,
modelId: string
@@ -498,8 +540,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 +595,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,