fix(usage): pricing routing, SSE lifecycle, and validation hardening

* model pricing routing: extend prefix-match families (gpt-/o1-o5/
  gemini-/deepseek-/qwen-/glm-/kimi-/minimax-) with per-family dash
  thresholds so short base IDs like gpt-5 no longer mis-match
  gpt-5-mini; strip ISO and 8-digit date suffixes via UTF-8-safe
  byte matching so claude-haiku-4-5-20251001 falls back to
  claude-haiku-4-5 pricing
* SSE collector: SseUsageFinishGuard (RAII) guarantees finish() on
  early return or panic; AtomicBool fast path lets push() skip the
  Mutex once first-event time is recorded
* validation: shared validate_cost_multiplier / validate_pricing_source
  helpers across DAO and service layers; PRICING_SOURCE_RESPONSE /
  PRICING_SOURCE_REQUEST constants replace string literals; price
  fields in update_model_pricing now reject empty / non-decimal /
  negative input before INSERT
* backfill: add backfill_missing_usage_costs_for_model so a single
  price edit only scans matching rows instead of the full log table;
  startup backfill remains full-scan
* session_usage{,_codex,_gemini}: share find_model_pricing helper from
  usage_stats; metadata_modified_nanos centralizes mtime precision
* frontend: NON_NEGATIVE_DECIMAL_REGEX + isNonNegativeDecimalString
  replace three copies of the same multiplier regex; isUnpricedUsage
  surfaces zero-cost rows that have usage tokens (cached per row to
  avoid double evaluation); invalidate usageKeys.all on pricing mutate
  so backfilled rows refresh
