/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format'
import {
CHANNEL_STATUS_CONFIG,
CHANNEL_TYPES,
MULTI_KEY_STATUS_CONFIG,
RESPONSE_TIME_CONFIG,
RESPONSE_TIME_THRESHOLDS,
TYPE_TO_KEY_PROMPT,
} from '../constants'
import type { Channel, ChannelSettings, ChannelOtherSettings } from '../types'
// ============================================================================
// Channel Type Utilities
// ============================================================================
/**
* Get human-readable channel type label
*/
export function getChannelTypeLabel(type: number): string {
return CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown'
}
/**
* Get channel type icon name for getLobeIcon
* Maps channel types to Lobe icon names using type number (language-independent)
*/
export function getChannelTypeIcon(type: number): string {
const TYPE_TO_ICON: Record = {
// OpenAI family
1: 'OpenAI', // OpenAI
6: 'OpenAI', // OpenAIMax
7: 'OpenAI', // OhMyGPT
8: 'OpenAI', // Custom
58: 'OpenAI', // Advanced Custom
3: 'Azure', // Azure
// Anthropic
14: 'Claude', // Anthropic
// Google family
24: 'Gemini', // Gemini
11: 'Google', // PaLM
41: 'Gemini', // Vertex AI
// Cloud providers
33: 'Aws', // AWS
39: 'Cloudflare', // Cloudflare
// Chinese providers
15: 'Baidu', // Baidu
46: 'Baidu', // Baidu V2
16: 'Zhipu', // Zhipu
26: 'Zhipu', // Zhipu V4
17: 'Qwen', // Ali
18: 'Spark', // Xunfei
23: 'Hunyuan', // Tencent
19: 'Ai360', // 360
25: 'Moonshot', // Moonshot
31: 'Yi', // LingYiWanWu
35: 'Minimax', // MiniMax
45: 'Volcengine', // VolcEngine
// Other AI providers
4: 'Ollama', // Ollama
27: 'Perplexity', // Perplexity
34: 'Cohere', // Cohere
42: 'Mistral', // Mistral
43: 'DeepSeek', // DeepSeek
48: 'XAI', // xAI
49: 'Coze', // Coze
40: 'SiliconCloud', // SiliconFlow
44: 'OpenAI', // MokaAI
20: 'OpenRouter', // OpenRouter
// Image/Video generation
2: 'Midjourney', // Midjourney
5: 'Midjourney', // MidjourneyPlus
50: 'Kling', // Kling
51: 'Jimeng', // Jimeng
52: 'Vidu', // Vidu
36: 'Suno', // SunoAPI
55: 'OpenAI', // Sora
54: 'Doubao', // DoubaoVideo
56: 'Replicate', // Replicate
// Tools & Platforms
37: 'Dify', // Dify
38: 'Jina', // Jina
22: 'FastGPT', // FastGPT
47: 'Xinference', // Xinference
53: 'OpenAI', // Submodel
// AI Proxy services
10: 'OpenAI', // AI Proxy
21: 'OpenAI', // AI Proxy Library
12: 'OpenAI', // API2GPT
13: 'OpenAI', // AIGC2D
9: 'OpenAI', // AILS
}
return TYPE_TO_ICON[type] || 'OpenAI'
}
// ============================================================================
// Status Utilities
// ============================================================================
/**
* Get status badge configuration
*/
export function getChannelStatusBadge(status: number) {
return (
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
CHANNEL_STATUS_CONFIG[0]
)
}
/**
* Get multi-key status badge configuration
*/
export function getMultiKeyStatusBadge(status: number) {
return (
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] ||
MULTI_KEY_STATUS_CONFIG[1]
)
}
/**
* Check if channel is enabled
*/
export function isChannelEnabled(channel: Channel): boolean {
return channel.status === 1
}
/**
* Check if channel is multi-key
*/
export function isMultiKeyChannel(channel: Channel): boolean {
return channel.channel_info?.is_multi_key || false
}
// ============================================================================
// Key Formatting
// ============================================================================
/**
* Format channel key for display
* Masks the key for security, showing only first and last few characters
*/
export function formatChannelKey(
key: string,
isMultiKey: boolean = false
): string {
if (!key) return ''
if (isMultiKey) {
const keys = key.split('\n').filter((k) => k.trim())
return `${keys.length} keys`
}
if (key.length <= 16) {
// For short keys, mask middle part
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
// For longer keys, show more context
return `${key.slice(0, 8)}...${key.slice(-8)}`
}
/**
* Format key preview for multi-key display
*/
export function formatKeyPreview(key: string, maxLength: number = 10): string {
if (!key) return ''
if (key.length <= maxLength) return key
return `${key.slice(0, maxLength)}...`
}
/**
* Count keys in multi-key string
*/
export function countKeys(key: string): number {
if (!key) return 0
return key.split('\n').filter((k) => k.trim()).length
}
// ============================================================================
// Model & Group Parsing
// ============================================================================
/**
* Parse comma-separated models list
*/
export function parseModelsList(models: string): string[] {
if (!models) return []
return models
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0)
}
/**
* Parse comma-separated groups list.
* Sorts with 'default' group first, then locale-sorted alphabetically.
*/
export function parseGroupsList(groups: string): string[] {
if (!groups) return []
const list = groups
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0)
return list.sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return a.localeCompare(b)
})
}
/**
* Format models array back to string
*/
export function formatModelsString(models: string[]): string {
return models.join(',')
}
/**
* Format groups array back to string
*/
export function formatGroupsString(groups: string[]): string {
return groups.join(',')
}
// ============================================================================
// Settings Parsing
// ============================================================================
/**
* Parse channel settings JSON
*/
export function parseChannelSettings(
settingStr: string | null | undefined
): ChannelSettings {
if (!settingStr) return {}
try {
return JSON.parse(settingStr) as ChannelSettings
} catch {
return {}
}
}
/**
* Parse channel other settings JSON
*/
export function parseChannelOtherSettings(
settingsStr: string | null | undefined
): ChannelOtherSettings {
if (!settingsStr || settingsStr === '{}') return {}
try {
return JSON.parse(settingsStr) as ChannelOtherSettings
} catch {
return {}
}
}
/**
* Validate JSON string
*/
export function validateChannelSettings(settings: string): boolean {
if (!settings || settings.trim() === '') return true
try {
JSON.parse(settings)
return true
} catch {
return false
}
}
// ============================================================================
// Balance Formatting
// ============================================================================
/**
* Format balance with currency symbol
*/
export function formatBalance(balance: number | null | undefined): string {
if (balance == null || Number.isNaN(balance)) return '-'
return formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
}
/**
* Get balance status color
*/
export function getBalanceVariant(
balance: number
): 'success' | 'warning' | 'danger' | 'neutral' {
if (balance === 0) return 'neutral'
if (balance < 1) return 'danger'
if (balance < 10) return 'warning'
return 'success'
}
// ============================================================================
// Response Time Utilities
// ============================================================================
/** Optional i18n: (key, options) => string, e.g. useTranslation().t */
type TFunction = (key: string, options?: { value?: number | string }) => string
/**
* Format response time in milliseconds to human-readable.
* Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s").
*/
export function formatResponseTime(timeMs: number, t?: TFunction): string {
if (timeMs === 0) return t ? t('Not tested') : 'Not tested'
if (timeMs < 1000)
return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms`
return t
? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) })
: `${(timeMs / 1000).toFixed(2)}s`
}
/**
* Get response time performance rating
*/
export function getResponseTimeConfig(timeMs: number) {
if (timeMs === 0) return RESPONSE_TIME_CONFIG.UNKNOWN
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT)
return RESPONSE_TIME_CONFIG.EXCELLENT
if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) return RESPONSE_TIME_CONFIG.GOOD
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) return RESPONSE_TIME_CONFIG.FAIR
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) return RESPONSE_TIME_CONFIG.POOR
return RESPONSE_TIME_CONFIG.POOR
}
// ============================================================================
// Time Formatting
// ============================================================================
/**
* Format Unix timestamp to relative time
* e.g., "2 hours ago", "3 days ago"
*/
export function formatRelativeTime(timestamp: number): string {
if (!timestamp || timestamp === 0) return 'Never'
try {
return dayjs(timestamp * 1000).fromNow()
} catch {
return 'Unknown'
}
}
/**
* Format Unix timestamp to date string
*/
export function formatTimestamp(timestamp: number): string {
if (!timestamp || timestamp === 0) return 'N/A'
try {
return formatTimestampToDate(timestamp)
} catch {
return 'Invalid date'
}
}
// ============================================================================
// Quota Formatting
// ============================================================================
/** Format quota units using the global currency display configuration. */
export function formatQuota(quota: number): string {
return formatQuotaWithCurrency(quota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
})
}
// ============================================================================
// Priority & Weight Utilities
// ============================================================================
/**
* Get priority display value
*/
export function getPriorityDisplay(
priority: number | null | undefined
): string {
if (priority === null || priority === undefined) return '0'
return String(priority)
}
/**
* Get weight display value
*/
export function getWeightDisplay(weight: number | null | undefined): string {
if (weight === null || weight === undefined) return '0'
return String(weight)
}
// ============================================================================
// Validation Utilities
// ============================================================================
/**
* Validate channel name
*/
export function validateChannelName(name: string): boolean {
return name.trim().length > 0
}
/**
* Validate API key format
*/
export function validateApiKey(key: string): boolean {
return key.trim().length > 0
}
/**
* Validate models list
*/
export function validateModels(models: string): boolean {
return parseModelsList(models).length > 0
}
/**
* Validate groups list
*/
export function validateGroups(groups: string): boolean {
return parseGroupsList(groups).length > 0
}
/**
* Check if channel needs attention (low balance, auto-disabled, etc.)
*/
export function channelNeedsAttention(channel: Channel): boolean {
// Auto-disabled
if (channel.status === 3) return true
// Low balance (less than $1)
if (channel.balance > 0 && channel.balance < 1) return true
// Multi-key channel with all keys disabled
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return true
}
return false
}
/**
* Get attention reason for channel
*/
export function getAttentionReason(channel: Channel): string | null {
if (channel.status === 3) return 'Auto-disabled'
if (channel.balance > 0 && channel.balance < 1) return 'Low balance'
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return 'All keys disabled'
}
return null
}
// ============================================================================
// Tag Aggregation Utilities
// ============================================================================
/**
* Tag row type (extends Channel with children)
*/
export type TagRow = Channel & {
children: Channel[]
}
/**
* Type guard to check whether a row is a tag aggregate row
*/
export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
return Array.isArray((row as TagRow).children)
}
/**
* Aggregate channels by tag for tag mode display
* Converts flat array into tree structure grouped by tag
*/
export function aggregateChannelsByTag(
channels: Channel[]
): (Channel | TagRow)[] {
const tagMap = new Map()
const result: (Channel | TagRow)[] = []
for (const channel of channels) {
const tag = channel.tag || ''
if (!tagMap.has(tag)) {
// Create tag aggregate row
const tagRow = {
...channel,
key: tag,
id: tag as unknown as number,
tag: tag,
name: tag,
type: 0,
status: undefined as unknown as number,
group: '',
used_quota: 0,
response_time: 0,
priority: -1 as unknown as number | null,
weight: -1 as unknown as number | null,
balance: 0,
test_time: 0,
created_time: 0,
balance_updated_time: 0,
models: '',
children: [],
} as TagRow
tagMap.set(tag, tagRow)
result.push(tagRow)
}
const tagRow = tagMap.get(tag)!
// Add to children
tagRow.children.push(channel)
const childCount = tagRow.children.length
// Aggregate used_quota (sum)
tagRow.used_quota += channel.used_quota
// Aggregate response_time (average)
tagRow.response_time =
(tagRow.response_time * (childCount - 1) + channel.response_time) /
childCount
// Aggregate priority (same value or null if different)
if (tagRow.priority === -1) {
tagRow.priority = channel.priority
} else if (tagRow.priority !== channel.priority) {
tagRow.priority = null
}
// Aggregate weight (same value or null if different)
if (tagRow.weight === -1) {
tagRow.weight = channel.weight
} else if (tagRow.weight !== channel.weight) {
tagRow.weight = null
}
// Aggregate group (concatenate and deduplicate)
if (tagRow.group === '') {
tagRow.group = channel.group
} else {
const existingGroups = new Set(tagRow.group.split(',').filter(Boolean))
const newGroups = channel.group.split(',').filter(Boolean)
newGroups.forEach((g) => {
if (!existingGroups.has(g)) {
tagRow.group += ',' + g
}
})
}
// Aggregate status (enabled if any child is enabled)
if (channel.status === 1) {
tagRow.status = 1
} else if (tagRow.status === undefined) {
tagRow.status = channel.status
}
}
return result
}
// ============================================================================
// Key Management Utilities
// ============================================================================
/**
* Deduplicate keys from a multiline string
* @param keysText - Text with one key per line
* @returns Object with deduplicated keys and statistics
*/
export function deduplicateKeys(keysText: string): {
deduplicatedText: string
beforeCount: number
afterCount: number
removedCount: number
} {
if (!keysText || keysText.trim() === '') {
return {
deduplicatedText: '',
beforeCount: 0,
afterCount: 0,
removedCount: 0,
}
}
// Split by lines
const keyLines = keysText.split('\n')
const beforeCount = keyLines.length
// Use Set for deduplication, maintaining order
const keySet = new Set()
const deduplicatedKeys: string[] = []
keyLines.forEach((line) => {
const trimmedLine = line.trim()
if (trimmedLine && !keySet.has(trimmedLine)) {
keySet.add(trimmedLine)
deduplicatedKeys.push(trimmedLine)
}
})
const afterCount = deduplicatedKeys.length
const deduplicatedText = deduplicatedKeys.join('\n')
return {
deduplicatedText,
beforeCount,
afterCount,
removedCount: beforeCount - afterCount,
}
}
/**
* Get key prompt based on channel type
*/
export function getKeyPromptForType(type: number): string {
return TYPE_TO_KEY_PROMPT[type] || 'Enter API key for this channel'
}