From 594171108dac3e99be09a4790f673e2573def9fb Mon Sep 17 00:00:00 2001 From: luji Date: Sun, 10 May 2026 21:51:44 -0700 Subject: [PATCH 01/20] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=87=AA=E7=94=A8?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 59239f0a4c3e..d15fe540c19d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@
+测试下这是我自己的修改 ![new-api](/web/default/public/logo.png) From f7eb8d7bca0d10481cdb6dc9105081b0812f5b17 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 04:13:28 -0700 Subject: [PATCH 02/20] fix: preserve startup data loading when skipping migrations SKIP_DB_MIGRATION now only skips schema migration and validation while still loading database-backed runtime data such as options, pricing, channel cache, custom OAuth providers, and setup status. When migration is skipped, setup status is loaded without creating a missing setup record, so existing databases with root users are treated as initialized without mutating schema-related state. Also fix the user deletion endpoint to return a real API error on delete failure and a success JSON response on successful deletion, preventing the default frontend from showing a false delete failure toast after the user is removed. Verified with: go test ./controller Co-Authored-By: Claude Opus 4.7 --- controller/user.go | 9 +++---- main.go | 7 +++++- model/main.go | 58 +++++++++++++++++++++++++++++++++++++++------- model/option.go | 6 +++-- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/controller/user.go b/controller/user.go index b5722668632d..4b20d6f51c4f 100644 --- a/controller/user.go +++ b/controller/user.go @@ -773,12 +773,13 @@ func DeleteUser(c *gin.Context) { } err = model.HardDeleteUserById(id) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - }) + common.ApiError(c, err) return } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) } func DeleteSelf(c *gin.Context) { diff --git a/main.go b/main.go index 3361b8ce9338..c18b16c338fa 100644 --- a/main.go +++ b/main.go @@ -284,7 +284,12 @@ func InitResources() error { return err } - model.CheckSetup() + if model.SkipDBMigration() { + common.SysLog("database schema validation skipped by SKIP_DB_MIGRATION") + model.LoadSetupStatus() + } else { + model.CheckSetup() + } // Initialize options, should after model.InitDB() model.InitOptionMap() diff --git a/model/main.go b/model/main.go index 16cd373fb203..e7cac62b9877 100644 --- a/model/main.go +++ b/model/main.go @@ -89,19 +89,31 @@ func createRootAccountIfNeed() error { } func CheckSetup() { + checkSetup(true) +} + +func LoadSetupStatus() { + checkSetup(false) +} + +func checkSetup(createMissingSetup bool) { setup := GetSetup() if setup == nil { // No setup record exists, check if we have a root user if RootUserExists() { - common.SysLog("system is not initialized, but root user exists") - // Create setup record - newSetup := Setup{ - Version: common.Version, - InitializedAt: time.Now().Unix(), - } - err := DB.Create(&newSetup).Error - if err != nil { - common.SysLog("failed to create setup record: " + err.Error()) + if createMissingSetup { + common.SysLog("system is not initialized, but root user exists") + // Create setup record + newSetup := Setup{ + Version: common.Version, + InitializedAt: time.Now().Unix(), + } + err := DB.Create(&newSetup).Error + if err != nil { + common.SysLog("failed to create setup record: " + err.Error()) + } + } else { + common.SysLog("setup record not found, treating existing root user as initialized") } constant.Setup = true } else { @@ -174,7 +186,24 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, error) { }) } +func SkipDBMigration() bool { + value := strings.TrimSpace(os.Getenv("SKIP_DB_MIGRATION")) + if value == "" { + return false + } + switch strings.ToLower(value) { + case "1", "t", "true", "y", "yes", "on": + return true + case "0", "f", "false", "n", "no", "off": + return false + default: + common.SysError(fmt.Sprintf("failed to parse SKIP_DB_MIGRATION: %s, using default value: false", value)) + return false + } +} + func InitDB() (err error) { + skipMigration := SkipDBMigration() db, err := chooseDB("SQL_DSN", false) if err == nil { if common.DebugEnabled { @@ -195,6 +224,11 @@ func InitDB() (err error) { sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + if skipMigration { + common.SysLog("database migration skipped by SKIP_DB_MIGRATION") + return nil + } + if !common.IsMasterNode { return nil } @@ -215,6 +249,7 @@ func InitLogDB() (err error) { LOG_DB = DB return } + skipMigration := SkipDBMigration() db, err := chooseDB("LOG_SQL_DSN", true) if err == nil { if common.DebugEnabled { @@ -235,6 +270,11 @@ func InitLogDB() (err error) { sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + if skipMigration { + common.SysLog("log database migration skipped by SKIP_DB_MIGRATION") + return nil + } + if !common.IsMasterNode { return nil } diff --git a/model/option.go b/model/option.go index e0a3048d34f2..a9a7d7f90739 100644 --- a/model/option.go +++ b/model/option.go @@ -26,7 +26,7 @@ func AllOption() ([]*Option, error) { return options, err } -func InitOptionMap() { +func InitOptionMap(loadFromDatabase ...bool) { common.OptionMapRWMutex.Lock() common.OptionMap = make(map[string]string) @@ -185,7 +185,9 @@ func InitOptionMap() { } common.OptionMapRWMutex.Unlock() - loadOptionsFromDatabase() + if len(loadFromDatabase) == 0 || loadFromDatabase[0] { + loadOptionsFromDatabase() + } } func loadOptionsFromDatabase() { From a29c60cdf3c94c51ce05231a0b297a346f21eaa4 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 04:19:45 -0700 Subject: [PATCH 03/20] feat(default): improve dashboard and chat navigation Update the default frontend with several UI improvements: fix invitation handling in the new sign-up UI, add token-focused dashboard charts, move the wallet invitation section to a more prominent position, and flatten chat presets into first-level sidebar navigation entries. Also update Bun lockfile metadata generated by the frontend package manager. Co-Authored-By: Claude Opus 4.7 --- web/classic/bun.lock | 1 + web/default/bun.lock | 1 + .../layout/components/chat-presets-item.tsx | 153 +----- .../layout/components/nav-group.tsx | 3 +- .../auth/sign-up/components/sign-up-form.tsx | 2 +- web/default/src/features/auth/types.ts | 2 +- .../models/consumption-distribution-chart.tsx | 91 +++- .../components/users/user-charts.tsx | 69 ++- .../src/features/dashboard/lib/charts.ts | 463 +++++++++++++++++- web/default/src/features/dashboard/types.ts | 7 + web/default/src/features/wallet/index.tsx | 14 +- .../src/features/wallet/lib/affiliate.ts | 2 +- 12 files changed, 620 insertions(+), 188 deletions(-) diff --git a/web/classic/bun.lock b/web/classic/bun.lock index da3c1e452a9d..4f109c348221 100644 --- a/web/classic/bun.lock +++ b/web/classic/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-template", diff --git a/web/default/bun.lock b/web/default/bun.lock index f9dc0300f2bb..bf4639e6cceb 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "newapi-web", diff --git a/web/default/src/components/layout/components/chat-presets-item.tsx b/web/default/src/components/layout/components/chat-presets-item.tsx index 0a35e49c66f1..d781a43d3040 100644 --- a/web/default/src/components/layout/components/chat-presets-item.tsx +++ b/web/default/src/components/layout/components/chat-presets-item.tsx @@ -18,26 +18,12 @@ For commercial licensing, please contact support@quantumnous.com */ import { useMemo, useCallback, useRef, useState } from 'react' import { Link, useLocation } from '@tanstack/react-router' -import { ExternalLink, Loader2, ChevronRight } from 'lucide-react' +import { ExternalLink, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { SidebarMenuButton, SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, useSidebar, } from '@/components/ui/sidebar' import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key' @@ -48,11 +34,7 @@ import { type ChatPreset, } from '@/features/chat/lib/chat-links' import { normalizeHref } from '../lib/url-utils' -import type { NavChatPresets } from '../types' -/** - * Sub-menu item for a single chat preset - */ function ChatMenuItem({ preset, active, @@ -68,8 +50,9 @@ function ChatMenuItem({ }) { if (preset.type === 'web') { return ( - - + {preset.name} - - + + ) } return ( - - + { if (!loading) void onOpen(preset) }} @@ -101,57 +85,15 @@ function ChatMenuItem({ ) : ( )} - - - ) -} - -/** - * Dropdown menu item for a single chat preset - */ -function DropdownPresetItem({ - preset, - loading, - onOpen, -}: { - preset: ChatPreset - loading: boolean - onOpen: (preset: ChatPreset) => void | Promise -}) { - if (preset.type === 'web') { - return ( - } - > - {preset.name} - - ) - } - - return ( - { - if (!loading) void onOpen(preset) - }} - > - {preset.name} - {loading ? ( - - ) : ( - - )} - + + ) } -/** - * Dynamic chat presets navigation item - */ -export function ChatPresetsItem({ item }: { item: NavChatPresets }) { +export function ChatPresetsItem() { const { t } = useTranslation() const { chatPresets, serverAddress } = useChatPresets() - const { state, isMobile, setOpenMobile } = useSidebar() + const { setOpenMobile } = useSidebar() const href = useLocation({ select: (location) => location.href }) const [loadingPresetId, setLoadingPresetId] = useState(null) const loadingPresetIdRef = useRef(null) @@ -214,67 +156,22 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { const normalizedHref = normalizeHref(href) - // Don't render if no visible presets if (visiblePresets.length === 0) { return null } - // Collapsed state on non-mobile - render dropdown menu - if (state === 'collapsed' && !isMobile) { - return ( - - - } - > - {item.icon && } - {item.title} - - - - {visiblePresets.map((preset) => ( - - ))} - - - - ) - } - - // Expanded state - render collapsible menu return ( - } - > - } - > - {item.icon && } - {item.title} - - - - - {visiblePresets.map((preset) => ( - setOpenMobile(false)} - /> - ))} - - - + <> + {visiblePresets.map((preset) => ( + setOpenMobile(false)} + /> + ))} + ) } diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index c1688acf095e..860c651e67f9 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -48,7 +48,6 @@ import { import { checkIsActive } from '../lib/url-utils' import { type NavCollapsible, - type NavChatPresets, type NavLink, type NavGroup as NavGroupProps, } from '../types' @@ -73,7 +72,7 @@ export function NavGroup({ title, items }: NavGroupProps) { // Special handling: dynamic chat presets list if (item.type === 'chat-presets') { - return + return } // If no sub-items, render regular link diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index b3a5813d7f48..b5ebd0b8bc40 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -155,7 +155,7 @@ export function SignUpForm({ password: data.password, email: data.email || undefined, verification_code: verificationCode || undefined, - aff: getAffiliateCode(), + aff_code: getAffiliateCode(), turnstile: turnstileToken, }) diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index b429e20c250b..60aedd97b067 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -37,7 +37,7 @@ export interface RegisterPayload { password: string email?: string verification_code?: string - aff?: string + aff_code?: string turnstile?: string } diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx index 6d09b5e3c7c6..293637bb18fc 100644 --- a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useRef, useState } from 'react' import { VChart } from '@visactor/react-vchart' -import { AreaChart, BarChart3, WalletCards } from 'lucide-react' +import { AreaChart, BarChart3, Coins, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useThemeRadiusPx } from '@/lib/theme-radius' import type { TimeGranularity } from '@/lib/time' @@ -32,6 +32,7 @@ import { import { processChartData } from '@/features/dashboard/lib' import type { ConsumptionDistributionChartType, + ConsumptionDistributionMetric, QuotaDataItem, } from '@/features/dashboard/types' @@ -54,6 +55,15 @@ const CHART_TYPE_ICONS: Record< area: AreaChart, } +const METRIC_OPTIONS: { + value: ConsumptionDistributionMetric + labelKey: string + icon: typeof WalletCards +}[] = [ + { value: 'quota', labelKey: 'Amount', icon: WalletCards }, + { value: 'tokens', labelKey: 'Tokens', icon: Coins }, +] + export function ConsumptionDistributionChart( props: ConsumptionDistributionChartProps ) { @@ -67,6 +77,8 @@ export function ConsumptionDistributionChart( const [chartType, setChartType] = useState( props.defaultChartType ?? 'bar' ) + const [metric, setMetric] = + useState('quota') const [themeReady, setThemeReady] = useState(false) const themeManagerRef = useRef< (typeof import('@visactor/vchart'))['ThemeManager'] | null @@ -114,9 +126,21 @@ export function ConsumptionDistributionChart( chartRadius, ] ) - const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area + const spec = + metric === 'tokens' + ? chartType === 'bar' + ? chartData.spec_token_line + : chartData.spec_token_area + : chartType === 'bar' + ? chartData.spec_line + : chartData.spec_area + const totalDisplay = + metric === 'tokens' + ? chartData.totalTokensDisplay + : chartData.totalQuotaDisplay const specType = typeof spec?.type === 'string' ? spec.type : chartType const chartKey = [ + metric, chartType, specType, props.loading ? 'loading' : 'ready', @@ -132,29 +156,52 @@ export function ConsumptionDistributionChart(
{t('Quota Distribution')}
- {t('Total:')} {chartData.totalQuotaDisplay} + {t('Total:')} {totalDisplay}
-
- {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => { - const Icon = CHART_TYPE_ICONS[item.value] - return ( - - ) - })} +
+
+ {METRIC_OPTIONS.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+ +
+ {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => { + const Icon = CHART_TYPE_ICONS[item.value] + return ( + + ) + })} +
diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 9ddbf80515d6..76d50bfdffba 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useEffect, useMemo, useState, useRef, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { VChart } from '@visactor/react-vchart' -import { Users, Loader2 } from 'lucide-react' +import { Users, Loader2, Coins, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getRollingDateRange, type TimeGranularity } from '@/lib/time' import { VCHART_OPTION } from '@/lib/vchart' @@ -37,7 +37,10 @@ import { saveGranularity, processUserChartData, } from '@/features/dashboard/lib' -import type { ProcessedUserChartData } from '@/features/dashboard/types' +import type { + ConsumptionDistributionMetric, + ProcessedUserChartData, +} from '@/features/dashboard/types' let themeManagerPromise: Promise< (typeof import('@visactor/vchart'))['ThemeManager'] @@ -46,20 +49,35 @@ let themeManagerPromise: Promise< const USER_CHARTS: { value: string labelKey: string - specKey: keyof ProcessedUserChartData + specKeys: Record }[] = [ { value: 'rank', labelKey: 'User Consumption Ranking', - specKey: 'spec_user_rank', + specKeys: { + quota: 'spec_user_rank', + tokens: 'spec_user_token_rank', + }, }, { value: 'trend', labelKey: 'User Consumption Trend', - specKey: 'spec_user_trend', + specKeys: { + quota: 'spec_user_trend', + tokens: 'spec_user_token_trend', + }, }, ] +const METRIC_OPTIONS: { + value: ConsumptionDistributionMetric + labelKey: string + icon: typeof WalletCards +}[] = [ + { value: 'quota', labelKey: 'Amount', icon: WalletCards }, + { value: 'tokens', labelKey: 'Tokens', icon: Coins }, +] + const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50] export function UserCharts() { @@ -78,6 +96,8 @@ export function UserCharts() { getDefaultDays(timeGranularity) ) const [topUserLimit, setTopUserLimit] = useState(10) + const [metric, setMetric] = + useState('quota') const [timeRange, setTimeRange] = useState(() => { const days = getDefaultDays(timeGranularity) const { start, end } = getRollingDateRange(days) @@ -210,6 +230,27 @@ export function UserCharts() { ))} +
+ {METRIC_OPTIONS.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+ {isLoading && ( )} @@ -217,7 +258,21 @@ export function UserCharts() {
{USER_CHARTS.map((chart) => { - const spec = chartData[chart.specKey] + const spec = chartData[chart.specKeys[metric]] + const specType = typeof spec?.type === 'string' ? spec.type : chart.value + const chartKey = [ + 'user', + chart.value, + metric, + specType, + isLoading ? 'loading' : 'ready', + userData?.length ?? 0, + topUserLimit, + timeGranularity, + selectedRange, + resolvedTheme, + customization.preset, + ].join('-') return (
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) + const formatTokens = (value: number) => + Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4) const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2) @@ -211,6 +213,24 @@ export function processChartData( stack: true, legends: { visible: true, selectMode: 'single' }, }, + spec_token_line: { + type: 'bar', + data: [{ id: 'tokenBarData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + }, + spec_token_area: { + type: 'area', + data: [{ id: 'tokenAreaData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + }, spec_model_line: { type: 'area', data: [{ id: 'lineData', values: [] }], @@ -236,6 +256,7 @@ export function processChartData( }, }, totalQuotaDisplay: formatQuotaTotal(0), + totalTokensDisplay: formatTokens(0), totalCountDisplay: formatInt(0), } } @@ -334,6 +355,10 @@ export function processChartData( (sum, x) => sum + (Number(x.quota) || 0), 0 ) + const totalTokens = Array.from(modelTotalsMap.values()).reduce( + (sum, x) => sum + (Number(x.tokens) || 0), + 0 + ) // Pie chart (model call count proportion) const pieValues = Array.from(modelTotalsMap.entries()) @@ -417,6 +442,122 @@ export function processChartData( }) areaValues.sort((a, b) => a.Time.localeCompare(b.Time)) + const tokenLineValues: Array<{ + Time: string + Model: string + Tokens: number + TimeSum: number + }> = [] + + chartTimes.forEach((time) => { + let timeData = sortedModels.map((model) => { + const stats = timeModelMap.get(time)?.get(model) + const tokens = Number(stats?.tokens) || 0 + return { + Time: time, + Model: model, + Tokens: tokens, + TimeSum: 0, + } + }) + + const timeSum = timeData.reduce((sum, item) => sum + item.Tokens, 0) + timeData.sort((a, b) => b.Tokens - a.Tokens) + timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })) + tokenLineValues.push(...timeData) + }) + tokenLineValues.sort((a, b) => a.Time.localeCompare(b.Time)) + + const rankedTokenModels = Array.from(modelTotalsMap.entries()) + .map(([model, stats]) => ({ + Model: model, + Tokens: Number(stats.tokens) || 0, + })) + .sort((a, b) => b.Tokens - a.Tokens) + const topTokenAreaModels = new Set( + rankedTokenModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model) + ) + + const tokenAreaValues: typeof tokenLineValues = [] + chartTimes.forEach((time) => { + const buckets = new Map() + const modelMap = timeModelMap.get(time) + let timeSum = 0 + sortedModels.forEach((model) => { + const tokens = Number(modelMap?.get(model)?.tokens) || 0 + timeSum += tokens + const key = topTokenAreaModels.has(model) ? model : otherLabel + const prev = buckets.get(key) || { tokens: 0 } + buckets.set(key, { tokens: prev.tokens + tokens }) + }) + for (const [model, vals] of buckets) { + tokenAreaValues.push({ + Time: time, + Model: model, + Tokens: vals.tokens, + TimeSum: timeSum, + }) + } + }) + tokenAreaValues.sort((a, b) => a.Time.localeCompare(b.Time)) + + const makeTokenTooltipDimensionUpdateContent = (options?: { + collapseOverflow?: boolean + }) => { + const collapseOverflow = options?.collapseOverflow ?? true + + return (array: TooltipLineItem[]) => { + const modelItems = array.filter((item) => !isOtherTooltipKey(item.key)) + const otherItems = array.filter((item) => isOtherTooltipKey(item.key)) + modelItems.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)) + array = [...modelItems, ...otherItems] + + let sum = 0 + for (let i = 0; i < array.length; i++) { + const v = Number(array[i].value) || 0 + if ( + array[i].datum && + (array[i].datum as Record)?.TimeSum + ) { + sum = + Number((array[i].datum as Record)?.TimeSum) || sum + } + array[i].value = formatTokens(v) + } + + if (collapseOverflow && array.length > MAX_TOOLTIP_MODELS) { + const visible = modelItems.slice(0, MAX_TOOLTIP_MODELS) + const otherSum = [ + ...modelItems.slice(MAX_TOOLTIP_MODELS), + ...otherItems, + ].reduce((sum, item) => { + const rawValue = item.datum + ? Number((item.datum as Record)?.Tokens) || 0 + : Number(item.value) || 0 + return sum + rawValue + }, 0) + array = [ + ...visible, + { + key: otherLabel, + value: formatTokens(otherSum), + hasShape: true, + shapeType: 'square', + shapeFill: otherTooltipColor, + shapeStroke: otherTooltipColor, + shapeSize: 8, + }, + ] + } + + array.unshift({ + key: tt('Total:'), + value: formatTokens(sum), + }) + return array + } + } + // Line chart: model call trend (top models + "Other" bucket) const MAX_TREND_MODELS = 20 const rankedTrendModels = Array.from(modelTotalsMap.entries()) @@ -605,6 +746,92 @@ export function processChartData( background: { fill: 'transparent' }, animation: true, }, + spec_token_line: { + type: 'bar', + data: [{ id: 'tokenBarData', values: tokenLineValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + color: modelColor, + bar: { + state: { + hover: { stroke: '#000', lineWidth: 1 }, + }, + }, + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: makeTokenTooltipDimensionUpdateContent(), + }, + }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_token_area: { + type: 'area', + data: [{ id: 'tokenAreaData', values: tokenAreaValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: false, + legends: { visible: true, selectMode: 'single' }, + color: modelColor, + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: makeTokenTooltipDimensionUpdateContent({ + collapseOverflow: false, + }), + }, + }, + area: { + style: { + fillOpacity: 0.08, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, + background: { fill: 'transparent' }, + animation: true, + }, spec_model_line: { type: 'area', data: [{ id: 'lineData', values: modelLineValues }], @@ -715,6 +942,7 @@ export function processChartData( animation: true, }, totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw), + totalTokensDisplay: formatTokens(totalTokens), totalCountDisplay: formatInt(totalTimes), } } @@ -752,6 +980,8 @@ export function processUserChartData( : USER_COLOR_FALLBACKS const formatVal = (raw: number) => renderQuotaCompat(raw, 2) + const formatTokens = (value: number) => + Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) const emptyResult: ProcessedUserChartData = { spec_user_rank: { @@ -786,39 +1016,92 @@ export function processUserChartData( point: { visible: false }, background: { fill: 'transparent' }, }, + spec_user_token_rank: { + type: 'bar', + data: [{ id: 'userTokenRankData', values: [] }], + xField: 'Tokens', + yField: 'User', + seriesField: 'User', + direction: 'horizontal', + title: { + visible: true, + text: tt('User Token Consumption Ranking'), + subtext: tt('No data available'), + }, + legends: { visible: false }, + color: { type: 'ordinal', range: userColorRange }, + background: { fill: 'transparent' }, + }, + spec_user_token_trend: { + type: 'area', + data: [{ id: 'userTokenTrendData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'User', + title: { + visible: true, + text: tt('User Token Consumption Trend'), + subtext: tt('No data available'), + }, + legends: { visible: true, selectMode: 'single' }, + color: { type: 'ordinal', range: userColorRange }, + point: { visible: false }, + background: { fill: 'transparent' }, + }, } if (!data || data.length === 0) return emptyResult - const userQuotaTotal = new Map() + const userQuotaTotal = new Map() data.forEach((item) => { const username = item.username || 'unknown' - const prev = userQuotaTotal.get(username) || 0 - userQuotaTotal.set(username, prev + (Number(item.quota) || 0)) + const prev = userQuotaTotal.get(username) || { quota: 0, tokens: 0 } + userQuotaTotal.set(username, { + quota: prev.quota + (Number(item.quota) || 0), + tokens: prev.tokens + (Number(item.token_used) || 0), + }) }) const sorted = Array.from(userQuotaTotal.entries()).sort( - (a, b) => b[1] - a[1] + (a, b) => b[1].quota - a[1].quota + ) + const sortedByTokens = Array.from(userQuotaTotal.entries()).sort( + (a, b) => b[1].tokens - a[1].tokens ) const topUsers = sorted.slice(0, limit).map(([u]) => u) - const topUserSet = new Set(topUsers) - const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0) + const topTokenUsers = sortedByTokens.slice(0, limit).map(([u]) => u) + const topUserSet = new Set([...topUsers, ...topTokenUsers]) + const totalQuota = sorted + .slice(0, limit) + .reduce((s, [, stats]) => s + stats.quota, 0) + const totalTokens = sortedByTokens + .slice(0, limit) + .reduce((s, [, stats]) => s + stats.tokens, 0) - const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({ + const rankValues = sorted.slice(0, limit).map(([username, stats]) => ({ User: username, - rawQuota: quota, - Usage: Number((quota / quotaPerUnit).toFixed(4)), + rawQuota: stats.quota, + Usage: Number((stats.quota / quotaPerUnit).toFixed(4)), })) - const userColorMap = topUsers.reduce>( - (acc, user, i) => { - acc[user] = userColorRange[i % userColorRange.length] - return acc - }, - {} - ) + const tokenRankValues = sortedByTokens + .slice(0, limit) + .map(([username, stats]) => ({ + User: username, + Tokens: stats.tokens, + })) + + const userColorMap = Array.from(new Set([...topUsers, ...topTokenUsers])).reduce< + Record + >((acc, user, i) => { + acc[user] = userColorRange[i % userColorRange.length] + return acc + }, {}) - const timeUserMap = new Map>() + const timeUserMap = new Map< + string, + Map + >() const allTimePoints = new Set() data.forEach((item) => { @@ -829,7 +1112,11 @@ export function processUserChartData( if (!topUserSet.has(user)) return if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map()) const map = timeUserMap.get(timeKey)! - map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0)) + const prev = map.get(user) || { quota: 0, tokens: 0 } + map.set(user, { + quota: prev.quota + (Number(item.quota) || 0), + tokens: prev.tokens + (Number(item.token_used) || 0), + }) }) const sortedTimePoints = Array.from(allTimePoints).sort() @@ -842,7 +1129,7 @@ export function processUserChartData( sortedTimePoints.forEach((time) => { topUsers.forEach((user) => { - const q = timeUserMap.get(time)?.get(user) || 0 + const q = timeUserMap.get(time)?.get(user)?.quota || 0 trendValues.push({ Time: time, User: user, @@ -852,6 +1139,22 @@ export function processUserChartData( }) }) + const tokenTrendValues: Array<{ + Time: string + User: string + Tokens: number + }> = [] + + sortedTimePoints.forEach((time) => { + topTokenUsers.forEach((user) => { + tokenTrendValues.push({ + Time: time, + User: user, + Tokens: timeUserMap.get(time)?.get(user)?.tokens || 0, + }) + }) + }) + return { spec_user_rank: { type: 'bar', @@ -990,5 +1293,127 @@ export function processUserChartData( background: { fill: 'transparent' }, animation: true, }, + spec_user_token_rank: { + type: 'bar', + data: [{ id: 'userTokenRankData', values: tokenRankValues }], + xField: 'Tokens', + yField: 'User', + seriesField: 'User', + direction: 'horizontal', + title: { + visible: true, + text: tt('User Token Consumption Ranking'), + subtext: `${tt('Total:')} ${formatTokens(totalTokens)}`, + }, + legends: { visible: false }, + bar: { + state: { hover: { stroke: '#000', lineWidth: 1 } }, + }, + label: { + visible: true, + position: 'outside', + formatMethod: (value: number) => formatTokens(value), + style: { fontSize: 11 }, + }, + axes: [ + { orient: 'left', type: 'band' }, + { orient: 'bottom', type: 'linear', visible: false }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + }, + color: { specified: userColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_user_token_trend: { + type: 'area', + data: [{ id: 'userTokenTrendData', values: tokenTrendValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'User', + stack: false, + title: { + visible: true, + text: tt('User Token Consumption Trend'), + subtext: `${tt('Total:')} ${formatTokens(totalTokens)}`, + }, + legends: { visible: true, selectMode: 'single' }, + axes: [ + { orient: 'bottom', type: 'band' }, + { + orient: 'left', + type: 'linear', + label: { + formatMethod: (value: number) => formatTokens(value), + }, + }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: ( + array: Array<{ + key: string + value: string | number + }> + ) => { + array.sort( + (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) + ) + let sum = 0 + for (let i = 0; i < array.length; i++) { + const v = Number(array[i].value) || 0 + sum += v + array[i].value = formatTokens(v) + } + array.unshift({ + key: tt('Total:'), + value: formatTokens(sum), + }) + return array + }, + }, + }, + area: { + style: { + fillOpacity: 0.15, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, + color: { specified: userColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, } } diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index ad002e3c3045..171d61d160dc 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -62,6 +62,8 @@ export interface DashboardFilters { export type ConsumptionDistributionChartType = 'bar' | 'area' +export type ConsumptionDistributionMetric = 'quota' | 'tokens' + export type ModelAnalyticsChartTab = 'trend' | 'proportion' | 'top' export interface DashboardChartPreferences { @@ -101,15 +103,20 @@ export interface ProcessedChartData { spec_pie: VChartSpec spec_line: VChartSpec spec_area: VChartSpec + spec_token_line: VChartSpec + spec_token_area: VChartSpec spec_model_line: VChartSpec spec_rank_bar: VChartSpec totalQuotaDisplay: string + totalTokensDisplay: string totalCountDisplay: string } export interface ProcessedUserChartData { spec_user_rank: VChartSpec spec_user_trend: VChartSpec + spec_user_token_rank: VChartSpec + spec_user_token_trend: VChartSpec } // ============================================================================ diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index 664ded8b1648..adb09fee72d9 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -268,6 +268,13 @@ export function Wallet(props: WalletProps) {
+ setTransferDialogOpen(true)} + loading={affiliateLoading} + /> +
- - setTransferDialogOpen(true)} - loading={affiliateLoading} - />
diff --git a/web/default/src/features/wallet/lib/affiliate.ts b/web/default/src/features/wallet/lib/affiliate.ts index da6556b27eb7..3e4a12f8ba31 100644 --- a/web/default/src/features/wallet/lib/affiliate.ts +++ b/web/default/src/features/wallet/lib/affiliate.ts @@ -25,5 +25,5 @@ For commercial licensing, please contact support@quantumnous.com */ export function generateAffiliateLink(affCode: string): string { if (typeof window === 'undefined') return '' - return `${window.location.origin}/register?aff=${affCode}` + return `${window.location.origin}/sign-up?aff=${affCode}` } From bb8cc4f0d3e3d45876f39275491d74567dfbd18c Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 05:08:55 -0700 Subject: [PATCH 04/20] feat(default): auto-open unread announcements Open the existing announcement dialog when users enter the site with unread notice or timeline items, while avoiding repeated prompts for the same unread batch. Co-Authored-By: Claude Opus 4.7 --- web/default/src/hooks/use-notifications.ts | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/web/default/src/hooks/use-notifications.ts b/web/default/src/hooks/use-notifications.ts index fe63ed99f7ae..d48d65334445 100644 --- a/web/default/src/hooks/use-notifications.ts +++ b/web/default/src/hooks/use-notifications.ts @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { useNotificationStore } from '@/stores/notification-store' import { getNotice } from '@/lib/api' @@ -57,6 +57,8 @@ function getAnnouncementKey(item: Record): string { return `hash:${hashString(fingerprint)}` } +const autoOpenedNotificationSignatures = new Set() + /** * Hook to manage notifications (Notice + Announcements) * Provides unread counts and read status management @@ -66,6 +68,7 @@ export function useNotifications() { const [activeTab, setActiveTab] = useState<'notice' | 'announcements'>( 'notice' ) + const autoOpenRef = useRef(null) // Fetch Notice from API const { @@ -120,6 +123,23 @@ export function useNotifications() { } }, [noticeContent, lastReadNotice, announcements, isAnnouncementRead]) + const unreadAnnouncementKeys = useMemo( + () => + announcements + .map((item: Record) => getAnnouncementKey(item)) + .filter((key) => key && !isAnnouncementRead(key)), + [announcements, isAnnouncementRead] + ) + + const autoOpenSignature = useMemo(() => { + if (unreadCounts.total === 0) return '' + + return JSON.stringify({ + notice: unreadCounts.notice > 0 ? noticeContent : '', + announcements: unreadAnnouncementKeys, + }) + }, [noticeContent, unreadAnnouncementKeys, unreadCounts]) + // Handle dialog open const handleOpenDialog = (tab?: 'notice' | 'announcements') => { // Mark Notice as read when opening dialog @@ -131,6 +151,31 @@ export function useNotifications() { setDialogOpen(true) } + useEffect(() => { + if ( + noticeLoading || + statusLoading || + dialogOpen || + !autoOpenSignature || + isNoticeClosed() || + autoOpenRef.current === autoOpenSignature || + autoOpenedNotificationSignatures.has(autoOpenSignature) + ) { + return + } + + autoOpenRef.current = autoOpenSignature + autoOpenedNotificationSignatures.add(autoOpenSignature) + handleOpenDialog(unreadCounts.notice > 0 ? 'notice' : 'announcements') + }, [ + autoOpenSignature, + dialogOpen, + noticeLoading, + statusLoading, + unreadCounts.notice, + isNoticeClosed, + ]) + // Handle tab change - mark announcements as read when switching to that tab const handleTabChange = (tab: 'notice' | 'announcements') => { setActiveTab(tab) From 4cf9f8d79b682a4cb63c26d702c197ab7c1313c9 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 05:21:48 -0700 Subject: [PATCH 05/20] DOCKER --- .github/workflows/docker-image-alpha.yml | 53 +++++++++++++++--------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml index 116dd1452152..9907db28c39d 100644 --- a/.github/workflows/docker-image-alpha.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -4,6 +4,7 @@ on: push: branches: - alpha + - cooper workflow_dispatch: inputs: name: @@ -34,10 +35,16 @@ jobs: with: fetch-depth: 1 - - name: Determine alpha version + - name: Determine image channel + run: | + CHANNEL="${GITHUB_REF_NAME:-alpha}" + echo "CHANNEL=$CHANNEL" >> $GITHUB_ENV + echo "Publishing channel: $CHANNEL" + + - name: Determine channel version id: version run: | - VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" + VERSION="${CHANNEL}-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" echo "$VERSION" > VERSION echo "value=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_ENV @@ -78,9 +85,9 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:alpha-${{ matrix.arch }} + calciumion/new-api:${{ env.CHANNEL }}-${{ matrix.arch }} calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }} + ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.CHANNEL }}-${{ matrix.arch }} ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha @@ -100,8 +107,8 @@ jobs: run: | echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY - echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY + echo "calciumion/new-api:${CHANNEL}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY + echo "ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY @@ -121,10 +128,16 @@ jobs: - name: Normalize GHCR repository run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - - name: Determine alpha version + - name: Determine image channel + run: | + CHANNEL="${GITHUB_REF_NAME:-alpha}" + echo "CHANNEL=$CHANNEL" >> $GITHUB_ENV + echo "Publishing channel: $CHANNEL" + + - name: Determine channel version id: version run: | - VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" + VERSION="${CHANNEL}-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" echo "value=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_ENV @@ -134,14 +147,14 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create & push manifest (Docker Hub - alpha) + - name: Create & push manifest (Docker Hub - channel) run: | docker buildx imagetools create \ - -t calciumion/new-api:alpha \ - calciumion/new-api:alpha-amd64 \ - calciumion/new-api:alpha-arm64 + -t calciumion/new-api:${CHANNEL} \ + calciumion/new-api:${CHANNEL}-amd64 \ + calciumion/new-api:${CHANNEL}-arm64 - - name: Create & push manifest (Docker Hub - versioned alpha) + - name: Create & push manifest (Docker Hub - versioned channel) run: | docker buildx imagetools create \ -t calciumion/new-api:${VERSION} \ @@ -155,14 +168,14 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create & push manifest (GHCR - alpha) + - name: Create & push manifest (GHCR - channel) run: | docker buildx imagetools create \ - -t ghcr.io/${GHCR_REPOSITORY}:alpha \ - ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \ - ghcr.io/${GHCR_REPOSITORY}:alpha-arm64 + -t ghcr.io/${GHCR_REPOSITORY}:${CHANNEL} \ + ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-amd64 \ + ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-arm64 - - name: Create & push manifest (GHCR - versioned alpha) + - name: Create & push manifest (GHCR - versioned channel) run: | docker buildx imagetools create \ -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \ @@ -173,7 +186,7 @@ jobs: run: | echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY + docker buildx imagetools inspect calciumion/new-api:${CHANNEL} >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY - docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY + docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:${CHANNEL} >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY From 8852083f9c8a776ff71364f55fb556e9fbb1c842 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 05:29:16 -0700 Subject: [PATCH 06/20] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BA=86dockerhub?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-image-alpha.yml | 40 ++---------------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml index 9907db28c39d..567ca6235994 100644 --- a/.github/workflows/docker-image-alpha.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -56,12 +56,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: @@ -74,10 +68,9 @@ jobs: uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: | - calciumion/new-api ghcr.io/${{ env.GHCR_REPOSITORY }} - - name: Build & push single-arch (to both registries) + - name: Build & push single-arch (to GHCR) id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -85,8 +78,6 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:${{ env.CHANNEL }}-${{ matrix.arch }} - calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.CHANNEL }}-${{ matrix.arch }} ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} @@ -99,21 +90,18 @@ jobs: uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Sign image with cosign - run: | - cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }} - cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }} + run: cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }} - name: Output digest run: | echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - echo "calciumion/new-api:${CHANNEL}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY echo "ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY create_manifests: - name: Create multi-arch manifests (Docker Hub + GHCR) + name: Create multi-arch manifests (GHCR) needs: [build_single_arch] runs-on: ubuntu-latest permissions: @@ -141,26 +129,6 @@ jobs: echo "value=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & push manifest (Docker Hub - channel) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:${CHANNEL} \ - calciumion/new-api:${CHANNEL}-amd64 \ - calciumion/new-api:${CHANNEL}-arm64 - - - name: Create & push manifest (Docker Hub - versioned channel) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:${VERSION} \ - calciumion/new-api:${VERSION}-amd64 \ - calciumion/new-api:${VERSION}-arm64 - - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: @@ -186,7 +154,5 @@ jobs: run: | echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - docker buildx imagetools inspect calciumion/new-api:${CHANNEL} >> $GITHUB_STEP_SUMMARY - echo "---" >> $GITHUB_STEP_SUMMARY docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:${CHANNEL} >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY From 05c4161d1f9997b23a1973183679d88ba42049a1 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 18:41:56 -0700 Subject: [PATCH 07/20] =?UTF-8?q?fix:=20retry=20=E6=97=B6=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E5=B7=B2=E4=BD=BF=E7=94=A8=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 relay 重试过程中记录已使用渠道,避免 auto 跨分组或后续重试重复选择同一渠道。 Co-Authored-By: Claude Opus 4.7 --- controller/relay.go | 20 ++++++++++++-------- model/ability.go | 37 +++++++++++++++++++++++++++++++++++++ model/channel_cache.go | 28 ++++++++++++++++++++++------ service/channel_select.go | 15 ++++++++------- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 5e2db44c25a4..5642700b547c 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -179,10 +179,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + ExcludeChannelIds: map[int]bool{}, } relayInfo.RetryIndex = 0 relayInfo.LastError = nil @@ -197,6 +198,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } addUsedChannel(c, channel.Id) + retryParam.ExcludeChannelIds[channel.Id] = true bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { // Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path) @@ -507,10 +509,11 @@ func RelayTask(c *gin.Context) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + ExcludeChannelIds: map[int]bool{}, } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { @@ -535,6 +538,7 @@ func RelayTask(c *gin.Context) { } addUsedChannel(c, channel.Id) + retryParam.ExcludeChannelIds[channel.Id] = true bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) { diff --git a/model/ability.go b/model/ability.go index 1d7c53fa5805..305ed86bf735 100644 --- a/model/ability.go +++ b/model/ability.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "sort" "strings" "sync" @@ -104,6 +105,10 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { } func GetChannel(group string, model string, retry int) (*Channel, error) { + return GetChannelExcluding(group, model, retry, nil) +} + +func GetChannelExcluding(group string, model string, retry int, excludeChannelIds map[int]bool) (*Channel, error) { var abilities []Ability var err error = nil @@ -111,6 +116,9 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { if err != nil { return nil, err } + if len(excludeChannelIds) > 0 { + channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true) + } if common.UsingSQLite || common.UsingPostgreSQL { err = channelQuery.Order("weight DESC").Find(&abilities).Error } else { @@ -119,6 +127,35 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { if err != nil { return nil, err } + if len(excludeChannelIds) > 0 { + availableAbilities := make([]Ability, 0, len(abilities)) + uniquePriorities := make(map[int]bool) + for _, ability_ := range abilities { + if excludeChannelIds[ability_.ChannelId] { + continue + } + availableAbilities = append(availableAbilities, ability_) + uniquePriorities[int(*ability_.Priority)] = true + } + if len(availableAbilities) == 0 { + return nil, nil + } + priorities := make([]int, 0, len(uniquePriorities)) + for priority := range uniquePriorities { + priorities = append(priorities, priority) + } + sort.Sort(sort.Reverse(sort.IntSlice(priorities))) + if retry >= len(priorities) { + retry = len(priorities) - 1 + } + targetPriority := priorities[retry] + abilities = abilities[:0] + for _, ability_ := range availableAbilities { + if int(*ability_.Priority) == targetPriority { + abilities = append(abilities, ability_) + } + } + } channel := Channel{} if len(abilities) > 0 { // Randomly choose one diff --git a/model/channel_cache.go b/model/channel_cache.go index c9c503576038..9f41c6eccc93 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -94,9 +94,13 @@ func SyncChannelCache(frequency int) { } func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + return GetRandomSatisfiedChannelExcluding(group, model, retry, nil) +} + +func GetRandomSatisfiedChannelExcluding(group string, model string, retry int, excludeChannelIds map[int]bool) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry) + return GetChannelExcluding(group, model, retry, excludeChannelIds) } channelSyncLock.RLock() @@ -115,15 +119,27 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, return nil, nil } - if len(channels) == 1 { - if channel, ok := channelsIDM[channels[0]]; ok { + availableChannels := make([]int, 0, len(channels)) + for _, channelId := range channels { + if excludeChannelIds != nil && excludeChannelIds[channelId] { + continue + } + availableChannels = append(availableChannels, channelId) + } + + if len(availableChannels) == 0 { + return nil, nil + } + + if len(availableChannels) == 1 { + if channel, ok := channelsIDM[availableChannels[0]]; ok { return channel, nil } - return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", availableChannels[0]) } uniquePriorities := make(map[int]bool) - for _, channelId := range channels { + for _, channelId := range availableChannels { if channel, ok := channelsIDM[channelId]; ok { uniquePriorities[int(channel.GetPriority())] = true } else { @@ -144,7 +160,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, // get the priority for the given retry number var sumWeight = 0 var targetChannels []*Channel - for _, channelId := range channels { + for _, channelId := range availableChannels { if channel, ok := channelsIDM[channelId]; ok { if channel.GetPriority() == targetPriority { sumWeight += channel.GetWeight() diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8cec3..fc6f826923ce 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -12,11 +12,12 @@ import ( ) type RetryParam struct { - Ctx *gin.Context - TokenGroup string - ModelName string - Retry *int - resetNextTry bool + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int + ExcludeChannelIds map[int]bool + resetNextTry bool } func (p *RetryParam) GetRetry() int { @@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) + channel, _ = model.GetRandomSatisfiedChannelExcluding(autoGroup, param.ModelName, priorityRetry, param.ExcludeChannelIds) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) + channel, err = model.GetRandomSatisfiedChannelExcluding(param.TokenGroup, param.ModelName, param.GetRetry(), param.ExcludeChannelIds) if err != nil { return nil, param.TokenGroup, err } From 76559c63b14aff21b84f239ec0c0b8c2e045cd59 Mon Sep 17 00:00:00 2001 From: luji Date: Mon, 11 May 2026 21:05:54 -0700 Subject: [PATCH 08/20] =?UTF-8?q?1=E3=80=81=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=B9=BF=E5=9C=BA=E4=BB=B7=E6=A0=BC=E5=9C=A8?= =?UTF-8?q?=E6=A0=87=E5=87=86/=E5=85=85=E5=80=BC=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=E7=9A=84=E5=88=87=E6=8D=A2BUG=202=E3=80=81=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=86=E8=AE=A2=E9=98=85=E5=A5=97=E9=A4=90=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E6=98=BE=E7=A4=BA=E3=80=82=E7=9B=AE=E5=89=8D=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=98=BE=E7=A4=BA=E5=B9=B3=E5=8F=B0=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=B4=A7=E5=B8=81=EF=BC=8C=E7=82=B9=E5=87=BB=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=90=8E=E4=BC=9A=E6=9B=B4=E5=85=B7=E6=94=AF=E4=BB=98=E7=B3=BB?= =?UTF-8?q?=E6=95=B0=E4=BD=BF=E7=94=A8=E6=94=AF=E4=BB=98=E8=B4=A7=E5=B8=81?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=EF=BC=9B=E7=AB=8B=E5=8D=B3=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E4=B8=AD=E5=B0=86=E6=80=BB=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E4=BB=A5=E5=B9=B3=E5=8F=B0=E5=B1=95=E7=A4=BA=E8=B4=A7=E5=B8=81?= =?UTF-8?q?=E7=9A=84=E5=BD=A2=E5=BC=8F=E5=B1=95=E7=8E=B0=EF=BC=8C=E8=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=98=AFTOKEN=E6=95=B0=E5=80=BC=EF=BC=9B?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E8=AE=A2=E9=98=85=E5=BC=B9=E7=AA=97=E4=B8=AD?= =?UTF-8?q?=E5=BA=94=E4=BB=98=E9=87=91=E9=A2=9D=E6=B7=BB=E5=8A=A0=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=BA=94=E6=94=AF=E4=BB=98=E7=9A=84=E8=B4=A7=E5=B8=81?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E3=80=82=203=E3=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/subscription_payment_epay.go | 10 +- .../pricing/hooks/use-pricing-data.ts | 20 ++- .../src/features/pricing/lib/dynamic-price.ts | 39 +++-- web/default/src/features/pricing/lib/price.ts | 140 ++++++++---------- .../dialogs/subscription-purchase-dialog.tsx | 38 ++++- .../components/subscriptions-columns.tsx | 11 +- .../src/features/subscriptions/lib/format.ts | 20 +++ .../src/features/subscriptions/lib/index.ts | 7 +- .../dialogs/payment-confirm-dialog.tsx | 31 ++-- .../wallet/components/recharge-form-card.tsx | 29 +++- .../components/subscription-plans-card.tsx | 5 +- web/default/src/features/wallet/index.tsx | 26 +++- web/default/src/features/wallet/lib/format.ts | 51 +++++++ 13 files changed, 299 insertions(+), 128 deletions(-) diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index 2567654ff470..16f54d34b3ab 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -15,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/samber/lo" + "github.com/shopspring/decimal" ) type SubscriptionEpayPayRequest struct { @@ -22,6 +23,10 @@ type SubscriptionEpayPayRequest struct { PaymentMethod string `json:"payment_method"` } +func getSubscriptionEpayMoney(priceAmount float64) float64 { + return decimal.NewFromFloat(priceAmount).Mul(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64() +} + func SubscriptionRequestEpay(c *gin.Context) { var req SubscriptionEpayPayRequest if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { @@ -81,10 +86,11 @@ func SubscriptionRequestEpay(c *gin.Context) { return } + paymentMoney := getSubscriptionEpayMoney(plan.PriceAmount) order := &model.SubscriptionOrder{ UserId: userId, PlanId: plan.Id, - Money: plan.PriceAmount, + Money: paymentMoney, TradeNo: tradeNo, PaymentMethod: req.PaymentMethod, PaymentProvider: model.PaymentProviderEpay, @@ -99,7 +105,7 @@ func SubscriptionRequestEpay(c *gin.Context) { Type: req.PaymentMethod, ServiceTradeNo: tradeNo, Name: fmt.Sprintf("SUB:%s", plan.Title), - Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64), + Money: strconv.FormatFloat(paymentMoney, 'f', 2, 64), Device: epay.PC, NotifyUrl: notifyUrl, ReturnUrl: returnUrl, diff --git a/web/default/src/features/pricing/hooks/use-pricing-data.ts b/web/default/src/features/pricing/hooks/use-pricing-data.ts index 914f6e63da51..1808676a51ed 100644 --- a/web/default/src/features/pricing/hooks/use-pricing-data.ts +++ b/web/default/src/features/pricing/hooks/use-pricing-data.ts @@ -19,10 +19,12 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useStatus } from '@/hooks/use-status' +import { useSystemConfig } from '@/hooks/use-system-config' import { getPricing } from '../api' export function usePricingData() { const { status } = useStatus() + const { currency } = useSystemConfig() const { data, isLoading, error, refetch } = useQuery({ queryKey: ['pricing'], @@ -35,10 +37,20 @@ export function usePricingData() { () => Math.max((status?.price as number) ?? 1, 0.001), [status?.price] ) - const usdExchangeRate = useMemo( - () => Math.max((status?.usd_exchange_rate as number) ?? priceRate, 0.001), - [status?.usd_exchange_rate, priceRate] - ) + const usdExchangeRate = useMemo(() => { + if (currency?.quotaDisplayType === 'CNY') { + return Math.max(currency.usdExchangeRate ?? priceRate, 0.001) + } + if (currency?.quotaDisplayType === 'CUSTOM') { + return Math.max(currency.customCurrencyExchangeRate ?? 1, 0.001) + } + return 1 + }, [ + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencyExchangeRate, + priceRate, + ]) const models = useMemo(() => { if (!data?.data || !data?.vendors) return [] diff --git a/web/default/src/features/pricing/lib/dynamic-price.ts b/web/default/src/features/pricing/lib/dynamic-price.ts index 616c4c1840d3..320382110769 100644 --- a/web/default/src/features/pricing/lib/dynamic-price.ts +++ b/web/default/src/features/pricing/lib/dynamic-price.ts @@ -17,6 +17,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { formatBillingCurrencyFromUSD } from '@/lib/currency' +import { getWalletCurrencyConfig } from '@/features/wallet/lib' +import { useSystemConfigStore } from '@/stores/system-config-store' import { TOKEN_UNIT_DIVISORS } from '../constants' import type { PricingModel, TokenUnit } from '../types' import { @@ -80,14 +82,22 @@ export function getDynamicDisplayGroupRatio(model: PricingModel): number { return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio } -function applyRechargeRate( - price: number, - showWithRecharge: boolean, - priceRate: number, - usdExchangeRate: number -): number { - if (!showWithRecharge) return price - return (price * priceRate) / usdExchangeRate +function formatDynamicPaymentCurrency(amount: number): string { + if (Number.isNaN(amount)) return '-' + + const currency = useSystemConfigStore.getState().config.currency + const walletCurrency = getWalletCurrencyConfig( + currency.quotaDisplayType, + currency.usdExchangeRate, + currency.customCurrencySymbol, + currency.customCurrencyExchangeRate + ) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: Math.abs(amount) >= 1 ? 4 : 6, + }).format(amount) + + return `${walletCurrency.paymentSymbol}${formatted}` } export function formatDynamicUnitPrice( @@ -96,18 +106,15 @@ export function formatDynamicUnitPrice( ): string { const groupRatio = options.groupRatioMultiplier ?? 1 const priceRate = options.priceRate ?? 1 - const usdExchangeRate = options.usdExchangeRate ?? 1 const priceUSD = (valuePerMillionTokens * groupRatio) / TOKEN_UNIT_DIVISORS[options.tokenUnit] - const displayPrice = applyRechargeRate( - priceUSD, - options.showRechargePrice ?? false, - priceRate, - usdExchangeRate - ) - return formatBillingCurrencyFromUSD(displayPrice, { + if (options.showRechargePrice) { + return formatDynamicPaymentCurrency(priceUSD * priceRate) + } + + return formatBillingCurrencyFromUSD(priceUSD, { digitsLarge: 4, digitsSmall: 6, abbreviate: false, diff --git a/web/default/src/features/pricing/lib/price.ts b/web/default/src/features/pricing/lib/price.ts index decbd5978cef..96a90f3b37e8 100644 --- a/web/default/src/features/pricing/lib/price.ts +++ b/web/default/src/features/pricing/lib/price.ts @@ -16,7 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { formatCurrencyFromUSD } from '@/lib/currency' +import { + formatBillingCurrencyFromUSD, + type CurrencyFormatOptions, +} from '@/lib/currency' +import { getWalletCurrencyConfig } from '@/features/wallet/lib' +import { useSystemConfigStore } from '@/stores/system-config-store' import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants' import type { PricingModel, TokenUnit, PriceType } from '../types' @@ -121,40 +126,49 @@ function hasRatio(value: number | null | undefined): boolean { return value !== undefined && value !== null && Number.isFinite(Number(value)) } -/** - * Apply recharge rate to price - * - * priceRate represents how much users need to recharge (in the display currency) - * to get 1 USD credit. usdExchangeRate is the real exchange rate. - * - * The returned value will be formatted by formatCurrencyFromUSD, which will - * multiply by the display currency's exchange rate. - * - * Examples: - * - * 1. Display currency = USD: - * - Model: 1 USD - * - priceRate = 0.5 (recharge $0.5 to get $1 credit) - * - usdExchangeRate = 1 - * - Return: 1 × 0.5 / 1 = 0.5 - * - formatCurrencyFromUSD(0.5) → $0.5 ✓ - * - * 2. Display currency = CNY: - * - Model: 1 USD - * - priceRate = 4 (recharge ¥4 to get $1 credit) - * - usdExchangeRate = 7 (real rate: 1 USD = ¥7) - * - Return: 1 × 4 / 7 = 0.571 - * - formatCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓ - * - Normal price: ¥7, Recharge price: ¥4 (cheaper!) - */ -function applyRechargeRate( - price: number, +const PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 6, + abbreviate: false, +} + +const REQUEST_PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 4, + abbreviate: false, +} + +function formatPaymentCurrency( + amount: number, + options: CurrencyFormatOptions +): string { + if (Number.isNaN(amount)) return '-' + + const currency = useSystemConfigStore.getState().config.currency + const walletCurrency = getWalletCurrencyConfig( + currency.quotaDisplayType, + currency.usdExchangeRate, + currency.customCurrencySymbol, + currency.customCurrencyExchangeRate + ) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: Math.abs(amount) >= 1 ? options.digitsLarge : options.digitsSmall, + }).format(amount) + + return `${walletCurrency.paymentSymbol}${formatted}` +} + +function formatPricingCurrency( + amountUSD: number, showWithRecharge: boolean, priceRate: number, - usdExchangeRate: number -): number { - if (!showWithRecharge) return price - return (price * priceRate) / usdExchangeRate + options: CurrencyFormatOptions +): string { + if (showWithRecharge) { + return formatPaymentCurrency(amountUSD * priceRate, options) + } + return formatBillingCurrencyFromUSD(amountUSD, options) } /** @@ -166,7 +180,7 @@ export function formatPrice( tokenUnit: TokenUnit, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1 + _usdExchangeRate = 1 ): string { if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { return '-' @@ -178,20 +192,14 @@ export function formatPrice( const groupRatio = model.group_ratio || {} const minRatio = getMinGroupRatio(enableGroups, groupRatio) - let priceInUSD = calculateTokenPrice(model, type, minRatio) - priceInUSD = applyRechargeRate( + const priceInUSD = + calculateTokenPrice(model, type, minRatio) / TOKEN_UNIT_DIVISORS[tokenUnit] + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + PRICE_FORMAT_OPTIONS ) - - const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] - return formatCurrencyFromUSD(price, { - digitsLarge: 4, - digitsSmall: 6, - abbreviate: false, - }) } /** @@ -204,7 +212,7 @@ export function formatGroupPrice( tokenUnit: TokenUnit, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1, + _usdExchangeRate = 1, groupRatio: Record ): string { if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { @@ -212,21 +220,15 @@ export function formatGroupPrice( } const ratio = groupRatio[group] || 1 - let priceInUSD = calculateTokenPrice(model, type, ratio) + const priceInUSD = + calculateTokenPrice(model, type, ratio) / TOKEN_UNIT_DIVISORS[tokenUnit] - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + PRICE_FORMAT_OPTIONS ) - - const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] - return formatCurrencyFromUSD(price, { - digitsLarge: 4, - digitsSmall: 6, - abbreviate: false, - }) } /** @@ -237,7 +239,7 @@ export function formatFixedPrice( group: string, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1, + _usdExchangeRate = 1, groupRatio: Record ): string { if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) { @@ -245,20 +247,14 @@ export function formatFixedPrice( } const ratio = groupRatio[group] || 1 - let priceInUSD = (model.model_price || 0) * ratio + const priceInUSD = (model.model_price || 0) * ratio - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + REQUEST_PRICE_FORMAT_OPTIONS ) - - return formatCurrencyFromUSD(priceInUSD, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) } /** @@ -268,7 +264,7 @@ export function formatRequestPrice( model: PricingModel, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1 + _usdExchangeRate = 1 ): string { if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) { return '-' @@ -280,18 +276,12 @@ export function formatRequestPrice( const groupRatio = model.group_ratio || {} const minRatio = getMinGroupRatio(enableGroups, groupRatio) - let priceInUSD = (model.model_price || 0) * minRatio + const priceInUSD = (model.model_price || 0) * minRatio - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + REQUEST_PRICE_FORMAT_OPTIONS ) - - return formatCurrencyFromUSD(priceInUSD, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) } diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index c2748294c9ee..1fe3e3c0c9ea 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -20,6 +20,16 @@ import { useState, useEffect } from 'react' import { Crown, CalendarClock, Package } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + formatBillingCurrencyFromUSD, + formatQuotaWithCurrency, +} from '@/lib/currency' +import { useStatus } from '@/hooks/use-status' +import { useSystemConfig } from '@/hooks/use-system-config' +import { + formatWalletCurrencyAmount, + getWalletCurrencyConfig, +} from '@/features/wallet/lib' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -43,7 +53,10 @@ import { paySubscriptionCreem, paySubscriptionEpay, } from '../../api' -import { formatDuration, formatResetPeriod } from '../../lib' +import { + formatDuration, + formatResetPeriod, +} from '../../lib' import type { PlanRecord } from '../../types' interface PaymentMethod { @@ -65,6 +78,8 @@ interface Props { export function SubscriptionPurchaseDialog(props: Props) { const { t } = useTranslation() + const { status } = useStatus() + const { currency } = useSystemConfig() const [paying, setPaying] = useState(false) const [selectedEpayMethod, setSelectedEpayMethod] = useState('') @@ -90,7 +105,17 @@ export function SubscriptionPurchaseDialog(props: Props) { selectedEpayMethod || t('Select payment method') const totalAmount = Number(plan.total_amount || 0) - const price = Number(plan.price_amount || 0).toFixed(2) + const price = formatBillingCurrencyFromUSD(plan.price_amount) + const walletCurrency = getWalletCurrencyConfig( + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencySymbol, + currency?.customCurrencyExchangeRate + ) + const localPaymentAmount = formatWalletCurrencyAmount( + plan.price_amount * ((status?.price as number) || 1), + walletCurrency.paymentSymbol + ) const limitReached = (props.purchaseLimit || 0) > 0 && (props.purchaseCount || 0) >= (props.purchaseLimit || 0) @@ -230,7 +255,7 @@ export function SubscriptionPurchaseDialog(props: Props) { - {totalAmount > 0 ? totalAmount : t('Unlimited')} + {totalAmount > 0 ? formatQuotaWithCurrency(totalAmount) : t('Unlimited')}
{plan.upgrade_group && ( @@ -244,7 +269,12 @@ export function SubscriptionPurchaseDialog(props: Props) {
{t('Amount Due')} - ${price} +
+
{price}
+
+ {localPaymentAmount} +
+
diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index f478cb7539f3..774ff1906dd1 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -22,7 +22,11 @@ import { useTranslation } from 'react-i18next' import { DataTableColumnHeader } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { StatusBadge } from '@/components/status-badge' -import { formatDuration, formatResetPeriod } from '../lib' +import { + formatDuration, + formatResetPeriod, + formatSubscriptionPrice, +} from '../lib' import type { PlanRecord } from '../types' import { DataTableRowActions } from './data-table-row-actions' @@ -74,7 +78,10 @@ export function useSubscriptionsColumns(): ColumnDef[] { ), cell: ({ row }) => ( - ${Number(row.original.plan.price_amount || 0).toFixed(2)} + {formatSubscriptionPrice( + row.original.plan.price_amount, + row.original.plan.currency + )} ), size: 100, diff --git a/web/default/src/features/subscriptions/lib/format.ts b/web/default/src/features/subscriptions/lib/format.ts index 3d035bfceba4..e2609502a2b8 100644 --- a/web/default/src/features/subscriptions/lib/format.ts +++ b/web/default/src/features/subscriptions/lib/format.ts @@ -20,6 +20,26 @@ import type { TFunction } from 'i18next' import dayjs from '@/lib/dayjs' import type { SubscriptionPlan } from '../types' +export function formatSubscriptionPrice( + amount: number | string | null | undefined, + currency?: string | null +): string { + const numeric = typeof amount === 'number' ? amount : Number(amount || 0) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number.isFinite(numeric) ? numeric : 0) + const code = currency?.trim().toUpperCase() || 'USD' + + if (code === 'USD') return `$${formatted}` + if (code === 'CNY' || code === 'RMB') return `¥${formatted}` + if (code === 'EUR') return `€${formatted}` + if (code === 'GBP') return `£${formatted}` + if (code === 'JPY') return `¥${formatted}` + + return `${code} ${formatted}` +} + export function formatDuration( plan: Partial, t: TFunction diff --git a/web/default/src/features/subscriptions/lib/index.ts b/web/default/src/features/subscriptions/lib/index.ts index 783d2d05964d..7a9dd9fe7ae8 100644 --- a/web/default/src/features/subscriptions/lib/index.ts +++ b/web/default/src/features/subscriptions/lib/index.ts @@ -16,7 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -export { formatDuration, formatResetPeriod, formatTimestamp } from './format' +export { + formatDuration, + formatResetPeriod, + formatSubscriptionPrice, + formatTimestamp, +} from './format' export { getPlanFormSchema, PLAN_FORM_DEFAULTS, diff --git a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx index 91b4a8846f4b..1265b3460a60 100644 --- a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx +++ b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx @@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com */ import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { formatLocalCurrencyAmount } from '@/lib/currency' import { AlertDialog, AlertDialogAction, @@ -31,7 +30,7 @@ import { } from '@/components/ui/alert-dialog' import { Skeleton } from '@/components/ui/skeleton' import { DEFAULT_DISCOUNT_RATE } from '../../constants' -import { formatCurrency, getPaymentIcon } from '../../lib' +import { formatWalletCurrencyAmount, getPaymentIcon } from '../../lib' import type { PaymentMethod } from '../../types' interface PaymentConfirmDialogProps { @@ -44,6 +43,8 @@ interface PaymentConfirmDialogProps { calculating: boolean processing: boolean discountRate?: number + topupCurrencySymbol?: string + paymentCurrencySymbol?: string usdExchangeRate?: number } @@ -57,6 +58,8 @@ export function PaymentConfirmDialog({ calculating, processing, discountRate = DEFAULT_DISCOUNT_RATE, + topupCurrencySymbol = '¥', + paymentCurrencySymbol = '¥', usdExchangeRate = 1, }: PaymentConfirmDialogProps) { const { t } = useTranslation() @@ -82,11 +85,10 @@ export function PaymentConfirmDialog({ {t('Topup Amount')} - {formatLocalCurrencyAmount(topupAmount * usdExchangeRate, { - digitsLarge: 2, - digitsSmall: 2, - abbreviate: false, - })} + {formatWalletCurrencyAmount( + topupAmount * usdExchangeRate, + topupCurrencySymbol + )} @@ -99,11 +101,17 @@ export function PaymentConfirmDialog({ ) : (
- {formatCurrency(paymentAmount)} + {formatWalletCurrencyAmount( + paymentAmount, + paymentCurrencySymbol + )} {hasDiscount && ( - {formatCurrency(originalAmount)} + {formatWalletCurrencyAmount( + originalAmount, + paymentCurrencySymbol + )} )}
@@ -115,7 +123,10 @@ export function PaymentConfirmDialog({
{t('You save')} - {formatCurrency(discountAmount)} + {formatWalletCurrencyAmount( + discountAmount, + paymentCurrencySymbol + )}
diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index f7e4a3b54b5f..1dfce42ad815 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com import { useState, useEffect } from 'react' import { Gift, ExternalLink, Loader2, Receipt, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { formatNumber } from '@/lib/format' import { cn } from '@/lib/utils' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -35,11 +34,11 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { - formatCurrency, getDiscountLabel, getPaymentIcon, getMinTopupAmount, calculatePresetPricing, + formatWalletCurrencyAmount, } from '../lib' import type { PaymentMethod, @@ -68,6 +67,8 @@ interface RechargeFormCardProps { topupLink?: string loading?: boolean priceRatio?: number + topupCurrencySymbol?: string + paymentCurrencySymbol?: string usdExchangeRate?: number onOpenBilling?: () => void creemProducts?: CreemProduct[] @@ -98,6 +99,8 @@ export function RechargeFormCard({ topupLink, loading, priceRatio = 1, + topupCurrencySymbol = '¥', + paymentCurrencySymbol = '¥', usdExchangeRate = 1, onOpenBilling, creemProducts, @@ -246,7 +249,10 @@ export function RechargeFormCard({ >
- {formatNumber(displayValue)} + {formatWalletCurrencyAmount( + displayValue, + topupCurrencySymbol + )}
{hasDiscount && (
@@ -255,11 +261,19 @@ export function RechargeFormCard({ )}
- Pay {formatCurrency(actualPrice)} + {t('Pay')}{' '} + {formatWalletCurrencyAmount( + actualPrice, + paymentCurrencySymbol + )} {hasDiscount && savedAmount > 0 && ( {' '} - • Save {formatCurrency(savedAmount)} + • {t('Save')}{' '} + {formatWalletCurrencyAmount( + savedAmount, + paymentCurrencySymbol + )} )}
@@ -295,7 +309,10 @@ export function RechargeFormCard({ ) : ( - {formatCurrency(paymentAmount)} + {formatWalletCurrencyAmount( + paymentAmount, + paymentCurrencySymbol + )} )}
diff --git a/web/default/src/features/wallet/components/subscription-plans-card.tsx b/web/default/src/features/wallet/components/subscription-plans-card.tsx index da009a1176b4..b6b0a511fdfe 100644 --- a/web/default/src/features/wallet/components/subscription-plans-card.tsx +++ b/web/default/src/features/wallet/components/subscription-plans-card.tsx @@ -21,6 +21,7 @@ import { Crown, RefreshCw, Sparkles, Check } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatQuota } from '@/lib/format' +import { formatBillingCurrencyFromUSD } from '@/lib/currency' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' @@ -511,7 +512,7 @@ export function SubscriptionPlansCard({ const plan = p?.plan if (!plan) return null const totalAmount = Number(plan.total_amount || 0) - const price = Number(plan.price_amount || 0).toFixed(2) + const price = formatBillingCurrencyFromUSD(plan.price_amount) const isPopular = index === 0 && plans.length > 1 const limit = Number(plan.max_purchase_per_user || 0) const count = planPurchaseCountMap.get(plan.id) || 0 @@ -565,7 +566,7 @@ export function SubscriptionPlansCard({
- ${price} + {price}
diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index adb09fee72d9..746d0467f8af 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -43,6 +43,7 @@ import { import { getDefaultPaymentType, getMinTopupAmount, + getWalletCurrencyConfig, isWaffoPancakePayment, } from './lib' import type { @@ -78,12 +79,21 @@ export function Wallet(props: WalletProps) { const { currency } = useSystemConfig() const { topupInfo, presetAmounts, loading: topupLoading } = useTopupInfo() - // Calculate effective exchange rate - when display type is USD, use rate of 1 - const effectiveUsdExchangeRate = useMemo(() => { - return currency?.quotaDisplayType === 'USD' - ? 1 - : currency?.usdExchangeRate || 1 - }, [currency?.quotaDisplayType, currency?.usdExchangeRate]) + const walletCurrency = useMemo(() => { + return getWalletCurrencyConfig( + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencySymbol, + currency?.customCurrencyExchangeRate + ) + }, [ + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencySymbol, + currency?.customCurrencyExchangeRate, + ]) + + const effectiveUsdExchangeRate = walletCurrency.rate const { amount: paymentAmount, calculating, @@ -301,6 +311,8 @@ export function Wallet(props: WalletProps) { topupLink={topupInfo?.topup_link} loading={topupLoading} priceRatio={(status?.price as number) || 1} + topupCurrencySymbol={walletCurrency.symbol} + paymentCurrencySymbol={walletCurrency.paymentSymbol} usdExchangeRate={effectiveUsdExchangeRate} onOpenBilling={() => setBillingDialogOpen(true)} creemProducts={topupInfo?.creem_products} @@ -335,6 +347,8 @@ export function Wallet(props: WalletProps) { calculating={calculating} processing={processing || pancakeProcessing} discountRate={getDiscountRate()} + topupCurrencySymbol={walletCurrency.symbol} + paymentCurrencySymbol={walletCurrency.paymentSymbol} usdExchangeRate={effectiveUsdExchangeRate} /> diff --git a/web/default/src/features/wallet/lib/format.ts b/web/default/src/features/wallet/lib/format.ts index b743345cefc4..8ec4a315a305 100644 --- a/web/default/src/features/wallet/lib/format.ts +++ b/web/default/src/features/wallet/lib/format.ts @@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import { DEFAULT_DISCOUNT_RATE } from '../constants' +const DEFAULT_PAYMENT_EXCHANGE_RATE = 7 + // ============================================================================ // Wallet-specific Formatting Functions // ============================================================================ @@ -61,6 +63,55 @@ export function formatCurrency(amount: number | string): string { }).format(numeric) } +export function formatWalletCurrencyAmount( + amount: number | string, + symbol = '¥' +): string { + const formatted = formatCurrency(amount) + return formatted === '-' ? formatted : `${symbol}${formatted}` +} + +export function getWalletCurrencyConfig( + quotaDisplayType?: string, + usdExchangeRate?: number, + customCurrencySymbol?: string, + customCurrencyExchangeRate?: number +) { + const effectiveUsdExchangeRate = + usdExchangeRate && usdExchangeRate > 0 + ? usdExchangeRate + : DEFAULT_PAYMENT_EXCHANGE_RATE + + if (quotaDisplayType === 'USD') { + return { + symbol: '$', + rate: 1, + paymentSymbol: effectiveUsdExchangeRate === 1 ? '$' : '¥', + paymentRate: effectiveUsdExchangeRate, + } + } + + if (quotaDisplayType === 'CUSTOM') { + const rate = + customCurrencyExchangeRate && customCurrencyExchangeRate > 0 + ? customCurrencyExchangeRate + : 1 + return { + symbol: customCurrencySymbol?.trim() || '¤', + rate, + paymentSymbol: customCurrencySymbol?.trim() || '¤', + paymentRate: rate, + } + } + + return { + symbol: '¥', + rate: effectiveUsdExchangeRate, + paymentSymbol: '¥', + paymentRate: effectiveUsdExchangeRate, + } +} + /** * Get discount label for display (e.g., "20% OFF") */ From d6eb2057b5387ce391bb39beafd07a25d87b18cf Mon Sep 17 00:00:00 2001 From: luji Date: Fri, 15 May 2026 09:04:04 -0700 Subject: [PATCH 09/20] =?UTF-8?q?1.=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA=E4=BD=8E=E5=AE=BD=E5=BA=A6=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=8E=92=E7=89=88=E9=94=99=E4=BD=8D=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E3=80=82=202.=E5=88=A0=E9=99=A4=E4=BA=86=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=BC=B9=E7=AA=97=E4=B8=AD=E7=9A=84API?= =?UTF-8?q?=E6=9D=BF=E5=9D=97=EF=BC=8C=E5=8F=8A=E6=A6=82=E8=A7=88=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E6=8E=A8=E6=B5=8B=E5=85=83=E4=BF=A1=E6=81=AF=E3=80=82?= =?UTF-8?q?=203.=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E5=AF=BC=E8=88=AA=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BA=86=E6=8E=92=E8=A1=8C=E6=A6=9C=E6=98=BE=E7=A4=BA=E5=80=8D?= =?UTF-8?q?=E7=8E=87=E8=AE=BE=E7=BD=AE=E3=80=82=204.=E3=80=90=E5=A4=A7?= =?UTF-8?q?=E6=94=B9=E3=80=91=E4=BC=98=E5=8C=96=E4=BA=86=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=A8=A1=E5=9E=8B=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=A4=9A=E6=A8=A1=E6=80=81=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=EF=BC=8C=E5=B9=B6=E6=96=B0=E5=A2=9E=E4=BA=86?= =?UTF-8?q?=E6=8C=89=E7=A7=92=E8=AE=A1=E8=B4=B9=E8=A7=84=E5=88=99=EF=BC=8C?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=E5=A6=82=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E3=80=81=E6=8C=89=E7=A7=92=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E9=85=8D=E7=BD=AE=E3=80=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA=E5=B1=95=E7=A4=BA=E3=80=81=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=B1=95=E7=A4=BA=E3=80=81=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=B0=E5=A2=9E=E4=B8=8B=E8=BD=BD=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E3=80=82=20=E6=9C=AC=E6=AC=A1=E6=89=80=E4=BB=A5?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E4=BB=85=E9=92=88=E5=AF=B9=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=8C=E6=9C=AA=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/endpoint_type.go | 5 +- common/model.go | 25 + controller/relay.go | 3 + model/option.go | 2 + model/pricing.go | 48 +- relay/channel/api_request.go | 9 +- relay/channel/api_request_test.go | 88 ++ relay/channel/task/doubao/adaptor.go | 58 +- relay/channel/task/doubao/adaptor_test.go | 117 +++ relay/common/relay_info.go | 123 ++- relay/common/relay_utils.go | 36 +- relay/common/relay_utils_test.go | 66 ++ relay/helper/price.go | 205 +++++ relay/helper/price_test.go | 56 ++ service/rankings.go | 103 ++- service/rankings_test.go | 39 + service/task_billing.go | 14 + setting/billing_setting/tiered_billing.go | 35 +- types/price_data.go | 11 + .../drawers/model-mutate-drawer.tsx | 782 +----------------- .../pricing/components/model-card.tsx | 73 +- .../pricing/components/model-details.tsx | 239 +++--- .../pricing/components/pricing-columns.tsx | 26 +- .../pricing/components/pricing-sidebar.tsx | 23 +- web/default/src/features/pricing/constants.ts | 2 + .../src/features/pricing/lib/filters.ts | 20 +- web/default/src/features/pricing/lib/price.ts | 33 + web/default/src/features/pricing/types.ts | 7 + .../system-settings/billing/index.tsx | 1 + .../billing/section-registry.tsx | 1 + .../maintenance/header-navigation-section.tsx | 161 +++- .../features/system-settings/models/index.tsx | 1 + .../models/model-pricing-sheet.tsx | 250 +++++- .../models/model-ratio-form.tsx | 22 + .../models/model-ratio-visual-editor.tsx | 84 +- .../models/ratio-settings-card.tsx | 16 + .../models/upstream-ratio-sync.tsx | 1 + .../features/system-settings/site/index.tsx | 2 + .../system-settings/site/section-registry.tsx | 2 + .../src/features/system-settings/types.ts | 4 + .../columns/common-logs-columns.tsx | 4 + .../components/columns/task-logs-columns.tsx | 8 +- .../components/dialogs/details-dialog.tsx | 66 +- web/default/src/features/usage-logs/types.ts | 8 + .../locales/_reports/ja.untranslated.json | 6 +- .../locales/_reports/ru.untranslated.json | 4 +- web/default/src/i18n/locales/en.json | 41 +- web/default/src/i18n/locales/fr.json | 41 +- web/default/src/i18n/locales/ja.json | 41 +- web/default/src/i18n/locales/ru.json | 41 +- web/default/src/i18n/locales/vi.json | 41 +- web/default/src/i18n/locales/zh.json | 41 +- 52 files changed, 2090 insertions(+), 1045 deletions(-) create mode 100644 relay/channel/task/doubao/adaptor_test.go create mode 100644 relay/common/relay_utils_test.go create mode 100644 service/rankings_test.go diff --git a/common/endpoint_type.go b/common/endpoint_type.go index a5e2ff8412e8..bd423592b7b6 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -28,7 +28,7 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} case constant.ChannelTypeXai: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} - case constant.ChannelTypeSora: + case constant.ChannelTypeSora, constant.ChannelTypeDoubaoVideo: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} default: if IsOpenAIResponseOnlyModel(modelName) { @@ -41,5 +41,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant // add to first endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) } + if IsVideoGenerationModel(modelName) { + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeOpenAIVideo}, endpointTypes...) + } return endpointTypes } diff --git a/common/model.go b/common/model.go index 4ebc7b532d74..240d8af84c5b 100644 --- a/common/model.go +++ b/common/model.go @@ -17,6 +17,18 @@ var ( "flux-", "flux.1-", } + VideoGenerationModels = []string{ + "doubao-seedance-", + "seedance-", + "sora-", + "veo-", + "kling", + "vidu", + "hailuo", + "jimeng", + "cogvideo", + "video", + } OpenAITextModels = []string{ "gpt-", "o1", @@ -48,6 +60,19 @@ func IsImageGenerationModel(modelName string) bool { return false } +func IsVideoGenerationModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range VideoGenerationModels { + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} + func IsOpenAITextModel(modelName string) bool { modelName = strings.ToLower(modelName) for _, m := range OpenAITextModels { diff --git a/controller/relay.go b/controller/relay.go index 5642700b547c..5ca074b4c9cf 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -593,6 +593,9 @@ func RelayTask(c *gin.Context) { OriginModelName: relayInfo.OriginModelName, PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice, } + if taskReq, reqErr := relaycommon.GetTaskRequest(c); reqErr == nil { + task.Properties.Input = taskReq.Prompt + } task.Quota = result.Quota task.Data = result.TaskData task.Action = relayInfo.Action diff --git a/model/option.go b/model/option.go index a9a7d7f90739..75323a009834 100644 --- a/model/option.go +++ b/model/option.go @@ -52,6 +52,8 @@ func InitOptionMap(loadFromDatabase ...bool) { common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled) common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled) + common.OptionMap["RankingsDisplayMultiplier"] = "1" + common.OptionMap["RankingsDisplayJitterRatio"] = "0" common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64) common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled) common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled) diff --git a/model/pricing.go b/model/pricing.go index b9574a388587..3fa93ba37afd 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -16,26 +16,27 @@ import ( ) type Pricing struct { - ModelName string `json:"model_name"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Tags string `json:"tags,omitempty"` - VendorID int `json:"vendor_id,omitempty"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - OwnerBy string `json:"owner_by"` - CompletionRatio float64 `json:"completion_ratio"` - CacheRatio *float64 `json:"cache_ratio,omitempty"` - CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"` - ImageRatio *float64 `json:"image_ratio,omitempty"` - AudioRatio *float64 `json:"audio_ratio,omitempty"` - AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` - EnableGroup []string `json:"enable_groups"` - SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` - BillingMode string `json:"billing_mode,omitempty"` - BillingExpr string `json:"billing_expr,omitempty"` - PricingVersion string `json:"pricing_version,omitempty"` + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + CacheRatio *float64 `json:"cache_ratio,omitempty"` + CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"` + ImageRatio *float64 `json:"image_ratio,omitempty"` + AudioRatio *float64 `json:"audio_ratio,omitempty"` + AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` + BillingMode string `json:"billing_mode,omitempty"` + BillingExpr string `json:"billing_expr,omitempty"` + VideoPrice *billing_setting.VideoPriceConfig `json:"video_price,omitempty"` + PricingVersion string `json:"pricing_version,omitempty"` } type PricingVendor struct { @@ -331,11 +332,16 @@ func updatePricing() { audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model) pricing.AudioCompletionRatio = &audioCompletionRatio } - if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" { + if billingMode := billing_setting.GetBillingMode(model); billingMode == billing_setting.BillingModeTieredExpr { if expr, ok := billing_setting.GetBillingExpr(model); ok && strings.TrimSpace(expr) != "" { pricing.BillingMode = billingMode pricing.BillingExpr = expr } + } else if billingMode == billing_setting.BillingModeVideoSeconds { + if cfg, ok := billing_setting.GetVideoPriceConfig(model); ok && len(cfg.Prices) > 0 { + pricing.BillingMode = billingMode + pricing.VideoPrice = &cfg + } } pricingMap = append(pricingMap, pricing) } diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 8dfb61d40093..e05ac88a2fab 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -1,6 +1,7 @@ package channel import ( + "bytes" "context" "errors" "fmt" @@ -534,12 +535,16 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req if err != nil { return nil, err } - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + bodyBytes, err := io.ReadAll(requestBody) + if err != nil { + return nil, fmt.Errorf("read request body failed: %w", err) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(requestBody), nil + return io.NopCloser(bytes.NewReader(bodyBytes)), nil } err = a.BuildRequestHeader(c, req, info) diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index f697f8555692..516f7702d014 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -1,11 +1,17 @@ package channel import ( + "io" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -191,3 +197,85 @@ func TestProcessHeaderOverride_PassHeadersTemplateSetsRuntimeHeaders(t *testing. require.Equal(t, "sess-123", upstreamReq.Header.Get("Session_id")) require.Empty(t, upstreamReq.Header.Get("X-Codex-Beta-Features")) } + +type replayTaskAdaptor struct { + url string +} + +func (a replayTaskAdaptor) Init(_ *relaycommon.RelayInfo) {} +func (a replayTaskAdaptor) ValidateRequestAndSetAction(_ *gin.Context, _ *relaycommon.RelayInfo) *dto.TaskError { + return nil +} +func (a replayTaskAdaptor) EstimateBilling(_ *gin.Context, _ *relaycommon.RelayInfo) map[string]float64 { + return nil +} +func (a replayTaskAdaptor) AdjustBillingOnSubmit(_ *relaycommon.RelayInfo, _ []byte) map[string]float64 { + return nil +} +func (a replayTaskAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { + return 0 +} +func (a replayTaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { + return a.url, nil +} +func (a replayTaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + return nil +} +func (a replayTaskAdaptor) BuildRequestBody(_ *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { + return strings.NewReader(`{"prompt":"小猫在城市上空急速飞行"}`), nil +} +func (a replayTaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return DoTaskApiRequest(a, c, info, requestBody) +} +func (a replayTaskAdaptor) DoResponse(_ *gin.Context, _ *http.Response, _ *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { + return "", nil, nil +} +func (a replayTaskAdaptor) GetModelList() []string { return nil } +func (a replayTaskAdaptor) GetChannelName() string { return "replay-test" } +func (a replayTaskAdaptor) FetchTask(_, _ string, _ map[string]any, _ string) (*http.Response, error) { + return nil, nil +} +func (a replayTaskAdaptor) ParseTaskResult(_ []byte) (*relaycommon.TaskInfo, error) { + return nil, nil +} + +func TestDoTaskApiRequestReplaysBodyAfterRedirect(t *testing.T) { + fetchSetting := system_setting.GetFetchSetting() + originalFetchSetting := *fetchSetting + fetchSetting.EnableSSRFProtection = false + defer func() { + *fetchSetting = originalFetchSetting + }() + service.InitHttpClient() + + var finalBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + http.Redirect(w, r, "/final", http.StatusTemporaryRedirect) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + finalBody = string(body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil) + + adaptor := replayTaskAdaptor{url: server.URL + "/redirect"} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + body, err := adaptor.BuildRequestBody(c, info) + require.NoError(t, err) + resp, err := DoTaskApiRequest(adaptor, c, info, body) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.JSONEq(t, `{"prompt":"小猫在城市上空急速飞行"}`, finalBody) +} diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index a6dabb5f1086..617760738a6a 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -273,36 +273,74 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* Content: []ContentItem{}, } + metadata := req.Metadata + if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + // Add images if present if req.HasImage() { - for _, imgURL := range req.Images { + imageInputs := req.ImageInputs + if len(imageInputs) == 0 { + imageInputs = make([]relaycommon.TaskImageInput, 0, len(req.Images)) + for _, imgURL := range req.Images { + imageInputs = append(imageInputs, relaycommon.TaskImageInput{URL: imgURL}) + } + } + for _, imageInput := range imageInputs { + if imageInput.URL == "" { + continue + } r.Content = append(r.Content, ContentItem{ Type: "image_url", ImageURL: &MediaURL{ - URL: imgURL, + URL: imageInput.URL, }, + Role: imageInput.Role, }) } } - metadata := req.Metadata - if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil { - return nil, errors.Wrap(err, "unmarshal metadata failed") + if req.Width > 0 && req.Height > 0 { + r.Resolution = fmt.Sprintf("%dp", minInt(req.Width, req.Height)) + r.Ratio = fmt.Sprintf("%d:%d", req.Width/gcdInt(req.Width, req.Height), req.Height/gcdInt(req.Width, req.Height)) } - if sec, _ := strconv.Atoi(req.Seconds); sec > 0 { + if req.Duration > 0 { + r.Duration = lo.ToPtr(dto.IntValue(req.Duration)) + } else if sec, _ := strconv.Atoi(req.Seconds); sec > 0 { r.Duration = lo.ToPtr(dto.IntValue(sec)) } r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) - r.Content = append(r.Content, ContentItem{ - Type: "text", - Text: req.Prompt, - }) + r.Content = append([]ContentItem{{Type: "text", Text: req.Prompt}}, r.Content...) return &r, nil } +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func gcdInt(a, b int) int { + if a < 0 { + a = -a + } + if b < 0 { + b = -b + } + for b != 0 { + a, b = b, a%b + } + if a == 0 { + return 1 + } + return a +} + func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { resTask := responseTask{} if err := common.Unmarshal(respBody, &resTask); err != nil { diff --git a/relay/channel/task/doubao/adaptor_test.go b/relay/channel/task/doubao/adaptor_test.go new file mode 100644 index 000000000000..51ae7b08b124 --- /dev/null +++ b/relay/channel/task/doubao/adaptor_test.go @@ -0,0 +1,117 @@ +package doubao + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/stretchr/testify/require" +) + +func TestConvertUnifiedRequestToDoubaoPayload(t *testing.T) { + adaptor := &TaskAdaptor{} + + payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2.0", + Prompt: "小猫在城市上空急速飞行", + Duration: 5, + Width: 1280, + Height: 720, + }) + + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2.0", payload.Model) + require.Equal(t, "720p", payload.Resolution) + require.Equal(t, "16:9", payload.Ratio) + require.NotNil(t, payload.Duration) + require.Equal(t, 5, int(*payload.Duration)) + require.Len(t, payload.Content, 1) + require.Equal(t, "text", payload.Content[0].Type) + require.Equal(t, "小猫在城市上空急速飞行", payload.Content[0].Text) +} + +func TestConvertUnifiedRequestSerializesOfficialDoubaoContent(t *testing.T) { + adaptor := &TaskAdaptor{} + + payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2.0", + Prompt: "小猫在城市上空急速飞行", + Duration: 5, + Width: 1280, + Height: 720, + }) + require.NoError(t, err) + + data, err := common.Marshal(payload) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, common.Unmarshal(data, &body)) + require.Equal(t, "doubao-seedance-2.0", body["model"]) + require.Equal(t, "720p", body["resolution"]) + require.Equal(t, "16:9", body["ratio"]) + require.EqualValues(t, 5, body["duration"]) + + content, ok := body["content"].([]any) + require.True(t, ok) + require.Len(t, content, 1) + textItem, ok := content[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", textItem["type"]) + require.Equal(t, "小猫在城市上空急速飞行", textItem["text"]) +} + +func TestConvertUnifiedRequestOverridesMetadataText(t *testing.T) { + adaptor := &TaskAdaptor{} + + payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2.0", + Prompt: "用户侧统一提示词", + Metadata: map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "metadata 中的旧提示词", + }, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "https://example.com/cat.png", + }, + }, + }, + }, + }) + + require.NoError(t, err) + require.Len(t, payload.Content, 2) + require.Equal(t, "text", payload.Content[0].Type) + require.Equal(t, "用户侧统一提示词", payload.Content[0].Text) + require.Equal(t, "image_url", payload.Content[1].Type) + require.Equal(t, "https://example.com/cat.png", payload.Content[1].ImageURL.URL) +} + +func TestConvertUnifiedRequestPreservesImageRoles(t *testing.T) { + adaptor := &TaskAdaptor{} + + payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2.0", + Prompt: "hello", + Images: []string{ + "https://example.com/first.jpeg", + "https://example.com/last.jpeg", + }, + ImageInputs: []relaycommon.TaskImageInput{ + {URL: "https://example.com/first.jpeg", Role: "first_frame"}, + {URL: "https://example.com/last.jpeg", Role: "last_frame"}, + }, + }) + + require.NoError(t, err) + require.Len(t, payload.Content, 3) + require.Equal(t, "text", payload.Content[0].Type) + require.Equal(t, "https://example.com/first.jpeg", payload.Content[1].ImageURL.URL) + require.Equal(t, "first_frame", payload.Content[1].Role) + require.Equal(t, "https://example.com/last.jpeg", payload.Content[2].ImageURL.URL) + require.Equal(t, "last_frame", payload.Content[2].Role) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfaa..70d96ed9d5b8 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -674,16 +674,53 @@ type TaskRelayInfo struct { } type TaskSubmitReq struct { - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` - Mode string `json:"mode,omitempty"` - Image string `json:"image,omitempty"` - Images []string `json:"images,omitempty"` - Size string `json:"size,omitempty"` - Duration int `json:"duration,omitempty"` - Seconds string `json:"seconds,omitempty"` - InputReference string `json:"input_reference,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Images []string `json:"images,omitempty"` + ImageInputs []TaskImageInput `json:"-"` + Size string `json:"size,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` + Seconds string `json:"seconds,omitempty"` + FPS int `json:"fps,omitempty"` + FrameRate int `json:"frame_rate,omitempty"` + FramesPerSecond int `json:"framespersecond,omitempty"` + FramesPerSecondCamel int `json:"framesPerSecond,omitempty"` + InputReference string `json:"input_reference,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type TaskImageInput struct { + URL string `json:"url,omitempty"` + Role string `json:"role,omitempty"` +} + +func (i *TaskImageInput) UnmarshalJSON(data []byte) error { + var url string + if err := common.Unmarshal(data, &url); err == nil { + i.URL = url + return nil + } + + var obj struct { + URL string `json:"url,omitempty"` + Role string `json:"role,omitempty"` + ImageURL *struct { + URL string `json:"url,omitempty"` + } `json:"image_url,omitempty"` + } + if err := common.Unmarshal(data, &obj); err != nil { + return err + } + i.URL = obj.URL + i.Role = obj.Role + if i.URL == "" && obj.ImageURL != nil { + i.URL = obj.ImageURL.URL + } + return nil } func (t *TaskSubmitReq) GetPrompt() string { @@ -695,19 +732,44 @@ func (t *TaskSubmitReq) HasImage() bool { } func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error { - type Alias TaskSubmitReq - aux := &struct { - Metadata json.RawMessage `json:"metadata,omitempty"` - Duration json.RawMessage `json:"duration,omitempty"` - *Alias - }{ - Alias: (*Alias)(t), + var aux struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Images json.RawMessage `json:"images,omitempty"` + Size string `json:"size,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration json.RawMessage `json:"duration,omitempty"` + Seconds string `json:"seconds,omitempty"` + FPS int `json:"fps,omitempty"` + FrameRate int `json:"frame_rate,omitempty"` + FramesPerSecond int `json:"framespersecond,omitempty"` + FramesPerSecondCamel int `json:"framesPerSecond,omitempty"` + InputReference string `json:"input_reference,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Extra map[string]interface{} `json:"-"` } if err := common.Unmarshal(data, &aux); err != nil { return err } + t.Prompt = aux.Prompt + t.Model = aux.Model + t.Mode = aux.Mode + t.Image = aux.Image + t.Size = aux.Size + t.Width = aux.Width + t.Height = aux.Height + t.Seconds = aux.Seconds + t.FPS = aux.FPS + t.FrameRate = aux.FrameRate + t.FramesPerSecond = aux.FramesPerSecond + t.FramesPerSecondCamel = aux.FramesPerSecondCamel + t.InputReference = aux.InputReference + if len(aux.Duration) > 0 { var durationInt int if err := common.Unmarshal(aux.Duration, &durationInt); err == nil { @@ -722,6 +784,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error { } } + if len(aux.Images) > 0 { + imageInputs, err := parseTaskImageInputs(aux.Images) + if err != nil { + return err + } + t.ImageInputs = imageInputs + t.Images = make([]string, 0, len(imageInputs)) + for _, imageInput := range imageInputs { + if imageInput.URL != "" { + t.Images = append(t.Images, imageInput.URL) + } + } + } + if len(aux.Metadata) > 0 { var metadataStr string if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" { @@ -740,6 +816,19 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error { return nil } + +func parseTaskImageInputs(data []byte) ([]TaskImageInput, error) { + var single TaskImageInput + if err := common.Unmarshal(data, &single); err == nil && single.URL != "" { + return []TaskImageInput{single}, nil + } + + var images []TaskImageInput + if err := common.Unmarshal(data, &images); err != nil { + return nil, err + } + return images, nil +} func (t *TaskSubmitReq) UnmarshalMetadata(v any) error { metadata := t.Metadata if metadata != nil { diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 18df77a645d6..d50a939b44e2 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "unicode/utf8" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -12,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/samber/lo" + "golang.org/x/text/encoding/simplifiedchinese" ) type HasPrompt interface { @@ -102,6 +104,10 @@ func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string if images := formData["images"]; len(images) > 0 { req.Images = images + req.ImageInputs = make([]TaskImageInput, 0, len(images)) + for _, image := range images { + req.ImageInputs = append(req.ImageInputs, TaskImageInput{URL: image}) + } } for key, values := range formData { @@ -139,6 +145,7 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError { } if req.InputReference != "" { req.Images = []string{req.InputReference} + req.ImageInputs = []TaskImageInput{{URL: req.InputReference}} } if strings.TrimSpace(req.Model) == "" { @@ -206,7 +213,12 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d } } // 为了metadata字段的兼容性,统一UnmarshalBodyReusable - if err := common.UnmarshalBodyReusable(c, &req); err != nil { + if strings.HasPrefix(contentType, "application/json") { + err = unmarshalTaskJSONBody(c, &req) + } else { + err = common.UnmarshalBodyReusable(c, &req) + } + if err != nil { return createTaskError(err, "invalid_request", http.StatusBadRequest, true) } @@ -217,8 +229,30 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { // 兼容单图上传 req.Images = []string{req.Image} + req.ImageInputs = []TaskImageInput{{URL: req.Image}} } storeTaskRequest(c, info, action, req) return nil } + +func unmarshalTaskJSONBody(c *gin.Context, req *TaskSubmitReq) error { + storage, err := common.GetBodyStorage(c) + if err != nil { + return err + } + requestBody, err := storage.Bytes() + if err != nil { + return err + } + if utf8.Valid(requestBody) { + return common.Unmarshal(requestBody, req) + } + decoded, decodeErr := simplifiedchinese.GB18030.NewDecoder().Bytes(requestBody) + if decodeErr == nil { + if err := common.Unmarshal(decoded, req); err == nil { + return nil + } + } + return common.Unmarshal(requestBody, req) +} diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go new file mode 100644 index 000000000000..4845c02f5d13 --- /dev/null +++ b/relay/common/relay_utils_test.go @@ -0,0 +1,66 @@ +package common + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "golang.org/x/text/encoding/simplifiedchinese" +) + +func TestValidateBasicTaskRequestDecodesGB18030JSONPrompt(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"model":"doubao-seedance-2.0","prompt":"小猫在城市上空急速飞行","duration":5,"width":1280,"height":720}` + encodedBody, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(body)) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", bytes.NewReader(encodedBody)) + ctx.Request.Header.Set("Content-Type", "application/json") + + info := &RelayInfo{TaskRelayInfo: &TaskRelayInfo{}} + taskErr := ValidateBasicTaskRequest(ctx, info, constant.TaskActionGenerate) + require.Nil(t, taskErr) + + req, err := GetTaskRequest(ctx) + require.NoError(t, err) + require.Equal(t, "小猫在城市上空急速飞行", req.Prompt) + require.Equal(t, "doubao-seedance-2.0", req.Model) + require.Equal(t, 5, req.Duration) + require.Equal(t, 1280, req.Width) + require.Equal(t, 720, req.Height) +} + +func TestValidateBasicTaskRequestAcceptsImageObjects(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"doubao-seedance-2.0", + "prompt":"hello", + "images":[ + {"url":"https://example.com/first.jpeg","role":"first_frame"}, + {"image_url":{"url":"https://example.com/last.jpeg"},"role":"last_frame"} + ], + "duration":5 + }` + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", bytes.NewReader([]byte(body))) + ctx.Request.Header.Set("Content-Type", "application/json") + + info := &RelayInfo{TaskRelayInfo: &TaskRelayInfo{}} + taskErr := ValidateBasicTaskRequest(ctx, info, constant.TaskActionGenerate) + require.Nil(t, taskErr) + + req, err := GetTaskRequest(ctx) + require.NoError(t, err) + require.Equal(t, []string{"https://example.com/first.jpeg", "https://example.com/last.jpeg"}, req.Images) + require.Len(t, req.ImageInputs, 2) + require.Equal(t, "first_frame", req.ImageInputs[0].Role) + require.Equal(t, "last_frame", req.ImageInputs[1].Role) +} diff --git a/relay/helper/price.go b/relay/helper/price.go index 0e68edba206b..4fabb9cacdf5 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,6 +2,7 @@ package helper import ( "fmt" + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -167,6 +168,10 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { groupRatioInfo := HandleGroupRatio(c, info) + if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeVideoSeconds { + return modelPriceHelperVideoSeconds(c, info, groupRatioInfo) + } + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) usePrice := success var modelRatio float64 @@ -224,6 +229,206 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types return priceData, nil } +type videoSecondsBillingTrace struct { + Resolution string + Duration float64 + FPS float64 + BaseFPS float64 + FPSMultiplier float64 + PricePerSecond float64 + TotalPrice float64 +} + +func (t videoSecondsBillingTrace) toPriceDataTrace() *types.VideoSecondsTrace { + return &types.VideoSecondsTrace{ + Resolution: t.Resolution, + Duration: t.Duration, + FPS: t.FPS, + BaseFPS: t.BaseFPS, + FPSMultiplier: t.FPSMultiplier, + PricePerSecond: t.PricePerSecond, + TotalPrice: t.TotalPrice, + } +} + +func modelPriceHelperVideoSeconds(c *gin.Context, info *relaycommon.RelayInfo, groupRatioInfo types.GroupRatioInfo) (types.PriceData, error) { + cfg, ok := billing_setting.GetVideoPriceConfig(info.OriginModelName) + if !ok || len(cfg.Prices) == 0 { + return types.PriceData{}, fmt.Errorf("model %s video per-second price not configured", info.OriginModelName) + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return types.PriceData{}, err + } + trace, err := calculateVideoSecondsBilling(req, cfg) + if err != nil { + return types.PriceData{}, err + } + quota := billingexpr.QuotaRound(trace.TotalPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + priceData := types.PriceData{ + ModelPrice: trace.TotalPrice, + UsePrice: true, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + VideoSecondsTrace: trace.toPriceDataTrace(), + } + return priceData, nil +} + +func calculateVideoSecondsBilling(req relaycommon.TaskSubmitReq, cfg billing_setting.VideoPriceConfig) (videoSecondsBillingTrace, error) { + resolution := resolveVideoResolution(req) + if resolution == "" { + return videoSecondsBillingTrace{}, fmt.Errorf("video resolution is required for video per-second billing") + } + pricePerSecond, ok := lookupVideoResolutionPrice(cfg.Prices, resolution) + if !ok || pricePerSecond <= 0 { + return videoSecondsBillingTrace{}, fmt.Errorf("video resolution %s price not configured", resolution) + } + duration := resolveVideoDuration(req) + if duration <= 0 { + return videoSecondsBillingTrace{}, fmt.Errorf("video duration is required for video per-second billing") + } + baseFPS := cfg.BaseFPS + if baseFPS <= 0 { + baseFPS = 24 + } + fps := resolveVideoFPS(req) + if fps <= 0 { + fps = baseFPS + } + fpsMultiplier := fps / baseFPS + totalPrice := pricePerSecond * duration * fpsMultiplier + return videoSecondsBillingTrace{ + Resolution: resolution, + Duration: duration, + FPS: fps, + BaseFPS: baseFPS, + FPSMultiplier: fpsMultiplier, + PricePerSecond: pricePerSecond, + TotalPrice: totalPrice, + }, nil +} + +func lookupVideoResolutionPrice(prices map[string]float64, resolution string) (float64, bool) { + normalized := normalizeVideoResolution(resolution) + for key, price := range prices { + if normalizeVideoResolution(key) == normalized { + return price, true + } + } + return 0, false +} + +func resolveVideoDuration(req relaycommon.TaskSubmitReq) float64 { + if req.Duration > 0 { + return float64(req.Duration) + } + if sec, err := strconv.ParseFloat(strings.TrimSpace(req.Seconds), 64); err == nil && sec > 0 { + return sec + } + return firstPositiveMetadataNumber(req.Metadata, "duration", "seconds", "duration_seconds", "durationSeconds") +} + +func resolveVideoFPS(req relaycommon.TaskSubmitReq) float64 { + for _, v := range []int{req.FPS, req.FrameRate, req.FramesPerSecond, req.FramesPerSecondCamel} { + if v > 0 { + return float64(v) + } + } + return firstPositiveMetadataNumber(req.Metadata, "fps", "frame_rate", "frameRate", "framespersecond", "framesPerSecond") +} + +func resolveVideoResolution(req relaycommon.TaskSubmitReq) string { + for _, key := range []string{"resolution", "quality", "size"} { + if v, ok := req.Metadata[key].(string); ok && strings.TrimSpace(v) != "" { + return normalizeVideoResolution(v) + } + } + if strings.TrimSpace(req.Size) != "" { + if res := resolutionFromSize(req.Size); res != "" { + return res + } + } + width := req.Width + height := req.Height + if width <= 0 { + width = int(firstPositiveMetadataNumber(req.Metadata, "width")) + } + if height <= 0 { + height = int(firstPositiveMetadataNumber(req.Metadata, "height")) + } + if width > 0 && height > 0 { + shortSide := width + if height < shortSide { + shortSide = height + } + return normalizeVideoResolution(fmt.Sprintf("%dp", shortSide)) + } + return "" +} + +func resolutionFromSize(size string) string { + parts := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(size)), func(r rune) bool { + return r == 'x' || r == '*' || r == '×' + }) + if len(parts) != 2 { + return normalizeVideoResolution(size) + } + width, errW := strconv.Atoi(strings.TrimSpace(parts[0])) + height, errH := strconv.Atoi(strings.TrimSpace(parts[1])) + if errW != nil || errH != nil || width <= 0 || height <= 0 { + return normalizeVideoResolution(size) + } + shortSide := width + if height < shortSide { + shortSide = height + } + return normalizeVideoResolution(fmt.Sprintf("%dp", shortSide)) +} + +func normalizeVideoResolution(resolution string) string { + resolution = strings.ToLower(strings.TrimSpace(resolution)) + resolution = strings.ReplaceAll(resolution, " ", "") + if strings.HasSuffix(resolution, "p") { + return resolution + } + if v, err := strconv.Atoi(resolution); err == nil && v > 0 { + return fmt.Sprintf("%dp", v) + } + return resolution +} + +func firstPositiveMetadataNumber(metadata map[string]interface{}, keys ...string) float64 { + if metadata == nil { + return 0 + } + for _, key := range keys { + value, ok := metadata[key] + if !ok { + continue + } + switch v := value.(type) { + case int: + if v > 0 { + return float64(v) + } + case int64: + if v > 0 { + return float64(v) + } + case float64: + if v > 0 { + return v + } + case string: + if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && n > 0 { + return n + } + } + } + return 0 +} + func HasModelBillingConfig(modelName string) bool { if _, ok := ratio_setting.GetModelPrice(modelName, false); ok { return true diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index afa64c4b0eda..7c0f2b8a110b 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -60,3 +60,59 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) { require.Equal(t, billing_setting.BillingModeTieredExpr, info.TieredBillingSnapshot.BillingMode) require.Equal(t, common.QuotaPerUnit, info.TieredBillingSnapshot.QuotaPerUnit) } + +func TestCalculateVideoSecondsBilling(t *testing.T) { + trace, err := calculateVideoSecondsBilling(relaycommon.TaskSubmitReq{ + Duration: 5, + Width: 1280, + Height: 720, + FPS: 30, + }, billing_setting.VideoPriceConfig{ + BaseFPS: 24, + Prices: map[string]float64{ + "720p": 1, + "1080p": 2, + }, + }) + require.NoError(t, err) + require.Equal(t, "720p", trace.Resolution) + require.Equal(t, 5.0, trace.Duration) + require.Equal(t, 30.0/24.0, trace.FPSMultiplier) + require.Equal(t, 6.25, trace.TotalPrice) +} + +func TestModelPriceHelperVideoSecondsDoesNotExposeBillableRatios(t *testing.T) { + gin.SetMode(gin.TestMode) + + saved := map[string]string{} + require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error { + saved[key] = value + return nil + })) + t.Cleanup(func() { + require.NoError(t, config.GlobalConfig.LoadFromDB(saved)) + }) + + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "billing_setting.billing_mode": `{"video-test-model":"video_seconds"}`, + "billing_setting.video_price": `{"video-test-model":{"base_fps":24,"prices":{"720p":1}}}`, + })) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "video-test-model", + Prompt: "astronaut walking on the moon", + Duration: 5, + Width: 1280, + Height: 720, + }) + + priceData, err := modelPriceHelperVideoSeconds(ctx, &relaycommon.RelayInfo{ + OriginModelName: "video-test-model", + }, types.GroupRatioInfo{GroupRatio: 1}) + require.NoError(t, err) + require.Equal(t, billingexpr.QuotaRound(5*common.QuotaPerUnit), priceData.Quota) + require.Equal(t, 5.0, priceData.ModelPrice) + require.Empty(t, priceData.OtherRatios) +} diff --git a/service/rankings.go b/service/rankings.go index 01a096ddccfb..acaacde1a680 100644 --- a/service/rankings.go +++ b/service/rankings.go @@ -2,11 +2,14 @@ package service import ( "fmt" + "hash/fnv" "math" "sort" + "strconv" "sync" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" ) @@ -18,6 +21,9 @@ const ( rankingMoverLimit = 6 rankingOthersLabel = "Others" rankingUnknownVendor = "Unknown" + + rankingDisplayMultiplierOption = "RankingsDisplayMultiplier" + rankingDisplayJitterOption = "RankingsDisplayJitterRatio" ) type RankingsResponse struct { @@ -119,6 +125,11 @@ type rankingModelMeta struct { vendorIcon string } +type rankingDisplaySettings struct { + multiplier float64 + jitter float64 +} + type vendorAggregate struct { name string icon string @@ -141,20 +152,22 @@ func GetRankingsSnapshot(period string) (*RankingsResponse, error) { } now := time.Now() + displaySettings := getRankingDisplaySettings() + cacheKey := rankingCacheKey(config, displaySettings) rankingCacheMu.Lock() - if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) { + if item, ok := rankingCache[cacheKey]; ok && now.Before(item.expiresAt) { rankingCacheMu.Unlock() return item.data, nil } rankingCacheMu.Unlock() - data, err := buildRankingsSnapshot(config, now) + data, err := buildRankingsSnapshot(config, now, displaySettings) if err != nil { return nil, err } rankingCacheMu.Lock() - rankingCache[config.id] = rankingCacheItem{ + rankingCache[cacheKey] = rankingCacheItem{ expiresAt: now.Add(rankingCacheTTL), data: data, } @@ -180,7 +193,31 @@ func rankingConfig(period string) (rankingPeriodConfig, error) { } } -func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) { +func getRankingDisplaySettings() rankingDisplaySettings { + common.OptionMapRWMutex.RLock() + multiplierValue := common.OptionMap[rankingDisplayMultiplierOption] + jitterValue := common.OptionMap[rankingDisplayJitterOption] + common.OptionMapRWMutex.RUnlock() + + multiplier, err := strconv.ParseFloat(multiplierValue, 64) + if err != nil || multiplier < 0 { + multiplier = 1 + } + jitter, err := strconv.ParseFloat(jitterValue, 64) + if err != nil || jitter < 0 { + jitter = 0 + } + return rankingDisplaySettings{ + multiplier: multiplier, + jitter: jitter, + } +} + +func rankingCacheKey(config rankingPeriodConfig, settings rankingDisplaySettings) string { + return fmt.Sprintf("%s:display:%g:%g", config.id, settings.multiplier, settings.jitter) +} + +func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time, displaySettings rankingDisplaySettings) (*RankingsResponse, error) { startTime, endTime := rankingTimeRange(config, now) currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime) if err != nil { @@ -200,6 +237,10 @@ func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*Rankings } } + currentTotals = applyRankingDisplayToTotals(currentTotals, displaySettings, config.id+":current") + currentBuckets = applyRankingDisplayToBuckets(currentBuckets, displaySettings, config.id+":bucket") + previousTotals = applyRankingDisplayToTotals(previousTotals, displaySettings, config.id+":previous") + meta := buildRankingModelMeta() totalTokens := sumRankingTokens(currentTotals) previousRankByModel := rankingRankMap(previousTotals) @@ -513,6 +554,60 @@ func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) { return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit) } +func applyRankingDisplayToTotals(totals []model.RankingQuotaTotal, settings rankingDisplaySettings, saltPrefix string) []model.RankingQuotaTotal { + if !rankingDisplayEnabled(settings) || len(totals) == 0 { + return totals + } + rows := make([]model.RankingQuotaTotal, len(totals)) + for i, item := range totals { + rows[i] = item + rows[i].TotalTokens = rankingDisplayValue(item.TotalTokens, settings, fmt.Sprintf("%s:%s:%d", saltPrefix, item.ModelName, item.TotalTokens)) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].TotalTokens == rows[j].TotalTokens { + return rows[i].ModelName < rows[j].ModelName + } + return rows[i].TotalTokens > rows[j].TotalTokens + }) + return rows +} + +func applyRankingDisplayToBuckets(buckets []model.RankingQuotaBucket, settings rankingDisplaySettings, saltPrefix string) []model.RankingQuotaBucket { + if !rankingDisplayEnabled(settings) || len(buckets) == 0 { + return buckets + } + rows := make([]model.RankingQuotaBucket, len(buckets)) + for i, item := range buckets { + rows[i] = item + rows[i].Tokens = rankingDisplayValue(item.Tokens, settings, fmt.Sprintf("%s:%d:%s:%d", saltPrefix, item.Bucket, item.ModelName, item.Tokens)) + } + return rows +} + +func rankingDisplayEnabled(settings rankingDisplaySettings) bool { + return settings.multiplier != 1 || settings.jitter != 0 +} + +func rankingDisplayValue(value int64, settings rankingDisplaySettings, salt string) int64 { + if value <= 0 { + return 0 + } + scaled := float64(value) * settings.multiplier + if settings.jitter > 0 { + scaled += scaled * settings.jitter * rankingStableRandom01(salt) + } + if scaled <= 0 { + return 0 + } + return int64(math.Round(scaled)) +} + +func rankingStableRandom01(salt string) float64 { + hasher := fnv.New64a() + _, _ = hasher.Write([]byte(salt)) + return float64(hasher.Sum64()%1_000_000) / 1_000_000 +} + func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 { buckets := make([]int64, 0, len(bucketSet)) for bucket := range bucketSet { diff --git a/service/rankings_test.go b/service/rankings_test.go new file mode 100644 index 000000000000..47864fc23268 --- /dev/null +++ b/service/rankings_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" +) + +func TestRankingDisplayValueMultiplier(t *testing.T) { + settings := rankingDisplaySettings{multiplier: 12, jitter: 0} + + value := rankingDisplayValue(100, settings, "model-a") + + if value != 1200 { + t.Fatalf("expected multiplier to produce 1200, got %d", value) + } +} + +func TestApplyRankingDisplayToTotalsSortsByDisplayedValue(t *testing.T) { + settings := rankingDisplaySettings{multiplier: 1, jitter: 1} + totals := []model.RankingQuotaTotal{ + {ModelName: "model-a", TotalTokens: 100}, + {ModelName: "model-b", TotalTokens: 100}, + } + + rows := applyRankingDisplayToTotals(totals, settings, "test") + + if len(rows) != len(totals) { + t.Fatalf("expected %d rows, got %d", len(totals), len(rows)) + } + for _, row := range rows { + if row.TotalTokens < 100 || row.TotalTokens > 200 { + t.Fatalf("expected jittered value between 100 and 200, got %d", row.TotalTokens) + } + } + if rows[0].TotalTokens < rows[1].TotalTokens { + t.Fatalf("expected rows sorted by displayed value descending: %+v", rows) + } +} diff --git a/service/task_billing.go b/service/task_billing.go index 6cf7a965c8eb..076c94f430eb 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/billing_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" ) @@ -39,6 +40,19 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { other["is_task"] = true other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice + if billingMode := billing_setting.GetBillingMode(info.OriginModelName); billingMode == billing_setting.BillingModeVideoSeconds { + other["billing_mode"] = billingMode + other["video_total_price"] = info.PriceData.ModelPrice + if trace := info.PriceData.VideoSecondsTrace; trace != nil { + other["video_resolution"] = trace.Resolution + other["video_duration"] = trace.Duration + other["video_price_per_second"] = trace.PricePerSecond + other["video_fps"] = trace.FPS + other["video_base_fps"] = trace.BaseFPS + other["video_fps_multiplier"] = trace.FPSMultiplier + } + logContent = fmt.Sprintf("%s,视频按秒计费", logContent) + } if info.PriceData.ModelRatio > 0 { other["model_ratio"] = info.PriceData.ModelRatio } diff --git a/setting/billing_setting/tiered_billing.go b/setting/billing_setting/tiered_billing.go index 46dc70de257f..46ab0cdfd366 100644 --- a/setting/billing_setting/tiered_billing.go +++ b/setting/billing_setting/tiered_billing.go @@ -9,22 +9,31 @@ import ( ) const ( - BillingModeRatio = "ratio" - BillingModeTieredExpr = "tiered_expr" - BillingModeField = "billing_mode" - BillingExprField = "billing_expr" + BillingModeRatio = "ratio" + BillingModeTieredExpr = "tiered_expr" + BillingModeVideoSeconds = "video_seconds" + BillingModeField = "billing_mode" + BillingExprField = "billing_expr" + VideoPriceField = "video_price" ) // BillingSetting is managed by config.GlobalConfig.Register. -// DB keys: billing_setting.billing_mode, billing_setting.billing_expr +// DB keys: billing_setting.billing_mode, billing_setting.billing_expr, billing_setting.video_price +type VideoPriceConfig struct { + BaseFPS float64 `json:"base_fps,omitempty"` + Prices map[string]float64 `json:"prices,omitempty"` +} + type BillingSetting struct { - BillingMode map[string]string `json:"billing_mode"` - BillingExpr map[string]string `json:"billing_expr"` + BillingMode map[string]string `json:"billing_mode"` + BillingExpr map[string]string `json:"billing_expr"` + VideoPrice map[string]VideoPriceConfig `json:"video_price"` } var billingSetting = BillingSetting{ BillingMode: make(map[string]string), BillingExpr: make(map[string]string), + VideoPrice: make(map[string]VideoPriceConfig), } func init() { @@ -47,6 +56,11 @@ func GetBillingExpr(model string) (string, bool) { return expr, ok } +func GetVideoPriceConfig(model string) (VideoPriceConfig, bool) { + cfg, ok := billingSetting.VideoPrice[model] + return cfg, ok +} + func GetBillingModeCopy() map[string]string { return lo.Assign(billingSetting.BillingMode) } @@ -55,6 +69,10 @@ func GetBillingExprCopy() map[string]string { return lo.Assign(billingSetting.BillingExpr) } +func GetVideoPriceCopy() map[string]VideoPriceConfig { + return lo.Assign(billingSetting.VideoPrice) +} + func GetPricingSyncData(base map[string]any) map[string]any { extra := make(map[string]any, 2) if modes := GetBillingModeCopy(); len(modes) > 0 { @@ -63,6 +81,9 @@ func GetPricingSyncData(base map[string]any) map[string]any { if exprs := GetBillingExprCopy(); len(exprs) > 0 { extra[BillingExprField] = exprs } + if videoPrices := GetVideoPriceCopy(); len(videoPrices) > 0 { + extra[VideoPriceField] = videoPrices + } return lo.Assign(base, extra) } diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..55d8838918e3 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -21,12 +21,23 @@ type PriceData struct { AudioRatio float64 AudioCompletionRatio float64 OtherRatios map[string]float64 + VideoSecondsTrace *VideoSecondsTrace UsePrice bool Quota int // 按次计费的最终额度(MJ / Task) QuotaToPreConsume int // 按量计费的预消耗额度 GroupRatioInfo GroupRatioInfo } +type VideoSecondsTrace struct { + Resolution string + Duration float64 + FPS float64 + BaseFPS float64 + FPSMultiplier float64 + PricePerSecond float64 + TotalPrice float64 +} + func (p *PriceData) AddOtherRatio(key string, ratio float64) { if p.OtherRatios == nil { p.OtherRatios = make(map[string]float64) diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index 1a93ad0a1c16..63c2760b3bf3 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -16,20 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useState, useCallback, useMemo } from 'react' +import { useEffect, useState, useCallback } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ChevronDown, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' import { Form, FormControl, @@ -64,20 +59,11 @@ import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { JsonEditor } from '@/components/json-editor' import { TagInput } from '@/components/tag-input' -import { - useSystemOptions, - getOptionValue, -} from '@/features/system-settings/hooks/use-system-options' -import { useUpdateOption } from '@/features/system-settings/hooks/use-update-option' -import { normalizeJsonString } from '@/features/system-settings/models/utils' -import type { ModelSettings } from '@/features/system-settings/types' -import { safeJsonParse } from '@/features/system-settings/utils/json-parser' import { createModel, updateModel, getModel, getVendors } from '../../api' import { getNameRuleOptions, ENDPOINT_TEMPLATES } from '../../constants' import { modelsQueryKeys, vendorsQueryKeys, parseModelTags } from '../../lib' import type { Model } from '../../types' -// Extended schema for ratio configuration (internal form state only) const extendedModelFormSchema = z.object({ id: z.number().optional(), model_name: z.string().min(1, 'Model name is required'), @@ -89,20 +75,10 @@ const extendedModelFormSchema = z.object({ name_rule: z.number(), status: z.boolean(), sync_official: z.boolean(), - price: z.string().optional(), - ratio: z.string().optional(), - cacheRatio: z.string().optional(), - completionRatio: z.string().optional(), - imageRatio: z.string().optional(), - audioRatio: z.string().optional(), - audioCompletionRatio: z.string().optional(), }) type ExtendedModelFormValues = z.infer -type PricingMode = 'per-token' | 'per-request' -type PricingSubMode = 'ratio' | 'price' - type ModelMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void @@ -118,12 +94,6 @@ export function ModelMutateDrawer({ const queryClient = useQueryClient() const isEditing = Boolean(currentRow?.id) const [isSubmitting, setIsSubmitting] = useState(false) - const [pricingMode, setPricingMode] = useState('per-token') - const [pricingSubMode, setPricingSubMode] = useState('ratio') - const [advancedOpen, setAdvancedOpen] = useState(false) - const [promptPrice, setPromptPrice] = useState('') - const [completionPrice, setCompletionPrice] = useState('') - const [oldModelName, setOldModelName] = useState('') // Fetch vendors for dropdown const { data: vendorsData } = useQuery({ @@ -141,63 +111,6 @@ export function ModelMutateDrawer({ enabled: open && isEditing, }) - // Fetch system options for ratio configuration - const { data: systemOptionsData } = useSystemOptions() - - const updateOption = useUpdateOption() - - // Get model settings from system options - const modelSettings = useMemo(() => { - if (!systemOptionsData?.data) return null - const defaultModelSettings: ModelSettings = { - 'global.pass_through_request_enabled': false, - 'global.thinking_model_blacklist': '[]', - 'global.chat_completions_to_responses_policy': '{}', - 'general_setting.ping_interval_enabled': false, - 'general_setting.ping_interval_seconds': 60, - 'gemini.safety_settings': '', - 'gemini.version_settings': '', - 'gemini.supported_imagine_models': '', - 'gemini.thinking_adapter_enabled': false, - 'gemini.thinking_adapter_budget_tokens_percentage': 0.6, - 'gemini.function_call_thought_signature_enabled': false, - 'gemini.remove_function_response_id_enabled': true, - 'claude.model_headers_settings': '', - 'claude.default_max_tokens': '', - 'claude.thinking_adapter_enabled': true, - 'claude.thinking_adapter_budget_tokens_percentage': 0.8, - ModelPrice: '', - ModelRatio: '', - CacheRatio: '', - CompletionRatio: '', - ImageRatio: '', - AudioRatio: '', - AudioCompletionRatio: '', - ExposeRatioEnabled: false, - 'billing_setting.billing_mode': '{}', - 'billing_setting.billing_expr': '{}', - 'tool_price_setting.prices': '{}', - TopupGroupRatio: '', - GroupRatio: '', - UserUsableGroups: '', - GroupGroupRatio: '', - AutoGroups: '', - DefaultUseAutoGroup: false, - CreateCacheRatio: '', - 'group_ratio_setting.group_special_usable_group': '{}', - 'grok.violation_deduction_enabled': false, - 'grok.violation_deduction_amount': 0, - 'channel_affinity_setting.enabled': false, - 'channel_affinity_setting.switch_on_success': true, - 'channel_affinity_setting.max_entries': 100000, - 'channel_affinity_setting.default_ttl_seconds': 3600, - 'channel_affinity_setting.rules': '[]', - 'model_deployment.ionet.api_key': '', - 'model_deployment.ionet.enabled': false, - } - return getOptionValue(systemOptionsData.data, defaultModelSettings) - }, [systemOptionsData]) - const form = useForm({ resolver: zodResolver(extendedModelFormSchema), defaultValues: { @@ -210,55 +123,15 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', }, }) - const validateNumber = (value: string) => { - if (value === '') return true - return !isNaN(parseFloat(value)) - } - - const handlePromptPriceChange = (value: string) => { - setPromptPrice(value) - if (value && !isNaN(parseFloat(value))) { - const ratio = parseFloat(value) / 2 - form.setValue('ratio', ratio.toString()) - } else { - form.setValue('ratio', '') - } - } - - const handleCompletionPriceChange = (value: string) => { - setCompletionPrice(value) - if ( - value && - !isNaN(parseFloat(value)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) && - parseFloat(promptPrice) > 0 - ) { - const completionRatio = parseFloat(value) / parseFloat(promptPrice) - form.setValue('completionRatio', completionRatio.toString()) - } else { - form.setValue('completionRatio', '') - } - } - - // Load model data for editing and ratio configuration + // Load model data for editing useEffect(() => { if (open && isEditing && modelData?.data) { const model = modelData.data - setOldModelName(model.model_name) - // Base model data reset - const baseModelData = { + form.reset({ id: model.id, model_name: model.model_name, description: model.description || '', @@ -269,100 +142,9 @@ export function ModelMutateDrawer({ name_rule: model.name_rule || 0, status: model.status === 1, sync_official: model.sync_official === 1, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', - } - - // Parse ratio configurations from system settings if available - if (modelSettings) { - const priceMap = safeJsonParse>( - modelSettings.ModelPrice, - { fallback: {}, silent: true } - ) - const ratioMap = safeJsonParse>( - modelSettings.ModelRatio, - { fallback: {}, silent: true } - ) - const cacheMap = safeJsonParse>( - modelSettings.CacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - modelSettings.CompletionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>( - modelSettings.ImageRatio, - { fallback: {}, silent: true } - ) - const audioMap = safeJsonParse>( - modelSettings.AudioRatio, - { fallback: {}, silent: true } - ) - const audioCompletionMap = safeJsonParse>( - modelSettings.AudioCompletionRatio, - { fallback: {}, silent: true } - ) - - // Extract ratio config for this model - const modelName = model.model_name - const price = priceMap[modelName] - const ratio = ratioMap[modelName] - const cacheRatio = cacheMap[modelName] - const completionRatio = completionMap[modelName] - const imageRatio = imageMap[modelName] - const audioRatio = audioMap[modelName] - const audioCompletionRatio = audioCompletionMap[modelName] - - // Determine pricing mode - if (price !== undefined && price !== null) { - setPricingMode('per-request') - form.reset({ - ...baseModelData, - price: price.toString(), - }) - } else { - setPricingMode('per-token') - if (ratio !== undefined && ratio !== null) { - const tokenPrice = ratio * 2 - setPromptPrice(tokenPrice.toString()) - if (completionRatio !== undefined && completionRatio !== null) { - const compPrice = tokenPrice * completionRatio - setCompletionPrice(compPrice.toString()) - } - } - form.reset({ - ...baseModelData, - ratio: ratio?.toString() || '', - cacheRatio: cacheRatio?.toString() || '', - completionRatio: completionRatio?.toString() || '', - imageRatio: imageRatio?.toString() || '', - audioRatio: audioRatio?.toString() || '', - audioCompletionRatio: audioCompletionRatio?.toString() || '', - }) - setAdvancedOpen( - !!(cacheRatio || imageRatio || audioRatio || audioCompletionRatio) - ) - } - } else { - // If system settings not loaded yet, just load base model data - setPricingMode('per-token') - form.reset(baseModelData) - setAdvancedOpen(false) - } + }) } else if (open && !isEditing) { // Pre-fill model name if passed from missing models - setOldModelName('') - setPricingMode('per-token') - setPricingSubMode('ratio') - setPromptPrice('') - setCompletionPrice('') - setAdvancedOpen(false) form.reset({ model_name: currentRow?.model_name || '', description: '', @@ -373,16 +155,9 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', }) } - }, [open, isEditing, modelData, currentRow, form, modelSettings]) + }, [open, isEditing, modelData, currentRow, form]) const onSubmit = useCallback( async (values: ExtendedModelFormValues): Promise => { @@ -396,198 +171,11 @@ export function ModelMutateDrawer({ sync_official: values.sync_official ? 1 : 0, } - // Remove ratio fields from model data (they're stored in system settings) - const { - price, - ratio, - cacheRatio, - completionRatio, - imageRatio, - audioRatio, - audioCompletionRatio, - ...modelData - } = submitData - const response = isEditing - ? await updateModel({ ...modelData, id: currentRow!.id }) - : await createModel(modelData) + ? await updateModel({ ...submitData, id: currentRow!.id }) + : await createModel(submitData) if (response.success) { - // Handle ratio configuration updates in system settings - const finalModelName = values.model_name - const hasRatioConfig = - (pricingMode === 'per-request' && - values.price && - values.price !== '') || - (pricingMode === 'per-token' && - (values.ratio || - values.cacheRatio || - values.completionRatio || - values.imageRatio || - values.audioRatio || - values.audioCompletionRatio)) - - // Always process system settings updates if we have modelSettings - // This ensures we can remove stale entries even when clearing all pricing fields - if (modelSettings) { - // Read existing configurations - const priceMap = safeJsonParse>( - modelSettings.ModelPrice, - { fallback: {}, silent: true } - ) - const ratioMap = safeJsonParse>( - modelSettings.ModelRatio, - { fallback: {}, silent: true } - ) - const cacheMap = safeJsonParse>( - modelSettings.CacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - modelSettings.CompletionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>( - modelSettings.ImageRatio, - { fallback: {}, silent: true } - ) - const audioMap = safeJsonParse>( - modelSettings.AudioRatio, - { fallback: {}, silent: true } - ) - const audioCompletionMap = safeJsonParse>( - modelSettings.AudioCompletionRatio, - { fallback: {}, silent: true } - ) - - // Remove old model name entries if model name changed (always, even if no new config) - if (isEditing && oldModelName && oldModelName !== finalModelName) { - delete priceMap[oldModelName] - delete ratioMap[oldModelName] - delete cacheMap[oldModelName] - delete completionMap[oldModelName] - delete imageMap[oldModelName] - delete audioMap[oldModelName] - delete audioCompletionMap[oldModelName] - } - - // Remove current model name from all maps first (always, to handle mode switches or clearing) - // This ensures stale entries are removed even when user clears all fields - delete priceMap[finalModelName] - delete ratioMap[finalModelName] - delete cacheMap[finalModelName] - delete completionMap[finalModelName] - delete imageMap[finalModelName] - delete audioMap[finalModelName] - delete audioCompletionMap[finalModelName] - - // Only add new entries if user provided new configuration - if (hasRatioConfig) { - if ( - pricingMode === 'per-request' && - values.price && - values.price !== '' - ) { - priceMap[finalModelName] = parseFloat(values.price) - } else if (pricingMode === 'per-token') { - if (values.ratio && values.ratio !== '') { - ratioMap[finalModelName] = parseFloat(values.ratio) - } - if (values.cacheRatio && values.cacheRatio !== '') { - cacheMap[finalModelName] = parseFloat(values.cacheRatio) - } - if (values.completionRatio && values.completionRatio !== '') { - completionMap[finalModelName] = parseFloat( - values.completionRatio - ) - } - if (values.imageRatio && values.imageRatio !== '') { - imageMap[finalModelName] = parseFloat(values.imageRatio) - } - if (values.audioRatio && values.audioRatio !== '') { - audioMap[finalModelName] = parseFloat(values.audioRatio) - } - if ( - values.audioCompletionRatio && - values.audioCompletionRatio !== '' - ) { - audioCompletionMap[finalModelName] = parseFloat( - values.audioCompletionRatio - ) - } - } - } - - // Update system options if there are changes - const updates: Array<{ key: string; value: string }> = [] - - const newModelPrice = normalizeJsonString(JSON.stringify(priceMap)) - if ( - newModelPrice !== normalizeJsonString(modelSettings.ModelPrice) - ) { - updates.push({ key: 'ModelPrice', value: newModelPrice }) - } - - const newModelRatio = normalizeJsonString(JSON.stringify(ratioMap)) - if ( - newModelRatio !== normalizeJsonString(modelSettings.ModelRatio) - ) { - updates.push({ key: 'ModelRatio', value: newModelRatio }) - } - - const newCacheRatio = normalizeJsonString(JSON.stringify(cacheMap)) - if ( - newCacheRatio !== normalizeJsonString(modelSettings.CacheRatio) - ) { - updates.push({ key: 'CacheRatio', value: newCacheRatio }) - } - - const newCompletionRatio = normalizeJsonString( - JSON.stringify(completionMap) - ) - if ( - newCompletionRatio !== - normalizeJsonString(modelSettings.CompletionRatio) - ) { - updates.push({ - key: 'CompletionRatio', - value: newCompletionRatio, - }) - } - - const newImageRatio = normalizeJsonString(JSON.stringify(imageMap)) - if ( - newImageRatio !== normalizeJsonString(modelSettings.ImageRatio) - ) { - updates.push({ key: 'ImageRatio', value: newImageRatio }) - } - - const newAudioRatio = normalizeJsonString(JSON.stringify(audioMap)) - if ( - newAudioRatio !== normalizeJsonString(modelSettings.AudioRatio) - ) { - updates.push({ key: 'AudioRatio', value: newAudioRatio }) - } - - const newAudioCompletionRatio = normalizeJsonString( - JSON.stringify(audioCompletionMap) - ) - if ( - newAudioCompletionRatio !== - normalizeJsonString(modelSettings.AudioCompletionRatio) - ) { - updates.push({ - key: 'AudioCompletionRatio', - value: newAudioCompletionRatio, - }) - } - - // Apply all updates (including deletions when clearing fields) - for (const update of updates) { - await updateOption.mutateAsync(update) - } - } - toast.success( isEditing ? 'Model updated successfully' @@ -605,16 +193,7 @@ export function ModelMutateDrawer({ setIsSubmitting(false) } }, - [ - isEditing, - currentRow, - queryClient, - onOpenChange, - pricingMode, - oldModelName, - modelSettings, - updateOption, - ] + [isEditing, currentRow, queryClient, onOpenChange] ) const handleFillEndpointTemplate = (templateKey: string) => { @@ -887,349 +466,6 @@ export function ModelMutateDrawer({ - {/* Pricing Configuration */} -
-

- {t('Pricing Configuration')} -

- -
- - - setPricingMode(value as PricingMode) - } - > -
- - -
-
- - -
-
-
- - {pricingMode === 'per-request' ? ( - ( - - {t('Fixed price (USD)')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t( - 'Cost in USD per request, regardless of tokens used.' - )} - - - - )} - /> - ) : ( - <> -
- - - setPricingSubMode(value as PricingSubMode) - } - > -
- - -
-
- - -
-
-
- - {pricingSubMode === 'ratio' ? ( - <> - ( - - {t('Model ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - if (value) { - setPromptPrice( - (parseFloat(value) * 2).toString() - ) - } else { - setPromptPrice('') - } - } - }} - /> - - - {field.value && !isNaN(parseFloat(field.value)) - ? `Calculated price: $${(parseFloat(field.value) * 2).toFixed(4)} per 1M tokens` - : t('Multiplier for prompt tokens.')} - - - - )} - /> - - ( - - {t('Completion ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - const ratio = form.getValues('ratio') - if (value && ratio) { - const compPrice = - parseFloat(ratio) * - 2 * - parseFloat(value) - setCompletionPrice(compPrice.toString()) - } else { - setCompletionPrice('') - } - } - }} - /> - - - {field.value && - !isNaN(parseFloat(field.value)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) - ? `Calculated price: $${(parseFloat(promptPrice) * parseFloat(field.value)).toFixed(4)} per 1M tokens` - : t('Multiplier for completion tokens.')} - - - - )} - /> - - ) : ( - <> -
-
- - - handlePromptPriceChange(e.target.value) - } - /> -

- {promptPrice && !isNaN(parseFloat(promptPrice)) - ? `Calculated ratio: ${(parseFloat(promptPrice) / 2).toFixed(4)}` - : t('Enter Input price to calculate ratio')} -

-
- -
- - - handleCompletionPriceChange(e.target.value) - } - /> -

- {completionPrice && - !isNaN(parseFloat(completionPrice)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) && - parseFloat(promptPrice) > 0 - ? `Calculated ratio: ${(parseFloat(completionPrice) / parseFloat(promptPrice)).toFixed(4)}` - : t('Enter Completion price to calculate ratio')} -

-
-
- - )} - - - - } - > - {t('Advanced options')} - - - - ( - - {t('Cache ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Discount ratio for cache hits.')} - - - - )} - /> - - ( - - {t('Image ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for image processing.')} - - - - )} - /> - - ( - - {t('Audio ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for audio inputs.')} - - - - )} - /> - - ( - - {t('Audio completion ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for audio outputs.')} - - - - )} - /> - - - - )} -
- - - {/* Status & Sync */}

{t('Status & Sync')}

diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index a8d792bc87e6..e44a32e1e64b 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -30,7 +30,12 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { isTokenBasedModel } from '../lib/model-helpers' -import { formatPrice, formatRequestPrice } from '../lib/price' +import { + formatPrice, + formatRequestPrice, + formatVideoSecondPrice, + getVideoPriceEntries, +} from '../lib/price' import type { PricingModel, TokenUnit } from '../types' import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge' @@ -63,6 +68,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const isDynamicPricing = props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr) + const isVideoSeconds = props.model.billing_mode === 'video_seconds' const hasCachedPrice = isTokenBased && props.model.cache_ratio != null const dynamicSummary = isDynamicPricing ? getDynamicPricingSummary(props.model, { @@ -76,6 +82,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const primaryGroup = groups[0] const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)] + const videoPriceEntries = isVideoSeconds + ? getVideoPriceEntries(props.model).slice(0, 2) + : [] const hiddenCount = Math.max(groups.length - 1, 0) + Math.max(endpoints.length - 2, 0) + @@ -138,6 +147,36 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { {t('Dynamic Pricing')} ) + ) : isVideoSeconds ? ( + <> + {videoPriceEntries.length > 0 ? ( + videoPriceEntries.map((entry) => ( + + {entry.resolution}{' '} + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + showRechargePrice, + priceRate, + usdExchangeRate + )} + + /{t('second')} + + )) + ) : ( + - + )} + ) : isTokenBased ? ( <> @@ -227,15 +266,19 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {

{/* Footer: left metadata and right performance summary share row alignment */} -
+
{primaryGroup && ( - + {primaryGroup} {t('Groups')} )} - - {isTokenBased ? t('Token-based') : t('Per Request')} + + {isVideoSeconds + ? t('Video per-second') + : isTokenBased + ? t('Token-based') + : t('Per Request')} {isDynamicPricing && ( )}
- +
{bottomTags.map((item) => ( - + {item} ))} - - {tokenUnitLabel} - + {!isVideoSeconds && ( + + {tokenUnitLabel} + + )} {hiddenCount > 0 && ( - + +{hiddenCount} )} diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 2746b221e0f2..92e3b6aa66ac 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' -import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react' +import { ArrowLeft, HeartPulse, Info, Timer } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' @@ -60,20 +60,15 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers' -import { inferModelMetadata } from '../lib/model-metadata' -import { formatFixedPrice, formatGroupPrice } from '../lib/price' -import type { - Modality, - ModelCapability, - PriceType, - PricingModel, - TokenUnit, -} from '../types' +import { + formatFixedPrice, + formatGroupPrice, + formatVideoSecondPrice, + getVideoPriceEntries, +} from '../lib/price' +import type { PriceType, PricingModel, TokenUnit } from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' -import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' -import { ModalityIcons } from './model-details-modalities' import { ModelDetailsPerformance } from './model-details-performance' -import { ModelDetailsQuickStats } from './model-details-quick-stats' // ---------------------------------------------------------------------------- // Local UI helpers @@ -87,87 +82,6 @@ function SectionTitle(props: { children: React.ReactNode }) { ) } -const CAPABILITY_LABEL_KEYS: Record = { - function_calling: 'Function calling', - streaming: 'Streaming', - vision: 'Vision', - json_mode: 'JSON mode', - structured_output: 'Structured output', - reasoning: 'Reasoning', - tools: 'Tools', - system_prompt: 'System prompt', - web_search: 'Web search', - code_interpreter: 'Code interpreter', - caching: 'Prompt caching', - embeddings: 'Embeddings', -} - -function CompactCapabilityList(props: { capabilities: ModelCapability[] }) { - const { t } = useTranslation() - - if (props.capabilities.length === 0) { - return ( - - {t('No capabilities reported for this model.')} - - ) - } - - return ( -
- {props.capabilities.map((capability) => ( - - {t(CAPABILITY_LABEL_KEYS[capability] ?? capability)} - - ))} -
- ) -} - -function CompactModalities(props: { input: Modality[]; output: Modality[] }) { - const { t } = useTranslation() - - return ( -
-
- - {t('Input')} - - -
-
- - {t('Output')} - - -
-
- ) -} - -function ModelSignalsSection(props: { - capabilities: ModelCapability[] - input: Modality[] - output: Modality[] -}) { - const { t } = useTranslation() - - return ( -
- - {t('Capabilities')} / {t('Supported modalities')} - -
- - -
-
- ) -} - function OverviewMetric(props: { icon: React.ComponentType<{ className?: string }> label: string @@ -299,9 +213,11 @@ function ModelHeader(props: { model: PricingModel }) { )} · - {model.quota_type === QUOTA_TYPE_VALUES.TOKEN - ? t('Token-based') - : t('Per Request')} + {model.billing_mode === 'video_seconds' + ? t('Video per-second') + : model.quota_type === QUOTA_TYPE_VALUES.TOKEN + ? t('Token-based') + : t('Per Request')} {model.billing_mode === 'tiered_expr' && model.billing_expr && ( <> @@ -397,6 +313,50 @@ function PriceSection(props: { }, ] + if (props.model.billing_mode === 'video_seconds') { + const entries = getVideoPriceEntries(props.model) + return ( +
+ {t('Base Price')} + {entries.length > 0 ? ( +
+
+ {entries.map((entry) => ( +
+ + {entry.resolution} + + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate + )} + + / {t('second')} + + +
+ ))} +
+
+ ) : ( +

-

+ )} +
+ ) + } + if (dynamicSummary) { if (dynamicSummary.isSpecialExpression) { return ( @@ -644,6 +604,72 @@ function GroupPricingSection(props: { const thClass = 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase' + if (props.model.billing_mode === 'video_seconds') { + const videoEntries = getVideoPriceEntries(props.model) + return ( +
+ {t('Pricing by Group')} + +
+ + + + {t('Group')} + {t('Ratio')} + {videoEntries.map((entry) => ( + + {entry.resolution} + + ))} + + + + {availableGroups.map((group) => { + const ratio = props.groupRatio[group] || 1 + return ( + + + + + + {ratio}x + + {videoEntries.map((entry) => ( + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + showRechargePrice, + props.priceRate, + props.usdExchangeRate, + ratio + )} + + ))} + + ) + })} + +
+

+ {t('Prices shown per second')} +

+
+
+ ) + } + if (isDynamicPricingModel(props.model)) { const dynamicTiers = getDynamicPricingTiers(props.model) @@ -882,7 +908,7 @@ function GroupPricingSection(props: { ) } -const TAB_VALUES = ['overview', 'performance', 'api'] as const +const TAB_VALUES = ['overview', 'performance'] as const type TabValue = (typeof TAB_VALUES)[number] const TAB_META: Record< @@ -891,7 +917,6 @@ const TAB_META: Record< > = { overview: { icon: Info, labelKey: 'Overview' }, performance: { icon: HeartPulse, labelKey: 'Performance' }, - api: { icon: Code2, labelKey: 'API' }, } export interface ModelDetailsContentProps { @@ -909,7 +934,6 @@ export interface ModelDetailsContentProps { export function ModelDetailsContent(props: ModelDetailsContentProps) { const { t } = useTranslation() const showRechargePrice = props.showRechargePrice ?? false - const metadata = useMemo(() => inferModelMetadata(props.model), [props.model]) const isDynamic = props.model.billing_mode === 'tiered_expr' && @@ -963,27 +987,12 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { /> - - - - - - - -
) diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index c6f26406f2b4..d61c53e11bf5 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -37,6 +37,8 @@ import { isTokenBasedModel } from '../lib/model-helpers' import { formatPrice, formatRequestPrice, + formatVideoSecondPrice, + getVideoPriceEntries, stripTrailingZeros, } from '../lib/price' import type { PricingModel, TokenUnit } from '../types' @@ -140,9 +142,10 @@ export function usePricingColumns( header: t('Type'), cell: ({ row }) => { const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN + const isVideo = row.original.billing_mode === 'video_seconds' return ( - {isTokenBased ? t('Token') : t('Request')} + {isVideo ? t('Video') : isTokenBased ? t('Token') : t('Request')} ) }, @@ -216,6 +219,27 @@ export function usePricingColumns( ) } + if (model.billing_mode === 'video_seconds') { + const price = stripTrailingZeros( + formatVideoSecondPrice( + model, + showRechargePrice, + priceRate, + usdExchangeRate + ) + ) + const firstEntry = getVideoPriceEntries(model)[0] + return ( +
+ {price} +
+ / {t('second')} + {firstEntry ? ` 路 ${firstEntry.resolution}` : ''} +
+
+ ) + } + const isTokenBased = isTokenBasedModel(model) if (isTokenBased) { diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 99b161e94358..44f77778c7ad 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -199,12 +199,26 @@ export function PricingSidebar(props: PricingSidebarProps) { { value: QUOTA_TYPES.TOKEN, label: quotaTypeLabels[QUOTA_TYPES.TOKEN], - count: countBy(props.models, (model) => model.quota_type === 0), + count: countBy( + props.models, + (model) => model.billing_mode !== 'video_seconds' && model.quota_type === 0 + ), }, { value: QUOTA_TYPES.REQUEST, label: quotaTypeLabels[QUOTA_TYPES.REQUEST], - count: countBy(props.models, (model) => model.quota_type === 1), + count: countBy( + props.models, + (model) => model.billing_mode !== 'video_seconds' && model.quota_type === 1 + ), + }, + { + value: QUOTA_TYPES.VIDEO, + label: quotaTypeLabels[QUOTA_TYPES.VIDEO], + count: countBy( + props.models, + (model) => model.billing_mode === 'video_seconds' + ), }, ] @@ -238,7 +252,10 @@ export function PricingSidebar(props: PricingSidebarProps) { label, count: countBy( props.models, - (model) => model.supported_endpoint_types?.includes(value) ?? false + (model) => + model.supported_endpoint_types?.includes(value) || + (value === ENDPOINT_TYPES.OPENAI_VIDEO && + model.billing_mode === 'video_seconds') ), })), ] diff --git a/web/default/src/features/pricing/constants.ts b/web/default/src/features/pricing/constants.ts index baee2650881e..fdfde592b338 100644 --- a/web/default/src/features/pricing/constants.ts +++ b/web/default/src/features/pricing/constants.ts @@ -48,6 +48,7 @@ export const QUOTA_TYPES = { ALL: 'all', TOKEN: 'token', REQUEST: 'request', + VIDEO: 'video', } as const export type QuotaTypeOption = (typeof QUOTA_TYPES)[keyof typeof QUOTA_TYPES] @@ -60,6 +61,7 @@ export function getQuotaTypeLabels( [QUOTA_TYPES.ALL]: t('All Models'), [QUOTA_TYPES.TOKEN]: t('Token-based'), [QUOTA_TYPES.REQUEST]: t('Per Request'), + [QUOTA_TYPES.VIDEO]: t('Video per-second'), } } diff --git a/web/default/src/features/pricing/lib/filters.ts b/web/default/src/features/pricing/lib/filters.ts index 83788dd700f6..bfc11746ba5a 100644 --- a/web/default/src/features/pricing/lib/filters.ts +++ b/web/default/src/features/pricing/lib/filters.ts @@ -78,11 +78,16 @@ export function filterByQuotaType( quotaType: string ): PricingModel[] { if (quotaType === QUOTA_TYPES.ALL) return models + if (quotaType === QUOTA_TYPES.VIDEO) { + return models.filter((m) => m.billing_mode === 'video_seconds') + } const targetType = quotaType === QUOTA_TYPES.TOKEN ? QUOTA_TYPE_VALUES.TOKEN : QUOTA_TYPE_VALUES.REQUEST - return models.filter((m) => m.quota_type === targetType) + return models.filter( + (m) => m.billing_mode !== 'video_seconds' && m.quota_type === targetType + ) } /** @@ -93,6 +98,13 @@ export function filterByEndpointType( endpointType: string ): PricingModel[] { if (endpointType === ENDPOINT_TYPES.ALL) return models + if (endpointType === ENDPOINT_TYPES.OPENAI_VIDEO) { + return models.filter( + (m) => + m.supported_endpoint_types?.includes(endpointType) || + m.billing_mode === 'video_seconds' + ) + } return models.filter((m) => m.supported_endpoint_types?.includes(endpointType) ) @@ -102,6 +114,12 @@ export function filterByEndpointType( * Get model price for sorting */ function getModelPrice(model: PricingModel): number { + if (model.billing_mode === 'video_seconds') { + const prices = Object.values(model.video_price?.prices || {}) + .map(Number) + .filter((price) => Number.isFinite(price) && price > 0) + return prices.length > 0 ? Math.min(...prices) : 0 + } return model.quota_type === 0 ? model.model_ratio : model.model_price || 0 } diff --git a/web/default/src/features/pricing/lib/price.ts b/web/default/src/features/pricing/lib/price.ts index 96a90f3b37e8..37aae851d764 100644 --- a/web/default/src/features/pricing/lib/price.ts +++ b/web/default/src/features/pricing/lib/price.ts @@ -138,6 +138,12 @@ const REQUEST_PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { abbreviate: false, } +const VIDEO_PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 4, + abbreviate: false, +} + function formatPaymentCurrency( amount: number, options: CurrencyFormatOptions @@ -285,3 +291,30 @@ export function formatRequestPrice( REQUEST_PRICE_FORMAT_OPTIONS ) } + +export function getVideoPriceEntries( + model: PricingModel +): Array<{ resolution: string; price: number }> { + const prices = model.video_price?.prices || {} + return Object.entries(prices) + .map(([resolution, price]) => ({ resolution, price: Number(price) })) + .filter((entry) => Number.isFinite(entry.price) && entry.price > 0) + .sort((a, b) => a.price - b.price) +} + +export function formatVideoSecondPrice( + model: PricingModel, + showWithRecharge = false, + priceRate = 1, + _usdExchangeRate = 1, + ratio = 1 +): string { + const first = getVideoPriceEntries(model)[0] + if (!first) return '-' + return formatPricingCurrency( + first.price * ratio, + showWithRecharge, + priceRate, + VIDEO_PRICE_FORMAT_OPTIONS + ) +} diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 9e643c913b22..fa9540725da4 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -53,6 +53,8 @@ export type PricingModel = { billing_mode?: string /** Raw expression describing dynamic / tiered billing */ billing_expr?: string + /** Video per-second pricing by resolution */ + video_price?: VideoPricingConfig /** Pricing version returned by backend, useful for cache busting */ pricing_version?: string /** @@ -71,6 +73,11 @@ export type PricingModel = { capabilities?: ModelCapability[] } +export type VideoPricingConfig = { + base_fps?: number + prices?: Record +} + /** Input/output modalities supported by a model. */ export type Modality = 'text' | 'image' | 'audio' | 'video' | 'file' diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index d0a059dba21f..2d2221d0e343 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -49,6 +49,7 @@ const defaultBillingSettings: BillingSettings = { ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', + 'billing_setting.video_price': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 059438b85794..58405d83e73d 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -37,6 +37,7 @@ const getModelDefaults = (settings: BillingSettings) => ({ ExposeRatioEnabled: settings.ExposeRatioEnabled, BillingMode: settings['billing_setting.billing_mode'], BillingExpr: settings['billing_setting.billing_expr'], + VideoPrice: settings['billing_setting.video_price'], }) const getGroupDefaults = (settings: BillingSettings) => ({ diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index b7ac1f6a7ed6..8fd13ffd3650 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -31,6 +31,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -47,18 +48,42 @@ const headerNavSchema = z.object({ pricingRequireAuth: z.boolean(), rankingsEnabled: z.boolean(), rankingsRequireAuth: z.boolean(), + rankingsDisplayMultiplier: z.number().min(0), + rankingsDisplayJitterRatio: z.number().min(0), docs: z.boolean(), about: z.boolean(), }) type HeaderNavFormValues = z.infer +type HeaderNavBooleanKey = Exclude< + keyof HeaderNavFormValues, + 'rankingsDisplayMultiplier' | 'rankingsDisplayJitterRatio' +> type HeaderNavigationSectionProps = { config: HeaderNavModulesConfig initialSerialized: string + rankingsDisplayMultiplier: string + rankingsDisplayJitterRatio: string } -const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ +const parseDisplayNumber = (value: string | undefined, fallback: number) => { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +const toOptionNumber = (value: number) => { + if (!Number.isFinite(value) || value < 0) { + return '0' + } + return String(value) +} + +const toFormValues = ( + config: HeaderNavModulesConfig, + multiplier: string, + jitterRatio: string +): HeaderNavFormValues => ({ home: config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home), console: @@ -81,6 +106,8 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ config.rankings?.requireAuth === undefined ? HEADER_NAV_DEFAULT.rankings.requireAuth : Boolean(config.rankings.requireAuth), + rankingsDisplayMultiplier: parseDisplayNumber(multiplier, 1), + rankingsDisplayJitterRatio: parseDisplayNumber(jitterRatio, 0), docs: config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), about: @@ -92,10 +119,20 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ export function HeaderNavigationSection({ config, initialSerialized, + rankingsDisplayMultiplier, + rankingsDisplayJitterRatio, }: HeaderNavigationSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() - const formDefaults = useMemo(() => toFormValues(config), [config]) + const formDefaults = useMemo( + () => + toFormValues( + config, + rankingsDisplayMultiplier, + rankingsDisplayJitterRatio + ), + [config, rankingsDisplayJitterRatio, rankingsDisplayMultiplier] + ) const form = useForm({ resolver: zodResolver(headerNavSchema), @@ -126,22 +163,47 @@ export function HeaderNavigationSection({ } const serialized = serializeHeaderNavModules(payload) - if (serialized === initialSerialized) { - return + const updates: Array<{ key: string; value: string }> = [] + if (serialized !== initialSerialized) { + updates.push({ + key: 'HeaderNavModules', + value: serialized, + }) + } + + const nextMultiplier = toOptionNumber(values.rankingsDisplayMultiplier) + if ( + nextMultiplier !== + toOptionNumber(parseDisplayNumber(rankingsDisplayMultiplier, 1)) + ) { + updates.push({ + key: 'RankingsDisplayMultiplier', + value: nextMultiplier, + }) } - await updateOption.mutateAsync({ - key: 'HeaderNavModules', - value: serialized, - }) + const nextJitterRatio = toOptionNumber(values.rankingsDisplayJitterRatio) + if ( + nextJitterRatio !== + toOptionNumber(parseDisplayNumber(rankingsDisplayJitterRatio, 0)) + ) { + updates.push({ + key: 'RankingsDisplayJitterRatio', + value: nextJitterRatio, + }) + } + + for (const update of updates) { + await updateOption.mutateAsync(update) + } } const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT)) + form.reset(toFormValues(HEADER_NAV_DEFAULT, '1', '0')) } const simpleModules: Array<{ - key: keyof HeaderNavFormValues + key: HeaderNavBooleanKey title: string description: string }> = [ @@ -168,8 +230,8 @@ export function HeaderNavigationSection({ ] const accessModules: Array<{ - enabledKey: keyof HeaderNavFormValues - requireAuthKey: keyof HeaderNavFormValues + enabledKey: HeaderNavBooleanKey + requireAuthKey: HeaderNavBooleanKey requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled' title: string description: string @@ -287,6 +349,81 @@ export function HeaderNavigationSection({ ))}
+
+
+

+ {t('Rankings display values')} +

+

+ {t( + 'Only changes the public rankings display. Raw usage logs and billing stay unchanged.' + )} +

+
+
+ ( + + {t('Display multiplier')} + + + field.onChange(event.currentTarget.valueAsNumber) + } + /> + + + {t( + 'Displayed value equals raw value multiplied by this number.' + )} + + + + )} + /> + ( + + {t('Random jitter ratio')} + + + field.onChange(event.currentTarget.valueAsNumber) + } + /> + + + {t( + 'Adds stable positive random noise. Example: 0.05 means up to 5%.' + )} + + + + )} + /> +
+
+
+
+
+ + + + {t('Resolution')} + {t('Price per second')} + + {t('Actions')} + + + + + {props.rows.map((row) => ( + + + + updateRow(row.id, 'resolution', event.target.value) + } + /> + + + + $ + { + const value = event.target.value + if (numericDraftRegex.test(value)) { + updateRow(row.id, 'price', value) + } + }} + /> + + {t('/ sec')} + + + + + + + + ))} + +
+
+ + + ) +} diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index f87d9b945209..3f5a39e543ad 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -46,6 +46,7 @@ type ModelFormValues = { ExposeRatioEnabled: boolean BillingMode: string BillingExpr: string + VideoPrice: string } type ModelRatioFormProps = { @@ -112,10 +113,12 @@ export const ModelRatioForm = memo(function ModelRatioForm({ audioCompletionRatio={form.watch('AudioCompletionRatio')} billingMode={form.watch('BillingMode')} billingExpr={form.watch('BillingExpr')} + videoPrice={form.watch('VideoPrice')} onChange={(field, value) => { const fieldMap: Record = { 'billing_setting.billing_mode': 'BillingMode', 'billing_setting.billing_expr': 'BillingExpr', + 'billing_setting.video_price': 'VideoPrice', } const formField = fieldMap[field] || (field as keyof ModelFormValues) @@ -314,6 +317,25 @@ export const ModelRatioForm = memo(function ModelRatioForm({ )} /> + ( + + {t('Video per-second pricing')} + +