This commit is contained in:
Jason
2026-05-14 11:53:51 +08:00
parent 206125b4e3
commit 402570ce31
19 changed files with 590 additions and 375 deletions
+3 -2
View File
@@ -28,7 +28,7 @@ import {
} from "@/components/ui/select";
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { PricingEditModal } from "./PricingEditModal";
import type { ModelPricing } from "@/types/usage";
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
@@ -143,7 +143,7 @@ export function PricingConfigPanel() {
);
return;
}
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
if (!isNonNegativeDecimalString(trimmed)) {
toast.error(
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
);
@@ -281,6 +281,7 @@ export function PricingConfigPanel() {
<Input
type="number"
step="0.01"
min="0"
inputMode="decimal"
value={appConfigs[app].multiplier}
onChange={(e) =>
+2 -3
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUpdateModelPricing } from "@/lib/query/usage";
import type { ModelPricing } from "@/types/usage";
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
interface PricingEditModalProps {
open: boolean;
@@ -52,8 +52,7 @@ export function PricingEditModal({
];
for (const value of values) {
const num = parseFloat(value);
if (isNaN(num) || num < 0) {
if (!isNonNegativeDecimalString(value)) {
toast.error(t("usage.invalidPrice", "价格必须为非负数"));
return;
}
+10 -3
View File
@@ -6,7 +6,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useRequestDetail } from "@/lib/query/usage";
import { getFreshInputTokens } from "@/types/usage";
import { getFreshInputTokens, isUnpricedUsage } from "@/types/usage";
interface RequestDetailPanelProps {
requestId: string;
@@ -53,6 +53,7 @@ export function RequestDetailPanel({
const freshInput = getFreshInputTokens(request);
const isCacheInclusive = request.inputTokens !== freshInput;
const unpriced = isUnpricedUsage(request);
return (
<Dialog open onOpenChange={onClose}>
@@ -255,8 +256,14 @@ export function RequestDetailPanel({
</span>
)}
</dt>
<dd className="text-lg font-semibold text-primary">
${parseFloat(request.totalCostUsd).toFixed(6)}
<dd
className={`text-lg font-semibold ${
unpriced ? "text-muted-foreground" : "text-primary"
}`}
>
{unpriced
? t("usage.unpriced", "未定价")
: `$${parseFloat(request.totalCostUsd).toFixed(6)}`}
</dd>
</div>
</dl>
+117 -104
View File
@@ -21,6 +21,7 @@ import { useRequestLogs } from "@/lib/query/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
isUnpricedUsage,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
@@ -293,115 +294,127 @@ export function RequestLogTable({
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
{new Date(log.createdAt * 1000).toLocaleString(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell className="text-center">
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell className="text-center font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${log.requestModel}${log.model}`
: log.model
}
>
{log.requestModel &&
log.requestModel !== log.model ? (
<span>
{log.requestModel}
<span className="text-muted-foreground">
{" → "}
{log.model}
logs.map((log) => {
const unpriced = isUnpricedUsage(log);
return (
<TableRow key={log.requestId}>
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
{new Date(log.createdAt * 1000).toLocaleString(
locale,
{
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
},
)}
</TableCell>
<TableCell className="text-center">
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell className="text-center font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${log.requestModel}${log.model}`
: log.model
}
>
{log.requestModel &&
log.requestModel !== log.model ? (
<span>
{log.requestModel}
<span className="text-muted-foreground">
{" → "}
{log.model}
</span>
</span>
</span>
) : (
log.model
)}
</div>
</TableCell>
<TableCell className="text-center px-1.5">
{(() => {
const freshInput = getFreshInputTokens(log);
const isCacheInclusive =
log.inputTokens !== freshInput;
return (
<div
className="tabular-nums"
title={
isCacheInclusive
? `Raw: ${log.inputTokens.toLocaleString()}`
: undefined
}
>
{fmtInt(freshInput, locale)}
</div>
);
})()}
{(log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0) && (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{[
log.cacheReadTokens > 0 &&
`R${fmtInt(log.cacheReadTokens, locale)}`,
log.cacheCreationTokens > 0 &&
`W${fmtInt(log.cacheCreationTokens, locale)}`,
]
.filter(Boolean)
.join("·")}
) : (
log.model
)}
</div>
)}
</TableCell>
<TableCell className="text-center">
{fmtInt(log.outputTokens, locale)}
</TableCell>
<TableCell className="text-center px-1.5">
<div className="font-medium tabular-nums">
{fmtUsd(log.totalCostUsd, 4)}
</div>
{parseFiniteNumber(log.costMultiplier) != null &&
parseFiniteNumber(log.costMultiplier) !== 1 && (
<div className="text-[11px] text-muted-foreground">
×
{parseFiniteNumber(log.costMultiplier)?.toFixed(
2,
)}
</TableCell>
<TableCell className="text-center px-1.5">
{(() => {
const freshInput = getFreshInputTokens(log);
const isCacheInclusive =
log.inputTokens !== freshInput;
return (
<div
className="tabular-nums"
title={
isCacheInclusive
? `Raw: ${log.inputTokens.toLocaleString()}`
: undefined
}
>
{fmtInt(freshInput, locale)}
</div>
);
})()}
{(log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0) && (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{[
log.cacheReadTokens > 0 &&
`R${fmtInt(log.cacheReadTokens, locale)}`,
log.cacheCreationTokens > 0 &&
`W${fmtInt(log.cacheCreationTokens, locale)}`,
]
.filter(Boolean)
.join("·")}
</div>
)}
</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
{(log.latencyMs / 1000).toFixed(1)}s
{log.firstTokenMs != null && (
<span className="text-muted-foreground">
/{(log.firstTokenMs / 1000).toFixed(1)}s
</TableCell>
<TableCell className="text-center">
{fmtInt(log.outputTokens, locale)}
</TableCell>
<TableCell className="text-center px-1.5">
<div
className={`font-medium tabular-nums ${
unpriced ? "text-muted-foreground" : ""
}`}
>
{unpriced
? t("usage.unpriced", "未定价")
: fmtUsd(log.totalCostUsd, 4)}
</div>
{parseFiniteNumber(log.costMultiplier) != null &&
parseFiniteNumber(log.costMultiplier) !== 1 && (
<div className="text-[11px] text-muted-foreground">
×
{parseFiniteNumber(log.costMultiplier)?.toFixed(
2,
)}
</div>
)}
</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
{(log.latencyMs / 1000).toFixed(1)}s
{log.firstTokenMs != null && (
<span className="text-muted-foreground">
/{(log.firstTokenMs / 1000).toFixed(1)}s
</span>
)}
</TableCell>
<TableCell className="text-center">
<span
className={
log.statusCode >= 200 && log.statusCode < 300
? "text-green-600"
: "text-red-600"
}
>
{log.statusCode}
</span>
)}
</TableCell>
<TableCell className="text-center">
<span
className={
log.statusCode >= 200 && log.statusCode < 300
? "text-green-600"
: "text-red-600"
}
>
{log.statusCode}
</span>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{log.dataSource || "proxy"}
</TableCell>
</TableRow>
))
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{log.dataSource || "proxy"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>