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:
Calcium-Ion
2026-07-11 20:44:12 +08:00
committed by GitHub
parent 1250fb2eb5
commit c36418c863
106 changed files with 13345 additions and 4307 deletions
@@ -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
View File
@@ -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
View File
@@ -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'
@@ -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
View File
@@ -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