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:
commit
92492b0bc0
@ -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
|
||||
}
|
||||
|
||||
47
model/log.go
47
model/log.go
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
50
web/src/features/external-billing/api.ts
Normal file
50
web/src/features/external-billing/api.ts
Normal 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
|
||||
@ -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',
|
||||
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…": "加载中…"
|
||||
}
|
||||
}
|
||||
|
||||
23
web/src/routeTree.gen.ts
generated
23
web/src/routeTree.gen.ts
generated
@ -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,
|
||||
|
||||
194
web/src/routes/_authenticated/external-billing/index.tsx
Normal file
194
web/src/routes/_authenticated/external-billing/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user