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:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+12
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user