feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters * refactor relayconvert text converters * feat: refine relay converters and advanced custom routing * refactor: enhance logging and add thought signature handling for Gemini requests * refactor: enhance channel cache and pricing endpoint handling for advanced custom models * feat: preserve billing usage semantics * feat: add protocol-aware billing usage * Delete useless files * chore: update action versions in workflow files * chore: update Docker action versions in workflow files * fix: harden billing usage settlement and hot-path route matching - estimate Gemini completion tokens locally when billable usageMetadata is prompt-only but output content was received (e.g. client aborts the stream before the final chunk), and rebuild the attached billing_usage as estimated so settlement does not bill zero output tokens - guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching the OpenAI/Gemini constructors, so a zero billing_usage cannot override a non-zero top-level usage during settlement - cache compiled advanced-custom route model regexes; they run on the request hot path and were recompiled per request - move the effectiveBillingUsage remap to PostTextConsumeQuota only, and document that calculateTextQuotaSummary expects remapped usage - document the updatePricingLock -> channelSyncLock lock ordering that InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in GeminiChatResponse.UnmarshalJSON
This commit is contained in:
+658
-109
@@ -16,15 +16,35 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowRight, Check, Plus, Shuffle, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Info,
|
||||
Plus,
|
||||
Shuffle,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -53,13 +73,17 @@ import {
|
||||
createAdvancedCustomConfig,
|
||||
createAdvancedCustomRoute,
|
||||
getAdvancedCustomAuthMode,
|
||||
getAdvancedCustomConverterDefaults,
|
||||
getAdvancedCustomConverterOptions,
|
||||
getAdvancedCustomIncomingPathLabel,
|
||||
getAdvancedCustomModelRuleKind,
|
||||
getAdvancedCustomRegexModelPattern,
|
||||
getAdvancedCustomTemplateConfig,
|
||||
getAdvancedCustomUpstreamPathPlaceholder,
|
||||
getDefaultAdvancedCustomIncomingPath,
|
||||
isAdvancedCustomIncomingPathAllowed,
|
||||
normalizeAdvancedCustomConfig,
|
||||
parseAdvancedCustomRouteModels,
|
||||
parseAdvancedCustomConfig,
|
||||
stringifyAdvancedCustomConfig,
|
||||
validateAdvancedCustomConfig,
|
||||
@@ -84,9 +108,23 @@ const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
|
||||
const longSelectItemClass =
|
||||
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
|
||||
const routeEditorGridClassName =
|
||||
'lg:grid-cols-[7rem_minmax(0,1.45fr)_minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.85fr)_2rem]'
|
||||
'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]'
|
||||
const upstreamPathDescriptionKey =
|
||||
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
|
||||
const catchAllOrderErrorMessage =
|
||||
'Catch-all route must be last for the same incoming path'
|
||||
const emptyAdvancedRoutes: AdvancedCustomRoute[] = []
|
||||
|
||||
type AdvancedCustomRouteRow = {
|
||||
route: AdvancedCustomRoute
|
||||
routeKey: string
|
||||
index: number
|
||||
}
|
||||
|
||||
type AdvancedCustomRouteGroup = {
|
||||
incomingPath: string
|
||||
routeRows: AdvancedCustomRouteRow[]
|
||||
}
|
||||
|
||||
function getOptionLabel(
|
||||
options: ReadonlyArray<{ value: string; label: string }>,
|
||||
@@ -95,6 +133,34 @@ function getOptionLabel(
|
||||
return options.find((option) => option.value === value)?.label || value
|
||||
}
|
||||
|
||||
function getRouteIncomingPath(route: AdvancedCustomRoute): string {
|
||||
return (route.incoming_path || '').trim()
|
||||
}
|
||||
|
||||
function isCatchAllRoute(route: AdvancedCustomRoute): boolean {
|
||||
return !route.models || route.models.length === 0
|
||||
}
|
||||
|
||||
function buildRouteGroups(
|
||||
routeRows: AdvancedCustomRouteRow[]
|
||||
): AdvancedCustomRouteGroup[] {
|
||||
const groups: AdvancedCustomRouteGroup[] = []
|
||||
const groupByPath = new Map<string, AdvancedCustomRouteGroup>()
|
||||
|
||||
for (const routeRow of routeRows) {
|
||||
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||
let group = groupByPath.get(incomingPath)
|
||||
if (!group) {
|
||||
group = { incomingPath, routeRows: [] }
|
||||
groupByPath.set(incomingPath, group)
|
||||
groups.push(group)
|
||||
}
|
||||
group.routeRows.push(routeRow)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export function AdvancedCustomEditorDialog({
|
||||
open,
|
||||
value,
|
||||
@@ -133,20 +199,28 @@ export function AdvancedCustomEditorDialog({
|
||||
() => normalizeAdvancedCustomConfig(config),
|
||||
[config]
|
||||
)
|
||||
const routes = normalizedConfig.advanced_routes || []
|
||||
const routeRows = routes.map((route, index) => ({
|
||||
route,
|
||||
routeKey:
|
||||
routeKeys.at(index) ||
|
||||
route.incoming_path ||
|
||||
route.upstream_path ||
|
||||
route.converter ||
|
||||
'advanced-custom-route',
|
||||
}))
|
||||
const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes
|
||||
const routeRows = useMemo(
|
||||
() =>
|
||||
routes.map((route, index) => ({
|
||||
route,
|
||||
index,
|
||||
routeKey:
|
||||
routeKeys.at(index) ||
|
||||
route.incoming_path ||
|
||||
route.upstream_path ||
|
||||
route.converter ||
|
||||
'advanced-custom-route',
|
||||
})),
|
||||
[routeKeys, routes]
|
||||
)
|
||||
const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows])
|
||||
const validationError = useMemo(
|
||||
() => validateAdvancedCustomConfig(normalizedConfig),
|
||||
[normalizedConfig]
|
||||
)
|
||||
const canFixCatchAllOrder =
|
||||
validationError?.message === catchAllOrderErrorMessage
|
||||
|
||||
const createRouteKey = () => {
|
||||
routeKeyCounterRef.current += 1
|
||||
@@ -165,6 +239,17 @@ export function AdvancedCustomEditorDialog({
|
||||
})
|
||||
}
|
||||
|
||||
const replaceRoutes = (
|
||||
nextRoutes: AdvancedCustomRoute[],
|
||||
nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||
) => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
return { ...next, advanced_routes: nextRoutes }
|
||||
})
|
||||
setRouteKeys(nextRouteKeys)
|
||||
}
|
||||
|
||||
const addRoute = () => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
@@ -179,6 +264,25 @@ export function AdvancedCustomEditorDialog({
|
||||
setRouteKeys((current) => [...current, createRouteKey()])
|
||||
}
|
||||
|
||||
const addRouteForIncomingPath = (incomingPath: string) => {
|
||||
const resolvedIncomingPath = incomingPath || '/v1/chat/completions'
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
return {
|
||||
...next,
|
||||
advanced_routes: [
|
||||
...(next.advanced_routes || []),
|
||||
{
|
||||
...createAdvancedCustomRoute(),
|
||||
incoming_path: resolvedIncomingPath,
|
||||
upstream_path: resolvedIncomingPath,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
setRouteKeys((current) => [...current, createRouteKey()])
|
||||
}
|
||||
|
||||
const removeRoute = (index: number) => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
@@ -194,6 +298,105 @@ export function AdvancedCustomEditorDialog({
|
||||
)
|
||||
}
|
||||
|
||||
const updateGroupIncomingPath = (
|
||||
group: AdvancedCustomRouteGroup,
|
||||
nextIncomingPath: string | null
|
||||
) => {
|
||||
const resolvedIncomingPath = nextIncomingPath || '/v1/chat/completions'
|
||||
const groupRouteIndexes = new Set(
|
||||
group.routeRows.map((routeRow) => routeRow.index)
|
||||
)
|
||||
const nextRoutes = routes.map((route, routeIndex) => {
|
||||
if (!groupRouteIndexes.has(routeIndex)) return route
|
||||
const converter = route.converter || 'none'
|
||||
return {
|
||||
...route,
|
||||
incoming_path: resolvedIncomingPath,
|
||||
converter: isAdvancedCustomIncomingPathAllowed(
|
||||
resolvedIncomingPath,
|
||||
converter
|
||||
)
|
||||
? converter
|
||||
: 'none',
|
||||
}
|
||||
})
|
||||
replaceRoutes(nextRoutes)
|
||||
}
|
||||
|
||||
const swapRoutes = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex === toIndex) return
|
||||
const nextRoutes = [...routes]
|
||||
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||
const fromRoute = nextRoutes[fromIndex]
|
||||
nextRoutes[fromIndex] = nextRoutes[toIndex]
|
||||
nextRoutes[toIndex] = fromRoute
|
||||
const fromRouteKey = nextRouteKeys[fromIndex]
|
||||
nextRouteKeys[fromIndex] = nextRouteKeys[toIndex]
|
||||
nextRouteKeys[toIndex] = fromRouteKey
|
||||
replaceRoutes(nextRoutes, nextRouteKeys)
|
||||
}
|
||||
|
||||
const moveRouteWithinGroup = (index: number, direction: -1 | 1) => {
|
||||
const incomingPath = getRouteIncomingPath(routes[index])
|
||||
const samePathIndexes = routes
|
||||
.map((route, routeIndex) => ({ route, routeIndex }))
|
||||
.filter(({ route }) => getRouteIncomingPath(route) === incomingPath)
|
||||
.map(({ routeIndex }) => routeIndex)
|
||||
const position = samePathIndexes.indexOf(index)
|
||||
const nextIndex = samePathIndexes.at(position + direction)
|
||||
if (nextIndex === undefined) return
|
||||
swapRoutes(index, nextIndex)
|
||||
}
|
||||
|
||||
const moveRouteToGroupEnd = (index: number) => {
|
||||
const incomingPath = getRouteIncomingPath(routes[index])
|
||||
let lastSamePathIndex = -1
|
||||
for (let routeIndex = routes.length - 1; routeIndex >= 0; routeIndex -= 1) {
|
||||
if (getRouteIncomingPath(routes[routeIndex]) === incomingPath) {
|
||||
lastSamePathIndex = routeIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastSamePathIndex < 0 || index === lastSamePathIndex) return
|
||||
|
||||
const nextRoutes = [...routes]
|
||||
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||
const [route] = nextRoutes.splice(index, 1)
|
||||
const [routeKey] = nextRouteKeys.splice(index, 1)
|
||||
nextRoutes.splice(lastSamePathIndex, 0, route)
|
||||
nextRouteKeys.splice(lastSamePathIndex, 0, routeKey)
|
||||
replaceRoutes(nextRoutes, nextRouteKeys)
|
||||
}
|
||||
|
||||
const fixCatchAllOrder = () => {
|
||||
const routeRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
||||
for (const routeRow of routeRows) {
|
||||
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||
routeRowsByPath.set(incomingPath, [
|
||||
...(routeRowsByPath.get(incomingPath) || []),
|
||||
routeRow,
|
||||
])
|
||||
}
|
||||
|
||||
const orderedRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
||||
for (const [incomingPath, rows] of routeRowsByPath) {
|
||||
orderedRowsByPath.set(incomingPath, [
|
||||
...rows.filter((routeRow) => !isCatchAllRoute(routeRow.route)),
|
||||
...rows.filter((routeRow) => isCatchAllRoute(routeRow.route)),
|
||||
])
|
||||
}
|
||||
|
||||
const nextRows = routeRows.map((routeRow) => {
|
||||
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||
const orderedRows = orderedRowsByPath.get(incomingPath)
|
||||
return orderedRows?.shift() || routeRow
|
||||
})
|
||||
replaceRoutes(
|
||||
nextRows.map((routeRow) => routeRow.route),
|
||||
nextRows.map((routeRow) => routeRow.routeKey)
|
||||
)
|
||||
}
|
||||
|
||||
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
||||
const parsed = parseAdvancedCustomConfig(jsonText)
|
||||
if (!parsed) {
|
||||
@@ -302,7 +505,7 @@ export function AdvancedCustomEditorDialog({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='button' onClick={saveConfig}>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
<Check data-icon='inline-start' />
|
||||
{t('Save changes')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -395,18 +598,30 @@ export function AdvancedCustomEditorDialog({
|
||||
size='sm'
|
||||
onClick={addRoute}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add route')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validationError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{validationError.routeIndex !== undefined
|
||||
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||
: ''}
|
||||
{t(validationError.message)}
|
||||
<AlertDescription className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span>
|
||||
{validationError.routeIndex !== undefined
|
||||
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||
: ''}
|
||||
{t(validationError.message)}
|
||||
</span>
|
||||
{canFixCatchAllOrder ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={fixCatchAllOrder}
|
||||
>
|
||||
{t('Fix order')}
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -415,27 +630,24 @@ export function AdvancedCustomEditorDialog({
|
||||
{t(upstreamPathDescriptionKey)}
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col gap-4 lg:gap-2'>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground hidden items-center gap-2 px-3 text-xs font-medium lg:grid',
|
||||
routeEditorGridClassName
|
||||
)}
|
||||
>
|
||||
<span>{t('Route')}</span>
|
||||
<span>{t('Incoming path')}</span>
|
||||
<span>{t('Upstream path')}</span>
|
||||
<span>{t('Converter')}</span>
|
||||
<span>{t('Auth')}</span>
|
||||
<span aria-hidden='true' />
|
||||
</div>
|
||||
{routeRows.map((routeRow, index) => (
|
||||
<RouteEditor
|
||||
key={routeRow.routeKey}
|
||||
route={routeRow.route}
|
||||
index={index}
|
||||
onChange={(patch) => updateRoute(index, patch)}
|
||||
onRemove={() => removeRoute(index)}
|
||||
<div className='flex flex-col gap-4'>
|
||||
{routeGroups.map((routeGroup) => (
|
||||
<RouteGroupEditor
|
||||
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
|
||||
group={routeGroup}
|
||||
validationError={validationError}
|
||||
onAddRoute={() =>
|
||||
addRouteForIncomingPath(routeGroup.incomingPath)
|
||||
}
|
||||
onIncomingPathChange={(nextIncomingPath) =>
|
||||
updateGroupIncomingPath(routeGroup, nextIncomingPath)
|
||||
}
|
||||
onMoveRoute={(index, direction) =>
|
||||
moveRouteWithinGroup(index, direction)
|
||||
}
|
||||
onMoveRouteToEnd={moveRouteToGroupEnd}
|
||||
onRemoveRoute={removeRoute}
|
||||
onRouteChange={updateRoute}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -476,15 +688,191 @@ export function AdvancedCustomEditorDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function RouteGroupEditor({
|
||||
group,
|
||||
validationError,
|
||||
onAddRoute,
|
||||
onIncomingPathChange,
|
||||
onMoveRoute,
|
||||
onMoveRouteToEnd,
|
||||
onRemoveRoute,
|
||||
onRouteChange,
|
||||
}: {
|
||||
group: AdvancedCustomRouteGroup
|
||||
validationError: ReturnType<typeof validateAdvancedCustomConfig>
|
||||
onAddRoute: () => void
|
||||
onIncomingPathChange: (incomingPath: string | null) => void
|
||||
onMoveRoute: (index: number, direction: -1 | 1) => void
|
||||
onMoveRouteToEnd: (index: number) => void
|
||||
onRemoveRoute: (index: number) => void
|
||||
onRouteChange: (index: number, patch: Partial<AdvancedCustomRoute>) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const incomingPath = group.incomingPath || '/v1/chat/completions'
|
||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||
const catchAllRoute = group.routeRows.find((routeRow) =>
|
||||
isCatchAllRoute(routeRow.route)
|
||||
)
|
||||
const catchAllRoutePosition = catchAllRoute
|
||||
? group.routeRows.findIndex(
|
||||
(routeRow) => routeRow.index === catchAllRoute.index
|
||||
)
|
||||
: -1
|
||||
const hasCatchAll = catchAllRoute !== undefined
|
||||
const catchAllIsLast =
|
||||
!hasCatchAll || catchAllRoutePosition === group.routeRows.length - 1
|
||||
const groupHasError =
|
||||
validationError?.routeIndex !== undefined &&
|
||||
group.routeRows.some(
|
||||
(routeRow) => routeRow.index === validationError.routeIndex
|
||||
)
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'border-border overflow-hidden rounded-md border',
|
||||
groupHasError && 'border-destructive/60'
|
||||
)}
|
||||
>
|
||||
<div className='bg-muted/20 flex flex-col gap-3 p-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-sm font-medium'>{t('Route group')}</span>
|
||||
<Badge variant='secondary'>
|
||||
{group.routeRows.length} {t('Routes')}
|
||||
</Badge>
|
||||
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
|
||||
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
|
||||
</Badge>
|
||||
{!catchAllIsLast ? (
|
||||
<Badge variant='destructive'>{t('Fallback must be last')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Select value={incomingPath} onValueChange={onIncomingPathChange}>
|
||||
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{incomingPathLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
alignItemWithTrigger={false}
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={longSelectItemClass}
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
||||
<span>{option.label}</span>
|
||||
<span className='text-muted-foreground font-mono text-xs break-all'>
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type='button' variant='outline' size='sm' onClick={onAddRoute}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add split')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='border-t px-3 py-2'>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
{t(
|
||||
'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.'
|
||||
)}
|
||||
</p>
|
||||
{groupHasError && validationError ? (
|
||||
<p className='text-destructive mt-1 text-xs'>
|
||||
{validationError.routeIndex !== undefined
|
||||
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||
: ''}
|
||||
{t(validationError.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground hidden items-center gap-2 border-t bg-muted/10 px-3 py-2 text-xs font-medium lg:grid',
|
||||
routeEditorGridClassName
|
||||
)}
|
||||
>
|
||||
<span>{t('Route')}</span>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
{t('Client model')}
|
||||
<ModelRuleHelpPopover />
|
||||
</span>
|
||||
<span>{t('Upstream path')}</span>
|
||||
<span>{t('Converter')}</span>
|
||||
<span>{t('Auth')}</span>
|
||||
<span className='text-right'>{t('Actions')}</span>
|
||||
</div>
|
||||
|
||||
<div className='divide-y'>
|
||||
{group.routeRows.map((routeRow, position) => {
|
||||
const canMoveUp = position > 0
|
||||
const canMoveDown = position < group.routeRows.length - 1
|
||||
const catchAllOutOfOrder =
|
||||
isCatchAllRoute(routeRow.route) && canMoveDown
|
||||
const routeErrorMessage =
|
||||
validationError?.routeIndex === routeRow.index
|
||||
? validationError.message
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<RouteEditor
|
||||
key={routeRow.routeKey}
|
||||
route={routeRow.route}
|
||||
index={routeRow.index}
|
||||
errorMessage={routeErrorMessage}
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
catchAllOutOfOrder={catchAllOutOfOrder}
|
||||
onChange={(patch) => onRouteChange(routeRow.index, patch)}
|
||||
onMoveDown={() => onMoveRoute(routeRow.index, 1)}
|
||||
onMoveUp={() => onMoveRoute(routeRow.index, -1)}
|
||||
onMoveCatchAllToEnd={() => onMoveRouteToEnd(routeRow.index)}
|
||||
onRemove={() => onRemoveRoute(routeRow.index)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteEditor({
|
||||
route,
|
||||
index,
|
||||
errorMessage,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
catchAllOutOfOrder,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onMoveCatchAllToEnd,
|
||||
onRemove,
|
||||
}: {
|
||||
route: AdvancedCustomRoute
|
||||
index: number
|
||||
errorMessage?: string
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
catchAllOutOfOrder: boolean
|
||||
onChange: (patch: Partial<AdvancedCustomRoute>) => void
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
onMoveCatchAllToEnd: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -496,39 +884,52 @@ function RouteEditor({
|
||||
() => getAdvancedCustomConverterOptions(incomingPath),
|
||||
[incomingPath]
|
||||
)
|
||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||
const converterLabel = getOptionLabel(
|
||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||
converter
|
||||
)
|
||||
const converterTriggerLabel =
|
||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS.find(
|
||||
(option) => option.value === converter
|
||||
)?.triggerLabel || converterLabel
|
||||
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
||||
const isNativeConverter = converter === 'none'
|
||||
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
||||
const modelsInputValue = route.models?.join(', ') || ''
|
||||
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
|
||||
const isFallback = parsedRouteModels.length === 0
|
||||
|
||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
||||
if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) {
|
||||
patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
||||
let nextIncomingPath = incomingPath
|
||||
if (!isAdvancedCustomIncomingPathAllowed(nextIncomingPath, nextConverter)) {
|
||||
nextIncomingPath = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
||||
}
|
||||
onChange(patch)
|
||||
}
|
||||
|
||||
const setIncomingPath = (nextIncomingPath: string | null) => {
|
||||
const resolvedIncomingPath =
|
||||
nextIncomingPath || getDefaultAdvancedCustomIncomingPath(converter)
|
||||
const patch: Partial<AdvancedCustomRoute> = {
|
||||
incoming_path: resolvedIncomingPath,
|
||||
}
|
||||
if (!isAdvancedCustomIncomingPathAllowed(resolvedIncomingPath, converter)) {
|
||||
patch.converter = 'none'
|
||||
}
|
||||
onChange(patch)
|
||||
const defaults = getAdvancedCustomConverterDefaults(
|
||||
nextConverter,
|
||||
nextIncomingPath
|
||||
)
|
||||
onChange({
|
||||
converter: nextConverter,
|
||||
incoming_path: nextIncomingPath,
|
||||
upstream_path: defaults.upstream_path,
|
||||
auth: defaults.auth,
|
||||
})
|
||||
}
|
||||
|
||||
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
||||
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
||||
}
|
||||
|
||||
const setModelsInput = (value: string) => {
|
||||
onChange({
|
||||
models: value === '' ? [] : value.split(','),
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeModelsInput = (value: string) => {
|
||||
onChange({ models: parseAdvancedCustomRouteModels(value) })
|
||||
}
|
||||
|
||||
const updateAuth = (
|
||||
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
|
||||
value: string
|
||||
@@ -546,7 +947,12 @@ function RouteEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-border flex flex-col gap-4 rounded-md border p-4 lg:gap-2 lg:p-3'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 px-4 py-4 lg:gap-2 lg:px-3 lg:py-3',
|
||||
errorMessage && 'bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 md:grid-cols-2 lg:items-center lg:gap-2',
|
||||
@@ -559,6 +965,9 @@ function RouteEditor({
|
||||
<div className='text-sm font-medium'>
|
||||
{t('Route')} {index + 1}
|
||||
</div>
|
||||
{isFallback ? (
|
||||
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||
) : null}
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -586,51 +995,80 @@ function RouteEditor({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='lg:hidden'
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Delete')}</span>
|
||||
</Button>
|
||||
<div className='flex shrink-0 items-center gap-1 lg:hidden'>
|
||||
<TooltipIconButton
|
||||
label={t('Move route up')}
|
||||
icon={ArrowUp}
|
||||
disabled={!canMoveUp}
|
||||
onClick={onMoveUp}
|
||||
/>
|
||||
<TooltipIconButton
|
||||
label={t('Move route down')}
|
||||
icon={ArrowDown}
|
||||
disabled={!canMoveDown}
|
||||
onClick={onMoveDown}
|
||||
/>
|
||||
{catchAllOutOfOrder ? (
|
||||
<TooltipIconButton
|
||||
label={t('Move fallback to end')}
|
||||
icon={ArrowDownToLine}
|
||||
onClick={onMoveCatchAllToEnd}
|
||||
/>
|
||||
) : null}
|
||||
<TooltipIconButton
|
||||
label={t('Delete')}
|
||||
icon={Trash2}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldBlock
|
||||
label={t('Incoming path')}
|
||||
label={
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
{t('Client model')}
|
||||
<ModelRuleHelpPopover />
|
||||
</span>
|
||||
}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
<Select value={incomingPath} onValueChange={setIncomingPath}>
|
||||
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{`${incomingPathLabel}`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
alignItemWithTrigger={false}
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={longSelectItemClass}
|
||||
<Input
|
||||
value={modelsInputValue}
|
||||
onChange={(event) => setModelsInput(event.target.value)}
|
||||
onBlur={(event) => normalizeModelsInput(event.target.value)}
|
||||
placeholder={
|
||||
isFallback
|
||||
? t('Leave empty for fallback')
|
||||
: t('e.g. gpt-4o, gemini-2.5-flash')
|
||||
}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{isFallback ? (
|
||||
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||
) : (
|
||||
parsedRouteModels.map((model) => {
|
||||
const ruleKind = getAdvancedCustomModelRuleKind(model)
|
||||
const displayModel =
|
||||
ruleKind === 'regex'
|
||||
? getAdvancedCustomRegexModelPattern(model) || model
|
||||
: model
|
||||
return (
|
||||
<Badge
|
||||
key={model}
|
||||
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
|
||||
className='max-w-full gap-1.5 font-mono'
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
||||
<span>{option.label}</span>
|
||||
<span className='text-muted-foreground font-mono text-xs break-all'>
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
|
||||
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
|
||||
</span>
|
||||
<span className='truncate'>{displayModel}</span>
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock
|
||||
@@ -645,7 +1083,10 @@ function RouteEditor({
|
||||
upstream_path: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
|
||||
placeholder={getAdvancedCustomUpstreamPathPlaceholder(
|
||||
converter,
|
||||
incomingPath
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
||||
{t(upstreamPathDescriptionKey)}
|
||||
@@ -665,7 +1106,7 @@ function RouteEditor({
|
||||
>
|
||||
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{t(converterLabel)}
|
||||
{t(converterTriggerLabel)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
@@ -717,18 +1158,38 @@ function RouteEditor({
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='hidden lg:inline-flex'
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Delete')}</span>
|
||||
</Button>
|
||||
<div className='hidden items-center justify-end gap-1 lg:flex'>
|
||||
<TooltipIconButton
|
||||
label={t('Move route up')}
|
||||
icon={ArrowUp}
|
||||
disabled={!canMoveUp}
|
||||
onClick={onMoveUp}
|
||||
/>
|
||||
<TooltipIconButton
|
||||
label={t('Move route down')}
|
||||
icon={ArrowDown}
|
||||
disabled={!canMoveDown}
|
||||
onClick={onMoveDown}
|
||||
/>
|
||||
{catchAllOutOfOrder ? (
|
||||
<TooltipIconButton
|
||||
label={t('Move fallback to end')}
|
||||
icon={ArrowDownToLine}
|
||||
onClick={onMoveCatchAllToEnd}
|
||||
/>
|
||||
) : null}
|
||||
<TooltipIconButton
|
||||
label={t('Delete')}
|
||||
icon={Trash2}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className='text-destructive text-xs'>{t(errorMessage)}</p>
|
||||
) : null}
|
||||
|
||||
{authMode === 'header' || authMode === 'query' ? (
|
||||
<>
|
||||
<Separator className='lg:hidden' />
|
||||
@@ -775,13 +1236,101 @@ function RouteEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function ModelRuleHelpPopover() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground size-6'
|
||||
aria-label={t('Client model matching help')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className='size-3.5' aria-hidden='true' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={8}
|
||||
className='w-[min(22rem,calc(100vw-2rem))] gap-3 p-3'
|
||||
>
|
||||
<PopoverHeader className='gap-1'>
|
||||
<PopoverTitle>{t('Client model matching')}</PopoverTitle>
|
||||
<PopoverDescription className='text-xs leading-relaxed'>
|
||||
{t(
|
||||
'Rules match the original model value from the client request body.'
|
||||
)}
|
||||
</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className='text-muted-foreground space-y-2 text-xs leading-relaxed'>
|
||||
<p>
|
||||
{t(
|
||||
'Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'Leave the final split empty as the fallback for models not matched above.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipIconButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon data-icon='inline-start' aria-hidden='true' />
|
||||
<span className='sr-only'>{label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldBlock({
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
label: ReactNode
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
children: ReactNode
|
||||
|
||||
+196
-14
@@ -29,31 +29,47 @@ export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
||||
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||
value: AdvancedCustomConverter
|
||||
label: string
|
||||
triggerLabel: string
|
||||
}> = [
|
||||
{ value: 'none', label: 'Native forwarding' },
|
||||
{
|
||||
value: 'none',
|
||||
label: 'Native forwarding',
|
||||
triggerLabel: 'Native forwarding',
|
||||
},
|
||||
{
|
||||
value: 'anthropic_messages_to_openai_chat_completions',
|
||||
label: 'Anthropic Messages to OpenAI Chat',
|
||||
triggerLabel: 'To OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_anthropic_messages',
|
||||
label: 'OpenAI Chat to Anthropic Messages',
|
||||
triggerLabel: 'To Anthropic Messages',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_openai_responses',
|
||||
label: 'OpenAI Chat to OpenAI Responses',
|
||||
triggerLabel: 'To OpenAI Responses',
|
||||
},
|
||||
{
|
||||
value: 'openai_responses_to_openai_chat_completions',
|
||||
label: 'OpenAI Responses to OpenAI Chat',
|
||||
triggerLabel: 'To OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'openai_responses_to_gemini_generate_content',
|
||||
label: 'OpenAI Responses to Gemini Generate Content',
|
||||
triggerLabel: 'To Gemini Generate Content',
|
||||
},
|
||||
{
|
||||
value: 'gemini_generate_content_to_openai_chat_completions',
|
||||
label: 'Gemini Generate Content to OpenAI Chat',
|
||||
triggerLabel: 'To OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_gemini_generate_content',
|
||||
label: 'OpenAI Chat to Gemini Generate Content',
|
||||
triggerLabel: 'To Gemini Generate Content',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -157,6 +173,20 @@ export type AdvancedCustomTemplateOption = {
|
||||
config: AdvancedCustomConfig
|
||||
}
|
||||
|
||||
export type AdvancedCustomConverterDefaults = {
|
||||
upstream_path: string
|
||||
auth?: AdvancedCustomRouteAuth
|
||||
}
|
||||
|
||||
export const ADVANCED_CUSTOM_MODEL_REGEX_PREFIX = 're:'
|
||||
|
||||
export type AdvancedCustomModelRuleKind = 'exact' | 'regex'
|
||||
|
||||
const openAIChatPath = '/v1/chat/completions'
|
||||
const openAIResponsesPath = '/v1/responses'
|
||||
const claudeMessagesPath = '/v1/messages'
|
||||
const geminiGenerateContentPath = '/v1beta/models/{model}:generateContent'
|
||||
|
||||
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
||||
type: 'header',
|
||||
name: 'Authorization',
|
||||
@@ -313,8 +343,8 @@ export function getAdvancedCustomTemplateConfig(
|
||||
|
||||
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
|
||||
return {
|
||||
incoming_path: '/v1/chat/completions',
|
||||
upstream_path: '/v1/chat/completions',
|
||||
incoming_path: openAIChatPath,
|
||||
upstream_path: openAIChatPath,
|
||||
converter: 'none',
|
||||
}
|
||||
}
|
||||
@@ -326,18 +356,67 @@ export function createAdvancedCustomConfig(): AdvancedCustomConfig {
|
||||
}
|
||||
|
||||
export function getAdvancedCustomUpstreamPathPlaceholder(
|
||||
converter: AdvancedCustomConverter
|
||||
converter: AdvancedCustomConverter,
|
||||
incomingPath = getDefaultAdvancedCustomIncomingPath(converter)
|
||||
): string {
|
||||
if (converter === 'openai_chat_completions_to_gemini_generate_content') {
|
||||
return '/v1beta/models/{model}:generateContent'
|
||||
return getAdvancedCustomConverterDefaults(converter, incomingPath)
|
||||
.upstream_path
|
||||
}
|
||||
|
||||
export function getAdvancedCustomConverterDefaults(
|
||||
converter: AdvancedCustomConverter,
|
||||
incomingPath: string
|
||||
): AdvancedCustomConverterDefaults {
|
||||
const normalizedIncomingPath =
|
||||
incomingPath.trim() || getDefaultAdvancedCustomIncomingPath(converter)
|
||||
|
||||
if (converter === 'none') {
|
||||
return {
|
||||
upstream_path: normalizedIncomingPath,
|
||||
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
|
||||
}
|
||||
}
|
||||
if (
|
||||
converter === 'anthropic_messages_to_openai_chat_completions' ||
|
||||
converter === 'gemini_generate_content_to_openai_chat_completions' ||
|
||||
converter === 'openai_responses_to_openai_chat_completions'
|
||||
) {
|
||||
return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() }
|
||||
}
|
||||
if (converter === 'openai_chat_completions_to_openai_responses') {
|
||||
return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() }
|
||||
}
|
||||
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
||||
return '/v1/messages'
|
||||
return { upstream_path: claudeMessagesPath, auth: apiKeyHeaderAuth() }
|
||||
}
|
||||
if (converter === 'openai_responses_to_openai_chat_completions') {
|
||||
return '/v1/chat/completions'
|
||||
if (
|
||||
converter === 'openai_chat_completions_to_gemini_generate_content' ||
|
||||
converter === 'openai_responses_to_gemini_generate_content'
|
||||
) {
|
||||
return { upstream_path: geminiGenerateContentPath, auth: geminiQueryAuth() }
|
||||
}
|
||||
return '/v1/chat/completions'
|
||||
|
||||
return {
|
||||
upstream_path: normalizedIncomingPath || openAIChatPath,
|
||||
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
|
||||
}
|
||||
}
|
||||
|
||||
function getAdvancedCustomNativeAuth(
|
||||
incomingPath: string
|
||||
): AdvancedCustomRouteAuth {
|
||||
if (incomingPath === claudeMessagesPath) {
|
||||
return apiKeyHeaderAuth()
|
||||
}
|
||||
if (
|
||||
incomingPath.includes(':generateContent') ||
|
||||
incomingPath.includes(':streamGenerateContent') ||
|
||||
incomingPath.includes(':embedContent') ||
|
||||
incomingPath.includes(':batchEmbedContents')
|
||||
) {
|
||||
return geminiQueryAuth()
|
||||
}
|
||||
return bearerHeaderAuth()
|
||||
}
|
||||
|
||||
export function getAdvancedCustomIncomingPathOptions(
|
||||
@@ -416,6 +495,29 @@ export function normalizeAdvancedCustomConfig(
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAdvancedCustomRouteModels(value: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function getAdvancedCustomModelRuleKind(
|
||||
modelRule: string
|
||||
): AdvancedCustomModelRuleKind {
|
||||
return modelRule.startsWith(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX)
|
||||
? 'regex'
|
||||
: 'exact'
|
||||
}
|
||||
|
||||
export function getAdvancedCustomRegexModelPattern(modelRule: string): string {
|
||||
return modelRule.slice(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX.length)
|
||||
}
|
||||
|
||||
export function validateAdvancedCustomConfig(
|
||||
config: AdvancedCustomConfig | null
|
||||
): AdvancedCustomValidationError | null {
|
||||
@@ -431,12 +533,16 @@ export function validateAdvancedCustomConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const seenPaths = new Set<string>()
|
||||
const routeModelsByPath = new Map<
|
||||
string,
|
||||
{ catchAllIndex: number | null; models: Map<string, number> }
|
||||
>()
|
||||
for (let index = 0; index < routes.length; index += 1) {
|
||||
const route = routes[index]
|
||||
const incomingPath = route.incoming_path?.trim() || ''
|
||||
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
|
||||
const converter = route.converter || 'none'
|
||||
const routeModels = normalizeAdvancedCustomRouteModels(route.models)
|
||||
|
||||
if (!incomingPath) {
|
||||
return { routeIndex: index, message: 'Incoming path is required' }
|
||||
@@ -450,10 +556,15 @@ export function validateAdvancedCustomConfig(
|
||||
message: 'Incoming path must not include query',
|
||||
}
|
||||
}
|
||||
if (seenPaths.has(incomingPath)) {
|
||||
return { routeIndex: index, message: 'Incoming path must be unique' }
|
||||
const routeModelsError = validateAdvancedCustomRouteModels(
|
||||
index,
|
||||
incomingPath,
|
||||
routeModels,
|
||||
routeModelsByPath
|
||||
)
|
||||
if (routeModelsError) {
|
||||
return routeModelsError
|
||||
}
|
||||
seenPaths.add(incomingPath)
|
||||
|
||||
if (!upstreamPath) {
|
||||
return { routeIndex: index, message: 'Upstream path is required' }
|
||||
@@ -555,6 +666,10 @@ function normalizeAdvancedCustomRoute(
|
||||
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
|
||||
converter: route.converter || 'none',
|
||||
}
|
||||
const models = normalizeAdvancedCustomRouteModels(route.models)
|
||||
if (models.length > 0) {
|
||||
nextRoute.models = models
|
||||
}
|
||||
if (route.auth) {
|
||||
nextRoute.auth = {
|
||||
type: route.auth.type,
|
||||
@@ -565,6 +680,70 @@ function normalizeAdvancedCustomRoute(
|
||||
return nextRoute
|
||||
}
|
||||
|
||||
function normalizeAdvancedCustomRouteModels(
|
||||
models: string[] | undefined
|
||||
): string[] {
|
||||
if (!Array.isArray(models)) return []
|
||||
return models.map((model) => model.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function validateAdvancedCustomRouteModels(
|
||||
routeIndex: number,
|
||||
incomingPath: string,
|
||||
models: string[],
|
||||
routeModelsByPath: Map<
|
||||
string,
|
||||
{ catchAllIndex: number | null; models: Map<string, number> }
|
||||
>
|
||||
): AdvancedCustomValidationError | null {
|
||||
let state = routeModelsByPath.get(incomingPath)
|
||||
if (!state) {
|
||||
state = { catchAllIndex: null, models: new Map<string, number>() }
|
||||
routeModelsByPath.set(incomingPath, state)
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
if (state.catchAllIndex !== null) {
|
||||
return {
|
||||
routeIndex,
|
||||
message:
|
||||
'Only one catch-all route is allowed for the same incoming path',
|
||||
}
|
||||
}
|
||||
state.catchAllIndex = routeIndex
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.catchAllIndex !== null) {
|
||||
return {
|
||||
routeIndex,
|
||||
message: 'Catch-all route must be last for the same incoming path',
|
||||
}
|
||||
}
|
||||
|
||||
const seenInRoute = new Set<string>()
|
||||
for (const model of models) {
|
||||
if (
|
||||
getAdvancedCustomModelRuleKind(model) === 'regex' &&
|
||||
getAdvancedCustomRegexModelPattern(model) === ''
|
||||
) {
|
||||
return { routeIndex, message: 'Model regex cannot be empty' }
|
||||
}
|
||||
if (seenInRoute.has(model)) {
|
||||
return { routeIndex, message: 'Duplicate model in route models' }
|
||||
}
|
||||
seenInRoute.add(model)
|
||||
if (state.models.has(model)) {
|
||||
return {
|
||||
routeIndex,
|
||||
message: 'Route models must be unique for the same incoming path',
|
||||
}
|
||||
}
|
||||
state.models.set(model, routeIndex)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAdvancedCustomRouteUpstreamPath(
|
||||
route: AdvancedCustomRoute
|
||||
): string {
|
||||
@@ -622,6 +801,9 @@ function isConverterPathAllowed(
|
||||
if (converter === 'openai_responses_to_openai_chat_completions') {
|
||||
return incomingPath === '/v1/responses'
|
||||
}
|
||||
if (converter === 'openai_responses_to_gemini_generate_content') {
|
||||
return incomingPath === '/v1/responses'
|
||||
}
|
||||
return (
|
||||
incomingPath.includes(':generateContent') ||
|
||||
incomingPath.includes(':streamGenerateContent')
|
||||
|
||||
+2
@@ -117,6 +117,7 @@ export interface AdvancedCustomRoute {
|
||||
incoming_path?: string
|
||||
upstream_path?: string
|
||||
converter?: AdvancedCustomConverter
|
||||
models?: string[]
|
||||
auth?: AdvancedCustomRouteAuth
|
||||
}
|
||||
|
||||
@@ -132,6 +133,7 @@ export type AdvancedCustomConverter =
|
||||
| 'openai_chat_completions_to_anthropic_messages'
|
||||
| 'openai_chat_completions_to_openai_responses'
|
||||
| 'openai_responses_to_openai_chat_completions'
|
||||
| 'openai_responses_to_gemini_generate_content'
|
||||
| 'gemini_generate_content_to_openai_chat_completions'
|
||||
| 'openai_chat_completions_to_gemini_generate_content'
|
||||
|
||||
|
||||
+42
-10
@@ -31,6 +31,7 @@ import {
|
||||
Info,
|
||||
LogIn,
|
||||
} from 'lucide-react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
@@ -62,7 +63,7 @@ import {
|
||||
isPerCallBilling,
|
||||
isTimingLogType,
|
||||
} from '../../lib/utils'
|
||||
import type { LogOtherData } from '../../types'
|
||||
import { USAGE_BILLING_PATH, type LogOtherData } from '../../types'
|
||||
|
||||
// Maps a channel-update changed-field token (as recorded by the backend audit)
|
||||
// to its i18n label key for display in the audit details.
|
||||
@@ -150,6 +151,41 @@ function formatRatio(ratio: number | undefined): string {
|
||||
return ratio.toFixed(4)
|
||||
}
|
||||
|
||||
function getUsageBillingPathLabel(
|
||||
t: TFunction,
|
||||
adminInfo: LogOtherData['admin_info']
|
||||
): string {
|
||||
switch (adminInfo?.usage_billing_path) {
|
||||
case USAGE_BILLING_PATH.LOCAL:
|
||||
return t('Local Billing')
|
||||
case USAGE_BILLING_PATH.OPENAI:
|
||||
return t('Upstream Response (billing-usage-openai)')
|
||||
case USAGE_BILLING_PATH.OPENAI_ESTIMATED:
|
||||
return t('Upstream Response (billing-usage-openai-estimated)')
|
||||
case USAGE_BILLING_PATH.ANTHROPIC:
|
||||
return t('Upstream Response (billing-usage-anthropic)')
|
||||
case USAGE_BILLING_PATH.ANTHROPIC_ESTIMATED:
|
||||
return t('Upstream Response (billing-usage-anthropic-estimated)')
|
||||
case USAGE_BILLING_PATH.GEMINI:
|
||||
return t('Upstream Response (billing-usage-gemini)')
|
||||
case USAGE_BILLING_PATH.GEMINI_ESTIMATED:
|
||||
return t('Upstream Response (billing-usage-gemini-estimated)')
|
||||
case USAGE_BILLING_PATH.UPSTREAM:
|
||||
return t('Upstream Response')
|
||||
default:
|
||||
return adminInfo?.local_count_tokens
|
||||
? t('Local Billing')
|
||||
: t('Upstream Response')
|
||||
}
|
||||
}
|
||||
|
||||
function isUsageBillingPathLocal(adminInfo: LogOtherData['admin_info']): boolean {
|
||||
if (adminInfo?.usage_billing_path) {
|
||||
return adminInfo.usage_billing_path === USAGE_BILLING_PATH.LOCAL
|
||||
}
|
||||
return adminInfo?.local_count_tokens === true
|
||||
}
|
||||
|
||||
function quotaSaturationKindLabel(
|
||||
kind: 'overflow' | 'underflow' | 'nan',
|
||||
t: (key: string) => string
|
||||
@@ -326,10 +362,8 @@ function BillingBreakdown(props: {
|
||||
|
||||
if (isAdmin && other.admin_info) {
|
||||
rows.push({
|
||||
label: t('Billing Source'),
|
||||
value: other.admin_info.local_count_tokens
|
||||
? t('Local Billing')
|
||||
: t('Upstream Response'),
|
||||
label: t('Billing Path'),
|
||||
value: getUsageBillingPathLabel(t, other.admin_info),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1037,18 +1071,16 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
props.log.type !== 6 &&
|
||||
other?.admin_info && (
|
||||
<DetailRow
|
||||
label={t('Billing Source')}
|
||||
label={t('Billing Path')}
|
||||
value={
|
||||
<span className='flex items-center gap-1'>
|
||||
{other.admin_info.local_count_tokens ? (
|
||||
{isUsageBillingPathLocal(other.admin_info) ? (
|
||||
<Monitor className='size-3 text-blue-500' />
|
||||
) : (
|
||||
<Cloud className='size-3 text-emerald-500' />
|
||||
)}
|
||||
<span className='text-xs'>
|
||||
{other.admin_info.local_count_tokens
|
||||
? t('Local Billing')
|
||||
: t('Upstream Response')}
|
||||
{getUsageBillingPathLabel(t, other.admin_info)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
+15
@@ -92,12 +92,27 @@ export interface ChannelAffinityInfo {
|
||||
using_group?: string
|
||||
}
|
||||
|
||||
export const USAGE_BILLING_PATH = {
|
||||
LOCAL: 'local',
|
||||
UPSTREAM: 'upstream',
|
||||
OPENAI: 'billing-usage-openai',
|
||||
OPENAI_ESTIMATED: 'billing-usage-openai-estimated',
|
||||
ANTHROPIC: 'billing-usage-anthropic',
|
||||
ANTHROPIC_ESTIMATED: 'billing-usage-anthropic-estimated',
|
||||
GEMINI: 'billing-usage-gemini',
|
||||
GEMINI_ESTIMATED: 'billing-usage-gemini-estimated',
|
||||
} as const
|
||||
|
||||
export type UsageBillingPath =
|
||||
(typeof USAGE_BILLING_PATH)[keyof typeof USAGE_BILLING_PATH]
|
||||
|
||||
export interface LogOtherData {
|
||||
admin_info?: {
|
||||
is_multi_key?: boolean
|
||||
multi_key_index?: number
|
||||
use_channel?: number[]
|
||||
local_count_tokens?: boolean
|
||||
usage_billing_path?: UsageBillingPath | string
|
||||
channel_affinity?: ChannelAffinityInfo
|
||||
// Top-up audit fields (type=1, admin only)
|
||||
payment_method?: string
|
||||
|
||||
Reference in New Issue
Block a user