Refine model pricing and mobile list layout

This commit is contained in:
Evan Yang
2026-03-07 18:15:29 +08:00
parent f66d98e6b5
commit 89fc48eea2
10 changed files with 567 additions and 155 deletions
+23
View File
@@ -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)
+18 -2
View File
@@ -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"]
+97
View File
@@ -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
+15 -23
View File
@@ -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
View File
@@ -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>
);
+11 -2
View File
@@ -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)}
+4
View File
@@ -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}
+3 -1
View File
@@ -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;
+64
View File
@@ -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);
}
+20
View File
@@ -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 };
}