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

+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))
}
@@ -16,7 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { CHANNEL_TYPES } from '../constants'
import {
CHANNEL_TYPES,
CHANNEL_TYPE_VLLM,
CHANNEL_TYPE_SGLANG,
} from '../constants'
// ============================================================================
// Channel Type Configuration
@@ -26,7 +30,6 @@ export interface ChannelTypeConfig {
id: number
name: string
icon: string
defaultBaseUrl?: string
requiresOrganization?: boolean
requiresRegion?: boolean
supportedModels?: string[]
@@ -46,14 +49,32 @@ export interface ChannelTypeConfig {
* Configuration for each channel type
*/
export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
[CHANNEL_TYPE_SGLANG]: {
id: CHANNEL_TYPE_SGLANG,
name: CHANNEL_TYPES[CHANNEL_TYPE_SGLANG],
icon: 'SGLang',
hints: {
baseUrl: 'SGLang server address, without /v1',
key: 'SGLang API key, or EMPTY if authentication is disabled',
models: 'Models fetched from upstream /v1/models',
},
},
[CHANNEL_TYPE_VLLM]: {
id: CHANNEL_TYPE_VLLM,
name: CHANNEL_TYPES[CHANNEL_TYPE_VLLM],
icon: 'Vllm',
hints: {
baseUrl: 'vLLM server address, without /v1',
key: 'vLLM API key, or EMPTY if authentication is disabled',
models: 'Models fetched from upstream /v1/models',
},
},
1: {
id: 1,
name: CHANNEL_TYPES[1],
icon: 'openai',
defaultBaseUrl: 'https://api.openai.com',
requiresOrganization: true,
hints: {
baseUrl: 'Default: https://api.openai.com',
key: 'Format: sk-...',
models: 'gpt-4,gpt-4-turbo,gpt-3.5-turbo',
},
@@ -77,7 +98,6 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
id: 14,
name: CHANNEL_TYPES[14],
icon: 'anthropic',
defaultBaseUrl: 'https://api.anthropic.com',
hints: {
key: 'Format: sk-ant-...',
models: 'claude-3-opus,claude-3-sonnet,claude-3-haiku',
@@ -107,7 +127,6 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
id: 43,
name: CHANNEL_TYPES[43],
icon: 'deepseek',
defaultBaseUrl: 'https://api.deepseek.com',
hints: {
key: 'DeepSeek API Key',
models: 'deepseek-chat,deepseek-coder',
@@ -117,7 +136,6 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
id: 20,
name: CHANNEL_TYPES[20],
icon: 'openrouter',
defaultBaseUrl: 'https://openrouter.ai/api',
hints: {
key: 'OpenRouter API Key',
models: 'Use model IDs from OpenRouter',
@@ -127,11 +145,9 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
id: 56,
name: CHANNEL_TYPES[56],
icon: 'replicate',
defaultBaseUrl: 'https://api.replicate.com',
hints: {
key: 'Replicate API Token',
models: 'Replicate model IDs',
baseUrl: 'Default: https://api.replicate.com',
},
},
58: {
@@ -193,13 +209,6 @@ export function requiresRegion(type: number): boolean {
return CHANNEL_TYPE_CONFIGS[type]?.requiresRegion || false
}
/**
* Get default base URL for channel type
*/
export function getDefaultBaseUrl(type: number): string {
return CHANNEL_TYPE_CONFIGS[type]?.defaultBaseUrl || ''
}
/**
* Get hints for channel type
*/
@@ -54,6 +54,8 @@ export function getChannelTypeIcon(type: number): string {
58: 'NewAPI', // Advanced Custom
59: 'Sub2API', // Sub2API
60: 'NewAPI', // New API
62: 'Vllm', // vLLM
63: 'SGLang', // SGLang
3: 'Azure', // Azure
// Anthropic
@@ -0,0 +1,83 @@
/*
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
*/
/**
* The header override rule key the relay treats as "forward every client
* request header" (see processHeaderOverride in relay/channel/api_request.go).
* The quick "Pass Through Request Headers" switch is a shortcut for this rule.
*/
export const HEADER_PASSTHROUGH_ALL_KEY = '*'
export type HeaderPassthroughState = 'enabled' | 'disabled' | 'invalid'
function parseHeaderOverrideObject(
headerOverride: string | undefined
): Record<string, unknown> | null {
const trimmed = headerOverride?.trim()
if (!trimmed) return {}
try {
const parsed: unknown = JSON.parse(trimmed)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// Fall through: invalid JSON cannot be edited structurally.
}
return null
}
function isPassthroughAllKey(key: string): boolean {
return key.trim() === HEADER_PASSTHROUGH_ALL_KEY
}
/**
* Reports whether the header override JSON contains the wildcard passthrough
* rule. `invalid` means the text is not a JSON object and cannot be toggled.
*/
export function getHeaderPassthroughState(
headerOverride: string | undefined
): HeaderPassthroughState {
const parsed = parseHeaderOverrideObject(headerOverride)
if (parsed === null) return 'invalid'
return Object.keys(parsed).some(isPassthroughAllKey) ? 'enabled' : 'disabled'
}
/**
* Adds or removes the wildcard passthrough rule while preserving every other
* header override entry. Invalid JSON is returned unchanged; removing the last
* entry yields an empty string so the field reads as unconfigured.
*/
export function setHeaderPassthrough(
headerOverride: string | undefined,
enabled: boolean
): string {
const parsed = parseHeaderOverrideObject(headerOverride)
if (parsed === null) return headerOverride ?? ''
const rest = Object.fromEntries(
Object.entries(parsed).filter(([key]) => !isPassthroughAllKey(key))
)
if (!enabled) {
return Object.keys(rest).length === 0 ? '' : JSON.stringify(rest, null, 2)
}
return JSON.stringify(
{ [HEADER_PASSTHROUGH_ALL_KEY]: true, ...rest },
null,
2
)
}
+2
View File
@@ -26,4 +26,6 @@ export * from './channel-type-config'
export * from './channel-utils'
export * from './multi-key-utils'
export * from './model-mapping-validation'
export * from './model-mapping-rules'
export * from './model-naming-patterns'
export * from './model-categories'
@@ -0,0 +1,140 @@
/*
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
*/
export type InferenceMetric = {
name: string
labels: Record<string, string>
value: number
}
export type InferenceStatus = {
sampled_at: number
endpoints: Record<string, { status: number; error?: string }>
version: string
models: { id: string; root: string; max_model_len: number | null }[]
metrics: InferenceMetric[]
raw_metrics: string
}
export function metricValue(
metrics: InferenceMetric[],
name: string,
labels: Record<string, string> = {},
mode: 'sum' | 'max' = 'sum'
): number | undefined {
const values = metrics
.filter(
(metric) =>
metric.name === name &&
Object.entries(labels).every(
([key, value]) => metric.labels[key] === value
)
)
.map((metric) => metric.value)
if (
!values.length ||
values.some((value) => !Number.isFinite(value) || value < 0)
) {
return undefined
}
if (mode === 'max') return Math.max(...values)
const total = values.reduce((sum, value) => sum + value, 0)
return Number.isFinite(total) ? total : undefined
}
export function metricRatio(
numerator: number | undefined,
denominator: number | undefined
): number | undefined {
if (
numerator === undefined ||
denominator === undefined ||
denominator <= 0
) {
return undefined
}
const ratio = numerator / denominator
return Number.isFinite(ratio) ? ratio : undefined
}
export function metricMean(
metrics: InferenceMetric[],
name: string
): number | undefined {
return metricRatio(
metricValue(metrics, `${name}_sum`),
metricValue(metrics, `${name}_count`)
)
}
function metricSeriesKey(metric: InferenceMetric): string {
return JSON.stringify([
metric.name,
Object.entries(metric.labels).sort(([a], [b]) => a.localeCompare(b)),
])
}
export function recentMetrics(
current: InferenceStatus,
previous?: InferenceStatus
): { seconds: number; metrics: InferenceMetric[] } | undefined {
if (
!previous ||
current.endpoints['/metrics']?.status !== 200 ||
previous.endpoints['/metrics']?.status !== 200 ||
current.endpoints['/metrics']?.error ||
previous.endpoints['/metrics']?.error ||
current.sampled_at <= previous.sampled_at
) {
return undefined
}
const old = new Map(
previous.metrics.map((metric) => [metricSeriesKey(metric), metric.value])
)
const metrics: InferenceMetric[] = []
const tracked = current.metrics.filter(
(metric) =>
metric.name === 'process_start_time_seconds' ||
/_(total|sum|count|created)$/.test(metric.name)
)
const previousTracked = previous.metrics.filter(
(metric) =>
metric.name === 'process_start_time_seconds' ||
/_(total|sum|count|created)$/.test(metric.name)
)
if (tracked.length !== previousTracked.length) return undefined
for (const metric of tracked) {
const last = old.get(metricSeriesKey(metric))
if (
last === undefined ||
!Number.isFinite(metric.value) ||
metric.value < last
) {
return undefined
}
if (
metric.name === 'process_start_time_seconds' ||
metric.name.endsWith('_created')
) {
if (metric.value !== last) return undefined
continue
}
metrics.push({ ...metric, value: metric.value - last })
}
return { seconds: (current.sampled_at - previous.sampled_at) / 1000, metrics }
}
@@ -17,146 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
type ModelCategoryRule = {
name: string
keywords?: readonly string[]
pattern?: RegExp
}
// Rules are ordered so platform-specific IDs such as Perplexity's Sonar and
// NVIDIA's Nemotron take precedence over the base Llama/Mixtral family name.
const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [
{ name: 'Perplexity', keywords: ['perplexity', 'sonar-'] },
{ name: 'NVIDIA', keywords: ['nvidia/', 'nvidia.', 'nemotron'] },
{
name: 'OpenAI',
keywords: [
'openai/',
'openai.',
'gpt-',
'chatgpt-',
'codex-',
'dall-e-',
'whisper-',
'omni-moderation-',
'text-moderation-',
'text-embedding-ada-',
'text-embedding-3-',
'text-ada-',
'text-babbage-',
'text-curie-',
'davinci-',
'babbage-',
'computer-use-preview',
'sora',
],
pattern: /(?:^|[/.:])(?:o(?:1|3|4)(?=$|[-.:])|tts-)/,
},
{ name: 'Anthropic', keywords: ['anthropic', 'claude'] },
{
name: 'Gemini',
keywords: [
'gemini',
'gemma',
'learnlm',
'imagen',
'veo',
'nano-banana',
'palm-',
],
pattern: /(?:^|[/.:])aqa$/,
},
{ name: 'xAI', keywords: ['x-ai/', 'xai/', 'xai-', 'grok'] },
{ name: 'DeepSeek', keywords: ['deepseek'] },
{
name: 'Qwen',
keywords: ['qwen', 'qwq-', 'qvq-', 'tongyi', 'gte-'],
pattern: /(?:^|[/.:])(?:text-embedding-v\d+|gui-plus|z-image)(?:$|[-_.:])/,
},
{ name: 'Wan', pattern: /(?:^|[/.:])wan(?:x?\d|[-_])/ },
{ name: 'Moonshot', keywords: ['moonshot', 'kimi-'] },
{
name: 'MiniMax',
keywords: ['minimax', 'abab', 'hailuo'],
pattern: /^(?:t2v|i2v|s2v)-01(?:-|$)/,
},
{
name: 'Doubao',
keywords: ['doubao', 'volcengine', 'seedance', 'seedream', 'seed-1-'],
},
{
name: 'Zhipu',
keywords: ['zhipu', 'zai-org', 'thudm', 'chatglm', 'cogview', 'cogvideo'],
pattern: /(?:^|[/._-])glm(?=$|[-._])/,
},
{ name: 'Baidu', keywords: ['baidu', 'wenxin', 'ernie'] },
{ name: 'Yi', keywords: ['01-ai/'], pattern: /(?:^|[/.:])yi(?=$|[-_])/ },
{ name: 'iFlytek', keywords: ['iflytek', 'sparkdesk'] },
{
name: 'Tencent',
keywords: ['tencent', 'hunyuan'],
pattern: /(?:^|[/.:])hy\d*(?=$|[-_.:])/,
},
{ name: 'Baichuan', keywords: ['baichuan'] },
{ name: 'InternLM', keywords: ['internlm'] },
{ name: 'StepFun', keywords: ['stepfun', 'step-'] },
{ name: 'MiMo', keywords: ['xiaomi', 'mimo-'] },
{
name: 'Mistral',
keywords: [
'mistral',
'mixtral',
'codestral',
'ministral',
'pixtral',
'magistral',
],
},
{ name: 'Meta', keywords: ['meta-llama', 'llama-', 'llama2', 'llama3'] },
{
name: 'Cohere',
keywords: ['cohere', 'command-', 'c4ai-aya', 'aya-'],
pattern: /(?:^|[/.:])command$/,
},
{ name: 'Jina', keywords: ['jinaai', 'jina-'] },
{ name: 'BAAI', keywords: ['baai/', 'bge-'] },
{ name: 'Black Forest Labs', keywords: ['black-forest-labs', 'flux.'] },
{
name: 'Microsoft',
keywords: ['microsoft/'],
pattern: /(?:^|[/.:])phi(?=$|[-._])/,
},
{
name: 'Amazon',
keywords: ['amazon/', 'amazon.', 'nova-', 'titan-'],
},
{ name: 'AI21 Labs', keywords: ['ai21', 'jamba'] },
{
name: 'Stability AI',
keywords: ['stabilityai', 'stable-diffusion', 'stable-image', 'sdxl-'],
},
{ name: 'Nous Research', keywords: ['nousresearch', 'hermes-'] },
{ name: '360 AI', keywords: ['360gpt', '360zhinao'] },
{ name: 'Midjourney', keywords: ['midjourney', 'mj_', 'mj-', 'swap_face'] },
{ name: 'Kling', keywords: ['kling'] },
{ name: 'Vidu', keywords: ['vidu'] },
{ name: 'Suno', keywords: ['suno'] },
{ name: 'Jimeng', keywords: ['jimeng'] },
]
import { resolveModelProvider } from '@/lib/model-provider'
export function getModelCategory(modelName: string): string {
const normalizedName = modelName.trim().toLowerCase()
for (const rule of MODEL_CATEGORY_RULES) {
if (
rule.keywords?.some((keyword) => normalizedName.includes(keyword)) ||
rule.pattern?.test(normalizedName)
) {
return rule.name
}
}
return 'Other'
return resolveModelProvider(modelName)?.name ?? 'Other'
}
export function categorizeModels(
@@ -0,0 +1,138 @@
/*
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
*/
export type ModelMappingPair = {
/** Request model name that users call. */
from: string
/** Upstream model name sent to the provider. */
to: string
}
/** Which side of the mapping the selected model names belong to. */
export type ModelMappingDirection = 'upstream' | 'request'
/**
* A naming rule applied to the selected names to derive the other side of the
* mapping. `strip-*` rules accept several comma-separated affixes and remove
* the first one that matches. The backend matches mapping keys exactly, so
* rules are expanded on the client into explicit pairs before saving.
*/
export type ModelMappingRule =
| { type: 'strip-prefix'; values: string }
| { type: 'strip-suffix'; values: string }
| { type: 'add-prefix'; value: string }
| { type: 'add-suffix'; value: string }
| { type: 'replace'; find: string; replaceWith: string }
export type ModelMappingDerivation = {
pairs: ModelMappingPair[]
/** Selected models the rule left unchanged; they produce no mapping. */
unchanged: string[]
/** Selected models whose request name is already taken by an earlier pair. */
conflicts: string[]
}
export function deriveModelMappingPairs(
models: readonly string[],
direction: ModelMappingDirection,
rule: ModelMappingRule
): ModelMappingDerivation {
const seen = new Set<string>()
const requestNames = new Set<string>()
const result: ModelMappingDerivation = {
pairs: [],
unchanged: [],
conflicts: [],
}
const affixes =
'values' in rule
? rule.values
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: []
for (const raw of models) {
const model = raw.trim()
if (!model || seen.has(model)) continue
seen.add(model)
let derived = model
if (rule.type === 'strip-prefix') {
const prefix = affixes.find((affix) => model.startsWith(affix))
if (prefix) derived = model.slice(prefix.length)
} else if (rule.type === 'strip-suffix') {
const suffix = affixes.find((affix) => model.endsWith(affix))
if (suffix) derived = model.slice(0, -suffix.length)
} else if (rule.type === 'add-prefix') {
derived = `${rule.value}${model}`
} else if (rule.type === 'add-suffix') {
derived = `${model}${rule.value}`
} else if (rule.find) {
derived = model.replaceAll(rule.find, rule.replaceWith)
}
derived = derived.trim()
if (!derived || derived === model) {
result.unchanged.push(model)
continue
}
const pair =
direction === 'upstream'
? { from: derived, to: model }
: { from: model, to: derived }
if (requestNames.has(pair.from)) {
result.conflicts.push(model)
continue
}
requestNames.add(pair.from)
result.pairs.push(pair)
}
return result
}
/**
* Merges pairs into a model-mapping JSON document. Existing request names keep
* their position and receive the new upstream name; new pairs are appended.
* Returns `null` when the current document is not a valid mapping object.
*/
export function mergeModelMappingPairs(
current: string,
pairs: readonly ModelMappingPair[]
): string | null {
const mapping: Record<string, string> = {}
const trimmed = current.trim()
if (trimmed) {
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') return null
mapping[key] = value
}
}
for (const pair of pairs) {
mapping[pair.from] = pair.to
}
if (Object.keys(mapping).length === 0) return ''
return JSON.stringify(mapping, null, 2)
}
@@ -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 {
deriveModelMappingPairs,
type ModelMappingPair,
type ModelMappingRule,
} from './model-mapping-rules'
/** A naming rule detected in an upstream model list, with the models it covers. */
export type ModelNamingSuggestion = {
id: string
rule: ModelMappingRule
models: string[]
}
const MAX_SUGGESTIONS = 3
/** Suffixes relay upstreams commonly append to otherwise standard names. */
const RELAY_SUFFIXES = ['-all', '-latest', '-ca']
const DATE_SUFFIX = /-\d{4}-\d{2}-\d{2}$/
/** Unverified prefix groups need this many models before they are suggested. */
const MIN_PREFIX_MATCHES = 2
/**
* Detects how an upstream list deviates from the platform's known model names:
* relay suffixes, date suffixes, vendor prefixes and dotted versions. Known
* names are never candidates, so a rule that would rename a real model is not
* suggested just because a shorter real name exists.
*/
export function detectModelNamingPatterns(
models: readonly string[],
knownModels: readonly string[]
): ModelNamingSuggestion[] {
const known = new Set(
knownModels.map((model) => model.trim()).filter(Boolean)
)
const candidates = [
...new Set(
models
.map((model) => model.trim())
.filter((model) => model && !known.has(model))
),
]
const suggestions: Array<ModelNamingSuggestion & { verified: number }> = []
for (const suffix of RELAY_SUFFIXES) {
const matched = candidates.filter(
(model) => model.endsWith(suffix) && model.length > suffix.length
)
if (matched.length === 0) continue
suggestions.push({
id: `strip-suffix:${suffix}`,
rule: { type: 'strip-suffix', values: suffix },
models: matched,
verified: matched.filter((model) =>
known.has(model.slice(0, -suffix.length))
).length,
})
}
const dateSuffixes = new Set(
candidates.map((model) => DATE_SUFFIX.exec(model)?.[0]).filter(Boolean)
)
for (const suffix of dateSuffixes) {
if (!suffix) continue
const matched = candidates.filter(
(model) => model.endsWith(suffix) && model.length > suffix.length
)
suggestions.push({
id: `strip-suffix:${suffix}`,
rule: { type: 'strip-suffix', values: suffix },
models: matched,
verified: matched.filter((model) =>
known.has(model.slice(0, -suffix.length))
).length,
})
}
const prefixes = new Set(
candidates
.map((model) => {
const slash = model.indexOf('/')
return slash > 0 && slash < model.length - 1
? model.slice(0, slash + 1)
: ''
})
.filter(Boolean)
)
for (const prefix of prefixes) {
const matched = candidates.filter((model) => model.startsWith(prefix))
const verified = matched.filter((model) =>
known.has(model.slice(prefix.length))
).length
if (verified === 0 && matched.length < MIN_PREFIX_MATCHES) continue
suggestions.push({
id: `strip-prefix:${prefix}`,
rule: { type: 'strip-prefix', values: prefix },
models: matched,
verified,
})
}
const dotted = candidates.filter(
(model) => model.includes('.') && known.has(model.replaceAll('.', '-'))
)
if (dotted.length > 0) {
suggestions.push({
id: 'replace:.:-',
rule: { type: 'replace', find: '.', replaceWith: '-' },
models: dotted,
verified: dotted.length,
})
}
return suggestions
.sort(
(a, b) =>
b.models.length - a.models.length ||
b.verified - a.verified ||
a.id.localeCompare(b.id)
)
.slice(0, MAX_SUGGESTIONS)
.map(({ id, rule, models: covered }) => ({ id, rule, models: covered }))
}
/**
* Returns the request-to-upstream pair a suggestion produces for one model,
* but only when the derived request name is a known platform model. That is
* the bar for applying an alias without asking the user first.
*/
export function findVerifiedAlias(
model: string,
suggestions: readonly ModelNamingSuggestion[],
knownModels: readonly string[]
): ModelMappingPair | null {
const known = new Set(knownModels)
for (const suggestion of suggestions) {
if (!suggestion.models.includes(model)) continue
const pair = deriveModelMappingPairs([model], 'upstream', suggestion.rule)
.pairs[0]
if (pair && known.has(pair.from)) return pair
}
return null
}
@@ -0,0 +1,37 @@
/*
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 { CHANNEL_TYPE_NEW_API, CHANNEL_TYPE_SUB2API } from '../constants'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from './advanced-custom'
/**
* Channel types whose upstream can carry the Responses WebSocket protocol.
* Mirrors the backend FilterResponsesWebSocket allow list; the per-channel
* toggle still decides whether a channel is actually used.
*/
const RESPONSES_WEBSOCKET_CHANNEL_TYPES: ReadonlySet<number> = new Set([
1,
57,
CHANNEL_TYPE_ADVANCED_CUSTOM,
CHANNEL_TYPE_SUB2API,
CHANNEL_TYPE_NEW_API,
])
export function supportsResponsesWebSocket(channelType: number): boolean {
return RESPONSES_WEBSOCKET_CHANNEL_TYPES.has(channelType)
}
+8
View File
@@ -85,6 +85,8 @@ export interface ChannelSettings {
thinking_to_content?: boolean
proxy?: string
pass_through_body_enabled?: boolean
responses_websocket_enabled?: boolean
responses_astra_model_guard?: boolean
system_prompt?: string
system_prompt_override?: boolean
http_protocol?: 'auto' | 'http1' | string
@@ -103,6 +105,7 @@ export interface ChannelOtherSettings {
allow_inference_geo?: boolean
allow_speed?: boolean
claude_beta_query?: boolean
ollama_openai_chat?: boolean
disable_task_polling_sleep?: boolean
upstream_model_update_check_enabled?: boolean
upstream_model_update_auto_sync_enabled?: boolean
@@ -122,6 +125,7 @@ export interface AdvancedCustomRoute {
converter?: AdvancedCustomConverter
models?: string[]
auth?: AdvancedCustomRouteAuth
pass_through_body_enabled?: boolean
}
export interface AdvancedCustomRouteAuth {
@@ -179,6 +183,10 @@ export interface ChannelOpsResponse {
message?: string
data?: {
retry_times: number
request_policy?: {
automatic_disable: boolean
source: string
}
}
}
@@ -17,15 +17,20 @@ 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 { t } from 'i18next'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
import {
requireServerSuccess,
createServerError,
} from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
export async function fetchActiveChatKey() {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
throw createServerError(result, t('Failed to load API keys'))
}
const items = result.data?.items ?? []
@@ -36,7 +41,7 @@ export async function fetchActiveChatKey() {
const keyResult = await fetchTokenKey(active.id)
if (!keyResult.success || !keyResult.data?.key) {
throw new Error(keyResult.message || 'Failed to load API key')
throw createServerError(keyResult, t('Failed to load API keys'))
}
return `sk-${keyResult.data.key}`
@@ -50,7 +55,7 @@ export function useActiveChatKey(enabled: boolean) {
return useQuery({
queryKey: ['chat-active-key', userId],
queryFn: fetchActiveChatKey,
queryFn: async () => requireServerSuccess(await fetchActiveChatKey()),
enabled: enabled && Boolean(userId),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@@ -86,6 +86,7 @@ import type {
} from '@/features/dashboard/types'
import { formatQuota } from '@/lib/format'
import { ROLE } from '@/lib/roles'
import { requireServerSuccess } from '@/lib/server-error-message'
import { computeTimeRange } from '@/lib/time'
import { useChartTheme } from '@/lib/use-chart-theme'
import { cn } from '@/lib/utils'
@@ -336,7 +337,8 @@ export function FlowCharts(props: FlowChartsProps) {
isLoading,
} = useQuery({
queryKey: ['dashboard', 'flow', flowQueryParams, flowRole],
queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin),
queryFn: async () =>
requireServerSuccess(await getFlowQuotaDates(flowQueryParams, isAdmin)),
select: (res) =>
requireSuccessfulFlowRows(res, t('Please try again later.')),
staleTime: 60_000,
@@ -32,62 +32,20 @@ import {
getSuccessRateTextClass,
} from '@/features/performance-metrics/lib/format'
import type { PerfModelSummary } from '@/features/performance-metrics/types'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
const PERFORMANCE_WINDOW_HOURS = 24
const TOP_MODEL_LIMIT = 6
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
type PerformanceSummary = {
totalRequests: number
avgLatencyMs: number
avgTps: number
successRate: number
}
function simpleAverage(
rows: PerfModelSummary[],
metric: WeightedMetric,
isValid: (value: number) => boolean
): number {
let total = 0
let count = 0
for (const row of rows) {
const value = Number(row[metric])
if (!isValid(value)) continue
total += value
count++
}
return count > 0 ? total / count : Number.NaN
}
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
return {
totalRequests: rows.length,
avgLatencyMs: Math.round(
simpleAverage(
rows,
'avg_latency_ms',
(value) => Number.isFinite(value) && value > 0
)
),
avgTps: simpleAverage(
rows,
'avg_tps',
(value) => Number.isFinite(value) && value > 0
),
successRate: simpleAverage(rows, 'success_rate', Number.isFinite),
}
}
export function PerformanceOverview() {
const { t } = useTranslation()
const metricsQuery = useQuery({
queryKey: ['perf-metrics-summary', PERFORMANCE_WINDOW_HOURS],
queryFn: () => getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS),
queryFn: async () =>
requireServerSuccess(
await getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS)
),
staleTime: 60 * 1000,
retry: false,
})
@@ -96,7 +54,7 @@ export function PerformanceOverview() {
() => metricsQuery.data?.data.models ?? [],
[metricsQuery.data]
)
const summary = useMemo(() => buildPerformanceSummary(models), [models])
const summary = metricsQuery.data?.data.summary
const topModels = useMemo(() => models.slice(0, TOP_MODEL_LIMIT), [models])
const loading = metricsQuery.isLoading
const hasData = models.length > 0
@@ -140,20 +98,22 @@ export function PerformanceOverview() {
<InlineMetric
icon={HeartPulse}
label={t('Success rate')}
value={formatUptimePct(summary.successRate)}
valueClassName={getSuccessRateTextClass(summary.successRate)}
value={formatUptimePct(summary?.success_rate ?? Number.NaN)}
valueClassName={getSuccessRateTextClass(
summary?.success_rate ?? Number.NaN
)}
tone='success'
/>
<InlineMetric
icon={Timer}
label={t('Average latency')}
value={formatLatency(summary.avgLatencyMs)}
value={formatLatency(summary?.avg_latency_ms ?? 0)}
tone='warning'
/>
<InlineMetric
icon={Gauge}
label={t('Throughput')}
value={formatThroughput(summary.avgTps)}
value={formatThroughput(summary?.avg_tps ?? 0)}
tone='info'
/>
</div>
@@ -52,8 +52,10 @@ import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import type { ApiKey } from '@/features/keys/types'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { getUserModels } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import { MOTION_TRANSITION } from '@/lib/motion'
import { ROLE } from '@/lib/roles'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
@@ -298,7 +300,7 @@ function RequestPreview(props: {
const result = await fetchTokenKey(props.example.keyId)
const key = result.success && result.data?.key ? result.data.key : ''
if (!key) {
toast.error(result.message || t('Failed to copy to clipboard'))
handleServerError(result, t('Failed to copy to clipboard'))
return
}
@@ -313,6 +315,8 @@ function RequestPreview(props: {
} else {
toast.error(t('Failed to copy to clipboard'))
}
} catch (error) {
handleServerError(error, t('Failed to copy to clipboard'))
} finally {
setIsCopying(false)
}
@@ -480,7 +484,7 @@ export function OverviewDashboard() {
const apiKeysQuery = useQuery({
queryKey: ['dashboard', 'overview', 'api-keys'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 10 })
const result = requireServerSuccess(await getApiKeys({ p: 1, size: 10 }))
return result.success ? (result.data?.items ?? []) : []
},
staleTime: 60 * 1000,
@@ -489,7 +493,7 @@ export function OverviewDashboard() {
const modelsQuery = useQuery({
queryKey: ['dashboard', 'overview', 'user-models'],
queryFn: async () => {
const result = await getUserModels()
const result = requireServerSuccess(await getUserModels())
return result.success ? (result.data ?? []) : []
},
staleTime: 5 * 60 * 1000,
@@ -31,35 +31,20 @@ import {
getSuccessRateDotClass,
getSuccessRateTextClass,
} from '@/features/performance-metrics/lib/format'
import type { PerfModelSummary } from '@/features/performance-metrics/types'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
const PERFORMANCE_WINDOW_HOURS = 24
const TOP_MODEL_LIMIT = 6
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
function simpleAverage(
rows: PerfModelSummary[],
metric: WeightedMetric,
isValid: (value: number) => boolean
): number {
let total = 0
let count = 0
for (const row of rows) {
const value = Number(row[metric])
if (!isValid(value)) continue
total += value
count++
}
return count > 0 ? total / count : Number.NaN
}
export function PerformanceHealthPanel() {
const { t } = useTranslation()
const metricsQuery = useQuery({
queryKey: ['perf-metrics-summary', PERFORMANCE_WINDOW_HOURS],
queryFn: () => getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS),
queryFn: async () =>
requireServerSuccess(
await getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS)
),
staleTime: 60 * 1000,
retry: false,
})
@@ -69,23 +54,7 @@ export function PerformanceHealthPanel() {
[metricsQuery.data]
)
const summary = useMemo(() => {
return {
avgLatencyMs: Math.round(
simpleAverage(
models,
'avg_latency_ms',
(v) => Number.isFinite(v) && v > 0
)
),
avgTps: simpleAverage(
models,
'avg_tps',
(v) => Number.isFinite(v) && v > 0
),
successRate: simpleAverage(models, 'success_rate', Number.isFinite),
}
}, [models])
const summary = metricsQuery.data?.data.summary
const topModels = useMemo(() => models.slice(0, TOP_MODEL_LIMIT), [models])
const loading = metricsQuery.isLoading
@@ -108,22 +77,24 @@ export function PerformanceHealthPanel() {
<MetricCell
icon={HeartPulse}
label={t('Success rate')}
value={formatUptimePct(summary.successRate)}
value={formatUptimePct(summary?.success_rate ?? Number.NaN)}
loading={loading}
valueClassName={getSuccessRateTextClass(summary.successRate)}
valueClassName={getSuccessRateTextClass(
summary?.success_rate ?? Number.NaN
)}
tone='success'
/>
<MetricCell
icon={Timer}
label={t('Average latency')}
value={formatLatency(summary.avgLatencyMs)}
value={formatLatency(summary?.avg_latency_ms ?? 0)}
loading={loading}
tone='warning'
/>
<MetricCell
icon={Gauge}
label={t('Throughput')}
value={formatThroughput(summary.avgTps)}
value={formatThroughput(summary?.avg_tps ?? 0)}
loading={loading}
tone='info'
/>
@@ -30,6 +30,7 @@ import type { QuotaDataItem } from '@/features/dashboard/types'
import { useStatus } from '@/hooks/use-status'
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
import { formatNumber, formatQuota } from '@/lib/format'
import { requireServerSuccess } from '@/lib/server-error-message'
import { computeTimeRange } from '@/lib/time'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
@@ -155,11 +156,13 @@ export function SummaryCards() {
summaryTimeRange.end_timestamp,
],
queryFn: async () =>
getUserQuotaDates({
start_timestamp: summaryTimeRange.start_timestamp,
end_timestamp: summaryTimeRange.end_timestamp,
default_time: 'hour',
}),
requireServerSuccess(
await getUserQuotaDates({
start_timestamp: summaryTimeRange.start_timestamp,
end_timestamp: summaryTimeRange.end_timestamp,
default_time: 'hour',
})
),
staleTime: 60 * 1000,
})
@@ -104,7 +104,7 @@ export function UptimePanel() {
{t('Uptime')}
</span>
}
description={t('Grouped monitor status from Uptime Kuma')}
description={t('Request and detection status by group')}
loading={loading}
empty={!groups.length}
emptyMessage={t('No uptime monitoring configured')}
@@ -40,6 +40,7 @@ import type {
ProcessedUserChartData,
UserChartsFilters,
} from '@/features/dashboard/types'
import { requireServerSuccess } from '@/lib/server-error-message'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
@@ -138,7 +139,8 @@ export function UserCharts(props: UserChartsProps) {
const { data: userData, isLoading } = useQuery({
queryKey: ['dashboard', 'user-quota', timeRange],
queryFn: () => getUserQuotaDataByUsers(timeRange),
queryFn: async () =>
requireServerSuccess(await getUserQuotaDataByUsers(timeRange)),
select: (res) => (res.success ? res.data : []),
staleTime: 60_000,
})
+1 -1
View File
@@ -34,7 +34,7 @@ export const DEFAULT_DASHBOARD_CHART_PREFERENCES: DashboardChartPreferences = {
export const TIME_RANGE_BY_GRANULARITY = {
hour: 1,
day: 7,
week: 30,
week: 29,
} as const
export const TIME_GRANULARITY_OPTIONS = [
@@ -20,6 +20,7 @@ import type {
FlowQuotaDataItem,
FlowUserFilterOption,
} from '@/features/dashboard/types'
import { createServerError } from '@/lib/server-error-message'
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
@@ -34,7 +35,7 @@ export function requireSuccessfulFlowRows(
fallbackMessage: string
): FlowQuotaDataItem[] {
if (!response.success) {
throw new Error(response.message || fallbackMessage)
throw createServerError(response, response.message || fallbackMessage)
}
return response.data ?? []
}
+55
View File
@@ -169,6 +169,61 @@ export interface ProcessedFlowData {
// ============================================================================
export interface UptimeMonitor {
probe_history?: { at: number; status: number }[]
id?: string
models?: string[]
alias?: string
hidden?: boolean
status_source?: string
status_checked_at?: number
pelican?: {
interval_minutes?: number
interval_max_minutes?: number
channels: number
normal: number
degraded: number
unknown: number
failed: number
running: number
history: {
id: string
at: number
status: string
quality: string
user_initiated: boolean
has_html: boolean
source_url?: string
}[]
}
fingerprint?: {
enabled: boolean
available: boolean
channels: number
sampled_channels: number
matches: number
mismatches: number
inconclusive: number
errors: number
running: number
rate: number | null
window_hours?: number
interval_minutes?: number
interval_max_minutes?: number
availability_only?: boolean
availability_interval_minutes?: number
availability_checks?: number
availability_successes?: number
availability_rate?: number | null
history: {
id: string
at: number
status: 'match' | 'mismatch' | 'inconclusive' | 'error' | 'running'
valid_samples: number
closest?: string
score_gap?: number
user_initiated?: boolean
}[]
}
metrics?: {
avg_ttft_ms: number
avg_latency_ms: number
@@ -0,0 +1,64 @@
import { cleanup, render, screen } from '@testing-library/react'
import { t } from 'i18next'
import { afterEach, describe, expect, it } from 'vitest'
import { AdaptiveSchedule } from '../adaptive-schedule'
import { PelicanPanel } from '../detection-panel'
afterEach(cleanup)
describe('adaptive detection schedule', () => {
it('shows one interval for channels with the same cadence', () => {
render(<AdaptiveSchedule min={60} max={60} />)
expect(
screen.getByText(
t('Current interval: {{minutes}} minutes', { minutes: 60 }),
{ exact: false }
)
).toBeInTheDocument()
})
it('shows a range for channels with different cadences', () => {
render(<AdaptiveSchedule min={15} max={60} />)
expect(
screen.getByText(
t('Current intervals: {{min}}–{{max}} minutes', { min: 15, max: 60 }),
{ exact: false }
)
).toBeInTheDocument()
})
it('uses the initial interval when no schedule has been recorded', () => {
render(<AdaptiveSchedule />)
expect(
screen.getByText(
t('Current interval: {{minutes}} minutes', { minutes: 30 }),
{ exact: false }
)
).toBeInTheDocument()
})
it('renders pelican schedule data instead of the old fixed frequency', () => {
render(
<PelicanPanel
data={{
channels: 2,
normal: 1,
degraded: 0,
unknown: 0,
failed: 0,
running: 0,
history: [],
interval_minutes: 15,
interval_max_minutes: 30,
}}
/>
)
expect(
screen.getByText(
t('Current intervals: {{min}}–{{max}} minutes', { min: 15, max: 30 }),
{ exact: false }
)
).toBeInTheDocument()
expect(
screen.queryByText(t('Every 30 minutes, one work per Astra channel.'))
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,156 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { t } from 'i18next'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { DetectionActions, PelicanPanel } from '../detection-panel'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
useAuthStore.getState().auth.setUser(null)
})
describe('paid detection', () => {
it('does not display another account task after switching accounts', async () => {
useAuthStore.getState().auth.setUser({ id: 1, username: 'first', role: 1 })
vi.spyOn(api, 'get')
.mockResolvedValueOnce({
data: {
success: true,
data: {
id: 'private-task',
settled: true,
charged: 100,
usage: [{ input_tokens: 123456 }],
},
},
})
.mockImplementation(() => new Promise(() => {}))
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<DetectionActions group='a' />
</QueryClientProvider>
)
await screen.findByText(/123456/)
act(() =>
useAuthStore
.getState()
.auth.setUser({ id: 2, username: 'second', role: 1 })
)
expect(screen.queryByText(/123456/)).not.toBeInTheDocument()
})
it('hides actions for visitors', () => {
render(<DetectionActions group='a' />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('requires a quote and explicit confirmation before charging', async () => {
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'detector', role: 1 })
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: {
success: true,
data: { reserved: 1000, ratio: 0.18, calls: 1, price_version: 'v1' },
},
})
.mockResolvedValueOnce({ data: { success: true, data: { id: 'task' } } })
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
id: 'task',
settled: true,
charged: 100,
status: 'succeeded',
quality: 'normal',
usage: [],
},
},
})
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
render(
<QueryClientProvider client={client}>
<DetectionActions group='a' />
</QueryClientProvider>
)
fireEvent.click(screen.getByRole('button', { name: t('Test pelican') }))
await screen.findByRole('alertdialog')
expect(post).toHaveBeenCalledTimes(1)
expect(post.mock.calls[0][0]).toBe('/api/detections/estimate')
fireEvent.click(
screen.getByRole('button', { name: t('Confirm and detect') })
)
await waitFor(() => expect(post).toHaveBeenCalledTimes(2))
expect(post.mock.calls[1][1]).toMatchObject({
group: 'a',
kind: 'pelican',
price_version: 'v1',
})
await screen.findByRole('status')
})
it('isolates works and switches the selected history record', () => {
render(
<PelicanPanel
data={{
channels: 1,
normal: 1,
degraded: 1,
unknown: 1,
failed: 1,
running: 0,
history: [
{
id: 'new',
at: 1,
status: 'succeeded',
quality: 'normal',
user_initiated: true,
has_html: true,
},
{
id: 'old',
at: 0,
status: 'succeeded',
quality: 'degraded',
user_initiated: false,
has_html: true,
},
],
}}
/>
)
expect(screen.getByText('50.0%')).toBeInTheDocument()
const frame = screen.getByTitle(t('Pelican work preview'))
expect(frame).toHaveAttribute('sandbox', 'allow-scripts')
expect(frame).toHaveAttribute('src', '/api/group-status/artwork/new')
fireEvent.click(screen.getByText(t('Recent detection results')))
fireEvent.click(
screen.getAllByRole('button', { name: t('Preview work') })[1]
)
expect(screen.getByTitle(t('Pelican work preview'))).toHaveAttribute(
'src',
'/api/group-status/artwork/old'
)
})
})
@@ -1,7 +1,18 @@
import { render, screen, cleanup } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
render,
screen,
cleanup,
fireEvent,
waitFor,
} from '@testing-library/react'
import { t } from 'i18next'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { FingerprintRetest } from '../fingerprint-panel'
import { MonitorCard } from '../index'
vi.mock('@/components/layout', () => ({ PublicLayout: () => null }))
@@ -9,15 +20,35 @@ vi.mock('@/components/layout', () => ({ PublicLayout: () => null }))
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
useAuthStore.getState().auth.setUser(null)
})
describe('group cache metrics', () => {
it('shows real request metrics instead of endpoint timing', () => {
render(<MonitorCard monitor={{ name: 'request-group', status: 2, uptime: 0.873,
last_checked: '2026-09-06 10:00:00',
cache_stats: { requests: 1, cached_requests: 0, input_tokens: 10, cached_tokens: 0, first_response_ms: 200 },
metrics: { avg_ttft_ms: 10730, avg_latency_ms: 22250, avg_tps: 26.6, success_rate: 87.3 },
}} />)
render(
<MonitorCard
monitor={{
name: 'request-group',
status: 2,
uptime: 0.873,
last_checked: '2026-09-06 10:00:00',
cache_stats: {
requests: 1,
cached_requests: 0,
input_tokens: 10,
cached_tokens: 0,
first_response_ms: 200,
},
metrics: {
avg_ttft_ms: 10730,
avg_latency_ms: 22250,
avg_tps: 26.6,
success_rate: 87.3,
},
}}
/>
)
expect(screen.getByText('87.3%')).toBeInTheDocument()
expect(screen.getByText('10.7 s')).toBeInTheDocument()
expect(screen.getByText('26.6 t/s')).toBeInTheDocument()
@@ -39,22 +70,113 @@ describe('group cache metrics', () => {
expect(screen.queryByText(t('Operational'))).not.toBeInTheDocument()
})
it('positions a new monitor sample at the current end of the history timeline', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-05T10:00:00Z'))
it('replaces status history with unknown fingerprint results', () => {
render(
<MonitorCard monitor={{ name: 'new-group', status: 1, uptime: 1 }} />
)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText(t('Sampled downgrade rate'))).toBeInTheDocument()
expect(screen.getByText('—')).toBeInTheDocument()
})
it('renders the conclusive-only rate and exposes uncertain rounds separately', () => {
render(
<MonitorCard
monitor={{
name: 'new-group',
name: 'sampled',
status: 1,
uptime: 1,
history: [{ status: 1, time: '2026-09-05 10:00:00' }],
fingerprint: {
enabled: true,
available: true,
channels: 2,
sampled_channels: 1,
matches: 3,
mismatches: 1,
errors: 2,
inconclusive: 4,
running: 0,
rate: 0.25,
window_hours: 6,
interval_minutes: 15,
history: [
{
id: 'sample-1',
at: 1758610000,
status: 'mismatch',
valid_samples: 3,
closest: 'gpt-5.6-luna',
score_gap: 0.65,
},
],
},
}}
/>
)
const history = screen.getByRole('img')
expect(history.children).toHaveLength(1)
expect(history.firstElementChild).toHaveStyle({ gridColumnStart: '24' })
expect(screen.getByText('25.0%')).toBeInTheDocument()
expect(
screen.getByText(
t('{{downgraded}} downgraded / {{valid}} measured rounds', {
downgraded: 1,
valid: 4,
})
)
).toBeInTheDocument()
expect(screen.getByText(t('Non-Astra fingerprint'))).toBeInTheDocument()
expect(
screen.getByText(t('Astra · {{hours}}h', { hours: 6 }))
).toBeInTheDocument()
expect(
screen.getByText(
t('Current interval: {{minutes}} minutes', { minutes: 15 }),
{ exact: false }
)
).toBeInTheDocument()
})
it('shows hourly availability checks for groups without Astra channels', () => {
render(
<MonitorCard
monitor={{
name: 'availability-only',
status: 1,
uptime: 1,
fingerprint: {
enabled: true,
available: true,
channels: 0,
sampled_channels: 0,
matches: 0,
mismatches: 0,
errors: 0,
inconclusive: 0,
running: 0,
rate: null,
window_hours: 6,
availability_only: true,
availability_interval_minutes: 60,
availability_checks: 5,
availability_successes: 4,
availability_rate: 0.8,
history: [],
},
}}
/>
)
expect(
screen.getByText(
t('Availability check · every {{minutes}} minutes', { minutes: 60 })
)
).toBeInTheDocument()
expect(screen.getByText('80.0%')).toBeInTheDocument()
expect(
screen.getByText(
t('{{success}} / {{checks}} checks succeeded', {
success: 4,
checks: 5,
})
)
).toBeInTheDocument()
})
it('shows a stale request-derived status as no recent requests', () => {
@@ -113,3 +235,92 @@ describe('group cache metrics', () => {
expect(screen.getAllByText('0.0%')).toHaveLength(2)
})
})
describe('fingerprint retest permission and cost confirmation', () => {
function renderRetest() {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
return render(
<QueryClientProvider client={client}>
<FingerprintRetest enabled />
</QueryClientProvider>
)
}
it('hides paid probing from ordinary visitors', () => {
renderRetest()
expect(
screen.queryByRole('button', { name: t('Sample all Astra channels') })
).not.toBeInTheDocument()
})
it('does not submit until the administrator confirms platform cost', async () => {
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'test-admin', role: 100 })
const post = vi
.spyOn(api, 'post')
.mockResolvedValue({ data: { success: true } })
renderRetest()
fireEvent.click(
screen.getByRole('button', { name: t('Sample all Astra channels') })
)
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
expect(post).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', { name: t('Confirm and sample') })
)
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
})
})
it('uses the real group for main-site display and detection when an alias exists', async () => {
useAuthStore.getState().auth.setUser({ id: 1, username: 'detector', role: 1 })
vi.spyOn(api, 'get').mockResolvedValue({ data: { success: true, data: [] } })
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: { reserved: 1000, ratio: 0.18, calls: 1, price_version: 'v1' },
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<MonitorCard
monitor={{
name: '#720',
group: 'example-secondary',
status: 1,
uptime: 1,
fingerprint: {
enabled: true,
available: true,
channels: 1,
sampled_channels: 0,
matches: 0,
mismatches: 0,
inconclusive: 0,
errors: 0,
running: 0,
rate: null,
history: [],
},
}}
/>
</QueryClientProvider>
)
expect(
screen.getByRole('heading', { name: 'example-secondary' })
).toBeInTheDocument()
expect(
screen.queryByRole('heading', { name: '#720' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: t('Test pelican') }))
await screen.findByRole('alertdialog')
expect(post).toHaveBeenCalledWith('/api/detections/estimate', {
group: 'example-secondary',
kind: 'pelican',
})
expect(post).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,34 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { t } from 'i18next'
import { afterEach, expect, it, vi } from 'vitest'
import { ProbeHistory } from '../probe-history'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('shows real checks with empty slots and counts down to the next refresh', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T00:00:00Z'))
render(
<ProbeHistory
history={[
{ at: 1, status: 1 },
{ at: 2, status: 0 },
]}
updatedAt={Date.now()}
/>
)
expect(screen.getAllByRole('button')).toHaveLength(18)
expect(screen.getAllByRole('button', { name: t('No data') })).toHaveLength(16)
expect(
screen.getByText(t('Refresh in {{seconds}}s', { seconds: 15 }))
).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(3000)
})
expect(
screen.getByText(t('Refresh in {{seconds}}s', { seconds: 12 }))
).toBeInTheDocument()
})
Loaded 100 of 828 files, more files were not shown because too many files have changed in this diff. Show more