feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg width="596" height="683" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden"><defs><filter id="fx0" x="-10%" y="-10%" width="120%" height="120%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse"><feComponentTransfer color-interpolation-filters="sRGB"><feFuncR type="discrete" tableValues="0.835294 0.835294"/><feFuncG type="discrete" tableValues="0.345098 0.345098"/><feFuncB type="discrete" tableValues="0.086275 0.086275"/><feFuncA type="linear" slope="0.400000" intercept="0.000000"/></feComponentTransfer><feGaussianBlur stdDeviation="6.111111 6.111111"/></filter><clipPath id="clip1"><rect x="1.41663" y="1.66669" width="592.667" height="675.667"/></clipPath><clipPath id="clip2"><rect x="-2078.55" y="-2770.64" width="374073" height="374073"/></clipPath><image width="512" height="512" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAgCgAwAEAAAAAQAAAgAAAAAAGcBUEAAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAQABJREFUeAHt3QmcXFWd6PF/VXVWwq6OozAgIjoiDKQTEBjHwCij7Nla2Yc1Ks6Mz+d7b5aPb9pZ3rzZfM8NCKC4IWMnnYAs6jAIjEImJJ1gEFRkGQVxYSdk63TVnf+pTnf6dN3uruUu55z7u34wfW/de5bvuVX/f926iwgTAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBAwgKlhMujOAQQQCA1gUikdNsRss+OmbJvKZLZUUm2Vrtkx8Nr5Be9IrXUKqZgBAIUIAEIcFDpEgKhCNx8tLyuWpF3RZGcKCU5Uvv1Fv1vdkz/duiyJ/S/72uS8L1yTe5ctEF+GLMeixBAYJcACQC7AgIIOCVw+6EyY9u+crZEcoE27J36X7nNBm7S7W6oTpPretbI822WwWYIBCtAAhDs0NIxBPwSuH6BzNx7i3xYv+1/VFv+mwm2/hU9KnBtrSp/17NRnkmwXIpCwGsBEgCvh4/GIxCGQH+3nKqH+D+tvTkktR6V5AU9b+Djm9bLVb2cL5AaMwX7I0AC4M9Y0VIEghMw3/r33CJ/r4H5j7PqnH7o3V2pyrlnbpSns6qTehBwUYAEwMVRoU0IFECg/1g5QKpym3bVnNyX9fQrPbHgjIXr5f6sK6Y+BFwRaPfkGlfaTzsQQMBDgZXdejZ/Ve7VpucR/I3Yb+g1g3eu6JaTPeSjyQgkIsARgEQYKQQBBJoVuGm+HFiN6sH/wGa3SXG97bWanLx0g3w3xTooGgEnBUgAnBwWGoVAmAJ9x8l+lZ2yRnt3mDM9NCcHVuUE7hvgzIjQkIwE+AkgI2iqQaDoAnopXqkyKNepgzvB3wxKJPtGZVlxS3fsDYaKPmz0P2ABEoCAB5euIeCSgF7q9wG91G+hS20a05bDB8vyT2Pm+ROB4AX4CSD4IaaDCOQvsOpIeU00XX6sLdkn/9ZM2IKa3oTo+CUDsnbCNXgBgYAEugLqC12ZRMAcfr15vhygd0N7kx7ufL1+E5ulH3Z7l0rykmaBW/T1J6sij/Ssk19OUgwvIdCWQDRD/lYPtbsc/E2/yvp++JT++/a2OunBRnctkK5nXpaD9YP/t/RzYI7ef2GGfhYMVkvyin4ePCXb5Imeh2TQg67QxAQEOAKQAKKrRdQfpFKWJfrEtN/XNv6u/rdfE239pX4gmDOi/02fsraSe6g3IcYqkwrsut7/MV1p+qQrOvJiVJP3LNkg33akOR01o+9wmV6eLX+ghbxLE7Df0w/8w/XvaZMUOqSv/Vg/A/5d1/+OPkfhNv0M2DbJ+rzksQAJgMeDF9d0802/f568Vwf2I/r6SfpfJW69JpeZbwK362VSn+QyqSbFWK1BYOU8+aTuj/+t4QV3F9y1eH39veNuC6do2apj5DD9Rv8RDeLv01WbSfwnKnGzltFfLsunFq6TByZaieV+CpAA+Dlusa02NzXRQ5j/pIN6ROwKnS28Tz9QPsrvo50hFm1r/QY6pzJLDy2L7O1R3yP9Bnzo4nXyuEdtrje1b64cWinL/9UZc7Jl0id5f0uPjvwvPTqyyTcX2hsvQAIQ7+LV0puPld8YGpKr9EMr7TOs9eZpcv22inz0vLXysldINDYXgf75crl+g1yeS+WdVKoPDVo8IH/TSRFZbmsO9XfNkv+tRwA/pvXOSLFu8xPB1VsG5U8v2CRbUqyHojMQSDpDzKDJVDFWQD9g3zVU1UNz6Qd/U63ZXy6ZVZWB1XOle2w7+BuBWIFIlsUud31hSd7rehNH2rf67XJwZbbco8H/L3RZmsHfVGlOHP/wnOn6GTBfjjILmPwV4AiAv2MnGvz/UL9dXatdMG/KrKcdegbx+YsGZEXWFVOfHwL9c/Vs+nL9rn9+NNhu5ZAe6drf9SNd+hnwe/oZcLM2PY8rLLZpADl70fp6/bYec14IcATAi2FqbKSeWPUX+sa/Xl/JI/ibBs3Qqwtu1HZc3Ng6liCgAmX5oMcOXTN2yu+43H492fcM/QwwVyvkEfwNzSw96tCv7bjEZSfaNrEACcDENs6+om+4Xs28Xfh9sqLtuFbbs8RZLBqWi8CtR8i+WrHX+0Wlok8sdHRaMU8WaNO+rv/NzLmJ5iqja1fNkytybgfVtyFAAtAGWp6bmOCv9f9lnm0YV7fZh76ij3c9dtxyZgsssGN6/cjQbJ8J9NvtG11svwbbt2nUNYf98w7+Izz6a6B8ZpW51TOTVwIkAB4Nl4PBf0Rvpl5+uMI86W1kAf8WV0CDQUn/d7n3AvqQINf6cP0Cmam+N+h/eznWtpL+JHglRwIcG5UpmkMCMAWQKy87HPxHiA7UJ719emSGLine truncated

After

Width:  |  Height:  |  Size: 51 KiB

+31
View File
@@ -0,0 +1,31 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// Official Wan favicon: https://g.alicdn.com/sail-web/wan-static-resources/0.0.30/images/favicon.ico
import wanIcon from './wan.png'
export function IconWan(props: { size?: number }) {
return (
<img
src={wanIcon}
alt=''
width={props.size ?? 20}
height={props.size ?? 20}
/>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -23,6 +23,7 @@ export { BadgeListCell } from './core/badge-list-cell'
export { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableMobileFilterPanel } from './toolbar/mobile-filter-panel'
export { DataTableBulkActions } from './toolbar/bulk-actions'
export {
StaticDataTable,
@@ -0,0 +1,161 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
getCoreRowModel,
useReactTable,
type ColumnFiltersState,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DataTableToolbar } from '../toolbar'
const rows = [{ name: 'Channel', model: 'model', status: 'enabled' }]
const filters = [
{
columnId: 'status',
title: 'Status',
options: [{ value: 'enabled', label: 'Enabled' }],
},
]
function Fixture(props: { collapsibleOnMobile?: boolean }) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([
{ id: 'status', value: ['enabled'] },
])
const table = useReactTable({
data: rows,
columns: [
{ accessorKey: 'name' },
{ accessorKey: 'model' },
{ accessorKey: 'status' },
],
getCoreRowModel: getCoreRowModel(),
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
})
return (
<DataTableToolbar
table={table}
collapsibleOnMobile={props.collapsibleOnMobile}
searchPlaceholder='Search channels'
searchDebounceMs={50}
filters={filters}
additionalSearch={
<Input
aria-label='Model'
value={String(table.getColumn('model')?.getFilterValue() ?? '')}
onChange={(event) =>
table.getColumn('model')?.setFilterValue(event.target.value)
}
/>
}
preActions={<Button>Refresh</Button>}
viewToggle={<Button>Card view</Button>}
/>
)
}
function setMobileViewport(mobile: boolean) {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: mobile && query === '(max-width: 640px)',
}))
}
afterEach(() => {
vi.restoreAllMocks()
})
it('hides the mobile filter group while keeping reset and quick actions accessible', async () => {
setMobileViewport(true)
const user = userEvent.setup()
render(<Fixture collapsibleOnMobile />)
const collapse = screen.getByRole('button', { name: 'Collapse' })
expect(collapse).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
collapse.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute(
'aria-expanded',
'false'
)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Status/ })
).not.toBeInTheDocument()
for (const name of ['Reset', 'Refresh', 'Card view', 'View']) {
expect(screen.getByRole('button', { name })).toBeVisible()
expect(screen.getByRole('button', { name })).toBeEnabled()
}
})
it('retains mobile search and filter values after expanding and resets them while collapsed', async () => {
setMobileViewport(true)
const user = userEvent.setup()
render(<Fixture collapsibleOnMobile />)
await user.type(screen.getByPlaceholderText('Search channels'), 'production')
await user.type(screen.getByRole('textbox', { name: 'Model' }), 'gpt')
await user.click(screen.getByRole('button', { name: 'Collapse' }))
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByPlaceholderText('Search channels')).toHaveValue(
'production'
)
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('gpt')
expect(screen.getByRole('button', { name: /^Status/ })).toHaveTextContent(
'Enabled'
)
await user.click(screen.getByRole('button', { name: 'Collapse' }))
await user.click(screen.getByRole('button', { name: 'Reset' }))
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByPlaceholderText('Search channels')).toHaveValue('')
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('')
expect(screen.getByRole('button', { name: 'Status' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Reset' })
).not.toBeInTheDocument()
})
it.each([
{ mobile: false, collapsibleOnMobile: true, condition: 'on desktop' },
{ mobile: true, collapsibleOnMobile: undefined, condition: 'without opt-in' },
])('keeps filters visible without a panel toggle $condition', (testCase) => {
setMobileViewport(testCase.mobile)
render(<Fixture collapsibleOnMobile={testCase.collapsibleOnMobile} />)
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
expect(
screen.queryByRole('button', { name: /^(Collapse|Expand)$/ })
).not.toBeInTheDocument()
})
@@ -0,0 +1,98 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
type DataTableMobileFilterPanelProps = {
children: ReactNode
actions: ReactNode
compact?: boolean
className?: string
}
export function DataTableMobileFilterPanel(
props: DataTableMobileFilterPanelProps
) {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn(
'bg-card/50 flex min-w-0 flex-col rounded-lg border p-2.5',
open && (props.compact ? 'gap-2.5' : 'gap-2'),
props.className
)}
>
<CollapsibleContent>{props.children}</CollapsibleContent>
<div
className={cn(
props.compact
? 'grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2'
: 'flex items-center justify-end gap-1.5'
)}
>
<CollapsibleTrigger
aria-label={open ? t('Collapse') : t('Expand')}
render={
<Button
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground',
props.compact ? 'size-9' : 'mr-auto size-7'
)}
/>
}
>
<ChevronDown
aria-hidden='true'
className={cn(
'transition-transform duration-200',
props.compact ? 'size-4' : 'size-3.5',
open && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<div
role='group'
aria-label={t('Actions')}
className={cn(
'flex items-center justify-end gap-1.5',
props.compact &&
'min-w-0 flex-wrap [&>button]:h-auto [&>button]:min-h-9 [&>button]:max-w-full [&>button]:[overflow-wrap:anywhere] [&>button]:whitespace-normal'
)}
>
{props.actions}
</div>
</div>
</Collapsible>
)
}
@@ -24,10 +24,11 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks'
import { useDebounce, useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import { DataTableFacetedFilter } from './faceted-filter'
import { DataTableMobileFilterPanel } from './mobile-filter-panel'
import { DataTableViewOptions } from './view-options'
type FilterDef = {
@@ -78,6 +79,10 @@ export type DataTableToolbarProps<TData> = {
* search input and filter chips.
*/
additionalSearch?: ReactNode
/**
* Collapse the complete filter panel on mobile while keeping actions visible.
*/
collapsibleOnMobile?: boolean
/**
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
* inputs) are currently active. Controls Reset button visibility
@@ -155,6 +160,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [isSearchComposing, setIsSearchComposing] = useState(false)
const isMobile = useMediaQuery('(max-width: 640px)')
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
@@ -341,6 +347,33 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const hasLeftActions = props.leftActions != null
if (isMobile && props.collapsibleOnMobile) {
return (
<DataTableMobileFilterPanel
compact
className={props.className}
actions={
<>
{props.leftActions}
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
</>
}
>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
{expanded && hasExpandable && props.expandable}
{expandToggle}
</div>
</DataTableMobileFilterPanel>
)
}
if (hasLeftActions) {
return (
<div className={cn('flex flex-col gap-2', props.className)}>
+477
View File
@@ -0,0 +1,477 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type FloatingWindowPosition = {
x: number
y: number
}
type FloatingWindowSize = {
width: number
/** Unset until the user resizes vertically; content decides until then. */
height?: number
}
type FloatingWindowProps = {
title: ReactNode
children: ReactNode
onClose: () => void
/** Viewport coordinates used until the user moves the window. */
defaultPosition: FloatingWindowPosition
defaultWidth: number
defaultHeight?: number
minWidth?: number
minHeight?: number
/** Small element rendered beside the title, e.g. a count badge. */
badge?: ReactNode
footer?: ReactNode
/**
* Persists position, size and collapsed state in `localStorage` under this
* key so the window reopens where and how the user left it.
*/
storageKey?: string
className?: string
}
type FloatingWindowState = {
position: FloatingWindowPosition
size: FloatingWindowSize
collapsed: boolean
}
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const RESIZE_DIRECTIONS: ResizeDirection[] = [
'n',
's',
'e',
'w',
'ne',
'nw',
'se',
'sw',
]
const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
n: 'top-0 left-2 right-2 h-1.5 cursor-ns-resize',
s: 'bottom-0 left-2 right-2 h-1.5 cursor-ns-resize',
e: 'top-2 bottom-2 right-0 w-1.5 cursor-ew-resize',
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
}
const VIEWPORT_MARGIN = 8
/** Keeps at least the header reachable when the window is dragged downward. */
const HEADER_HEIGHT = 48
const DEFAULT_MIN_WIDTH = 280
const DEFAULT_MIN_HEIGHT = 200
function storageKeyFor(key: string) {
return `floating-window:${key}`
}
function readStoredState(key?: string): FloatingWindowState | null {
if (!key) return null
try {
const raw = window.localStorage.getItem(storageKeyFor(key))
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
const stored = parsed as Record<string, unknown>
if (typeof stored.x !== 'number' || typeof stored.y !== 'number') {
return null
}
return {
position: { x: stored.x, y: stored.y },
size: {
width: typeof stored.width === 'number' ? stored.width : Number.NaN,
height: typeof stored.height === 'number' ? stored.height : undefined,
},
collapsed: stored.collapsed === true,
}
} catch {
return null
}
}
function clampPosition(
position: FloatingWindowPosition,
width: number
): FloatingWindowPosition {
const maxX = Math.max(
VIEWPORT_MARGIN,
window.innerWidth - width - VIEWPORT_MARGIN
)
const maxY = Math.max(
VIEWPORT_MARGIN,
window.innerHeight - HEADER_HEIGHT - VIEWPORT_MARGIN
)
return {
x: Math.min(Math.max(VIEWPORT_MARGIN, position.x), maxX),
y: Math.min(Math.max(VIEWPORT_MARGIN, position.y), maxY),
}
}
function clampSize(
size: FloatingWindowSize,
limits: { minWidth: number; minHeight: number }
): FloatingWindowSize {
const maxWidth = Math.max(
limits.minWidth,
window.innerWidth - VIEWPORT_MARGIN * 2
)
const maxHeight = Math.max(
limits.minHeight,
window.innerHeight - VIEWPORT_MARGIN * 2
)
return {
width: Math.min(Math.max(limits.minWidth, size.width), maxWidth),
height:
size.height === undefined
? undefined
: Math.min(Math.max(limits.minHeight, size.height), maxHeight),
}
}
/**
* Non-modal window that floats above the page: dragged by its header, resized
* from any edge or corner, collapsible to a pill. It renders where it is
* mounted (no portal) with `position: fixed`, so a parent modal keeps
* treating it as its own content.
*/
export function FloatingWindow(props: FloatingWindowProps) {
const { t } = useTranslation()
const id = useId()
const limits = useMemo(
() => ({
minWidth: props.minWidth ?? DEFAULT_MIN_WIDTH,
minHeight: props.minHeight ?? DEFAULT_MIN_HEIGHT,
}),
[props.minWidth, props.minHeight]
)
const [state, setState] = useState<FloatingWindowState>(() => {
const stored = readStoredState(props.storageKey)
const size = clampSize(
{
width:
stored && !Number.isNaN(stored.size.width)
? stored.size.width
: props.defaultWidth,
height: stored ? stored.size.height : props.defaultHeight,
},
limits
)
return {
position: clampPosition(
stored?.position ?? props.defaultPosition,
size.width
),
size,
collapsed: stored?.collapsed ?? false,
}
})
const windowRef = useRef<HTMLElement>(null)
const dragRef = useRef<{
pointerId: number
offsetX: number
offsetY: number
} | null>(null)
const resizeRef = useRef<{
pointerId: number
direction: ResizeDirection
startX: number
startY: number
position: FloatingWindowPosition
size: { width: number; height: number }
} | null>(null)
useEffect(() => {
if (!props.storageKey) return
try {
window.localStorage.setItem(
storageKeyFor(props.storageKey),
JSON.stringify({
x: state.position.x,
y: state.position.y,
width: state.size.width,
height: state.size.height,
collapsed: state.collapsed,
})
)
} catch {
// Storage may be unavailable; the window still works for this session.
}
}, [props.storageKey, state])
useEffect(() => {
const keepInViewport = () =>
setState((previous) => {
const size = clampSize(previous.size, limits)
return {
...previous,
size,
position: clampPosition(previous.position, size.width),
}
})
window.addEventListener('resize', keepInViewport)
return () => window.removeEventListener('resize', keepInViewport)
}, [limits])
const capturePointer = (
event: ReactPointerEvent<HTMLElement>,
capture: boolean
) => {
const method = capture ? 'setPointerCapture' : 'releasePointerCapture'
if (typeof event.currentTarget[method] === 'function') {
event.currentTarget[method](event.pointerId)
}
}
const handleDragStart = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return
if (
event.target instanceof Element &&
event.target.closest('button, a, input, select, textarea')
) {
return
}
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - state.position.x,
offsetY: event.clientY - state.position.y,
}
capturePointer(event, true)
}
const handleDragMove = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
const next = clampPosition(
{ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY },
state.size.width
)
setState((previous) => ({ ...previous, position: next }))
}
const handleDragEnd = (event: ReactPointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
dragRef.current = null
capturePointer(event, false)
}
const handleResizeStart = (
event: ReactPointerEvent<HTMLElement>,
direction: ResizeDirection
) => {
if (event.button !== 0) return
event.preventDefault()
// Until the user has set a height the window is content-sized, so the
// rendered height becomes the starting point of a vertical resize.
const renderedHeight = windowRef.current?.getBoundingClientRect().height
resizeRef.current = {
pointerId: event.pointerId,
direction,
startX: event.clientX,
startY: event.clientY,
position: state.position,
size: {
width: state.size.width,
height:
state.size.height ?? Math.max(renderedHeight ?? 0, limits.minHeight),
},
}
capturePointer(event, true)
}
const handleResizeMove = (event: ReactPointerEvent<HTMLElement>) => {
const resize = resizeRef.current
if (!resize || resize.pointerId !== event.pointerId) return
const dx = event.clientX - resize.startX
const dy = event.clientY - resize.startY
let { width, height } = resize.size
let { x, y } = resize.position
if (resize.direction.includes('e')) width = resize.size.width + dx
if (resize.direction.includes('s')) height = resize.size.height + dy
if (resize.direction.includes('w')) width = resize.size.width - dx
if (resize.direction.includes('n')) height = resize.size.height - dy
const size = clampSize({ width, height }, limits)
if (resize.direction.includes('w')) {
x = resize.position.x + (resize.size.width - size.width)
}
if (resize.direction.includes('n')) {
y = resize.position.y + (resize.size.height - (size.height ?? height))
}
const position = clampPosition({ x, y }, size.width)
setState((previous) => ({ ...previous, size, position }))
}
const handleResizeEnd = (event: ReactPointerEvent<HTMLElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return
resizeRef.current = null
capturePointer(event, false)
}
const dragHandleProps = {
title: t('Drag to move'),
onPointerDown: handleDragStart,
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
}
const style = {
position: 'fixed' as const,
left: state.position.x,
top: state.position.y,
}
if (state.collapsed) {
return (
<div
{...dragHandleProps}
style={style}
className={cn(
'bg-background border-border z-50 flex cursor-move touch-none items-center gap-2 rounded-full border py-1 pr-1 pl-3 shadow-lg select-none',
props.className
)}
>
<GripHorizontal
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='text-sm font-semibold'>{props.title}</span>
{props.badge}
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Expand panel')}
onClick={() =>
setState((previous) => ({ ...previous, collapsed: false }))
}
>
<ChevronsUpDown aria-hidden='true' />
</Button>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Close panel')}
onClick={props.onClose}
>
<X aria-hidden='true' />
</Button>
</div>
)
}
return (
<aside
ref={windowRef}
role='complementary'
aria-labelledby={`${id}-title`}
style={{
...style,
width: state.size.width,
height: state.size.height,
maxHeight: `calc(100vh - ${VIEWPORT_MARGIN * 2}px)`,
}}
className={cn(
'bg-background border-border z-50 flex flex-col overflow-hidden rounded-xl border shadow-xl',
props.className
)}
>
<header
{...dragHandleProps}
className='border-border/70 flex cursor-move touch-none items-center gap-2 border-b px-4 py-3 select-none'
>
<GripHorizontal
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<h3 id={`${id}-title`} className='text-sm font-semibold'>
{props.title}
</h3>
{props.badge}
<div className='ml-auto flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Collapse panel')}
onClick={() =>
setState((previous) => ({ ...previous, collapsed: true }))
}
>
<ChevronsUpDown aria-hidden='true' />
</Button>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Close panel')}
onClick={props.onClose}
>
<X aria-hidden='true' />
</Button>
</div>
</header>
<div className='min-h-0 flex-1 overflow-y-auto px-4 py-3'>
{props.children}
</div>
{props.footer && (
<footer className='border-border/70 border-t px-4 py-3'>
{props.footer}
</footer>
)}
{RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
data-slot='floating-window-resize'
data-direction={direction}
title={t('Drag to resize')}
aria-hidden='true'
className={cn(
'absolute z-10 touch-none',
RESIZE_HANDLE_CLASS[direction]
)}
onPointerDown={(event) => handleResizeStart(event, direction)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
/>
))}
</aside>
)
}
@@ -0,0 +1,175 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, test, vi } from 'vitest'
import { FloatingWindow } from '@/components/floating-window'
afterEach(() => {
window.localStorage.clear()
})
function renderWindow(props?: { storageKey?: string; onClose?: () => void }) {
return render(
<FloatingWindow
title='Scratch pad'
defaultPosition={{ x: 100, y: 20 }}
defaultWidth={300}
storageKey={props?.storageKey}
onClose={props?.onClose ?? vi.fn()}
footer={<span>footer</span>}
>
<p>body</p>
</FloatingWindow>
)
}
test('renders fixed at the default position and follows a header drag, remembering where it was dropped', () => {
renderWindow({ storageKey: 'scratch' })
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
expect(window_).toHaveStyle({
position: 'fixed',
left: '100px',
top: '20px',
width: '300px',
})
expect(screen.getByText('body')).toBeVisible()
expect(screen.getByText('footer')).toBeVisible()
const handle = screen.getByTitle('Drag to move')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 1,
clientX: 120,
clientY: 40,
})
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 220, clientY: 90 })
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 220, clientY: 90 })
expect(window_).toHaveStyle({ left: '200px', top: '70px' })
expect(
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
).toEqual({ x: 200, y: 70, width: 300, collapsed: false })
})
function resizeHandle(direction: string) {
const handle = document.querySelector<HTMLElement>(
`[data-slot="floating-window-resize"][data-direction="${direction}"]`
)
if (!handle) throw new Error(`missing ${direction} resize handle`)
return handle
}
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
renderWindow({ storageKey: 'scratch' })
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
const handle = resizeHandle('se')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 3,
clientX: 400,
clientY: 300,
})
fireEvent.pointerMove(handle, { pointerId: 3, clientX: 450, clientY: 380 })
fireEvent.pointerUp(handle, { pointerId: 3, clientX: 450, clientY: 380 })
// jsdom renders no height, so the vertical start is the minimum height.
expect(window_).toHaveStyle({ width: '350px', height: '280px' })
expect(
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
).toEqual({ x: 100, y: 20, width: 350, height: 280, collapsed: false })
})
test('dragging the left edge keeps the right edge in place and never shrinks below the minimum width', () => {
renderWindow()
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
const handle = resizeHandle('w')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 4,
clientX: 100,
clientY: 200,
})
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 60, clientY: 200 })
expect(window_).toHaveStyle({ left: '60px', width: '340px' })
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 300, clientY: 200 })
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 300, clientY: 200 })
expect(window_).toHaveStyle({ left: '120px', width: '280px' })
})
test('a stored size is restored and clamped to the viewport', () => {
window.localStorage.setItem(
'floating-window:scratch',
JSON.stringify({ x: 10, y: 10, width: 5000, height: 120 })
)
renderWindow({ storageKey: 'scratch' })
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ width: `${window.innerWidth - 16}px`, height: '200px' })
})
test('a stored position outside the viewport is clamped back inside on mount', () => {
window.localStorage.setItem(
'floating-window:scratch',
JSON.stringify({ x: 5000, y: -50 })
)
renderWindow({ storageKey: 'scratch' })
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ left: `${window.innerWidth - 300 - 8}px`, top: '8px' })
})
test('collapses to a pill that keeps the title and reopens, and the close control reports to the caller', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderWindow({ onClose })
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
expect(
screen.queryByRole('complementary', { name: 'Scratch pad' })
).not.toBeInTheDocument()
expect(screen.getByText('Scratch pad')).toBeVisible()
expect(screen.queryByText('body')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Close panel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
test('pressing a header button does not start a drag', () => {
const onClose = vi.fn()
renderWindow({ onClose })
const close = screen.getByRole('button', { name: 'Close panel' })
fireEvent.pointerDown(close, {
button: 0,
pointerId: 2,
clientX: 380,
clientY: 30,
})
fireEvent.pointerMove(screen.getByTitle('Drag to move'), {
pointerId: 2,
clientX: 100,
clientY: 300,
})
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ left: '100px', top: '20px' })
})
@@ -21,11 +21,12 @@ import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
import { useNotifications } from '@/hooks/use-notifications'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import type { TopNavLink } from '../types'
import { Header } from './header'
import { SystemBrand } from './system-brand'
import { TopNav } from './top-nav'
@@ -111,40 +112,43 @@ export function AppHeader({
const notifications = useNotifications()
return (
<>
<Header>
<Header>
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1'>
<SystemBrand variant='inline' />
<SystemUpdateAction presentation='version' />
</div>
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? (
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && <Search />}
{showNotifications && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
<LanguageSwitcher />
{showConfigDrawer && <ConfigDrawer />}
{showProfileDropdown && <ProfileDropdown />}
</div>
)}
</Header>
</>
{rightContent ?? (
<div className='ms-auto flex shrink-0 items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && (
<Search className='w-8 flex-none [&>span]:hidden sm:[&>span]:inline' />
)}
{showNotifications && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
<LanguageSwitcher />
{showConfigDrawer && <ConfigDrawer />}
{showProfileDropdown && <ProfileDropdown />}
</div>
)}
</Header>
)
}
@@ -48,6 +48,7 @@ import {
resolveChatUrl,
type ChatPreset,
} from '@/features/chat/lib/chat-links'
import { handleServerError } from '@/lib/handle-server-error'
import { normalizeHref } from '../lib/url-utils'
import type { NavChatPresets } from '../types'
@@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
: t(
'Unable to prepare chat link. Please ensure you have an enabled API key.'
)
toast.error(message)
handleServerError(error, message)
return
} finally {
loadingPresetIdRef.current = null
@@ -149,6 +149,22 @@ function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
)
}
export function ServiceSupportAttribution() {
const { t } = useTranslation()
return (
<span className='text-muted-foreground/60 text-xs'>
<a
href='https://www.crash.work/'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('Service support provided by Crash Workshop Cloud Computing')}
</a>
</span>
)
}
export function Footer(props: FooterProps) {
const { t } = useTranslation()
const {
@@ -238,6 +254,7 @@ export function Footer(props: FooterProps) {
/>
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<LegalLinks />
<ServiceSupportAttribution />
<ProjectAttribution currentYear={currentYear} inline />
</div>
</div>
@@ -272,14 +289,14 @@ export function Footer(props: FooterProps) {
{/* Links columns */}
{isDemoSiteMode && (
<div className='grid grid-cols-3 gap-8 md:gap-16'>
{displayColumns.map((column, index) => (
<div key={index}>
{displayColumns.map((column) => (
<div key={column.title}>
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
{t(column.title)}
</p>
<ul className='space-y-2.5'>
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
{column.links.map((link) => (
<li key={`${link.text}:${link.href}`}>
<FooterLinkItem link={link} />
</li>
))}
@@ -301,6 +318,7 @@ export function Footer(props: FooterProps) {
<LegalLinks leadingSeparator />
</div>
<ProjectAttribution currentYear={currentYear} />
<ServiceSupportAttribution />
</div>
</div>
</footer>
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
@@ -27,6 +27,7 @@ import { ProfileDropdown } from '@/components/profile-dropdown'
import { ThemeSwitch } from '@/components/theme-switch'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
@@ -98,6 +99,29 @@ export function PublicHeader(props: PublicHeaderProps) {
const displaySiteName = customSiteName || systemName
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
let logoContent: ReactNode = (
<HeaderLogo
src={systemLogo}
loading={loading}
logoLoaded={logoLoaded}
className='size-full rounded-lg object-contain'
/>
)
if (customLogo) logoContent = customLogo
if (loading) logoContent = <Skeleton className='size-full rounded-lg' />
let authContent = (
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
render={<Link to='/sign-in' />}
>
{t('Sign in')}
</Button>
)
if (isAuthenticated) authContent = <ProfileDropdown />
if (loading) authContent = <Skeleton className='h-8 w-20 rounded-lg' />
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20)
onScroll()
@@ -184,52 +208,52 @@ export function PublicHeader(props: PublicHeaderProps) {
>
<nav
className={cn(
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
'flex items-center justify-between gap-2 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
scrolled
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
: 'h-16 px-2'
)}
>
{/* Logo */}
<Link
to={homeUrl}
className='group flex shrink-0 items-center gap-2.5'
>
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
{loading ? (
<Skeleton className='size-full rounded-lg' />
) : customLogo ? (
customLogo
) : (
<HeaderLogo
src={systemLogo}
loading={loading}
logoLoaded={logoLoaded}
className='size-full rounded-lg object-contain'
/>
)}
</div>
<span className='text-sm font-semibold tracking-tight'>
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
</span>
</Link>
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1 lg:min-w-36'>
<Link
to={homeUrl}
className='group flex min-w-0 items-center gap-2.5'
>
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
{logoContent}
</div>
<span
className='max-w-48 truncate text-sm font-semibold tracking-tight'
title={displaySiteName}
>
{loading ? (
<Skeleton className='h-4 w-16' />
) : (
displaySiteName
)}
</span>
</Link>
<SystemUpdateAction presentation='version' />
</div>
{/* Desktop nav */}
<div className='hidden items-center gap-0.5 sm:flex'>
{links.map((link, i) => {
<div className='hidden min-w-0 items-center gap-0.5 lg:flex'>
{links.map((link) => {
const isActive = pathname === link.href
if (link.external) {
return (
<a
key={i}
key={`${link.title}:${link.href}`}
href={link.href}
title={t(link.title)}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
'text-muted-foreground hover:text-foreground min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
link.disabled && 'pointer-events-none opacity-50'
)}
>
@@ -239,12 +263,13 @@ export function PublicHeader(props: PublicHeaderProps) {
}
return (
<Link
key={i}
key={`${link.title}:${link.href}`}
to={link.href}
title={t(link.title)}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
'min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
@@ -280,25 +305,13 @@ export function PublicHeader(props: PublicHeaderProps) {
{showAuthButtons && (
<>
<div className='bg-border/40 mx-1 h-4 w-px' />
{loading ? (
<Skeleton className='h-8 w-20 rounded-lg' />
) : isAuthenticated ? (
<ProfileDropdown />
) : (
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
render={<Link to='/sign-in' />}
>
{t('Sign in')}
</Button>
)}
{authContent}
</>
)}
</div>
{/* Mobile: compact actions + hamburger */}
<div className='flex items-center gap-2 sm:hidden'>
<div className='flex shrink-0 items-center gap-2 lg:hidden'>
{showThemeSwitch && <ThemeSwitch />}
{showAuthButtons && !loading && isAuthenticated && (
<ProfileDropdown />
@@ -340,7 +353,7 @@ export function PublicHeader(props: PublicHeaderProps) {
{/* Mobile full-screen overlay */}
<div
className={cn(
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] lg:pointer-events-none lg:hidden',
mobileOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
@@ -364,7 +377,7 @@ export function PublicHeader(props: PublicHeaderProps) {
if (link.external) {
return (
<a
key={i}
key={`${link.title}:${link.href}`}
href={link.href}
target='_blank'
rel='noopener noreferrer'
@@ -380,7 +393,7 @@ export function PublicHeader(props: PublicHeaderProps) {
}
return (
<Link
key={i}
key={`${link.title}:${link.href}`}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link, true)}
@@ -61,11 +61,11 @@ export function SystemBrand(props: SystemBrandProps) {
to='/'
aria-label={t('Go to home')}
className={cn(
'text-foreground inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
'text-foreground inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
'hover:bg-accent focus-visible:ring-ring/40 focus-visible:ring-2'
)}
>
<div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
<div className='flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-md'>
<img
src={logo}
alt={t('Logo')}
@@ -16,9 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import type { TFunction } from 'i18next'
import {
Box,
Route,
CreditCard,
Layout,
Settings,
@@ -32,6 +33,7 @@ import { getBillingSectionNavItems } from '@/features/system-settings/billing/se
import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx'
import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx'
import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx'
import { getPolicySectionNavItems } from '@/features/system-settings/request-policies/section-registry'
import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx'
import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx'
@@ -66,10 +68,15 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
items: getBillingSectionNavItems(t),
},
{
title: t('Models & Routing'),
title: t('Models'),
icon: Box,
items: getModelsSectionNavItems(t),
},
{
title: t('Request policies'),
icon: Route,
items: getPolicySectionNavItems(t),
},
{
title: t('Security & Limits'),
icon: ShieldAlert,
+58 -2
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowRightLeft } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -41,6 +42,11 @@ import { cn } from '@/lib/utils'
export type Option = {
label: string
value: string
/**
* Secondary text shown beside the option in the dropdown. Chips for hinted
* values also carry a marker icon whose tooltip repeats the hint.
*/
hint?: string
}
interface MultiSelectProps {
@@ -139,6 +145,14 @@ export function MultiSelect(props: MultiSelectProps) {
return map
}, [props.options])
const hintMap = React.useMemo(() => {
const map = new Map<string, string>()
for (const option of props.options) {
if (option.hint) map.set(option.value, option.hint)
}
return map
}, [props.options])
const trimmedInput = inputValue.trim()
const inputMatchesExisting =
trimmedInput.length > 0 &&
@@ -164,7 +178,7 @@ export function MultiSelect(props: MultiSelectProps) {
if (canCreate) {
set.add(trimmedInput)
}
return Array.from(set)
return [...set]
}, [props.options, props.selected, canCreate, trimmedInput])
const addValues = React.useCallback(
@@ -198,6 +212,25 @@ export function MultiSelect(props: MultiSelectProps) {
setInputValue(value)
}
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>): void => {
if (!props.allowCreate || props.disabled) return
const pasted = event.clipboardData.getData('text/plain')
if (!COMMA_REGEX.test(pasted)) return
event.preventDefault()
const input = event.currentTarget
const value =
input.value.slice(0, input.selectionStart ?? input.value.length) +
pasted +
input.value.slice(input.selectionEnd ?? input.value.length)
// A pasted batch is complete, including its final value. Read the clipboard
// before the single-line input can strip newline separators.
addValues(value.split(COMMA_REGEX))
setInputValue('')
}
const handleValueChange = (next: string[]) => {
props.onChange(next)
// When an item is picked (multiple mode), Base UI keeps the input but most
@@ -282,6 +315,7 @@ export function MultiSelect(props: MultiSelectProps) {
<>
{visibleValues.map((value) => {
const label = labelMap.get(value) ?? value
const hint = hintMap.get(value)
return (
<ComboboxChip key={value}>
{props.copyChipOnClick ? (
@@ -299,6 +333,15 @@ export function MultiSelect(props: MultiSelectProps) {
) : (
<span className='max-w-[16rem] truncate'>{label}</span>
)}
{hint && (
<span
title={hint}
aria-hidden='true'
className='text-muted-foreground inline-flex shrink-0'
>
<ArrowRightLeft className='size-3' />
</span>
)}
</ComboboxChip>
)
})}
@@ -346,6 +389,7 @@ export function MultiSelect(props: MultiSelectProps) {
: undefined
}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
aria-label={placeholder}
/>
</ComboboxChips>
@@ -356,11 +400,13 @@ export function MultiSelect(props: MultiSelectProps) {
{(item: string) => {
const isCreate = canCreate && item === trimmedInput
const label = labelMap.get(item) ?? item
const hint = hintMap.get(item)
return (
<ComboboxItem
key={item}
value={item}
className={isCreate ? 'text-foreground' : undefined}
aria-description={hint}
>
{isCreate ? (
<>
@@ -377,7 +423,17 @@ export function MultiSelect(props: MultiSelectProps) {
</span>
</>
) : (
<span className='truncate'>{label}</span>
<>
<span className='truncate'>{label}</span>
{hint && (
<span
aria-hidden='true'
className='text-muted-foreground ml-auto max-w-40 shrink-0 truncate text-xs'
>
{hint}
</span>
)}
</>
)}
</ComboboxItem>
)
@@ -0,0 +1,53 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { MultiSelect } from '@/components/multi-select'
test('hinted options show the hint in the dropdown and mark their chips without changing the chip name', async () => {
const user = userEvent.setup()
render(
<MultiSelect
options={[
{ value: 'alias', label: 'alias', hint: 'Redirects to upstream' },
{ value: 'plain', label: 'plain' },
]}
selected={['alias', 'plain']}
onChange={() => undefined}
copyChipOnClick
/>
)
expect(screen.getByRole('button', { name: 'alias' })).toBeVisible()
expect(screen.getByTitle('Redirects to upstream')).toHaveAttribute(
'aria-hidden',
'true'
)
expect(screen.getAllByTitle('Redirects to upstream')).toHaveLength(1)
await user.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: /^alias/ })).toHaveTextContent(
'Redirects to upstream'
)
expect(screen.getByRole('option', { name: 'plain' })).not.toHaveTextContent(
'Redirects to upstream'
)
})
@@ -0,0 +1,159 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { MultiSelect } from '@/components/multi-select'
const options = [
{ value: 'a', label: 'a' },
{ value: 'c', label: 'c' },
]
function Fixture(props: {
initialSelected?: string[]
allowCreate?: boolean
disabled?: boolean
}) {
const [selected, setSelected] = useState(props.initialSelected ?? [])
return (
<>
<MultiSelect
options={options}
selected={selected}
onChange={setSelected}
allowCreate={props.allowCreate ?? true}
disabled={props.disabled}
/>
<output aria-label='Selected values'>{selected.join(',')}</output>
</>
)
}
describe('multi-select batch paste', () => {
it.each(['a,b,c', 'a,b,c', 'a\nb\nc', 'a\r\nb\r\nc'])(
'adds every pasted value including the final item without a trailing separator: %j',
async (text) => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste(text)
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^a,b,c$/
)
expect(input).toHaveValue('')
expect(input).toHaveFocus()
}
)
it('preserves existing selections and ignores duplicate, blank, and trailing entries', async () => {
const user = userEvent.setup()
render(<Fixture initialSelected={['existing', 'a']} />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste(' a,, b,b\r\nc, ')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^existing,a,b,c$/
)
expect(input).toHaveValue('')
})
it('replaces the selected draft text when pasting a batch and preserves surrounding text', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole<HTMLInputElement>('combobox')
await user.type(input, 'model-old-end')
input.setSelectionRange(6, 9)
await user.paste('a,b')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^model-a,b-end$/
)
expect(input).toHaveValue('')
})
it('keeps the last manually typed value as a draft until a separator is typed', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.type(input, 'a,b,c')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^a,b$/)
expect(input).toHaveValue('c')
await user.type(input, ',')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^a,b,c$/
)
expect(input).toHaveValue('')
})
it('keeps a single pasted value available for search and explicit selection', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('c')
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('c')
await user.click(screen.getByRole('option', { name: 'c' }))
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^c$/)
expect(input).toHaveValue('')
})
it('leaves pasted text as search input when custom creation is disabled', async () => {
const user = userEvent.setup()
render(<Fixture allowCreate={false} />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('a,b,c')
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('a,b,c')
})
it('does not add pasted values when the control is disabled', async () => {
const user = userEvent.setup()
render(<Fixture disabled />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('a,b,c')
expect(input).toBeDisabled()
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('')
})
})
+3 -4
View File
@@ -25,6 +25,7 @@ import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { logout } from '@/features/auth/api'
import { clearAuthenticatedClientState } from '@/lib/auth-session'
import { handleServerError } from '@/lib/handle-server-error'
interface SignOutDialogProps {
open: boolean
@@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
try {
const response = await logout()
if (!response.success) {
toast.error(response.message || t('Failed to sign out session'))
handleServerError(response, t('Failed to sign out session'))
return
}
@@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
toast.success(t('Signed out'))
void navigate({ to: '/sign-in', replace: true })
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to sign out session')
)
handleServerError(error, t('Failed to sign out session'))
} finally {
setIsSigningOut(false)
}
@@ -21,7 +21,10 @@ import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { Dialog } from '@/components/dialog'
import { Combobox } from '../combobox'
import { Sheet, SheetContent, SheetTitle } from '../sheet'
const options = [
{ value: 'openai', label: 'OpenAI' },
@@ -46,6 +49,90 @@ function Fixture() {
}
describe('searchable single selection', () => {
it.each(['dialog', 'sheet'] as const)(
'keeps the options closed when a %s autofocuses the input, then opens on click',
async (container) => {
const field = <Fixture />
render(
container === 'dialog' ? (
<Dialog open title='Choose a provider'>
{field}
</Dialog>
) : (
<Sheet open>
<SheetContent>
<SheetTitle>Choose a provider</SheetTitle>
{field}
</SheetContent>
</Sheet>
)
)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await waitFor(() => expect(input).toHaveFocus())
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(input).toHaveValue('OpenAI')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
await user.click(input)
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
await user.click(screen.getByRole('option', { name: 'Google' }))
await waitFor(() => expect(input).toHaveValue('Google'))
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByRole('dialog')).toBeVisible()
}
)
it('keeps options closed on Tab focus and opens them with the arrow key', async () => {
render(<Fixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-expanded', 'false')
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
await user.keyboard('{Escape}')
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(input).toHaveValue('OpenAI')
})
it('opens and filters when typing into a focused, closed input', async () => {
render(<Fixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
await user.keyboard('g')
expect(input).toHaveValue('g')
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
expect(
screen.queryByRole('option', { name: 'OpenAI' })
).not.toBeInTheDocument()
})
it.each([true, false])(
'honors explicit openOnFocus=%s',
async (openOnFocus) => {
render(
<Combobox
options={options}
value='openai'
aria-label='Provider'
openOnFocus={openOnFocus}
/>
)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-expanded', String(openOnFocus))
if (!openOnFocus) {
await user.click(screen.getByRole('button', { name: 'Provider' }))
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
}
}
)
it('searches labels and values without committing text, shows empty results, and restores the selection on Escape', async () => {
render(<Fixture />)
const user = userEvent.setup()
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { describe, expect, it } from 'vitest'
import { FieldLabel } from '../field'
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form'
import { Input } from '../input'
import { Label } from '../label'
function FormLabelFixture() {
const form = useForm({ defaultValues: { name: '' } })
return (
<Form {...form}>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
</Form>
)
}
describe('required field labels', () => {
it.each([Label, FieldLabel])(
'updates the required marker while preserving the associated control',
async (LabelComponent) => {
const view = render(
<>
<LabelComponent htmlFor='model' required>
Model
</LabelComponent>
<Input id='model' />
</>
)
const user = userEvent.setup()
const input = screen.getByRole('textbox', { name: 'Model *' })
await user.click(screen.getByText('*'))
expect(input).toHaveFocus()
view.rerender(
<>
<LabelComponent htmlFor='model' required={false}>
Model
</LabelComponent>
<Input id='model' />
</>
)
expect(screen.queryByText('*')).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Model' })).toBe(input)
}
)
it('keeps the required form label associated with its React Hook Form control', async () => {
render(<FormLabelFixture />)
const user = userEvent.setup()
const input = screen.getByRole('textbox', { name: 'Name *' })
await user.click(screen.getByText('*'))
expect(input).toHaveFocus()
await user.type(input, 'Example')
expect(input).toHaveValue('Example')
})
})
+117 -52
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Check, ChevronsUpDown } from 'lucide-react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
@@ -38,12 +39,15 @@ interface ComboboxInputProps {
placeholder?: string
emptyText?: string
className?: string
dropdownClassName?: string
id?: string
allowCustomValue?: boolean
openOnFocus?: boolean
disabled?: boolean
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
'aria-label'?: string
'aria-labelledby'?: string
'aria-invalid'?: React.AriaAttributes['aria-invalid']
}
export function ComboboxInput({
@@ -53,12 +57,15 @@ export function ComboboxInput({
placeholder = 'Select or type...',
emptyText = 'No option found.',
className,
dropdownClassName,
id,
allowCustomValue = false,
openOnFocus = true,
disabled = false,
onKeyDown,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-invalid': ariaInvalid,
}: ComboboxInputProps) {
const { t } = useTranslation()
const listId = React.useId()
@@ -69,6 +76,16 @@ export function ComboboxInput({
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const listRef = React.useRef<HTMLUListElement>(null)
const dropdownRef = React.useRef<HTMLDivElement>(null)
// The dropdown is portaled so scrolling ancestors cannot clip it. It goes
// into the enclosing dialog when there is one, which keeps a modal parent
// from treating option clicks as outside presses.
const [dropdown, setDropdown] = React.useState<{
container: HTMLElement
top: number
left: number
width: number
} | null>(null)
const pointerFocusRef = React.useRef(false)
const selectedOption = React.useMemo(
() => options.find((option) => option.value === value),
@@ -96,9 +113,11 @@ export function ComboboxInput({
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
!containerRef.current.contains(target) &&
!dropdownRef.current?.contains(target)
) {
setOpen(false)
setSearchValue('')
@@ -109,6 +128,34 @@ export function ComboboxInput({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
// Position the portaled dropdown under the input and follow scroll/resize.
React.useEffect(() => {
if (!open) {
setDropdown(null)
return
}
const input = inputRef.current
if (!input) return
const container =
input.closest<HTMLElement>('[role="dialog"]') ?? document.body
const measure = () => {
const rect = input.getBoundingClientRect()
setDropdown({
container,
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}
measure()
window.addEventListener('scroll', measure, true)
window.addEventListener('resize', measure)
return () => {
window.removeEventListener('scroll', measure, true)
window.removeEventListener('resize', measure)
}
}, [open])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
@@ -172,6 +219,7 @@ export function ComboboxInput({
const showDropdown =
open &&
!disabled &&
(filteredOptions.length > 0 || (allowCustomValue && searchValue.trim()))
return (
@@ -181,8 +229,10 @@ export function ComboboxInput({
id={id}
type='text'
role='combobox'
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-invalid={ariaInvalid}
aria-expanded={!!showDropdown}
aria-controls={
showDropdown && filteredOptions.length > 0 ? listId : undefined
@@ -234,59 +284,74 @@ export function ComboboxInput({
/>
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
{showDropdown && (
<div className='bg-popover text-popover-foreground absolute top-full z-100 mt-1 w-full rounded-md border shadow-md'>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
id={listId}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
{showDropdown &&
dropdown &&
createPortal(
<div
ref={dropdownRef}
style={{
position: 'fixed',
top: dropdown.top,
left: dropdown.left,
width: dropdown.width,
}}
className={cn(
'bg-popover text-popover-foreground z-100 rounded-md border shadow-md',
dropdownClassName
)}
>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
id={listId}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
/>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>,
dropdown.container
)}
</div>
)
}
+7 -3
View File
@@ -50,6 +50,7 @@ type LegacyComboboxProps = {
allowCustomValue?: boolean
showSelectedIcon?: boolean
className?: string
dropdownClassName?: string
id?: string
openOnFocus?: boolean
disabled?: boolean
@@ -79,6 +80,7 @@ function Combobox(
id={props.id}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-invalid={props['aria-invalid']}
onKeyDown={props.onKeyDown}
options={props.options}
value={props.value ?? ''}
@@ -86,6 +88,7 @@ function Combobox(
placeholder={props.searchPlaceholder ?? props.placeholder}
emptyText={props.emptyText}
className={props.className}
dropdownClassName={props.dropdownClassName}
allowCustomValue={props.allowCustomValue}
openOnFocus={props.openOnFocus}
/>
@@ -113,9 +116,9 @@ function OptionCombobox(props: LegacyComboboxProps) {
onInputValueChange={(value, details) => {
if (details.reason === 'input-change') setSearch(value)
}}
onOpenChange={(nextOpen) => {
onOpenChange={(nextOpen, details) => {
setOpen(nextOpen)
setSearch('')
if (details.reason !== 'input-change') setSearch('')
}}
onValueChange={(option) => {
if (option) props.onValueChange?.(option.value)
@@ -137,7 +140,8 @@ function OptionCombobox(props: LegacyComboboxProps) {
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
onFocus={() => {
if (props.openOnFocus !== false) setOpen(true)
// Dialog autofocus should not expand a select-style combobox.
if (props.openOnFocus) setOpen(true)
}}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
+18 -2
View File
@@ -22,7 +22,12 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<'label'>) {
function Label({
className,
children,
required = false,
...props
}: React.ComponentProps<'label'> & { required?: boolean }) {
return (
<label
data-slot='label'
@@ -31,7 +36,18 @@ function Label({ className, ...props }: React.ComponentProps<'label'>) {
className
)}
{...props}
/>
>
{required ? (
<span>
{children}{' '}
<span data-slot='required-indicator' className='text-destructive'>
*
</span>
</span>
) : (
children
)}
</label>
)
}
+23 -5
View File
@@ -26,6 +26,8 @@ import { useMemo } from 'react'
import { cn } from '@/lib/utils'
interface MarkdownProps {
/** Resolve relative document links against their source, never the dashboard URL. */
baseUrl?: string
breaks?: boolean
children: string
className?: string
@@ -718,7 +720,7 @@ const markdownParser = new Marked({
markdownParser.use(...markdownExtensions)
function addExternalLinkAttributes(html: string): string {
function addExternalLinkAttributes(html: string, baseUrl?: string): string {
if (typeof window === 'undefined') {
return html
}
@@ -727,6 +729,18 @@ function addExternalLinkAttributes(html: string): string {
template.innerHTML = html
template.content.querySelectorAll('a[href]').forEach((link) => {
if (baseUrl) {
try {
const url = new URL(link.getAttribute('href') ?? '', baseUrl)
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
link.setAttribute('href', url.href)
} else {
link.removeAttribute('href')
}
} catch {
link.removeAttribute('href')
}
}
link.setAttribute('target', '_blank')
link.setAttribute('rel', 'noopener noreferrer')
})
@@ -734,20 +748,24 @@ function addExternalLinkAttributes(html: string): string {
return template.innerHTML
}
function renderMarkdown(markdown: string, breaks = false): string {
function renderMarkdown(
markdown: string,
breaks = false,
baseUrl?: string
): string {
const parsedHtml = markdownParser.parse(markdown, {
...markdownOptions,
breaks,
})
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
return addExternalLinkAttributes(html)
return addExternalLinkAttributes(html, baseUrl)
}
export function Markdown(props: MarkdownProps) {
const html = useMemo(
() => renderMarkdown(props.children, props.breaks),
[props.breaks, props.children]
() => renderMarkdown(props.children, props.breaks, props.baseUrl),
[props.breaks, props.children, props.baseUrl]
)
return (
+2 -1
View File
@@ -24,6 +24,7 @@ import { PublicLayout } from '@/components/layout'
import { RichContent } from '@/components/rich-content'
import { Skeleton } from '@/components/ui/skeleton'
import { isHttpUrl, isLikelyHtml } from '@/lib/content-format'
import { requireServerSuccess } from '@/lib/server-error-message'
import { getAboutContent } from './api'
@@ -116,7 +117,7 @@ export function About() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({
queryKey: ['about-content'],
queryFn: getAboutContent,
queryFn: async () => requireServerSuccess(await getAboutContent()),
})
const rawContent = data?.data?.trim() ?? ''
@@ -42,6 +42,9 @@ import {
} from '@/features/auth/constants'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { useCountdown } from '@/hooks/use-countdown'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
export function ForgotPasswordForm({
@@ -81,10 +84,14 @@ export function ForgotPasswordForm({
startCountdown()
toast.success(t('Reset email sent, please check your inbox'))
} else {
toast.error(res?.message || t('Failed to send reset email'))
handleServerError(
createServerError(res, t('Failed to send reset email'))
)
}
} catch (_error) {
// Errors are handled by global interceptor
handleServerError(
AuthOperationError.from(_error, t('Failed to send reset email'))
)
} finally {
setIsLoading(false)
}
@@ -21,6 +21,9 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { sendEmailVerification } from '../api'
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
@@ -63,12 +66,17 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) {
toast.success(i18next.t('Verification email sent'))
return true
}
toast.error(
res?.message || i18next.t('Failed to send verification email')
handleServerError(
createServerError(res, i18next.t('Failed to send verification email'))
)
return false
} catch (_error) {
// Errors are handled by global interceptor
handleServerError(
AuthOperationError.from(
_error,
i18next.t('Failed to send verification email')
)
)
return false
} finally {
setIsSending(false)
+26 -13
View File
@@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { clearAuthentication } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { createOAuthAuthorization, createOAuthFlow, logout } from '../api'
import {
@@ -59,7 +61,7 @@ export function useOAuthLogin(
const resetSession = async () => {
const response = await logout()
if (!response.success) {
throw new Error(response.message || t('Failed to sign out session'))
throw createServerError(response, t('Failed to sign out session'))
}
clearAuthentication()
}
@@ -91,8 +93,10 @@ export function useOAuthLogin(
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
} catch {
toast.error(t('Failed to start GitHub login'))
} catch (error) {
handleServerError(
AuthOperationError.from(error, t('Failed to start GitHub login'))
)
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
@@ -113,8 +117,10 @@ export function useOAuthLogin(
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
window.open(url, '_self')
} catch {
toast.error(t('Failed to start Discord login'))
} catch (error) {
handleServerError(
AuthOperationError.from(error, t('Failed to start Discord login'))
)
} finally {
setIsLoading(false)
}
@@ -135,8 +141,10 @@ export function useOAuthLogin(
state
)
window.open(url, '_self')
} catch {
toast.error(t('Failed to start OIDC login'))
} catch (error) {
handleServerError(
AuthOperationError.from(error, t('Failed to start OIDC login'))
)
} finally {
setIsLoading(false)
}
@@ -153,8 +161,10 @@ export function useOAuthLogin(
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
window.open(url, '_self')
} catch {
toast.error(t('Failed to start LinuxDO login'))
} catch (error) {
handleServerError(
AuthOperationError.from(error, t('Failed to start LinuxDO login'))
)
} finally {
setIsLoading(false)
}
@@ -179,7 +189,7 @@ export function useOAuthLogin(
rememberOAuthLoginRedirect(authorization.state, redirectTo)
window.open(authorization.authorizationUrl, '_self')
} catch (error) {
toast.error(t(AuthOperationError.from(error).message))
handleServerError(AuthOperationError.from(error))
} finally {
setIsLoading(false)
}
@@ -205,9 +215,12 @@ export function useOAuthLogin(
}
window.open(url.toString(), '_self')
} catch {
toast.error(
t('Failed to start {{provider}} login', { provider: provider.name })
} catch (error) {
handleServerError(
AuthOperationError.from(
error,
t('Failed to start {{provider}} login', { provider: provider.name })
)
)
} finally {
setIsLoading(false)
@@ -20,6 +20,7 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
@@ -77,7 +78,7 @@ export function OtpForm() {
toast.success(t('Signed in'))
} catch (error) {
if (!active) return
toast.error(t(AuthOperationError.from(error).message))
handleServerError(AuthOperationError.from(error))
redirectToLogin()
}
})()
@@ -0,0 +1,447 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// @vitest-environment-options {"url":"https://www.example.com"}
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { STATUS_QUERY_KEY } from '@/lib/status-query'
import type { AuthBundle } from '@/stores/auth-store'
import { verify, verifyLogin } from '../../secure-verification/api'
import { SecureVerificationDialog } from '../../secure-verification/components/secure-verification-dialog'
import { useSecureVerification } from '../../secure-verification/hooks/use-secure-verification'
import type { SecurityProof } from '../../secure-verification/types'
import { PasskeyDomainSelector } from '../components/passkey-domain-selector'
const storageKey = 'passkey:last-successful-rp-id'
const operation = {
scope: 'channel.key.read',
context: { channel_id: 7 },
} as const
const proof: SecurityProof = {
proof_token: 'verified-proof',
scope: operation.scope,
method: 'passkey',
expires_at: 4102444800,
}
const credential = {
id: 'credential',
rawId: new Uint8Array([1, 2, 3]).buffer,
type: 'public-key',
response: {
clientDataJSON: new Uint8Array([1]).buffer,
authenticatorData: new Uint8Array([2]).buffer,
signature: new Uint8Array([3]).buffer,
userHandle: new Uint8Array([4]).buffer,
},
getClientExtensionResults: () => ({}),
}
function setupPasskeyNetwork() {
const get = vi.fn().mockResolvedValue(credential)
vi.stubGlobal('PublicKeyCredential', class {})
vi.stubGlobal('navigator', { credentials: { get } })
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data:
url === '/api/status'
? {
passkey_rp_ids: ['example.com', 'www.example.com'],
}
: {
scope: operation.scope,
methods: [{ method: 'passkey', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
}))
let flow = 0
const post = vi
.spyOn(api, 'post')
.mockImplementation(async (url, data?: unknown) => {
const request = data as { rp_id?: string } | undefined
if (url.endsWith('/begin')) {
return {
data: {
success: true,
data: {
flow_token: `flow-${++flow}`,
rp_ids: ['example.com', 'www.example.com'],
options: {
publicKey: {
challenge: 'AQID',
rpId: request?.rp_id ?? 'example.com',
},
},
},
},
}
}
return { data: { success: true, data: proof } }
})
return { get, post }
}
function DomainVerificationHarness(props: {
onResult: (proof: SecurityProof | null) => void
}) {
const verification = useSecureVerification()
return (
<>
<button
type='button'
onClick={async () =>
props.onResult(await verification.requestVerification(operation))
}
>
Protected action
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
)
}
beforeEach(() => localStorage.clear())
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
localStorage.clear()
})
describe('Passkey domain compatibility', () => {
it('binds a login factor to its pending login and the selected domain challenge', async () => {
const { get, post } = setupPasskeyNetwork()
const bundle: AuthBundle = {
access_token: 'login-access',
token_type: 'Bearer',
access_expires_at: 4102444800,
user: { id: 7, username: 'user', role: 1 },
session: {
sid: 'login-session',
current: true,
login_method: 'passkey',
ip: '',
user_agent: '',
created_at: 1,
last_active_at: 1,
expires_at: 4102444800,
},
}
post
.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'domain-flow',
rp_ids: ['www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
.mockResolvedValueOnce({ data: { success: true, data: bundle } })
await expect(
verifyLogin(
{ method: 'passkey', rpID: 'www.example.com' },
{
require_verification: true,
flow_token: 'pending-login',
expires_at: 4102444800,
methods: [{ method: 'passkey', available: true }],
},
new AbortController().signal
)
).resolves.toEqual(bundle)
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(post).toHaveBeenNthCalledWith(
1,
'/api/user/login/passkey/begin',
{
flow_token: 'pending-login',
rp_id: 'www.example.com',
},
expect.objectContaining({ skipAuthRefresh: true })
)
expect(post).toHaveBeenLastCalledWith(
'/api/user/login/passkey/finish',
expect.objectContaining({
flow_token: 'pending-login',
passkey_flow_token: 'domain-flow',
}),
expect.anything()
)
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
})
it('uses the last successful domain without storing flows or credentials', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'www.example.com')
await expect(
verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(post).toHaveBeenNthCalledWith(
1,
'/api/user/passkey/verify/begin',
{ ...operation, rp_id: 'www.example.com' },
expect.anything()
)
expect(get).toHaveBeenCalledOnce()
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
expect(localStorage.length).toBe(1)
})
it('accepts the credential domain chosen by the server over a remembered hint', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'example.com')
post.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'known-flow',
rp_ids: ['www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
await verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
})
it('discards a removed remembered domain before opening a single browser prompt', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'removed.example.com')
post.mockResolvedValueOnce({
data: { success: false, code: 'PASSKEY_RP_ID_UNAVAILABLE' },
})
await verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
expect(post).toHaveBeenNthCalledWith(
2,
'/api/user/passkey/verify/begin',
operation,
expect.anything()
)
expect(get).toHaveBeenCalledOnce()
expect(localStorage.getItem(storageKey)).toBe('example.com')
})
it('does not remember or replay a failed finish request', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'example.com')
post
.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'flow',
rp_ids: ['example.com', 'www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
.mockResolvedValueOnce({
data: { success: false, code: 'SECURITY_VERIFICATION_FAILED' },
})
await expect(
verify(
{ method: 'passkey', rpID: 'www.example.com' },
operation,
false,
new AbortController().signal
)
).rejects.toThrow()
expect(get).toHaveBeenCalledOnce()
expect(post).toHaveBeenCalledTimes(2)
expect(localStorage.getItem(storageKey)).toBe('example.com')
})
it('allows a manual domain switch after cancellation with a fresh challenge', async () => {
const { get, post } = setupPasskeyNetwork()
get.mockRejectedValueOnce(
new DOMException('No matching credential', 'NotAllowedError')
)
const onResult = vi.fn()
const user = userEvent.setup()
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<DomainVerificationHarness onResult={onResult} />
</QueryClientProvider>
)
await user.click(screen.getByRole('button', { name: 'Protected action' }))
await user.click(await screen.findByRole('button', { name: 'Verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Passkey verification was cancelled or timed out'
)
expect(get).toHaveBeenCalledOnce()
expect(post).toHaveBeenCalledTimes(1)
expect(onResult).not.toHaveBeenCalled()
await user.click(
screen.getByRole('button', { name: 'Use a Passkey from another website' })
)
await user.click(
screen.getByRole('combobox', { name: 'Passkey website domain' })
)
await user.click(
await screen.findByRole('option', { name: 'www.example.com' })
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
await waitFor(() => expect(onResult).toHaveBeenCalledWith(proof))
expect(get).toHaveBeenCalledTimes(2)
expect(post).toHaveBeenNthCalledWith(
2,
'/api/user/passkey/verify/begin',
{ ...operation, rp_id: 'www.example.com' },
expect.anything()
)
expect(post).toHaveBeenLastCalledWith(
'/api/user/passkey/verify/finish',
expect.objectContaining({ flow_token: 'flow-2' }),
expect.anything()
)
})
it('supports verification when browser storage is blocked', async () => {
const { get } = setupPasskeyNetwork()
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Blocked')
})
await expect(
verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(get).toHaveBeenCalledOnce()
})
it('does not submit an assertion after its operation is aborted', async () => {
const { get, post } = setupPasskeyNetwork()
const controller = new AbortController()
get.mockImplementation(async () => {
controller.abort()
return credential
})
await expect(
verify({ method: 'passkey' }, operation, false, controller.signal)
).rejects.toThrow()
expect(post).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(storageKey)).toBeNull()
})
})
it('keeps case-sensitive historical domains selectable and labels the distinction', async () => {
setupPasskeyNetwork()
vi.mocked(api.get).mockResolvedValue({
data: {
success: true,
data: {
passkey_rp_ids: ['www.example.com', 'WWW.example.com'],
},
},
})
const changed = vi.fn()
const user = userEvent.setup()
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<PasskeyDomainSelector value='www.example.com' onChange={changed} />
</QueryClientProvider>
)
await user.click(
await screen.findByRole('button', {
name: 'Use a Passkey from another website',
})
)
await user.click(screen.getByRole('combobox'))
expect(
await screen.findByRole('option', { name: 'www.example.com' })
).toBeVisible()
await user.click(
screen.getByRole('option', {
name: /WWW.example.com.*Historical capitalization/,
})
)
expect(changed).toHaveBeenCalledExactlyOnceWith('WWW.example.com')
})
it.each([
{ rpIDs: ['api.example.com'], canSwitch: false },
{ rpIDs: ['example.com'], canSwitch: false },
{ rpIDs: ['example.com', 'www.example.com'], canSwitch: true },
])('does not display origins from cached status with $rpIDs', async (test) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
client.setQueryData(STATUS_QUERY_KEY, {
passkey_rp_ids: test.rpIDs,
passkey_origins: 'https://www.example.com,https://api.example.com',
})
const user = userEvent.setup()
render(
<QueryClientProvider client={client}>
<PasskeyDomainSelector onChange={vi.fn()} />
</QueryClientProvider>
)
if (test.canSwitch) {
await user.click(screen.getByRole('button'))
expect(screen.getByRole('combobox')).toBeVisible()
} else {
expect(screen.queryByRole('button')).not.toBeInTheDocument()
}
expect(screen.queryAllByRole('link')).toHaveLength(0)
expect(screen.queryByText('https://api.example.com')).not.toBeInTheDocument()
})
+16 -9
View File
@@ -80,36 +80,43 @@ export function deletePasskey(
)
}
export async function beginPasskeyLogin(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin'
export function beginPasskeyLogin(
rpID?: string,
signal?: AbortSignal
): Promise<PasskeyOptionsPayload> {
return authResult(
api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin',
rpID ? { rp_id: rpID } : undefined,
{ ...authRequestOptions, signal, skipAuthRefresh: true }
)
)
return res.data
}
export async function finishPasskeyLogin(
flowToken: string,
payload: Record<string, unknown>
payload: Record<string, unknown>,
signal?: AbortSignal
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/login/finish',
{ flow_token: flowToken, credential: payload },
{ skipAuthRefresh: true }
{ skipAuthRefresh: true, signal }
)
return res.data
}
export function beginPasskeyVerification(
operation: VerificationOperation,
signal?: AbortSignal
signal?: AbortSignal,
rpID?: string
): Promise<PasskeyOptionsPayload> {
return authResult(
api.post(
'/api/user/passkey/verify/begin',
{
scope: operation.scope,
...(rpID ? { rp_id: rpID } : {}),
...(operation.context ? { context: operation.context } : {}),
},
{ ...authRequestOptions, signal }
+120
View File
@@ -0,0 +1,120 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
buildAssertionResult,
prepareCredentialRequestOptions,
} from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import type { PasskeyOptionsPayload } from './types'
const rememberedRPIDKey = 'passkey:last-successful-rp-id'
export interface PasskeyDomains {
rpID?: string
rpIDs: string[]
}
export interface PasskeySelection {
rpID?: string
onDomains?: (domains: PasskeyDomains) => void
}
export function rememberPasskeyRPID(rpID?: string) {
if (!rpID) return
try {
localStorage.setItem(rememberedRPIDKey, rpID)
} catch {
// Storage is optional; it never authorizes a credential or a domain.
}
}
export async function requestPasskeyAssertion(
begin: (rpID?: string) => Promise<PasskeyOptionsPayload>,
signal?: AbortSignal,
selection: PasskeySelection = {}
): Promise<{
flowToken: string
assertion: Record<string, unknown>
rpID?: string
}> {
signal?.throwIfAborted()
let remembered: string | undefined
try {
const value = localStorage.getItem(rememberedRPIDKey)
if (value && value.length <= 253) remembered = value
} catch {
// Private browsing and blocked storage still support Passkey verification.
}
let payload: PasskeyOptionsPayload
try {
payload = await begin(selection.rpID ?? remembered)
} catch (error) {
const failure = AuthOperationError.from(error)
if (
selection.rpID ||
!remembered ||
failure.code !== 'PASSKEY_RP_ID_UNAVAILABLE'
) {
throw failure
}
signal?.throwIfAborted()
try {
localStorage.removeItem(rememberedRPIDKey)
} catch {
/* optional storage */
}
// Retry a stale hint before opening a browser prompt. A dismissed prompt
// or a failed finish request is never retried automatically.
payload = await begin()
}
signal?.throwIfAborted()
if (!payload.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const publicKey = prepareCredentialRequestOptions(payload.options ?? payload)
let rpIDs = publicKey.rpId ? [publicKey.rpId] : []
if (Array.isArray(payload.rp_ids)) {
rpIDs = payload.rp_ids.filter((id): id is string => typeof id === 'string')
}
selection.onDomains?.({ rpID: publicKey.rpId, rpIDs })
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey,
signal,
})) as PublicKeyCredential | null
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new AuthOperationError(
'Passkey verification was cancelled or timed out'
)
}
throw error
}
signal?.throwIfAborted()
if (!credential) {
throw new AuthOperationError('Passkey verification was cancelled')
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new AuthOperationError('Unable to build Passkey assertion')
}
return { flowToken: payload.flow_token, assertion, rpID: publicKey.rpId }
}
@@ -0,0 +1,113 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { statusQueryOptions } from '@/lib/status-query'
import type { PasskeyDomains } from '../assertion'
interface PasskeyDomainSelectorProps {
domains?: PasskeyDomains | null
value?: string
onChange: (rpID: string) => void
disabled?: boolean
}
export function PasskeyDomainSelector(props: PasskeyDomainSelectorProps) {
const { t } = useTranslation()
const id = useId()
const [expanded, setExpanded] = useState(false)
const { data: status } = useQuery(statusQueryOptions)
const configured = Array.isArray(status?.passkey_rp_ids)
? status.passkey_rp_ids.filter(
(value): value is string => typeof value === 'string'
)
: []
const candidates = props.domains?.rpIDs ?? configured
const available = candidates.filter((rpID) => {
const domain = rpID.toLowerCase()
return (
window.location.hostname === domain ||
window.location.hostname.endsWith(`.${domain}`)
)
})
if (available.length < 2) return null
const value = props.value ?? props.domains?.rpID ?? available[0]
return (
<div className='space-y-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
disabled={props.disabled}
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={id}
>
{t('Use a Passkey from another website')}
</Button>
{expanded && (
<div id={id} className='space-y-2'>
<Label htmlFor={`${id}-domain`}>{t('Passkey website domain')}</Label>
<Select
value={available.includes(value ?? '') ? value : available[0]}
onValueChange={(value) => {
if (value) props.onChange(value)
}}
disabled={props.disabled}
>
<SelectTrigger id={`${id}-domain`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{available.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
{domain !== domain.toLowerCase() &&
available.some(
(other) =>
other !== domain &&
other.toLowerCase() === domain.toLowerCase()
) && <> · {t('Historical capitalization')}</>}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground'>
{t(
'Choose the domain used when this Passkey was created, then try again.'
)}
</p>
</div>
)}
</div>
)
}
+1
View File
@@ -31,6 +31,7 @@ export interface PasskeyStatus {
}
export interface PasskeyOptionsPayload {
rp_ids?: string[]
options?: unknown
flow_token?: string
expires_at?: number
@@ -29,6 +29,9 @@ import { Label } from '@/components/ui/label'
import { useCountdown } from '@/hooks/use-countdown'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { AuthLayout } from '../auth-layout'
@@ -82,9 +85,11 @@ export function ResetPasswordConfirm({
} else {
toast.success(t('Password reset: {{password}}', { password }))
}
} else {
handleServerError(createServerError(res.data, t('Request failed')))
}
} catch {
// Errors handled by global interceptor
} catch (error) {
handleServerError(AuthOperationError.from(error))
} finally {
setLoading(false)
}
@@ -179,13 +184,15 @@ export function ResetPasswordConfirm({
newPassword ? false : loading || isActive || !isValidResetLink
}
>
{newPassword
? t('auth.resetPasswordConfirm.backToLogin')
: isActive
? t('auth.resetPasswordConfirm.retry', {
seconds: secondsLeft,
})
: t('auth.resetPasswordConfirm.confirm')}
{newPassword && t('auth.resetPasswordConfirm.backToLogin')}
{!newPassword &&
isActive &&
t('auth.resetPasswordConfirm.retry', {
seconds: secondsLeft,
})}
{!newPassword &&
!isActive &&
t('auth.resetPasswordConfirm.confirm')}
</Button>
{!newPassword && (
@@ -16,8 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
@@ -43,8 +45,11 @@ function Harness(props: {
operation?: RequestVerificationOptions
}) {
const verification = useSecureVerification()
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } })
)
return (
<>
<QueryClientProvider client={client}>
<button
type='button'
onClick={async () =>
@@ -60,7 +65,7 @@ function Harness(props: {
Protected action
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
</QueryClientProvider>
)
}
@@ -81,8 +86,11 @@ function LoginHarness(props: {
onResult: (bundle: AuthBundle | null) => void
}) {
const verification = useSecureVerification()
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } })
)
return (
<>
<QueryClientProvider client={client}>
<button
type='button'
onClick={async () =>
@@ -102,13 +110,15 @@ function LoginHarness(props: {
Continue sign-in
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
</QueryClientProvider>
)
}
it('lets a pending login switch from Passkey to 2FA without using authenticated verification endpoints', async () => {
const get = vi
.spyOn(api, 'get')
.mockResolvedValue({ data: { success: true, data: {} } })
vi.stubGlobal('PublicKeyCredential', class {})
const get = vi.spyOn(api, 'get')
const bundle: AuthBundle = {
access_token: 'verified-login',
token_type: 'Bearer',
@@ -152,7 +162,7 @@ it('lets a pending login switch from Passkey to 2FA without using authenticated
signal: expect.any(AbortSignal),
})
)
expect(get).not.toHaveBeenCalled()
expect(get.mock.calls.every(([url]) => url === '/api/status')).toBe(true)
})
it.each(['success', 'cancel', 'retry'] as const)(
@@ -18,11 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { api, isAuthBundle } from '@/lib/api'
import { buildOAuthAuthorizationUrl } from '@/lib/oauth'
import {
buildAssertionResult,
isPasskeySupported,
prepareCredentialRequestOptions,
} from '@/lib/passkey'
import { isPasskeySupported } from '@/lib/passkey'
import {
AuthOperationError,
authRequestOptions,
@@ -37,6 +33,12 @@ import {
beginPasskeyVerification,
finishPasskeyVerification,
} from '../passkey/api'
import {
requestPasskeyAssertion,
rememberPasskeyRPID,
type PasskeyDomains,
} from '../passkey/assertion'
import type { PasskeyOptionsPayload } from '../passkey/types'
import type { SystemStatus } from '../types'
import type {
SecurityProof,
@@ -134,7 +136,8 @@ export async function getLoginVerificationRequirements(
export async function verifyLogin(
input: VerificationInput,
challenge: LoginChallenge,
signal: AbortSignal
signal: AbortSignal,
onDomains?: (domains: PasskeyDomains) => void
): Promise<AuthBundle> {
if (challenge.expires_at * 1000 <= Date.now()) {
throw new AuthOperationError(
@@ -157,28 +160,35 @@ export async function verifyLogin(
)
)
} else if (input.method === 'passkey') {
const begin = await authResult<{ flow_token: string; options: unknown }>(
api.post(
'/api/user/login/passkey/begin',
{ flow_token: challenge.flow_token },
options
)
const passkey = await requestPasskeyAssertion(
(rpID) =>
authResult<PasskeyOptionsPayload>(
api.post(
'/api/user/login/passkey/begin',
{
flow_token: challenge.flow_token,
...(rpID ? { rp_id: rpID } : {}),
},
options
)
),
signal,
{ rpID: input.rpID, onDomains }
)
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const assertion = await requestPasskeyAssertion(begin.options, signal)
result = await authResult(
api.post(
'/api/user/login/passkey/finish',
{
flow_token: challenge.flow_token,
passkey_flow_token: begin.flow_token,
credential: assertion,
passkey_flow_token: passkey.flowToken,
credential: passkey.assertion,
},
options
)
)
signal.throwIfAborted()
if (!isAuthBundle(result)) throw new AuthOperationError('Login failed')
rememberPasskeyRPID(passkey.rpID)
} else {
throw new AuthOperationError(
'This verification method is not allowed for this action.'
@@ -193,7 +203,8 @@ export async function verify(
input: VerificationInput,
operation: VerificationOperation,
passwordEncryptionEnabled: boolean,
signal: AbortSignal
signal: AbortSignal,
onDomains?: (domains: PasskeyDomains) => void
): Promise<SecurityProof> {
try {
const operationFields = {
@@ -242,7 +253,7 @@ export async function verify(
break
}
case 'passkey':
proof = await verifyPasskey(operation, signal)
proof = await verifyPasskey(operation, signal, input.rpID, onDomains)
break
case 'oauth':
proof = await verifyOAuth(input.provider, operation, signal)
@@ -265,50 +276,30 @@ export async function verify(
async function verifyPasskey(
operation: VerificationOperation,
signal: AbortSignal
signal: AbortSignal,
rpID?: string,
onDomains?: (domains: PasskeyDomains) => void
): Promise<SecurityProof> {
const begin = await beginPasskeyVerification(operation, signal)
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const assertion = await requestPasskeyAssertion(
begin.options ?? begin,
const passkey = await requestPasskeyAssertion(
(selected) => beginPasskeyVerification(operation, signal, selected),
signal,
{ rpID, onDomains }
)
const proof = await finishPasskeyVerification(
passkey.flowToken,
passkey.assertion,
signal
)
return finishPasskeyVerification(begin.flow_token, assertion, signal)
}
async function requestPasskeyAssertion(
options: unknown,
signal: AbortSignal
): Promise<Record<string, unknown>> {
const publicKey = prepareCredentialRequestOptions(options)
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey,
signal,
})) as PublicKeyCredential | null
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new AuthOperationError(
'Passkey verification was cancelled or timed out'
)
}
throw error
}
signal.throwIfAborted()
if (!credential) {
throw new AuthOperationError(
'Passkey verification was cancelled',
'AUTH_CANCELLED'
)
if (
proof.proof_token &&
proof.method === 'passkey' &&
proof.scope === operation.scope &&
proof.expires_at * 1000 > Date.now()
) {
rememberPasskeyRPID(passkey.rpID)
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new AuthOperationError('Unable to build Passkey assertion')
}
return assertion
return proof
}
async function verifyOAuth(
@@ -26,6 +26,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { PasskeyDomains } from '../../passkey/assertion'
import { PasskeyDomainSelector } from '../../passkey/components/passkey-domain-selector'
import type {
SecureVerificationState,
VerificationInput,
@@ -34,6 +36,7 @@ import type {
interface SecureVerificationDialogProps {
state: SecureVerificationState
passkeyDomains?: PasskeyDomains | null
onVerify: () => void | Promise<void>
onCancel: () => void
onRetry: () => void
@@ -223,7 +226,15 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
: t('Enter the 6-digit authenticator code.')}
</p>
</TabsContent>
<TabsContent value='passkey'>
<TabsContent value='passkey' className='space-y-3'>
<PasskeyDomainSelector
domains={props.passkeyDomains}
value={input.method === 'passkey' ? input.rpID : undefined}
onChange={(rpID) =>
props.onInputChange({ method: 'passkey', rpID })
}
disabled={verifying}
/>
<p className='text-muted-foreground flex items-center gap-2 text-sm'>
<KeyRound className='size-5' />
{t(
@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useReducer, useRef } from 'react'
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { AuthOperationError } from '@/lib/secure-verification'
import type { AuthBundle } from '@/stores/auth-store'
import type { PasskeyDomains } from '../../passkey/assertion'
import {
checkVerificationMethods,
getLoginVerificationRequirements,
@@ -140,6 +141,9 @@ type PendingVerification = PendingVerificationBase &
export function useSecureVerification() {
const [state, dispatch] = useReducer(verificationReducer, { phase: 'idle' })
const pending = useRef<PendingVerification | null>(null)
const [passkeyDomains, setPasskeyDomains] = useState<PasskeyDomains | null>(
null
)
const cancel = useCallback(() => {
const current = pending.current
@@ -147,6 +151,7 @@ export function useSecureVerification() {
current?.controller.abort()
if (current) current.initialPassword = undefined
current?.resolve(null)
setPasskeyDomains(null)
dispatch({ type: 'reset' })
}, [])
@@ -242,6 +247,7 @@ export function useSecureVerification() {
submitting: false,
}
pending.current = current
setPasskeyDomains(null)
void loadRequirements(current)
})
},
@@ -264,6 +270,7 @@ export function useSecureVerification() {
submitting: false,
}
pending.current = current
setPasskeyDomains(null)
void loadRequirements(current)
})
},
@@ -288,7 +295,10 @@ export function useSecureVerification() {
const bundle = await verifyLogin(
input,
current.request.challenge,
current.controller.signal
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(bundle)
@@ -297,7 +307,10 @@ export function useSecureVerification() {
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(proof)
@@ -334,6 +347,7 @@ export function useSecureVerification() {
isActive: state.phase !== 'idle',
dialogProps: {
state,
passkeyDomains,
onCancel: cancel,
onRetry: retry,
onInputChange: setInput,
@@ -71,7 +71,7 @@ export interface VerificationRequirements {
export type VerificationInput =
| { method: '2fa'; code: string }
| { method: 'password'; password: string }
| { method: 'passkey' }
| { method: 'passkey'; rpID?: string }
| { method: 'oauth'; provider: string }
| { method: 'session' }
@@ -18,9 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import axios from 'axios'
import { Loader2, LogIn, KeyRound } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -47,14 +46,18 @@ import { loginFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import {
requestPasskeyAssertion,
rememberPasskeyRPID,
type PasskeyDomains,
} from '@/features/auth/passkey/assertion'
import { PasskeyDomainSelector } from '@/features/auth/passkey/components/passkey-domain-selector'
import type { AuthFormProps } from '@/features/auth/types'
import { useStatus } from '@/hooks/use-status'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { handleServerError } from '@/lib/handle-server-error'
import { isPasskeySupported as detectPasskeySupport } from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
export function UserAuthForm({
@@ -68,6 +71,12 @@ export function UserAuthForm({
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [passkeyDomains, setPasskeyDomains] = useState<PasskeyDomains | null>(
null
)
const [passkeyRPID, setPasskeyRPID] = useState<string>()
const passkeyOperation = useRef<AbortController | null>(null)
useEffect(() => () => passkeyOperation.current?.abort(), [])
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0)
@@ -178,10 +187,11 @@ export function UserAuthForm({
if (await handleLoginResult(res.data, redirectTo)) {
toast.success(t('Welcome back!'))
}
} else {
handleServerError(createServerError(res, loginFailedMessage))
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) return
toast.error(error instanceof Error ? error.message : loginFailedMessage)
handleServerError(AuthOperationError.from(error, loginFailedMessage))
} finally {
setIsLoading(false)
}
@@ -219,12 +229,12 @@ export function UserAuthForm({
toast.success(t('Signed in via WeChat'))
}
} else {
if (getServerErrorMessageKey(res)) return
toast.error(res?.message || loginFailedMessage)
handleServerError(createServerError(res, loginFailedMessage))
}
} catch (error: unknown) {
if (getServerErrorMessageKey(error)) return
toast.error(loginFailedMessage)
handleServerError(
new AuthOperationError(loginFailedMessage, undefined, { cause: error })
)
} finally {
setIsWeChatSubmitting(false)
}
@@ -246,55 +256,45 @@ export function UserAuthForm({
return
}
if (passkeyOperation.current) return
const controller = new AbortController()
passkeyOperation.current = controller
setIsPasskeyLoading(true)
try {
const begin = await beginPasskeyLogin()
if (!begin.success) {
if (getServerErrorMessageKey(begin)) return
throw new Error(begin.message || t('Failed to start Passkey login'))
}
const publicKey = prepareCredentialRequestOptions(
begin.data?.options ?? begin.data
const passkey = await requestPasskeyAssertion(
(rpID) => beginPasskeyLogin(rpID, controller.signal),
controller.signal,
{ rpID: passkeyRPID, onDomains: setPasskeyDomains }
)
const flowToken = begin.data?.flow_token
if (!flowToken) {
throw new Error(t('Login flow expired. Please sign in again.'))
}
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
toast.info(t('Passkey login was cancelled'))
return
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(t('Invalid Passkey response'))
}
const finish = await finishPasskeyLogin(flowToken, assertion)
const finish = await finishPasskeyLogin(
passkey.flowToken,
passkey.assertion,
controller.signal
)
controller.signal.throwIfAborted()
if (!finish.success) {
if (getServerErrorMessageKey(finish)) return
throw new Error(finish.message || t('Failed to complete Passkey login'))
throw createServerError(finish, t('Failed to complete Passkey login'))
}
rememberPasskeyRPID(passkey.rpID)
if (await handleLoginResult(finish.data, redirectTo)) {
toast.success(t('Signed in with Passkey'))
}
} catch (error: unknown) {
if (getServerErrorMessageKey(error)) return
if (controller.signal.aborted) return
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(t('Passkey login was cancelled or timed out'))
} else if (error instanceof Error) {
toast.error(error.message)
handleServerError(AuthOperationError.from(error))
} else {
toast.error(t('Passkey login failed'))
handleServerError(
AuthOperationError.from(error, t('Passkey login failed'))
)
}
} finally {
if (passkeyOperation.current === controller) {
passkeyOperation.current = null
}
setIsPasskeyLoading(false)
}
}
@@ -317,6 +317,12 @@ export function UserAuthForm({
)}
{t('Sign in with Passkey')}
</Button>
<PasskeyDomainSelector
domains={passkeyDomains}
value={passkeyRPID}
onChange={setPasskeyRPID}
disabled={passkeyButtonDisabled}
/>
{!passkeySupported && (
<p className='text-muted-foreground text-xs'>
{t('Passkey is not supported on this device.')}
@@ -50,7 +50,9 @@ import {
saveAffiliateCode,
} from '@/features/auth/lib/storage'
import { useStatus } from '@/hooks/use-status'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
export function SignUpForm({
@@ -172,10 +174,12 @@ export function SignUpForm({
toast.success(t('Account created! Please sign in'))
redirectToLogin()
} else {
toast.error(res?.message || t('Failed to create account'))
handleServerError(createServerError(res, t('Failed to create account')))
}
} catch {
// Errors are handled by global interceptor
} catch (error) {
handleServerError(
AuthOperationError.from(error, t('Failed to create account'))
)
} finally {
setIsLoading(false)
}
@@ -220,12 +224,12 @@ export function SignUpForm({
toast.success(t('Signed in via WeChat'))
}
} else {
if (getServerErrorMessageKey(res)) return
toast.error(res?.message || t('Login failed'))
handleServerError(createServerError(res, t('Login failed')))
}
} catch (error: unknown) {
if (getServerErrorMessageKey(error)) return
toast.error(t('Login failed'))
handleServerError(
new AuthOperationError(t('Login failed'), undefined, { cause: error })
)
} finally {
setIsWeChatSubmitting(false)
}
+34 -3
View File
@@ -18,7 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { getGroups as getUserGroups } from '@/features/users/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import { requireServerSuccess } from '@/lib/server-error-message'
import type { InferenceStatus } from './lib/inference-status'
import type {
AddChannelRequest,
BatchDeleteParams,
@@ -48,15 +50,29 @@ const channelActionConfig = (
skipErrorHandler: true,
})
export async function getInferenceStatus(
channelId: number,
provider: 'vllm' | 'sglang',
signal?: AbortSignal
): Promise<InferenceStatus> {
const response = await api.get<{ success: boolean; data: InferenceStatus }>(
`/api/channel/${channelId}/${provider}/status`,
{ signal, disableDuplicate: true }
)
return requireServerSuccess(response.data).data
}
export type TaskPluginOption = {
sortPriority?: number
website?: string
key: string
name: string
description?: Record<string, string> | null
icon?: string
hasIcon?: boolean
baseUrl?: string
models: string[]
channelTypes?: number[] | null
}
export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
@@ -64,7 +80,7 @@ export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
success: boolean
data: TaskPluginOption[]
}>('/api/task_plugin_options')
return response.data.data
return requireServerSuccess(response.data).data
}
export type CodexUsageResponse = {
@@ -127,11 +143,26 @@ export async function getChannel(id: number): Promise<GetChannelResponse> {
/**
* Get channel operations summary for administrators
*/
export async function getChannelOps(): Promise<ChannelOpsResponse> {
const res = await api.get('/api/channel/ops', channelActionConfig())
export async function getChannelOps(
autoBan?: boolean
): Promise<ChannelOpsResponse> {
const res = await api.get('/api/channel/ops', {
...channelActionConfig(),
params: autoBan === undefined ? undefined : { auto_ban: autoBan },
})
return res.data
}
export async function getChannelDefaultBaseURLs(): Promise<
Partial<Record<number, string>>
> {
const response = await api.get<{
success: boolean
data: Partial<Record<number, string>>
}>('/api/channel/default_base_urls')
return requireServerSuccess(response.data).data
}
/**
* Create new channel(s)
* Supports single, batch, and multi-key modes
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,311 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterContextProvider,
} from '@tanstack/react-router'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { Form } from '@/components/ui/form'
import { api } from '@/lib/api'
import {
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_SUB2API,
CHANNEL_TYPE_TASK_PLUGIN,
} from '../../constants'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from '../../lib/advanced-custom'
import {
CHANNEL_FORM_DEFAULT_VALUES,
type ChannelFormValues,
} from '../../lib/channel-form'
import { ChannelQuickOptions } from '../channel-quick-options'
import type { PassthroughKind } from '../dialogs/passthrough-warning-dialog'
type HarnessProps = {
channelType?: number
layout?: 'stacked' | 'inline'
values?: Partial<ChannelFormValues>
confirm: (kind: PassthroughKind) => Promise<boolean>
onSave?: (values: ChannelFormValues) => void
}
function QuickOptionsHarness(props: HarnessProps) {
const channelType = props.channelType ?? 1
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } })
)
const [router] = useState(() =>
createRouter({
routeTree: createRootRoute(),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
)
const form = useForm<ChannelFormValues>({
defaultValues: {
...CHANNEL_FORM_DEFAULT_VALUES,
type: channelType,
...props.values,
},
})
return (
<QueryClientProvider client={client}>
<RouterContextProvider router={router}>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => props.onSave?.(values))}
>
<ChannelQuickOptions
channelType={channelType}
sensitiveLocked={false}
disabled={false}
layout={props.layout}
confirmEnablePassthrough={props.confirm}
/>
<button type='submit'>Save</button>
</form>
</Form>
</RouterContextProvider>
</QueryClientProvider>
)
}
const HEADERS_SWITCH = { name: 'Pass Through Request Headers' }
const BODY_SWITCH = { name: 'Pass Through Body' }
beforeEach(() => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: {} },
})
})
describe('header passthrough quick option', () => {
test('is checked when the header override contains the wildcard rule', () => {
render(
<QuickOptionsHarness
values={{ header_override: '{"*": true, "X-Foo": "bar"}' }}
confirm={() => Promise.resolve(true)}
/>
)
expect(screen.getByRole('switch', HEADERS_SWITCH)).toBeChecked()
})
test('is unchecked when the header override has no wildcard rule', () => {
render(
<QuickOptionsHarness
values={{ header_override: '{"X-Foo": "bar"}' }}
confirm={() => Promise.resolve(true)}
/>
)
expect(screen.getByRole('switch', HEADERS_SWITCH)).not.toBeChecked()
})
test('enabling asks for confirmation and merges the wildcard rule into existing overrides on approval', async () => {
const user = userEvent.setup()
const confirm = vi.fn(() => Promise.resolve(true))
const saved: ChannelFormValues[] = []
render(
<QuickOptionsHarness
values={{ header_override: '{"X-Foo": "bar"}' }}
confirm={confirm}
onSave={(values) => saved.push(values)}
/>
)
const toggle = screen.getByRole('switch', HEADERS_SWITCH)
await user.click(toggle)
expect(confirm).toHaveBeenCalledWith('headers')
await waitFor(() => expect(toggle).toBeChecked())
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saved).toHaveLength(1))
expect(JSON.parse(saved[0].header_override ?? '')).toEqual({
'*': true,
'X-Foo': 'bar',
})
})
test('declining the confirmation leaves the header override untouched', async () => {
const user = userEvent.setup()
const saved: ChannelFormValues[] = []
render(
<QuickOptionsHarness
values={{ header_override: '' }}
confirm={() => Promise.resolve(false)}
onSave={(values) => saved.push(values)}
/>
)
const toggle = screen.getByRole('switch', HEADERS_SWITCH)
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saved).toHaveLength(1))
expect(toggle).not.toBeChecked()
expect(saved[0].header_override).toBe('')
})
test('disabling removes the wildcard rule immediately without asking', async () => {
const user = userEvent.setup()
const confirm = vi.fn(() => Promise.resolve(true))
const saved: ChannelFormValues[] = []
render(
<QuickOptionsHarness
values={{ header_override: '{"*": true}' }}
confirm={confirm}
onSave={(values) => saved.push(values)}
/>
)
const toggle = screen.getByRole('switch', HEADERS_SWITCH)
expect(toggle).toBeChecked()
await user.click(toggle)
expect(confirm).not.toHaveBeenCalled()
expect(toggle).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saved).toHaveLength(1))
expect(saved[0].header_override).toBe('')
})
test('invalid header override JSON disables the switch and explains why', async () => {
const user = userEvent.setup()
const confirm = vi.fn(() => Promise.resolve(true))
render(
<QuickOptionsHarness
values={{ header_override: '{"*": tru' }}
confirm={confirm}
/>
)
const toggle = screen.getByRole('switch', HEADERS_SWITCH)
expect(toggle).toHaveAttribute('aria-disabled', 'true')
expect(toggle).not.toBeChecked()
expect(
screen.getByText('Fix the Request Header Override JSON first')
).toBeVisible()
await user.click(toggle)
expect(confirm).not.toHaveBeenCalled()
})
test('inline layout surfaces the warning as the toggle tooltip', () => {
render(
<QuickOptionsHarness
layout='inline'
confirm={() => Promise.resolve(true)}
/>
)
const toggle = screen.getByRole('switch', HEADERS_SWITCH)
expect(toggle.closest('[title]')).toHaveAttribute(
'title',
'Enable when the upstream needs client information from Codex or Claude Code headers; may expose client information'
)
})
})
describe('body passthrough quick option', () => {
test('enabling asks for confirmation and disabling does not', async () => {
const user = userEvent.setup()
const confirm = vi.fn(() => Promise.resolve(true))
render(<QuickOptionsHarness confirm={confirm} />)
const toggle = screen.getByRole('switch', BODY_SWITCH)
expect(toggle).not.toBeChecked()
await user.click(toggle)
expect(confirm).toHaveBeenCalledWith('body')
await waitFor(() => expect(toggle).toBeChecked())
await user.click(toggle)
expect(toggle).not.toBeChecked()
expect(confirm).toHaveBeenCalledTimes(1)
})
test('declining the confirmation keeps body passthrough off', async () => {
const user = userEvent.setup()
const saved: ChannelFormValues[] = []
render(
<QuickOptionsHarness
confirm={() => Promise.resolve(false)}
onSave={(values) => saved.push(values)}
/>
)
const toggle = screen.getByRole('switch', BODY_SWITCH)
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saved).toHaveLength(1))
expect(toggle).not.toBeChecked()
expect(saved[0].pass_through_body_enabled).toBe(false)
})
test('shows the consequence warning as the switch description', () => {
render(<QuickOptionsHarness confirm={() => Promise.resolve(true)} />)
expect(
screen.getByText(
'Preserve upstream-specific fields when API formats match; bypasses model redirect, parameter override and format conversion'
)
).toBeVisible()
})
})
test('task plugin channels hide both passthrough switches', () => {
render(
<QuickOptionsHarness
channelType={CHANNEL_TYPE_TASK_PLUGIN}
confirm={() => Promise.resolve(true)}
/>
)
expect(screen.queryByRole('switch', BODY_SWITCH)).not.toBeInTheDocument()
expect(screen.queryByRole('switch', HEADERS_SWITCH)).not.toBeInTheDocument()
expect(
screen.getByRole('switch', { name: 'Auto-disable channel' })
).toBeInTheDocument()
})
describe('responses websocket quick option', () => {
const WEBSOCKET_SWITCH = { name: 'Responses WebSocket' }
test.each([
1,
57,
CHANNEL_TYPE_ADVANCED_CUSTOM,
CHANNEL_TYPE_SUB2API,
CHANNEL_TYPE_NEW_API,
])('is offered for channel type %s', (channelType) => {
render(
<QuickOptionsHarness
channelType={channelType}
confirm={() => Promise.resolve(true)}
/>
)
expect(screen.getByRole('switch', WEBSOCKET_SWITCH)).toBeInTheDocument()
})
test.each([14, CHANNEL_TYPE_TASK_PLUGIN])(
'is hidden for channel type %s',
(channelType) => {
render(
<QuickOptionsHarness
channelType={channelType}
confirm={() => Promise.resolve(true)}
/>
)
expect(
screen.queryByRole('switch', WEBSOCKET_SWITCH)
).not.toBeInTheDocument()
}
)
})
@@ -0,0 +1,516 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createRouter,
createRootRoute,
createMemoryHistory,
RouterContextProvider,
} from '@tanstack/react-router'
import {
act,
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { CHANNEL_TYPE_VLLM, CHANNEL_TYPE_SGLANG } from '../../constants'
import type { InferenceStatus } from '../../lib/inference-status'
import { channelSchema } from '../../types'
import { ChannelRowActionsLayoutContext } from '../channel-row-actions-context'
import { BalanceCell } from '../channels-columns'
import { ChannelsDialogs } from '../channels-dialogs'
import { ChannelsProvider } from '../channels-provider'
import { InferenceStatusDialog } from '../dialogs/inference-status-dialog'
import { ChannelMutateDrawer } from '../drawers/channel-mutate-drawer'
const originalAuth = useAuthStore.getState().auth
let client: QueryClient
function fixture(): InferenceStatus {
return {
sampled_at: 1000,
endpoints: {
'/health': { status: 200 },
'/version': { status: 200 },
'/v1/models': { status: 200 },
'/metrics': { status: 200 },
},
version: '0.25.2-test',
models: [
{ id: 'served-model', root: 'source/model', max_model_len: 1048576 },
],
metrics: [
{ name: 'vllm:num_requests_running', labels: {}, value: 0 },
{ name: 'vllm:num_requests_waiting', labels: {}, value: 2 },
],
raw_metrics: 'vllm:num_requests_running 0\n',
}
}
function panel(
channelId = 42,
callbacks = {
onClose: vi.fn(),
onSyncModels: vi.fn(),
onTestChannel: vi.fn(),
}
) {
return (
<QueryClientProvider client={client}>
<InferenceStatusDialog
provider='vllm'
key={channelId}
channelId={channelId}
channelName='Test vLLM'
{...callbacks}
/>
</QueryClientProvider>
)
}
beforeEach(() => {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
useAuthStore.setState({
auth: {
...originalAuth,
user: { id: 1, username: 'root', role: ROLE.SUPER_ADMIN },
},
})
})
afterEach(() => {
cleanup()
client.clear()
vi.useRealTimers()
vi.restoreAllMocks()
useAuthStore.setState({ auth: originalAuth })
})
it.each([
{
layout: 'table' as const,
action: 'click',
provider: 'vllm',
name: 'vLLM',
type: CHANNEL_TYPE_VLLM,
},
{
layout: 'table' as const,
action: 'click',
provider: 'sglang',
name: 'SGLang',
type: CHANNEL_TYPE_SGLANG,
},
{
layout: 'card' as const,
action: 'Enter',
provider: 'vllm',
name: 'vLLM',
type: CHANNEL_TYPE_VLLM,
},
{
layout: 'card' as const,
action: ' ',
provider: 'sglang',
name: 'SGLang',
type: CHANNEL_TYPE_SGLANG,
},
])(
'opens $name status from the balance cell in $layout view using $action',
async ({ layout, action, provider, name, type }) => {
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === `/api/channel/42/${provider}/status`) {
return { data: { success: true, data: fixture() } }
}
return { data: { success: true, data: [] } }
})
const channel = channelSchema.parse({
id: 42,
type,
key: '',
name: `Test ${name}`,
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
})
const user = userEvent.setup()
render(
<QueryClientProvider client={client}>
<ChannelsProvider>
<ChannelRowActionsLayoutContext.Provider value={layout}>
<BalanceCell channel={channel} />
</ChannelRowActionsLayoutContext.Provider>
<ChannelsDialogs />
</ChannelsProvider>
</QueryClientProvider>
)
const entry = screen.getByRole('button', { name: `${name} status` })
expect(entry).toHaveAttribute('aria-haspopup', 'dialog')
if (action === 'click') {
await user.click(entry)
} else {
entry.focus()
await user.keyboard(action === 'Enter' ? '{Enter}' : ' ')
}
expect(
await screen.findByRole('dialog', { name: `${name} status` })
).toBeInTheDocument()
expect(await screen.findByText('served-model')).toBeInTheDocument()
expect(get.mock.calls.some(([url]) => url.includes('update_balance'))).toBe(
false
)
}
)
it.each([
{ language: 'zhCN', locale: 'zh-CN' },
{ language: 'zhTW', locale: 'zh-TW' },
])(
'formats status numbers and timestamps for $language',
async ({ language, locale }) => {
const i18n = createInstance()
await i18n.init({
lng: language,
resources: {
[language]: { translation: { 'vLLM status': 'vLLM status' } },
},
})
const data = fixture()
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data },
})
render(<I18nextProvider i18n={i18n}>{panel()}</I18nextProvider>)
expect(
await screen.findByText('Max context tokens: 1,048,576')
).toBeInTheDocument()
expect(
screen.getByText(
`Last updated: ${new Date(data.sampled_at).toLocaleString(locale)}`,
{ collapseWhitespace: false }
)
).toBeInTheDocument()
}
)
it('shows zero load, unavailable optional metrics, model details and existing channel actions', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: fixture() },
})
const callbacks = {
onClose: vi.fn(),
onSyncModels: vi.fn(),
onTestChannel: vi.fn(),
}
const user = userEvent.setup()
render(panel(42, callbacks))
expect(screen.getByRole('button', { name: 'Export snapshot' })).toBeDisabled()
expect(await screen.findByText('served-model')).toBeInTheDocument()
expect(screen.getByText('Max context tokens: 1,048,576')).toBeInTheDocument()
const load = within(screen.getByRole('region', { name: 'Current load' }))
expect(load.getByText('0')).toBeInTheDocument()
expect(load.getByText('2')).toBeInTheDocument()
expect(load.getAllByText('Unavailable')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Export snapshot' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Sync models' }))
expect(callbacks.onSyncModels).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'Test Connection' }))
expect(callbacks.onTestChannel).toHaveBeenCalledOnce()
await user.keyboard('{Escape}')
expect(callbacks.onClose).toHaveBeenCalledOnce()
})
it('keeps healthy model information when metrics are unavailable', async () => {
const data = fixture()
data.endpoints['/metrics'] = { status: 404, error: 'http_error' }
data.metrics = []
vi.spyOn(api, 'get').mockResolvedValue({ data: { success: true, data } })
render(panel())
expect(await screen.findByText('served-model')).toBeInTheDocument()
expect(screen.getByText('Unavailable · HTTP 404')).toBeInTheDocument()
expect(
within(screen.getByRole('region', { name: 'Current load' })).getAllByText(
'Unavailable'
)
).toHaveLength(4)
})
it('shows errors, retries on demand and labels preserved data after a failed refresh', async () => {
const get = vi
.spyOn(api, 'get')
.mockRejectedValueOnce(new Error('Connection unavailable'))
const user = userEvent.setup()
render(panel())
expect(
await screen.findByText('Failed to load vLLM status')
).toBeInTheDocument()
expect(screen.getByText('Connection unavailable')).toBeInTheDocument()
get.mockResolvedValueOnce({ data: { success: true, data: fixture() } })
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('served-model')).toBeInTheDocument()
get.mockRejectedValueOnce(new Error('Connection unavailable'))
await user.click(screen.getByRole('button', { name: 'Refresh' }))
expect(
await screen.findByText(
'Refresh failed. Showing the last successful snapshot.'
)
).toBeInTheDocument()
expect(screen.getByText('served-model')).toBeInTheDocument()
})
it('polls while enabled and stops after disabling auto refresh or closing the panel', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
let sampledAt = 1000
const get = vi.spyOn(api, 'get').mockImplementation(async () => {
const data = fixture()
data.sampled_at = sampledAt
sampledAt += 5000
return { data: { success: true, data } }
})
const user = userEvent.setup()
const view = render(panel())
await screen.findByText('served-model')
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
await screen.findByText('Sample interval: 5 seconds')
await user.click(screen.getByRole('switch', { name: 'Auto refresh (5s)' }))
const count = get.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(10000)
})
expect(get).toHaveBeenCalledTimes(count)
await user.click(screen.getByRole('switch', { name: 'Auto refresh (5s)' }))
view.unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(10000)
})
expect(get).toHaveBeenCalledTimes(count)
})
it('cancels requests on channel changes and ignores late results from the previous channel', async () => {
let finish!: (value: {
data: { success: boolean; data: InferenceStatus }
}) => void
let firstSignal: AbortSignal | undefined
const pending = new Promise<{
data: { success: boolean; data: InferenceStatus }
}>((resolve) => {
finish = resolve
})
vi.spyOn(api, 'get').mockImplementation((url, config) => {
if (url === '/api/channel/42/vllm/status') {
firstSignal = config?.signal as AbortSignal
return pending
}
const data = fixture()
data.version = 'channel-43-version'
return Promise.resolve({ data: { success: true, data } })
})
const view = render(panel())
await waitFor(() => expect(firstSignal).toBeDefined())
view.rerender(panel(43))
expect(firstSignal?.aborted).toBe(true)
await screen.findByText('channel-43-version')
await act(async () => {
finish({ data: { success: true, data: fixture() } })
await pending
})
expect(screen.queryByText('0.25.2-test')).not.toBeInTheDocument()
})
it('disables channel mutations and tests for a read-only operator', async () => {
useAuthStore.setState({
auth: {
...originalAuth,
user: {
id: 2,
username: 'reader',
role: ROLE.ADMIN,
permissions: { admin_permissions: { channel: { read: true } } },
},
},
})
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: fixture() },
})
render(panel())
await screen.findByText('served-model')
expect(screen.getByRole('button', { name: 'Sync models' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()
})
it('keeps SGLang worker values separate and wraps long labels below them', async () => {
const data = fixture()
const workerLabels = {
engine_type: 'unified',
model_name: 'deepseek-v4.1-flash',
moe_ep_rank: '0',
pp_rank: '0',
tp_rank: '0',
}
data.version = '0.5.19'
data.endpoints = {
'/health': { status: 200 },
'/server_info': { status: 200 },
'/v1/models': { status: 200 },
'/metrics': { status: 200 },
}
data.metrics = [
{ name: 'sglang:num_running_reqs', labels: {}, value: 3 },
{ name: 'sglang:num_queue_reqs', labels: {}, value: 0 },
{ name: 'sglang:token_usage', labels: { dp_rank: '0' }, value: 0.25 },
{ name: 'sglang:token_usage', labels: { dp_rank: '1' }, value: 0.5 },
{ name: 'sglang:cache_hit_rate', labels: workerLabels, value: 0.2 },
{ name: 'sglang:cache_hit_rate', labels: { dp_rank: '1' }, value: 0.8 },
{ name: 'sglang:spec_accept_rate', labels: workerLabels, value: 0.99 },
{ name: 'sglang:inter_token_latency_seconds_sum', labels: {}, value: 4 },
{
name: 'sglang:inter_token_latency_seconds_count',
labels: {},
value: 2,
},
]
const get = vi
.spyOn(api, 'get')
.mockResolvedValue({ data: { success: true, data } })
render(
<QueryClientProvider client={client}>
<InferenceStatusDialog
provider='sglang'
channelId={63}
channelName='SGLang'
onClose={vi.fn()}
onSyncModels={vi.fn()}
onTestChannel={vi.fn()}
/>
</QueryClientProvider>
)
expect(await screen.findByText('0.5.19')).toBeInTheDocument()
const load = within(screen.getByRole('region', { name: 'Current load' }))
expect(load.getByText('3')).toBeInTheDocument()
expect(load.getByText('0')).toBeInTheDocument()
expect(load.getByText('50%')).toBeInTheDocument()
expect(
within(screen.getByRole('region', { name: 'Cumulative usage' })).getByText(
'2 sec'
)
).toBeInTheDocument()
const workers = within(screen.getByRole('region', { name: 'Worker metrics' }))
expect(workers.getByText('20%')).toBeInTheDocument()
expect(workers.getByText('80%')).toBeInTheDocument()
expect(workers.getByText('99%')).toBeInTheDocument()
const workerCard = workers.getByText('20%').parentElement
assert(workerCard)
expect(within(workerCard).getByRole('term')).toHaveTextContent(
/^Prefix cache hit rate$/
)
expect(workerCard).toHaveClass('min-w-0')
const labels = within(workerCard).getByText(JSON.stringify(workerLabels))
expect(labels).toHaveClass('col-span-2', 'break-all')
expect(screen.queryByText('Awake engines')).not.toBeInTheDocument()
expect(get).toHaveBeenCalledWith(
'/api/channel/63/sglang/status',
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
get.mockResolvedValue({
data: {
success: true,
data: {
...data,
endpoints: {
...data.endpoints,
'/metrics': { status: 404, error: 'http_error' },
},
metrics: [],
},
},
})
await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' }))
expect(
await screen.findByText(
'Start SGLang with --enable-metrics to expose Prometheus metrics.'
)
).toBeInTheDocument()
expect(screen.getByText('served-model')).toBeInTheDocument()
})
it.each([
{ type: CHANNEL_TYPE_VLLM, editable: false },
{ type: CHANNEL_TYPE_SGLANG, editable: false },
{ type: 58, editable: true },
])(
'limits the route editor to advanced custom channels: type $type',
async ({ type, editable }) => {
const channel = channelSchema.parse({
id: 42,
type,
key: '',
name: 'Inference',
base_url: 'https://inference.example',
models: 'served-model',
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
settings:
'{"advanced_custom":{"advanced_routes":[{"incoming_path":"/v1/chat/completions","upstream_path":"/v1/chat/completions","converter":"none"}]}}',
})
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: { success: true, data: url === '/api/channel/42' ? channel : [] },
}))
const router = createRouter({
routeTree: createRootRoute(),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
render(
<QueryClientProvider client={client}>
<RouterContextProvider router={router}>
<ChannelsProvider>
<ChannelMutateDrawer
open
onOpenChange={vi.fn()}
currentRow={channel}
/>
</ChannelsProvider>
</RouterContextProvider>
</QueryClientProvider>
)
expect(await screen.findByDisplayValue('Inference')).toBeInTheDocument()
expect(
Boolean(screen.queryByRole('button', { name: 'Configure routes' }))
).toBe(editable)
}
)
@@ -0,0 +1,142 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { ModelMappingBatchDialog } from '../model-mapping-batch-dialog'
test('upstream names are the default source: stripping a suffix derives request names, skips unchanged models, and syncs the model list', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['gpt-4o-all', 'o3']}
channelModels={['gpt-4o']}
onApply={onApply}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
expect(
dialog.getByRole('tab', { name: /Upstream model list/ })
).toHaveAttribute('aria-selected', 'true')
expect(
dialog.getByRole('radio', {
name: 'Upstream names returned by the provider',
})
).toBeChecked()
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
expect(
dialog.getByRole('button', { name: 'Add 0 mapping(s)' })
).toBeDisabled()
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.click(dialog.getByRole('checkbox', { name: 'o3' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
'gpt-4ogpt-4o-all'
)
expect(
dialog.getByText('1 model(s) unchanged by the rule were skipped')
).toBeVisible()
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'gpt-4o', to: 'gpt-4o-all' }],
syncModels: true,
})
})
test('the channel-model source flips to request names, offers add rules, and never syncs the model list', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['x-all']}
channelModels={['gemini-2.5-flash']}
onApply={onApply}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
await user.click(dialog.getByRole('tab', { name: /Channel models/ }))
expect(
dialog.getByRole('radio', { name: 'Request names your users call' })
).toBeChecked()
expect(dialog.getByRole('radio', { name: 'Add suffix' })).toBeChecked()
expect(
dialog.queryByRole('checkbox', { name: /Publish the request names/ })
).not.toBeInTheDocument()
await user.click(dialog.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }],
syncModels: false,
})
})
test('without an upstream list the dialog offers only channel models, and conflicting request names are reported', async () => {
const user = userEvent.setup()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
channelModels={['claude-all', 'claude-latest']}
onApply={vi.fn()}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
expect(dialog.queryByRole('tab')).not.toBeInTheDocument()
expect(
dialog.getByRole('radio', { name: 'Request names your users call' })
).toBeChecked()
await user.click(
dialog.getByRole('radio', {
name: 'Upstream names returned by the provider',
})
)
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
await user.click(dialog.getByRole('checkbox', { name: 'claude-all' }))
await user.click(dialog.getByRole('checkbox', { name: 'claude-latest' }))
await user.type(
dialog.getByRole('textbox', { name: 'Suffix' }),
'-all, -latest'
)
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
'claudeclaude-all'
)
expect(
dialog.getByText(
'1 model(s) skipped because another model derives the same request name'
)
).toBeVisible()
expect(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })).toBeEnabled()
})
@@ -0,0 +1,262 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { expect, test, vi } from 'vitest'
import en from '@/i18n/locales/en.json'
import zh from '@/i18n/locales/zh.json'
import { ModelMappingEditor } from '../model-mapping-editor'
test('external mapping changes update the editor without emitting an edit', () => {
const onChange = vi.fn()
const view = render(
<ModelMappingEditor value='{"client-a":"upstream-a"}' onChange={onChange} />
)
expect(screen.getByDisplayValue('client-a')).toBeVisible()
expect(screen.getByDisplayValue('upstream-a')).toBeVisible()
view.rerender(
<ModelMappingEditor value='{"client-b":"upstream-b"}' onChange={onChange} />
)
expect(screen.getByDisplayValue('client-b')).toBeVisible()
expect(screen.getByDisplayValue('upstream-b')).toBeVisible()
expect(screen.queryByDisplayValue('client-a')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
test('language changes preserve draft mappings and explain the same direction in JSON mode', async () => {
const i18n = createInstance()
await i18n.init({
lng: 'en',
fallbackLng: 'en',
resources: { en, zh },
keySeparator: false,
interpolation: { escapeValue: false },
})
const user = userEvent.setup()
const onChange = vi.fn()
render(
<I18nextProvider i18n={i18n}>
<ModelMappingEditor value='' onChange={onChange} />
</I18nextProvider>
)
await user.click(screen.getByRole('button', { name: 'Add Mapping' }))
await user.type(screen.getByPlaceholderText('gpt-3.5-turbo'), 'client-alias')
await user.type(
screen.getByPlaceholderText('gpt-3.5-turbo-0125'),
'provider-model'
)
await act(() => i18n.changeLanguage('zh'))
expect(screen.getByText('请求模型名称')).toBeVisible()
expect(screen.getByText('上游模型名称')).toBeVisible()
expect(screen.getByDisplayValue('client-alias')).toBeVisible()
expect(screen.getByDisplayValue('provider-model')).toBeVisible()
await user.click(screen.getByRole('tab', { name: 'JSON' }))
expect(
screen.getByText('JSON 的键是请求模型名称,值是上游模型名称。')
).toBeVisible()
expect(screen.getByRole('textbox', { name: '模型映射' })).toHaveValue(
'{\n "client-alias": "provider-model"\n}'
)
expect(onChange).toHaveBeenLastCalledWith(
'{\n "client-alias": "provider-model"\n}'
)
})
test('a new row focuses its request field and both fields offer searchable model options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ModelMappingEditor
value=''
onChange={onChange}
sourceModelOptions={['alpha-model', 'beta-model']}
targetModelOptions={['upstream-a']}
/>
)
await user.click(screen.getByRole('button', { name: 'Add Mapping' }))
const source = screen.getByRole('combobox', { name: 'Request Model Name' })
expect(source).toHaveFocus()
expect(source).toHaveAttribute('aria-expanded', 'true')
await user.click(screen.getByRole('option', { name: 'alpha-model' }))
expect(source).toHaveValue('alpha-model')
expect(onChange).toHaveBeenLastCalledWith('{\n "alpha-model": ""\n}')
await user.click(
screen.getByRole('combobox', { name: 'Upstream Model Name' })
)
await user.click(screen.getByRole('option', { name: 'upstream-a' }))
expect(onChange).toHaveBeenLastCalledWith(
'{\n "alpha-model": "upstream-a"\n}'
)
})
test('the row filter appears from six mappings and narrows the visible rows by either name', async () => {
const user = userEvent.setup()
const five = JSON.stringify(
Object.fromEntries([1, 2, 3, 4, 5].map((n) => [`alpha-${n}`, `up-${n}`]))
)
const view = render(<ModelMappingEditor value={five} onChange={vi.fn()} />)
expect(
screen.queryByRole('textbox', { name: 'Filter mappings' })
).not.toBeInTheDocument()
const six = JSON.stringify({ ...JSON.parse(five), 'beta-6': 'up-6' })
view.rerender(<ModelMappingEditor value={six} onChange={vi.fn()} />)
await user.type(
screen.getByRole('textbox', { name: 'Filter mappings' }),
'beta'
)
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(1)
expect(screen.getByDisplayValue('beta-6')).toBeVisible()
expect(screen.getByText('Showing 1 of 6 mappings')).toBeVisible()
await user.clear(screen.getByRole('textbox', { name: 'Filter mappings' }))
await user.type(
screen.getByRole('textbox', { name: 'Filter mappings' }),
'up-2'
)
expect(screen.getByDisplayValue('alpha-2')).toBeVisible()
expect(screen.getByText('Showing 1 of 6 mappings')).toBeVisible()
})
test('the batch button is only offered when the caller handles it', async () => {
const user = userEvent.setup()
const onBatchAdd = vi.fn()
const view = render(<ModelMappingEditor value='' onChange={vi.fn()} />)
expect(
screen.queryByRole('button', { name: 'Batch Add' })
).not.toBeInTheDocument()
view.rerender(
<ModelMappingEditor value='' onChange={vi.fn()} onBatchAdd={onBatchAdd} />
)
await user.click(screen.getByRole('button', { name: 'Batch Add' }))
expect(onBatchAdd).toHaveBeenCalledTimes(1)
})
test('a draft request with a known request name appends a row, focuses the upstream field, and stays unsaved until completed', async () => {
const onChange = vi.fn()
const handled = vi.fn()
render(
<ModelMappingEditor
value=''
onChange={onChange}
draftRequest={{ from: 'alias', to: '', token: 1 }}
onDraftRequestHandled={handled}
/>
)
await waitFor(() =>
expect(
screen.getByRole('combobox', { name: 'Upstream Model Name' })
).toHaveFocus()
)
expect(
screen.getByRole('combobox', { name: 'Request Model Name' })
).toHaveValue('alias')
expect(handled).toHaveBeenCalledTimes(1)
expect(onChange).not.toHaveBeenCalled()
})
test('a draft request with only the upstream name focuses the request field, and a known request name reuses its existing row', async () => {
const view = render(
<ModelMappingEditor
value='{"alias":"old"}'
onChange={vi.fn()}
draftRequest={{ from: '', to: 'up-1', token: 1 }}
/>
)
await waitFor(() =>
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(2)
)
expect(screen.getByDisplayValue('up-1')).toBeVisible()
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })[1]
).toHaveFocus()
view.rerender(
<ModelMappingEditor
value='{"alias":"old"}'
onChange={vi.fn()}
draftRequest={{ from: 'alias', to: '', token: 2 }}
/>
)
await waitFor(() =>
expect(
screen.getAllByRole('combobox', { name: 'Upstream Model Name' })[0]
).toHaveFocus()
)
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(2)
})
test('a draft request can ask for the request field of an existing row', async () => {
render(
<ModelMappingEditor
value='{"alias":"old"}'
onChange={vi.fn()}
draftRequest={{ from: 'alias', to: 'old', focus: 'from', token: 1 }}
/>
)
await waitFor(() =>
expect(
screen.getByRole('combobox', { name: 'Request Model Name' })
).toHaveFocus()
)
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(1)
})
test('commit fires once an edit settles on Enter or when focus leaves, not per keystroke', async () => {
const user = userEvent.setup()
const onCommit = vi.fn()
render(
<>
<ModelMappingEditor
value='{"alias":""}'
onChange={vi.fn()}
onCommit={onCommit}
/>
<button type='button'>outside</button>
</>
)
const upstream = screen.getByRole('combobox', { name: 'Upstream Model Name' })
await user.type(upstream, 'up-1')
expect(onCommit).not.toHaveBeenCalled()
await user.keyboard('{Enter}')
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenLastCalledWith('{\n "alias": "up-1"\n}')
await user.click(screen.getByRole('combobox', { name: 'Request Model Name' }))
expect(onCommit).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'outside' }))
expect(onCommit).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,867 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { expect, test, vi } from 'vitest'
import zh from '@/i18n/locales/zh.json'
import type { TaskPluginOption } from '../../api'
import { ChannelProviderPicker } from '../drawers/channel-provider-picker'
const plugins = [
{
key: 'provider-video',
name: 'OpenAI',
icon: 'text:PV',
description: {
en: 'Video generation via the vendor API',
zh: '通过厂商接口生成视频',
},
models: ['video-model'],
},
{
key: 'another-video',
name: 'Video Provider',
icon: 'text:VP',
models: ['video-one', 'video-two'],
},
]
const migratedProviders = [
{ type: 36, label: 'SunoAPI', key: 'sunoapi' },
{ type: 50, label: 'Kling', key: 'kling' },
{ type: 51, label: 'Jimeng', key: 'jimeng' },
{ type: 52, label: 'Vidu', key: 'vidu' },
{ type: 54, label: 'DoubaoVideo', key: 'doubao' },
{ type: 55, label: 'Sora', key: 'sora' },
]
const migratedPlugins = migratedProviders.map((provider) => ({
key: provider.key,
name: `${provider.label} Tasks`,
icon: 'text',
models: ['video-model'],
}))
const extensionPlugin: TaskPluginOption = {
key: 'sora',
name: 'Sora Video',
icon: 'text',
description: { en: 'Video generation', zh: '视频生成' },
channelTypes: [1, 55],
models: ['sora-2'],
}
test('creation hides legacy Zhipu from categories and numeric search while GLM remains selectable', async () => {
const user = userEvent.setup()
const select = vi.fn()
render(
<ChannelProviderPicker
isCreating
currentProvider={{ kind: 'builtin', type: 16 }}
plugins={[]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
expect(screen.queryByRole('option', { name: /#16$/ })).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Built-in' }))
const search = screen.getByRole('combobox')
await user.type(search, 'zhi')
expect(screen.getAllByRole('option')).toHaveLength(1)
await user.click(
screen.getByRole('option', { name: 'Zhipu GLM Built-in #26' })
)
expect(select).toHaveBeenCalledWith({ kind: 'builtin', type: 26 })
await user.clear(search)
await user.type(search, '16')
expect(screen.queryByRole('option')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'All' }))
expect(screen.queryByRole('option')).not.toBeInTheDocument()
})
test('editing an existing legacy Zhipu channel retains its selectable provider', async () => {
const user = userEvent.setup()
const select = vi.fn()
render(
<ChannelProviderPicker
isCreating={false}
currentProvider={{ kind: 'builtin', type: 16 }}
plugins={[]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
await user.type(screen.getByRole('combobox'), '16')
const legacy = screen.getByRole('option', { name: 'Zhipu Built-in #16' })
expect(legacy).toHaveAttribute('aria-current', 'true')
await user.click(legacy)
expect(select).toHaveBeenCalledWith({ kind: 'builtin', type: 16 })
})
test('Zhipu GLM uses the renamed Chinese label and remains searchable after changing language', async () => {
const language = createInstance()
await language.init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: {} }, zh },
})
const user = userEvent.setup()
render(
<I18nextProvider i18n={language}>
<ChannelProviderPicker
isCreating
plugins={[]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
</I18nextProvider>
)
await user.type(screen.getByRole('combobox'), 'GLM')
expect(
screen.getByRole('option', { name: 'Zhipu GLM Built-in #26' })
).toBeVisible()
await act(() => language.changeLanguage('zh'))
expect(screen.getByRole('option', { name: '智谱GLM 内置 #26' })).toBeVisible()
})
test('plugin extensions keep built-in and independent plugin choices distinct in every category', async () => {
const select = vi.fn()
const user = userEvent.setup()
render(
<ChannelProviderPicker
isCreating
currentProvider={{ kind: 'builtin', type: 1 }}
plugins={[extensionPlugin]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
const builtin = screen.getByRole('option', { name: 'OpenAI Built-in #1' })
expect(builtin).toHaveClass('min-h-24', 'md:min-h-32')
expect(builtin).toHaveAttribute('aria-current', 'true')
const extension = within(builtin).getByLabelText('Plugin extensions')
expect(extension).toHaveTextContent('Sora Video')
expect(extension).toHaveClass('text-muted-foreground', 'min-w-0')
expect(within(extension).getByText('Sora Video')).toHaveClass('truncate')
expect(extension).toHaveAttribute('title', 'Sora Video · Video generation')
expect(
within(builtin).queryByText('Supports plugin extensions')
).not.toBeInTheDocument()
expect(builtin).not.toHaveTextContent('Video generation')
expect(builtin).toHaveAccessibleDescription(/Sora Video.*Video generation/)
expect(
screen.getByRole('option', { name: 'Sora Video Plugin sora' })
).toBeVisible()
expect(
screen.queryByRole('option', { name: 'Sora Built-in #55' })
).not.toBeInTheDocument()
await user.click(builtin)
expect(select).toHaveBeenLastCalledWith({ kind: 'builtin', type: 1 })
await user.click(screen.getByRole('tab', { name: 'Built-in' }))
expect(
screen.getByRole('option', { name: 'OpenAI Built-in #1' })
).toBeVisible()
expect(
screen.queryByRole('option', { name: 'Sora Video Plugin sora' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(
screen.queryByRole('option', { name: 'OpenAI Built-in #1' })
).not.toBeInTheDocument()
await user.click(
screen.getByRole('option', { name: 'Sora Video Plugin sora' })
)
expect(select).toHaveBeenLastCalledWith({ kind: 'plugin', key: 'sora' })
})
test.each(['Sora Video', 'sora'])(
'searching extension %s finds both its built-in provider and independent plugin',
async (searchText) => {
const user = userEvent.setup()
render(
<ChannelProviderPicker
isCreating
plugins={[extensionPlugin]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
await user.type(screen.getByRole('combobox'), searchText)
expect(screen.getAllByRole('option')).toHaveLength(2)
expect(
screen.getByRole('option', { name: 'OpenAI Built-in #1' })
).toBeVisible()
expect(
screen.getByRole('option', { name: 'Sora Video Plugin sora' })
).toBeVisible()
}
)
test.each([
{ name: 'loading', loading: true, failed: false, canBindPlugin: true },
{ name: 'failed', loading: false, failed: true, canBindPlugin: true },
{ name: 'unpermitted', loading: false, failed: false, canBindPlugin: false },
{
name: 'missing metadata',
loading: false,
failed: false,
canBindPlugin: true,
},
])(
'built-in providers omit extension claims when metadata is $name',
(state) => {
render(
<ChannelProviderPicker
isCreating
plugins={[
{
...extensionPlugin,
channelTypes:
state.name === 'missing metadata'
? undefined
: extensionPlugin.channelTypes,
},
]}
canBindPlugin={state.canBindPlugin}
loading={state.loading}
failed={state.failed}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
expect(
screen.getByRole('option', { name: 'OpenAI Built-in #1' })
).toBeVisible()
expect(screen.queryByLabelText('Plugin extensions')).not.toBeInTheDocument()
}
)
test('extension associations follow declared types and exclude legacy task-only types', () => {
render(
<ChannelProviderPicker
plugins={[
{ ...extensionPlugin, name: 'OpenAI', channelTypes: [24, 55, 61, 999] },
]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
const gemini = screen.getByRole('option', { name: 'Gemini Built-in #24' })
expect(within(gemini).getByLabelText('Plugin extensions')).toBeVisible()
for (const name of [
'OpenAI Built-in #1',
'Sora Built-in #55',
'OpenAI Plugin sora',
]) {
expect(
within(screen.getByRole('option', { name })).queryByLabelText(
'Plugin extensions'
)
).not.toBeInTheDocument()
}
})
test('creation replaces migrated built-ins with their plugins in both all and built-in categories', async () => {
const user = userEvent.setup()
render(
<ChannelProviderPicker
isCreating
plugins={migratedPlugins}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
for (const provider of migratedProviders) {
expect(
screen.getByRole('option', {
name: `${provider.label} Tasks Plugin ${provider.key}`,
})
).toBeVisible()
expect(
screen.queryByRole('option', {
name: `${provider.label} Built-in #${provider.type}`,
})
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('tab', { name: 'Built-in' }))
for (const provider of migratedProviders) {
expect(
screen.queryByRole('option', {
name: `${provider.label} Built-in #${provider.type}`,
})
).not.toBeInTheDocument()
}
})
test.each([
{ label: 'loading', loading: true, failed: false, canBindPlugin: true },
{ label: 'failed', loading: false, failed: true, canBindPlugin: true },
{ label: 'unpermitted', loading: false, failed: false, canBindPlugin: false },
{ label: 'unavailable', loading: false, failed: false, canBindPlugin: true },
])(
'creation preserves legacy choices when plugins are $label',
async (state) => {
const select = vi.fn()
const user = userEvent.setup()
render(
<ChannelProviderPicker
isCreating
plugins={state.label === 'unavailable' ? [] : migratedPlugins}
canBindPlugin={state.canBindPlugin}
loading={state.loading}
failed={state.failed}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
for (const provider of migratedProviders) {
expect(
screen.getByRole('option', {
name: `${provider.label} Built-in #${provider.type}`,
})
).toBeVisible()
}
await user.click(screen.getByRole('option', { name: 'Sora Built-in #55' }))
expect(select).toHaveBeenCalledWith({ kind: 'builtin', type: 55 })
}
)
test('creation retains complementary built-ins and does not replace a legacy type with an unrelated same-name plugin', () => {
render(
<ChannelProviderPicker
isCreating
plugins={[
...migratedPlugins.filter((plugin) => plugin.key !== 'kling'),
{ key: 'unrelated-kling', name: 'Kling', models: [] },
...['google', 'vertex-ai', 'alibaba', 'hailuo'].map((key) => ({
key,
name: key,
models: [],
})),
]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
for (const name of [
'OpenAI Built-in #1',
'Gemini Built-in #24',
'Vertex AI Built-in #41',
'Ali Built-in #17',
'MiniMax Built-in #35',
'VolcEngine Built-in #45',
'Kling Built-in #50',
'Kling Plugin unrelated-kling',
]) {
expect(screen.getByRole('option', { name })).toBeVisible()
}
})
test('creation searches legacy names and translated names through the replacement plugin', async () => {
const language = createInstance()
await language.init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: { translation: {} },
zh: { translation: { DoubaoVideo: '豆包视频' } },
},
})
const select = vi.fn()
const user = userEvent.setup()
render(
<I18nextProvider i18n={language}>
<ChannelProviderPicker
isCreating
plugins={[
{ key: 'doubao', name: 'Ark Video', models: ['video-model'] },
]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
</I18nextProvider>
)
const search = screen.getByRole('combobox')
await user.type(search, 'DoubaoVideo')
expect(
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
).toBeVisible()
await act(() => language.changeLanguage('zh'))
await user.clear(search)
await user.type(search, '豆包视频')
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
).toBeVisible()
await user.click(search)
await user.keyboard('{ArrowDown}{Enter}')
expect(select).toHaveBeenCalledWith({ kind: 'plugin', key: 'doubao' })
})
test.each(migratedProviders)(
'creation searches legacy type $type through its plugin without offering a custom type',
async (provider) => {
const select = vi.fn()
const user = userEvent.setup()
render(
<ChannelProviderPicker
isCreating
plugins={migratedPlugins}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
const search = screen.getByRole('combobox')
await user.type(search, String(provider.type))
expect(
screen.getByRole('option', {
name: `${provider.label} Tasks Plugin ${provider.key}`,
})
).toBeVisible()
expect(
screen.queryByRole('option', {
name: `${provider.label} Built-in #${provider.type}`,
})
).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Custom' }))
expect(screen.queryByRole('option')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(screen.getAllByRole('option')).toHaveLength(1)
await user.click(search)
await user.keyboard('{ArrowDown}{Enter}')
expect(select).toHaveBeenCalledWith({ kind: 'plugin', key: provider.key })
}
)
test.each([true, false])(
'gateway category contains only New API and Sub2API without changing existing categories (plugin permission: %s)',
async (canBindPlugin) => {
const user = userEvent.setup()
const select = vi.fn()
render(
<ChannelProviderPicker
isCreating
plugins={plugins}
canBindPlugin={canBindPlugin}
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
for (const category of ['All', 'Built-in', 'Gateways']) {
await user.click(screen.getByRole('tab', { name: category }))
expect(
screen.getByRole('option', { name: 'New API Built-in #60' })
).toBeVisible()
expect(
screen.getByRole('option', { name: 'Sub2API Built-in #59' })
).toBeVisible()
}
expect(screen.getAllByRole('option')).toHaveLength(2)
await user.click(
screen.getByRole('option', { name: 'New API Built-in #60' })
)
expect(select).toHaveBeenLastCalledWith({ kind: 'builtin', type: 60 })
const search = screen.getByRole('combobox')
await user.type(search, 'Sub2API')
expect(screen.getAllByRole('option')).toHaveLength(1)
await user.click(
screen.getByRole('option', { name: 'Sub2API Built-in #59' })
)
expect(select).toHaveBeenLastCalledWith({ kind: 'builtin', type: 59 })
await user.click(screen.getByRole('tab', { name: 'Built-in' }))
expect(search).toHaveValue('Sub2API')
expect(
screen.getByRole('option', { name: 'Sub2API Built-in #59' })
).toBeVisible()
await user.click(screen.getByRole('tab', { name: 'Gateways' }))
await user.clear(search)
await user.type(search, '999')
expect(screen.queryByRole('option')).not.toBeInTheDocument()
}
)
test('custom providers have their own tab and keep the search when switching categories', async () => {
const user = userEvent.setup()
render(
<ChannelProviderPicker
plugins={plugins}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
const tabs = screen.getByRole('tablist', { name: 'Provider source' })
expect(within(tabs).getAllByRole('tab')).toHaveLength(5)
await user.click(within(tabs).getByRole('tab', { name: 'Custom' }))
const panel = screen.getByRole('tabpanel', { name: 'Custom' })
expect(within(panel).getAllByRole('option')).toHaveLength(2)
expect(
within(panel).getByRole('option', { name: 'Advanced Custom Built-in #58' })
).toBeVisible()
expect(
within(panel).getByRole('option', { name: 'Custom Built-in #8' })
).toBeVisible()
expect(
within(panel).queryByRole('option', { name: /Plugin/ })
).not.toBeInTheDocument()
await user.click(within(tabs).getByRole('tab', { name: 'Built-in' }))
expect(
screen.getByRole('option', { name: 'OpenAI Built-in #1' })
).toBeVisible()
expect(
screen.queryByRole('option', { name: 'Advanced Custom Built-in #58' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('option', { name: 'Custom Built-in #8' })
).not.toBeInTheDocument()
await user.type(screen.getByRole('combobox'), '58')
expect(screen.queryByRole('option')).not.toBeInTheDocument()
await user.click(within(tabs).getByRole('tab', { name: 'Custom' }))
expect(screen.getByRole('combobox')).toHaveValue('58')
expect(screen.getAllByRole('option')).toHaveLength(1)
expect(
screen.getByRole('option', { name: 'Advanced Custom Built-in #58' })
).toBeVisible()
})
test('category tabs use arrow and Enter navigation without moving focus into the search field', async () => {
const user = userEvent.setup()
render(
<ChannelProviderPicker
plugins={plugins}
canBindPlugin
loading={false}
failed
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
const all = screen.getByRole('tab', { name: 'All' })
const builtin = screen.getByRole('tab', { name: 'Built-in' })
await user.click(all)
await user.keyboard('{ArrowRight}')
expect(builtin).toHaveFocus()
expect(all).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{Enter}')
expect(builtin).toHaveAttribute('aria-selected', 'true')
expect(builtin).toHaveFocus()
expect(screen.getByRole('tabpanel', { name: 'Built-in' })).toHaveAttribute(
'id',
builtin.getAttribute('aria-controls')
)
expect(screen.queryByText('Failed to load plugins')).not.toBeInTheDocument()
await user.keyboard('{ArrowRight}{Enter}')
expect(screen.getByRole('tab', { name: 'Plugins' })).toHaveFocus()
expect(screen.getByText('Failed to load plugins')).toBeVisible()
await user.keyboard('{ArrowRight}{Enter}')
expect(screen.getByRole('tab', { name: 'Gateways' })).toHaveFocus()
expect(screen.getByRole('tabpanel', { name: 'Gateways' })).toBeVisible()
expect(screen.queryByText('Failed to load plugins')).not.toBeInTheDocument()
await user.keyboard('{ArrowRight}{Enter}')
expect(screen.getByRole('tab', { name: 'Custom' })).toHaveFocus()
expect(screen.getByRole('tabpanel', { name: 'Custom' })).toBeVisible()
expect(screen.queryByText('Failed to load plugins')).not.toBeInTheDocument()
})
test('removing plugin binding permission returns an active plugin tab to all providers', async () => {
const user = userEvent.setup()
const props = {
plugins,
loading: false,
failed: false,
disabled: false,
onRetry: vi.fn(),
onSelect: vi.fn(),
}
const view = render(<ChannelProviderPicker {...props} canBindPlugin />)
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
view.rerender(<ChannelProviderPicker {...props} canBindPlugin={false} />)
expect(screen.queryByRole('tab', { name: 'Plugins' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute(
'aria-selected',
'true'
)
expect(screen.getByRole('tabpanel', { name: 'All' })).toBeVisible()
expect(
screen.queryByRole('option', { name: /Plugin/ })
).not.toBeInTheDocument()
expect(
screen.getByRole('option', { name: 'Advanced Custom Built-in #58' })
).toBeVisible()
})
test('every built-in provider has a description and Anthropic includes compatible services', async () => {
const language = createInstance()
await language.init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: {} }, zhCN: zh },
})
render(
<I18nextProvider i18n={language}>
<ChannelProviderPicker
plugins={[]}
canBindPlugin={false}
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
</I18nextProvider>
)
for (const option of screen.getAllByRole('option')) {
expect(option).toHaveAccessibleDescription()
}
const anthropic = screen.getByRole('option', {
name: 'Anthropic Built-in #14',
})
expect(anthropic).toHaveAccessibleDescription(
'Connect to the Anthropic API or compatible services'
)
await act(() => language.changeLanguage('zhCN'))
expect(anthropic).toHaveAccessibleDescription(
'接入 Anthropic API 或兼容其接口的服务'
)
expect(
within(anthropic).getByText('接入 Anthropic API 或兼容其接口的服务')
).toBeVisible()
})
test('deprecated and flexible integration badges preserve provider selection and expose the full explanation', async () => {
const select = vi.fn()
const user = userEvent.setup()
const props = {
plugins: [],
canBindPlugin: false,
loading: false,
failed: false,
disabled: false,
onRetry: vi.fn(),
onSelect: select,
}
const view = render(<ChannelProviderPicker {...props} />)
const custom = screen.getByRole('option', { name: 'Custom Built-in #8' })
const advanced = screen.getByRole('option', {
name: 'Advanced Custom Built-in #58',
})
expect(custom).toHaveAccessibleDescription(
'Deprecated · Legacy full-URL integration; use Advanced Custom for new channels'
)
await user.click(within(custom).getByText('Deprecated'))
expect(select).toHaveBeenNthCalledWith(1, { kind: 'builtin', type: 8 })
const details =
"New API's flexible channel lets you configure upstream addresses and authentication per endpoint, choose native forwarding or supported protocol conversions, and configure model listing and balance queries independently"
expect(advanced).toHaveAccessibleDescription(
`Flexible integration · ${details}`
)
expect(
within(advanced).getByText(
'Configure endpoint routing, authentication and protocol conversion for different upstream services'
)
).toHaveAttribute('title', details)
await user.click(within(advanced).getByText('Flexible integration'))
expect(select).toHaveBeenNthCalledWith(2, { kind: 'builtin', type: 58 })
view.rerender(<ChannelProviderPicker {...props} disabled />)
expect(advanced).toHaveAttribute('aria-disabled', 'true')
await user.click(within(advanced).getByText('Flexible integration'))
expect(select).toHaveBeenCalledTimes(2)
})
test('plugin descriptions follow the current language with an English fallback and remain selectable', async () => {
const language = createInstance()
await language.init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: {} } },
})
const select = vi.fn()
const user = userEvent.setup()
render(
<I18nextProvider i18n={language}>
<ChannelProviderPicker
plugins={plugins}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
</I18nextProvider>
)
const option = screen.getByRole('option', {
name: 'OpenAI Plugin provider-video',
})
expect(
within(option).getByText('Video generation via the vendor API')
).toBeVisible()
expect(option).toHaveAccessibleDescription(
'Video generation via the vendor API'
)
expect(
screen.getByRole('option', { name: 'Video Provider Plugin another-video' })
).not.toHaveAccessibleDescription()
await act(() => language.changeLanguage('zhCN'))
expect(within(option).getByText('通过厂商接口生成视频')).toBeVisible()
expect(option).toHaveAccessibleDescription('通过厂商接口生成视频')
await act(() => language.changeLanguage('fr'))
await user.click(
within(option).getByText('Video generation via the vendor API')
)
expect(select).toHaveBeenCalledWith({ kind: 'plugin', key: 'provider-video' })
})
test('built-in and plugin providers with the same name remain distinct and plugin search selects the binding', async () => {
const select = vi.fn()
const user = userEvent.setup()
render(
<ChannelProviderPicker
plugins={plugins}
currentProvider={{ kind: 'builtin', type: 1 }}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
expect(screen.getAllByRole('option', { name: /^OpenAI / })).toHaveLength(2)
expect(
screen.getByRole('option', { name: 'OpenAI Built-in #1' })
).toHaveAttribute('aria-current', 'true')
expect(
screen.getByRole('option', { name: 'OpenAI Plugin provider-video' })
).not.toHaveAttribute('aria-current')
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(screen.getAllByRole('option')).toHaveLength(2)
await user.type(screen.getByRole('combobox'), 'provider-video')
expect(screen.getAllByRole('option')).toHaveLength(1)
await user.keyboard('{ArrowDown}{Enter}')
expect(select).toHaveBeenCalledWith({ kind: 'plugin', key: 'provider-video' })
})
test('searching a known type number selects that type and an unknown positive number remains usable', async () => {
const select = vi.fn()
const user = userEvent.setup()
render(
<ChannelProviderPicker
plugins={[]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={select}
/>
)
const search = screen.getByRole('combobox')
await user.type(search, '43')
await user.keyboard('{ArrowDown}{Enter}')
expect(select).toHaveBeenLastCalledWith({ kind: 'builtin', type: 43 })
await user.clear(search)
await user.type(search, '999')
await user.click(screen.getByRole('tab', { name: 'Built-in' }))
expect(screen.queryByRole('option')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Custom' }))
await user.click(search)
await user.keyboard('{ArrowDown}{Enter}')
expect(select).toHaveBeenLastCalledWith({ kind: 'builtin', type: 999 })
await user.clear(search)
await user.type(search, '61')
expect(screen.queryByRole('option')).not.toBeInTheDocument()
})
test('an empty plugin filter reports that no plugins can be bound', async () => {
const user = userEvent.setup()
render(
<ChannelProviderPicker
plugins={[]}
canBindPlugin
loading={false}
failed={false}
disabled={false}
onRetry={vi.fn()}
onSelect={vi.fn()}
/>
)
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(screen.getByText('No plugins available for binding')).toBeVisible()
expect(screen.queryByRole('option')).not.toBeInTheDocument()
})
@@ -0,0 +1,197 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import { Form } from '@/components/ui/form'
import {
buildSettingJSON,
CHANNEL_FORM_DEFAULT_VALUES,
transformChannelToFormDefaults,
type ChannelFormValues,
} from '../../lib/channel-form'
import { channelSchema } from '../../types'
import { AstraModelGuardSetting } from '../astra-model-guard-setting'
import { ResponsesWebSocketSetting } from '../responses-websocket-setting'
function SettingsForm(props: {
channelType: number
disabled?: boolean
enabled?: boolean
onSave: (value: string) => void
}) {
const form = useForm<ChannelFormValues>({
defaultValues: {
...CHANNEL_FORM_DEFAULT_VALUES,
type: props.channelType,
responses_websocket_enabled: props.enabled,
},
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
props.onSave(buildSettingJSON(values))
)}
>
<ResponsesWebSocketSetting
channelType={props.channelType}
disabled={props.disabled}
/>
<button type='submit'>Save</button>
</form>
</Form>
)
}
function AstraGuardForm(props: {
enabled?: boolean
onSave: (value: string) => void
}) {
const form = useForm<ChannelFormValues>({
defaultValues: {
...CHANNEL_FORM_DEFAULT_VALUES,
responses_astra_model_guard: props.enabled,
},
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
props.onSave(buildSettingJSON(values))
)}
>
<AstraModelGuardSetting />
<button type='submit'>Save</button>
</form>
</Form>
)
}
describe('Astra model guard channel setting', () => {
test('keeps the legacy setting read-only and preserves its stored value', async () => {
const user = userEvent.setup()
const saved: string[] = []
render(<AstraGuardForm onSave={(value) => saved.push(value)} />)
const toggle = screen.getByRole('switch', {
name: 'Legacy Astra model guard (deprecated)',
})
expect(toggle).toBeChecked()
expect(toggle).toHaveAttribute('aria-disabled', 'true')
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(saved[0]).responses_astra_model_guard).toBe(true)
})
})
describe('Responses WebSocket channel setting', () => {
test.each([undefined, false, true])(
'loads and saves the explicit setting %s',
(enabled) => {
const channel = channelSchema.parse({
id: 1,
name: 'Test channel',
key: '',
type: 1,
status: 1,
created_time: 0,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
setting: JSON.stringify({
responses_websocket_enabled: enabled,
responses_astra_model_guard: true,
}),
})
const values = transformChannelToFormDefaults(channel)
const settings = JSON.parse(buildSettingJSON(values))
expect(settings.responses_websocket_enabled).toBe(enabled === true)
expect(settings.responses_astra_model_guard).toBe(true)
}
)
test('new channels default to WebSocket disabled', () => {
expect(
JSON.parse(buildSettingJSON(CHANNEL_FORM_DEFAULT_VALUES))
.responses_websocket_enabled
).toBe(false)
expect(
JSON.parse(buildSettingJSON(CHANNEL_FORM_DEFAULT_VALUES))
.responses_astra_model_guard
).toBe(true)
})
test.each([1, 57])(
'channel type %s exposes an accessible switch and saves on and off',
async (channelType) => {
const user = userEvent.setup()
const saved: string[] = []
render(
<SettingsForm
channelType={channelType}
onSave={(value) => saved.push(value)}
/>
)
const toggle = screen.getByRole('switch', {
name: 'Enable Responses WebSocket',
})
expect(toggle).not.toBeChecked()
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(saved[0]).responses_websocket_enabled).toBe(true)
toggle.focus()
await user.keyboard(' ')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(saved[1]).responses_websocket_enabled).toBe(false)
}
)
test('unsupported channels hide the switch and do not save it as enabled', async () => {
const user = userEvent.setup()
const saved: string[] = []
render(
<SettingsForm
channelType={14}
enabled
onSave={(value) => saved.push(value)}
/>
)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(saved[0]).responses_websocket_enabled).toBe(false)
})
test('locked channel settings prevent toggling', async () => {
const user = userEvent.setup()
render(
<SettingsForm channelType={1} disabled enabled onSave={() => undefined} />
)
const toggle = screen.getByRole('switch', {
name: 'Enable Responses WebSocket',
})
expect(toggle).toHaveAttribute('aria-disabled', 'true')
await user.click(toggle)
expect(toggle).toBeChecked()
})
})
@@ -0,0 +1,253 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import { fetchModels } from '../../api'
import { ChannelsProvider } from '../channels-provider'
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
import { UpstreamModelSelection } from '../upstream-model-selection'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
test.each([
{
result: 'nonempty',
models: ['gpt-existing', 'gpt-new'],
removedCount: 1,
savedModels: ['gpt-existing', 'alias', 'gpt-new'],
},
{
result: 'empty',
models: [],
removedCount: 2,
savedModels: ['alias'],
},
])(
'removed models stay available after batch deselection with a $result upstream list and only checked models are saved',
async ({ models, removedCount, savedModels }) => {
vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: models },
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const select = vi.fn()
const close = vi.fn()
const user = userEvent.setup()
const view = render(
<QueryClientProvider client={client}>
<ChannelsProvider>
<FetchModelsDialog
open
onOpenChange={close}
onModelsSelected={select}
existingModelsOverride={['gpt-existing', 'alias', 'manual-model']}
redirectSourceModels={['alias']}
customFetcher={async () =>
(
await fetchModels({
type: 1,
base_url: 'https://example.com',
key: 'test-key',
})
).data ?? []
}
/>
</ChannelsProvider>
</QueryClientProvider>
)
const removedTab = await screen.findByRole('tab', {
name: `Removed Models (${removedCount})`,
})
if (models.length > 0) {
await user.click(screen.getByRole('checkbox', { name: 'gpt-new' }))
}
await user.click(removedTab)
expect(
screen.queryByRole('checkbox', { name: 'alias' })
).not.toBeInTheDocument()
await user.click(
screen.getByRole('checkbox', { name: 'Select all models in Removed' })
)
expect(removedTab).toHaveAttribute('aria-selected', 'true')
const manualModel = screen.getByRole('checkbox', { name: 'manual-model' })
expect(manualModel).not.toBeChecked()
await user.click(manualModel)
expect(manualModel).toBeChecked()
await user.click(manualModel)
expect(manualModel).not.toBeChecked()
expect(
screen.getByRole('tab', { name: `Removed Models (${removedCount})` })
).toHaveAttribute('aria-selected', 'true')
expect(select).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Save Models' }))
expect(select).toHaveBeenCalledWith(savedModels)
expect(close).toHaveBeenCalledWith(false)
view.unmount()
client.clear()
}
)
function InlineSelection() {
const [selected, setSelected] = useState(['manual-model'])
return (
<>
<UpstreamModelSelection
models={['gpt-one', 'gpt-two', 'another-model']}
selected={selected}
existingModels={[]}
showChanges={false}
onChange={setSelected}
/>
<output aria-label='Selected models'>{selected.join(',')}</output>
</>
)
}
test('category headers keep a transparent background while expanding and collapsing without changing selection', async () => {
const user = userEvent.setup()
render(<InlineSelection />)
const header = screen.getByRole('button', { name: /^OpenAI \(2\)/ })
expect(header).toHaveAttribute('aria-expanded', 'true')
expect(header).toHaveClass(
'aria-expanded:bg-transparent',
'hover:bg-transparent',
'dark:hover:bg-transparent'
)
await user.click(header)
expect(header).toHaveAttribute('aria-expanded', 'false')
expect(
screen.queryByRole('checkbox', { name: 'gpt-one' })
).not.toBeInTheDocument()
await user.keyboard('{Enter}')
expect(header).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('checkbox', { name: 'gpt-one' })).toBeVisible()
expect(header).toHaveFocus()
expect(screen.getByLabelText('Selected models')).toHaveTextContent(
'manual-model'
)
})
test('the matching-model action only appears for a nonblank search and disappears when cleared', async () => {
const user = userEvent.setup()
render(<InlineSelection />)
const search = screen.getByRole('textbox', { name: 'Search models...' })
expect(
screen.queryByRole('button', { name: 'Select all matching models' })
).not.toBeInTheDocument()
await user.type(search, ' ')
expect(
screen.queryByRole('button', { name: 'Select all matching models' })
).not.toBeInTheDocument()
await user.type(search, 'gpt-')
expect(
screen.getByRole('button', { name: 'Select all matching models' })
).toBeEnabled()
await user.clear(search)
expect(
screen.queryByRole('button', { name: 'Select all matching models' })
).not.toBeInTheDocument()
await user.type(search, 'missing')
expect(
screen.getByRole('button', { name: 'Select all matching models' })
).toBeDisabled()
})
test('selecting search results adds only matching models and retains the manual selection', async () => {
const user = userEvent.setup()
render(<InlineSelection />)
await user.type(
screen.getByRole('textbox', { name: 'Search models...' }),
'gpt-'
)
await user.click(
screen.getByRole('button', { name: 'Select all matching models' })
)
expect(screen.getByLabelText('Selected models')).toHaveTextContent(
'manual-model,gpt-one,gpt-two'
)
expect(
screen.queryByRole('checkbox', { name: 'another-model' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'gpt-one' }))
expect(screen.getByLabelText('Selected models')).toHaveTextContent(
'manual-model,gpt-two'
)
})
test('the redirect action appears per model only when a handler is provided and reports the model without toggling it', async () => {
const user = userEvent.setup()
const onRedirect = vi.fn()
const view = render(
<UpstreamModelSelection
models={['gpt-one']}
selected={[]}
existingModels={[]}
showChanges={false}
onChange={() => undefined}
/>
)
expect(
screen.queryByRole('button', { name: 'Redirect gpt-one' })
).not.toBeInTheDocument()
view.rerender(
<UpstreamModelSelection
models={['gpt-one']}
selected={[]}
existingModels={[]}
showChanges={false}
onChange={() => undefined}
onRedirectModel={onRedirect}
/>
)
await user.click(screen.getByRole('button', { name: 'Redirect gpt-one' }))
expect(onRedirect).toHaveBeenCalledWith('gpt-one')
expect(screen.getByRole('checkbox', { name: 'gpt-one' })).not.toBeChecked()
})
test('models published under another name show that name as a badge next to the row', () => {
render(
<UpstreamModelSelection
models={['gpt-4o-all', 'o3']}
selected={[]}
existingModels={[]}
showChanges={false}
onChange={() => undefined}
aliases={{ 'gpt-4o-all': 'gpt-4o' }}
/>
)
expect(screen.getByLabelText('Published as gpt-4o')).toHaveTextContent(
'gpt-4o'
)
expect(screen.queryByLabelText(/Published as o3/)).not.toBeInTheDocument()
})
@@ -0,0 +1,47 @@
import { useFormContext } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import type { ChannelFormValues } from '../lib/channel-form'
export function AstraModelGuardSetting(_props: { disabled?: boolean }) {
const { t } = useTranslation()
const form = useFormContext<ChannelFormValues>()
return (
<FormField
control={form.control}
name='responses_astra_model_guard'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-4 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Legacy Astra model guard (deprecated)')}</FormLabel>
<FormDescription>
{t(
'Astra auto-switch is now controlled by each API key. This legacy channel setting no longer affects requests.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value !== false}
onCheckedChange={field.onChange}
disabled
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
</FormItem>
)}
/>
)
}
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
import { resolveLocalizedText } from '@/lib/localized-text'
import type { TaskPluginOption } from '../api'
export function ChannelPluginExtensions(props: {
plugins: TaskPluginOption[]
selected: string[]
onConfigure: (pluginKey: string) => void
}) {
const { t, i18n } = useTranslation()
const plugins = props.plugins.filter((plugin) => plugin.models.length > 0)
if (plugins.length === 0) return null
const selected = new Set(props.selected)
return (
<div
role='group'
aria-label={t('Plugin extensions')}
className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'
>
<span className='text-muted-foreground text-xs'>
{t('Plugin extensions')}
</span>
{plugins.map((plugin) => {
const models = [...new Set(plugin.models)]
const selectedCount = models.filter((model) =>
selected.has(model)
).length
const selectionLabel = t('Selected {{selected}} / {{total}}', {
selected: selectedCount,
total: models.length,
})
const description = resolveLocalizedText(
plugin.description,
i18n.language
)
return (
<Button
key={plugin.key}
type='button'
variant='ghost'
size='sm'
className='max-w-full min-w-0 gap-1.5 px-1.5 font-normal'
aria-haspopup='dialog'
aria-label={`${plugin.name} ${selectionLabel}`}
title={description ? `${plugin.name}: ${description}` : plugin.name}
onClick={() => props.onConfigure(plugin.key)}
>
<span aria-hidden='true' className='shrink-0'>
<PluginIcon plugin={plugin} size={16} />
</span>
<span className='max-w-36 truncate'>{plugin.name}</span>
<span className='text-muted-foreground shrink-0 text-xs tabular-nums'>
{selectionLabel}
</span>
<ChevronRight
className='text-muted-foreground size-3 shrink-0'
aria-hidden='true'
/>
</Button>
)
})}
</div>
)
}
@@ -0,0 +1,241 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ReactNode, useId } from 'react'
import { type UseFormReturn, useFormContext, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { FieldGroup } from '@/components/ui/field'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { SettingsSwitchField } from '@/features/system-settings/components/settings-form-layout'
import { ChannelHealthSource } from '@/features/system-settings/request-policies/related-policy-link'
import { cn } from '@/lib/utils'
import { CHANNEL_TYPE_TASK_PLUGIN, MODEL_FETCHABLE_TYPES } from '../constants'
import type { ChannelFormValues } from '../lib/channel-form'
import {
getHeaderPassthroughState,
setHeaderPassthrough,
} from '../lib/header-passthrough'
import { supportsResponsesWebSocket } from '../lib/responses-websocket'
import type { PassthroughKind } from './dialogs/passthrough-warning-dialog'
type ChannelQuickOptionsProps = {
channelType: number
sensitiveLocked: boolean
disabled: boolean
/**
* `stacked` is the full list with descriptions used inside the form;
* `inline` is a compact toggle strip for the drawer header on wide screens.
*/
layout?: 'stacked' | 'inline'
className?: string
/** Required when rendered outside the form provider, e.g. in the drawer header. */
form?: UseFormReturn<ChannelFormValues>
/**
* Asks the user to acknowledge the consequences before a passthrough option
* is switched on. Resolves `false` when they decline; switching off never asks.
*/
confirmEnablePassthrough: (kind: PassthroughKind) => Promise<boolean>
}
type QuickOption = {
key: string
label: string
description: ReactNode
checked: boolean
onCheckedChange: (value: boolean) => void
disabled: boolean
}
export function ChannelQuickOptions(props: ChannelQuickOptionsProps) {
const { t } = useTranslation()
const id = useId()
const formContext = useFormContext<ChannelFormValues>()
const form = props.form ?? formContext
const [passthrough, headerOverride, autoBan, modelCheck, websocket] =
useWatch({
control: form.control,
name: [
'pass_through_body_enabled',
'header_override',
'auto_ban',
'upstream_model_update_check_enabled',
'responses_websocket_enabled',
],
})
const sensitiveDisabled = props.sensitiveLocked || props.disabled
const setOption = (
name:
| 'pass_through_body_enabled'
| 'upstream_model_update_check_enabled'
| 'responses_websocket_enabled',
value: boolean
) => form.setValue(name, value, { shouldDirty: true, shouldValidate: true })
// The header switch is a shortcut for the "*" rule in Request Header
// Override, so it reads and writes that JSON instead of a separate flag.
const headerPassthroughState = getHeaderPassthroughState(headerOverride)
const applyHeaderPassthrough = (value: boolean) =>
form.setValue(
'header_override',
setHeaderPassthrough(form.getValues('header_override'), value),
{ shouldDirty: true, shouldValidate: true }
)
const options: QuickOption[] = []
if (props.channelType !== CHANNEL_TYPE_TASK_PLUGIN) {
options.push({
key: 'passthrough',
label: t('Pass Through Body'),
description: t(
'Preserve upstream-specific fields when API formats match; bypasses model redirect, parameter override and format conversion'
),
checked: passthrough === true,
onCheckedChange: (value) => {
if (!value) {
setOption('pass_through_body_enabled', false)
return
}
void props.confirmEnablePassthrough('body').then((confirmed) => {
if (confirmed) setOption('pass_through_body_enabled', true)
})
},
disabled: sensitiveDisabled,
})
options.push({
key: 'passthrough-headers',
label: t('Pass Through Request Headers'),
description:
headerPassthroughState === 'invalid'
? t('Fix the Request Header Override JSON first')
: t(
'Enable when the upstream needs client information from Codex or Claude Code headers; may expose client information'
),
checked: headerPassthroughState === 'enabled',
onCheckedChange: (value) => {
if (!value) {
applyHeaderPassthrough(false)
return
}
void props.confirmEnablePassthrough('headers').then((confirmed) => {
if (confirmed) applyHeaderPassthrough(true)
})
},
disabled: sensitiveDisabled || headerPassthroughState === 'invalid',
})
}
options.push({
key: 'auto-ban',
label: t('Auto-disable channel'),
description: <ChannelHealthSource autoBan={(autoBan ?? 1) === 1} />,
checked: (autoBan ?? 1) === 1,
onCheckedChange: (value) =>
form.setValue('auto_ban', value ? 1 : 0, {
shouldDirty: true,
shouldValidate: true,
}),
disabled: props.disabled,
})
if (MODEL_FETCHABLE_TYPES.has(props.channelType)) {
options.push({
key: 'model-check',
label: t('Detect model updates'),
description: t('Check for new upstream models'),
checked: modelCheck === true,
onCheckedChange: (value) =>
setOption('upstream_model_update_check_enabled', value),
disabled: sensitiveDisabled,
})
}
if (supportsResponsesWebSocket(props.channelType)) {
options.push({
key: 'websocket',
label: t('Responses WebSocket'),
description: t('Requires upstream WebSocket support'),
checked: websocket === true,
onCheckedChange: (value) =>
setOption('responses_websocket_enabled', value),
disabled: sensitiveDisabled,
})
}
if (props.layout === 'inline') {
return (
<div
role='group'
aria-label={t('Quick options')}
className={cn('flex flex-wrap items-center gap-2', props.className)}
>
{options.map((option) => (
<div
key={option.key}
title={
typeof option.description === 'string'
? option.description
: undefined
}
className='border-border/60 bg-muted/30 flex items-center gap-2 rounded-md border px-2.5 py-1.5'
>
<Switch
id={`${id}-${option.key}`}
size='sm'
checked={option.checked}
onCheckedChange={option.onCheckedChange}
disabled={option.disabled}
/>
<Label
htmlFor={`${id}-${option.key}`}
className='cursor-pointer text-xs font-medium'
>
{option.label}
</Label>
</div>
))}
</div>
)
}
return (
<section
role='group'
aria-labelledby={`${id}-title`}
className={cn(
'border-border/60 @container border-t px-4 pt-4 pb-2',
props.className
)}
>
<h3 id={`${id}-title`} className='mb-1 text-sm font-semibold'>
{t('Quick options')}
</h3>
<FieldGroup className='grid gap-x-6 gap-y-0 @sm:grid-cols-2'>
{options.map((option) => (
<SettingsSwitchField
key={option.key}
controlId={`${id}-${option.key}`}
checked={option.checked}
onCheckedChange={option.onCheckedChange}
label={option.label}
description={option.description}
disabled={option.disabled}
/>
))}
</FieldGroup>
</section>
)
}
@@ -30,6 +30,7 @@ import {
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
@@ -77,7 +78,7 @@ export function TaskPluginChannelBadge(props: { pluginKey?: string }) {
)
const query = useQuery({
queryKey: ['task-plugin-options'],
queryFn: getTaskPluginOptions,
queryFn: async () => requireServerSuccess(await getTaskPluginOptions()),
enabled: Boolean(props.pluginKey) && canBind,
staleTime: 60 * 1000,
})
@@ -53,12 +53,16 @@ import {
getCurrencyLabel,
} from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { createServerError } from '@/lib/server-error-message'
import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api'
import {
CHANNEL_STATUS_CONFIG,
CHANNEL_TYPE_TASK_PLUGIN,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
MODEL_FETCHABLE_TYPES,
} from '../constants'
import {
@@ -335,7 +339,7 @@ export function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible, setCurrentRow } = useChannels()
const { sensitiveVisible, setCurrentRow, setOpen } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0
@@ -426,8 +430,17 @@ export function BalanceCell({ channel }: { channel: Channel }) {
// Regular channel row: show used and remaining with click to update
const variant = getBalanceVariant(balance)
const isInferenceChannel =
channel.type === CHANNEL_TYPE_VLLM || channel.type === CHANNEL_TYPE_SGLANG
const inferenceStatusLabel =
channel.type === CHANNEL_TYPE_SGLANG ? t('SGLang status') : t('vLLM status')
const handleClickUpdate = async () => {
if (isInferenceChannel) {
setCurrentRow(channel)
setOpen('inference-status')
return
}
if (isUpdating) {
return
}
@@ -437,14 +450,12 @@ export function BalanceCell({ channel }: { channel: Channel }) {
try {
const res = await getCodexUsage(channel.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
throw createServerError(res, t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
setCodexUsageOpen(true)
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
handleServerError(error, t('Failed to fetch usage'))
} finally {
setIsUpdating(false)
}
@@ -470,12 +481,10 @@ export function BalanceCell({ channel }: { channel: Channel }) {
setCurrentRow(channel)
setRawBalanceResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to update balance'))
handleServerError(response, t('Failed to update balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update balance')
)
handleServerError(error, t('Failed to update balance'))
} finally {
setIsUpdating(false)
}
@@ -485,19 +494,34 @@ export function BalanceCell({ channel }: { channel: Channel }) {
remainingBadgeLabel = t('Updating...')
} else if (sensitiveVisible && channel.type === 57) {
remainingBadgeLabel = t('Account Info')
} else if (sensitiveVisible && isInferenceChannel) {
remainingBadgeLabel = inferenceStatusLabel
}
let remainingTooltipLabel = remainingLabel
if (!sensitiveVisible) {
remainingTooltipLabel = maskedRemainingLabel
} else if (channel.type === 57) {
remainingTooltipLabel = t('Click to view Codex usage')
} else if (isInferenceChannel) {
remainingTooltipLabel = inferenceStatusLabel
}
let remainingBadgeVariant: StatusBadgeProps['variant'] = variant
if (channel.type === 57) {
if (channel.type === 57 || isInferenceChannel) {
remainingBadgeVariant = 'info'
} else if (isUpdating) {
remainingBadgeVariant = 'neutral'
}
const remainingBadge = (
<StatusBadge
label={remainingBadgeLabel}
variant={remainingBadgeVariant}
size='sm'
copyable={false}
showDot={false}
className='cursor-pointer'
onClick={isInferenceChannel ? undefined : handleClickUpdate}
/>
)
return (
<TooltipProvider>
@@ -522,20 +546,26 @@ export function BalanceCell({ channel }: { channel: Channel }) {
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={remainingBadgeLabel}
variant={remainingBadgeVariant}
size='sm'
copyable={false}
showDot={false}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
isInferenceChannel ? (
<Button
variant='ghost'
size='sm'
className='h-auto rounded-full p-0'
aria-haspopup='dialog'
onClick={handleClickUpdate}
>
{remainingBadge}
</Button>
) : (
remainingBadge
)
}
/>
<TooltipContent>
<p>{remainingTooltipLabel}</p>
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
{channel.type !== 57 && !isInferenceChannel && (
<p>{t('Click to update balance')}</p>
)}
</TooltipContent>
</Tooltip>
</div>
@@ -556,15 +586,11 @@ export function BalanceCell({ channel }: { channel: Channel }) {
try {
const res = await getCodexUsage(channel.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
throw createServerError(res, t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('Failed to fetch usage')
)
handleServerError(error, t('Failed to fetch usage'))
} finally {
setIsUpdating(false)
}
@@ -16,12 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { CHANNEL_TYPE_VLLM, CHANNEL_TYPE_SGLANG } from '../constants'
import { useChannels } from './channels-provider'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
import { EditTagDialog } from './dialogs/edit-tag-dialog'
import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
import { InferenceStatusDialog } from './dialogs/inference-status-dialog'
import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
@@ -33,6 +35,21 @@ export function ChannelsDialogs() {
return (
<>
{open === 'inference-status' &&
currentRow &&
[CHANNEL_TYPE_VLLM, CHANNEL_TYPE_SGLANG].includes(currentRow.type) && (
<InferenceStatusDialog
key={currentRow.id}
provider={
currentRow.type === CHANNEL_TYPE_SGLANG ? 'sglang' : 'vllm'
}
channelId={currentRow.id}
channelName={currentRow.name}
onClose={() => setOpen(null)}
onSyncModels={() => setOpen('fetch-models')}
onTestChannel={() => setOpen('test-channel')}
/>
)}
{/* Channel Create/Update Drawer */}
<ChannelMutateDrawer
open={open === 'create-channel' || open === 'update-channel'}
@@ -41,6 +41,7 @@ type DialogType =
| 'balance-query'
| 'fetch-models'
| 'ollama-models'
| 'inference-status'
| 'multi-key-manage'
| 'tag-batch-edit'
| 'edit-tag'
@@ -44,6 +44,7 @@ import {
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { requireServerSuccess } from '@/lib/server-error-message'
import { getChannels, searchChannels, getGroups } from '../api'
import {
@@ -205,7 +206,7 @@ export function ChannelsTable() {
// Fetch groups for filter
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
queryFn: async () => requireServerSuccess(await getGroups()),
})
const groupOptions = useMemo(
@@ -243,47 +244,51 @@ export function ChannelsTable() {
}),
queryFn: async () => {
if (shouldSearch) {
return searchChannels({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
return requireServerSuccess(
await searchChannels({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
)
} else {
return getChannels({
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
return requireServerSuccess(
await getChannels({
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
)
}
},
placeholderData: (previousData) => previousData,
@@ -424,6 +429,7 @@ export function ChannelsTable() {
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
toolbarProps={{
collapsibleOnMobile: true,
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
@@ -30,6 +30,7 @@ import {
Info,
ListTree,
Plus,
Send,
Shuffle,
Trash2,
type LucideIcon,
@@ -60,6 +61,7 @@ import {
} from '@/components/ui/popover'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
@@ -95,6 +97,7 @@ import {
getDefaultAdvancedCustomIncomingPath,
isAdvancedCustomIncomingPathAllowed,
isAdvancedCustomManagementPath,
isAdvancedCustomPassThroughAllowed,
normalizeAdvancedCustomConfig,
parseAdvancedCustomRouteModels,
parseAdvancedCustomConfig,
@@ -123,9 +126,11 @@ 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-[minmax(9rem,0.9fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_minmax(0,0.85fr)_7rem]'
'lg:grid-cols-[minmax(9rem,0.9fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_minmax(0,0.85fr)_5.5rem_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 passThroughDescriptionKey =
'Send the original request body to upstream without conversion. Only available for native forwarding routes.'
const catchAllOrderErrorMessage =
'Catch-all route must be last for the same incoming path'
const emptyAdvancedRoutes: AdvancedCustomRoute[] = []
@@ -192,20 +197,33 @@ function getRouteConverters(
export function RouteModeBadges(props: { routes: AdvancedCustomRoute[] }) {
const { t } = useTranslation()
return getRouteConverters(props.routes).map((item) => (
<Badge
key={item.converter}
variant={item.converter === 'none' ? 'secondary' : 'outline'}
className='max-w-full'
>
{item.converter === 'none' ? (
<ArrowRight aria-hidden='true' />
) : (
<Shuffle aria-hidden='true' />
)}
<span className='truncate'>{t(item.label)}</span>
</Badge>
))
const hasPassThrough = props.routes.some(
(route) => route.pass_through_body_enabled === true
)
return (
<>
{getRouteConverters(props.routes).map((item) => (
<Badge
key={item.converter}
variant={item.converter === 'none' ? 'secondary' : 'outline'}
className='max-w-full'
>
{item.converter === 'none' ? (
<ArrowRight aria-hidden='true' />
) : (
<Shuffle aria-hidden='true' />
)}
<span className='truncate'>{t(item.label)}</span>
</Badge>
))}
{hasPassThrough ? (
<Badge variant='outline' className='max-w-full'>
<Send aria-hidden='true' />
<span className='truncate'>{t('Pass-through')}</span>
</Badge>
) : null}
</>
)
}
function buildRouteGroups(
@@ -1180,7 +1198,20 @@ onValueChange={onIncomingPathChange}
className='h-9 max-w-full lg:max-w-[420px]'
/>
</div>
</div>
) : null}
<div className={cn('px-3 py-2', !hideHeader && 'border-t')}>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<p className='text-muted-foreground text-xs leading-relaxed'>
{isModelListGroup
? t(
'This route discovers upstream OpenAI models and cannot be split or matched by client model rules.'
)
: t(
'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.'
)}
</p>
{!isModelListGroup ? (
<Button
type='button'
@@ -1188,23 +1219,11 @@ className='h-9 max-w-full lg:max-w-[420px]'
size='sm'
onClick={onAddRoute}
>
<Plus data-icon='inline-start' />
<Plus data-icon='inline-start' aria-hidden='true' />
{t('Add split')}
</Button>
) : null}
</div>
) : null}
<div className={cn('px-3 py-2', !hideHeader && 'border-t')}>
<p className='text-muted-foreground text-xs leading-relaxed'>
{isModelListGroup
? t(
'This route discovers upstream OpenAI models and cannot be split or matched by client model rules.'
)
: 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
@@ -1229,6 +1248,10 @@ className='h-9 max-w-full lg:max-w-[420px]'
<span>{t('Upstream path')}</span>
<span>{t('Converter')}</span>
<span>{t('Auth')}</span>
<span className='inline-flex items-center gap-1'>
{t('Pass-through')}
<PassThroughHelpPopover />
</span>
<span className='text-right'>{t('Actions')}</span>
</div>
@@ -1308,6 +1331,10 @@ function RouteEditor({
const modelsInputValue = route.models?.join(', ') || ''
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
const isFallback = !isModelListRoute && parsedRouteModels.length === 0
const passThroughAllowed =
!isModelListRoute && isAdvancedCustomPassThroughAllowed(converter)
const passThroughEnabled =
passThroughAllowed && route.pass_through_body_enabled === true
const setConverter = (nextConverter: AdvancedCustomConverter) => {
let nextIncomingPath = incomingPath
@@ -1323,6 +1350,11 @@ function RouteEditor({
incoming_path: nextIncomingPath,
upstream_path: defaults.upstream_path,
auth: defaults.auth,
pass_through_body_enabled: isAdvancedCustomPassThroughAllowed(
nextConverter
)
? route.pass_through_body_enabled
: false,
})
}
@@ -1560,6 +1592,31 @@ function RouteEditor({
</Select>
</FieldBlock>
<FieldBlock
label={
<span className='inline-flex items-center gap-1'>
{t('Pass-through')}
<PassThroughHelpPopover />
</span>
}
className='lg:gap-1'
labelClassName='lg:sr-only'
>
<div className='flex h-9 items-center lg:h-8'>
<Switch
checked={passThroughEnabled}
disabled={!passThroughAllowed}
aria-label={t('Pass-through')}
onCheckedChange={(checked) =>
onChange({ pass_through_body_enabled: checked })
}
/>
</div>
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
{t(passThroughDescriptionKey)}
</p>
</FieldBlock>
<div className='hidden items-center justify-end gap-1 lg:flex'>
<TooltipIconButton
label={t('Move route up')}
@@ -1631,6 +1688,7 @@ function RouteEditor({
<span className='hidden lg:block' aria-hidden='true' />
<span className='hidden lg:block' aria-hidden='true' />
<span className='hidden lg:block' aria-hidden='true' />
<span className='hidden lg:block' aria-hidden='true' />
</div>
</>
) : null}
@@ -1638,6 +1696,41 @@ function RouteEditor({
)
}
function PassThroughHelpPopover() {
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('Pass-through help')}
/>
}
>
<Info className='size-3.5' aria-hidden='true' />
</PopoverTrigger>
<PopoverContent
align='start'
side='bottom'
sideOffset={8}
className='w-[min(22rem,calc(100vw-2rem))] p-3'
>
<PopoverHeader className='gap-1'>
<PopoverTitle>{t('Pass-through')}</PopoverTitle>
<PopoverDescription className='text-xs leading-relaxed'>
{t(passThroughDescriptionKey)}
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
)
}
function ModelRuleHelpPopover() {
const { t } = useTranslation()
@@ -32,6 +32,8 @@ import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { createServerError } from '@/lib/server-error-message'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
@@ -71,13 +73,11 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
try {
const res = await getCodexUsage(row.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
throw createServerError(res, t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
handleServerError(error, t('Failed to fetch usage'))
} finally {
setIsQuerying(false)
}
@@ -119,12 +119,10 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
} else if (response.success && response.raw_response !== undefined) {
setRawResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to query balance'))
handleServerError(response, t('Failed to query balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to query balance')
)
handleServerError(error, t('Failed to query balance'))
} finally {
setIsQuerying(false)
}
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import { useQueryClient } from '@tanstack/react-query'
import type {
ColumnDef,
@@ -61,9 +60,9 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Combobox } from '@/components/ui/combobox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Sheet,
SheetContent,
@@ -80,6 +79,7 @@ import {
} from '@/components/ui/tooltip'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { handleServerError } from '@/lib/handle-server-error'
import { updateChannel } from '../../api'
import {
@@ -193,7 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
]
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
'embeddings',
'image-generation',
@@ -794,14 +793,10 @@ function ChannelTestDialogContent({
refreshChannelLists()
setIsDeleteFailedDialogOpen(false)
} else {
toast.error(response.message || t('Failed to delete failed models'))
handleServerError(response, t('Failed to delete failed models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error
? error.message
: t('Failed to delete failed models')
)
handleServerError(error, t('Failed to delete failed models'))
} finally {
setIsDeletingFailed(false)
}
@@ -988,13 +983,13 @@ function ChannelTestDialogContent({
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Combobox
options={endpointSelectItems}
value={endpointType}
onValueChange={handleEndpointTypeChange}
id='endpoint-type'
className='w-full min-w-0'
placeholder={t('Auto detect (default)')}
/>
options={endpointSelectItems}
value={endpointType}
onValueChange={handleEndpointTypeChange}
id='endpoint-type'
className='w-full min-w-0'
placeholder={t('Auto detect (default)')}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
@@ -76,6 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import dayjs from '@/lib/dayjs'
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import {
@@ -983,8 +984,9 @@ export function CodexUsageDialog({
try {
const res = await getCodexResetCredits(channelId)
if (!res.success) {
throw new Error(
res.message || t('Failed to fetch reset credit details')
throw createServerError(
res,
t('Failed to fetch reset credit details')
)
}
setResetCreditsResponse(res)
@@ -1034,7 +1036,7 @@ export function CodexUsageDialog({
try {
const res = await resetCodexUsage(channelId)
if (!res.success) {
throw new Error(res.message || t('Failed to reset usage'))
throw createServerError(res, t('Failed to reset usage'))
}
const resetPayload = res.data as
@@ -0,0 +1,160 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
import type { TaskPluginOption } from '../../api'
import { UpstreamModelSelection } from '../upstream-model-selection'
type ConfigureModelsDialogProps = {
open: boolean
models: string[]
plugins?: TaskPluginOption[]
initialPluginKey?: string
onOpenChange: (open: boolean) => void
onApply: (models: string[]) => void
/** Called after the selection is applied when the user starts a redirect for a model. */
onRedirect?: (model: string) => void
}
export function ConfigureModelsDialog(props: ConfigureModelsDialogProps) {
const { t } = useTranslation()
// Mounted for each opening. Keep unchecked candidates available until close.
const [initialModels] = useState(props.models)
const [selected, setSelected] = useState(initialModels)
const [source, setSource] = useState(
props.initialPluginKey ? `plugin:${props.initialPluginKey}` : 'all'
)
const plugins =
props.plugins?.filter((plugin) => plugin.models.length > 0) ?? []
const plugin = plugins.find((item) => `plugin:${item.key}` === source)
const models = [
...new Set(
plugin
? plugin.models
: [...initialModels, ...plugins.flatMap((item) => item.models)]
),
]
const onRedirect = props.onRedirect
// Redirecting keeps the current picks and makes sure the redirected model is
// published, otherwise the alias could never be called.
const handleRedirect = onRedirect
? (model: string) => {
props.onApply(
selected.includes(model) ? selected : [...selected, model]
)
props.onOpenChange(false)
onRedirect(model)
}
: undefined
const selection = (
<UpstreamModelSelection
key={plugin?.key ?? 'all'}
models={models}
selected={selected}
existingModels={initialModels}
onChange={setSelected}
onRedirectModel={handleRedirect}
showChanges={false}
summaryText={
plugins.length > 0
? t('Selected {{selected}} / {{total}}', {
selected: models.filter((model) => selected.includes(model))
.length,
total: models.length,
})
: t('Current models: {{count}}', { count: models.length })
}
/>
)
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Configure Models')}
description={
plugins.length > 0
? t('Select models and apply to channel models list.')
: t('Select the models to keep in this channel.')
}
contentClassName='sm:max-w-3xl'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={() => {
props.onApply(selected)
props.onOpenChange(false)
}}
>
{t('Apply')}
</Button>
</>
}
>
{plugins.length > 0 ? (
<Tabs
value={plugin ? `plugin:${plugin.key}` : 'all'}
onValueChange={(value) => setSource(String(value))}
className='min-w-0 gap-3'
>
<div className='max-w-full overflow-x-auto'>
<TabsList
variant='line'
aria-label={t('Models')}
className='min-w-max'
>
<TabsTrigger value='all'>{t('All')}</TabsTrigger>
{plugins.map((item) => (
<TabsTrigger
key={item.key}
value={`plugin:${item.key}`}
title={item.name}
>
<span aria-hidden='true'>
<PluginIcon plugin={item} size={16} />
</span>
<span className='max-w-40 truncate'>{item.name}</span>
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value={plugin ? `plugin:${plugin.key}` : 'all'}>
{selection}
</TabsContent>
</Tabs>
) : (
selection
)}
</Dialog>
)
}
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
@@ -28,11 +27,13 @@ import { GroupBadge } from '@/components/group-badge'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { handleServerError } from '@/lib/handle-server-error'
import { requireServerSuccess } from '@/lib/server-error-message'
import {
editTagChannels,
@@ -65,21 +66,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
// Fetch tag models
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
queryKey: ['tag-models', currentTag],
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
queryFn: async () =>
requireServerSuccess(
await (currentTag ? getTagModels(currentTag) : null)
),
enabled: open && !!currentTag,
})
// Fetch all available models
const { data: allModelsData } = useQuery({
queryKey: ['all-models'],
queryFn: getAllModels,
queryFn: async () => requireServerSuccess(await getAllModels()),
enabled: open,
})
// Fetch groups
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
queryFn: async () => requireServerSuccess(await getGroups()),
enabled: open,
})
@@ -193,12 +197,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update tag'))
handleServerError(response, t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
handleServerError(error, t('Failed to update tag'))
} finally {
setIsSubmitting(false)
}
@@ -298,14 +300,19 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<div className='flex gap-2'>
<Combobox
options={availableModels.map((model) => ({ value: model, label: model }))}
onValueChange={(value: string | null) => {
if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value])
}}
className='flex-1'
placeholder={t('Add from available models...')}
aria-label={t('Add from available models...')}
/>
options={availableModels.map((model) => ({
value: model,
label: model,
}))}
onValueChange={(value: string | null) => {
if (value !== null && !selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
className='flex-1'
placeholder={t('Add from available models...')}
aria-label={t('Add from available models...')}
/>
</div>
<div className='flex gap-2'>
@@ -17,37 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { handleServerError } from '@/lib/handle-server-error'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
categorizeModels,
categorizeModelsWithRedirect,
channelsQueryKeys,
normalizeModelName,
parseModelsString,
} from '../../lib'
import { useChannels } from '../channels-provider'
import { UpstreamModelSelection } from '../upstream-model-selection'
function normalizeModelNameList(models: readonly string[]): string[] {
return [...new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))]
@@ -91,8 +77,8 @@ export function FetchModelsDialog({
const [isFetching, setIsFetching] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [candidateModels, setCandidateModels] = useState<string[]>([])
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
// Parse existing models
const existingModels = useMemo(
@@ -101,37 +87,17 @@ export function FetchModelsDialog({
[existingModelsOverride, activeChannel?.models]
)
// Categorize models with redirect models
const modelCategories = useMemo(
() => categorizeModelsWithRedirect(existingModels, redirectModels),
[existingModels, redirectModels]
const fetchedModelSet = new Set(normalizeModelNameList(fetchedModels))
const redirectSourceSet = new Set(
normalizeModelNameList(redirectSourceModels)
)
const { classificationSet, redirectOnlySet } = modelCategories
const fetchedModelSet = useMemo(
() => new Set(normalizeModelNameList(fetchedModels)),
[fetchedModels]
const hasUnlistedModels = normalizeModelNameList([
...candidateModels,
...selectedModels,
]).some(
(model) => !fetchedModelSet.has(model) && !redirectSourceSet.has(model)
)
// Source keys in model_mapping are aliases, not real upstream IDs, so we
// must skip them when computing "removed upstream" entries to avoid false
// positives.
const redirectSourceKeysSet = useMemo(
() => new Set(normalizeModelNameList(redirectSourceModels)),
[redirectSourceModels]
)
const removedModels = useMemo(() => {
const kw = searchKeyword.toLowerCase().trim()
return normalizeModelNameList(selectedModels).filter((model) => {
if (fetchedModelSet.has(model)) return false
if (redirectSourceKeysSet.has(model)) return false
if (!kw) return true
return model.toLowerCase().includes(kw)
})
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
useEffect(() => {
if (open && (activeChannel || customFetcher)) {
handleFetchModels()
@@ -147,6 +113,7 @@ export function FetchModelsDialog({
if (customFetcher) {
const list = await customFetcher()
setFetchedModels(list)
setCandidateModels(existingModels)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else if (activeChannel) {
@@ -154,17 +121,16 @@ export function FetchModelsDialog({
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
setCandidateModels(existingModels)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
handleServerError(response, t('Failed to fetch models'))
setFetchedModels([])
}
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch models')
)
handleServerError(error, t('Failed to fetch models'))
setFetchedModels([])
} finally {
setIsFetching(false)
@@ -193,12 +159,10 @@ export function FetchModelsDialog({
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update models'))
handleServerError(response, t('Failed to update models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update models')
)
handleServerError(error, t('Failed to update models'))
} finally {
setIsSaving(false)
}
@@ -206,146 +170,15 @@ export function FetchModelsDialog({
const handleClose = () => {
setFetchedModels([])
setCandidateModels([])
setSelectedModels([])
setSearchKeyword('')
onOpenChange(false)
}
// Filter models by search
const filteredModels = useMemo(() => {
if (!searchKeyword) return fetchedModels
return fetchedModels.filter((model) =>
model.toLowerCase().includes(searchKeyword.toLowerCase())
)
}, [fetchedModels, searchKeyword])
const {
newModels,
existingFilteredModels,
newModelsByCategory,
existingModelsByCategory,
} = useMemo(() => {
const newModels: string[] = []
const existingFilteredModels: string[] = []
for (const model of filteredModels) {
if (classificationSet.has(normalizeModelName(model))) {
existingFilteredModels.push(model)
} else {
newModels.push(model)
}
}
return {
newModels,
existingFilteredModels,
newModelsByCategory: categorizeModels(newModels),
existingModelsByCategory: categorizeModels(existingFilteredModels),
}
}, [classificationSet, filteredModels])
// 厂商分类按 a-z 排序,Other 放最后,便于查找
const getSortedCategoryEntries = (
categories: Record<string, string[]>
): [string, string[]][] =>
Object.entries(categories).sort(([a], [b]) => {
if (a === 'Other') return 1
if (b === 'Other') return -1
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
const toggleModel = (model: string) => {
setSelectedModels((prev) =>
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
)
}
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
setSelectedModels((prev) => {
if (isChecked) {
const newSelected = [...prev]
categoryModels.forEach((model) => {
if (!newSelected.includes(model)) {
newSelected.push(model)
}
})
return newSelected
} else {
return prev.filter((m) => !categoryModels.includes(m))
}
})
}
const isCategorySelected = (categoryModels: string[]) => {
return categoryModels.every((m) => selectedModels.includes(m))
}
const renderModelCategory = (
categoryName: string,
categoryModels: string[]
) => {
const allSelected = isCategorySelected(categoryModels)
return (
<Collapsible key={categoryName} defaultOpen>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
<div className='flex items-center gap-2'>
<ChevronDown className='h-4 w-4' />
<span className='font-medium'>
{categoryName} ({categoryModels.length})
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
/ {categoryModels.length} selected
</span>
<Checkbox
checked={allSelected}
onCheckedChange={(checked) =>
toggleCategory(categoryModels, !!checked)
}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className='px-4 py-2'>
<div className='grid grid-cols-2 gap-2'>
{categoryModels.map((model) => (
<div key={model} className='flex items-center space-x-2'>
<Checkbox
id={model}
checked={selectedModels.includes(model)}
onCheckedChange={() => toggleModel(model)}
/>
<Label
htmlFor={model}
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
/>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
</Label>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
(fetchedModels.length > 0 || hasUnlistedModels)
let dialogDescription: ReactNode = t('Fetch available models from upstream')
if (activeChannel) {
@@ -362,13 +195,6 @@ export function FetchModelsDialog({
)
}
let defaultTab = 'existing'
if (newModels.length > 0) {
defaultTab = 'new'
} else if (removedModels.length > 0) {
defaultTab = 'removed'
}
let dialogBody: ReactNode
if (!activeChannel && !customFetcher) {
dialogBody = (
@@ -382,7 +208,7 @@ export function FetchModelsDialog({
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
)
} else if (fetchedModels.length === 0 && removedModels.length === 0) {
} else if (fetchedModels.length === 0 && !hasUnlistedModels) {
dialogBody = (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
@@ -397,84 +223,14 @@ export function FetchModelsDialog({
)
} else {
dialogBody = (
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={defaultTab}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
<UpstreamModelSelection
models={fetchedModels}
selected={selectedModels}
onChange={setSelectedModels}
existingModels={existingModels}
redirectModels={redirectModels}
redirectSourceModels={redirectSourceModels}
/>
)
}
@@ -0,0 +1,549 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { toIntlLocale } from '@/i18n/languages'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getServerErrorMessage } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import { getInferenceStatus } from '../../api'
import {
metricMean,
metricValue,
metricRatio,
recentMetrics,
type InferenceStatus,
} from '../../lib/inference-status'
type MetricRow = {
label: string
details?: string
value: number | undefined
unit?: 'percent' | 'seconds' | 'rate'
}
type InferenceStatusDialogProps = {
provider: 'vllm' | 'sglang'
channelId: number
channelName: string
onClose: () => void
onSyncModels: () => void
onTestChannel: () => void
}
// Mounted only while this channel's status dialog is open; unmount aborts the query.
export function InferenceStatusDialog(props: InferenceStatusDialogProps) {
const { t, i18n } = useTranslation()
const isSGLang = props.provider === 'sglang'
const [autoRefresh, setAutoRefresh] = useState(true)
const autoRefreshId = useId()
const previous = useRef<
| { channelId: number; provider: string; snapshot: InferenceStatus }
| undefined
>(undefined)
const user = useAuthStore((state) => state.auth.user)
const canOperate = hasPermission(
user,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.OPERATE
)
const canSync =
canOperate &&
hasPermission(
user,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.WRITE
)
const query = useQuery({
queryKey: ['channels', props.channelId, props.provider, 'inference-status'],
queryFn: async ({ signal }) => {
const snapshot = await getInferenceStatus(
props.channelId,
props.provider,
signal
)
signal.throwIfAborted()
const last =
previous.current?.channelId === props.channelId &&
previous.current.provider === props.provider
? previous.current.snapshot
: undefined
const recent = recentMetrics(snapshot, last)
previous.current = {
channelId: props.channelId,
provider: props.provider,
snapshot,
}
return { snapshot, recent }
},
refetchInterval: autoRefresh ? 5000 : false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
retry: false,
gcTime: 0,
meta: { errorToast: false },
})
const snapshot = query.data?.snapshot
const metrics = snapshot?.metrics ?? []
const recent = query.data?.recent
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const number = new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
})
const percent = new Intl.NumberFormat(locale, {
style: 'percent',
maximumFractionDigits: 2,
})
const seconds = new Intl.NumberFormat(locale, {
style: 'unit',
unit: 'second',
maximumFractionDigits: 2,
})
const groups: { title: string; hint?: string; rows: MetricRow[] }[] = [
{
title: t('Current load'),
rows: [
{
label: t('Running requests'),
value: metricValue(
metrics,
isSGLang ? 'sglang:num_running_reqs' : 'vllm:num_requests_running'
),
},
{
label: t('Waiting requests'),
value: metricValue(
metrics,
isSGLang ? 'sglang:num_queue_reqs' : 'vllm:num_requests_waiting'
),
},
{
label: isSGLang
? t('Peak token pool usage')
: t('Peak KV cache usage'),
value: isSGLang
? metricValue(metrics, 'sglang:token_usage', {}, 'max')
: (metricValue(metrics, 'vllm:kv_cache_usage_perc', {}, 'max') ??
metricValue(metrics, 'vllm:gpu_cache_usage_perc', {}, 'max')),
unit: 'percent',
},
...(isSGLang
? [
{
label: t('Output token rate'),
value: metricValue(metrics, 'sglang:gen_throughput'),
unit: 'rate' as const,
},
]
: [
{
label: t('Awake engines'),
value: metricValue(metrics, 'vllm:engine_sleep_state', {
sleep_state: 'awake',
}),
},
]),
],
},
{
title: t('Cumulative usage'),
hint: t(
'Counters and averages cover the period since the upstream metrics were initialized or reset.'
),
rows: [
{
label: isSGLang ? t('Processed requests') : t('Finished requests'),
value: metricValue(
metrics,
isSGLang
? 'sglang:num_requests_total'
: 'vllm:request_success_total'
),
},
{
label: t('Input tokens'),
value: metricValue(metrics, `${props.provider}:prompt_tokens_total`),
},
{
label: t('Output tokens'),
value: metricValue(
metrics,
`${props.provider}:generation_tokens_total`
),
},
...(isSGLang
? [
{
label: t('Cached tokens'),
value: metricValue(metrics, 'sglang:cached_tokens_total'),
},
]
: [
{
label: t('Prefix cache hit rate'),
value: metricRatio(
metricValue(metrics, 'vllm:prefix_cache_hits_total'),
metricValue(metrics, 'vllm:prefix_cache_queries_total')
),
unit: 'percent' as const,
},
{
label: t('Speculative decoding acceptance rate'),
value: metricRatio(
metricValue(
metrics,
'vllm:spec_decode_num_accepted_tokens_total'
),
metricValue(
metrics,
'vllm:spec_decode_num_draft_tokens_total'
)
),
unit: 'percent' as const,
},
]),
{
label: t('Mean time to first token'),
value: metricMean(
metrics,
`${props.provider}:time_to_first_token_seconds`
),
unit: 'seconds',
},
{
label: t('Mean request latency'),
value: metricMean(
metrics,
`${props.provider}:e2e_request_latency_seconds`
),
unit: 'seconds',
},
{
label: t('Mean queue time'),
value: metricMean(
metrics,
isSGLang
? 'sglang:queue_time_seconds'
: 'vllm:request_queue_time_seconds'
),
unit: 'seconds',
},
{
label: t('Mean time per output token'),
value: isSGLang
? (metricMean(metrics, 'sglang:inter_token_latency_seconds') ??
metricMean(metrics, 'sglang:time_per_output_token_seconds'))
: metricMean(metrics, 'vllm:request_time_per_output_token_seconds'),
unit: 'seconds',
},
],
},
{
title: t('Since previous sample'),
hint: recent
? t('Sample interval: {{seconds}} seconds', {
seconds: number.format(recent.seconds),
})
: t(
'Waiting for two comparable samples. Counter resets restart the sampling window.'
),
rows: [
{
label: t('Input token rate'),
value: metricRatio(
metricValue(
recent?.metrics ?? [],
`${props.provider}:prompt_tokens_total`
),
recent?.seconds
),
unit: 'rate',
},
{
label: t('Output token rate'),
value: metricRatio(
metricValue(
recent?.metrics ?? [],
`${props.provider}:generation_tokens_total`
),
recent?.seconds
),
unit: 'rate',
},
{
label: t('Mean time to first token'),
value: metricMean(
recent?.metrics ?? [],
`${props.provider}:time_to_first_token_seconds`
),
unit: 'seconds',
},
{
label: t('Mean request latency'),
value: metricMean(
recent?.metrics ?? [],
`${props.provider}:e2e_request_latency_seconds`
),
unit: 'seconds',
},
],
},
]
if (isSGLang) {
// These SGLang values are per-worker gauges, not cumulative counters.
// Keep each labelled series rather than adding or averaging percentages.
const workerMetrics = metrics.filter((metric) =>
['sglang:cache_hit_rate', 'sglang:spec_accept_rate'].includes(metric.name)
)
if (workerMetrics.length) {
groups.push({
title: t('Worker metrics'),
rows: workerMetrics.map((metric) => ({
label:
metric.name === 'sglang:cache_hit_rate'
? t('Prefix cache hit rate')
: t('Speculative decoding acceptance rate'),
details: JSON.stringify(metric.labels),
value: metric.value,
unit: 'percent',
})),
})
}
}
const handleExport = () => {
if (!snapshot) return
const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${props.provider}-${props.channelId}-${snapshot.sampled_at}.json`
link.click()
URL.revokeObjectURL(url)
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) props.onClose()
}}
title={isSGLang ? t('SGLang status') : t('vLLM status')}
description={props.channelName}
contentClassName='sm:max-w-3xl'
bodyClassName='space-y-5'
footer={
<Button variant='outline' onClick={props.onClose}>
{t('Close')}
</Button>
}
>
<div className='flex flex-wrap items-center gap-2'>
<Button
variant='outline'
disabled={query.isFetching}
onClick={() => void query.refetch()}
>
{t('Refresh')}
</Button>
<Button
variant='outline'
disabled={!canSync}
onClick={props.onSyncModels}
>
{t('Sync models')}
</Button>
<Button
variant='outline'
disabled={!canOperate}
onClick={props.onTestChannel}
>
{t('Test Connection')}
</Button>
<Button variant='outline' disabled={!snapshot} onClick={handleExport}>
{t('Export snapshot')}
</Button>
<div className='flex items-center gap-2'>
<Switch
id={autoRefreshId}
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label htmlFor={autoRefreshId}>{t('Auto refresh (5s)')}</Label>
</div>
</div>
{isSGLang && snapshot?.endpoints['/metrics']?.error && (
<Alert>
<AlertDescription>
{t(
'Start SGLang with --enable-metrics to expose Prometheus metrics.'
)}
</AlertDescription>
</Alert>
)}
{query.isPending && <LoadingState />}
{query.isError && !snapshot && (
<ErrorState
title={
isSGLang
? t('Failed to load SGLang status')
: t('Failed to load vLLM status')
}
description={getServerErrorMessage(query.error)}
onRetry={() => void query.refetch()}
/>
)}
{query.isError && snapshot && (
<Alert variant='destructive'>
<AlertDescription>
{t('Refresh failed. Showing the last successful snapshot.')}
</AlertDescription>
</Alert>
)}
{snapshot && (
<>
<div className='flex flex-wrap gap-x-6 gap-y-2 text-sm'>
<span>
{t('Version')}:{' '}
<strong className='break-all'>
{snapshot.version || t('Unavailable')}
</strong>
</span>
<span>
{t('Last updated')}:{' '}
{new Date(snapshot.sampled_at).toLocaleString(locale)}
</span>
</div>
<dl className='grid grid-cols-2 gap-2 text-sm sm:grid-cols-4'>
{Object.entries(snapshot.endpoints).map(([path, endpoint]) => (
<div key={path} className='rounded-md border p-2'>
<dt className='font-mono text-xs'>{path}</dt>
<dd
className={
endpoint.error ? 'text-destructive' : 'text-foreground'
}
>
{endpoint.error ? t('Unavailable') : t('Available')}
{endpoint.status > 0 && ` · HTTP ${endpoint.status}`}
{endpoint.error === 'invalid_response' &&
` · ${t('Invalid response')}`}
{endpoint.error === 'response_too_large' &&
` · ${t('Response too large')}`}
</dd>
</div>
))}
</dl>
<section className='space-y-2' aria-label={t('Models')}>
<h3 className='font-medium'>{t('Models')}</h3>
{snapshot.models.length === 0 && (
<p className='text-muted-foreground text-sm'>
{snapshot.endpoints['/v1/models']?.error
? t('Unavailable')
: t('No models found')}
</p>
)}
{snapshot.models.map((model) => (
<div
key={model.id}
className='space-y-1 rounded-md border p-3 text-sm'
>
<p className='font-medium break-all'>{model.id}</p>
<p className='text-muted-foreground break-all'>
{t('Model source')}: {model.root || t('Unavailable')}
</p>
<p>
{t('Max context tokens')}:{' '}
{model.max_model_len == null
? t('Unavailable')
: number.format(model.max_model_len)}
</p>
</div>
))}
</section>
{groups.map((group) => (
<section
key={group.title}
className='space-y-2'
aria-label={group.title}
>
<h3 className='font-medium'>{group.title}</h3>
{group.hint && (
<p className='text-muted-foreground text-xs'>{group.hint}</p>
)}
<dl className='grid gap-2 sm:grid-cols-2'>
{group.rows.map((row) => {
let value = t('Unavailable')
if (row.value !== undefined) {
value = number.format(row.value)
if (row.unit === 'percent') {
value = percent.format(row.value)
}
if (row.unit === 'seconds') {
value = seconds.format(row.value)
}
if (row.unit === 'rate') {
value = t('{{value}} tokens/s', {
value: number.format(row.value),
})
}
}
return (
<div
key={`${row.label}:${row.details ?? ''}`}
className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-1 rounded-md border p-3 text-sm'
>
<dt className='text-muted-foreground break-words'>
{row.label}
</dt>
<dd className='font-medium whitespace-nowrap tabular-nums'>
{value}
</dd>
{row.details && (
<dd className='text-muted-foreground col-span-2 break-all text-xs'>
{row.details}
</dd>
)}
</div>
)
})}
</dl>
</section>
))}
</>
)}
</Dialog>
)
}
@@ -41,6 +41,7 @@ import {
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { handleServerError } from '@/lib/handle-server-error'
import { useAuthStore } from '@/stores/auth-store'
import {
@@ -137,19 +138,17 @@ export function MultiKeyManageDialog({
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
} else {
toast.error(response.message || t('Failed to load key status'))
handleServerError(response, t('Failed to load key status'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to load key status')
)
handleServerError(error, t('Failed to load key status'))
} finally {
setIsLoading(false)
}
}
const handleStatusFilterChange = (value: string) => {
const newFilter = value === 'all' ? null : parseInt(value)
const newFilter = value === 'all' ? null : Number.parseInt(value)
setStatusFilter(newFilter)
setCurrentPage(1)
loadKeyStatus(1, pageSize, newFilter)
@@ -204,12 +203,10 @@ export function MultiKeyManageDialog({
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || t('Operation failed'))
handleServerError(response, t('Operation failed'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Operation failed')
)
handleServerError(error, t('Operation failed'))
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
@@ -294,12 +291,10 @@ export function MultiKeyManageDialog({
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
items={MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}))}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
@@ -378,15 +373,17 @@ export function MultiKeyManageDialog({
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
{isLoading && (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
)}
{!isLoading && keys.length === 0 && (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
)}
{!isLoading && !(keys.length === 0) && (
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
@@ -40,6 +40,7 @@ import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { getFreshAuthHeaders } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import {
deleteOllamaModel,
@@ -155,7 +156,7 @@ export function OllamaModelsDialog({
}
if (!normalized.length && lastErr) {
toast.error(lastErr || t('Failed to fetch models'))
handleServerError(new Error(lastErr), t('Failed to fetch models'))
}
setModels(normalized)
@@ -169,8 +170,7 @@ export function OllamaModelsDialog({
: normalized.map((m) => m.id)
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to fetch models'))
handleServerError(err, t('Failed to fetch models'))
setModels([])
} finally {
setIsFetching(false)
@@ -216,12 +216,10 @@ export function OllamaModelsDialog({
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} else {
toast.error(res.message || t('Failed to update models'))
handleServerError(res, t('Failed to update models'))
}
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : t('Failed to update models')
)
handleServerError(err, t('Failed to update models'))
}
}
@@ -293,7 +291,7 @@ export function OllamaModelsDialog({
if (data?.status) {
setPullProgress(data)
} else if (data?.error) {
toast.error(String(data.error))
handleServerError(data, String(data.error))
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
@@ -329,7 +327,7 @@ export function OllamaModelsDialog({
(err as { name?: unknown }).name === 'AbortError'
if (!isAbort) {
const msg = err instanceof Error ? err.message : ''
toast.error(t('Model pull failed: {{msg}}', { msg }))
handleServerError(err, t('Model pull failed: {{msg}}', { msg }))
}
setIsPulling(false)
setPullProgress(null)
@@ -352,11 +350,10 @@ export function OllamaModelsDialog({
setDeleteOpen(false)
setDeleteTarget(null)
} else {
toast.error(payload?.message || t('Failed to delete model'))
handleServerError(payload, t('Failed to delete model'))
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to delete model'))
handleServerError(err, t('Failed to delete model'))
} finally {
setIsDeleting(false)
}
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import {
ChevronDown,
ChevronUp,
@@ -46,9 +45,18 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Combobox } from '@/components/ui/combobox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
@@ -571,16 +579,21 @@ const getOperationSummary = (
}
const getModeTagTailwind = (mode: string): string => {
if (mode.includes('header'))
if (mode.includes('header')) {
return 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/20'
if (mode.includes('replace') || mode.includes('trim'))
}
if (mode.includes('replace') || mode.includes('trim')) {
return 'bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/20'
if (mode.includes('copy') || mode.includes('move'))
}
if (mode.includes('copy') || mode.includes('move')) {
return 'bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/20'
if (mode.includes('error') || mode.includes('prune'))
}
if (mode.includes('error') || mode.includes('prune')) {
return 'bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/20'
if (mode.includes('sync'))
}
if (mode.includes('sync')) {
return 'bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/20'
}
return 'bg-muted text-muted-foreground'
}
@@ -625,17 +638,20 @@ const getModeToPlaceholder = (mode: string): string => {
}
const getModeValueLabel = (mode: string): string => {
if (mode === 'set_header')
if (mode === 'set_header') {
return 'Header Value (supports string or JSON mapping)'
if (mode === 'pass_headers')
}
if (mode === 'pass_headers') {
return 'Pass-through Headers (comma-separated or JSON array)'
}
if (
mode === 'trim_prefix' ||
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
)
) {
return 'Prefix/Suffix Text'
}
if (mode === 'prune_objects') return 'Prune Rule (string or JSON object)'
return 'Value (supports JSON or plain text)'
}
@@ -648,8 +664,9 @@ const getModeValuePlaceholder = (mode: string): string => {
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
)
) {
return 'openai/'
}
if (mode === 'prune_objects') return '{"type":"redacted_thinking"}'
return '0.7'
}
@@ -785,8 +802,9 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
if (!raw) return defaults
try {
const parsed = JSON.parse(raw)
if (typeof parsed === 'string')
if (typeof parsed === 'string') {
return { ...defaults, typeText: parsed.trim() }
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const rules: PruneRule[] = []
if (
@@ -802,8 +820,9 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
}
if (Array.isArray(parsed.conditions)) {
for (const item of parsed.conditions) {
if (item && typeof item === 'object')
if (item && typeof item === 'object') {
rules.push(normalizePruneRule(item))
}
}
} else if (
parsed.conditions &&
@@ -862,31 +881,35 @@ const buildPruneObjectsValueText = (draft: PruneObjectsDraft): string => {
return conditionPayload
})
if (conditions.length > 0) payload.conditions = conditions
if (!payload.type && !payload.conditions)
if (!payload.type && !payload.conditions) {
return JSON.stringify({ logic: 'AND' })
}
return JSON.stringify(payload)
}
// pass_headers helpers
const parsePassHeaderNames = (rawValue: unknown): string[] => {
if (Array.isArray(rawValue))
if (Array.isArray(rawValue)) {
return rawValue.map((i) => String(i ?? '').trim()).filter(Boolean)
}
if (rawValue && typeof rawValue === 'object') {
const obj = rawValue as Record<string, unknown>
if (Array.isArray(obj.headers))
if (Array.isArray(obj.headers)) {
return obj.headers.map((i) => String(i ?? '').trim()).filter(Boolean)
}
if (obj.header !== undefined) {
const single = String(obj.header ?? '').trim()
return single ? [single] : []
}
return []
}
if (typeof rawValue === 'string')
if (typeof rawValue === 'string') {
return rawValue
.split(',')
.map((i) => i.trim())
.filter(Boolean)
}
return []
}
@@ -921,18 +944,22 @@ const validateOperations = (
const fromValue = op.from.trim()
const toValue = op.to.trim()
if (meta.path && !pathValue)
if (meta.path && !pathValue) {
return t('Rule {{line}} is missing target path', { line })
}
if (FROM_REQUIRED_MODES.has(mode) && !fromValue) {
if (!(meta.pathAlias && pathValue))
if (!(meta.pathAlias && pathValue)) {
return t('Rule {{line}} is missing source field', { line })
}
}
if (TO_REQUIRED_MODES.has(mode) && !toValue) {
if (!(meta.pathAlias && pathValue))
if (!(meta.pathAlias && pathValue)) {
return t('Rule {{line}} is missing target field', { line })
}
}
if (VALUE_REQUIRED_MODES.has(mode) && op.value_text.trim() === '')
if (VALUE_REQUIRED_MODES.has(mode) && op.value_text.trim() === '') {
return t('Rule {{line}} is missing value', { line })
}
if (mode === 'return_error') {
const raw = op.value_text.trim()
@@ -940,10 +967,13 @@ const validateOperations = (
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
if (!String((parsed as Record<string, unknown>).message || '').trim())
if (
!String((parsed as Record<string, unknown>).message || '').trim()
) {
return t('Rule {{line}} return_error requires a message field', {
line,
})
}
}
} catch {
/* plain string is allowed */
@@ -952,18 +982,21 @@ const validateOperations = (
if (mode === 'prune_objects') {
const raw = op.value_text.trim()
if (!raw)
if (!raw) {
return t('Rule {{line}} prune_objects is missing conditions', { line })
}
}
if (mode === 'pass_headers') {
const raw = op.value_text.trim()
if (!raw)
if (!raw) {
return t('Rule {{line}} pass_headers is missing header names', { line })
}
const parsed = parseLooseValue(raw)
const headers = parsePassHeaderNames(parsed)
if (headers.length === 0)
if (headers.length === 0) {
return t('Rule {{line}} pass_headers format is invalid', { line })
}
}
}
return ''
@@ -1209,14 +1242,16 @@ export function ParamOverrideEditorDialog(
)
const returnErrorDraft = useMemo(() => {
if (!selectedOperation || selectedOperation.mode !== 'return_error')
if (!selectedOperation || selectedOperation.mode !== 'return_error') {
return null
}
return parseReturnErrorDraft(selectedOperation.value_text)
}, [selectedOperation])
const pruneObjectsDraft = useMemo(() => {
if (!selectedOperation || selectedOperation.mode !== 'prune_objects')
if (!selectedOperation || selectedOperation.mode !== 'prune_objects') {
return null
}
return parsePruneObjectsDraft(selectedOperation.value_text)
}, [selectedOperation])
@@ -1473,11 +1508,13 @@ export function ParamOverrideEditorDialog(
if (visualMode === 'legacy') {
const trimmed = legacyValue.trim()
if (!trimmed) return ''
if (!verifyJSON(trimmed))
if (!verifyJSON(trimmed)) {
throw new Error(t('Parameter override must be valid JSON format'))
}
const parsed = JSON.parse(trimmed) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(t('Legacy format must be a JSON object'))
}
return JSON.stringify(parsed, null, 2)
}
return buildOperationsJson(operations, { validate: true }, t)
@@ -1573,7 +1610,7 @@ export function ParamOverrideEditorDialog(
}
parsedCurrent = JSON.parse(trimmed) as Record<string, unknown>
}
const merged = { ...(payload || {}), ...parsedCurrent }
const merged = { ...payload, ...parsedCurrent }
const text = JSON.stringify(merged, null, 2)
setVisualMode('legacy')
setLegacyValue(text)
@@ -1668,8 +1705,9 @@ export function ParamOverrideEditorDialog(
if (editMode === 'json') {
const trimmed = jsonText.trim()
if (trimmed) {
if (!verifyJSON(trimmed))
if (!verifyJSON(trimmed)) {
throw new Error(t('Parameter override must be valid JSON format'))
}
result = JSON.stringify(JSON.parse(trimmed), null, 2)
}
} else {
@@ -1758,18 +1796,16 @@ export function ParamOverrideEditorDialog(
{t('Template')}
</span>
<Combobox
options={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
options={templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
className='h-8 w-[220px]'
/>
className='h-8 w-[220px]'
/>
<Button
type='button'
variant='outline'
@@ -1798,8 +1834,8 @@ className='h-8 w-[220px]'
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
{editMode === 'visual' &&
(visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
@@ -2011,9 +2047,9 @@ className='h-8 w-[220px]'
)}
</div>
</div>
)
) : (
/* JSON mode */
))}
{/* JSON mode */}
{editMode !== 'visual' && (
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
@@ -2094,6 +2130,10 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
const { t } = useTranslation()
const operation = ruleEditorProps.operation
const mode = operation.mode || 'set'
const returnErrorDraft =
mode === 'return_error' ? ruleEditorProps.returnErrorDraft : null
const pruneObjectsDraft =
mode === 'prune_objects' ? ruleEditorProps.pruneObjectsDraft : null
const meta = MODE_META[mode] || MODE_META.set
const conditions = operation.conditions
const syncFromTarget =
@@ -2142,21 +2182,19 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<div className='space-y-1.5'>
<label className='text-xs font-medium'>{t('Operation Type')}</label>
<Combobox
options={[
...OPERATION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={mode}
onValueChange={(nextMode) =>
options={OPERATION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={mode}
onValueChange={(nextMode) =>
nextMode !== null &&
ruleEditorProps.updateOperation(operation.id, {
mode: nextMode,
})
}
className='h-9'
/>
className='h-9'
/>
</div>
{(meta.path || meta.pathOptional) && (
<div className='space-y-1.5'>
@@ -2210,62 +2248,67 @@ className='h-9'
</div>
{/* Value section */}
{meta.value &&
(mode === 'return_error' && ruleEditorProps.returnErrorDraft ? (
<ReturnErrorEditor
operationId={operation.id}
draft={ruleEditorProps.returnErrorDraft}
updateDraft={ruleEditorProps.updateReturnErrorDraft}
/>
) : mode === 'prune_objects' && ruleEditorProps.pruneObjectsDraft ? (
<PruneObjectsEditor
operationId={operation.id}
draft={ruleEditorProps.pruneObjectsDraft}
updateDraft={ruleEditorProps.updatePruneObjectsDraft}
addRule={ruleEditorProps.addPruneRule}
updateRule={ruleEditorProps.updatePruneRule}
removeRule={ruleEditorProps.removePruneRule}
/>
) : (
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='text-xs font-medium'>
{t(getModeValueLabel(mode))}
</label>
{operation.value_text.trim().startsWith('{') && (
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs'
onClick={() => {
try {
const parsed = JSON.parse(operation.value_text)
ruleEditorProps.updateOperation(operation.id, {
value_text: JSON.stringify(parsed, null, 2),
})
} catch (_e) {
/* not valid JSON */
}
}}
>
{t('Format')}
</Button>
)}
</div>
<Textarea
value={operation.value_text}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
value_text: e.target.value,
})
}
placeholder={getModeValuePlaceholder(mode)}
rows={3}
className='max-h-[200px] resize-y overflow-y-auto font-mono text-xs'
{meta.value && (
<>
{returnErrorDraft && (
<ReturnErrorEditor
operationId={operation.id}
draft={returnErrorDraft}
updateDraft={ruleEditorProps.updateReturnErrorDraft}
/>
</div>
))}
)}
{pruneObjectsDraft && (
<PruneObjectsEditor
operationId={operation.id}
draft={pruneObjectsDraft}
updateDraft={ruleEditorProps.updatePruneObjectsDraft}
addRule={ruleEditorProps.addPruneRule}
updateRule={ruleEditorProps.updatePruneRule}
removeRule={ruleEditorProps.removePruneRule}
/>
)}
{!returnErrorDraft && !pruneObjectsDraft && (
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='text-xs font-medium'>
{t(getModeValueLabel(mode))}
</label>
{operation.value_text.trim().startsWith('{') && (
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs'
onClick={() => {
try {
const parsed = JSON.parse(operation.value_text)
ruleEditorProps.updateOperation(operation.id, {
value_text: JSON.stringify(parsed, null, 2),
})
} catch {
/* not valid JSON */
}
}}
>
{t('Format')}
</Button>
)}
</div>
<Textarea
value={operation.value_text}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
value_text: e.target.value,
})
}
placeholder={getModeValuePlaceholder(mode)}
rows={3}
className='max-h-[200px] resize-y overflow-y-auto font-mono text-xs'
/>
</div>
)}
</>
)}
{/* keep_origin */}
{meta.keepOrigin && (
@@ -2285,14 +2328,15 @@ className='h-9'
)}
{/* sync_fields */}
{mode === 'sync_fields' && syncFromTarget && syncToTarget ? (
{mode === 'sync_fields' && syncFromTarget && syncToTarget && (
<SyncFieldsEditor
operationId={operation.id}
syncFromTarget={syncFromTarget}
syncToTarget={syncToTarget}
updateOperation={ruleEditorProps.updateOperation}
/>
) : (meta.from || meta.to !== undefined) && mode !== 'sync_fields' ? (
)}
{(meta.from || meta.to !== undefined) && mode !== 'sync_fields' && (
<div className='grid gap-3 sm:grid-cols-2'>
{(meta.from || meta.to === false) && (
<div className='space-y-1.5'>
@@ -2329,7 +2373,7 @@ className='h-9'
</div>
)}
</div>
) : null}
)}
{/* Conditions */}
<div className='rounded-lg border p-3'>
@@ -2519,14 +2563,12 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
{t('Match Mode')}
</label>
<Combobox
options={[
...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={condition.mode}
onValueChange={(v) =>
options={CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={condition.mode}
onValueChange={(v) =>
v !== null &&
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
@@ -2534,8 +2576,8 @@ onValueChange={(v) =>
{ mode: v }
)
}
className='h-8 text-xs'
/>
className='h-8 text-xs'
/>
</div>
<div className='space-y-1'>
<label className='text-[10px] font-medium'>
@@ -2647,9 +2689,9 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium'>
{t('Error Message (required)')}
</label>
<Label required className='text-xs font-medium'>
{t('Error Message')}
</Label>
<Textarea
value={draft.message}
onChange={(e) =>
@@ -2680,7 +2722,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
onChange={(e) =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ statusCode: parseInt(e.target.value, 10) || 400 }
{ statusCode: Number.parseInt(e.target.value, 10) || 400 }
)
}
placeholder='400'
@@ -3027,14 +3069,12 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
{t('Match Mode')}
</label>
<Combobox
options={[
...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={rule.mode}
onValueChange={(v) =>
options={CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={rule.mode}
onValueChange={(v) =>
v !== null &&
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
@@ -3042,8 +3082,8 @@ onValueChange={(v) =>
{ mode: v }
)
}
className='h-7 text-xs'
/>
className='h-7 text-xs'
/>
</div>
<div className='space-y-0.5'>
<label className='text-[10px] font-medium'>
@@ -3128,12 +3168,10 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
</label>
<div className='flex gap-2'>
<Select
items={[
...SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
items={SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={syncFieldsEditorProps.syncFromTarget.type || 'json'}
onValueChange={(v) =>
v !== null &&
@@ -3185,12 +3223,10 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
</label>
<div className='flex gap-2'>
<Select
items={[
...SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
items={SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
}))}
value={syncFieldsEditorProps.syncToTarget.type || 'json'}
onValueChange={(v) =>
v !== null &&
@@ -0,0 +1,106 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
export type PassthroughKind = 'body' | 'headers'
type PassthroughWarningDialogProps = {
open: boolean
/** Which passthrough option is about to be enabled. */
kind: PassthroughKind | null
onOpenChange: (open: boolean) => void
handleConfirm: () => void
}
/**
* Shown before a channel passthrough option is switched on. Passthrough
* bypasses most of the gateway's request processing, so the user must
* acknowledge which features stop working first.
*/
export function PassthroughWarningDialog(props: PassthroughWarningDialogProps) {
const { t } = useTranslation()
const isBody = props.kind !== 'headers'
const title = isBody
? t('Enable request body passthrough?')
: t('Enable request header passthrough?')
const useCase = isBody
? t(
'Enable when the client and upstream use the same API format and you need to preserve upstream-specific or new fields the gateway does not yet support. Usually leave this off.'
)
: t(
'Enable when the upstream needs information from clients such as Codex or Claude Code, including User-Agent, client version or session headers. Usually leave this off.'
)
const intro = isBody
? t(
'After enabling, the original request body is forwarded to the upstream without gateway rewriting:'
)
: t(
'After enabling, client request headers are forwarded to the upstream, excluding standard authentication headers, cookies and transport headers:'
)
const effects = isBody
? [
t('Model redirect is unavailable'),
t('Parameter override and system prompt are unavailable'),
t(
'Format conversion is unavailable; the client must use a format the upstream supports'
),
]
: [
t(
'Client information such as User-Agent and IP is exposed to the upstream'
),
t(
'Client headers override the gateway defaults and may cause upstream errors'
),
t('Entries configured in Request Header Override still take priority'),
]
return (
<ConfirmDialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<span className='text-destructive flex items-center gap-2'>
<AlertTriangle className='size-5 shrink-0' aria-hidden='true' />
{title}
</span>
}
desc={
<div className='space-y-3 text-sm'>
<p>{useCase}</p>
<p>{intro}</p>
<ul className='list-disc space-y-1 pl-5'>
{effects.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className='text-foreground font-medium'>
{t('Make sure you understand these effects before enabling.')}
</p>
</div>
}
confirmText={t('Enable passthrough')}
destructive
handleConfirm={props.handleConfirm}
/>
)
}
@@ -30,6 +30,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { handleServerError } from '@/lib/handle-server-error'
import { requireServerSuccess } from '@/lib/server-error-message'
import {
getTagModels,
@@ -66,14 +68,14 @@ export function TagBatchEditDialog({
// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
queryFn: async () => requireServerSuccess(await getGroups()),
})
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...groups])
return Array.from(allGroups).map((group) => ({
return [...allGroups].map((group) => ({
value: group,
label: group,
}))
@@ -93,12 +95,13 @@ export function TagBatchEditDialog({
try {
// Fetch current tag models
const tagModelsResponse = await getTagModels(currentTag)
requireServerSuccess(tagModelsResponse)
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
// Fetch all available models (for future use if needed)
const allModelsResponse = await getAllModels()
const allModelsResponse = requireServerSuccess(await getAllModels())
if (allModelsResponse.success && allModelsResponse.data) {
// Available models could be used for autocomplete in the future
}
@@ -106,9 +109,7 @@ export function TagBatchEditDialog({
// Initialize new tag with current tag name
setNewTag(currentTag)
} catch (_error: unknown) {
toast.error(
_error instanceof Error ? _error.message : t('Failed to load tag data')
)
handleServerError(_error, t('Failed to load tag data'))
} finally {
setIsLoading(false)
}
@@ -121,7 +122,7 @@ export function TagBatchEditDialog({
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch (_error) {
} catch {
toast.error(t('Model mapping must be valid JSON'))
return
}
@@ -163,12 +164,10 @@ export function TagBatchEditDialog({
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
handleClose()
} else {
toast.error(response.message || t('Failed to update tag'))
handleServerError(response, t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
handleServerError(error, t('Failed to update tag'))
} finally {
setIsSaving(false)
}
@@ -219,83 +218,79 @@ export function TagBatchEditDialog({
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t('Enter new tag name (leave empty to disband tag)')}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
</>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
)}
</Dialog>
)
@@ -0,0 +1,194 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Check, CircleAlert, CircleDashed } from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type {
ChannelConfigurationSection,
ChannelConfigurationStatus,
} from '../../lib/channel-configuration'
type ChannelConfigurationProps = {
section: ChannelConfigurationSection
onSectionChange: (section: ChannelConfigurationSection) => void
statuses: Record<ChannelConfigurationSection, ChannelConfigurationStatus>
/** Item counts shown on a tab; a count replaces the plain configured mark. */
badges?: Partial<
Record<ChannelConfigurationSection, { count: number; label: string }>
>
connection: ReactNode
models: ReactNode
quickOptions: ReactNode
routing: ReactNode
request: ReactNode
other: ReactNode
}
export function ChannelConfigurationStatusIndicator(props: {
status: ChannelConfigurationStatus
required?: boolean
}) {
const { t } = useTranslation()
if (props.status === 'idle' && !props.required) return null
let label = t('Incomplete')
let StatusIcon = CircleDashed
let iconColor = 'text-warning'
if (props.status === 'error') {
label = t('Error')
StatusIcon = CircleAlert
iconColor = 'text-destructive'
}
if (props.status === 'ready') {
label = t('Ready')
StatusIcon = Check
iconColor = 'text-success'
}
if (props.status === 'configured') {
label = t('Configured')
StatusIcon = Check
iconColor = 'text-success'
}
return (
<Tooltip>
<TooltipTrigger
render={
<span
role='img'
aria-label={label}
className={cn('inline-flex shrink-0 items-center', iconColor)}
/>
}
>
<StatusIcon className='size-3.5' aria-hidden='true' />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
export function ChannelConfiguration(props: ChannelConfigurationProps) {
const { t } = useTranslation()
const [visited, setVisited] = useState(() => new Set([props.section]))
useEffect(() => {
setVisited((previous) => {
if (previous.has(props.section)) return previous
return new Set([...previous, props.section])
})
}, [props.section])
const sections = [
{ id: 'connection', label: t('Connection & Models') },
{ id: 'routing', label: t('Routing & Mapping') },
{ id: 'request', label: t('Request & Response') },
{ id: 'other', label: t('Other Settings') },
] as const
return (
<Tabs
value={props.section}
onValueChange={(value) =>
props.onSectionChange(value as ChannelConfigurationSection)
}
className='min-h-0 flex-1 gap-5'
>
<div className='-mt-1 shrink-0 overflow-x-auto py-1'>
<TabsList
aria-label={t('Channel configuration')}
variant='line'
className='min-w-full justify-start'
>
{sections.map((section) => {
const status = props.statuses[section.id]
const badge = props.badges?.[section.id]
const showBadge = Boolean(badge && badge.count > 0)
return (
<TabsTrigger
key={section.id}
value={section.id}
className='gap-2 px-3'
>
{section.label}
{showBadge && badge && (
<Badge
variant='secondary'
aria-label={badge.label}
className='h-4 min-w-4 px-1 text-[10px] tabular-nums'
>
{badge.count}
</Badge>
)}
{!(showBadge && status === 'configured') && (
<ChannelConfigurationStatusIndicator
status={status}
required={section.id === 'connection'}
/>
)}
</TabsTrigger>
)
})}
</TabsList>
</div>
<TabsContent
value='connection'
keepMounted
className='-m-1 min-h-0 overflow-y-auto overscroll-contain p-1'
>
<div className='grid min-w-0 items-start gap-6 lg:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-5'>{props.connection}</div>
<div className='flex min-w-0 flex-col gap-5'>
{props.models}
{props.quickOptions}
</div>
</div>
</TabsContent>
<TabsContent
value='routing'
keepMounted
className='-m-1 min-h-0 space-y-5 overflow-y-auto overscroll-contain p-1'
>
{(props.section === 'routing' || visited.has('routing')) &&
props.routing}
</TabsContent>
<TabsContent
value='request'
keepMounted
className='-m-1 min-h-0 space-y-5 overflow-y-auto overscroll-contain p-1'
>
{(props.section === 'request' || visited.has('request')) &&
props.request}
</TabsContent>
<TabsContent
value='other'
keepMounted
className='-m-1 min-h-0 space-y-5 overflow-y-auto overscroll-contain p-1'
>
{(props.section === 'other' || visited.has('other')) && props.other}
</TabsContent>
</Tabs>
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Puzzle } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EmptyState } from '@/components/empty-state'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/ui/badge'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { AutoGroupFlowBorder } from '@/features/keys/components/auto-group-visuals'
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
import { useMediaQuery } from '@/hooks/use-media-query'
import { resolveLocalizedText } from '@/lib/localized-text'
import type { TaskPluginOption } from '../../api'
import {
CHANNEL_PROVIDER_PRESENTATION,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_OPTIONS,
CHANNEL_TYPE_SUB2API,
CHANNEL_TYPE_TASK_PLUGIN,
type ChannelProviderPresentation,
} from '../../constants'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from '../../lib/advanced-custom'
import type { ChannelProviderTarget } from '../../lib/channel-configuration'
import {
getChannelPluginExtensions,
LEGACY_TASK_PLUGIN_KEYS,
} from '../../lib/channel-plugin-extensions'
import { ChannelTypeLogo } from '../channel-type-badge'
type ChannelProviderPickerProps = {
isCreating?: boolean
plugins: TaskPluginOption[]
currentProvider?: ChannelProviderTarget | null
canBindPlugin: boolean
loading: boolean
failed: boolean
disabled: boolean
onRetry: () => void
onSelect: (target: ChannelProviderTarget) => void
}
export function ChannelProviderPicker(props: ChannelProviderPickerProps) {
const { t, i18n } = useTranslation()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const [search, setSearch] = useState('')
const [selectedFilter, setSelectedFilter] = useState('all')
const filter =
!props.canBindPlugin && selectedFilter === 'plugin' ? 'all' : selectedFilter
const keyword = search.trim().toLocaleLowerCase()
const options = useMemo(() => {
const availablePlugins =
props.canBindPlugin && !props.loading && !props.failed
? props.plugins
: []
const pluginAliases = new Map<string, string>()
const replacedTypes = new Set<number>()
if (props.isCreating) {
const availablePluginKeys = new Set(
availablePlugins.map((plugin) => plugin.key)
)
for (const option of CHANNEL_TYPE_OPTIONS) {
const pluginKey = LEGACY_TASK_PLUGIN_KEYS[option.value]
if (!pluginKey) continue
pluginAliases.set(
pluginKey,
`${option.value} ${option.label} ${t(option.label)}`
)
const isCurrent =
props.currentProvider?.kind === 'builtin' &&
props.currentProvider.type === option.value
if (availablePluginKeys.has(pluginKey) && !isCurrent) {
replacedTypes.add(option.value)
}
}
}
const entries: Array<{
id: string
label: string
target: ChannelProviderTarget
plugin?: TaskPluginOption
description?: string
detail?: string
extensionNames?: string
extensionSummary?: string
badge?: ChannelProviderPresentation['badge']
searchText: string
}> = []
for (const option of CHANNEL_TYPE_OPTIONS) {
if (option.value === CHANNEL_TYPE_TASK_PLUGIN) {
if (!props.canBindPlugin || (filter !== 'all' && filter !== 'plugin')) {
continue
}
for (const plugin of props.plugins) {
entries.push({
id: `plugin:${plugin.key}`,
label: plugin.name,
target: { kind: 'plugin', key: plugin.key },
plugin,
description: resolveLocalizedText(
plugin.description,
i18n.language
),
searchText: `${plugin.name} ${plugin.key} ${pluginAliases.get(plugin.key) ?? ''}`,
})
}
} else if (filter !== 'plugin') {
// Keep legacy Zhipu available when editing existing channels.
if (props.isCreating && option.value === 16) continue
if (props.isCreating && option.value === 7) continue
if (replacedTypes.has(option.value)) continue
const isCustom =
option.value === 8 || option.value === CHANNEL_TYPE_ADVANCED_CUSTOM
const isGateway =
option.value === CHANNEL_TYPE_NEW_API ||
option.value === CHANNEL_TYPE_SUB2API
if (filter === 'gateway' && !isGateway) continue
if (filter === 'custom' && !isCustom) continue
if (filter === 'builtin' && isCustom) continue
const presentation = CHANNEL_PROVIDER_PRESENTATION[option.value]
const extensions = getChannelPluginExtensions(
option.value,
availablePlugins
)
const extensionSummary = extensions
.map((plugin) =>
[
plugin.name,
resolveLocalizedText(plugin.description, i18n.language),
]
.filter(Boolean)
.join(' · ')
)
.join(' / ')
entries.push({
id: `builtin:${option.value}`,
label: t(option.label),
target: { kind: 'builtin', type: option.value },
description: presentation
? t(presentation.descriptionKey)
: undefined,
detail: presentation?.detailKey
? t(presentation.detailKey)
: undefined,
badge: presentation?.badge,
extensionNames: extensions.map((plugin) => plugin.name).join(' · '),
extensionSummary,
searchText: `${option.value} ${option.label} ${t(option.label)} ${extensions.map((plugin) => `${plugin.name} ${plugin.key}`).join(' ')}`,
})
}
}
return entries.filter((entry) =>
entry.searchText.toLocaleLowerCase().includes(keyword)
)
}, [
filter,
i18n.language,
keyword,
props.isCreating,
props.canBindPlugin,
props.currentProvider,
props.loading,
props.failed,
props.plugins,
t,
])
const customType = Number(search.trim())
const canUseCustomType =
(filter === 'all' || filter === 'custom') &&
/^\d+$/.test(search.trim()) &&
Number.isSafeInteger(customType) &&
customType > 0 &&
!CHANNEL_TYPE_OPTIONS.some((option) => option.value === customType)
const showPluginStatus =
props.canBindPlugin && (filter === 'all' || filter === 'plugin')
let currentProviderId: string | undefined
if (props.currentProvider?.kind === 'builtin') {
currentProviderId = `builtin:${props.currentProvider.type}`
} else if (props.currentProvider?.kind === 'plugin') {
currentProviderId = `plugin:${props.currentProvider.key}`
}
return (
<Tabs
value={filter}
onValueChange={(value) => setSelectedFilter(String(value))}
className='min-h-0 flex-1 gap-4 p-4 sm:p-6'
>
<TabsList
aria-label={t('Provider source')}
className='max-w-full shrink-0 flex-wrap justify-start group-data-horizontal/tabs:h-auto'
>
<TabsTrigger value='all' className='h-auto'>
{t('All')}
</TabsTrigger>
<TabsTrigger value='builtin' className='h-auto'>
{t('Built-in')}
</TabsTrigger>
{props.canBindPlugin && (
<TabsTrigger value='plugin' className='h-auto'>
{t('Plugins')}
</TabsTrigger>
)}
<TabsTrigger value='gateway' className='h-auto'>
{t('Gateways')}
</TabsTrigger>
<TabsTrigger value='custom' className='h-auto'>
{t('Custom')}
</TabsTrigger>
</TabsList>
{/* Keep the search and command state when changing categories. */}
<TabsContent
value={filter}
keepMounted
className='flex min-h-0 flex-1 flex-col gap-4'
>
<Command
label={t('Search providers, plugins, or type numbers')}
defaultValue={currentProviderId}
shouldFilter={false}
className='min-h-0 flex-1 bg-transparent p-0'
>
<CommandInput
autoFocus
className='placeholder:text-muted-foreground'
value={search}
onValueChange={setSearch}
placeholder={t('Search providers, plugins, or type numbers')}
aria-label={t('Search providers, plugins, or type numbers')}
/>
<CommandList className='mt-3 max-h-none min-h-0 flex-1'>
<CommandEmpty>{t('No matching provider')}</CommandEmpty>
<CommandGroup className='p-1 [&_[cmdk-group-items]]:grid [&_[cmdk-group-items]]:gap-2.5 md:[&_[cmdk-group-items]]:grid-cols-2 xl:[&_[cmdk-group-items]]:grid-cols-3'>
{options.map((option) => (
<CommandItem
key={option.id}
value={option.id}
aria-current={
option.id === currentProviderId ? true : undefined
}
data-checked={option.id === currentProviderId}
aria-label={
option.target.kind === 'builtin'
? `${option.label} ${t('Built-in')} #${option.target.type}`
: `${option.label} ${t('Plugin')} ${option.target.key}`
}
aria-description={
[
option.badge && t(option.badge.labelKey),
option.detail || option.description,
option.extensionSummary &&
t('Supports plugin extensions'),
option.extensionSummary,
]
.filter(Boolean)
.join(' · ') || undefined
}
disabled={props.disabled}
onSelect={() => props.onSelect(option.target)}
className='data-selected:border-primary/50 data-selected:bg-primary/5 min-h-24 flex-col items-stretch justify-between gap-2.5 rounded-lg border p-3 md:min-h-32 [&>svg]:hidden'
>
{option.target.kind === 'builtin' &&
option.target.type === CHANNEL_TYPE_ADVANCED_CUSTOM && (
<AutoGroupFlowBorder
shouldReduceMotion={
shouldReduceMotion || props.disabled
}
/>
)}
<span className='flex min-w-0 items-start gap-2.5'>
<span className='shrink-0'>
{option.plugin ? (
<PluginIcon plugin={option.plugin} size={20} />
) : (
<ChannelTypeLogo
type={
option.target.kind === 'builtin'
? option.target.type
: CHANNEL_TYPE_TASK_PLUGIN
}
size={20}
/>
)}
</span>
<span className='min-w-0 flex-1'>
<span className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
<span
className='max-w-full truncate font-medium'
title={option.label}
>
{option.label}
</span>
{option.badge && (
<StatusBadge
label={t(option.badge.labelKey)}
variant={
option.badge.tone === 'warning'
? 'warning'
: 'info'
}
copyable={false}
size='sm'
className={
option.badge.tone === 'warning'
? 'bg-warning/10 text-xs text-[color-mix(in_oklab,var(--warning)_50%,var(--foreground))]'
: 'bg-primary/10 text-xs text-[color-mix(in_oklab,var(--primary)_50%,var(--foreground))]'
}
/>
)}
</span>
{option.description && (
<span
className='text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed break-words'
title={option.detail || option.description}
>
{option.description}
</span>
)}
</span>
</span>
<span className='flex min-w-0 items-center gap-2'>
<Badge
variant={
option.id === currentProviderId
? 'default'
: 'secondary'
}
className='shrink-0 rounded-md px-1.5 py-0 text-[10px] font-normal'
>
{option.id === currentProviderId && t('Current')}
{option.id !== currentProviderId &&
(option.plugin ? t('Plugin') : t('Built-in'))}
</Badge>
{option.extensionNames && (
<span
aria-label={t('Plugin extensions')}
title={option.extensionSummary}
className='text-muted-foreground flex min-w-0 items-center gap-1 text-xs'
>
<Puzzle className='size-3' aria-hidden='true' />
<span className='truncate'>
{option.extensionNames}
</span>
</span>
)}
{option.plugin && (
<span
className='text-muted-foreground min-w-0 truncate text-xs'
title={option.plugin.key}
>
{option.plugin.key}
</span>
)}
{option.target.kind === 'builtin' && (
<span className='text-muted-foreground ml-auto shrink-0 text-[11px] tabular-nums'>
#{option.target.type}
</span>
)}
{option.plugin && (
<span className='text-muted-foreground ml-auto shrink-0 text-[11px]'>
{t('{{count}} models', {
count: option.plugin.models.length,
})}
</span>
)}
</span>
</CommandItem>
))}
{canUseCustomType && (
<CommandItem
value={`custom:${customType}`}
aria-current={
currentProviderId === `builtin:${customType}`
? true
: undefined
}
data-checked={currentProviderId === `builtin:${customType}`}
aria-label={t('Use channel type {{type}}', {
type: customType,
})}
disabled={props.disabled}
onSelect={() =>
props.onSelect({ kind: 'builtin', type: customType })
}
className='data-selected:border-primary/50 data-selected:bg-primary/5 min-h-24 gap-2.5 rounded-lg border p-3 md:min-h-32 [&>svg]:hidden'
>
<span className='shrink-0'>
<ChannelTypeLogo type={customType} size={20} />
</span>
<span className='min-w-0 break-words'>
{t('Use channel type {{type}}', { type: customType })}
</span>
{currentProviderId === `builtin:${customType}` && (
<Badge>{t('Current')}</Badge>
)}
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
{showPluginStatus && props.loading && (
<LoadingState inline message={t('Loading plugins...')} />
)}
{showPluginStatus && props.failed && (
<ErrorState
className='min-h-0 p-3'
title={t('Failed to load plugins')}
onRetry={props.onRetry}
/>
)}
{showPluginStatus &&
!props.loading &&
!props.failed &&
props.plugins.length === 0 && (
<EmptyState
className='min-h-0 p-3'
title={t('No plugins available for binding')}
/>
)}
</TabsContent>
</Tabs>
)
}
@@ -0,0 +1,453 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowRight } from 'lucide-react'
import { type ReactNode, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
deriveModelMappingPairs,
type ModelMappingDirection,
type ModelMappingPair,
type ModelMappingRule,
} from '../lib'
import { UpstreamModelSelection } from './upstream-model-selection'
const PREVIEW_LIMIT = 8
/** Where the selectable names come from. */
export type ModelMappingBatchSource = 'upstream' | 'channel'
export type ModelMappingBatchResult = {
pairs: ModelMappingPair[]
/** Publish the request names and drop the raw upstream names from the model list. */
syncModels: boolean
}
type RuleType = ModelMappingRule['type']
type ModelMappingBatchDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Names returned by the provider's model list; enables the upstream source. */
upstreamModels?: string[]
channelModels: string[]
initialSource?: ModelMappingBatchSource
initialSelected?: string[]
onApply: (result: ModelMappingBatchResult) => void
}
export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
const { t } = useTranslation()
const id = useId()
const upstreamModels = props.upstreamModels ?? []
const hasUpstream = upstreamModels.length > 0
const [source, setSource] = useState<ModelMappingBatchSource>(
() => props.initialSource ?? (hasUpstream ? 'upstream' : 'channel')
)
const [selected, setSelected] = useState<string[]>(
props.initialSelected ?? []
)
const [direction, setDirection] = useState<ModelMappingDirection>(
source === 'upstream' ? 'upstream' : 'request'
)
const [ruleType, setRuleType] = useState<RuleType>(
direction === 'upstream' ? 'strip-suffix' : 'add-suffix'
)
const [stripPrefixes, setStripPrefixes] = useState('')
const [stripSuffixes, setStripSuffixes] = useState('')
const [prefix, setPrefix] = useState('')
const [suffix, setSuffix] = useState('')
const [find, setFind] = useState('')
const [replaceWith, setReplaceWith] = useState('')
const [syncModels, setSyncModels] = useState(true)
const candidates =
source === 'upstream' ? upstreamModels : props.channelModels
const rule = useMemo<ModelMappingRule>(() => {
if (ruleType === 'strip-prefix') {
return { type: 'strip-prefix', values: stripPrefixes }
}
if (ruleType === 'strip-suffix') {
return { type: 'strip-suffix', values: stripSuffixes }
}
if (ruleType === 'add-prefix') return { type: 'add-prefix', value: prefix }
if (ruleType === 'add-suffix') return { type: 'add-suffix', value: suffix }
return { type: 'replace', find, replaceWith }
}, [
ruleType,
stripPrefixes,
stripSuffixes,
prefix,
suffix,
find,
replaceWith,
])
const derivation = useMemo(
() => deriveModelMappingPairs(selected, direction, rule),
[selected, direction, rule]
)
const preview = derivation.pairs.slice(0, PREVIEW_LIMIT)
const remaining = derivation.pairs.length - preview.length
const changeDirection = (next: ModelMappingDirection) => {
setDirection(next)
setRuleType(next === 'upstream' ? 'strip-suffix' : 'add-suffix')
}
const changeSource = (next: ModelMappingBatchSource) => {
setSource(next)
setSelected([])
changeDirection(next === 'upstream' ? 'upstream' : 'request')
}
const replaceControls = (
<div className='grid gap-2 sm:grid-cols-2'>
<Input
aria-label={t('Find')}
placeholder='.'
value={find}
onChange={(event) => setFind(event.target.value)}
disabled={ruleType !== 'replace'}
/>
<Input
aria-label={t('Replace with')}
placeholder='-'
value={replaceWith}
onChange={(event) => setReplaceWith(event.target.value)}
disabled={ruleType !== 'replace'}
/>
</div>
)
const affixHint = t('Separate several values with commas')
let ruleOptions: Array<{
type: RuleType
label: string
control: ReactNode
hint?: string
}>
if (direction === 'upstream') {
ruleOptions = [
{
type: 'strip-suffix',
label: t('Strip suffix'),
hint: affixHint,
control: (
<Input
aria-label={t('Suffix')}
placeholder='-all, -latest'
value={stripSuffixes}
onChange={(event) => setStripSuffixes(event.target.value)}
disabled={ruleType !== 'strip-suffix'}
/>
),
},
{
type: 'strip-prefix',
label: t('Strip prefix'),
hint: affixHint,
control: (
<Input
aria-label={t('Prefix')}
placeholder='openai/, anthropic/'
value={stripPrefixes}
onChange={(event) => setStripPrefixes(event.target.value)}
disabled={ruleType !== 'strip-prefix'}
/>
),
},
{ type: 'replace', label: t('Replace text'), control: replaceControls },
]
} else {
ruleOptions = [
{
type: 'add-suffix',
label: t('Add suffix'),
control: (
<Input
aria-label={t('Suffix')}
placeholder='-all'
value={suffix}
onChange={(event) => setSuffix(event.target.value)}
disabled={ruleType !== 'add-suffix'}
/>
),
},
{
type: 'add-prefix',
label: t('Add prefix'),
control: (
<Input
aria-label={t('Prefix')}
placeholder='openai/'
value={prefix}
onChange={(event) => setPrefix(event.target.value)}
disabled={ruleType !== 'add-prefix'}
/>
),
},
{ type: 'replace', label: t('Replace text'), control: replaceControls },
]
}
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Batch add mappings')}
description={t(
'Pick models and derive the other side with a rule to create request-to-upstream mappings.'
)}
contentClassName='sm:max-w-4xl'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='button'
disabled={derivation.pairs.length === 0}
onClick={() => {
props.onApply({
pairs: derivation.pairs,
syncModels: direction === 'upstream' && syncModels,
})
props.onOpenChange(false)
}}
>
{t('Add {{count}} mapping(s)', { count: derivation.pairs.length })}
</Button>
</>
}
>
<div className='grid gap-6 lg:grid-cols-2'>
<section aria-labelledby={`${id}-models`} className='min-w-0 space-y-3'>
<h3 id={`${id}-models`} className='text-sm font-semibold'>
{t('Select models')}
</h3>
{hasUpstream && (
<Tabs
value={source}
onValueChange={(value) =>
changeSource(value === 'channel' ? 'channel' : 'upstream')
}
>
<TabsList variant='line' aria-label={t('Select models')}>
<TabsTrigger value='upstream'>
{t('Upstream model list')} ({upstreamModels.length})
</TabsTrigger>
<TabsTrigger value='channel'>
{t('Channel models')} ({props.channelModels.length})
</TabsTrigger>
</TabsList>
</Tabs>
)}
<UpstreamModelSelection
key={source}
models={candidates}
selected={selected}
existingModels={candidates}
onChange={setSelected}
showChanges={false}
summaryText={
source === 'channel'
? t('Current models: {{count}}', { count: candidates.length })
: undefined
}
/>
</section>
<div className='min-w-0 space-y-5'>
<section aria-labelledby={`${id}-direction`} className='space-y-2'>
<h3 id={`${id}-direction`} className='text-sm font-semibold'>
{t('Selected names are')}
</h3>
<RadioGroup
value={direction}
aria-labelledby={`${id}-direction`}
onValueChange={(value) => {
if (value === 'upstream' || value === 'request') {
changeDirection(value)
}
}}
className='gap-2'
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value='upstream'
id={`${id}-direction-upstream`}
className='mt-0.5'
/>
<div className='min-w-0 space-y-0.5'>
<Label
htmlFor={`${id}-direction-upstream`}
className='font-normal'
>
{t('Upstream names returned by the provider')}
</Label>
<p className='text-muted-foreground text-xs'>
{t('Derives the request names your users call')}
</p>
</div>
</div>
<div className='flex items-start gap-3'>
<RadioGroupItem
value='request'
id={`${id}-direction-request`}
className='mt-0.5'
/>
<div className='min-w-0 space-y-0.5'>
<Label
htmlFor={`${id}-direction-request`}
className='font-normal'
>
{t('Request names your users call')}
</Label>
<p className='text-muted-foreground text-xs'>
{t('Derives the upstream names sent to the provider')}
</p>
</div>
</div>
</RadioGroup>
</section>
<section aria-labelledby={`${id}-rule`} className='space-y-3'>
<h3 id={`${id}-rule`} className='text-sm font-semibold'>
{t('Rule')}
</h3>
<RadioGroup
value={ruleType}
aria-labelledby={`${id}-rule`}
onValueChange={(value) => {
const option = ruleOptions.find((item) => item.type === value)
if (option) setRuleType(option.type)
}}
className='gap-3'
>
{ruleOptions.map((option) => (
<div key={option.type} className='flex items-start gap-3'>
<RadioGroupItem
value={option.type}
id={`${id}-rule-${option.type}`}
className='mt-2'
/>
<div className='min-w-0 flex-1 space-y-2'>
<Label
htmlFor={`${id}-rule-${option.type}`}
className='font-normal'
>
{option.label}
</Label>
{option.control}
{option.hint && ruleType === option.type && (
<p className='text-muted-foreground text-xs'>
{option.hint}
</p>
)}
</div>
</div>
))}
</RadioGroup>
</section>
{direction === 'upstream' && (
<div className='flex items-start gap-3 rounded-md border p-3'>
<Checkbox
id={`${id}-sync`}
className='mt-0.5'
checked={syncModels}
onCheckedChange={(checked) => setSyncModels(checked === true)}
/>
<Label htmlFor={`${id}-sync`} className='leading-5 font-normal'>
{t(
'Publish the request names and remove the raw upstream names from the model list'
)}
</Label>
</div>
)}
<section aria-labelledby={`${id}-preview`} className='space-y-2'>
<h3 id={`${id}-preview`} className='text-sm font-semibold'>
{t('Preview')}
</h3>
{selected.length === 0 && (
<p className='text-muted-foreground text-sm'>
{t('Select at least one model to preview mappings.')}
</p>
)}
{derivation.pairs.length > 0 && (
<div className='rounded-md border p-3 font-mono text-xs'>
<div className='text-muted-foreground mb-2 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 font-sans'>
<span>{t('Request Model Name')}</span>
<span className='w-3.5' />
<span>{t('Upstream Model Name')}</span>
</div>
<ul
aria-label={t('Preview')}
className='max-h-56 space-y-1 overflow-y-auto'
>
{preview.map((pair) => (
<li
key={pair.to}
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
>
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
<ArrowRight
className='mt-0.5 size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
</li>
))}
{remaining > 0 && (
<li className='text-muted-foreground'>
{t('+{{count}} more', { count: remaining })}
</li>
)}
</ul>
</div>
)}
{derivation.unchanged.length > 0 && (
<p className='text-muted-foreground text-xs'>
{t('{{count}} model(s) unchanged by the rule were skipped', {
count: derivation.unchanged.length,
})}
</p>
)}
{derivation.conflicts.length > 0 && (
<p className='text-warning text-xs'>
{t(
'{{count}} model(s) skipped because another model derives the same request name',
{ count: derivation.conflicts.length }
)}
</p>
)}
</section>
</div>
</div>
</Dialog>
)
}
@@ -16,22 +16,59 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Code, Plus, Table, Trash2 } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Code, ListPlus, Plus, Table, Trash2 } from 'lucide-react'
import {
useEffect,
useEffectEvent,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
type MappingField = 'from' | 'to'
/**
* Asks the editor to open a mapping row for a model the caller already knows
* one side of. When a row for `from` exists its upstream field is focused;
* otherwise a draft row is appended and the empty side receives focus. Drafts
* are only written to the mapping once both names are filled in.
*/
export type ModelMappingDraftRequest = {
/** Request model name; empty when only the upstream name is known. */
from: string
/** Upstream model name; empty when only the request name is known. */
to: string
/** Field to focus; defaults to the empty side, or the upstream side of an existing row. */
focus?: MappingField
/** Changes re-trigger the request even for identical names. */
token: number
}
type ModelMappingEditorProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
sourceModelOptions?: string[]
targetModelOptions?: string[]
/** Shows the batch button; the caller owns the batch dialog. */
onBatchAdd?: () => void
/**
* Fires with the latest value once an edit settles: Enter, focus leaving the
* editor, or a row being deleted. Lets callers react per completed edit
* instead of per keystroke.
*/
onCommit?: (value: string) => void
draftRequest?: ModelMappingDraftRequest | null
onDraftRequestHandled?: () => void
}
type MappingRow = {
@@ -41,6 +78,8 @@ type MappingRow = {
}
const DUPLICATE_MAPPING_SENTINEL = '{ "duplicate_source_models": '
/** Show the row filter once the table is long enough to need scanning. */
const MAPPING_FILTER_THRESHOLD = 6
function getDuplicateSources(rows: MappingRow[]): string[] {
const seen = new Set<string>()
@@ -56,25 +95,49 @@ function getDuplicateSources(rows: MappingRow[]): string[] {
}
}
return Array.from(duplicates)
return [...duplicates]
}
export function ModelMappingEditor(props: ModelMappingEditorProps) {
const { t } = useTranslation()
const sourceListId = useId()
const targetListId = useId()
const inputsId = useId()
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<MappingRow[]>([])
const [jsonValue, setJsonValue] = useState(props.value)
const [jsonError, setJsonError] = useState<string | null>(null)
const [filter, setFilter] = useState('')
const nextRowIdRef = useRef(0)
const lastEmittedRef = useRef(props.value)
const pendingRowFocusRef = useRef<{
rowId: string
field: MappingField
} | null>(null)
const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows])
const sourceOptions = useMemo(
() =>
(props.sourceModelOptions ?? []).map((model) => ({
value: model,
label: model,
})),
[props.sourceModelOptions]
)
const targetOptions = useMemo(
() =>
(props.targetModelOptions ?? []).map((model) => ({
value: model,
label: model,
})),
[props.targetModelOptions]
)
const createRowId = () => {
nextRowIdRef.current += 1
return `mapping-${nextRowIdRef.current}`
}
const rowInputId = (rowId: string, field: MappingField) =>
`${inputsId}-${rowId}-${field}`
const parseJsonToRows = (json: string): boolean => {
try {
if (!json.trim()) {
@@ -95,7 +158,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
}
setRows((previousRows) => {
const remainingRows = [...previousRows]
return entries.map(([from, to], index) => {
const mapped = entries.map(([from, to], index) => {
const toString = String(to)
const existingIndex = remainingRows.findIndex(
(row) =>
@@ -117,22 +180,90 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
to: toString,
}
})
// Rows without a request name are not serialized yet; keep them so a
// draft survives edits to other rows.
const mappedIds = new Set(mapped.map((row) => row.id))
const drafts = previousRows.filter(
(row) => !row.from.trim() && !mappedIds.has(row.id)
)
return [...mapped, ...drafts]
})
setJsonError(null)
return true
} catch (_error) {
} catch {
setJsonError(t('Model mapping must be valid JSON format'))
return false
}
}
// Parse JSON to rows when value changes externally
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
const syncExternalValue = useEffectEvent(() => {
lastEmittedRef.current = props.value
setJsonValue(props.value)
parseJsonToRows(props.value)
})
const commit = () => {
props.onCommit?.(lastEmittedRef.current)
}
// Only replace the draft when the external value changes, not on language changes.
useEffect(() => {
syncExternalValue()
}, [props.value])
// A freshly added row exists one commit after the click, so focus it once
// its inputs are rendered.
const focusPendingRow = useEffectEvent(() => {
const pending = pendingRowFocusRef.current
if (!pending) return
const input = document.querySelector(
`[id="${rowInputId(pending.rowId, pending.field)}"]`
)
if (!(input instanceof HTMLInputElement)) return
pendingRowFocusRef.current = null
input.focus()
})
useEffect(() => {
focusPendingRow()
}, [rows])
const applyDraftRequest = useEffectEvent(() => {
const request = props.draftRequest
if (!request) return
const existing = request.from
? rows.find((row) => row.from === request.from)
: undefined
if (existing) {
const input = document.querySelector(
`[id="${rowInputId(existing.id, request.focus ?? 'to')}"]`
)
if (input instanceof HTMLInputElement) {
input.focus()
input.scrollIntoView({ block: 'center' })
}
} else {
const rowId = createRowId()
pendingRowFocusRef.current = {
rowId,
field: request.focus ?? (request.from ? 'to' : 'from'),
}
setRows((previous) => [
...previous,
{ id: rowId, from: request.from, to: request.to },
])
}
props.onDraftRequestHandled?.()
})
// Callers raise draft requests while closing a dialog, whose focus return
// runs on the next frame; wait one frame so the editor keeps the focus.
useEffect(() => {
if (!props.draftRequest) return
const frame = window.requestAnimationFrame(() => applyDraftRequest())
return () => window.cancelAnimationFrame(frame)
}, [props.draftRequest])
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
if (updatedRows.length === 0) {
return ''
@@ -152,6 +283,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
if (duplicates.length > 0) {
setJsonError(t('Duplicate source model mappings are not allowed'))
setJsonValue(DUPLICATE_MAPPING_SENTINEL)
lastEmittedRef.current = DUPLICATE_MAPPING_SENTINEL
props.onChange(DUPLICATE_MAPPING_SENTINEL)
return
}
@@ -159,6 +291,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
const json = convertRowsToJson(updatedRows)
setJsonError(null)
setJsonValue(json)
lastEmittedRef.current = json
props.onChange(json)
}
@@ -168,16 +301,18 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
from: '',
to: '',
}
pendingRowFocusRef.current = { rowId: newRow.id, field: 'from' }
syncRows([...rows, newRow])
}
const handleDeleteRow = (id: string) => {
syncRows(rows.filter((row) => row.id !== id))
commit()
}
const handleRowChange = (
id: string,
field: 'from' | 'to',
field: MappingField,
newValue: string
) => {
const updatedRows = rows.map((row) =>
@@ -188,6 +323,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
const handleJsonChange = (newJson: string) => {
setJsonValue(newJson)
lastEmittedRef.current = newJson
props.onChange(newJson)
parseJsonToRows(newJson)
}
@@ -199,8 +335,10 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
2
)
setJsonValue(template)
lastEmittedRef.current = template
props.onChange(template)
parseJsonToRows(template)
commit()
}
const handleModeChange = (nextMode: string) => {
@@ -210,6 +348,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
if (duplicates.length === 0) {
const json = convertRowsToJson(rows)
setJsonValue(json)
lastEmittedRef.current = json
props.onChange(json)
}
setMode('json')
@@ -219,10 +358,29 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
setMode('visual')
}
const filterKeyword = filter.trim().toLowerCase()
const showFilter = rows.length >= MAPPING_FILTER_THRESHOLD
let visibleRows = rows
if (showFilter && filterKeyword) {
visibleRows = rows.filter(
(row) =>
row.from.toLowerCase().includes(filterKeyword) ||
row.to.toLowerCase().includes(filterKeyword)
)
}
return (
<div className='space-y-2'>
<div
className='space-y-2'
onKeyDown={(event) => {
if (event.key === 'Enter') commit()
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) commit()
}}
>
<Tabs value={mode} onValueChange={handleModeChange} className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<TabsList>
<TabsTrigger value='visual'>
<Table className='h-4 w-4' aria-hidden='true' />
@@ -233,16 +391,30 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
{t('JSON')}
</TabsTrigger>
</TabsList>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
onClick={handleFillTemplate}
disabled={props.disabled}
>
{t('Fill Template')}
</Button>
<div className='flex items-center gap-3'>
{props.onBatchAdd && (
<Button
type='button'
variant='outline'
size='sm'
onClick={props.onBatchAdd}
disabled={props.disabled || Boolean(jsonError)}
>
<ListPlus aria-hidden='true' />
{t('Batch Add')}
</Button>
)}
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
onClick={handleFillTemplate}
disabled={props.disabled}
>
{t('Fill Template')}
</Button>
</div>
</div>
{jsonError && (
@@ -264,33 +436,59 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
<TabsContent value='visual' className='space-y-2'>
{rows.length > 0 ? (
<div className='space-y-2'>
{showFilter && (
<div className='space-y-1'>
<Input
aria-label={t('Filter mappings')}
placeholder={t('Filter by model name')}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
{filterKeyword && (
<p className='text-muted-foreground text-xs'>
{t('Showing {{shown}} of {{total}} mappings', {
shown: visibleRows.length,
total: rows.length,
})}
</p>
)}
</div>
)}
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
<div>{t('Original Model')}</div>
<div>{t('Replacement Model')}</div>
<div className='w-10'></div>
<div>{t('Request Model Name')}</div>
<div>{t('Upstream Model Name')}</div>
<div className='w-10' />
</div>
{rows.map((row) => (
{visibleRows.map((row) => (
<div
key={row.id}
className='grid grid-cols-[1fr_1fr_auto] gap-2'
>
<Input
<ComboboxInput
id={rowInputId(row.id, 'from')}
options={sourceOptions}
value={row.from}
onChange={(e) =>
handleRowChange(row.id, 'from', e.target.value)
onValueChange={(value) =>
handleRowChange(row.id, 'from', value)
}
placeholder='gpt-3.5-turbo'
emptyText='No matching items'
allowCustomValue
disabled={props.disabled}
list={sourceListId}
aria-label={t('Request Model Name')}
/>
<Input
<ComboboxInput
id={rowInputId(row.id, 'to')}
options={targetOptions}
value={row.to}
onChange={(e) =>
handleRowChange(row.id, 'to', e.target.value)
onValueChange={(value) =>
handleRowChange(row.id, 'to', value)
}
placeholder='gpt-3.5-turbo-0125'
emptyText='No matching items'
allowCustomValue
disabled={props.disabled}
list={targetListId}
aria-label={t('Upstream Model Name')}
/>
<Button
type='button'
@@ -305,6 +503,11 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
</Button>
</div>
))}
{visibleRows.length === 0 && (
<p className='text-muted-foreground py-3 text-center text-sm'>
{t('No matching items')}
</p>
)}
</div>
) : (
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
@@ -325,11 +528,16 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
{t('Add Mapping')}
</Button>
</TabsContent>
<TabsContent value='json'>
<TabsContent value='json' className='space-y-2'>
<p className='text-muted-foreground text-sm'>
{t(
'JSON keys are request model names; values are upstream model names.'
)}
</p>
<JsonCodeEditor
value={jsonValue}
onChange={handleJsonChange}
placeholder={t('{"original-model": "replacement-model"}')}
placeholder='{"request-model": "upstream-model"}'
disabled={props.disabled}
className={jsonError ? 'border-destructive' : undefined}
aria-invalid={Boolean(jsonError)}
@@ -337,21 +545,6 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
/>
</TabsContent>
</Tabs>
{props.sourceModelOptions && props.sourceModelOptions.length > 0 && (
<datalist id={sourceListId}>
{props.sourceModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
{props.targetModelOptions && props.targetModelOptions.length > 0 && (
<datalist id={targetListId}>
{props.targetModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
</div>
)
}
@@ -0,0 +1,163 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Wand2 } from 'lucide-react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import {
FloatingWindow,
type FloatingWindowPosition,
} from '@/components/floating-window'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import type { ModelNamingSuggestion } from '../lib'
import {
ModelMappingEditor,
type ModelMappingDraftRequest,
} from './model-mapping-editor'
type ModelRedirectPanelProps = {
defaultPosition: FloatingWindowPosition
width: number
onClose: () => void
mappingCount: number
mappingValue: string
onMappingChange: (value: string) => void
/** Called once an edit settles; the drawer syncs the model list here. */
onMappingCommit: (value: string) => void
suggestions: ModelNamingSuggestion[]
onApplySuggestion: (suggestion: ModelNamingSuggestion) => void
onOpenRules: () => void
syncModels: boolean
onSyncModelsChange: (value: boolean) => void
sourceModelOptions: string[]
targetModelOptions: string[]
draftRequest: ModelMappingDraftRequest | null
onDraftRequestHandled: () => void
disabled?: boolean
}
/**
* Floating workbench for model redirects. It is mounted inside the channel
* drawer so the modal sheet treats it as its own content, and floats above
* the page from there.
*/
export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
const { t } = useTranslation()
const id = useId()
return (
<FloatingWindow
title={t('Model redirects')}
badge={
<Badge variant='secondary' className='h-4 px-1 text-[10px]'>
{props.mappingCount}
</Badge>
}
defaultPosition={props.defaultPosition}
defaultWidth={props.width}
minWidth={340}
storageKey='channel-model-redirects'
onClose={props.onClose}
footer={
<div className='flex items-start gap-3'>
<Checkbox
id={`${id}-sync`}
className='mt-0.5'
checked={props.syncModels}
onCheckedChange={(checked) =>
props.onSyncModelsChange(checked === true)
}
/>
<Label
htmlFor={`${id}-sync`}
className='text-xs leading-5 font-normal'
>
{t(
'Publish the request names and remove the raw upstream names from the model list'
)}
</Label>
</div>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
{props.suggestions.length > 0 && (
<p className='text-muted-foreground text-xs'>
{t('Suggested rules')}
</p>
)}
<div className='flex flex-wrap items-center gap-2'>
{props.suggestions.map((suggestion) => {
let label = ''
if (suggestion.rule.type === 'replace') {
label = t('Replace {{find}} with {{replaceWith}}', {
find: suggestion.rule.find,
replaceWith: suggestion.rule.replaceWith,
})
} else if ('values' in suggestion.rule) {
label = t('Strip {{affix}}', {
affix: suggestion.rule.values,
})
}
return (
<Button
key={suggestion.id}
type='button'
variant='secondary'
size='xs'
disabled={props.disabled}
onClick={() => props.onApplySuggestion(suggestion)}
>
<Wand2 aria-hidden='true' />
{label}
<Badge variant='outline' className='h-4 px-1 text-[10px]'>
{suggestion.models.length}
</Badge>
</Button>
)
})}
<Button
type='button'
variant='ghost'
size='xs'
disabled={props.disabled}
onClick={props.onOpenRules}
>
{t('More rules')}
</Button>
</div>
</div>
<ModelMappingEditor
value={props.mappingValue}
onChange={props.onMappingChange}
onCommit={props.onMappingCommit}
disabled={props.disabled}
sourceModelOptions={props.sourceModelOptions}
targetModelOptions={props.targetModelOptions}
draftRequest={props.draftRequest}
onDraftRequestHandled={props.onDraftRequestHandled}
/>
</div>
</FloatingWindow>
)
}
@@ -0,0 +1,79 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useFormContext } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from '../lib/advanced-custom'
import type { ChannelFormValues } from '../lib/channel-form'
import { supportsResponsesWebSocket } from '../lib/responses-websocket'
export function ResponsesWebSocketSetting(props: {
channelType: number
disabled?: boolean
}) {
const { t } = useTranslation()
const form = useFormContext<ChannelFormValues>()
if (!supportsResponsesWebSocket(props.channelType)) return null
return (
<FormField
control={form.control}
name='responses_websocket_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-4 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Enable Responses WebSocket')}</FormLabel>
<FormDescription>
{t(
'Enable only if the upstream supports Responses WebSocket. HTTP requests are unaffected when disabled.'
)}
{props.channelType === CHANNEL_TYPE_ADVANCED_CUSTOM && (
<>
{' '}
{t(
'For advanced custom channels this applies only to /v1/responses routes without protocol conversion.'
)}
</>
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === true}
onCheckedChange={field.onChange}
disabled={props.disabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
</FormItem>
)}
/>
)
}
@@ -0,0 +1,424 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowRightLeft, ChevronDown, Info } from 'lucide-react'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
categorizeModels,
categorizeModelsWithRedirect,
normalizeModelName,
} from '../lib'
function sortedModelCategories(models: string[]): [string, string[]][] {
return Object.entries(categorizeModels(models)).sort(([a], [b]) => {
if (a === 'Other') return 1
if (b === 'Other') return -1
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
}
type ModelCategoryProps = {
name: string
models: string[]
selected: string[]
redirectOnly: Set<string>
onChange: (models: string[]) => void
onRedirectModel?: (model: string) => void
aliases?: Record<string, string>
}
function ModelCategory(props: ModelCategoryProps) {
const { t } = useTranslation()
const categoryName = props.name === 'Other' ? t('Other') : props.name
const id = useId()
const selected = new Set(props.selected)
const selectedCount = props.models.filter((model) =>
selected.has(model)
).length
const allSelected = selectedCount === props.models.length
return (
<Collapsible defaultOpen className='rounded-lg border'>
<div className='flex items-center gap-3 px-3'>
<Checkbox
aria-label={t('Select all models in {{category}}', {
category: categoryName,
})}
checked={allSelected}
indeterminate={selectedCount > 0 && !allSelected}
onCheckedChange={(checked) => {
if (checked) {
props.onChange([...new Set([...props.selected, ...props.models])])
} else {
props.onChange(
props.selected.filter((model) => !props.models.includes(model))
)
}
}}
/>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
className='min-w-0 flex-1 justify-between px-0 hover:bg-transparent aria-expanded:bg-transparent dark:hover:bg-transparent'
/>
}
>
<span className='truncate'>
{categoryName} ({props.models.length})
</span>
<span className='text-muted-foreground ml-auto text-xs'>
{selectedCount} / {props.models.length}
</span>
<ChevronDown className='size-4' aria-hidden='true' />
</CollapsibleTrigger>
</div>
<CollapsibleContent className='border-t px-3 py-2'>
<div className='grid gap-2 sm:grid-cols-2'>
{props.models.map((model) => (
<div key={model} className='flex min-w-0 items-start gap-2'>
<Checkbox
id={`${id}-${model}`}
className='mt-0.5 shrink-0'
checked={selected.has(model)}
onCheckedChange={(checked) =>
props.onChange(
checked
? [...props.selected, model]
: props.selected.filter((item) => item !== model)
)
}
/>
<Label
htmlFor={`${id}-${model}`}
className='min-w-0 cursor-pointer text-sm font-normal break-all'
>
{model}
</Label>
{props.aliases?.[model] && (
<Badge
variant='outline'
aria-label={t('Published as {{model}}', {
model: props.aliases[model],
})}
title={t('Published as {{model}}', {
model: props.aliases[model],
})}
className='max-w-40 shrink-0 truncate font-normal'
>
{props.aliases[model]}
</Badge>
)}
{props.redirectOnly.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger
render={
<Info className='size-3.5 shrink-0 text-amber-500' />
}
/>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
{props.onRedirectModel && (
<Button
type='button'
variant='ghost'
size='icon-xs'
className='text-muted-foreground hover:text-foreground -my-1 ml-auto shrink-0'
aria-label={t('Redirect {{model}}', { model })}
title={t('Set up redirect')}
onClick={() => props.onRedirectModel?.(model)}
>
<ArrowRightLeft aria-hidden='true' />
</Button>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
type ChangeTab = 'new' | 'existing' | 'removed'
type UpstreamModelSelectionProps = {
models: string[]
selected: string[]
onChange: (models: string[]) => void
existingModels: string[]
redirectModels?: string[]
redirectSourceModels?: string[]
showChanges?: boolean
summaryText?: string
/** Renders a per-model action that starts a redirect for that model. */
onRedirectModel?: (model: string) => void
/** Upstream model name to the request name it is published under. */
aliases?: Record<string, string>
}
export function UpstreamModelSelection(props: UpstreamModelSelectionProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
// Keep models seen in this picker session available even after deselection.
const [candidateModels, setCandidateModels] = useState(props.selected)
const candidateSet = new Set(candidateModels)
if (props.selected.some((model) => !candidateSet.has(model))) {
setCandidateModels([...new Set([...candidateModels, ...props.selected])])
}
const categorized = useMemo(() => {
const classification = categorizeModelsWithRedirect(
props.existingModels,
props.redirectModels ?? []
)
const models = [
...new Set(props.models.map(normalizeModelName).filter(Boolean)),
]
const modelSet = new Set(models)
const sourceSet = new Set(
(props.redirectSourceModels ?? []).map(normalizeModelName)
)
const keyword = search.toLowerCase().trim()
const filtered = models.filter((model) =>
model.toLowerCase().includes(keyword)
)
const existing = filtered.filter((model) =>
classification.classificationSet.has(model)
)
const added = filtered.filter(
(model) => !classification.classificationSet.has(model)
)
const removed = [
...new Set(candidateModels.map(normalizeModelName).filter(Boolean)),
].filter(
(model) =>
!modelSet.has(model) &&
!sourceSet.has(model) &&
model.toLowerCase().includes(keyword)
)
return {
filtered,
existing,
added,
removed,
redirectOnly: classification.redirectOnlySet,
}
}, [
props.existingModels,
props.models,
props.redirectModels,
props.redirectSourceModels,
candidateModels,
search,
])
const showChanges = props.showChanges ?? true
const counts = {
new: categorized.added.length,
existing: categorized.existing.length,
removed: categorized.removed.length,
}
let defaultTab: ChangeTab = 'existing'
if (counts.new) defaultTab = 'new'
else if (counts.removed) defaultTab = 'removed'
// A fresh upstream list resets the tab to the default; selection and alias
// edits keep the current tab so the user is not yanked elsewhere. Only an
// emptied tab falls through, preferring the rows the user was working on.
const [tabState, setTabState] = useState({
models: props.models,
tab: defaultTab,
})
if (tabState.models !== props.models) {
setTabState({ models: props.models, tab: defaultTab })
}
let fallbackTab: ChangeTab = 'new'
if (counts.existing) fallbackTab = 'existing'
else if (counts.removed) fallbackTab = 'removed'
const activeTab = counts[tabState.tab] > 0 ? tabState.tab : fallbackTab
return (
<div className='space-y-3'>
<Input
aria-label={t('Search models...')}
placeholder={t('Search models...')}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{!showChanges && (
<div className='flex items-center justify-between gap-2'>
<span className='text-muted-foreground text-xs'>
{props.summaryText ??
t('Fetched {{count}} models', { count: props.models.length })}
</span>
{search.trim().length > 0 && (
<Button
type='button'
variant='outline'
size='sm'
disabled={!categorized.filtered.length}
onClick={() =>
props.onChange([
...new Set([...props.selected, ...categorized.filtered]),
])
}
>
{t('Select all matching models')}
</Button>
)}
</div>
)}
{showChanges ? (
<Tabs
value={activeTab}
onValueChange={(value) => {
if (
value === 'new' ||
value === 'existing' ||
value === 'removed'
) {
setTabState({ models: props.models, tab: value })
}
}}
>
<TabsList className='flex h-auto w-full flex-wrap'>
<TabsTrigger value='new' disabled={!categorized.added.length}>
{t('New Models ({{count}})', { count: categorized.added.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={!categorized.existing.length}
>
{t('Existing Models ({{count}})', {
count: categorized.existing.length,
})}
</TabsTrigger>
{categorized.removed.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: categorized.removed.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{sortedModelCategories(categorized.added).map(([name, models]) => (
<ModelCategory
key={name}
name={name}
models={models}
selected={props.selected}
redirectOnly={categorized.redirectOnly}
onChange={props.onChange}
onRedirectModel={props.onRedirectModel}
aliases={props.aliases}
/>
))}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{sortedModelCategories(categorized.existing).map(
([name, models]) => (
<ModelCategory
key={name}
name={name}
models={models}
selected={props.selected}
redirectOnly={categorized.redirectOnly}
onChange={props.onChange}
onRedirectModel={props.onRedirectModel}
aliases={props.aliases}
/>
)
)}
</TabsContent>
{categorized.removed.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models were not returned by the upstream. Uncheck to remove them, or select them again before saving. Model mapping source aliases are excluded.'
)}
</p>
<ModelCategory
name={t('Removed')}
models={categorized.removed}
selected={props.selected}
redirectOnly={categorized.redirectOnly}
onChange={props.onChange}
onRedirectModel={props.onRedirectModel}
aliases={props.aliases}
/>
</TabsContent>
)}
</Tabs>
) : (
<div className='max-h-96 space-y-2 overflow-y-auto'>
{sortedModelCategories(categorized.filtered).map(([name, models]) => (
<ModelCategory
key={name}
name={name}
models={models}
selected={props.selected}
redirectOnly={categorized.redirectOnly}
onChange={props.onChange}
onRedirectModel={props.onRedirectModel}
aliases={props.aliases}
/>
))}
{!categorized.filtered.length && (
<p className='text-muted-foreground py-3 text-sm'>
{t('No matching items')}
</p>
)}
</div>
)}
<p className='text-muted-foreground text-sm'>
{t('{{n}} model(s) selected', { n: props.selected.length })}
</p>
</div>
)
}
+130 -6
View File
@@ -21,10 +21,18 @@ For commercial licensing, please contact support@quantumnous.com
// All label/name values are i18n keys; use t(value) when displaying.
// ============================================================================
export const CHANNEL_TYPE_OLLAMA = 4
export const CHANNEL_TYPE_SUB2API = 59
export const CHANNEL_TYPE_NEW_API = 60
export const CHANNEL_TYPE_TASK_PLUGIN = 61
export const CHANNEL_TYPE_VLLM = 62
export const CHANNEL_TYPE_SGLANG = 63
export const CHANNEL_TYPES = {
0: 'Unknown',
1: 'OpenAI',
@@ -52,7 +60,7 @@ export const CHANNEL_TYPES = {
23: 'Tencent',
24: 'Gemini',
25: 'Moonshot',
26: 'Zhipu V4',
26: 'Zhipu GLM',
27: 'Perplexity',
31: 'LingYiWanWu',
33: 'AWS',
@@ -84,11 +92,97 @@ export const CHANNEL_TYPES = {
59: 'Sub2API',
60: 'New API',
61: 'Task Plugin',
62: 'vLLM',
63: 'SGLang',
} as const
export type ChannelProviderPresentation = {
descriptionKey: string
detailKey?: string
badge?: { labelKey: string; tone: 'warning' | 'primary' }
}
// Display copy only; channel routing, availability and ordering remain independent.
export const CHANNEL_PROVIDER_PRESENTATION: Partial<
Record<number, ChannelProviderPresentation>
> = {
1: { descriptionKey: 'Connect to the OpenAI API or compatible services' },
2: { descriptionKey: 'Generate Midjourney images through MjProxy' },
3: { descriptionKey: 'Connect to OpenAI models deployed on Azure' },
4: { descriptionKey: 'Connect to local or self-hosted Ollama models' },
5: { descriptionKey: 'Generate Midjourney images through MjProxyPlus' },
7: { descriptionKey: 'Access model services through the OhMyGPT gateway' },
8: {
descriptionKey:
'Legacy full-URL integration; use Advanced Custom for new channels',
badge: { labelKey: 'Deprecated', tone: 'warning' },
},
14: { descriptionKey: 'Connect to the Anthropic API or compatible services' },
15: { descriptionKey: 'Access Baidu Qianfan models through the legacy API' },
16: { descriptionKey: 'Access Zhipu models through the legacy API' },
17: { descriptionKey: 'Connect to Alibaba Cloud Bailian model services' },
18: { descriptionKey: 'Connect to iFlytek Spark model services' },
19: { descriptionKey: 'Connect to 360 model services' },
20: {
descriptionKey: 'Access models from multiple providers through OpenRouter',
},
22: { descriptionKey: 'Connect to FastGPT applications' },
23: { descriptionKey: 'Connect to Tencent Hunyuan model services' },
24: { descriptionKey: 'Connect to models through the Google Gemini API' },
25: { descriptionKey: 'Connect to Moonshot AI model services' },
26: { descriptionKey: 'Access Zhipu models through the V4 API' },
27: { descriptionKey: 'Connect to Perplexity model services' },
31: { descriptionKey: 'Connect to LingYiWanWu model services' },
33: { descriptionKey: 'Access models through Amazon Bedrock' },
34: { descriptionKey: 'Connect to Cohere model services' },
35: { descriptionKey: 'Connect to MiniMax model services' },
36: { descriptionKey: 'Generate music and lyrics through SunoAPI' },
37: { descriptionKey: 'Connect to Dify applications and workflows' },
38: { descriptionKey: 'Connect to Jina embedding and reranking services' },
39: { descriptionKey: 'Access models through Cloudflare Workers AI' },
40: { descriptionKey: 'Connect to SiliconFlow model inference services' },
41: { descriptionKey: 'Access models through Google Cloud Vertex AI' },
42: { descriptionKey: 'Connect to Mistral AI model services' },
43: { descriptionKey: 'Connect to DeepSeek model services' },
44: { descriptionKey: 'Access model services through MokaAI' },
45: { descriptionKey: 'Connect to Volcengine Ark model services' },
46: { descriptionKey: 'Access Baidu Qianfan models through the V2 API' },
47: { descriptionKey: 'Connect to self-hosted models served by Xinference' },
48: { descriptionKey: 'Connect to xAI Grok model services' },
49: { descriptionKey: 'Connect to Coze bots' },
50: { descriptionKey: 'Connect to Kling video generation services' },
51: {
descriptionKey: 'Connect to Jimeng image and video generation services',
},
52: { descriptionKey: 'Connect to Vidu video generation services' },
53: { descriptionKey: 'Connect to Submodel model services' },
54: {
descriptionKey: 'Generate Doubao Seedance videos through Volcengine Ark',
},
55: { descriptionKey: 'Connect to OpenAI Sora video generation services' },
56: { descriptionKey: 'Access hosted model predictions through Replicate' },
57: { descriptionKey: 'Access Codex using ChatGPT subscription credentials' },
58: {
descriptionKey:
'Configure endpoint routing, authentication and protocol conversion for different upstream services',
detailKey:
"New API's flexible channel lets you configure upstream addresses and authentication per endpoint, choose native forwarding or supported protocol conversions, and configure model listing and balance queries independently",
badge: { labelKey: 'Flexible integration', tone: 'primary' },
},
59: { descriptionKey: 'Connect to model services through a Sub2API gateway' },
60: {
descriptionKey: 'Connect to model services from another New API instance',
},
62: { descriptionKey: 'Connect to self-hosted models served by vLLM' },
63: { descriptionKey: 'Connect to self-hosted models served by SGLang' },
} satisfies Record<
Exclude<keyof typeof CHANNEL_TYPES, 0 | typeof CHANNEL_TYPE_TASK_PLUGIN>,
ChannelProviderPresentation
>
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 61, 42, 34, 20, 4, 40, 27, 25, 17, 26,
15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21,
1, 14, 24, 33, 43, 3, 41, 17, 45, 25, 26, 23, 48, 60, 58, 59, 61, 42, 34, 20,
4, 62, 40, 27, 15, 46, 18, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21,
44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56,
]
@@ -381,7 +475,7 @@ export const FIELD_DESCRIPTIONS = {
'List of models supported by this channel. Use comma to separate multiple models.',
GROUP: 'User groups that can access this channel. ',
MODEL_MAPPING:
'Map request model names to actual provider model names (JSON format)',
'For this channel, map the model name in client requests to the model name sent upstream.',
PRIORITY: 'Higher priority channels are selected first',
WEIGHT: 'Used for load balancing. Higher weight = more requests',
TEST_MODEL: 'Model to use when testing channel connectivity',
@@ -402,8 +496,30 @@ export const FIELD_DESCRIPTIONS = {
// ============================================================================
export const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
59, 60,
1,
4,
14,
17,
20,
23,
24,
25,
26,
27,
31,
34,
35,
40,
42,
43,
47,
48,
57,
58,
59,
60,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
])
export const FIELD_PASSTHROUGH_TYPES = new Set([
@@ -413,6 +529,8 @@ export const FIELD_PASSTHROUGH_TYPES = new Set([
58,
59,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
])
export const OPENAI_FIELD_PASSTHROUGH_TYPES = new Set([
@@ -421,6 +539,8 @@ export const OPENAI_FIELD_PASSTHROUGH_TYPES = new Set([
58,
59,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
])
export const CLAUDE_FIELD_PASSTHROUGH_TYPES = new Set([
@@ -428,6 +548,8 @@ export const CLAUDE_FIELD_PASSTHROUGH_TYPES = new Set([
58,
59,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
])
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
@@ -441,6 +563,8 @@ export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
59: 'Enter API key for this channel',
60: 'Enter API key for this channel',
62: 'vLLM API key, or EMPTY if authentication is disabled',
63: 'SGLang API key, or EMPTY if authentication is disabled',
}
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { renderHook } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import { useRedirectPanelPlacement } from '../use-redirect-panel-placement'
function mockSheet(rect: { left: number; right: number }, innerWidth: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: innerWidth,
})
const anchor = document.createElement('div')
anchor.dataset.slot = 'sheet-content'
document.body.append(anchor)
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
x: rect.left,
y: 0,
left: rect.left,
top: 0,
right: rect.right,
bottom: 800,
width: rect.right - rect.left,
height: 800,
toJSON: () => ({}),
} as DOMRect)
return { current: anchor }
}
afterEach(() => {
document.body.innerHTML = ''
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1024,
})
})
test('a left-hand sheet starts the window in the free space on its right', () => {
const ref = mockSheet({ left: 0, right: 900 }, 1920)
const { result } = renderHook(() =>
useRedirectPanelPlacement(ref, true, 'left')
)
expect(result.current).toEqual({ x: 916, y: 16, width: 420 })
})
test('a right-hand sheet starts the window on its left, overlapping the sheet when the viewport has no room beside it', () => {
const roomy = mockSheet({ left: 1000, right: 1920 }, 1920)
expect(
renderHook(() => useRedirectPanelPlacement(roomy, true, 'right')).result
.current
).toEqual({ x: 564, y: 16, width: 420 })
const cramped = mockSheet({ left: 100, right: 1296 }, 1296)
expect(
renderHook(() => useRedirectPanelPlacement(cramped, true, 'right')).result
.current
).toEqual({ x: 16, y: 16, width: 420 })
})
test('there is no placement on narrow viewports or while disabled', () => {
const narrow = mockSheet({ left: 0, right: 700 }, 1200)
expect(
renderHook(() => useRedirectPanelPlacement(narrow, true, 'left')).result
.current
).toBe(null)
const disabled = mockSheet({ left: 0, right: 900 }, 1920)
expect(
renderHook(() => useRedirectPanelPlacement(disabled, false, 'left')).result
.current
).toBe(null)
})
@@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useSecureVerification } from '@/features/auth/secure-verification'
import { handleServerError } from '@/lib/handle-server-error'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { getChannelKey } from '../api'
@@ -72,13 +74,13 @@ export function useChannelKeyDisclosure(
)
if (operation.current !== current) return
if (!res.success) {
throw new Error(res.message || t('Failed to fetch channel key'))
throw createServerError(res, t('Failed to fetch channel key'))
}
setDisclosedKey({ channelId, key: res.data?.key ?? '' })
toast.success(t('Channel key unlocked'))
} catch (error) {
if (operation.current === current && !current.signal.aborted) {
toast.error(t(AuthOperationError.from(error).message))
handleServerError(AuthOperationError.from(error))
}
} finally {
if (operation.current === current) {
@@ -0,0 +1,84 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { createServerError } from '@/lib/server-error-message'
import { fetchModels, fetchUpstreamModels } from '../api'
type DiscoveryState = {
status: 'idle' | 'loading' | 'success' | 'error' | 'stale'
models: string[]
error?: unknown
}
export type ChannelModelDiscoveryRequest =
| { kind: 'saved'; channelId: number }
| { kind: 'preview'; data: Parameters<typeof fetchModels>[0] }
type ChannelModelDiscoveryProps = {
enabled: boolean
request: ChannelModelDiscoveryRequest
}
export function useChannelModelDiscovery(props: ChannelModelDiscoveryProps) {
const [state, setState] = useState<DiscoveryState>({
status: 'idle',
models: [],
})
const sequence = useRef(0)
useEffect(() => {
sequence.current += 1
setState((previous) => {
if (previous.status === 'idle') return previous
if (!props.enabled) return { status: 'idle', models: [] }
return { status: 'stale', models: previous.models }
})
return () => {
sequence.current += 1
}
}, [props.enabled, props.request])
const fetch = useCallback(async () => {
if (!props.enabled) return
const requestSequence = ++sequence.current
setState((previous) => ({ status: 'loading', models: previous.models }))
try {
const response =
props.request.kind === 'saved'
? await fetchUpstreamModels(props.request.channelId)
: await fetchModels(props.request.data)
if (requestSequence !== sequence.current) return
if (!response.success) {
throw createServerError(response, 'Failed to fetch models')
}
setState({ status: 'success', models: response.data ?? [] })
} catch (error) {
if (requestSequence !== sequence.current) return
setState((previous) => ({
status: 'error',
models: previous.models,
error,
}))
}
}, [props.enabled, props.request])
return { ...state, fetch }
}
@@ -25,6 +25,8 @@ import {
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { handleServerError } from '@/lib/handle-server-error'
import { createServerError } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import { createChannel, updateChannel } from '../api'
@@ -55,31 +57,6 @@ const SENSITIVE_UPDATE_FIELDS = [
'other',
] satisfies (keyof Channel)[]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
if (!isRecord(error)) return undefined
const response = error.response
if (isRecord(response)) {
const data = response.data
if (isRecord(data)) {
const message = data.message
if (typeof message === 'string') return message
}
}
const message = error.message
if (typeof message === 'string') return message
return undefined
}
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
const currentUser = useAuthStore((s) => s.auth.user)
@@ -115,12 +92,14 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
}
: payload
const response = await updateChannel(
props.currentRow.id,
payloadWithKeyMode
)
const response = await updateChannel(props.currentRow.id, {
...payloadWithKeyMode,
...(canEditSensitive && props.isMultiKeyChannel
? { multi_key_mode: data.multi_key_type }
: {}),
})
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
throw createServerError(response, t(ERROR_MESSAGES.UPDATE_FAILED))
}
return SUCCESS_MESSAGES.UPDATED
}
@@ -128,7 +107,7 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const payload = transformFormDataToCreatePayload(data)
const response = await createChannel(payload)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
throw createServerError(response, t(ERROR_MESSAGES.CREATE_FAILED))
}
return SUCCESS_MESSAGES.CREATED
},
@@ -137,7 +116,7 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
props.onSuccess()
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
handleServerError(error, t(ERROR_MESSAGES.CREATE_FAILED))
},
})
}
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api, type ApiRequestConfig } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import { normalizeModelList } from '../lib/upstream-update-utils'
@@ -31,9 +32,9 @@ const upstreamUpdateRequestConfig = {
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object')
if (settings && typeof settings === 'object') {
parsed = settings as Record<string, unknown>
else if (typeof settings === 'string') {
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
@@ -126,9 +127,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
const { success, data } = res.data || {}
if (!success) {
toast.error(message || t('Operation failed'))
handleServerError(res.data, t('Operation failed'))
return
}
@@ -146,13 +147,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
closeModal()
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Operation failed')
)
handleServerError(e, t('Operation failed'))
} finally {
applyRef.current = false
setApplyLoading(false)
@@ -171,9 +166,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
const { success, data } = res.data || {}
if (!success) {
toast.error(message || t('Batch processing failed'))
handleServerError(res.data, t('Batch processing failed'))
return
}
@@ -190,15 +185,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch processing failed')
)
handleServerError(e, t('Batch processing failed'))
} finally {
applyAllRef.current = false
setApplyAllLoading(false)
@@ -215,9 +202,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{ id: ch.id },
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
const { success, data } = res.data || {}
if (!success) {
toast.error(message || t('Detection failed'))
handleServerError(res.data, t('Detection failed'))
return
}
@@ -229,13 +216,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Detection failed')
)
handleServerError(e, t('Detection failed'))
} finally {
detectRef.current = false
}
@@ -253,9 +234,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{},
upstreamUpdateRequestConfig
)
const { success, message } = res.data || {}
const { success } = res.data || {}
if (!success) {
toast.error(message || t('Batch detection failed'))
handleServerError(res.data, t('Batch detection failed'))
return
}
@@ -266,15 +247,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch detection failed')
)
handleServerError(e, t('Batch detection failed'))
} finally {
detectAllRef.current = false
setDetectAllLoading(false)
@@ -0,0 +1,67 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type RefObject, useLayoutEffect, useState } from 'react'
export type RedirectPanelPlacement = {
x: number
y: number
width: number
}
/** Below this viewport width the floating panel is not offered at all. */
export const REDIRECT_PANEL_MIN_VIEWPORT = 1280
const GAP = 16
const WIDTH = 420
/**
* Picks where the redirect window first appears: beside the channel sheet on
* its free side, or overlapping the sheet's inner edge when the viewport has
* no room there. The window is draggable afterwards, so this is only a start
* position. `null` means the viewport is too narrow for a floating window.
*/
export function useRedirectPanelPlacement(
anchorRef: RefObject<HTMLElement | null>,
enabled: boolean,
sheetSide: 'left' | 'right'
): RedirectPanelPlacement | null {
const [placement, setPlacement] = useState<RedirectPanelPlacement | null>(
null
)
useLayoutEffect(() => {
if (!enabled || window.innerWidth < REDIRECT_PANEL_MIN_VIEWPORT) {
setPlacement(null)
return
}
const anchor =
anchorRef.current?.closest<HTMLElement>('[data-slot="sheet-content"]') ??
anchorRef.current
if (!anchor) {
setPlacement(null)
return
}
const rect = anchor.getBoundingClientRect()
const beside =
sheetSide === 'left' ? rect.right + GAP : rect.left - GAP - WIDTH
const x = Math.min(Math.max(GAP, beside), window.innerWidth - WIDTH - GAP)
setPlacement({ x, y: rect.top + GAP, width: WIDTH })
}, [anchorRef, enabled, sheetSide])
return placement
}
+4 -3
View File
@@ -29,6 +29,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ROLE } from '@/lib/roles'
import { requireServerSuccess } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import { getChannelOps } from './api'
@@ -44,7 +45,7 @@ export function Channels() {
)
const channelOpsQuery = useQuery({
queryKey: ['channel-ops'],
queryFn: getChannelOps,
queryFn: async () => requireServerSuccess(await getChannelOps()),
retry: false,
staleTime: 5 * 60 * 1000,
})
@@ -63,8 +64,8 @@ export function Channels() {
aria-label={t('Retry Settings')}
render={
<Link
to='/system-settings/models/$section'
params={{ section: 'routing-reliability' }}
to='/system-settings/request-policies/$section'
params={{ section: 'routing' }}
/>
}
/>
@@ -0,0 +1,97 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import {
getHeaderPassthroughState,
setHeaderPassthrough,
} from '../header-passthrough'
describe('getHeaderPassthroughState', () => {
test.each([
['undefined', undefined, 'disabled'],
['empty string', '', 'disabled'],
['whitespace only', ' \n', 'disabled'],
['empty object', '{}', 'disabled'],
['object without wildcard', '{"X-Foo":"bar"}', 'disabled'],
['regex rule only', '{"re:^X-Trace-.*$": true}', 'disabled'],
['wildcard with boolean value', '{"*": true}', 'enabled'],
['wildcard with empty string value', '{"*": ""}', 'enabled'],
['padded wildcard key', '{" * ": true}', 'enabled'],
['wildcard among other rules', '{"X-Foo":"bar","*":true}', 'enabled'],
['invalid JSON', '{"*": tru', 'invalid'],
['JSON array', '["*"]', 'invalid'],
['JSON string', '"*"', 'invalid'],
] as const)('reports %s as %s', (_label, headerOverride, expected) => {
expect(getHeaderPassthroughState(headerOverride)).toBe(expected)
})
})
describe('setHeaderPassthrough', () => {
test('enabling on an empty field writes the wildcard rule alone', () => {
expect(JSON.parse(setHeaderPassthrough('', true))).toEqual({ '*': true })
expect(JSON.parse(setHeaderPassthrough(undefined, true))).toEqual({
'*': true,
})
})
test('enabling keeps existing overrides and lists the wildcard first', () => {
const next = setHeaderPassthrough(
'{"Authorization":"Bearer {api_key}","X-Foo":"{client_header:X-Foo}"}',
true
)
expect(JSON.parse(next)).toEqual({
'*': true,
Authorization: 'Bearer {api_key}',
'X-Foo': '{client_header:X-Foo}',
})
expect(Object.keys(JSON.parse(next))[0]).toBe('*')
})
test('enabling when already enabled normalizes a padded key to "*"', () => {
expect(JSON.parse(setHeaderPassthrough('{" * ": ""}', true))).toEqual({
'*': true,
})
})
test('disabling removes only the wildcard rule and keeps other entries', () => {
const next = setHeaderPassthrough(
'{"*": true, "re:^X-Trace-.*$": true, "X-Foo": "bar"}',
false
)
expect(JSON.parse(next)).toEqual({
're:^X-Trace-.*$': true,
'X-Foo': 'bar',
})
})
test('disabling the last rule clears the field to an empty string', () => {
expect(setHeaderPassthrough('{"*": true}', false)).toBe('')
expect(setHeaderPassthrough('', false)).toBe('')
})
test('invalid JSON is returned unchanged in both directions', () => {
expect(setHeaderPassthrough('{"*": tru', true)).toBe('{"*": tru')
expect(setHeaderPassthrough('{"*": tru', false)).toBe('{"*": tru')
})
test('output is pretty-printed with two-space indentation', () => {
expect(setHeaderPassthrough('', true)).toBe('{\n "*": true\n}')
})
})
@@ -0,0 +1,149 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, it } from 'vitest'
import {
metricMean,
metricValue,
metricRatio,
recentMetrics,
type InferenceMetric,
type InferenceStatus,
} from '../inference-status'
function snapshot(at: number, metrics: InferenceMetric[]): InferenceStatus {
return {
sampled_at: at,
endpoints: { '/metrics': { status: 200 } },
version: 'test',
models: [],
metrics,
raw_metrics: '',
}
}
describe('vLLM metric summaries', () => {
it('preserves real zeroes and leaves missing or invalid metrics unavailable', () => {
const metrics = [{ name: 'running', labels: {}, value: 0 }]
expect(metricValue(metrics, 'running')).toBe(0)
expect(metricValue(metrics, 'missing')).toBeUndefined()
expect(
metricValue([{ name: 'bad', labels: {}, value: Number.NaN }], 'bad')
).toBeUndefined()
expect(metricRatio(0, 100)).toBe(0)
expect(metricRatio(0, 0)).toBeUndefined()
})
it('sums engine counters but uses peak occupancy and weighted histogram means', () => {
const metrics = [
{ name: 'running', labels: { engine: '0' }, value: 1 },
{ name: 'running', labels: { engine: '1' }, value: 3 },
{ name: 'latency_sum', labels: { engine: '0' }, value: 10 },
{ name: 'latency_count', labels: { engine: '0' }, value: 2 },
{ name: 'latency_sum', labels: { engine: '1' }, value: 50 },
{ name: 'latency_count', labels: { engine: '1' }, value: 8 },
]
expect(metricValue(metrics, 'running')).toBe(4)
expect(metricValue(metrics, 'running', {}, 'max')).toBe(3)
expect(metricValue(metrics, 'running', { engine: '0' })).toBe(1)
expect(metricMean(metrics, 'latency')).toBe(6)
})
it('computes counter deltas using elapsed sample time regardless of label order', () => {
const previous = snapshot(1000, [
{
name: 'vllm:generation_tokens_total',
labels: { model_name: 'm', engine: '0' },
value: 100,
},
])
const current = snapshot(6000, [
{
name: 'vllm:generation_tokens_total',
labels: { engine: '0', model_name: 'm' },
value: 150,
},
])
const recent = recentMetrics(current, previous)
expect(recent?.seconds).toBe(5)
expect(
metricRatio(
metricValue(recent?.metrics ?? [], 'vllm:generation_tokens_total'),
recent?.seconds
)
).toBe(10)
expect(recentMetrics(current)).toBeUndefined()
expect(recentMetrics(previous, current)).toBeUndefined()
})
it('restarts the window when one engine resets even if the combined total increases', () => {
const previous = snapshot(1000, [
{ name: 'vllm:prompt_tokens_total', labels: { engine: '0' }, value: 100 },
{ name: 'vllm:prompt_tokens_total', labels: { engine: '1' }, value: 100 },
])
const current = snapshot(6000, [
{ name: 'vllm:prompt_tokens_total', labels: { engine: '0' }, value: 0 },
{ name: 'vllm:prompt_tokens_total', labels: { engine: '1' }, value: 300 },
])
expect(recentMetrics(current, previous)).toBeUndefined()
current.metrics[0].value = 30
expect(
recentMetrics(
{ ...current, sampled_at: 11000 },
snapshot(6000, [
{
name: 'vllm:prompt_tokens_total',
labels: { engine: '0' },
value: 0,
},
{
name: 'vllm:prompt_tokens_total',
labels: { engine: '1' },
value: 300,
},
])
)?.metrics[0].value
).toBe(30)
})
it('rejects restarted processes, changing series and unavailable metrics', () => {
const previous = snapshot(1000, [
{ name: 'process_start_time_seconds', labels: {}, value: 1 },
])
expect(
recentMetrics(
snapshot(6000, [
{ name: 'process_start_time_seconds', labels: {}, value: 2 },
]),
previous
)
).toBeUndefined()
expect(recentMetrics(snapshot(6000, []), previous)).toBeUndefined()
expect(
recentMetrics(
{
...previous,
sampled_at: 6000,
endpoints: { '/metrics': { status: 404, error: 'http_error' } },
},
previous
)
).toBeUndefined()
})
})
@@ -0,0 +1,128 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import {
deriveModelMappingPairs,
mergeModelMappingPairs,
} from '../model-mapping-rules'
describe('deriveModelMappingPairs', () => {
test('stripping one of several suffixes from upstream names yields request-to-upstream pairs and skips unchanged models', () => {
expect(
deriveModelMappingPairs(
[' gpt-4o-all ', 'claude-sonnet-latest', 'o3', 'gpt-4o-all'],
'upstream',
{ type: 'strip-suffix', values: '-all, -latest' }
)
).toEqual({
pairs: [
{ from: 'gpt-4o', to: 'gpt-4o-all' },
{ from: 'claude-sonnet', to: 'claude-sonnet-latest' },
],
unchanged: ['o3'],
conflicts: [],
})
})
test('stripping a vendor prefix reports models that collapse onto the same request name', () => {
expect(
deriveModelMappingPairs(
['openai/gpt-4o', 'azure/gpt-4o', 'anthropic/claude'],
'upstream',
{ type: 'strip-prefix', values: 'openai/,azure/, anthropic/' }
)
).toEqual({
pairs: [
{ from: 'gpt-4o', to: 'openai/gpt-4o' },
{ from: 'claude', to: 'anthropic/claude' },
],
unchanged: [],
conflicts: ['azure/gpt-4o'],
})
})
test('request names derive upstream names by adding an affix or replacing text', () => {
expect(
deriveModelMappingPairs(['gemini-2.5-flash'], 'request', {
type: 'add-suffix',
value: '-all',
}).pairs
).toEqual([{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }])
expect(
deriveModelMappingPairs(['gpt-4o'], 'request', {
type: 'add-prefix',
value: 'openai/',
}).pairs
).toEqual([{ from: 'gpt-4o', to: 'openai/gpt-4o' }])
expect(
deriveModelMappingPairs(['claude-3.5-sonnet'], 'upstream', {
type: 'replace',
find: '.',
replaceWith: '-',
}).pairs
).toEqual([{ from: 'claude-3-5-sonnet', to: 'claude-3.5-sonnet' }])
})
test('an empty replace search or an affix that strips the whole name produces no pair', () => {
expect(
deriveModelMappingPairs(['gpt-4o'], 'request', {
type: 'replace',
find: '',
replaceWith: 'x',
})
).toEqual({ pairs: [], unchanged: ['gpt-4o'], conflicts: [] })
expect(
deriveModelMappingPairs(['-all'], 'upstream', {
type: 'strip-suffix',
values: '-all',
})
).toEqual({ pairs: [], unchanged: ['-all'], conflicts: [] })
})
})
describe('mergeModelMappingPairs', () => {
test('appends new request names and updates existing ones in place', () => {
expect(
mergeModelMappingPairs('{"gpt-4o":"old","o3":"o3-all"}', [
{ from: 'gpt-4o', to: 'gpt-4o-all' },
{ from: 'claude', to: 'claude-all' },
])
).toBe(
'{\n "gpt-4o": "gpt-4o-all",\n "o3": "o3-all",\n "claude": "claude-all"\n}'
)
})
test('starts from an empty document and returns an empty string when nothing remains', () => {
expect(mergeModelMappingPairs(' ', [{ from: 'a', to: 'b' }])).toBe(
'{\n "a": "b"\n}'
)
expect(mergeModelMappingPairs('', [])).toBe('')
})
test('refuses to merge into invalid or non-object documents', () => {
expect(mergeModelMappingPairs('{not json', [{ from: 'a', to: 'b' }])).toBe(
null
)
expect(mergeModelMappingPairs('["a"]', [{ from: 'a', to: 'b' }])).toBe(null)
expect(mergeModelMappingPairs('{"a":1}', [{ from: 'a', to: 'b' }])).toBe(
null
)
})
})
@@ -0,0 +1,92 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import {
detectModelNamingPatterns,
findVerifiedAlias,
} from '../model-naming-patterns'
describe('detectModelNamingPatterns', () => {
test('a relay suffix is suggested for every model carrying it, while known names are never candidates', () => {
const suggestions = detectModelNamingPatterns(
['gpt-4o-all', 'brand-new-all', 'gpt-4o-mini', 'o3'],
['gpt-4o', 'gpt-4o-mini', 'o3']
)
expect(suggestions).toEqual([
{
id: 'strip-suffix:-all',
rule: { type: 'strip-suffix', values: '-all' },
models: ['gpt-4o-all', 'brand-new-all'],
},
])
})
test('vendor prefixes need two models or one verified name, and dotted versions need a known dashed name', () => {
expect(
detectModelNamingPatterns(
['openai/gpt-4o', 'openai/o3', 'solo/thing', 'claude-3.5-sonnet'],
['claude-3-5-sonnet']
)
).toEqual([
{
id: 'strip-prefix:openai/',
rule: { type: 'strip-prefix', values: 'openai/' },
models: ['openai/gpt-4o', 'openai/o3'],
},
{
id: 'replace:.:-',
rule: { type: 'replace', find: '.', replaceWith: '-' },
models: ['claude-3.5-sonnet'],
},
])
})
test('date suffixes are grouped per date and arbitrary suffixes such as -mini are not suggested', () => {
const suggestions = detectModelNamingPatterns(
['gpt-4o-2024-08-06', 'gpt-4o-2024-11-20', 'o3-mini'],
['gpt-4o', 'o3']
)
expect(suggestions.map((item) => item.id)).toEqual([
'strip-suffix:-2024-08-06',
'strip-suffix:-2024-11-20',
])
})
})
describe('findVerifiedAlias', () => {
const suggestions = detectModelNamingPatterns(
['gpt-4o-all', 'brand-new-all'],
['gpt-4o']
)
test('returns the pair when the derived request name is a known model', () => {
expect(findVerifiedAlias('gpt-4o-all', suggestions, ['gpt-4o'])).toEqual({
from: 'gpt-4o',
to: 'gpt-4o-all',
})
})
test('returns null for unknown derived names and for models outside every suggestion', () => {
expect(findVerifiedAlias('brand-new-all', suggestions, ['gpt-4o'])).toBe(
null
)
expect(findVerifiedAlias('o3', suggestions, ['gpt-4o', 'o3'])).toBe(null)
})
})
@@ -20,10 +20,16 @@ import { describe, expect, test } from 'vitest'
import {
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
CHANNEL_TYPE_OPTIONS,
MODEL_FETCHABLE_TYPES,
} from '../../constants'
import { CHANNEL_FORM_DEFAULT_VALUES, channelFormSchema } from '../channel-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
channelFormSchema,
transformFormDataToCreatePayload,
} from '../channel-form'
import { getChannelTypeConfig } from '../channel-type-config'
import { getChannelTypeIcon, getKeyPromptForType } from '../channel-utils'
@@ -89,3 +95,53 @@ describe('New API channel', () => {
expect(result.success).toBe(true)
})
})
describe.each([
{ type: CHANNEL_TYPE_VLLM, name: 'vLLM', icon: 'Vllm' },
{ type: CHANNEL_TYPE_SGLANG, name: 'SGLang', icon: 'SGLang' },
])('$name channel', ({ type, name, icon }) => {
test('can be selected and discover served models', () => {
expect(CHANNEL_TYPE_OPTIONS).toContainEqual({
value: type,
label: name,
})
expect(MODEL_FETCHABLE_TYPES.has(type)).toBe(true)
expect(getChannelTypeIcon(type)).toBe(icon)
expect(getChannelTypeConfig(type).icon).toBe(icon)
expect(getKeyPromptForType(type)).toBe(
`${name} API key, or EMPTY if authentication is disabled`
)
})
test('requires an upstream address and submits the served model name', () => {
const form = {
...newAPIForm(''),
type,
models: 'deepseek-v4-flash-vision-exp',
key: 'EMPTY',
}
const blank = channelFormSchema.safeParse(form)
expect(blank.success).toBe(false)
if (!blank.success) {
expect(blank.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ['base_url'],
message: 'Base URL is required for this channel type',
}),
])
)
}
const parsed = channelFormSchema.parse({
...form,
base_url: 'http://vllm:8000/',
})
const payload = transformFormDataToCreatePayload(parsed)
expect(payload.channel).toMatchObject({
type,
base_url: 'http://vllm:8000',
models: 'deepseek-v4-flash-vision-exp',
key: 'EMPTY',
})
})
})
@@ -509,6 +509,12 @@ export function isAdvancedCustomIncomingPathAllowed(
return isConverterPathAllowed(incomingPath, converter)
}
export function isAdvancedCustomPassThroughAllowed(
converter: AdvancedCustomConverter
): boolean {
return converter === 'none'
}
export function getAdvancedCustomConverterOptions(
incomingPath: string
): typeof ADVANCED_CUSTOM_CONVERTER_OPTIONS {
@@ -656,6 +662,12 @@ export function validateAdvancedCustomConfig(
message: `${routeLabel} upstream path must not contain {model}`,
}
}
if (route.pass_through_body_enabled) {
return {
routeIndex: index,
message: `${routeLabel} route does not support pass-through`,
}
}
}
const routeModelsError = validateAdvancedCustomRouteModels(
index,
@@ -685,6 +697,15 @@ export function validateAdvancedCustomConfig(
message: 'Converter does not match incoming path',
}
}
if (
route.pass_through_body_enabled &&
!isAdvancedCustomPassThroughAllowed(converter)
) {
return {
routeIndex: index,
message: 'Pass-through requires native forwarding',
}
}
const authError = validateRouteAuth(route.auth)
if (authError) {
@@ -781,6 +802,9 @@ function normalizeAdvancedCustomRoute(
if (models.length > 0) {
nextRoute.models = models
}
if (route.pass_through_body_enabled === true) {
nextRoute.pass_through_body_enabled = true
}
if (route.auth) {
nextRoute.auth = {
type: route.auth.type,
@@ -20,6 +20,8 @@ import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import {
copyChannel,
deleteChannel,
@@ -46,6 +48,8 @@ import type { ChannelTestResponse, CopyChannelParams } from '../types'
export const channelsQueryKeys = {
all: ['channels'] as const,
defaultBaseURLs: () =>
[...channelsQueryKeys.all, 'default_base_urls'] as const,
lists: () => [...channelsQueryKeys.all, 'list'] as const,
list: (params: Record<string, unknown>) =>
[...channelsQueryKeys.lists(), params] as const,
@@ -126,10 +130,10 @@ export async function handleEnableChannel(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -151,10 +155,10 @@ export async function handleDisableChannel(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -189,10 +193,10 @@ export async function handleDeleteChannel(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
@@ -221,10 +225,10 @@ export async function handleUpdateChannelField(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -255,10 +259,10 @@ export async function handleUpdateTagField(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -314,7 +318,8 @@ export async function handleTestChannel(
} else {
const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
if (!options?.silent) {
toast.error(i18next.t('{{target}} test failed', { target }), {
handleServerError(response, undefined, {
title: i18next.t('{{target}} test failed', { target }),
description: response.error_code
? `${errorMsg} (${response.error_code})`
: errorMsg,
@@ -328,7 +333,8 @@ export async function handleTestChannel(
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
const target = getChannelTestLabel(options)
if (!options?.silent) {
toast.error(i18next.t('{{target}} test failed', { target }), {
handleServerError(_error, undefined, {
title: i18next.t('{{target}} test failed', { target }),
description: errorMsg,
})
}
@@ -352,10 +358,10 @@ export async function handleCopyChannel(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data?.id ?? 0)
} else {
toast.error(response.message || i18next.t('Failed to copy channel'))
handleServerError(response, i18next.t('Failed to copy channel'))
}
} catch {
toast.error(i18next.t('Failed to copy channel'))
} catch (error) {
handleServerError(error, i18next.t('Failed to copy channel'))
}
}
@@ -387,10 +393,10 @@ export async function handleBatchDelete(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || ids.length)
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
handleServerError(response, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
} catch (error) {
handleServerError(error, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
@@ -421,14 +427,14 @@ export async function handleBatchEnable(
}
if (!response.success) {
toast.error(response.message || i18next.t('Failed to enable channels'))
handleServerError(response, i18next.t('Failed to enable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
}
} catch {
toast.error(i18next.t('Failed to enable channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to enable channels'))
}
}
@@ -462,7 +468,7 @@ export async function handleBatchDisable(
}
if (!response.success) {
toast.error(response.message || i18next.t('Failed to disable channels'))
handleServerError(response, i18next.t('Failed to disable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to disable', {
@@ -470,8 +476,8 @@ export async function handleBatchDisable(
})
)
}
} catch {
toast.error(i18next.t('Failed to disable channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to disable channels'))
}
}
@@ -496,10 +502,10 @@ export async function handleBatchSetTag(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t('Failed to set tag'))
handleServerError(response, i18next.t('Failed to set tag'))
}
} catch {
toast.error(i18next.t('Failed to set tag'))
} catch (error) {
handleServerError(error, i18next.t('Failed to set tag'))
}
}
@@ -524,12 +530,10 @@ export async function handleEnableTagChannels(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to enable tag channels')
)
handleServerError(response, i18next.t('Failed to enable tag channels'))
}
} catch {
toast.error(i18next.t('Failed to enable tag channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to enable tag channels'))
}
}
@@ -550,12 +554,10 @@ export async function handleDisableTagChannels(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to disable tag channels')
)
handleServerError(response, i18next.t('Failed to disable tag channels'))
}
} catch {
toast.error(i18next.t('Failed to disable tag channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to disable tag channels'))
}
}
@@ -581,12 +583,13 @@ export async function handleDeleteAllDisabled(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || 0)
} else {
toast.error(
response.message || i18next.t('Failed to delete disabled channels')
handleServerError(
response,
i18next.t('Failed to delete disabled channels')
)
}
} catch {
toast.error(i18next.t('Failed to delete disabled channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to delete disabled channels'))
}
}
@@ -612,12 +615,13 @@ export async function handleFixAbilities(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
} else {
toast.error(
response.message || i18next.t('Failed to repair channel consistency')
handleServerError(
response,
i18next.t('Failed to repair channel consistency')
)
}
} catch {
toast.error(i18next.t('Failed to repair channel consistency'))
} catch (error) {
handleServerError(error, i18next.t('Failed to repair channel consistency'))
}
}
@@ -639,12 +643,13 @@ export async function handleTestAllChannels(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to start testing all channels')
handleServerError(
response,
i18next.t('Failed to start testing all channels')
)
}
} catch {
toast.error(i18next.t('Failed to test all channels'))
} catch (error) {
handleServerError(error, i18next.t('Failed to test all channels'))
}
}
@@ -666,11 +671,9 @@ export async function handleUpdateAllBalances(
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to update all balances')
)
handleServerError(response, i18next.t('Failed to update all balances'))
}
} catch {
toast.error(i18next.t('Failed to update all balances'))
} catch (error) {
handleServerError(error, i18next.t('Failed to update all balances'))
}
}
@@ -0,0 +1,240 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { FieldErrors, FieldPath } from 'react-hook-form'
import {
CHANNEL_TYPE_OLLAMA,
CLAUDE_FIELD_PASSTHROUGH_TYPES,
MODEL_FETCHABLE_TYPES,
OPENAI_FIELD_PASSTHROUGH_TYPES,
} from '../constants'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from './advanced-custom'
import { channelFormSchema, type ChannelFormValues } from './channel-form'
import { supportsResponsesWebSocket } from './responses-websocket'
export type ChannelProviderTarget =
| { kind: 'builtin'; type: number }
| { kind: 'plugin'; key: string }
export type ChannelConfigurationSection =
| 'connection'
| 'routing'
| 'request'
| 'other'
export type ChannelConfigurationStatus =
| 'idle'
| 'configured'
| 'ready'
| 'error'
const CONFIGURATION_BLOCKS = {
modelMapping: { section: 'routing', fields: ['model_mapping'] },
routingStrategy: {
section: 'routing',
fields: ['priority', 'weight', 'test_model', 'auto_ban'],
},
overrideRules: {
section: 'request',
fields: ['status_code_mapping', 'param_override', 'header_override'],
},
requestProcessing: {
section: 'request',
fields: [
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
'responses_astra_model_guard',
'responses_websocket_enabled',
'ollama_openai_chat',
'system_prompt',
'system_prompt_override',
],
},
fieldPassthrough: {
section: 'request',
fields: [
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
],
},
extraSettings: {
section: 'other',
fields: [
'proxy',
'http_protocol',
'http2_connection_shards',
'disable_task_polling_sleep',
],
},
upstreamModelDetection: {
section: 'other',
fields: [
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
],
},
internalNotes: { section: 'other', fields: ['tag', 'remark'] },
} as const satisfies Record<
string,
{
section: ChannelConfigurationSection
fields: readonly FieldPath<ChannelFormValues>[]
}
>
export type ChannelConfigurationBlock = keyof typeof CONFIGURATION_BLOCKS
export function getChannelConfigurationSection(
field: string
): ChannelConfigurationSection {
for (const block of Object.values(CONFIGURATION_BLOCKS)) {
if ((block.fields as readonly string[]).includes(field)) {
return block.section
}
}
return 'connection'
}
function hasConfiguredJson(value: string | undefined): boolean {
const trimmed = value?.trim()
if (!trimmed || trimmed === 'null') return false
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed.length > 0
if (typeof parsed === 'object') return Object.keys(parsed).length > 0
} catch {
// Preserve the presence of invalid input; validation errors take precedence.
}
return true
}
export function getChannelConfigurationState(
values: ChannelFormValues,
errors: FieldErrors<ChannelFormValues>,
isEditing: boolean
) {
const openaiPassthrough = OPENAI_FIELD_PASSTHROUGH_TYPES.has(values.type)
const claudePassthrough = CLAUDE_FIELD_PASSTHROUGH_TYPES.has(values.type)
const configured: Record<ChannelConfigurationBlock, boolean> = {
modelMapping: hasConfiguredJson(values.model_mapping),
routingStrategy: Boolean(
values.priority ||
values.weight ||
values.test_model?.trim() ||
(values.auto_ban ?? 1) !== 1
),
overrideRules:
hasConfiguredJson(values.status_code_mapping) ||
hasConfiguredJson(values.param_override) ||
hasConfiguredJson(values.header_override),
requestProcessing: Boolean(
(values.type === 1 && values.force_format) ||
values.thinking_to_content ||
(values.type !== CHANNEL_TYPE_ADVANCED_CUSTOM &&
values.pass_through_body_enabled) ||
(supportsResponsesWebSocket(values.type) &&
values.responses_websocket_enabled) ||
(values.type === CHANNEL_TYPE_OLLAMA && values.ollama_openai_chat) ||
values.system_prompt?.trim() ||
values.system_prompt_override
),
fieldPassthrough: Boolean(
((openaiPassthrough || claudePassthrough) &&
(values.allow_service_tier || values.allow_inference_geo)) ||
(openaiPassthrough &&
(values.disable_store ||
values.allow_safety_identifier ||
values.allow_include_obfuscation)) ||
(claudePassthrough &&
(values.allow_speed ||
(values.type === 14 && values.claude_beta_query)))
),
extraSettings: Boolean(
values.proxy?.trim() ||
(values.http_protocol && values.http_protocol !== 'auto') ||
(values.http2_connection_shards ?? 1) > 1 ||
values.disable_task_polling_sleep
),
upstreamModelDetection:
MODEL_FETCHABLE_TYPES.has(values.type) &&
Boolean(
values.upstream_model_update_check_enabled ||
values.upstream_model_update_auto_sync_enabled ||
values.upstream_model_update_ignored_models?.trim()
),
internalNotes: Boolean(values.tag?.trim() || values.remark?.trim()),
}
const blocks = {} as Record<
ChannelConfigurationBlock,
ChannelConfigurationStatus
>
const sections: Record<
ChannelConfigurationSection,
ChannelConfigurationStatus
> = {
connection: 'idle',
routing: 'idle',
request: 'idle',
other: 'idle',
}
for (const id of Object.keys(
CONFIGURATION_BLOCKS
) as ChannelConfigurationBlock[]) {
const block = CONFIGURATION_BLOCKS[id]
const hasError = block.fields.some((field) => Boolean(errors[field]))
blocks[id] = configured[id] ? 'configured' : 'idle'
if (hasError) blocks[id] = 'error'
if (hasError || (sections[block.section] !== 'error' && configured[id])) {
sections[block.section] = blocks[id]
}
}
// Reuse provider-specific validation without displaying errors before submit.
const validation = channelFormSchema.safeParse(values)
const connectionInvalid =
!validation.success &&
validation.error.issues.some(
(issue) =>
getChannelConfigurationSection(String(issue.path[0])) === 'connection'
)
if (
!connectionInvalid &&
values.name.trim() &&
values.type > 0 &&
values.models.trim() &&
(isEditing || values.key?.trim())
) {
sections.connection = 'ready'
}
if (
Object.keys(errors).some(
(field) => getChannelConfigurationSection(field) === 'connection'
)
) {
sections.connection = 'error'
}
return { blocks, sections }
}
@@ -38,6 +38,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
'responses_websocket_enabled',
'proxy',
'http_protocol',
'http2_connection_shards',
@@ -50,6 +51,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'ollama_openai_chat',
'disable_task_polling_sleep',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
+42 -4
View File
@@ -21,7 +21,10 @@ import { z } from 'zod'
import {
CLAUDE_FIELD_PASSTHROUGH_TYPES,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_OLLAMA,
CHANNEL_TYPE_TASK_PLUGIN,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
CHANNEL_STATUS,
ERROR_MESSAGES,
FIELD_PASSTHROUGH_TYPES,
@@ -264,6 +267,8 @@ export const channelFormSchema = z
http_protocol: z.enum(['auto', 'http1']).optional(),
http2_connection_shards: z.number().int().optional(),
pass_through_body_enabled: z.boolean().optional(),
responses_websocket_enabled: z.boolean().optional(),
responses_astra_model_guard: z.boolean().optional(),
system_prompt: z.string().optional(),
system_prompt_override: z.boolean().optional(),
// Type-specific settings (stored in settings JSON)
@@ -279,6 +284,7 @@ export const channelFormSchema = z
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
ollama_openai_chat: z.boolean().optional(), // Ollama: OpenAI-compatible /v1/chat/completions instead of native /api/chat
disable_task_polling_sleep: z.boolean().optional(),
// Upstream model update settings (stored in settings JSON)
upstream_model_update_check_enabled: z.boolean().optional(),
@@ -287,9 +293,16 @@ export const channelFormSchema = z
})
.superRefine((data, ctx) => {
if (
[3, 8, 36, 45, CHANNEL_TYPE_NEW_API, CHANNEL_TYPE_TASK_PLUGIN].includes(
data.type
) &&
[
3,
8,
36,
45,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_TASK_PLUGIN,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
].includes(data.type) &&
!data.base_url?.trim()
) {
addRequiredIssue(
@@ -445,6 +458,8 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
http_protocol: HTTP_PROTOCOL_AUTO,
http2_connection_shards: 1,
pass_through_body_enabled: false,
responses_astra_model_guard: true,
responses_websocket_enabled: false,
system_prompt: '',
system_prompt_override: false,
// Type-specific settings
@@ -460,6 +475,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
allow_inference_geo: false,
allow_speed: false,
claude_beta_query: false,
ollama_openai_chat: false,
disable_task_polling_sleep: false,
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
@@ -479,6 +495,7 @@ export function transformChannelToFormDefaults(
): ChannelFormValues {
// Parse channel extra settings from setting field
let extraSettings = {
responses_astra_model_guard: true,
task_plugin_key: '',
force_format: false,
thinking_to_content: false,
@@ -486,6 +503,7 @@ export function transformChannelToFormDefaults(
http_protocol: HTTP_PROTOCOL_AUTO as 'auto' | 'http1',
http2_connection_shards: 1,
pass_through_body_enabled: false,
responses_websocket_enabled: false,
system_prompt: '',
system_prompt_override: false,
}
@@ -498,6 +516,8 @@ export function transformChannelToFormDefaults(
parsed.http2_connection_shards
)
extraSettings = {
responses_astra_model_guard:
parsed.responses_astra_model_guard !== false,
task_plugin_key: parsed.task_plugin_key || '',
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
@@ -505,6 +525,8 @@ export function transformChannelToFormDefaults(
http_protocol: protocol,
http2_connection_shards: protocol === HTTP_PROTOCOL_HTTP1 ? 1 : shards,
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
responses_websocket_enabled:
parsed.responses_websocket_enabled === true,
system_prompt: parsed.system_prompt || '',
system_prompt_override: parsed.system_prompt_override || false,
}
@@ -526,6 +548,7 @@ export function transformChannelToFormDefaults(
let allowInferenceGeo = false
let allowSpeed = false
let claudeBetaQuery = false
let ollamaOpenAIChat = false
let disableTaskPollingSleep = false
let upstreamModelUpdateCheckEnabled = false
let upstreamModelUpdateAutoSyncEnabled = false
@@ -546,6 +569,7 @@ export function transformChannelToFormDefaults(
allowInferenceGeo = parsed.allow_inference_geo === true
allowSpeed = parsed.allow_speed === true
claudeBetaQuery = parsed.claude_beta_query === true
ollamaOpenAIChat = parsed.ollama_openai_chat === true
disableTaskPollingSleep = parsed.disable_task_polling_sleep === true
upstreamModelUpdateCheckEnabled =
parsed.upstream_model_update_check_enabled === true
@@ -604,6 +628,7 @@ export function transformChannelToFormDefaults(
allow_inference_geo: allowInferenceGeo,
allow_speed: allowSpeed,
claude_beta_query: claudeBetaQuery,
ollama_openai_chat: ollamaOpenAIChat,
disable_task_polling_sleep: disableTaskPollingSleep,
allow_safety_identifier: allowSafetyIdentifier,
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
@@ -618,6 +643,7 @@ export function transformChannelToFormDefaults(
*/
export function buildSettingJSON(formData: ChannelFormValues): string {
const settingObj: Record<string, unknown> = {
responses_astra_model_guard: formData.responses_astra_model_guard !== false,
task_plugin_key:
formData.type === CHANNEL_TYPE_TASK_PLUGIN
? formData.task_plugin_key?.trim() || ''
@@ -625,7 +651,12 @@ export function buildSettingJSON(formData: ChannelFormValues): string {
force_format: formData.force_format || false,
thinking_to_content: formData.thinking_to_content || false,
proxy: formData.proxy?.trim() || '',
pass_through_body_enabled: formData.pass_through_body_enabled || false,
pass_through_body_enabled:
formData.type !== CHANNEL_TYPE_ADVANCED_CUSTOM &&
formData.pass_through_body_enabled === true,
responses_websocket_enabled:
(formData.type === 1 || formData.type === 57) &&
formData.responses_websocket_enabled === true,
system_prompt: formData.system_prompt || '',
system_prompt_override: formData.system_prompt_override || false,
}
@@ -740,6 +771,13 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
delete settingsObj.claude_beta_query
}
// Only the Ollama adaptor can switch chat completions to the OpenAI-compatible endpoint.
if (formData.type === CHANNEL_TYPE_OLLAMA) {
settingsObj.ollama_openai_chat = formData.ollama_openai_chat === true
} else if ('ollama_openai_chat' in settingsObj) {
delete settingsObj.ollama_openai_chat
}
settingsObj.disable_task_polling_sleep =
formData.disable_task_polling_sleep === true
@@ -0,0 +1,48 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TaskPluginOption } from '../api'
import { CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_TASK_PLUGIN } from '../constants'
// These task-only legacy types are replaced by plugins, not extended by them.
export const LEGACY_TASK_PLUGIN_KEYS: Readonly<
Partial<Record<number, string>>
> = {
36: 'sunoapi',
50: 'kling',
51: 'jimeng',
52: 'vidu',
54: 'doubao',
55: 'sora',
}
export function supportsChannelPluginExtensions(channelType: number): boolean {
return (
channelType !== CHANNEL_TYPE_TASK_PLUGIN &&
!LEGACY_TASK_PLUGIN_KEYS[channelType] &&
CHANNEL_TYPE_OPTIONS.some((option) => option.value === channelType)
)
}
export function getChannelPluginExtensions(
channelType: number,
plugins: TaskPluginOption[]
): TaskPluginOption[] {
if (!supportsChannelPluginExtensions(channelType)) return []
return plugins.filter((plugin) => plugin.channelTypes?.includes(channelType))
}
Loaded 100 of 828 files, more files were not shown because too many files have changed in this diff. Show more