refactor: advanced custom channel route editor (#6865)

* refactor: advanced custom channel route editor

* fix(channels): show raw balance response from balance cell
This commit is contained in:
Seefs
2026-08-18 17:31:21 +08:00
committed by GitHub
parent 3dda1d50c6
commit 2b0efd8484
21 changed files with 1551 additions and 506 deletions
@@ -55,7 +55,7 @@ import {
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage } from '../api'
import { getCodexUsage, updateChannelBalance } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatRelativeTime,
@@ -68,9 +68,9 @@ import {
parseModelsList,
parseGroupsList,
parseChannelSettings,
channelsQueryKeys,
handleUpdateChannelField,
handleUpdateTagField,
handleUpdateChannelBalance,
createChannelFieldUpdateScheduler,
isTagAggregateRow,
type TagRow,
@@ -81,6 +81,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import {
CodexUsageDialog,
type CodexUsageDialogData,
@@ -325,15 +326,18 @@ const SENSITIVE_MASK = '••••'
/**
* Balance cell component with click to update
*/
function BalanceCell({ channel }: { channel: Channel }) {
export function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible } = useChannels()
const { sensitiveVisible, setCurrentRow } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0
const [isUpdating, setIsUpdating] = useState(false)
const [rawBalanceResponse, setRawBalanceResponse] = useState<string | null>(
null
)
const [codexUsageOpen, setCodexUsageOpen] = useState(false)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
@@ -442,8 +446,34 @@ function BalanceCell({ channel }: { channel: Channel }) {
return
}
await handleUpdateChannelBalance(channel.id, queryClient)
setIsUpdating(false)
try {
const response = await updateChannelBalance(channel.id)
if (response.success && response.balance !== undefined) {
toast.success(
t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(response.balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
void queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else if (response.success && response.raw_response !== undefined) {
setCurrentRow(channel)
setRawBalanceResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to update balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update balance')
)
} finally {
setIsUpdating(false)
}
}
let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
if (sensitiveVisible && isUpdating) {
@@ -536,6 +566,17 @@ function BalanceCell({ channel }: { channel: Channel }) {
}}
isRefreshing={isUpdating}
/>
{rawBalanceResponse !== null && (
<BalanceQueryDialog
initialRawResponse={rawBalanceResponse}
open
onOpenChange={(open) => {
if (!open) {
setRawBalanceResponse(null)
}
}}
/>
)}
</TooltipProvider>
)
}
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,12 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
@@ -37,14 +42,12 @@ import {
} from './codex-usage-dialog'
type BalanceQueryDialogProps = {
initialRawResponse?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function BalanceQueryDialog({
open,
onOpenChange,
}: BalanceQueryDialogProps) {
export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient()
@@ -53,6 +56,9 @@ export function BalanceQueryDialog({
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
null
)
const [rawResponse, setRawResponse] = useState<string | null>(
props.initialRawResponse ?? null
)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
@@ -79,10 +85,10 @@ export function BalanceQueryDialog({
useEffect(() => {
if (!isCodex) return
if (!open) return
if (!props.open) return
handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isCodex])
}, [props.open, isCodex])
if (!currentRow) return null
@@ -109,6 +115,9 @@ export function BalanceQueryDialog({
await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
setRawResponse(null)
} else if (response.success && response.raw_response !== undefined) {
setRawResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to query balance'))
}
@@ -124,8 +133,9 @@ export function BalanceQueryDialog({
const handleClose = () => {
setBalance(null)
setBalanceUpdatedTime(null)
setRawResponse(null)
setCodexUsageResponse(null)
onOpenChange(false)
props.onOpenChange(false)
}
const formatBalance = (bal: number) =>
@@ -143,7 +153,7 @@ export function BalanceQueryDialog({
if (isCodex) {
return (
<CodexUsageDialog
open={open}
open={props.open}
onOpenChange={(v) => {
if (!v) handleClose()
}}
@@ -158,7 +168,7 @@ export function BalanceQueryDialog({
return (
<Dialog
open={open}
open={props.open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
@@ -176,24 +186,50 @@ export function BalanceQueryDialog({
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{rawResponse !== null ? (
<>
<Alert>
<AlertTitle>{t('Balance response not recognized')}</AlertTitle>
<AlertDescription>
{t(
'The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.'
)}
</AlertDescription>
</Alert>
<CodeBlock
code={rawResponse}
language='json'
maxExpandedLines={24}
showLineNumbers
title={t('Upstream JSON response')}
>
<CodeBlockCopyButton />
</CodeBlock>
</>
) : (
<>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
</>
)}
{/* Balance Update Button */}
<Button
+173 -112
View File
@@ -27,6 +27,9 @@ import type {
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
export const ADVANCED_CUSTOM_MODEL_LIST_PATH = '/v1/models'
export const ADVANCED_CUSTOM_MODEL_LIST_LABEL = 'OpenAI Models'
export const ADVANCED_CUSTOM_BALANCE_PATH =
'/v1/dashboard/billing/credit_grants'
export const ADVANCED_CUSTOM_BALANCE_LABEL = 'Balance Query'
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
value: AdvancedCustomConverter
@@ -109,11 +112,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
},
{
value: '/v1/alpha/search',
label: 'OpenAI Alpha Search',
},
{
value: ADVANCED_CUSTOM_MODEL_LIST_PATH,
label: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
label: 'Codex Alpha Search',
},
{
value: '/v1/embeddings',
@@ -145,7 +144,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
},
{
value: '/v1/rerank',
label: 'OpenAI Rerank',
label: 'Rerank',
},
{
value: '/v1/realtime',
@@ -172,6 +171,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
const ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS: Record<string, string> = {
'/v1/chat/completions': 'OpenAI Chat',
[ADVANCED_CUSTOM_MODEL_LIST_PATH]: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
[ADVANCED_CUSTOM_BALANCE_PATH]: ADVANCED_CUSTOM_BALANCE_LABEL,
}
export type AdvancedCustomValidationError = {
@@ -217,122 +217,93 @@ const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
value: '{api_key}',
})
function createOpenAINativeRoutes(): AdvancedCustomRoute[] {
return [
'/v1/chat/completions',
'/v1/completions',
'/v1/responses',
'/v1/responses/compact',
'/v1/embeddings',
'/v1/images/generations',
'/v1/images/edits',
'/v1/audio/speech',
'/v1/audio/transcriptions',
'/v1/audio/translations',
'/v1/realtime',
].map((path) => ({
incoming_path: path,
upstream_path: path,
converter: 'none',
auth: bearerHeaderAuth(),
}))
}
function createClaudeNativeRoutes(): AdvancedCustomRoute[] {
return [
{
incoming_path: '/v1/messages',
upstream_path: '/v1/messages',
converter: 'none',
auth: apiKeyHeaderAuth(),
},
]
}
function createGeminiNativeRoutes(): AdvancedCustomRoute[] {
return [
'/v1beta/models/{model}:generateContent',
'/v1beta/models/{model}:embedContent',
'/v1beta/models/{model}:batchEmbedContents',
].map((path) => ({
incoming_path: path,
upstream_path: path,
converter: 'none',
auth: geminiQueryAuth(),
}))
}
function createGatewayNativeRoutes(): AdvancedCustomRoute[] {
return ['/v1/alpha/search', '/v1/rerank'].map((path) => ({
incoming_path: path,
upstream_path: path,
converter: 'none',
auth: bearerHeaderAuth(),
}))
}
export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
[
{
value: 'official_openai_chat',
label: 'Official OpenAI Chat',
value: 'all_protocols',
label: 'All routes',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: '/v1/chat/completions',
converter: 'none',
auth: bearerHeaderAuth(),
},
...createOpenAINativeRoutes(),
...createClaudeNativeRoutes(),
...createGeminiNativeRoutes(),
...createGatewayNativeRoutes(),
],
},
},
{
value: 'official_openai_responses',
label: 'Official OpenAI Responses',
value: 'openai_only',
label: 'OpenAI only',
config: {
advanced_routes: [
{
incoming_path: '/v1/responses',
upstream_path: '/v1/responses',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_routes: createOpenAINativeRoutes(),
},
},
{
value: 'official_openai_embeddings',
label: 'Official OpenAI Embeddings',
value: 'claude_only',
label: 'Claude only',
config: {
advanced_routes: [
{
incoming_path: '/v1/embeddings',
upstream_path: '/v1/embeddings',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_routes: createClaudeNativeRoutes(),
},
},
{
value: 'official_openai_images',
label: 'Official OpenAI Images',
value: 'gemini_only',
label: 'Gemini only',
config: {
advanced_routes: [
{
incoming_path: '/v1/images/generations',
upstream_path: '/v1/images/generations',
converter: 'none',
auth: bearerHeaderAuth(),
},
{
incoming_path: '/v1/images/edits',
upstream_path: '/v1/images/edits',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
},
},
{
value: 'official_claude_messages',
label: 'Official Claude Messages',
config: {
advanced_routes: [
{
incoming_path: '/v1/messages',
upstream_path: '/v1/messages',
converter: 'none',
auth: apiKeyHeaderAuth(),
},
],
},
},
{
value: 'official_gemini_native',
label: 'Official Gemini Native',
config: {
advanced_routes: [
{
incoming_path: '/v1beta/models/{model}:generateContent',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:embedContent',
upstream_path: '/v1beta/models/{model}:embedContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
upstream_path: '/v1beta/models/{model}:batchEmbedContents',
converter: 'none',
auth: geminiQueryAuth(),
},
],
},
},
{
value: 'official_gemini_from_openai_chat',
label: 'Official Gemini from OpenAI Chat',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'openai_chat_completions_to_gemini_generate_content',
auth: geminiQueryAuth(),
},
],
advanced_routes: createGeminiNativeRoutes(),
},
},
]
@@ -340,7 +311,7 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
export function cloneAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
return structuredClone(config)
}
export function getAdvancedCustomTemplateConfig(
@@ -353,6 +324,78 @@ export function getAdvancedCustomTemplateConfig(
return cloneAdvancedCustomConfig(template.config)
}
export function isAdvancedCustomManagementPath(path: string): boolean {
return (
path === ADVANCED_CUSTOM_MODEL_LIST_PATH ||
path === ADVANCED_CUSTOM_BALANCE_PATH
)
}
export function getAdvancedCustomManagementRoute(
config: AdvancedCustomConfig,
path: string
): AdvancedCustomRoute | undefined {
return normalizeAdvancedCustomConfig(config).advanced_routes?.find(
(route) => route.incoming_path?.trim() === path
)
}
export function replaceAdvancedCustomManagementRoute(
config: AdvancedCustomConfig,
path: string,
route: AdvancedCustomRoute | null
): AdvancedCustomConfig {
const normalized = normalizeAdvancedCustomConfig(config)
const routes = [...(normalized.advanced_routes || [])]
const index = routes.findIndex(
(candidate) => candidate.incoming_path?.trim() === path
)
if (route === null) {
if (index >= 0) routes.splice(index, 1)
} else {
const managementRoute: AdvancedCustomRoute = {
incoming_path: path,
upstream_path: route.upstream_path || '',
converter: 'none',
models: [],
auth: route.auth,
}
if (index >= 0) routes[index] = managementRoute
else routes.push(managementRoute)
}
return { advanced_routes: routes }
}
export function replaceAdvancedCustomForwardingRoutes(
config: AdvancedCustomConfig,
forwardingRoutes: AdvancedCustomRoute[]
): AdvancedCustomConfig {
const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || []
const firstForwardingIndex = routes.findIndex(
(route) =>
!isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
)
const managementRoutes = routes.filter((route) =>
isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
)
if (firstForwardingIndex < 0) {
return { advanced_routes: [...managementRoutes, ...forwardingRoutes] }
}
const before = routes
.slice(0, firstForwardingIndex)
.filter((route) =>
isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
)
const after = routes
.slice(firstForwardingIndex)
.filter((route) =>
isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
)
return { advanced_routes: [...before, ...forwardingRoutes, ...after] }
}
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
return {
incoming_path: openAIChatPath,
@@ -367,6 +410,17 @@ export function createAdvancedCustomConfig(): AdvancedCustomConfig {
}
}
export function createAdvancedCustomManagementRoute(
path: string
): AdvancedCustomRoute {
return {
incoming_path: path,
upstream_path: path,
converter: 'none',
models: [],
}
}
export function getAdvancedCustomUpstreamPathPlaceholder(
converter: AdvancedCustomConverter,
incomingPath = getDefaultAdvancedCustomIncomingPath(converter)
@@ -550,6 +604,7 @@ export function validateAdvancedCustomConfig(
{ catchAllIndex: number | null; models: Map<string, number> }
>()
let modelListRouteIndex: number | null = null
let balanceRouteIndex: number | null = null
for (let index = 0; index < routes.length; index += 1) {
const route = routes[index]
const incomingPath = route.incoming_path?.trim() || ''
@@ -569,30 +624,36 @@ export function validateAdvancedCustomConfig(
message: 'Incoming path must not include query',
}
}
if (incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) {
if (modelListRouteIndex !== null) {
if (isAdvancedCustomManagementPath(incomingPath)) {
const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
const existingIndex = isModelListRoute
? modelListRouteIndex
: balanceRouteIndex
const routeLabel = isModelListRoute ? 'OpenAI Models' : 'Balance Query'
if (existingIndex !== null) {
return {
routeIndex: index,
message: 'Only one OpenAI Models route is allowed',
message: `Only one ${routeLabel} route is allowed`,
}
}
modelListRouteIndex = index
if (isModelListRoute) modelListRouteIndex = index
else balanceRouteIndex = index
if (routeModels.length > 0) {
return {
routeIndex: index,
message: 'OpenAI Models route does not support client model rules',
message: `${routeLabel} route does not support client model rules`,
}
}
if (converter !== 'none') {
return {
routeIndex: index,
message: 'OpenAI Models route must use native forwarding',
message: `${routeLabel} route must use native forwarding`,
}
}
if (upstreamPath.includes('{model}')) {
return {
routeIndex: index,
message: 'OpenAI Models upstream path must not contain {model}',
message: `${routeLabel} upstream path must not contain {model}`,
}
}
}
@@ -20,8 +20,6 @@ import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import {
copyChannel,
deleteChannel,
@@ -38,7 +36,6 @@ import {
editTagChannels,
testAllChannels,
updateAllChannelsBalance,
updateChannelBalance,
} from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { ChannelTestResponse, CopyChannelParams } from '../types'
@@ -362,41 +359,6 @@ export async function handleCopyChannel(
}
}
/**
* Update channel balance
*/
export async function handleUpdateChannelBalance(
id: number,
queryClient?: QueryClient,
onSuccess?: (balance: number) => void
): Promise<void> {
try {
const response = await updateChannelBalance(id)
if (response.success && response.balance !== undefined) {
const balance = response.balance
toast.success(
i18next.t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(balance)
} else {
toast.error(response.message || i18next.t('Failed to update balance'))
}
} catch (_error: unknown) {
toast.error(
_error instanceof Error
? _error.message
: i18next.t('Failed to update balance')
)
}
}
// ============================================================================
// Batch Actions
// ============================================================================
+1
View File
@@ -197,6 +197,7 @@ export interface ChannelBalanceResponse {
message?: string
balance?: number
currency?: string
raw_response?: string
}
export interface FetchModelsResponse {