From 89fc48eea249751f7ee1f59389b2520f49a6cb47 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Sat, 7 Mar 2026 12:34:06 +0800 Subject: [PATCH] Refine model pricing and mobile list layout --- routstr/payment/models.py | 23 ++ routstr/upstream/generic.py | 20 +- tests/unit/test_model_pricing_units.py | 97 +++++ ui/components/cost-calculator.tsx | 38 +- ui/components/model-item-card.tsx | 439 ++++++++++++++++------- ui/components/model-provider-section.tsx | 13 +- ui/components/model-selector.tsx | 4 + ui/lib/api/services/admin.ts | 4 +- ui/lib/currency.ts | 64 ++++ ui/lib/hooks/use-display-currency.ts | 20 ++ 10 files changed, 567 insertions(+), 155 deletions(-) create mode 100644 tests/unit/test_model_pricing_units.py create mode 100644 ui/lib/hooks/use-display-currency.ts diff --git a/routstr/payment/models.py b/routstr/payment/models.py index a5cefbc0..5ae60d64 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -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) diff --git a/routstr/upstream/generic.py b/routstr/upstream/generic.py index 390c8372..feb2160f 100644 --- a/routstr/upstream/generic.py +++ b/routstr/upstream/generic.py @@ -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"] diff --git a/tests/unit/test_model_pricing_units.py b/tests/unit/test_model_pricing_units.py new file mode 100644 index 00000000..3ed05147 --- /dev/null +++ b/tests/unit/test_model_pricing_units.py @@ -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 diff --git a/ui/components/cost-calculator.tsx b/ui/components/cost-calculator.tsx index 711acf9d..78cef87f 100644 --- a/ui/components/cost-calculator.tsx +++ b/ui/components/cost-calculator.tsx @@ -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(100); const [outputTokens, setOutputTokens] = useState(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 (
@@ -74,35 +78,25 @@ export function CostCalculator({ model }: CostCalculatorProps) {
Input Cost ({inputTokens.toLocaleString()} tokens): - - {formatCost(costCalculation.inputCost)} - + {formatDisplayCost(costCalculation.inputCost)}
Output Cost ({outputTokens.toLocaleString()} tokens): - - {formatCost(costCalculation.outputCost)} - + {formatDisplayCost(costCalculation.outputCost)}

Base Cost: - - {formatCost(costCalculation.baseCost)} - + {formatDisplayCost(costCalculation.baseCost)}
Minimum Cost per Request: - - {formatCost(costCalculation.minCostPerRequest)} - + {formatDisplayCost(costCalculation.minCostPerRequest)}

Final Cost: - - {formatCost(costCalculation.finalCost)} - + {formatDisplayCost(costCalculation.finalCost)}
@@ -124,8 +118,8 @@ export function CostCalculator({ model }: CostCalculatorProps) { {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)}.`} )} @@ -203,17 +197,15 @@ export function CostCalculator({ model }: CostCalculatorProps) {
Input cost per 1M tokens: - {formatCost(model.input_cost)} + {formatDisplayCost(model.input_cost)}
Output cost per 1M tokens: - {formatCost(model.output_cost)} + {formatDisplayCost(model.output_cost)}
Minimum cost per request: - - {formatCost(model.min_cost_per_request)} - + {formatDisplayCost(model.min_cost_per_request)}
diff --git a/ui/components/model-item-card.tsx b/ui/components/model-item-card.tsx index 9a581c58..3f3127c7 100644 --- a/ui/components/model-item-card.tsx +++ b/ui/components/model-item-card.tsx @@ -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 ; + case 'file': + return ; + case 'audio': + return ; + case 'video': + return