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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface ChannelBalanceResponse {
|
||||
message?: string
|
||||
balance?: number
|
||||
currency?: string
|
||||
raw_response?: string
|
||||
}
|
||||
|
||||
export interface FetchModelsResponse {
|
||||
|
||||
Reference in New Issue
Block a user