2
0
mirror of https://github.com/QuantumNous/new-api.git synced 2026-08-31 02:41:34 +00:00

Merge d99043bf1722c545d2e9bc1175117e18c81e6df4 into 66031a09d99f2ac4e0b94e2c41f04ed691a79304

This commit is contained in:
jeffer 2026-08-30 08:09:31 -04:00 committed by GitHub
commit 92492b0bc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 439 additions and 6 deletions

View File

@ -152,3 +152,37 @@ func GetLogsSelfStat(c *gin.Context) {
})
return
}
// GetExternalBillingStat returns per-user external-channel usage for all users (admin).
func GetExternalBillingStat(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
rows, err := model.SumExternalByUser(startTimestamp, endTimestamp, username)
if err != nil {
common.ApiError(c, err)
return
}
if rows == nil {
rows = []model.ExternalBillingRow{}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": rows})
return
}
// GetExternalBillingSelfStat returns the caller's own external-channel usage.
func GetExternalBillingSelfStat(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.GetString("username")
rows, err := model.SumExternalByUser(startTimestamp, endTimestamp, username)
if err != nil {
common.ApiError(c, err)
return
}
if rows == nil {
rows = []model.ExternalBillingRow{}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": rows})
return
}

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/types"
@ -707,6 +708,52 @@ func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelNa
return token
}
// ExternalBillingRow per-user external (third-party) channel usage aggregation.
type ExternalBillingRow struct {
Username string `json:"username"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
Quota int64 `json:"quota"`
ModelCount int64 `json:"model_count"`
}
// SumExternalByUser aggregates, per user, consumption on external channels
// (channels.tag = 'external') restricted to models that have an explicit
// configured price. startTimestamp/endTimestamp are unix seconds (0 = open).
func SumExternalByUser(startTimestamp int64, endTimestamp int64, username string) ([]ExternalBillingRow, error) {
priceKeys := ratio_setting.GetModelPriceMap()
if len(priceKeys) == 0 {
return nil, nil
}
priceList := make([]string, 0, len(priceKeys))
for k := range priceKeys {
priceList = append(priceList, k)
}
tx := LOG_DB.Table("logs").
Select("username, COALESCE(sum(prompt_tokens),0) prompt_tokens, COALESCE(sum(completion_tokens),0) completion_tokens, COALESCE(sum(prompt_tokens),0)+COALESCE(sum(completion_tokens),0) total_tokens, COALESCE(sum(quota),0) quota, count(DISTINCT model_name) model_count").
Joins("JOIN channels ON channels.id = logs.channel_id").
Where("channels.tag = ?", "external").
Where("logs.model_name IN ?", priceList).
Where("logs.type = ?", LogTypeConsume)
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("logs.created_at <= ?", endTimestamp)
}
if username != "" {
tx = tx.Where("logs.username = ?", username)
}
tx = tx.Group("logs.username")
var rows []ExternalBillingRow
if err := tx.Scan(&rows).Error; err != nil {
common.SysError("failed to query external billing stat: " + err.Error())
return nil, errors.New("查询外部渠道统计数据失败")
}
return rows, nil
}
func CountOldLog(ctx context.Context, targetTimestamp int64) (int64, error) {
var total int64
if err := LOG_DB.WithContext(ctx).Model(&Log{}).Where("created_at < ?", targetTimestamp).Count(&total).Error; err != nil {

View File

@ -292,6 +292,8 @@ func SetApiRouter(router *gin.Engine) {
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/stat/external", middleware.AdminAuth(), controller.GetExternalBillingStat)
logRoute.GET("/self/stat/external", middleware.UserAuth(), controller.GetExternalBillingSelfStat)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)

View File

@ -55,7 +55,7 @@ import {
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api'
import { getCodexUsage, updateChannelBalance, updateChannel } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatRelativeTime,
@ -584,6 +584,33 @@ export function BalanceCell({ channel }: { channel: Channel }) {
/**
* Generate channels columns configuration
*/
/**
* Clickable external/internal classifier for a channel (persisted into channels.tag).
*/
function ExternalTagToggle({ channel, tag }: { channel: Channel; tag: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const next = tag === 'external' ? 'internal' : 'external'
return (
<button
type='button'
onClick={async () => {
await updateChannel(channel.id, { tag: next })
queryClient.invalidateQueries({ queryKey: ['channels'] })
}}
title={t('Click to toggle external/internal')}
className='cursor-pointer'
>
<StatusBadge
label={tag === 'external' ? t('External') : t('Internal')}
variant={tag === 'external' ? 'warning' : 'success'}
size='sm'
className='-ml-1.5'
/>
</button>
)
}
export function useChannelsColumns(
options: {
enableSelection?: boolean
@ -1096,7 +1123,10 @@ export function useChannelsColumns(
if (!tag) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const orig = row.original as Channel
if (orig?.id && (tag === 'external' || tag === 'internal')) {
return <ExternalTagToggle channel={orig} tag={tag} />
}
return (
<StatusBadge
label={tag}

View File

@ -0,0 +1,50 @@
/* External billing API: per-user external (third-party) channel usage. */
import { api } from '@/lib/api'
export interface ExternalBillingRow {
username: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
quota: number
model_count: number
}
export interface ExternalBillingResponse {
success: boolean
message: string
data: ExternalBillingRow[]
}
function buildParams(startTimestamp: number, endTimestamp: number, username?: string) {
const p: Record<string, number | string> = {}
if (startTimestamp) p.start_timestamp = startTimestamp
if (endTimestamp) p.end_timestamp = endTimestamp
if (username) p.username = username
return p
}
/** Admin: all users' external usage. */
export async function fetchExternalBilling(
startTimestamp: number,
endTimestamp: number,
username?: string
): Promise<ExternalBillingResponse> {
const res = await api.get('/api/log/stat/external', {
params: buildParams(startTimestamp, endTimestamp, username),
})
return res.data
}
/** Member: own external usage. */
export async function fetchExternalBillingSelf(
startTimestamp: number,
endTimestamp: number
): Promise<ExternalBillingResponse> {
const res = await api.get('/api/log/self/stat/external', {
params: buildParams(startTimestamp, endTimestamp),
})
return res.data
}
export const QUOTA_PER_USD = 500000

View File

@ -28,6 +28,7 @@ import {
MessageSquare,
PlugZap,
Radio,
Receipt,
ServerCog,
Settings,
Ticket,
@ -109,6 +110,11 @@ export function useSidebarData(): SidebarData {
url: '/wallet',
icon: Wallet,
},
{
title: t('My External Usage'),
url: '/external-billing',
icon: Receipt,
},
{
title: t('Profile'),
url: '/profile',
@ -125,6 +131,11 @@ export function useSidebarData(): SidebarData {
url: '/channels',
icon: Radio,
},
{
title: t('External Billing'),
url: '/external-billing',
icon: Receipt,
},
{
title: t('Models'),
url: '/models/metadata',

View File

@ -2142,7 +2142,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "Related Projects",
"footer.defaultCopyright": "All rights reserved.",
"footer.new\u0061pi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.",
"footer.newapi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment",
"For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi",
"For security, existing access tokens cannot be displayed. Regenerate only when you need a new token.": "For security, existing access tokens cannot be displayed. Regenerate only when you need a new token.",
@ -5534,6 +5534,27 @@
"Zero retention": "Zero retention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"External Channel Billing": "External Channel Billing",
"External (third-party paid) channel usage per account. Models without a configured price are excluded.": "External (third-party paid) channel usage per account. Models without a configured price are excluded.",
"External Billing": "External Billing",
"My External Usage": "My External Usage",
"Click to toggle external/internal": "Click to toggle external/internal",
"Usage Summary": "Usage Summary",
"All time": "All time",
"Last 30 days": "Last 30 days",
"Last 7 days": "Last 7 days",
"Filter username…": "Filter username…",
"Accounts": "Accounts",
"Total external tokens": "Total external tokens",
"Total external spend": "Total external spend (USD)",
"Account": "Account",
"External tokens": "External tokens",
"Spend (USD)": "Spend (USD)",
"External models": "External models",
"No external usage in this range": "No external usage in this range",
"External": "External",
"Internal": "Internal",
"Loading…": "Loading…"
}
}

View File

@ -2142,7 +2142,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "相关项目",
"footer.defaultCopyright": "版权所有。",
"footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。",
"footer.newapi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"",
"For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署格式为https://fastgpt.run/api/openapi",
"For security, existing access tokens cannot be displayed. Regenerate only when you need a new token.": "出于安全考虑,现有访问令牌无法再次显示。仅在需要新令牌时重新生成。",
@ -5534,6 +5534,27 @@
"Zero retention": "零数据保留",
"Zhipu": "智谱",
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
"Zoom": "缩放",
"External Channel Billing": "外部渠道账单",
"External (third-party paid) channel usage per account. Models without a configured price are excluded.": "各账号外部(第三方付费)渠道用量。未配置单价的模型不计入。",
"Usage Summary": "用量汇总",
"All time": "全部时间",
"Last 30 days": "近 30 天",
"Last 7 days": "近 7 天",
"Filter username…": "筛选用户名…",
"Accounts": "账号数",
"Total external tokens": "外部总 Token",
"Total external spend": "外部总消费 (USD)",
"Account": "账号",
"External tokens": "外部 Token",
"Spend (USD)": "消费 (USD)",
"External models": "外部模型数",
"No external usage in this range": "该时间范围内无外部用量",
"External Billing": "外部账单",
"My External Usage": "我的外部用量",
"Click to toggle external/internal": "点击切换 外部/内部",
"External": "外部",
"Internal": "内部",
"Loading…": "加载中…"
}
}

View File

@ -39,6 +39,7 @@ import { Route as AuthenticatedChatChatIdRouteImport } from './routes/_authentic
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as AuthenticatedDashboardSectionRouteImport } from './routes/_authenticated/dashboard/$section'
import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
import { Route as AuthenticatedExternalBillingIndexRouteImport } from './routes/_authenticated/external-billing/index'
import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index'
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section'
@ -222,6 +223,12 @@ const AuthenticatedErrorsErrorRoute =
path: '/errors/$error',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedExternalBillingIndexRoute =
AuthenticatedExternalBillingIndexRouteImport.update({
id: '/external-billing/',
path: '/external-billing/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedKeysIndexRoute = AuthenticatedKeysIndexRouteImport.update({
id: '/keys/',
path: '/keys/',
@ -425,6 +432,7 @@ export interface FileRoutesByFullPath {
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/channels/': typeof AuthenticatedChannelsIndexRoute
'/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/external-billing/': typeof AuthenticatedExternalBillingIndexRoute
'/keys/': typeof AuthenticatedKeysIndexRoute
'/models/': typeof AuthenticatedModelsIndexRoute
'/playground/': typeof AuthenticatedPlaygroundIndexRoute
@ -483,6 +491,7 @@ export interface FileRoutesByTo {
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/channels': typeof AuthenticatedChannelsIndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/external-billing': typeof AuthenticatedExternalBillingIndexRoute
'/keys': typeof AuthenticatedKeysIndexRoute
'/models': typeof AuthenticatedModelsIndexRoute
'/playground': typeof AuthenticatedPlaygroundIndexRoute
@ -545,6 +554,7 @@ export interface FileRoutesById {
'/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/external-billing/': typeof AuthenticatedExternalBillingIndexRoute
'/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
'/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute
@ -606,6 +616,7 @@ export interface FileRouteTypes {
| '/usage-logs/$section'
| '/channels/'
| '/dashboard/'
| '/external-billing/'
| '/keys/'
| '/models/'
| '/playground/'
@ -664,6 +675,7 @@ export interface FileRouteTypes {
| '/usage-logs/$section'
| '/channels'
| '/dashboard'
| '/external-billing'
| '/keys'
| '/models'
| '/playground'
@ -725,6 +737,7 @@ export interface FileRouteTypes {
| '/_authenticated/usage-logs/$section'
| '/_authenticated/channels/'
| '/_authenticated/dashboard/'
| '/_authenticated/external-billing/'
| '/_authenticated/keys/'
| '/_authenticated/models/'
| '/_authenticated/playground/'
@ -985,6 +998,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedErrorsErrorRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/external-billing/': {
id: '/_authenticated/external-billing/'
path: '/external-billing'
fullPath: '/external-billing/'
preLoaderRoute: typeof AuthenticatedExternalBillingIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/keys/': {
id: '/_authenticated/keys/'
path: '/keys'
@ -1284,6 +1304,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute
AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
AuthenticatedExternalBillingIndexRoute: typeof AuthenticatedExternalBillingIndexRoute
AuthenticatedKeysIndexRoute: typeof AuthenticatedKeysIndexRoute
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute
@ -1308,6 +1329,8 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute,
AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
AuthenticatedExternalBillingIndexRoute:
AuthenticatedExternalBillingIndexRoute,
AuthenticatedKeysIndexRoute: AuthenticatedKeysIndexRoute,
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute,

View File

@ -0,0 +1,194 @@
/* External billing page: per-account external (third-party) channel usage. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useIsAdmin } from '@/hooks/use-admin'
import {
fetchExternalBilling,
fetchExternalBillingSelf,
QUOTA_PER_USD,
type ExternalBillingRow,
} from '@/features/external-billing/api'
export const Route = createFileRoute('/_authenticated/external-billing/')({
component: ExternalBillingPage,
})
type RangeKey = 'all' | '30d' | '7d' | 'custom'
function fmtTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function toTs(ms: number): number {
return Math.floor(ms / 1000)
}
function nowTs(): number {
return Math.floor(Date.now() / 1000)
}
export function ExternalBillingPage() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const [range, setRange] = useState<RangeKey>('all')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [username, setUsername] = useState('')
const { startTs, endTs } = useMemo(() => {
const now = nowTs()
if (range === '7d') return { startTs: now - 7 * 86400, endTs: 0 }
if (range === '30d') return { startTs: now - 30 * 86400, endTs: 0 }
if (range === 'custom') {
const s = from ? toTs(new Date(from).getTime()) : 0
const e = to ? toTs(new Date(to).getTime()) : 0
return { startTs: s, endTs: e }
}
return { startTs: 0, endTs: 0 }
}, [range, from, to])
const query = useQuery({
queryKey: ['external-billing', isAdmin, range, from, to, username],
queryFn: async () =>
isAdmin
? fetchExternalBilling(startTs, endTs, username || undefined)
: fetchExternalBillingSelf(startTs, endTs),
staleTime: 30_000,
})
const rows: ExternalBillingRow[] = useMemo(
() => (query.data?.data ?? []).slice().sort((a, b) => b.quota - a.quota),
[query.data]
)
const totalQuota = useMemo(() => rows.reduce((s, r) => s + r.quota, 0), [rows])
const totalTokens = useMemo(() => rows.reduce((s, r) => s + r.total_tokens, 0), [rows])
return (
<div className="p-4 sm:p-6 space-y-4">
<div>
<h1 className="text-xl font-semibold">{t('External Channel Billing')}</h1>
<p className="text-sm text-muted-foreground">
{t('External (third-party paid) channel usage per account. Models without a configured price are excluded.')}
</p>
</div>
<Card>
<CardHeader className="flex flex-row flex-wrap items-center gap-2 justify-between">
<CardTitle className="text-base">{t('Usage Summary')}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Select value={range} onValueChange={(v) => setRange(v as RangeKey)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All time')}</SelectItem>
<SelectItem value="30d">{t('Last 30 days')}</SelectItem>
<SelectItem value="7d">{t('Last 7 days')}</SelectItem>
<SelectItem value="custom">{t('Custom')}</SelectItem>
</SelectContent>
</Select>
{range === 'custom' && (
<>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="h-9 rounded-md border border-input px-2 text-sm"
/>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="h-9 rounded-md border border-input px-2 text-sm"
/>
</>
)}
{isAdmin && (
<input
type="text"
placeholder={t('Filter username…')}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-9 w-44 rounded-md border border-input px-2 text-sm"
/>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm">
<span>
{t('Accounts')} <b>{rows.length}</b>
</span>
<span>
{t('Total external tokens')} <b>{fmtTokens(totalTokens)}</b>
</span>
<span>
{t('Total external spend')} <b>{(totalQuota / QUOTA_PER_USD).toFixed(4)}</b>
</span>
{query.isFetching && <span className="text-muted-foreground">{t('Loading…')}</span>}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Account')}</TableHead>
<TableHead className="text-right">{t('External tokens')}</TableHead>
<TableHead className="text-right">{t('Quota')}</TableHead>
<TableHead className="text-right">{t('Spend (USD)')}</TableHead>
<TableHead className="text-right">{t('External models')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground h-16">
{t('No external usage in this range')}
</TableCell>
</TableRow>
)}
{rows.map((r) => (
<TableRow key={r.username}>
<TableCell className="font-medium">{r.username}</TableCell>
<TableCell className="text-right tabular-nums">{fmtTokens(r.total_tokens)}</TableCell>
<TableCell className="text-right tabular-nums">{r.quota.toLocaleString()}</TableCell>
<TableCell className="text-right tabular-nums">
{(r.quota / QUOTA_PER_USD).toFixed(4)}
</TableCell>
<TableCell className="text-right tabular-nums">{r.model_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}