mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Refine model pricing and mobile list layout
This commit is contained in:
@@ -65,6 +65,26 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def _normalize_legacy_prompt_completion_pricing(
|
||||
pricing: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
normalized = dict(pricing)
|
||||
|
||||
for field in ("prompt", "completion"):
|
||||
raw_value = normalized.get(field)
|
||||
try:
|
||||
value = float(raw_value) if raw_value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
# Older admin overrides were occasionally saved in "per 1M tokens"
|
||||
# units instead of the backend's expected per-token unit.
|
||||
if value is not None and value > 0.1:
|
||||
normalized[field] = value / 1_000_000
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
@@ -156,6 +176,9 @@ def _row_to_model(
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict):
|
||||
pricing = _normalize_legacy_prompt_completion_pricing(pricing)
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
|
||||
@@ -79,6 +79,22 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
data = response.json()
|
||||
|
||||
models_list = []
|
||||
|
||||
def parse_pricing_value(value: object) -> float:
|
||||
if isinstance(value, dict):
|
||||
value = value.get("usd", 0.0)
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
return 0.0
|
||||
|
||||
for model_data in data.get("data", []):
|
||||
model_id = model_data.get("id", "")
|
||||
if not model_id:
|
||||
@@ -111,8 +127,8 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
input_pricing = pricing_info.get("input", {})
|
||||
output_pricing = pricing_info.get("output", {})
|
||||
|
||||
prompt_price = input_pricing.get("usd", 0.001) / 1000000
|
||||
completion_price = output_pricing.get("usd", 0.001) / 1000000
|
||||
prompt_price = parse_pricing_value(input_pricing) / 1_000_000
|
||||
completion_price = parse_pricing_value(output_pricing) / 1_000_000
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for pricing unit normalization across model sources."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ModelRow
|
||||
from routstr.payment.models import _row_to_model
|
||||
from routstr.upstream.generic import GenericUpstreamProvider
|
||||
|
||||
|
||||
def test_row_to_model_normalizes_legacy_per_million_db_pricing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("routstr.payment.models.sats_usd_price", lambda: 0.00002)
|
||||
|
||||
row = ModelRow(
|
||||
id="anthropic/claude-opus-4.5",
|
||||
upstream_provider_id=1,
|
||||
name="Claude Opus 4.5",
|
||||
created=0,
|
||||
description="test",
|
||||
context_length=200000,
|
||||
architecture=json.dumps(
|
||||
{
|
||||
"modality": "text->text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "test",
|
||||
"instruct_type": None,
|
||||
}
|
||||
),
|
||||
pricing=json.dumps(
|
||||
{
|
||||
"prompt": 5.0,
|
||||
"completion": 25.0,
|
||||
"request": 0.0,
|
||||
"image": 0.0,
|
||||
"web_search": 0.0,
|
||||
"internal_reasoning": 0.0,
|
||||
}
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
model = _row_to_model(row)
|
||||
|
||||
assert model.pricing.prompt == pytest.approx(0.000005)
|
||||
assert model.pricing.completion == pytest.approx(0.000025)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_provider_missing_pricing_defaults_to_zero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-5-chat",
|
||||
"name": "gpt-5-chat",
|
||||
"created": 0,
|
||||
"owned_by": "openai",
|
||||
"model_spec": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self) -> "FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str] | None = None
|
||||
) -> FakeResponse:
|
||||
del url, headers
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"routstr.upstream.generic.httpx.AsyncClient",
|
||||
lambda timeout=30.0: FakeClient(),
|
||||
)
|
||||
|
||||
provider = GenericUpstreamProvider(base_url="https://example.test")
|
||||
|
||||
models = await provider.fetch_models()
|
||||
|
||||
assert len(models) == 1
|
||||
assert models[0].pricing.prompt == 0.0
|
||||
assert models[0].pricing.completion == 0.0
|
||||
@@ -5,8 +5,9 @@ import { type Model } from '@/lib/api/schemas/models';
|
||||
import {
|
||||
calculateRequestCost,
|
||||
estimateMinimumTokensForCost,
|
||||
formatCost,
|
||||
} from '@/lib/services/cost-validation';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,6 +22,7 @@ interface CostCalculatorProps {
|
||||
}
|
||||
|
||||
export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
const { displayUnit, usdPerSat } = useDisplayCurrency();
|
||||
const [inputTokens, setInputTokens] = useState<number>(100);
|
||||
const [outputTokens, setOutputTokens] = useState<number>(100);
|
||||
|
||||
@@ -39,6 +41,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
}, [model]);
|
||||
|
||||
const hasMinimumCost = model.min_cost_per_request > 0;
|
||||
const formatDisplayCost = (amountUsd: number) =>
|
||||
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -74,35 +78,25 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.inputCost)}
|
||||
</span>
|
||||
<span className='font-mono'>{formatDisplayCost(costCalculation.inputCost)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.outputCost)}
|
||||
</span>
|
||||
<span className='font-mono'>{formatDisplayCost(costCalculation.outputCost)}</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between'>
|
||||
<span>Base Cost:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.baseCost)}
|
||||
</span>
|
||||
<span className='font-mono'>{formatDisplayCost(costCalculation.baseCost)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum Cost per Request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.minCostPerRequest)}
|
||||
</span>
|
||||
<span className='font-mono'>{formatDisplayCost(costCalculation.minCostPerRequest)}</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between font-medium'>
|
||||
<span>Final Cost:</span>
|
||||
<span className='font-mono text-lg'>
|
||||
{formatCost(costCalculation.finalCost)}
|
||||
</span>
|
||||
<span className='font-mono text-lg'>{formatDisplayCost(costCalculation.finalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,8 +118,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{costCalculation.isMinimumApplied
|
||||
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
|
||||
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(costCalculation.minCostPerRequest)}.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -203,17 +197,15 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Input cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.input_cost)}</span>
|
||||
<span className='font-mono'>{formatDisplayCost(model.input_cost)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.output_cost)}</span>
|
||||
<span className='font-mono'>{formatDisplayCost(model.output_cost)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum cost per request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(model.min_cost_per_request)}
|
||||
</span>
|
||||
<span className='font-mono'>{formatDisplayCost(model.min_cost_per_request)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+312
-127
@@ -1,7 +1,7 @@
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { formatCost } from '@/lib/services/cost-validation';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -12,10 +12,26 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Ban, CheckCircle, Edit3, MoreVertical, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowRight,
|
||||
AudioLines,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Edit3,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Layers3,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Type,
|
||||
Video,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ModelItemCardProps {
|
||||
model: Model;
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
isSelected: boolean;
|
||||
hasEffectiveApiKey: boolean;
|
||||
hasIndividualSettings: boolean;
|
||||
@@ -29,8 +45,96 @@ interface ModelItemCardProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
type ModelModality =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'file'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'embedding'
|
||||
| 'multimodal';
|
||||
|
||||
const MODALITY_ORDER: ModelModality[] = [
|
||||
'text',
|
||||
'image',
|
||||
'file',
|
||||
'audio',
|
||||
'video',
|
||||
'embedding',
|
||||
'multimodal',
|
||||
];
|
||||
|
||||
function extractModalities(part: string): ModelModality[] {
|
||||
const normalized = part.toLowerCase();
|
||||
const modalities = MODALITY_ORDER.filter((modality) =>
|
||||
normalized.includes(modality)
|
||||
);
|
||||
|
||||
return modalities.length > 0 ? modalities : ['text'];
|
||||
}
|
||||
|
||||
function getModelTypeParts(modelType: string): {
|
||||
inputs: ModelModality[];
|
||||
outputs: ModelModality[];
|
||||
} {
|
||||
const [inputPart, outputPart] = modelType.split('->').map((part) => part.trim());
|
||||
|
||||
return {
|
||||
inputs: extractModalities(inputPart || modelType),
|
||||
outputs: outputPart ? extractModalities(outputPart) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function ModelTypeIcons({ modelType }: { modelType: string }) {
|
||||
const { inputs, outputs } = getModelTypeParts(modelType);
|
||||
|
||||
const renderIcon = (modality: ModelModality, index: number) => {
|
||||
const props = {
|
||||
className: 'h-3 w-3 shrink-0',
|
||||
'aria-hidden': true as const,
|
||||
};
|
||||
|
||||
switch (modality) {
|
||||
case 'image':
|
||||
return <ImageIcon key={`${modality}-${index}`} {...props} />;
|
||||
case 'file':
|
||||
return <FileText key={`${modality}-${index}`} {...props} />;
|
||||
case 'audio':
|
||||
return <AudioLines key={`${modality}-${index}`} {...props} />;
|
||||
case 'video':
|
||||
return <Video key={`${modality}-${index}`} {...props} />;
|
||||
case 'embedding':
|
||||
return <Waypoints key={`${modality}-${index}`} {...props} />;
|
||||
case 'multimodal':
|
||||
return <Layers3 key={`${modality}-${index}`} {...props} />;
|
||||
case 'text':
|
||||
default:
|
||||
return <Type key={`${modality}-${index}`} {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className='inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-hidden text-muted-foreground'
|
||||
title={modelType}
|
||||
aria-label={modelType}
|
||||
>
|
||||
{inputs.map(renderIcon)}
|
||||
{outputs.length > 0 && (
|
||||
<>
|
||||
<ArrowRight className='h-2.5 w-2.5 shrink-0 opacity-55' aria-hidden />
|
||||
{outputs.map(renderIcon)}
|
||||
</>
|
||||
)}
|
||||
<span className='sr-only'>{modelType}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelItemCard({
|
||||
model,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
isSelected,
|
||||
hasEffectiveApiKey,
|
||||
hasIndividualSettings,
|
||||
@@ -43,6 +147,15 @@ export function ModelItemCard({
|
||||
onEnable,
|
||||
onDelete,
|
||||
}: ModelItemCardProps) {
|
||||
const isDisabled = Boolean(model.soft_deleted);
|
||||
const statusLabel = isDisabled ? 'Disabled' : 'Enabled';
|
||||
const isFreeModel = model.is_free;
|
||||
const statusDotClass = cn(
|
||||
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
isDisabled
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]'
|
||||
);
|
||||
const modelSourceLabel =
|
||||
model.api_key_type === 'remote' ? 'Remote' : 'Database';
|
||||
const keySourceLabel = !hasEffectiveApiKey
|
||||
@@ -50,14 +163,98 @@ export function ModelItemCard({
|
||||
: hasIndividualSettings
|
||||
? 'Individual key'
|
||||
: 'Group key';
|
||||
const pricingLabel = model.is_free
|
||||
const inputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.input_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const outputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.output_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const pricingLabel = isFreeModel
|
||||
? 'Free'
|
||||
: `Input ${formatCost(model.input_cost)} · Output ${formatCost(model.output_cost)}`;
|
||||
: `Input ${inputPriceLabel} · Output ${outputPriceLabel}`;
|
||||
const renderActionsMenu = (className: string) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={className}
|
||||
aria-label={`Model actions for ${model.name}`}
|
||||
title={`Model actions for ${model.name}`}
|
||||
>
|
||||
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-52'>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOverride();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEnable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
Enable Model
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDisable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<Ban className='mr-2 h-4 w-4' />
|
||||
Disable Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150',
|
||||
"relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150 after:absolute after:right-3 after:bottom-0 after:left-3 after:h-px after:bg-border/80 after:content-[''] last:after:hidden md:after:hidden",
|
||||
'hover:bg-muted/25',
|
||||
isSelected && 'bg-primary/12',
|
||||
model.soft_deleted && 'bg-muted/20 opacity-80'
|
||||
@@ -65,8 +262,8 @@ export function ModelItemCard({
|
||||
onMouseEnter={onHoverStart}
|
||||
onMouseLeave={onHoverEnd}
|
||||
>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<div>
|
||||
<div className='px-2 py-2 md:flex md:items-center md:gap-2 md:py-1'>
|
||||
<div className='hidden md:flex md:items-center'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
@@ -76,156 +273,144 @@ export function ModelItemCard({
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1 px-0.5 text-left'>
|
||||
<div className='grid min-w-0 items-center gap-x-3 gap-y-1 md:grid-cols-[minmax(170px,1fr)_72px_78px_92px_120px] lg:grid-cols-[minmax(170px,1fr)_72px_78px_92px_120px_240px]'>
|
||||
<div className='hidden min-w-0 items-center gap-x-3 gap-y-1 md:grid md:grid-cols-[minmax(210px,1.45fr)_112px_96px_120px] lg:grid-cols-[minmax(230px,1.65fr)_128px_96px_120px_minmax(220px,1fr)]'>
|
||||
<div className='min-w-0'>
|
||||
<h3
|
||||
className={cn(
|
||||
'min-w-0 truncate text-sm font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'min-w-0 truncate text-sm font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden md:block'>
|
||||
<Badge
|
||||
variant={model.soft_deleted ? 'secondary' : 'outline'}
|
||||
className={cn(
|
||||
'h-5 w-[64px] justify-center px-1 text-[10px] leading-none',
|
||||
model.soft_deleted
|
||||
? 'text-muted-foreground'
|
||||
: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-500 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{model.soft_deleted ? 'Disabled' : 'Enabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className='text-muted-foreground hidden text-xs capitalize md:block'>
|
||||
{model.modelType}
|
||||
<span
|
||||
className='hidden min-w-0 overflow-hidden md:flex md:items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span className='text-muted-foreground hidden text-xs md:block'>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-xs md:block'
|
||||
title={modelSourceLabel}
|
||||
>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground hidden text-xs md:block',
|
||||
'text-muted-foreground hidden truncate text-xs md:block',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
title={keySourceLabel}
|
||||
>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
<span className='text-muted-foreground hidden truncate text-xs whitespace-nowrap lg:block'>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-right text-xs whitespace-nowrap lg:block'
|
||||
title={pricingLabel}
|
||||
>
|
||||
{pricingLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='text-muted-foreground mt-0.5 space-y-0.5 text-xs md:hidden'>
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
<Badge
|
||||
variant={model.soft_deleted ? 'secondary' : 'outline'}
|
||||
className={cn(
|
||||
'h-5 shrink-0 px-1 text-[10px] leading-none',
|
||||
model.soft_deleted
|
||||
? 'text-muted-foreground'
|
||||
: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-500 dark:text-emerald-400'
|
||||
)}
|
||||
<div className='grid grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-2.5 md:hidden'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='border-border/90 data-checked:ring-primary/40 mt-0.5 size-4 flex-shrink-0 self-start data-checked:ring-2'
|
||||
/>
|
||||
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2'>
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2.5'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
{model.soft_deleted ? 'Disabled' : 'Enabled'}
|
||||
</Badge>
|
||||
<span className='opacity-50'>•</span>
|
||||
<span className='capitalize'>{model.modelType}</span>
|
||||
<span className='opacity-50'>•</span>
|
||||
<span>{modelSourceLabel}</span>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'line-clamp-2 min-w-0 flex-1 text-[15px] font-medium leading-5',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 -mr-1 h-7 w-7 shrink-0 self-start'
|
||||
)}
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
|
||||
<div aria-hidden />
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]'>
|
||||
<span
|
||||
className='text-foreground/80 inline-flex items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span className='text-border/80' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className='inline-flex items-center'>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate',
|
||||
'inline-flex items-center',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
<span className='text-border/80 mr-2' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
<span className='opacity-50'>•</span>
|
||||
<span className='min-w-0 flex-1 truncate'>{pricingLabel}</span>
|
||||
</div>
|
||||
|
||||
<div aria-hidden />
|
||||
{isFreeModel ? (
|
||||
<div className='text-muted-foreground text-sm font-medium'>Free</div>
|
||||
) : (
|
||||
<div className='border-border/45 bg-background/20 grid grid-cols-2 overflow-hidden rounded-lg border'>
|
||||
<div className='px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
Input
|
||||
</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{inputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-border/45 border-l px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
Output
|
||||
</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{outputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
|
||||
aria-label={`Model actions for ${model.name}`}
|
||||
title={`Model actions for ${model.name}`}
|
||||
>
|
||||
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-52'>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOverride();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEnable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
Enable Model
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDisable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<Ban className='mr-2 h-4 w-4' />
|
||||
Disable Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className='hidden md:block'>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import type { AdminModelGroup } from '@/lib/api/services/admin';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { ModelItemCard } from '@/components/model-item-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
interface ModelProviderSectionProps {
|
||||
provider: string;
|
||||
providerModels: Model[];
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
filterProvider?: string;
|
||||
groupData?: AdminModelGroup;
|
||||
selectedModels: Set<string>;
|
||||
@@ -52,6 +55,8 @@ interface ModelProviderSectionProps {
|
||||
export function ModelProviderSection({
|
||||
provider,
|
||||
providerModels,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
filterProvider,
|
||||
groupData,
|
||||
selectedModels,
|
||||
@@ -79,11 +84,13 @@ export function ModelProviderSection({
|
||||
|
||||
if (filterProvider) {
|
||||
return (
|
||||
<div className='bg-card/35 divide-border/55 border-border/60 divide-y overflow-hidden rounded-lg border'>
|
||||
<div className='bg-card/35 border-border/70 overflow-hidden rounded-lg border md:divide-y md:divide-border/75'>
|
||||
{providerModels.map((model) => (
|
||||
<ModelItemCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
@@ -189,11 +196,13 @@ export function ModelProviderSection({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
|
||||
<div className='bg-card/35 divide-border/55 border-border/60 divide-y overflow-hidden rounded-lg border'>
|
||||
<div className='bg-card/35 border-border/70 overflow-hidden rounded-lg border md:divide-y md:divide-border/75'>
|
||||
{providerModels.map((model) => (
|
||||
<ModelItemCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
|
||||
@@ -12,6 +12,7 @@ type ModelGroup = AdminModelGroup;
|
||||
import { AddProviderModelDialog } from '@/components/add-provider-model-dialog';
|
||||
import { EditGroupForm } from '@/components/edit-group-form';
|
||||
import { ModelProviderSection } from '@/components/model-provider-section';
|
||||
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -104,6 +105,7 @@ export function ModelSelector({
|
||||
filteredModels: propFilteredModels,
|
||||
showDeleteAllButton = false,
|
||||
}: ModelSelectorProps) {
|
||||
const { displayUnit, usdPerSat } = useDisplayCurrency();
|
||||
const [, setHoveredModelId] = useState<string | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<{
|
||||
provider: string;
|
||||
@@ -884,6 +886,8 @@ export function ModelSelector({
|
||||
key={provider}
|
||||
provider={provider}
|
||||
providerModels={providerModels}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
filterProvider={filterProvider}
|
||||
groupData={groupData}
|
||||
selectedModels={selectedModels}
|
||||
|
||||
@@ -193,7 +193,9 @@ export class AdminService {
|
||||
adminModel: AdminModel,
|
||||
providerName?: string
|
||||
): AdminModelAsModel {
|
||||
const pricing = this.convertPricingToPerMillionTokens(adminModel.pricing);
|
||||
// Callers normalize prompt/completion to the UI's "per 1M tokens" unit
|
||||
// before they pass models into this mapper.
|
||||
const pricing = adminModel.pricing as Record<string, unknown>;
|
||||
const inputCost = (pricing?.prompt as number) || 0;
|
||||
const outputCost = (pricing?.completion as number) || 0;
|
||||
const requestCost = (pricing?.request as number) || 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DisplayUnit } from './types/units';
|
||||
import { formatCost } from './services/cost-validation';
|
||||
|
||||
export type CashuUnit = 'sat' | 'msat';
|
||||
|
||||
@@ -43,3 +44,66 @@ export function formatFromMsat(
|
||||
});
|
||||
return formatter.format(usd);
|
||||
}
|
||||
|
||||
function formatSatsFromUsd(amountUsd: number, usdPerSat: number): string {
|
||||
const sats = amountUsd / usdPerSat;
|
||||
|
||||
if (sats > 0 && sats < 0.001) {
|
||||
return '<0.001 sats';
|
||||
}
|
||||
|
||||
if (sats >= 1_000) {
|
||||
return `${new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(sats)} sats`;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = sats >= 1000 ? 0 : sats >= 1 ? 2 : 4;
|
||||
|
||||
return `${sats.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
})} sats`;
|
||||
}
|
||||
|
||||
function formatMsatsFromUsd(amountUsd: number, usdPerSat: number): string {
|
||||
const msats = (amountUsd / usdPerSat) * 1000;
|
||||
|
||||
if (msats > 0 && msats < 1) {
|
||||
return '<1 mSATs';
|
||||
}
|
||||
|
||||
if (msats >= 1_000) {
|
||||
return `${new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(msats)} mSATs`;
|
||||
}
|
||||
|
||||
return `${Math.round(msats).toLocaleString()} mSATs`;
|
||||
}
|
||||
|
||||
export function formatUsdAmountForDisplayUnit(
|
||||
amountUsd: number,
|
||||
displayUnit: DisplayUnit,
|
||||
usdPerSat: number | null
|
||||
): string {
|
||||
if (Number.isNaN(amountUsd)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (amountUsd === 0) {
|
||||
return 'Free';
|
||||
}
|
||||
|
||||
if (displayUnit === 'usd' || usdPerSat === null || usdPerSat <= 0) {
|
||||
return formatCost(amountUsd);
|
||||
}
|
||||
|
||||
if (displayUnit === 'msat') {
|
||||
return formatMsatsFromUsd(amountUsd, usdPerSat);
|
||||
}
|
||||
|
||||
return formatSatsFromUsd(amountUsd, usdPerSat);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
|
||||
export function useDisplayCurrency() {
|
||||
const { displayUnit } = useCurrencyStore();
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
queryKey: ['btc-usd-price'],
|
||||
queryFn: fetchBtcUsdPrice,
|
||||
refetchInterval: 120_000,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
|
||||
|
||||
return { displayUnit, usdPerSat };
|
||||
}
|
||||
Reference in New Issue
Block a user