feat: sync deployed v1.0.0-rc.38-custom.67
This commit is contained in:
1 parent
d2e03ab7e8
commit
ffc8cf56db
154 files changed
+39540
-20509
No files matched your search
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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, waitFor } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
it('finishes page entry without retaining a filter over scrollable content', async () => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion)')
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ ...media, matches: false })
|
||||
const { PageTransition } = await import('../page-transition')
|
||||
render(
|
||||
<PageTransition>
|
||||
<main aria-label='Page content'>Content</main>
|
||||
</PageTransition>
|
||||
)
|
||||
const wrapper = screen.getByRole('main', {
|
||||
name: 'Page content',
|
||||
}).parentElement
|
||||
await waitFor(() => expect(wrapper).toHaveStyle({ opacity: '1' }))
|
||||
expect(wrapper?.style.filter).toBe('')
|
||||
})
|
||||
@@ -169,6 +169,13 @@ export interface ProcessedFlowData {
|
||||
// ============================================================================
|
||||
|
||||
export interface UptimeMonitor {
|
||||
tags?: string[]
|
||||
recent_degraded?: boolean
|
||||
model_costs?: Record<
|
||||
string,
|
||||
{ usd_per_million: number | null; requests: number; tokens: number }
|
||||
>
|
||||
group_ratio?: number
|
||||
probe_history?: { at: number; status: number }[]
|
||||
id?: string
|
||||
models?: string[]
|
||||
@@ -176,7 +183,8 @@ export interface UptimeMonitor {
|
||||
hidden?: boolean
|
||||
status_source?: string
|
||||
status_checked_at?: number
|
||||
pelican?: {
|
||||
candy?: UptimeMonitor['benchmark']
|
||||
benchmark?: {
|
||||
interval_minutes?: number
|
||||
interval_max_minutes?: number
|
||||
channels: number
|
||||
@@ -191,8 +199,8 @@ export interface UptimeMonitor {
|
||||
status: string
|
||||
quality: string
|
||||
user_initiated: boolean
|
||||
has_html: boolean
|
||||
source_url?: string
|
||||
closest?: string
|
||||
relative_weight?: number
|
||||
}[]
|
||||
}
|
||||
fingerprint?: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { t } from 'i18next'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { AdaptiveSchedule } from '../adaptive-schedule'
|
||||
import { PelicanPanel } from '../detection-panel'
|
||||
import { BenchmarkPanel } from '../detection-panel'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -35,9 +35,9 @@ describe('adaptive detection schedule', () => {
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
it('renders pelican schedule data instead of the old fixed frequency', () => {
|
||||
it('renders benchmark schedule data instead of the old fixed frequency', () => {
|
||||
render(
|
||||
<PelicanPanel
|
||||
<BenchmarkPanel
|
||||
data={{
|
||||
channels: 2,
|
||||
normal: 1,
|
||||
|
||||
@@ -13,7 +13,11 @@ 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'
|
||||
import {
|
||||
DetectionActions,
|
||||
BenchmarkPanel,
|
||||
CandyPanel,
|
||||
} from '../detection-panel'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -94,7 +98,7 @@ describe('paid detection', () => {
|
||||
<DetectionActions group='a' />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Test pelican') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Test benchmark') }))
|
||||
await screen.findByRole('alertdialog')
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
expect(post.mock.calls[0][0]).toBe('/api/detections/estimate')
|
||||
@@ -104,53 +108,79 @@ describe('paid detection', () => {
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(2))
|
||||
expect(post.mock.calls[1][1]).toMatchObject({
|
||||
group: 'a',
|
||||
kind: 'pelican',
|
||||
kind: 'benchmark',
|
||||
price_version: 'v1',
|
||||
})
|
||||
await screen.findByRole('status')
|
||||
})
|
||||
it('isolates works and switches the selected history record', () => {
|
||||
render(
|
||||
<PelicanPanel
|
||||
it('shows benchmark history without generated artwork', () => {
|
||||
const view = render(
|
||||
<BenchmarkPanel
|
||||
data={{
|
||||
channels: 1,
|
||||
normal: 1,
|
||||
degraded: 1,
|
||||
unknown: 1,
|
||||
failed: 1,
|
||||
degraded: 0,
|
||||
unknown: 0,
|
||||
failed: 0,
|
||||
running: 0,
|
||||
history: [
|
||||
{
|
||||
id: 'new',
|
||||
id: 'b',
|
||||
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,
|
||||
closest: 'gpt-6-astra',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
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'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('heading', { name: t('Meow benchmark · auxiliary') })
|
||||
).toBeInTheDocument()
|
||||
expect(view.container.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['normal', 'Passed'],
|
||||
['degraded', 'Not passed'],
|
||||
])(
|
||||
'shows candy result %s separately from fingerprint classification',
|
||||
(quality, label) => {
|
||||
render(
|
||||
<CandyPanel
|
||||
data={{
|
||||
channels: 1,
|
||||
interval_minutes: 30,
|
||||
interval_max_minutes: 30,
|
||||
normal: quality === 'normal' ? 1 : 0,
|
||||
degraded: quality === 'degraded' ? 1 : 0,
|
||||
unknown: 0,
|
||||
failed: 0,
|
||||
running: 0,
|
||||
history: [
|
||||
{
|
||||
id: 'candy',
|
||||
at: 1,
|
||||
status: 'succeeded',
|
||||
quality,
|
||||
closest: quality === 'normal' ? '21' : '28',
|
||||
user_initiated: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('region', { name: t('Candy test') })).toBeVisible()
|
||||
expect(screen.getAllByText(t(label))[0]).toBeVisible()
|
||||
expect(
|
||||
screen.getByText(
|
||||
t(
|
||||
'Candy question · one request per channel. Only an answer of 21 passes.'
|
||||
)
|
||||
)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
@@ -30,6 +30,8 @@ it('uses the shared rows while preserving main-site names, artworks and metrics'
|
||||
{
|
||||
name: '#alias',
|
||||
group: 'real-astra',
|
||||
tags: ['Pro池', '不稳定'],
|
||||
recent_degraded: true,
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: ['gpt-6-astra'],
|
||||
@@ -39,7 +41,7 @@ it('uses the shared rows while preserving main-site names, artworks and metrics'
|
||||
avg_tps: 26.6,
|
||||
success_rate: 100,
|
||||
},
|
||||
pelican: {
|
||||
benchmark: {
|
||||
channels: 1,
|
||||
normal: 1,
|
||||
degraded: 0,
|
||||
@@ -53,7 +55,7 @@ it('uses the shared rows while preserving main-site names, artworks and metrics'
|
||||
status: 'succeeded',
|
||||
quality: 'normal',
|
||||
user_initiated: false,
|
||||
has_html: true,
|
||||
closest: 'gpt-6-astra',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -73,12 +75,17 @@ it('uses the shared rows while preserving main-site names, artworks and metrics'
|
||||
)
|
||||
await screen.findByRole('heading', { name: 'real-astra' })
|
||||
expect(get).toHaveBeenCalledWith('/api/group-status')
|
||||
expect(screen.getByText('Pro池')).toBeVisible()
|
||||
expect(screen.getByText('不稳定')).toBeVisible()
|
||||
expect(
|
||||
screen.getByText(t('Recent degradation') + ' · ' + t('Auto'))
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '#alias' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByTitle(t('Pelican sample {{number}}', { number: 1 }))
|
||||
).toHaveAttribute('src', '/api/group-status/artwork/hidden-uptime-work')
|
||||
screen.getByRole('region', { name: t('Meow benchmark · auxiliary') })
|
||||
).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Detection details') }))
|
||||
expect(await screen.findByText('10.7 s')).toBeInTheDocument()
|
||||
})
|
||||
@@ -382,11 +389,11 @@ it('uses the real group for main-site display and detection when an alias exists
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '#720' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Test pelican') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Test benchmark') }))
|
||||
await screen.findByRole('alertdialog')
|
||||
expect(post).toHaveBeenCalledWith('/api/detections/estimate', {
|
||||
group: 'example-secondary',
|
||||
kind: 'pelican',
|
||||
kind: 'benchmark',
|
||||
})
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -27,6 +27,42 @@ afterEach(() => {
|
||||
useAuthStore.getState().auth.setUser(null)
|
||||
})
|
||||
|
||||
it('shows per-model actual costs only when enabled and distinguishes free from unknown', () => {
|
||||
const monitor: UptimeMonitor = {
|
||||
name: 'cost-group',
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: ['priced-model', 'free-model', 'new-model'],
|
||||
model_costs: {
|
||||
'priced-model': { usd_per_million: 0.4469, requests: 2, tokens: 1000 },
|
||||
'free-model': { usd_per_million: 0, requests: 1, tokens: 100 },
|
||||
},
|
||||
}
|
||||
const view = render(<StatusGroupRow monitor={monitor} showModelCosts />)
|
||||
expect(
|
||||
screen.getByText(
|
||||
t(
|
||||
'For reference only: insufficient request samples may cause very large errors in the average charge.'
|
||||
)
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(screen.getByText('$0.4469')).toBeVisible()
|
||||
expect(screen.getByText('$0.0000')).toBeVisible()
|
||||
expect(screen.getByText('new-model').parentElement).toHaveTextContent('—')
|
||||
view.rerender(<StatusGroupRow monitor={monitor} showGroupRatio={false} />)
|
||||
expect(
|
||||
screen.queryByText(
|
||||
t(
|
||||
'For reference only: insufficient request samples may cause very large errors in the average charge.'
|
||||
)
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('$0.4469')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(t('Average charged / 1M tokens'))
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
function provider(children: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
@@ -36,51 +72,76 @@ function provider(children: React.ReactNode) {
|
||||
)
|
||||
}
|
||||
|
||||
it('shows all works newest first, opens a preview from the thumbnail, and removes it after reset', () => {
|
||||
it('shows the exact group multiplier including zero and handles missing data', () => {
|
||||
const monitor: UptimeMonitor = {
|
||||
name: '#688',
|
||||
name: 'group',
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: ['gpt-6-astra'],
|
||||
pelican: {
|
||||
channels: 1,
|
||||
normal: 5,
|
||||
degraded: 0,
|
||||
unknown: 0,
|
||||
failed: 0,
|
||||
running: 0,
|
||||
history: ['one', 'two', 'three', 'four', 'five'].map((id, index) => ({
|
||||
id,
|
||||
at: index + 1,
|
||||
status: 'succeeded',
|
||||
quality: 'normal',
|
||||
user_initiated: false,
|
||||
has_html: true,
|
||||
})),
|
||||
},
|
||||
group_ratio: 0.25,
|
||||
}
|
||||
const view = render(<StatusGroupRow monitor={monitor} />)
|
||||
expect(screen.getByRole('heading', { name: '#688' })).toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-6-astra')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: t('Preview work') })
|
||||
).toHaveLength(5)
|
||||
expect(
|
||||
screen.getByTitle(t('Pelican sample {{number}}', { number: 1 }))
|
||||
).toHaveAttribute('sandbox', 'allow-scripts')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: t('Preview work') })[0])
|
||||
expect(screen.getByTitle(t('Pelican work preview'))).toHaveAttribute(
|
||||
'src',
|
||||
'/api/detections/artwork/five'
|
||||
)
|
||||
expect(screen.getByText(t('Group Ratio'))).toBeVisible()
|
||||
expect(screen.getByText('0.25x')).toBeVisible()
|
||||
view.rerender(<StatusGroupRow monitor={{ ...monitor, group_ratio: 0 }} />)
|
||||
expect(screen.getByText('0x')).toBeVisible()
|
||||
view.rerender(
|
||||
<StatusGroupRow
|
||||
monitor={{ ...monitor, models: [], group_ratio: undefined }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(t('Group Ratio'))).toBeVisible()
|
||||
expect(screen.getByText('—')).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides group multipliers on the standalone uptime view', () => {
|
||||
const monitor: UptimeMonitor = {
|
||||
name: 'status-only',
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: ['gpt-6-astra'],
|
||||
group_ratio: 0.25,
|
||||
}
|
||||
const view = render(
|
||||
<StatusGroupRow monitor={monitor} showGroupRatio={false} />
|
||||
)
|
||||
expect(screen.queryByText(t('Group Ratio'))).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('0.25x')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(t('Downgrade rate · last 6 hours'))).toBeVisible()
|
||||
view.rerender(
|
||||
<StatusGroupRow
|
||||
monitor={{ ...monitor, models: [] }}
|
||||
showGroupRatio={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText(t('Group Ratio'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps auxiliary mismatches out of the primary downgrade rate and removes artwork', () => {
|
||||
const view = render(
|
||||
<StatusGroupRow
|
||||
monitor={{
|
||||
...monitor,
|
||||
pelican: {
|
||||
name: 'primary',
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: ['gpt-6-astra'],
|
||||
fingerprint: {
|
||||
enabled: true,
|
||||
available: true,
|
||||
channels: 1,
|
||||
sampled_channels: 1,
|
||||
matches: 5,
|
||||
mismatches: 0,
|
||||
inconclusive: 0,
|
||||
errors: 0,
|
||||
running: 0,
|
||||
rate: 0,
|
||||
history: [],
|
||||
},
|
||||
benchmark: {
|
||||
channels: 1,
|
||||
normal: 0,
|
||||
degraded: 0,
|
||||
degraded: 2,
|
||||
unknown: 0,
|
||||
failed: 0,
|
||||
running: 0,
|
||||
@@ -89,8 +150,14 @@ it('shows all works newest first, opens a preview from the thumbnail, and remove
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTitle(t('Pelican work preview'))).not.toBeInTheDocument()
|
||||
expect(screen.getByText(t('No pelican works yet'))).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(t('{{count}} measured rounds', { count: 5 }))
|
||||
).toBeVisible()
|
||||
expect(screen.queryByText('28.6%')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('region', { name: t('Meow benchmark · auxiliary') })
|
||||
).toBeVisible()
|
||||
expect(view.container.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides reset from ordinary users and requires confirmation for admins', async () => {
|
||||
@@ -226,7 +293,9 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => {
|
||||
.getAllByRole('heading', { level: 2 })
|
||||
.map((heading) => heading.textContent)
|
||||
).toEqual(['Degraded Astra', 'Astra group', 'Luna only', 'Failed Astra'])
|
||||
expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(3)
|
||||
expect(
|
||||
screen.getAllByRole('region', { name: t('ModelTrace fingerprint') })
|
||||
).toHaveLength(3)
|
||||
expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(3)
|
||||
expect(
|
||||
screen.getAllByRole('region', { name: t('Availability history') })
|
||||
@@ -249,3 +318,23 @@ it('keeps the main-site group status link on the original internal route', () =>
|
||||
'/group-status'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows configured and automatic group tags on main-site status cards', () => {
|
||||
render(
|
||||
<StatusGroupRow
|
||||
monitor={{
|
||||
name: 'tagged',
|
||||
status: 1,
|
||||
uptime: 1,
|
||||
models: [],
|
||||
tags: ['打野', '不稳定', '打野'],
|
||||
recent_degraded: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getAllByText('打野')).toHaveLength(1)
|
||||
expect(screen.getByText('不稳定')).toBeVisible()
|
||||
expect(
|
||||
screen.getByText(`${t('Recent degradation')} · ${t('Auto')}`)
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { toIntlLocale } from '@/i18n/languages'
|
||||
import { api } from '@/lib/api'
|
||||
import { formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { AdaptiveSchedule } from './adaptive-schedule'
|
||||
@@ -29,7 +30,7 @@ type Task = {
|
||||
error: string
|
||||
usage: unknown[]
|
||||
}
|
||||
type Kind = 'pelican' | 'fingerprint'
|
||||
type Kind = 'benchmark' | 'fingerprint'
|
||||
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = body ? await api.post(path, body) : await api.get(path)
|
||||
@@ -107,17 +108,17 @@ function PaidDetectionActions(props: { group: string; userId: number }) {
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={running || estimate.isPending}
|
||||
onClick={() => estimate.mutate('pelican')}
|
||||
onClick={() => estimate.mutate('fingerprint')}
|
||||
>
|
||||
{t('Test pelican')}
|
||||
{t('Test fingerprint')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={running || estimate.isPending}
|
||||
onClick={() => estimate.mutate('fingerprint')}
|
||||
onClick={() => estimate.mutate('benchmark')}
|
||||
>
|
||||
{t('Test fingerprint')}
|
||||
{t('Test benchmark')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -161,8 +162,8 @@ function PaidDetectionActions(props: { group: string; userId: number }) {
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
{props.group} ·{' '}
|
||||
{selection?.kind === 'pelican'
|
||||
? t('Test pelican')
|
||||
{selection?.kind === 'benchmark'
|
||||
? t('Test benchmark')
|
||||
: t('Test fingerprint')}
|
||||
</p>
|
||||
<p>
|
||||
@@ -191,6 +192,10 @@ function PaidDetectionActions(props: { group: string; userId: number }) {
|
||||
}
|
||||
|
||||
function qualityLabel(quality: string) {
|
||||
if (quality === 'failed') return 'Detection failed'
|
||||
if (quality === 'pending' || quality === 'running') {
|
||||
return 'Detection in progress'
|
||||
}
|
||||
if (quality === 'normal' || quality === 'match') return 'Normal'
|
||||
if (quality === 'degraded' || quality === 'mismatch') {
|
||||
return 'Suspected degradation'
|
||||
@@ -198,100 +203,127 @@ function qualityLabel(quality: string) {
|
||||
return 'Unable to classify'
|
||||
}
|
||||
|
||||
export function PelicanPanel(props: {
|
||||
data: NonNullable<UptimeMonitor['pelican']>
|
||||
export function BenchmarkPanel(props: {
|
||||
data: NonNullable<UptimeMonitor['benchmark']>
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [selected, setSelected] = useState('')
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const data = props.data
|
||||
const latest = data.history.find((entry) => entry.has_html)
|
||||
const artwork =
|
||||
data.history.find((entry) => entry.id === selected && entry.has_html) ||
|
||||
latest
|
||||
const valid = data.normal + data.degraded
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const latest = data.history.find((entry) => entry.closest)
|
||||
return (
|
||||
<section
|
||||
className='border-border/60 mt-5 border-t pt-4'
|
||||
aria-label={t('Pelican detection')}
|
||||
aria-label={t('Meow benchmark · auxiliary')}
|
||||
className='border-border/60 mt-4 border-t pt-4 text-xs'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<h3 className='text-sm font-medium'>{t('Pelican detection')}</h3>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Last 6 hours')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-2 text-2xl font-semibold'>
|
||||
{valid ? `${((data.degraded / valid) * 100).toFixed(1)}%` : '—'}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Suspected degradation')} ·{' '}
|
||||
<h3 className='font-medium'>{t('Meow benchmark · auxiliary')}</h3>
|
||||
<p className='mt-2'>{latest?.closest || t('Not measured')}</p>
|
||||
<p className='text-muted-foreground mt-1'>
|
||||
{t(
|
||||
'{{normal}} normal / {{degraded}} suspected / {{unknown}} unknown / {{failed}} failed',
|
||||
data
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
{t(
|
||||
'Meow results are auxiliary and do not affect the fingerprint downgrade rate.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
<AdaptiveSchedule
|
||||
min={data.interval_minutes}
|
||||
max={data.interval_max_minutes}
|
||||
/>
|
||||
<br />
|
||||
{t('One work per Astra channel.')}
|
||||
{t('Meow quick test · 32 probes per channel.')}
|
||||
</p>
|
||||
<details className='mt-3 text-xs'>
|
||||
<summary>{t('Recent detection results')}</summary>
|
||||
{!!data.running && <p className='mt-2'>{t('Detection in progress')}</p>}
|
||||
<details className='mt-3'>
|
||||
<summary>{t('Recent benchmark results')}</summary>
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{data.history.map((entry) => (
|
||||
<li key={entry.id} className='border-t pt-2'>
|
||||
<div>
|
||||
{new Date(entry.at * 1000).toLocaleString(locale)} ·{' '}
|
||||
{entry.user_initiated
|
||||
? t('User detection')
|
||||
: t('Automatic detection')}
|
||||
</div>
|
||||
<span>
|
||||
{entry.status === 'succeeded'
|
||||
? t(qualityLabel(entry.quality))
|
||||
: t(
|
||||
entry.status === 'failed'
|
||||
? 'Detection failed'
|
||||
: 'Detection in progress'
|
||||
)}
|
||||
</span>
|
||||
{entry.has_html && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setSelected(entry.id)}
|
||||
>
|
||||
{t('Preview work')}
|
||||
</Button>
|
||||
<time dateTime={new Date(entry.at * 1000).toISOString()}>
|
||||
{new Date(entry.at * 1000).toLocaleString(locale)}
|
||||
</time>
|
||||
{' · '}
|
||||
{t(
|
||||
qualityLabel(
|
||||
entry.status === 'succeeded' ? entry.quality : entry.status
|
||||
)
|
||||
)}
|
||||
{entry.closest && <p>{entry.closest}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CandyPanel(props: {
|
||||
data: NonNullable<UptimeMonitor['candy']>
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const data = props.data
|
||||
const latest = data.history.find((entry) => entry.status === 'succeeded')
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
return (
|
||||
<section
|
||||
aria-label={t('Candy test')}
|
||||
className='border-border/60 mt-4 border-t pt-4 text-xs'
|
||||
>
|
||||
<h3 className='font-medium'>{t('Candy test')}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 font-medium',
|
||||
latest?.quality === 'normal' &&
|
||||
'text-emerald-700 dark:text-emerald-400',
|
||||
latest?.quality === 'degraded' && 'text-red-700 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{latest
|
||||
? t(latest.quality === 'normal' ? 'Passed' : 'Not passed')
|
||||
: t('Not measured')}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1'>
|
||||
{t(
|
||||
'{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run',
|
||||
data
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
{t(
|
||||
'Candy question · one request per channel. Only an answer of 21 passes.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
<AdaptiveSchedule
|
||||
min={data.interval_minutes}
|
||||
max={data.interval_max_minutes}
|
||||
/>
|
||||
</p>
|
||||
{!!data.running && <p className='mt-2'>{t('Detection in progress')}</p>}
|
||||
<details className='mt-3'>
|
||||
<summary>{t('Recent candy test results')}</summary>
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{data.history.map((entry) => (
|
||||
<li key={entry.id} className='border-t pt-2'>
|
||||
<time dateTime={new Date(entry.at * 1000).toISOString()}>
|
||||
{new Date(entry.at * 1000).toLocaleString(locale)}
|
||||
</time>
|
||||
{' · '}
|
||||
{entry.status === 'succeeded'
|
||||
? t(entry.quality === 'normal' ? 'Passed' : 'Not passed')
|
||||
: t(qualityLabel(entry.status))}
|
||||
{entry.closest && (
|
||||
<p className='mt-1 break-words whitespace-pre-wrap'>
|
||||
{entry.closest}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
{artwork && (
|
||||
<iframe
|
||||
key={artwork.id}
|
||||
title={t('Pelican work preview')}
|
||||
sandbox='allow-scripts'
|
||||
referrerPolicy='no-referrer'
|
||||
src={`/api/group-status/artwork/${artwork.id}`}
|
||||
className='mt-3 h-72 w-full rounded border bg-white'
|
||||
/>
|
||||
)}
|
||||
{artwork?.source_url && (
|
||||
<a
|
||||
href={artwork.source_url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary mt-2 inline-block text-xs underline'
|
||||
>
|
||||
{t('View on manxue.ai')}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function FingerprintPanel({
|
||||
max={data?.interval_max_minutes}
|
||||
/>
|
||||
<br />
|
||||
{t('3 probes per channel.')}
|
||||
{t('ModelTrace · 3 independent probes per channel.')}
|
||||
</>
|
||||
)
|
||||
if (!data || !data.available) schedule = t('Sampling data unavailable')
|
||||
@@ -49,7 +49,7 @@ export function FingerprintPanel({
|
||||
return (
|
||||
<section
|
||||
className='border-border/60 mt-5 border-t pt-4'
|
||||
aria-label={t('Astra fingerprint sampling')}
|
||||
aria-label={t('ModelTrace fingerprint')}
|
||||
>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<h3 className='text-sm font-medium'>
|
||||
@@ -102,7 +102,7 @@ export function FingerprintPanel({
|
||||
{!data?.availability_only && (
|
||||
<p className='text-muted-foreground mt-3 text-xs leading-relaxed'>
|
||||
{t(
|
||||
'The rate uses the closest valid fingerprint: non-Astra results count as downgraded. Rounds without a valid fingerprint are excluded.'
|
||||
'ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -124,7 +124,7 @@ export function FingerprintPanel({
|
||||
{round.user_initiated && <span>{t('User detection')}</span>}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1'>
|
||||
{t('Valid probes {{count}}/3', {
|
||||
{t('Valid probes {{count}}', {
|
||||
count: round.valid_samples,
|
||||
})}
|
||||
{round.closest &&
|
||||
@@ -176,7 +176,7 @@ export function FingerprintRetest({ enabled }: { enabled: boolean }) {
|
||||
onOpenChange={setOpen}
|
||||
title={t('Sample all Astra channels')}
|
||||
desc={t(
|
||||
'This sends up to 3 independent requests per enabled Astra channel. Upstream costs are paid by the platform, not customer tokens. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.'
|
||||
'This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.'
|
||||
)}
|
||||
confirmText={t('Confirm and sample')}
|
||||
isLoading={mutation.isPending}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { DetectionActions, PelicanPanel } from './detection-panel'
|
||||
import { DetectionActions, BenchmarkPanel } from './detection-panel'
|
||||
import { FingerprintPanel } from './fingerprint-panel'
|
||||
import {
|
||||
cacheHitRates,
|
||||
@@ -162,7 +162,7 @@ export function MonitorCard({
|
||||
</div>
|
||||
|
||||
<FingerprintPanel data={monitor.fingerprint} />
|
||||
{monitor.pelican && <PelicanPanel data={monitor.pelican} />}
|
||||
{monitor.benchmark && <BenchmarkPanel data={monitor.benchmark} />}
|
||||
{!!monitor.fingerprint?.channels && !!monitor.group && (
|
||||
<DetectionActions group={monitor.group} />
|
||||
)}
|
||||
@@ -224,6 +224,7 @@ export function GroupStatus() {
|
||||
<div className='mt-7 space-y-5'>
|
||||
{monitors.map((monitor) => (
|
||||
<StatusGroupRow
|
||||
showModelCosts
|
||||
key={`${monitor.group}-${monitor.name}`}
|
||||
monitor={{ ...monitor, name: monitor.group || monitor.name }}
|
||||
updatedAt={query.dataUpdatedAt}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ResetGroupDetections(props: { group: string }) {
|
||||
}}
|
||||
title={t('Reset detection records')}
|
||||
desc={t(
|
||||
'Reset fingerprint and pelican history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.',
|
||||
'Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.',
|
||||
{ group: props.group }
|
||||
)}
|
||||
destructive
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, ArrowUpRight, RefreshCw, Settings2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
@@ -13,11 +13,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { UptimeMonitor } from '@/features/dashboard/types'
|
||||
import { GroupTags } from '@/features/keys/components/group-tags'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { BenchmarkPanel, CandyPanel } from './detection-panel'
|
||||
import { FingerprintPanel } from './fingerprint-panel'
|
||||
import {
|
||||
getOverallStatus,
|
||||
@@ -40,47 +42,6 @@ function statusLabel(monitor: UptimeMonitor): string {
|
||||
return 'No recent data'
|
||||
}
|
||||
|
||||
function qualityLabel(quality: string): string {
|
||||
if (quality === 'normal') return 'Normal'
|
||||
if (quality === 'degraded') return 'Suspected degradation'
|
||||
return 'Unable to classify'
|
||||
}
|
||||
|
||||
function ArtworkThumbnail(props: {
|
||||
id: string
|
||||
title: string
|
||||
artworkBase: string
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(0)
|
||||
useEffect(() => {
|
||||
const element = container.current
|
||||
if (!element) return
|
||||
const resize = () => setScale(element.clientWidth / 960)
|
||||
resize()
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
className='relative aspect-[3/2] overflow-hidden bg-white'
|
||||
>
|
||||
<iframe
|
||||
title={props.title}
|
||||
src={props.artworkBase + props.id}
|
||||
sandbox='allow-scripts'
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
tabIndex={-1}
|
||||
className='pointer-events-none absolute top-0 left-0 h-[640px] w-[960px] origin-top-left border-0'
|
||||
style={{ transform: 'scale(' + scale + ')' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge(props: { monitor: UptimeMonitor }) {
|
||||
const { t } = useTranslation()
|
||||
const kind = statusKind(props.monitor.status)
|
||||
@@ -105,30 +66,30 @@ function StatusBadge(props: { monitor: UptimeMonitor }) {
|
||||
|
||||
export function StatusGroupRow(props: {
|
||||
monitor: UptimeMonitor
|
||||
showGroupRatio?: boolean
|
||||
showModelCosts?: boolean
|
||||
updatedAt?: number
|
||||
refreshing?: boolean
|
||||
artworkBase?: string
|
||||
actions?: ReactNode
|
||||
detailsContent?: ReactNode
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const monitor = props.monitor
|
||||
const astra = supportsAstra(monitor)
|
||||
const artworkBase = props.artworkBase || '/api/detections/artwork/'
|
||||
const [selected, setSelected] = useState('')
|
||||
const [details, setDetails] = useState(false)
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const history = monitor.fingerprint?.history || []
|
||||
const latest = history.find(
|
||||
(round) =>
|
||||
round.valid_samples > 0 && round.closest && round.status !== 'running'
|
||||
round.valid_samples > 0 &&
|
||||
round.closest &&
|
||||
(round.status === 'match' || round.status === 'mismatch')
|
||||
)
|
||||
const works = (monitor.pelican?.history || [])
|
||||
.filter((work) => work.has_html)
|
||||
.sort((a, b) => b.at - a.at)
|
||||
const selectedWork = works.find((work) => work.id === selected)
|
||||
const rate = monitor.fingerprint?.rate
|
||||
const measuredRounds =
|
||||
(monitor.fingerprint?.matches || 0) + (monitor.fingerprint?.mismatches || 0)
|
||||
const degradedRounds = monitor.fingerprint?.mismatches || 0
|
||||
const rate = measuredRounds > 0 ? degradedRounds / measuredRounds : null
|
||||
return (
|
||||
<Card className='gap-0 py-0'>
|
||||
<div
|
||||
@@ -140,13 +101,16 @@ export function StatusGroupRow(props: {
|
||||
)}
|
||||
>
|
||||
<CardHeader className={cn('gap-4 p-5', astra && 'xl:border-r')}>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2 xl:items-start'>
|
||||
<CardTitle>
|
||||
<h2 className='text-lg font-semibold break-all'>
|
||||
{monitor.name}
|
||||
</h2>
|
||||
</CardTitle>
|
||||
<StatusBadge monitor={monitor} />
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2 xl:items-start'>
|
||||
<CardTitle>
|
||||
<h2 className='text-lg font-semibold break-all'>
|
||||
{monitor.name}
|
||||
</h2>
|
||||
</CardTitle>
|
||||
<StatusBadge monitor={monitor} />
|
||||
</div>
|
||||
<GroupTags tags={monitor.tags} recent={monitor.recent_degraded} />
|
||||
</div>
|
||||
{astra && (
|
||||
<div className='space-y-3'>
|
||||
@@ -154,27 +118,63 @@ export function StatusGroupRow(props: {
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Model fingerprint')}
|
||||
</p>
|
||||
<p className='mt-1 font-medium break-all'>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 font-medium break-all',
|
||||
latest?.status === 'match' &&
|
||||
'text-emerald-700 dark:text-emerald-400',
|
||||
latest?.status === 'mismatch' &&
|
||||
'text-red-700 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{latest?.closest || t('Not measured')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Downgrade rate · last 6 hours')}
|
||||
</p>
|
||||
<p className='mt-1 text-3xl font-semibold tracking-tight tabular-nums'>
|
||||
{rate == null ? '—' : `${(rate * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
props.showGroupRatio === false
|
||||
? undefined
|
||||
: 'grid grid-cols-2 gap-3'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Downgrade rate · last 6 hours')}
|
||||
</p>
|
||||
<p className='mt-1 text-2xl font-semibold tabular-nums'>
|
||||
{rate == null ? '—' : `${(rate * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
{props.showGroupRatio !== false && (
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Group Ratio')}
|
||||
</p>
|
||||
<p className='mt-1 text-2xl font-semibold break-all tabular-nums'>
|
||||
{monitor.group_ratio == null
|
||||
? '—'
|
||||
: `${monitor.group_ratio}x`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('{{count}} measured rounds', {
|
||||
count:
|
||||
(monitor.fingerprint?.matches || 0) +
|
||||
(monitor.fingerprint?.mismatches || 0),
|
||||
count: measuredRounds,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!astra && props.showGroupRatio !== false && (
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Group Ratio')}
|
||||
</p>
|
||||
<p className='mt-1 text-2xl font-semibold break-all tabular-nums'>
|
||||
{monitor.group_ratio == null ? '—' : `${monitor.group_ratio}x`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(astra || props.detailsContent) && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -190,89 +190,9 @@ export function StatusGroupRow(props: {
|
||||
</CardHeader>
|
||||
{astra && (
|
||||
<CardContent className='min-w-0 p-5'>
|
||||
<div className='mb-3 flex flex-wrap items-center justify-between gap-2'>
|
||||
<h3 className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Recent pelican works')}
|
||||
</h3>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Last 6 hours')}
|
||||
</span>
|
||||
</div>
|
||||
{works.length ? (
|
||||
<div
|
||||
className='flex gap-3 overflow-x-auto overscroll-x-contain pb-2'
|
||||
role='region'
|
||||
aria-label={t('Recent pelican works')}
|
||||
tabIndex={0}
|
||||
>
|
||||
{works.map((work, index) => (
|
||||
<div
|
||||
key={work.id}
|
||||
className='w-[200px] shrink-0 overflow-hidden rounded-lg border'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='block w-full cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-[-2px]'
|
||||
aria-label={t('Preview work')}
|
||||
onClick={() => setSelected(work.id)}
|
||||
>
|
||||
<ArtworkThumbnail
|
||||
id={work.id}
|
||||
artworkBase={artworkBase}
|
||||
title={t('Pelican sample {{number}}', {
|
||||
number: index + 1,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
<div className='space-y-1.5 px-2.5 py-2'>
|
||||
<div className='flex items-center justify-between gap-1'>
|
||||
<span className='text-xs'>
|
||||
{t('Sample {{number}}', { number: index + 1 })}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
work.quality === 'normal' ? 'secondary' : 'outline'
|
||||
}
|
||||
>
|
||||
{t(qualityLabel(work.quality))}
|
||||
</Badge>
|
||||
</div>
|
||||
<time
|
||||
className='text-muted-foreground block text-[11px]'
|
||||
dateTime={new Date(work.at * 1000).toISOString()}
|
||||
>
|
||||
{new Date(work.at * 1000).toLocaleString(locale, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</time>
|
||||
{work.source_url && (
|
||||
<a
|
||||
href={work.source_url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary block text-center text-xs underline'
|
||||
>
|
||||
{t('View on manxue.ai')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-muted/30 flex min-h-24 items-center justify-center rounded-lg border border-dashed px-5 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
monitor.pelican?.running
|
||||
? 'Detection in progress'
|
||||
: 'No pelican works yet'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FingerprintPanel data={monitor.fingerprint} />
|
||||
{monitor.benchmark && <BenchmarkPanel data={monitor.benchmark} />}
|
||||
{monitor.candy && <CandyPanel data={monitor.candy} />}
|
||||
</CardContent>
|
||||
)}
|
||||
<CardContent
|
||||
@@ -281,21 +201,57 @@ export function StatusGroupRow(props: {
|
||||
<h3 className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Available models')}
|
||||
</h3>
|
||||
{props.showModelCosts && (
|
||||
<p
|
||||
className='text-muted-foreground text-xs'
|
||||
title={t(
|
||||
'Last 24 hours, all users. Actual charges divided by total input and output tokens. Historical average, not a price quote.'
|
||||
)}
|
||||
>
|
||||
{t('Average charged / 1M tokens')}
|
||||
</p>
|
||||
)}
|
||||
{props.showModelCosts && (
|
||||
<p className='text-muted-foreground text-xs leading-relaxed break-words'>
|
||||
{t(
|
||||
'For reference only: insufficient request samples may cause very large errors in the average charge.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-2',
|
||||
astra && 'xl:flex-col xl:items-start'
|
||||
)}
|
||||
className={
|
||||
props.showModelCosts
|
||||
? 'space-y-2'
|
||||
: cn(
|
||||
'flex flex-wrap gap-2',
|
||||
astra && 'xl:flex-col xl:items-start'
|
||||
)
|
||||
}
|
||||
>
|
||||
{monitor.models?.length ? (
|
||||
monitor.models.map((model) => (
|
||||
<Badge
|
||||
<div
|
||||
key={model}
|
||||
variant='secondary'
|
||||
className='h-auto max-w-full rounded-md py-1 break-all whitespace-normal'
|
||||
className={
|
||||
props.showModelCosts
|
||||
? 'flex min-w-0 items-center justify-between gap-3 border-b pb-2 last:border-0'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{model}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-auto max-w-full rounded-md py-1 break-all whitespace-normal'
|
||||
>
|
||||
{model}
|
||||
</Badge>
|
||||
{props.showModelCosts && (
|
||||
<span className='shrink-0 text-sm tabular-nums'>
|
||||
{monitor.model_costs?.[model]?.usd_per_million == null
|
||||
? '—'
|
||||
: `$${monitor.model_costs[model].usd_per_million.toFixed(4)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -315,25 +271,6 @@ export function StatusGroupRow(props: {
|
||||
refreshing={props.refreshing}
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
open={!!selectedWork}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected('')
|
||||
}}
|
||||
title={`${monitor.name} · ${t('Pelican work preview')}`}
|
||||
contentClassName='sm:max-w-5xl'
|
||||
>
|
||||
{selectedWork && (
|
||||
<iframe
|
||||
title={t('Pelican work preview')}
|
||||
tabIndex={-1}
|
||||
src={artworkBase + selectedWork.id}
|
||||
sandbox='allow-scripts'
|
||||
referrerPolicy='no-referrer'
|
||||
className='h-[65vh] w-full rounded-lg border bg-white'
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={details}
|
||||
onOpenChange={setDetails}
|
||||
@@ -399,7 +336,7 @@ export function GroupStatus() {
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-3 text-sm'>
|
||||
{t(
|
||||
'Live group status, model fingerprints and recent pelican works.'
|
||||
'Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -464,6 +401,7 @@ export function GroupStatus() {
|
||||
<StatusGroupRow
|
||||
key={monitor.id || monitor.name}
|
||||
monitor={monitor}
|
||||
showGroupRatio={false}
|
||||
updatedAt={query.dataUpdatedAt}
|
||||
refreshing={query.isFetching}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
|
||||
import { apiKeySchema, type ApiKey } from '../../types'
|
||||
import { ApiKeyQuotaCell } from '../api-key-quota-cell'
|
||||
import { ApiKeysClockContext } from '../api-keys-clock-context'
|
||||
import { useApiKeysColumns } from '../api-keys-columns'
|
||||
import { ApiKeysProvider } from '../api-keys-provider'
|
||||
import { ApiKeysTable } from '../api-keys-table'
|
||||
@@ -80,9 +81,7 @@ await i18n.init({
|
||||
const clients: QueryClient[] = []
|
||||
|
||||
function QuotaTable(props: { apiKey: ApiKey }) {
|
||||
const columns = useApiKeysColumns(now).filter(
|
||||
(column) => column.id === 'quota'
|
||||
)
|
||||
const columns = useApiKeysColumns().filter((column) => column.id === 'quota')
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: [props.apiKey],
|
||||
@@ -127,7 +126,9 @@ function renderQuota(apiKey: ApiKey = key) {
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<QuotaTable apiKey={apiKey} />
|
||||
<ApiKeysClockContext value={now}>
|
||||
<QuotaTable apiKey={apiKey} />
|
||||
</ApiKeysClockContext>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
)
|
||||
@@ -525,3 +526,34 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by
|
||||
expect(within(details).getByText('192.0.2.1')).toBeVisible()
|
||||
expect(within(details).getByText('2001:db8::1')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps keyboard focus on a row checkbox when selection changes', async () => {
|
||||
await renderKeysPage()
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'Select row' })
|
||||
act(() => checkbox.focus())
|
||||
await userEvent.keyboard(' ')
|
||||
expect(screen.getByRole('checkbox', { name: 'Select row' })).toBeChecked()
|
||||
expect(screen.getByRole('checkbox', { name: 'Select row' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps quota details open while the relative-time clock updates', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] })
|
||||
vi.setSystemTime(now)
|
||||
try {
|
||||
await renderKeysPage(1, { expired_time: now / 1000 + 20 })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Remaining 80;/ }))
|
||||
expect(await screen.findByRole('dialog')).toBeVisible()
|
||||
expect(
|
||||
within(screen.getByRole('table')).getByRole('progressbar')
|
||||
).toHaveClass('text-emerald-500')
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(
|
||||
within(screen.getByRole('table')).getByRole('progressbar')
|
||||
).toHaveClass('text-muted-foreground/60')
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
expect(within(screen.getByRole('dialog')).getByText('40%')).toBeVisible()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
@@ -194,6 +194,26 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('API keys unified routing pool', () => {
|
||||
test('saves group degradation switching independently from response model detection', async () => {
|
||||
const createdPayloads: Array<Record<string, unknown>> = []
|
||||
installApiFixtures(createdPayloads)
|
||||
await renderCreateDrawer()
|
||||
changeInput(getControlByLabel('Name'), 'degradation-switch')
|
||||
fireEvent.click(
|
||||
screen.getByRole('switch', { name: 'Astra model detection' })
|
||||
)
|
||||
const toggle = screen.getByRole('switch', {
|
||||
name: 'Auto-switch on group degradation',
|
||||
})
|
||||
expect(toggle).not.toBeChecked()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(findButton('Save changes', true))
|
||||
await waitFor(() => expect(createdPayloads).toHaveLength(1))
|
||||
expect(createdPayloads[0]?.routing_policy).toMatchObject({
|
||||
skip_degraded_groups: true,
|
||||
})
|
||||
expect(createdPayloads[0]?.astra_detection_enabled).toBe(false)
|
||||
})
|
||||
test('requires fee confirmation before saving auto-switch and cancellation sends no request', async () => {
|
||||
const createdPayloads: Array<Record<string, unknown>> = []
|
||||
installApiFixtures(createdPayloads)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { t } from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RoutingPolicy } from '../../types'
|
||||
import { RoutingPoolEditor } from '../routing-pool-editor'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('puts automatic grouping first, edits settings, and restores the manual pool', async () => {
|
||||
const changed = vi.fn()
|
||||
function Form() {
|
||||
const [policy, setPolicy] = useState<RoutingPolicy>({
|
||||
version: 1,
|
||||
mode: 'custom',
|
||||
groups: [{ group: 'primary', priority: 0 }],
|
||||
model_rules: [],
|
||||
})
|
||||
return (
|
||||
<RoutingPoolEditor
|
||||
value={policy}
|
||||
onChange={(next) => {
|
||||
setPolicy(next)
|
||||
changed(next)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'primary', label: 'primary' },
|
||||
{ value: 'backup', label: 'backup' },
|
||||
]}
|
||||
inherited={[]}
|
||||
models={[]}
|
||||
maxCount={5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Form />)
|
||||
fireEvent.click(screen.getByText(t('Add group')))
|
||||
const automatic = await screen.findByRole('option', {
|
||||
name: new RegExp(t('Automatic grouping')),
|
||||
})
|
||||
expect(screen.getAllByRole('option')[0]).toBe(automatic)
|
||||
fireEvent.click(automatic)
|
||||
expect(changed).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'smart',
|
||||
groups: [],
|
||||
model_rules: [],
|
||||
auto_preference: 'cost',
|
||||
allow_degraded: false,
|
||||
})
|
||||
)
|
||||
expect(screen.queryByText(t('Model preferences'))).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Settings') }))
|
||||
expect(await screen.findByRole('dialog')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: t('Skip suspected degraded groups') })
|
||||
).toBeChecked()
|
||||
fireEvent.click(
|
||||
screen.getByRole('radio', { name: new RegExp(t('Fast response first')) })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('switch', { name: t('Skip suspected degraded groups') })
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Save') }))
|
||||
expect(changed).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'smart',
|
||||
auto_preference: 'speed',
|
||||
allow_degraded: true,
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: t('Return to manual pool') })
|
||||
)
|
||||
expect(changed).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'custom',
|
||||
groups: [{ group: 'primary', priority: 0 }],
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('primary')).toBeVisible()
|
||||
})
|
||||
@@ -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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CCSwitchDialog } from '../dialogs/cc-switch-dialog'
|
||||
|
||||
function renderDialog(models = ['gpt-6-astra', 'gpt-6-sol']) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
client.setQueryData(['user-models-ccswitch'], { success: true, data: models })
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CCSwitchDialog
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
tokenKey='test-fixture'
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return { user: userEvent.setup(), open }
|
||||
}
|
||||
|
||||
afterEach(() => localStorage.clear())
|
||||
|
||||
describe('CC Switch model selection', () => {
|
||||
it('imports the selected Codex model and its v1 endpoint', async () => {
|
||||
const { user, open } = renderDialog()
|
||||
await user.click(screen.getByRole('radio', { name: 'Codex' }))
|
||||
const input = screen.getByPlaceholderText('Select or enter model name')
|
||||
await user.click(input)
|
||||
await user.click(await screen.findByRole('option', { name: 'gpt-6-astra' }))
|
||||
await waitFor(() => expect(input).toHaveValue('gpt-6-astra'))
|
||||
await user.click(screen.getByRole('button', { name: 'Open CC Switch' }))
|
||||
const url = new URL(String(open.mock.calls[0]?.[0]))
|
||||
expect(url.protocol).toBe('ccswitch:')
|
||||
expect(url.searchParams.get('model')).toBe('gpt-6-astra')
|
||||
expect(url.searchParams.get('app')).toBe('codex')
|
||||
expect(url.searchParams.get('endpoint')).toBe(
|
||||
`${window.location.origin}/v1`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a manually entered model when the catalog is empty and focus leaves the field', async () => {
|
||||
const { user, open } = renderDialog([])
|
||||
await user.click(screen.getByRole('radio', { name: 'Codex' }))
|
||||
const input = screen.getByPlaceholderText('Select or enter model name')
|
||||
await user.type(input, 'gpt-custom')
|
||||
await user.tab()
|
||||
expect(input).toHaveValue('gpt-custom')
|
||||
await user.click(screen.getByRole('button', { name: 'Open CC Switch' }))
|
||||
const url = new URL(String(open.mock.calls[0]?.[0]))
|
||||
expect(url.searchParams.get('model')).toBe('gpt-custom')
|
||||
})
|
||||
|
||||
it('selects a model with the keyboard and clears it when switching applications', async () => {
|
||||
const { user, open } = renderDialog()
|
||||
await user.click(screen.getByRole('radio', { name: 'Codex' }))
|
||||
const input = screen.getByPlaceholderText('Select or enter model name')
|
||||
await user.click(input)
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
expect(input).toHaveValue('gpt-6-astra')
|
||||
await user.click(screen.getByRole('radio', { name: 'Gemini' }))
|
||||
expect(input).toHaveValue('')
|
||||
await user.click(screen.getByRole('button', { name: 'Open CC Switch' }))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { t } from 'i18next'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiKeyGroupCombobox } from '../api-key-group-combobox'
|
||||
import { GroupRatioBadge } from '../auto-group-visuals'
|
||||
import { AutomaticRoutingSettings } from '../automatic-routing-settings'
|
||||
|
||||
afterEach(cleanup)
|
||||
it('retains the child selection while the master is disabled and saves both', () => {
|
||||
const save = vi.fn()
|
||||
render(
|
||||
<AutomaticRoutingSettings
|
||||
policy={{
|
||||
version: 1,
|
||||
mode: 'smart',
|
||||
groups: [],
|
||||
model_rules: [],
|
||||
skip_degraded_non_astra: true,
|
||||
}}
|
||||
onSave={save}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)
|
||||
const master = screen.getByRole('switch', {
|
||||
name: t('Skip suspected degraded groups'),
|
||||
})
|
||||
const child = screen.getByRole('switch', {
|
||||
name: t('Skip non-Astra requests too'),
|
||||
})
|
||||
expect(master).toBeChecked()
|
||||
expect(child).toBeChecked()
|
||||
fireEvent.click(master)
|
||||
expect(child).toHaveAttribute('aria-disabled', 'true')
|
||||
fireEvent.click(child)
|
||||
expect(child).toBeChecked()
|
||||
expect(child).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('Save') }))
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allow_degraded: true,
|
||||
skip_degraded_non_astra: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
it('searches tags and distinguishes automatic degradation from manual tags', async () => {
|
||||
render(
|
||||
<ApiKeyGroupCombobox
|
||||
options={[
|
||||
{
|
||||
value: 'key',
|
||||
label: 'Key',
|
||||
tags: ['打野', '近期降智'],
|
||||
recent_degraded: true,
|
||||
ratio: 0.2,
|
||||
base_ratio: 0.27,
|
||||
has_personal_ratio: true,
|
||||
},
|
||||
{ value: 'other', label: 'Other', tags: ['Plus池'] },
|
||||
]}
|
||||
onValueChange={() => {}}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.change(screen.getByPlaceholderText(t('Search...')), {
|
||||
target: { value: '打野' },
|
||||
})
|
||||
expect(await screen.findByRole('option', { name: /Key/ })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('option', { name: /Other/ })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(`${t('Recent degradation')} · ${t('Auto')}`)
|
||||
).toBeVisible()
|
||||
expect(screen.getByText('0.27x').tagName).toBe('DEL')
|
||||
expect(screen.getByText(t('Personal rate'))).toBeVisible()
|
||||
})
|
||||
it('does not present higher or equal personal multipliers as discounts', () => {
|
||||
const result = render(
|
||||
<GroupRatioBadge ratio={0.3} baseRatio={0.2} personal />
|
||||
)
|
||||
expect(result.container.querySelector('del')).toBeNull()
|
||||
result.rerender(<GroupRatioBadge ratio={0.2} baseRatio={0.2} personal />)
|
||||
expect(screen.getAllByText('0.2x')).toHaveLength(1)
|
||||
})
|
||||
@@ -1,21 +1,3 @@
|
||||
/*
|
||||
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, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -42,12 +24,35 @@ import {
|
||||
AutoGroupFlowBorder,
|
||||
GroupRatioBadge,
|
||||
} from './auto-group-visuals'
|
||||
/*
|
||||
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 { GroupTags } from './group-tags'
|
||||
|
||||
export type ApiKeyGroupOption = {
|
||||
value: string
|
||||
label: string
|
||||
desc?: string
|
||||
ratio?: number | string
|
||||
base_ratio?: number
|
||||
has_personal_ratio?: boolean
|
||||
tags?: string[]
|
||||
recent_degraded?: boolean
|
||||
}
|
||||
|
||||
type ApiKeyGroupComboboxProps = {
|
||||
@@ -82,10 +87,13 @@ export function ApiKeyGroupCombobox({
|
||||
option.value.toLowerCase().includes(search) ||
|
||||
option.label.toLowerCase().includes(search) ||
|
||||
option.desc?.toLowerCase().includes(search) ||
|
||||
ratioText.includes(search)
|
||||
ratioText.includes(search) ||
|
||||
option.tags?.some((tag) => tag.toLowerCase().includes(search)) ||
|
||||
(option.recent_degraded &&
|
||||
t('Recent degradation').toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
}, [options, searchValue])
|
||||
}, [options, searchValue, t])
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onValueChange(selectedValue)
|
||||
@@ -123,15 +131,21 @@ export function ApiKeyGroupCombobox({
|
||||
<span className='block truncate font-medium'>
|
||||
{selectedOption?.label || placeholder || t('Select a group')}
|
||||
</span>
|
||||
<GroupTags
|
||||
tags={selectedOption?.tags}
|
||||
recent={selectedOption?.recent_degraded}
|
||||
/>
|
||||
{selectedOption?.desc && (
|
||||
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className='hidden sm:block'>
|
||||
<span className='shrink-0'>
|
||||
<GroupRatioBadge
|
||||
ratio={selectedOption?.ratio}
|
||||
baseRatio={selectedOption?.base_ratio}
|
||||
personal={selectedOption?.has_personal_ratio}
|
||||
isAuto={isAutoSelected}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
@@ -191,6 +205,10 @@ export function ApiKeyGroupCombobox({
|
||||
<span className='block truncate font-medium'>
|
||||
{option.label}
|
||||
</span>
|
||||
<GroupTags
|
||||
tags={option.tags}
|
||||
recent={option.recent_degraded}
|
||||
/>
|
||||
{option.desc && (
|
||||
<span className='text-muted-foreground block truncate text-xs'>
|
||||
{option.desc}
|
||||
@@ -199,6 +217,8 @@ export function ApiKeyGroupCombobox({
|
||||
</span>
|
||||
<GroupRatioBadge
|
||||
ratio={option.ratio}
|
||||
baseRatio={option.base_ratio}
|
||||
personal={option.has_personal_ratio}
|
||||
isAuto={isAutoOption}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
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 { createContext } from 'react'
|
||||
|
||||
export const ApiKeysClockContext = createContext(0)
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 { useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
import { ApiKeysClockContext } from './api-keys-clock-context'
|
||||
|
||||
export function ApiKeysClockProvider(props: { children: ReactNode }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
return <ApiKeysClockContext value={now}>{props.children}</ApiKeysClockContext>
|
||||
}
|
||||
@@ -17,7 +17,8 @@ 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 type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { CellContext, ColumnDef } from '@tanstack/react-table'
|
||||
import { useContext, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
IpRestrictionsCell,
|
||||
ModelLimitsCell,
|
||||
} from './api-keys-cells'
|
||||
import { ApiKeysClockContext } from './api-keys-clock-context'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
function useGroupRatios(): Record<string, number | string> {
|
||||
@@ -49,185 +51,227 @@ function useGroupRatios(): Record<string, number | string> {
|
||||
queryKey: ['user-groups'],
|
||||
queryFn: async () => requireServerSuccess(await getUserGroups()),
|
||||
staleTime: 0,
|
||||
select: (res) => {
|
||||
if (!res.success || !res.data) return {}
|
||||
const ratios: Record<string, number | string> = {}
|
||||
for (const [group, info] of Object.entries(res.data)) {
|
||||
if (typeof info.ratio === 'number' || typeof info.ratio === 'string') {
|
||||
ratios[group] = info.ratio
|
||||
}
|
||||
}
|
||||
return ratios
|
||||
},
|
||||
})
|
||||
|
||||
return data ?? {}
|
||||
return useMemo(() => {
|
||||
const ratios: Record<string, number | string> = {}
|
||||
for (const [group, info] of Object.entries(data?.data ?? {})) {
|
||||
if (typeof info.ratio === 'number' || typeof info.ratio === 'string') {
|
||||
ratios[group] = info.ratio
|
||||
}
|
||||
}
|
||||
return ratios
|
||||
}, [data])
|
||||
}
|
||||
|
||||
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
|
||||
const { t, i18n } = useTranslation()
|
||||
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
const { t } = useTranslation()
|
||||
useSystemConfigStore((state) => state.config.currency)
|
||||
const { meta: currency } = getCurrencyDisplay()
|
||||
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
|
||||
const groupRatios = useGroupRatios()
|
||||
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const justNowLabel = t('Just now')
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={t('Select all')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('Name'),
|
||||
cell: ({ row }) => (
|
||||
<span className='font-medium'>{row.getValue('name')}</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
|
||||
if (!statusConfig) return null
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
return useMemo<ColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label={t('Select all')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
accessorKey: 'key',
|
||||
header: t('API Key'),
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
accessorKey: 'remain_quota',
|
||||
header: `${t('Quota')} (${quotaUnit})`,
|
||||
cell: ({ row }) => <ApiKeyQuotaCell apiKey={row.original} now={now} />,
|
||||
size: 260,
|
||||
minSize: 260,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: t('Group'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
const group = row.getValue('group') as string
|
||||
if (apiKey.routing_policy) {
|
||||
const pool = apiKey.routing_policy
|
||||
if (pool.mode !== 'custom') return <span className='text-sm'>{t(pool.mode === 'user' ? 'Inherit user group' : 'Inherit global Auto')}</span>
|
||||
return <span className='text-sm' title={pool.model_rules.map(r => r.model).join(', ')}>{[...pool.groups].sort((a,b) => a.priority-b.priority).map(g => g.group).join(' → ')}{pool.model_rules.length ? ` · ${t('Model rules')}: ${pool.model_rules.length}` : ''}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiKeyGroupCell
|
||||
group={group}
|
||||
ratio={groupRatios[group]}
|
||||
crossGroupRetry={apiKey.cross_group_retry}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
)
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
size: 220,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'model_limits',
|
||||
accessorKey: 'model_limits',
|
||||
header: t('Models'),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'allow_ips',
|
||||
accessorKey: 'allow_ips',
|
||||
header: t('IP Restriction'),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'activity_time',
|
||||
accessorKey: 'created_time',
|
||||
header: t('Time'),
|
||||
cell: ({ row }) => <ApiKeyActivityCell apiKey={row.original} now={now} />,
|
||||
size: 220,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
header: t('Expires'),
|
||||
cell: ({ row }) => {
|
||||
const expiredTime = row.getValue('expired_time') as number
|
||||
if (expiredTime === -1) {
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('Name'),
|
||||
cell: ({ row }) => (
|
||||
<span className='font-medium'>{row.getValue('name')}</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const statusConfig =
|
||||
API_KEY_STATUSES[row.getValue('status') as number]
|
||||
if (!statusConfig) return null
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={expiredTime}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className={
|
||||
expiredTime * 1000 <= now
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
{
|
||||
id: 'key',
|
||||
accessorKey: 'key',
|
||||
header: t('API Key'),
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
accessorKey: 'remain_quota',
|
||||
header: `${t('Quota')} (${quotaUnit})`,
|
||||
cell: function ApiKeyQuotaColumnCell(
|
||||
props: CellContext<ApiKey, unknown>
|
||||
) {
|
||||
const now = useContext(ApiKeysClockContext)
|
||||
return <ApiKeyQuotaCell apiKey={props.row.original} now={now} />
|
||||
},
|
||||
size: 260,
|
||||
minSize: 260,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: t('Group'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
const group = row.getValue('group') as string
|
||||
if (apiKey.routing_policy) {
|
||||
const pool = apiKey.routing_policy
|
||||
if (pool.mode === 'smart') {
|
||||
return <span className='text-sm'>{t('Automatic grouping')}</span>
|
||||
}
|
||||
if (pool.mode !== 'custom') {
|
||||
return (
|
||||
<span className='text-sm'>
|
||||
{t(
|
||||
pool.mode === 'user'
|
||||
? 'Inherit user group'
|
||||
: 'Inherit global Auto'
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className='text-sm'
|
||||
title={pool.model_rules.map((r) => r.model).join(', ')}
|
||||
>
|
||||
{[...pool.groups]
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((g) => g.group)
|
||||
.join(' → ')}
|
||||
{pool.model_rules.length
|
||||
? ` · ${t('Model rules')}: ${pool.model_rules.length}`
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiKeyGroupCell
|
||||
group={group}
|
||||
ratio={groupRatios[group]}
|
||||
crossGroupRetry={apiKey.cross_group_retry}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 220,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'model_limits',
|
||||
accessorKey: 'model_limits',
|
||||
header: t('Models'),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'allow_ips',
|
||||
accessorKey: 'allow_ips',
|
||||
header: t('IP Restriction'),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'activity_time',
|
||||
accessorKey: 'created_time',
|
||||
header: t('Time'),
|
||||
cell: function ApiKeyActivityColumnCell(
|
||||
props: CellContext<ApiKey, unknown>
|
||||
) {
|
||||
const now = useContext(ApiKeysClockContext)
|
||||
return <ApiKeyActivityCell apiKey={props.row.original} now={now} />
|
||||
},
|
||||
size: 220,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
header: t('Expires'),
|
||||
cell: function ApiKeyExpiryColumnCell(
|
||||
props: CellContext<ApiKey, unknown>
|
||||
) {
|
||||
const now = useContext(ApiKeysClockContext)
|
||||
const { t, i18n } = useTranslation()
|
||||
const expiredTime = props.row.original.expired_time
|
||||
if (expiredTime === -1) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={expiredTime}
|
||||
now={now}
|
||||
locale={toIntlLocale(i18n.resolvedLanguage || i18n.language)}
|
||||
justNowLabel={t('Just now')}
|
||||
className={
|
||||
expiredTime * 1000 <= now
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
],
|
||||
[t, quotaUnit, groupRatios, shouldReduceMotion]
|
||||
)
|
||||
}
|
||||
@@ -166,6 +166,10 @@ export function ApiKeysMutateDrawer({
|
||||
label: key,
|
||||
desc: info.desc || key,
|
||||
ratio: info.ratio,
|
||||
base_ratio: info.base_ratio,
|
||||
has_personal_ratio: info.has_personal_ratio,
|
||||
tags: info.tags,
|
||||
recent_degraded: info.recent_degraded,
|
||||
})),
|
||||
[groupsData]
|
||||
)
|
||||
@@ -379,6 +383,13 @@ export function ApiKeysMutateDrawer({
|
||||
: t('Add a new API key by providing necessary info.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{!isFormInitialized && (
|
||||
<p role='status' className='text-muted-foreground p-4 text-sm'>
|
||||
{t(
|
||||
'Loading API key settings. The empty fields below are not the saved configuration.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='api-key-form'
|
||||
@@ -591,7 +602,7 @@ export function ApiKeysMutateDrawer({
|
||||
<FormLabel>{t('Astra model detection')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.'
|
||||
'Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
@@ -641,6 +652,61 @@ export function ApiKeysMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch('routing_policy.mode') !== 'smart' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='routing_policy.skip_degraded_groups'
|
||||
render={({ field }) => (
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<div className='space-y-1'>
|
||||
<FormLabel>
|
||||
{t('Auto-switch on group degradation')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'For Astra requests, skip groups whose latest valid fingerprint or pelican result indicates degradation. Failed checks do not replace valid results; each check must recover or be reset. Groups with no valid result remain eligible.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value === true}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='routing_policy.skip_degraded_non_astra'
|
||||
render={({ field }) => (
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<div className='space-y-1'>
|
||||
<FormLabel>
|
||||
{t('Skip non-Astra requests too')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Uses the group Astra fingerprint and pelican checks; other models have not been individually tested. Off by default.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value === true}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={
|
||||
!form.watch('routing_policy.skip_degraded_groups')
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SideDrawerSection>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useContext, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
ModelLimitsCell,
|
||||
IpRestrictionsCell,
|
||||
} from './api-keys-cells'
|
||||
import { ApiKeysClockProvider } from './api-keys-clock'
|
||||
import { ApiKeysClockContext } from './api-keys-clock-context'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
@@ -101,12 +103,11 @@ function ApiKeysMobileSkeleton() {
|
||||
function ApiKeysMobileList({
|
||||
table,
|
||||
isLoading,
|
||||
now,
|
||||
}: {
|
||||
table: TanstackTable<ApiKey>
|
||||
isLoading: boolean
|
||||
now: number
|
||||
}) {
|
||||
const now = useContext(ApiKeysClockContext)
|
||||
const { t } = useTranslation()
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
@@ -218,16 +219,7 @@ function ApiKeysMobileList({
|
||||
export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const columns = useApiKeysColumns(now)
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 30_000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [])
|
||||
const columns = useApiKeysColumns()
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -339,45 +331,45 @@ export function ApiKeysTable() {
|
||||
}, [columnVisibility, table])
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No API Keys Found')}
|
||||
emptyDescription={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
searchDebounceMs: 500,
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by API key...')}
|
||||
aria-label={t('Filter by API key...')}
|
||||
value={tokenFilterInput}
|
||||
onChange={(e) => setTokenFilterInput(e.target.value)}
|
||||
className='w-full sm:w-50 lg:w-60'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={
|
||||
<ApiKeysMobileList table={table} isLoading={isLoading} now={now} />
|
||||
}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
|
||||
}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
<ApiKeysClockProvider>
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No API Keys Found')}
|
||||
emptyDescription={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
searchDebounceMs: 500,
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by API key...')}
|
||||
aria-label={t('Filter by API key...')}
|
||||
value={tokenFilterInput}
|
||||
onChange={(e) => setTokenFilterInput(e.target.value)}
|
||||
className='w-full sm:w-50 lg:w-60'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
|
||||
}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
</ApiKeysClockProvider>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { parseUserSettings } from '@/features/profile/lib/format'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getApiKeys } from '../api'
|
||||
|
||||
export function AstraCostNotice() {
|
||||
@@ -48,7 +49,9 @@ export function AstraCostNotice() {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || t('Failed to load API keys'))
|
||||
}
|
||||
if (result.data.items.some((token) => token.astra_auto_switch === true)) {
|
||||
if (
|
||||
result.data.items.some((token) => token.astra_auto_switch === true)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (page * result.data.page_size >= result.data.total) return false
|
||||
@@ -58,19 +61,32 @@ export function AstraCostNotice() {
|
||||
enabled: userId !== undefined,
|
||||
})
|
||||
const acknowledge = useMutation({
|
||||
mutationFn: async () => requireServerSuccess(await updateUserSettings({ astra_cost_notice_ack: true })),
|
||||
mutationFn: async () =>
|
||||
requireServerSuccess(
|
||||
await updateUserSettings({ astra_cost_notice_ack: true })
|
||||
),
|
||||
onSuccess: () => {
|
||||
setDismissedUserId(userId)
|
||||
void queryClient.invalidateQueries({ queryKey: noticeKey })
|
||||
},
|
||||
onError: (error) => handleServerError(error, t('Failed to update settings')),
|
||||
onError: (error) =>
|
||||
handleServerError(error, t('Failed to update settings')),
|
||||
})
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={!!profile && hasMigratedToken === true && !parseUserSettings(profile.setting).astra_cost_notice_ack && dismissedUserId !== userId}
|
||||
onOpenChange={(value) => { if (!value && !acknowledge.isPending) setDismissedUserId(userId) }}
|
||||
open={
|
||||
!!profile &&
|
||||
hasMigratedToken === true &&
|
||||
!parseUserSettings(profile.setting).astra_cost_notice_ack &&
|
||||
dismissedUserId !== userId
|
||||
}
|
||||
onOpenChange={(value) => {
|
||||
if (!value && !acknowledge.isPending) setDismissedUserId(userId)
|
||||
}}
|
||||
title={t('Astra auto-switch and charges')}
|
||||
desc={t('Existing API keys keep Astra auto-switch enabled; new keys default to off. Rejected results still incur usage charges, and every additional upstream attempt is charged separately. You can change this setting for each API key.')}
|
||||
desc={t(
|
||||
'Existing API keys keep Astra auto-switch enabled; new keys default to off. Rejected results still incur usage charges, and every additional upstream attempt is charged separately. You can change this setting for each API key.'
|
||||
)}
|
||||
cancelBtnText={t('Remind me next time')}
|
||||
confirmText={t('I understand')}
|
||||
isLoading={acknowledge.isPending}
|
||||
|
||||
@@ -74,6 +74,8 @@ export function AutoGroupFrame(props: AutoGroupFrameProps) {
|
||||
type GroupRatioBadgeProps = {
|
||||
isAuto?: boolean
|
||||
ratio: GroupRatio
|
||||
baseRatio?: number
|
||||
personal?: boolean
|
||||
shouldReduceMotion?: boolean
|
||||
}
|
||||
|
||||
@@ -84,22 +86,44 @@ export function GroupRatioBadge(props: GroupRatioBadgeProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const discounted =
|
||||
props.personal &&
|
||||
typeof props.ratio === 'number' &&
|
||||
props.baseRatio !== undefined &&
|
||||
props.ratio < props.baseRatio
|
||||
return (
|
||||
<GroupMultiplierBadge
|
||||
ratio={typeof props.ratio === 'number' ? props.ratio : undefined}
|
||||
label={typeof props.ratio === 'number' ? undefined : t('Auto')}
|
||||
className={cn(
|
||||
props.isAuto &&
|
||||
'overflow-visible rounded-md border-primary/30 bg-primary/10 text-primary'
|
||||
<span className='inline-flex flex-wrap items-center gap-1'>
|
||||
{discounted && (
|
||||
<del className='text-muted-foreground text-xs'>{props.baseRatio}x</del>
|
||||
)}
|
||||
>
|
||||
{props.isAuto && (
|
||||
<AutoGroupFlowBorder
|
||||
appearance='subtle'
|
||||
shouldReduceMotion={props.shouldReduceMotion ?? false}
|
||||
/>
|
||||
<GroupMultiplierBadge
|
||||
ratio={typeof props.ratio === 'number' ? props.ratio : undefined}
|
||||
label={typeof props.ratio === 'number' ? undefined : t('Auto')}
|
||||
className={cn(
|
||||
props.isAuto &&
|
||||
'overflow-visible rounded-md border-primary/30 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
{props.isAuto && (
|
||||
<AutoGroupFlowBorder
|
||||
appearance='subtle'
|
||||
shouldReduceMotion={props.shouldReduceMotion ?? false}
|
||||
/>
|
||||
)}
|
||||
</GroupMultiplierBadge>
|
||||
{props.personal && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
discounted
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t('Personal rate')}
|
||||
</span>
|
||||
)}
|
||||
</GroupMultiplierBadge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import type { RoutingPolicy } from '../types'
|
||||
|
||||
type Props = {
|
||||
policy: RoutingPolicy
|
||||
onSave: (policy: RoutingPolicy) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AutomaticRoutingSettings(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [preference, setPreference] = useState(
|
||||
props.policy.auto_preference ?? 'cost'
|
||||
)
|
||||
const [allowDegraded, setAllowDegraded] = useState(
|
||||
props.policy.allow_degraded ?? false
|
||||
)
|
||||
const [skipNonAstra, setSkipNonAstra] = useState(
|
||||
props.policy.skip_degraded_non_astra ?? false
|
||||
)
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.onClose()
|
||||
}}
|
||||
title={t('Automatic group settings')}
|
||||
description={t(
|
||||
'Automatically choose from all groups you can access that support the requested model.'
|
||||
)}
|
||||
footer={
|
||||
<>
|
||||
<Button type='button' variant='outline' onClick={props.onClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
props.onSave({
|
||||
...props.policy,
|
||||
auto_preference: preference,
|
||||
allow_degraded: allowDegraded,
|
||||
skip_degraded_non_astra: skipNonAstra,
|
||||
})
|
||||
props.onClose()
|
||||
}}
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-5'>
|
||||
<RadioGroup
|
||||
value={preference}
|
||||
aria-label={t('Routing preference')}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'cost' || value === 'speed' || value === 'stable') {
|
||||
setPreference(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Label
|
||||
htmlFor={`${id}-cost`}
|
||||
className='flex cursor-pointer items-start gap-3 rounded-lg border p-4'
|
||||
>
|
||||
<RadioGroupItem id={`${id}-cost`} value='cost' />
|
||||
<span>
|
||||
<span className='block font-medium'>{t('Low cost first')}</span>
|
||||
<span className='text-muted-foreground mt-1 block text-xs'>
|
||||
{t(
|
||||
'Prefer your lowest effective group multiplier, including personal overrides.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label
|
||||
htmlFor={`${id}-speed`}
|
||||
className='flex cursor-pointer items-start gap-3 rounded-lg border p-4'
|
||||
>
|
||||
<RadioGroupItem id={`${id}-speed`} value='speed' />
|
||||
<span>
|
||||
<span className='block font-medium'>
|
||||
{t('Fast response first')}
|
||||
</span>
|
||||
<span className='text-muted-foreground mt-1 block text-xs'>
|
||||
{t(
|
||||
'Prefer recent streaming model-test results: latest valid first-token time per channel within 1 hour, averaged across the group. Untested groups rank last.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label
|
||||
htmlFor={`${id}-stable`}
|
||||
className='flex cursor-pointer items-start gap-3 rounded-lg border p-4'
|
||||
>
|
||||
<RadioGroupItem id={`${id}-stable`} value='stable' />
|
||||
<span>
|
||||
<span className='block font-medium'>{t('Stability first')}</span>
|
||||
<span className='text-muted-foreground mt-1 block text-xs'>
|
||||
{t(
|
||||
'Prefer the highest recent request success rate for this model.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor={`${id}-degraded`}>
|
||||
{t('Skip suspected degraded groups')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Enabled by default for gpt-6-astra. Keep the last valid fingerprint and pelican conclusions until recovery or reset. Failed checks do not clear them.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${id}-degraded`}
|
||||
checked={!allowDegraded}
|
||||
onCheckedChange={(checked) => setAllowDegraded(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor={`${id}-non-astra`}>
|
||||
{t('Skip non-Astra requests too')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Uses the group Astra fingerprint and pelican checks; other models have not been individually tested. Off by default.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${id}-non-astra`}
|
||||
checked={skipNonAstra}
|
||||
disabled={allowDegraded}
|
||||
onCheckedChange={setSkipNonAstra}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Speed uses recent streaming model tests; stability uses request statistics. Price uses effective multipliers.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Settings take effect after saving the API key.')}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { Copy, Trash2, Loader2 } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import { type ApiKey } from '../types'
|
||||
import type { ApiKey } from '../types'
|
||||
import { ApiKeysMultiDeleteDialog } from './api-keys-multi-delete-dialog'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
|
||||
@@ -17,13 +17,21 @@ 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 { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ComboboxInput } from '@/components/ui/combobox-input'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from '@/components/ui/combobox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
@@ -108,11 +116,6 @@ export function CCSwitchDialog(props: Props) {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const items = modelsData?.data ?? []
|
||||
return items.map((m) => ({ value: m, label: m }))
|
||||
}, [modelsData?.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -134,7 +137,7 @@ export function CCSwitchDialog(props: Props) {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!models.model) {
|
||||
if (!models.model?.trim()) {
|
||||
toast.warning(t('Please select a primary model'))
|
||||
return
|
||||
}
|
||||
@@ -153,9 +156,7 @@ export function CCSwitchDialog(props: Props) {
|
||||
title={t('Import to CC Switch')}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
bodyClassName={
|
||||
currentConfig.modelFields.length === 1 ? 'space-y-4 pb-52' : 'space-y-4'
|
||||
}
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
|
||||
@@ -190,29 +191,52 @@ export function CCSwitchDialog(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Name')}</Label>
|
||||
<ComboboxInput
|
||||
options={[]}
|
||||
<Label htmlFor='cc-switch-name'>{t('Name')}</Label>
|
||||
<Input
|
||||
id='cc-switch-name'
|
||||
value={name}
|
||||
onValueChange={setName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={currentConfig.defaultName}
|
||||
emptyText=''
|
||||
allowCustomValue
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentConfig.modelFields.map((field) => (
|
||||
<div key={field.key} className='space-y-2'>
|
||||
<Label required={field.required}>{t(field.labelKey)}</Label>
|
||||
<ComboboxInput
|
||||
options={modelOptions}
|
||||
value={models[field.key] || ''}
|
||||
onValueChange={(v) =>
|
||||
setModels((prev) => ({ ...prev, [field.key]: v }))
|
||||
}
|
||||
placeholder={t('Select or enter model name')}
|
||||
emptyText={t('No models found')}
|
||||
/>
|
||||
<Label htmlFor={`cc-switch-${field.key}`} required={field.required}>
|
||||
{t(field.labelKey)}
|
||||
</Label>
|
||||
<Combobox
|
||||
items={modelsData?.data ?? []}
|
||||
value={models[field.key] || null}
|
||||
inputValue={models[field.key] || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) {
|
||||
setModels((prev) => ({ ...prev, [field.key]: value }))
|
||||
}
|
||||
}}
|
||||
onInputValueChange={(value, details) => {
|
||||
if (details.reason === 'input-change') {
|
||||
setModels((prev) => ({ ...prev, [field.key]: value }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={`cc-switch-${field.key}`}
|
||||
className='w-full'
|
||||
placeholder={t('Select or enter model name')}
|
||||
triggerAriaLabel={t(field.labelKey)}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{t('No models found')}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export function GroupTags(props: { tags?: string[] | null; recent?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const tags = [...new Set(props.tags ?? [])].filter(
|
||||
(tag) =>
|
||||
!props.recent || (tag !== '近期降智' && tag !== 'Recent degradation')
|
||||
)
|
||||
return (
|
||||
<span className='mt-1 flex flex-wrap gap-1 whitespace-normal'>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant='secondary' className='max-w-full break-all'>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{props.recent && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||
>
|
||||
{t('Recent degradation')} · {t('Auto')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
type ApiKeyGroupOption,
|
||||
} from './api-key-group-combobox'
|
||||
import { GroupRatioBadge } from './auto-group-visuals'
|
||||
import { AutomaticRoutingSettings } from './automatic-routing-settings'
|
||||
import { GroupTags } from './group-tags'
|
||||
|
||||
type Props = {
|
||||
value?: RoutingPolicy
|
||||
@@ -56,14 +58,17 @@ export function RoutingPoolEditor({
|
||||
const revision = useRef(0)
|
||||
const ruleKeys = useRef<string[]>([])
|
||||
const nextRuleKey = useRef(0)
|
||||
const policy = value ?? {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const manualPolicy = useRef<RoutingPolicy | null>(null)
|
||||
const policy: RoutingPolicy = value ?? {
|
||||
version: 1,
|
||||
mode: 'inherit',
|
||||
groups: [],
|
||||
model_rules: [],
|
||||
}
|
||||
while (ruleKeys.current.length < policy.model_rules.length)
|
||||
while (ruleKeys.current.length < policy.model_rules.length) {
|
||||
ruleKeys.current.push(`${id}-rule-${nextRuleKey.current++}`)
|
||||
}
|
||||
const groups =
|
||||
policy.mode === 'inherit'
|
||||
? inherited.map((g, priority) => ({ group: g.value, priority }))
|
||||
@@ -94,7 +99,8 @@ export function RoutingPoolEditor({
|
||||
entries: RoutingGroup[],
|
||||
available: ApiKeyGroupOption[],
|
||||
update: (groups: RoutingGroup[]) => void,
|
||||
label: string
|
||||
label: string,
|
||||
isPool = false
|
||||
) {
|
||||
entries = [...entries].sort((a, b) => a.priority - b.priority)
|
||||
function move(index: number, offset: number) {
|
||||
@@ -110,13 +116,25 @@ export function RoutingPoolEditor({
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.group}
|
||||
className='flex items-center gap-2 rounded-md border p-2'
|
||||
className='flex flex-wrap items-center gap-2 rounded-md border p-2'
|
||||
>
|
||||
<span className='min-w-0 flex-1 text-sm break-all'>
|
||||
{entry.group}
|
||||
<GroupTags
|
||||
tags={options.find((g) => g.value === entry.group)?.tags}
|
||||
recent={
|
||||
options.find((g) => g.value === entry.group)?.recent_degraded
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<GroupRatioBadge
|
||||
ratio={options.find((g) => g.value === entry.group)?.ratio}
|
||||
baseRatio={
|
||||
options.find((g) => g.value === entry.group)?.base_ratio
|
||||
}
|
||||
personal={
|
||||
options.find((g) => g.value === entry.group)?.has_personal_ratio
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className='w-20'
|
||||
@@ -173,14 +191,41 @@ export function RoutingPoolEditor({
|
||||
</div>
|
||||
))}
|
||||
<ApiKeyGroupCombobox
|
||||
options={available.filter(
|
||||
(g) =>
|
||||
g.value !== 'auto' && !entries.some((e) => e.group === g.value)
|
||||
)}
|
||||
options={[
|
||||
...(isPool
|
||||
? [
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('Automatic grouping'),
|
||||
desc: t(
|
||||
'Choose automatically: low cost, fast response or stability'
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...available.filter(
|
||||
(g) =>
|
||||
entries.length < maxCount &&
|
||||
g.value !== 'auto' &&
|
||||
!entries.some((e) => e.group === g.value)
|
||||
),
|
||||
]}
|
||||
value=''
|
||||
placeholder={t('Add group')}
|
||||
disabled={entries.length >= maxCount}
|
||||
onValueChange={(group) =>
|
||||
disabled={!isPool && entries.length >= maxCount}
|
||||
onValueChange={(group) => {
|
||||
if (isPool && group === 'auto') {
|
||||
manualPolicy.current = policy
|
||||
change({
|
||||
...policy,
|
||||
mode: 'smart',
|
||||
groups: [],
|
||||
model_rules: [],
|
||||
auto_preference: policy.auto_preference ?? 'cost',
|
||||
allow_degraded: policy.allow_degraded ?? false,
|
||||
})
|
||||
return
|
||||
}
|
||||
update([
|
||||
...entries,
|
||||
{
|
||||
@@ -190,7 +235,7 @@ export function RoutingPoolEditor({
|
||||
: 0,
|
||||
},
|
||||
])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -204,124 +249,177 @@ export function RoutingPoolEditor({
|
||||
'Lower priority numbers run first. Equal priorities use round-robin. Removing a pool group also removes its rule references.'
|
||||
)}
|
||||
</p>
|
||||
{editor(groups, options, editGroups, t('Group pool'))}
|
||||
</section>
|
||||
<section className='space-y-3' aria-label={t('Model preferences')}>
|
||||
<h3 className='font-medium'>{t('Model preferences')}</h3>
|
||||
{policy.mode === 'smart' ? (
|
||||
<div className='flex flex-wrap items-center gap-2 rounded-lg border p-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium'>{t('Automatic grouping')}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Automatically choose from all groups you can access that support the requested model.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
{t('Settings')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
change(
|
||||
manualPolicy.current ?? {
|
||||
...policy,
|
||||
mode: 'inherit',
|
||||
groups: [],
|
||||
model_rules: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Return to manual pool')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
editor(groups, options, editGroups, t('Group pool'), true)
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Exact model names only. Preferred groups run before the remaining pool. A successful session binding takes precedence.'
|
||||
'Retry a server error once in the same group. Switch and cool down for 10 minutes only when a backup is available; otherwise keep the current group. Streaming and non-streaming cooldowns are separate.'
|
||||
)}
|
||||
</p>
|
||||
<datalist id={id}>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
{policy.model_rules.map((rule, index) => (
|
||||
<div
|
||||
key={ruleKeys.current[index]}
|
||||
className='space-y-2 rounded-lg border p-3'
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
list={id}
|
||||
value={rule.model}
|
||||
aria-label={t('Model name')}
|
||||
placeholder={t('Model name')}
|
||||
onChange={(e) =>
|
||||
</section>
|
||||
{policy.mode !== 'smart' && (
|
||||
<section className='space-y-3' aria-label={t('Model preferences')}>
|
||||
<h3 className='font-medium'>{t('Model preferences')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Exact model names only. Preferred groups run before the remaining pool. A successful session binding takes precedence.'
|
||||
)}
|
||||
</p>
|
||||
<datalist id={id}>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
{policy.model_rules.map((rule, index) => (
|
||||
<div
|
||||
key={ruleKeys.current[index]}
|
||||
className='space-y-2 rounded-lg border p-3'
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
list={id}
|
||||
value={rule.model}
|
||||
aria-label={t('Model name')}
|
||||
placeholder={t('Model name')}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
...policy,
|
||||
model_rules: policy.model_rules.map((r, i) =>
|
||||
i === index ? { ...r, model: e.target.value } : r
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
ruleKeys.current.splice(index, 1)
|
||||
change({
|
||||
...policy,
|
||||
model_rules: policy.model_rules.filter(
|
||||
(_, i) => i !== index
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('Delete rule')}
|
||||
</Button>
|
||||
</div>
|
||||
{editor(
|
||||
rule.groups,
|
||||
groups.map(
|
||||
(g) =>
|
||||
options.find((o) => o.value === g.group) ?? {
|
||||
value: g.group,
|
||||
label: g.group,
|
||||
}
|
||||
),
|
||||
(entries) =>
|
||||
change({
|
||||
...policy,
|
||||
model_rules: policy.model_rules.map((r, i) =>
|
||||
i === index ? { ...r, model: e.target.value } : r
|
||||
i === index ? { ...r, groups: entries } : r
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
ruleKeys.current.splice(index, 1)
|
||||
change({
|
||||
...policy,
|
||||
model_rules: policy.model_rules.filter(
|
||||
(_, i) => i !== index
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('Delete rule')}
|
||||
</Button>
|
||||
}),
|
||||
rule.model
|
||||
)}
|
||||
</div>
|
||||
{editor(
|
||||
rule.groups,
|
||||
groups.map(
|
||||
(g) =>
|
||||
options.find((o) => o.value === g.group) ?? {
|
||||
value: g.group,
|
||||
label: g.group,
|
||||
}
|
||||
),
|
||||
(entries) =>
|
||||
change({
|
||||
...policy,
|
||||
model_rules: policy.model_rules.map((r, i) =>
|
||||
i === index ? { ...r, groups: entries } : r
|
||||
),
|
||||
}),
|
||||
rule.model
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!groups.length || policy.model_rules.length >= 128}
|
||||
onClick={() =>
|
||||
change({
|
||||
...policy,
|
||||
mode: 'custom',
|
||||
groups,
|
||||
model_rules: [
|
||||
...policy.model_rules,
|
||||
{ model: '', groups: [{ ...groups[0], priority: 0 }] },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Add model rule')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={
|
||||
!presetGroup ||
|
||||
policy.model_rules.some((r) => r.model === 'gpt-6-astra')
|
||||
}
|
||||
onClick={() => {
|
||||
if (presetGroup)
|
||||
))}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!groups.length || policy.model_rules.length >= 128}
|
||||
onClick={() =>
|
||||
change({
|
||||
...policy,
|
||||
mode: 'custom',
|
||||
groups,
|
||||
model_rules: [
|
||||
...policy.model_rules,
|
||||
{
|
||||
model: 'gpt-6-astra',
|
||||
groups: [{ group: presetGroup, priority: 0 }],
|
||||
},
|
||||
{ model: '', groups: [{ ...groups[0], priority: 0 }] },
|
||||
],
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('Preset: Astra prefers preferred-group')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
{t('Add model rule')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={
|
||||
!presetGroup ||
|
||||
policy.model_rules.some((r) => r.model === 'gpt-6-astra')
|
||||
}
|
||||
onClick={() => {
|
||||
if (presetGroup) {
|
||||
change({
|
||||
...policy,
|
||||
mode: 'custom',
|
||||
groups,
|
||||
model_rules: [
|
||||
...policy.model_rules,
|
||||
{
|
||||
model: 'gpt-6-astra',
|
||||
groups: [{ group: presetGroup, priority: 0 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Preset: Astra prefers preferred-group')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<AutomaticRoutingSettings
|
||||
policy={policy}
|
||||
onSave={change}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<section className='space-y-2' aria-label={t('Routing preview')}>
|
||||
<h3 className='font-medium'>{t('Routing preview')}</h3>
|
||||
<div className='flex gap-2'>
|
||||
|
||||
@@ -57,6 +57,8 @@ export function getApiKeyFormSchema(t: TFunction, maxAutoGroups = 5) {
|
||||
const models = new Set(policy.model_rules.map((r) => r.model))
|
||||
if (
|
||||
(policy.mode === 'custom' && !policy.groups.length) ||
|
||||
(policy.mode === 'smart' &&
|
||||
(policy.groups.length > 0 || policy.model_rules.length > 0)) ||
|
||||
policy.groups.length > autoGroupLimit + 1 ||
|
||||
pool.size !== policy.groups.length ||
|
||||
models.size !== policy.model_rules.length ||
|
||||
|
||||
@@ -27,8 +27,12 @@ export const routingGroupSchema = z.object({
|
||||
priority: z.number().int().min(0).max(10000),
|
||||
})
|
||||
export const routingPolicySchema = z.object({
|
||||
auto_preference: z.enum(['cost', 'speed', 'stable']).optional(),
|
||||
allow_degraded: z.boolean().optional(),
|
||||
skip_degraded_non_astra: z.boolean().optional(),
|
||||
skip_degraded_groups: z.boolean().optional(),
|
||||
version: z.literal(1),
|
||||
mode: z.enum(['custom', 'inherit', 'user']),
|
||||
mode: z.enum(['custom', 'inherit', 'user', 'smart']),
|
||||
groups: z.array(routingGroupSchema),
|
||||
model_rules: z
|
||||
.array(
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
/*
|
||||
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 { Code2, Eye, HelpCircle } from 'lucide-react'
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
@@ -63,6 +45,25 @@ import { safeJsonParse } from '../utils/json-parser'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
|
||||
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
|
||||
/*
|
||||
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 { GroupTagsEditor } from './group-tags-editor'
|
||||
|
||||
type GroupFormValues = {
|
||||
GroupRatio: string
|
||||
@@ -151,6 +152,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GroupTagsEditor groups={groupNames} />
|
||||
<GroupPricingGuide open={guideOpen} onOpenChange={setGuideOpen} />
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { api } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
const schema = z.object({
|
||||
tags: z.array(z.string().min(1).max(40)).max(20),
|
||||
hide_recent_degraded: z.boolean(),
|
||||
})
|
||||
type Metadata = z.infer<typeof schema>
|
||||
const presets = [
|
||||
'不降智',
|
||||
'官Key',
|
||||
'伪官Key',
|
||||
'Pro池',
|
||||
'Plus池',
|
||||
'打野',
|
||||
'不稳定',
|
||||
]
|
||||
export function GroupTagsEditor(props: { groups: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const client = useQueryClient()
|
||||
const [group, setGroup] = useState('')
|
||||
const [custom, setCustom] = useState('')
|
||||
const form = useForm<Metadata>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tags: [], hide_recent_degraded: false },
|
||||
})
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-group-metadata'],
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
(
|
||||
await api.get<{ success: boolean; data: Record<string, Metadata> }>(
|
||||
'/api/group/metadata'
|
||||
)
|
||||
).data
|
||||
).data,
|
||||
})
|
||||
const reset = form.reset
|
||||
useEffect(() => {
|
||||
reset(query.data?.[group] ?? { tags: [], hide_recent_degraded: false })
|
||||
setCustom('')
|
||||
}, [group, query.data, reset])
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (value: Metadata) =>
|
||||
requireServerSuccess(
|
||||
(await api.put('/api/group/metadata', { groups: { [group]: value } }))
|
||||
.data
|
||||
),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: ['admin-group-metadata'] })
|
||||
await client.invalidateQueries({ queryKey: ['user-groups'] })
|
||||
toast.success(t('Saved successfully'))
|
||||
},
|
||||
})
|
||||
const tags = form.watch('tags')
|
||||
const toggle = (value: string) => {
|
||||
form.setValue(
|
||||
'tags',
|
||||
tags.includes(value)
|
||||
? tags.filter((tag) => tag !== value)
|
||||
: [...tags, value],
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className='space-y-3 rounded-lg border p-4'>
|
||||
<h3 className='font-medium'>{t('Group tags')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Manual labels only; they do not change pricing, permissions or routing. Save new groups before editing their tags.'
|
||||
)}
|
||||
</p>
|
||||
<Combobox
|
||||
aria-label={t('Group')}
|
||||
options={props.groups
|
||||
.filter((g) => g !== 'auto')
|
||||
.map((value) => ({ value, label: value }))}
|
||||
value={group}
|
||||
onValueChange={(v) => setGroup(v ?? '')}
|
||||
placeholder={t('Select a group')}
|
||||
/>
|
||||
{query.isError && <p role='alert'>{t('Failed to load')}</p>}
|
||||
{group && query.isSuccess && (
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{[...new Set([...presets, ...tags])].map((tag) => (
|
||||
<Button
|
||||
key={tag}
|
||||
type='button'
|
||||
size='sm'
|
||||
variant={tags.includes(tag) ? 'secondary' : 'outline'}
|
||||
aria-pressed={tags.includes(tag)}
|
||||
onClick={() => toggle(tag)}
|
||||
>
|
||||
{presets.includes(tag) ? t(tag) : tag}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
aria-label={t('Custom tag')}
|
||||
maxLength={40}
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={!custom.trim()}
|
||||
onClick={() => {
|
||||
if (!tags.includes(custom.trim())) toggle(custom.trim())
|
||||
setCustom('')
|
||||
}}
|
||||
>
|
||||
{t('Add tag')}
|
||||
</Button>
|
||||
</div>
|
||||
{form.formState.errors.tags && (
|
||||
<p role='alert' className='text-destructive text-xs'>
|
||||
{t('Use at most 20 tags, each no longer than 40 characters.')}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex items-center gap-3'>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={!form.watch('hide_recent_degraded')}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue('hide_recent_degraded', !value)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={id}>{t('Show recent degradation tag')}</Label>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Automatic tag uses the latest valid Astra fingerprint or pelican result within 6 hours. Hiding it does not change routing or detection.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={mutation.isPending}
|
||||
onClick={() =>
|
||||
void form.handleSubmit((value) => mutation.mutate(value))()
|
||||
}
|
||||
>
|
||||
{t('Save group tags')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { api } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
const schema = z.object({
|
||||
rows: z
|
||||
.array(
|
||||
z.object({
|
||||
group: z.string().min(1),
|
||||
ratio: z
|
||||
.string()
|
||||
.refine(
|
||||
(v) =>
|
||||
v.trim() !== '' && Number.isFinite(Number(v)) && Number(v) >= 0
|
||||
),
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(rows) => new Set(rows.map((row) => row.group)).size === rows.length
|
||||
),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function PersonalGroupRatios(props: { userId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const client = useQueryClient()
|
||||
const query = useQuery({
|
||||
queryKey: ['personal-group-ratios', props.userId],
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
(
|
||||
await api.get<{ success: boolean; data: Record<string, number> }>(
|
||||
`/api/user/${props.userId}/group-ratios`
|
||||
)
|
||||
).data
|
||||
).data,
|
||||
})
|
||||
const groups = useQuery({
|
||||
queryKey: ['admin-group-names'],
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
(await api.get<{ success: boolean; data: string[] }>('/api/group/'))
|
||||
.data
|
||||
).data,
|
||||
})
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { rows: [] },
|
||||
})
|
||||
const fields = useFieldArray({ control: form.control, name: 'rows' })
|
||||
const reset = form.reset
|
||||
useEffect(() => {
|
||||
if (query.data)
|
||||
{reset({
|
||||
rows: Object.entries(query.data).map(([group, ratio]) => ({
|
||||
group,
|
||||
ratio: String(ratio),
|
||||
})),
|
||||
})}
|
||||
}, [query.data, reset])
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) =>
|
||||
requireServerSuccess(
|
||||
(
|
||||
await api.put(`/api/user/${props.userId}/group-ratios`, {
|
||||
ratios: Object.fromEntries(
|
||||
values.rows.map((row) => [row.group, Number(row.ratio)])
|
||||
),
|
||||
})
|
||||
).data
|
||||
),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({
|
||||
queryKey: ['personal-group-ratios', props.userId],
|
||||
})
|
||||
await client.invalidateQueries({ queryKey: ['user-groups'] })
|
||||
toast.success(t('Saved successfully'))
|
||||
},
|
||||
})
|
||||
const rows = form.watch('rows')
|
||||
return (
|
||||
<section className='space-y-3 rounded-lg border p-4'>
|
||||
<h3 className='text-sm font-medium'>{t('Personal group multipliers')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Final multipliers for all of this user’s keys. Zero is allowed. Removing an override restores the normal billing rules. Save separately below.'
|
||||
)}
|
||||
</p>
|
||||
{query.isError && <p role='alert'>{t('Failed to load')}</p>}
|
||||
{fields.fields.map((field, index) => (
|
||||
<div key={field.id} className='flex flex-wrap items-center gap-2'>
|
||||
<div className='min-w-36 flex-1'>
|
||||
<Combobox
|
||||
aria-label={t('Group')}
|
||||
options={(groups.data ?? [])
|
||||
.filter((group) => group !== 'auto')
|
||||
.map((value) => ({ value, label: value }))}
|
||||
value={rows[index]?.group ?? ''}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(`rows.${index}.group`, value ?? '', {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className='w-24'
|
||||
type='number'
|
||||
min={0}
|
||||
step='any'
|
||||
aria-label={t('Final multiplier')}
|
||||
{...form.register(`rows.${index}.ratio`)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => fields.remove(index)}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.formState.errors.rows && (
|
||||
<p role='alert' className='text-destructive text-xs'>
|
||||
{t('Choose distinct groups and finite, non-negative multipliers.')}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={!query.isSuccess || !groups.isSuccess}
|
||||
onClick={() => fields.append({ group: '', ratio: '' })}
|
||||
>
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={!query.isSuccess || mutation.isPending}
|
||||
onClick={() =>
|
||||
void form.handleSubmit((values) => mutation.mutate(values))()
|
||||
}
|
||||
>
|
||||
{t('Save personal multipliers')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,3 @@
|
||||
/*
|
||||
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 { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Pencil } from 'lucide-react'
|
||||
@@ -94,6 +76,25 @@ import {
|
||||
transformUserToFormDefaults,
|
||||
} from '../lib'
|
||||
import type { User } from '../types'
|
||||
/*
|
||||
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 { PersonalGroupRatios } from './personal-group-ratios'
|
||||
import { UserQuotaDialog } from './user-quota-dialog'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
@@ -567,6 +568,9 @@ export function UsersMutateDrawer({
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
{open && currentRow && (
|
||||
<PersonalGroupRatios key={currentRow.id} userId={currentRow.id} />
|
||||
)}
|
||||
<SheetFooter className={sideDrawerFooterClassName()}>
|
||||
<SheetClose render={<Button variant='outline' />}>
|
||||
{t('Close')}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createInstance } from 'i18next'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import en from '../locales/en.json'
|
||||
import zh from '../locales/zh.json'
|
||||
describe('automatic routing translations', () => {
|
||||
it('loads Chinese feature labels from the translation namespace', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({
|
||||
lng: 'zhCN',
|
||||
fallbackLng: 'en',
|
||||
resources: { zhCN: zh, en },
|
||||
keySeparator: false,
|
||||
})
|
||||
expect(i18n.t('Automatic grouping')).toBe('自动分组')
|
||||
expect(i18n.t('Automatic group settings')).toBe('自动分组设置')
|
||||
expect(i18n.t('Low cost first')).toBe('低价优先')
|
||||
expect(i18n.t('Fast response first')).toBe('高速优先')
|
||||
expect(i18n.t('Stability first')).toBe('稳定优先')
|
||||
expect(i18n.t('Allow recently degraded groups')).toBe(
|
||||
'允许使用最近降智的渠道'
|
||||
)
|
||||
expect(i18n.t('Average charged / 1M tokens')).toBe('平均实扣 / 1M tokens')
|
||||
expect(i18n.t('Request and cache statistics: last 24 hours')).toBe(
|
||||
'请求与缓存统计:最近24小时'
|
||||
)
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('Average charged / 1M tokens')).toBe(
|
||||
'Average charged / 1M tokens'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "Auto-switch on group degradation",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra fingerprint sampling",
|
||||
"Sampled downgrade rate": "Sampled downgrade rate",
|
||||
@@ -6928,6 +6930,80 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
}
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Average charged / 1M tokens": "Average charged / 1M tokens",
|
||||
"Last 24 hours, all users. Actual charges divided by total input and output tokens. Historical average, not a price quote.": "Last 24 hours, all users. Actual charges divided by total input and output tokens. Historical average, not a price quote.",
|
||||
"Automatic group settings": "Automatic group settings",
|
||||
"Automatic grouping": "Automatic grouping",
|
||||
"Choose automatically: low cost, fast response or stability": "Choose automatically: low cost, fast response or stability",
|
||||
"Automatically choose from all groups you can access that support the requested model.": "Automatically choose from all groups you can access that support the requested model.",
|
||||
"Routing preference": "Routing preference",
|
||||
"Low cost first": "Low cost first",
|
||||
"Fast response first": "Fast response first",
|
||||
"Stability first": "Stability first",
|
||||
"Prefer the lowest average actual charge per million tokens for this model over the last 24 hours.": "Prefer the lowest average actual charge per million tokens for this model over the last 24 hours.",
|
||||
"Prefer the shortest average time to first token for this model.": "Prefer the shortest average time to first token for this model.",
|
||||
"Prefer the highest recent request success rate for this model.": "Prefer the highest recent request success rate for this model.",
|
||||
"Allow recently degraded groups": "Allow recently degraded groups",
|
||||
"Off by default. Exclude groups flagged by the latest valid quality checks within 6 hours. Unknown results remain eligible.": "Off by default. Exclude groups flagged by the latest valid quality checks within 6 hours. Unknown results remain eligible.",
|
||||
"Statistics cover the last 24 hours. Groups without samples are fallbacks, ordered by your applicable billing ratio. No price cap is applied.": "Statistics cover the last 24 hours. Groups without samples are fallbacks, ordered by your applicable billing ratio. No price cap is applied.",
|
||||
"Settings take effect after saving the API key.": "Settings take effect after saving the API key.",
|
||||
"Return to manual pool": "Return to manual pool",
|
||||
"Retry a server error once in the same group, then switch and cool down for 10 minutes. A failed recovery probe switches immediately.": "Retry a server error once in the same group, then switch and cool down for 10 minutes. A failed recovery probe switches immediately.",
|
||||
"Request and cache statistics: last 24 hours": "Request and cache statistics: last 24 hours",
|
||||
"For reference only: insufficient request samples may cause very large errors in the average charge.": "For reference only: insufficient request samples may cause very large errors in the average charge.",
|
||||
"Skip suspected degraded groups": "Skip suspected degraded groups",
|
||||
"Skip non-Astra requests too": "Skip non-Astra requests too",
|
||||
"Enabled by default for gpt-6-astra. Keep the last valid fingerprint and pelican conclusions until recovery or reset. Failed checks do not clear them.": "Enabled by default for gpt-6-astra. Keep the last valid fingerprint and pelican conclusions until recovery or reset. Failed checks do not clear them.",
|
||||
"Uses the group Astra fingerprint and pelican checks; other models have not been individually tested. Off by default.": "Uses the group Astra fingerprint and pelican checks; other models have not been individually tested. Off by default.",
|
||||
"Prefer your lowest effective group multiplier, including personal overrides.": "Prefer your lowest effective group multiplier, including personal overrides.",
|
||||
"Speed and stability use recent request statistics. Price ordering always uses your effective multiplier and does not depend on sample size.": "Speed and stability use recent request statistics. Price ordering always uses your effective multiplier and does not depend on sample size.",
|
||||
"Recent degradation": "Recent degradation",
|
||||
"Personal rate": "Personal rate",
|
||||
"Personal group multipliers": "Personal group multipliers",
|
||||
"Final multipliers for all of this user’s keys. Zero is allowed. Removing an override restores the normal billing rules. Save separately below.": "Final multipliers for all of this user’s keys. Zero is allowed. Removing an override restores the normal billing rules. Save separately below.",
|
||||
"Final multiplier": "Final multiplier",
|
||||
"Choose distinct groups and finite, non-negative multipliers.": "Choose distinct groups and finite, non-negative multipliers.",
|
||||
"Save personal multipliers": "Save personal multipliers",
|
||||
"Group tags": "Group tags",
|
||||
"Manual labels only; they do not change pricing, permissions or routing. Save new groups before editing their tags.": "Manual labels only; they do not change pricing, permissions or routing. Save new groups before editing their tags.",
|
||||
"Custom tag": "Custom tag",
|
||||
"Add tag": "Add tag",
|
||||
"Use at most 20 tags, each no longer than 40 characters.": "Use at most 20 tags, each no longer than 40 characters.",
|
||||
"Show recent degradation tag": "Show recent degradation tag",
|
||||
"Automatic tag uses the latest valid Astra fingerprint or pelican result within 6 hours. Hiding it does not change routing or detection.": "Automatic tag uses the latest valid Astra fingerprint or pelican result within 6 hours. Hiding it does not change routing or detection.",
|
||||
"Save group tags": "Save group tags",
|
||||
"不降智": "No degradation",
|
||||
"官Key": "Official key",
|
||||
"伪官Key": "Unofficial key",
|
||||
"Pro池": "Pro pool",
|
||||
"Plus池": "Plus pool",
|
||||
"打野": "Shared access",
|
||||
"不稳定": "Unstable",
|
||||
"Loading API key settings. The empty fields below are not the saved configuration.": "Loading API key settings. The empty fields below are not the saved configuration.",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint or pelican result indicates degradation. Failed checks do not replace valid results; each check must recover or be reset. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint or pelican result indicates degradation. Failed checks do not replace valid results; each check must recover or be reset. Groups with no valid result remain eligible.",
|
||||
"Prefer recent streaming model-test results: latest valid first-token time per channel within 6 hours, averaged across the group. Untested groups rank last.": "Prefer recent streaming model-test results: latest valid first-token time per channel within 6 hours, averaged across the group. Untested groups rank last.",
|
||||
"Speed uses recent streaming model tests; stability uses request statistics. Price uses effective multipliers.": "Speed uses recent streaming model tests; stability uses request statistics. Price uses effective multipliers.",
|
||||
"Prefer recent streaming model-test results: latest valid first-token time per channel within 1 hour, averaged across the group. Untested groups rank last.": "Prefer recent streaming model-test results: latest valid first-token time per channel within 1 hour, averaged across the group. Untested groups rank last.",
|
||||
"Meow quick test · 32 probes per channel.": "Meow quick test · 32 probes per channel.",
|
||||
"Valid probes {{count}}": "Valid probes {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "Candy test",
|
||||
"Passed": "Passed",
|
||||
"Not passed": "Not passed",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "Candy question · one request per channel. Only an answer of 21 passes.",
|
||||
"Recent candy test results": "Recent candy test results"
|
||||
},
|
||||
"Retry a server error once in the same group. Switch and cool down for 10 minutes only when a backup is available; otherwise keep the current group. Streaming and non-streaming cooldowns are separate.": "Retry a server error once in the same group. Switch and cool down for 10 minutes only when a backup is available; otherwise keep the current group. Streaming and non-streaming cooldowns are separate."
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "Changer automatiquement de groupe en cas de dégradation",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra fingerprint sampling",
|
||||
"Sampled downgrade rate": "Sampled downgrade rate",
|
||||
@@ -6928,6 +6930,25 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Meow quick test · 32 probes per channel.": "Meow quick test · 32 probes per channel.",
|
||||
"Valid probes {{count}}": "Valid probes {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "Candy test",
|
||||
"Passed": "Passed",
|
||||
"Not passed": "Not passed",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "Candy question · one request per channel. Only an answer of 21 passes.",
|
||||
"Recent candy test results": "Recent candy test results"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "グループの性能低下時に自動切替",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra fingerprint sampling",
|
||||
"Sampled downgrade rate": "Sampled downgrade rate",
|
||||
@@ -6928,6 +6930,25 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Meow quick test · 32 probes per channel.": "Meow quick test · 32 probes per channel.",
|
||||
"Valid probes {{count}}": "Valid probes {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "Candy test",
|
||||
"Passed": "Passed",
|
||||
"Not passed": "Not passed",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "Candy question · one request per channel. Only an answer of 21 passes.",
|
||||
"Recent candy test results": "Recent candy test results"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "Автопереключение при деградации группы",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra fingerprint sampling",
|
||||
"Sampled downgrade rate": "Sampled downgrade rate",
|
||||
@@ -6928,6 +6930,25 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Meow quick test · 32 probes per channel.": "Meow quick test · 32 probes per channel.",
|
||||
"Valid probes {{count}}": "Valid probes {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "Candy test",
|
||||
"Passed": "Passed",
|
||||
"Not passed": "Not passed",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "Candy question · one request per channel. Only an answer of 21 passes.",
|
||||
"Recent candy test results": "Recent candy test results"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "Tự động chuyển khi nhóm suy giảm",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra fingerprint sampling",
|
||||
"Sampled downgrade rate": "Sampled downgrade rate",
|
||||
@@ -6928,6 +6930,25 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Meow quick test · 32 probes per channel.": "Meow quick test · 32 probes per channel.",
|
||||
"Valid probes {{count}}": "Valid probes {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "Candy test",
|
||||
"Passed": "Passed",
|
||||
"Not passed": "Not passed",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "Candy question · one request per channel. Only an answer of 21 passes.",
|
||||
"Recent candy test results": "Recent candy test results"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "分組降智時自動切換",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "Astra 請求會跳過最近 6 小時內最後一次有效指紋檢測為非 Astra 的分組,按分組池順序繼續選擇;沒有有效檢測結果的分組仍可使用。",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "Remind me next time",
|
||||
"I understand": "I understand",
|
||||
"Astra model detection": "Astra model detection",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.",
|
||||
"Astra fingerprint sampling": "Astra 指纹抽检",
|
||||
"Sampled downgrade rate": "抽检降智率",
|
||||
@@ -6928,6 +6930,25 @@
|
||||
"Past": "Past",
|
||||
"Every 15 minutes": "Every 15 minutes",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "Updated at {{time}} · refreshes every 15 seconds",
|
||||
"View on manxue.ai": "View on manxue.ai"
|
||||
"View on manxue.ai": "View on manxue.ai",
|
||||
"Meow quick test · 32 probes per channel.": "meow 快速检测 · 每个渠道 32 次请求。",
|
||||
"Valid probes {{count}}": "有效样本 {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "meow 强指向 Astra 即为指纹通过;强指向其他模型才计入降级。证据不足的轮次不计入。鹈鹕能力表现独立评估。",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "每个已启用的 Astra 渠道发送 32 次独立 meow 检测请求,费用由平台承担。不会重复创建已有任务,两轮至少间隔 5 分钟。",
|
||||
"Test benchmark": "Test benchmark",
|
||||
"Meow benchmark · auxiliary": "Meow benchmark · auxiliary",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow results are auxiliary and do not affect the fingerprint downgrade rate.",
|
||||
"Recent benchmark results": "Recent benchmark results",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 3 independent probes per channel.",
|
||||
"ModelTrace fingerprint": "ModelTrace fingerprint",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.",
|
||||
"Candy test": "糖果题测试",
|
||||
"Passed": "通过",
|
||||
"Not passed": "未通过",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "通过 {{normal}} / 未通过 {{degraded}} / 运行失败 {{failed}}",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "每个渠道每轮请求 1 次,仅答案为 21 时通过。",
|
||||
"Recent candy test results": "最近糖果题结果"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"Auto-switch on group degradation": "分组降智时自动切换",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint in the last 6 hours is non-Astra and continue through the group pool. Groups with no valid result remain eligible.": "Astra 请求会跳过最近 6 小时内最后一次有效指纹检测为非 Astra 的分组,按分组池顺序继续选择;没有有效检测结果的分组仍可使用。",
|
||||
"360": "360",
|
||||
"1000": "1000",
|
||||
"10000": "10000",
|
||||
@@ -6825,7 +6827,7 @@
|
||||
"Remind me next time": "下次提醒我",
|
||||
"I understand": "我已知晓",
|
||||
"Astra model detection": "Astra 模型检测",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, model checks and automatic switching are both off.": "对比上游返回的模型并记录检测结果。仅检测不会拦截响应;关闭后不做模型校验,也不会自动切换。",
|
||||
"Compare the returned model with Astra and record the result. Detection alone does not reject responses. When disabled, response model checks and switching on model mismatch are off.": "对比上游返回的模型并记录检测结果。仅检测不会拦截响应;关闭后不校验返回模型,也不会因模型不符切换渠道。",
|
||||
"Requires model detection. When off, responses are delivered normally. When on, mismatched responses are rejected and a safe channel switch is attempted.": "需先开启模型检测。关闭时正常返回结果;开启时拦截模型不符的响应,并尝试安全切换渠道。",
|
||||
"Astra fingerprint sampling": "Astra 指纹抽检",
|
||||
"Sampled downgrade rate": "抽检降智率",
|
||||
@@ -6928,6 +6930,80 @@
|
||||
"Past": "过去",
|
||||
"Every 15 minutes": "每 15 分钟探活",
|
||||
"Updated at {{time}} · refreshes every 15 seconds": "更新于 {{time}} · 每 15 秒刷新",
|
||||
"View on manxue.ai": "在 manxue.ai 查看"
|
||||
}
|
||||
"View on manxue.ai": "在 manxue.ai 查看",
|
||||
"Average charged / 1M tokens": "平均实扣 / 1M tokens",
|
||||
"Last 24 hours, all users. Actual charges divided by total input and output tokens. Historical average, not a price quote.": "最近24小时全站用户:实际扣费合计 ÷ 输入与输出 token 合计 × 100万。历史均价仅供参考,不代表当前报价。",
|
||||
"Automatic group settings": "自动分组设置",
|
||||
"Automatic grouping": "自动分组",
|
||||
"Choose automatically: low cost, fast response or stability": "自动选择:低价、高速或稳定",
|
||||
"Automatically choose from all groups you can access that support the requested model.": "从你有权使用、且支持请求模型的全部分组中自动选择。",
|
||||
"Routing preference": "路由偏好",
|
||||
"Low cost first": "低价优先",
|
||||
"Fast response first": "高速优先",
|
||||
"Stability first": "稳定优先",
|
||||
"Prefer the lowest average actual charge per million tokens for this model over the last 24 hours.": "优先选择该模型近24小时每百万 token 平均实扣更低的分组。",
|
||||
"Prefer the shortest average time to first token for this model.": "优先选择该模型平均首字响应更快的分组。",
|
||||
"Prefer the highest recent request success rate for this model.": "优先选择该模型近期请求成功率更高的分组。",
|
||||
"Allow recently degraded groups": "允许使用最近降智的渠道",
|
||||
"Off by default. Exclude groups flagged by the latest valid quality checks within 6 hours. Unknown results remain eligible.": "默认关闭。排除近6小时内最新有效检测判定为降智的分组;暂无有效检测结果的分组仍可使用。",
|
||||
"Statistics cover the last 24 hours. Groups without samples are fallbacks, ordered by your applicable billing ratio. No price cap is applied.": "统计窗口为最近24小时。无样本分组作为备选,按适用计费倍率排序;不额外限制价格。",
|
||||
"Settings take effect after saving the API key.": "需保存密钥后设置才会生效。",
|
||||
"Return to manual pool": "切回手动分组池",
|
||||
"Retry a server error once in the same group, then switch and cool down for 10 minutes. A failed recovery probe switches immediately.": "遇到可重试服务错误时,在当前分组再试一次;仍失败则切换并冷却10分钟。到期回切首次失败即再次切走。",
|
||||
"Request and cache statistics: last 24 hours": "请求与缓存统计:最近24小时",
|
||||
"For reference only: insufficient request samples may cause very large errors in the average charge.": "仅供参考:请求样本不足时,平均实扣可能存在巨大误差。",
|
||||
"Skip suspected degraded groups": "跳过疑似降智分组",
|
||||
"Skip non-Astra requests too": "非 Astra 请求也跳过",
|
||||
"Enabled by default for gpt-6-astra. Keep the last valid fingerprint and pelican conclusions until recovery or reset. Failed checks do not clear them.": "默认开启,仅对 gpt-6-astra 生效。分别保留指纹、鹈鹕的最新有效结论,直到有效恢复或管理员重置;检测失败不会清除结论。",
|
||||
"Uses the group Astra fingerprint and pelican checks; other models have not been individually tested. Off by default.": "依据分组的 Astra 指纹和鹈鹕检测,不代表其他模型经过单独检测。默认关闭。",
|
||||
"Prefer your lowest effective group multiplier, including personal overrides.": "按你的实际生效倍率从低到高选择,包括个人专属倍率。",
|
||||
"Speed and stability use recent request statistics. Price ordering always uses your effective multiplier and does not depend on sample size.": "速度和稳定性参考近期请求统计。价格排序始终使用实际生效倍率,不受请求样本量影响。",
|
||||
"Recent degradation": "近期降智",
|
||||
"Personal rate": "专属",
|
||||
"Personal group multipliers": "专属分组倍率",
|
||||
"Final multipliers for all of this user’s keys. Zero is allowed. Removing an override restores the normal billing rules. Save separately below.": "对该用户所有密钥生效的最终倍率,允许为 0。删除覆盖后恢复原有计费规则,请使用下方按钮单独保存。",
|
||||
"Final multiplier": "最终倍率",
|
||||
"Choose distinct groups and finite, non-negative multipliers.": "请选择不重复的分组,并填写有限且不小于 0 的倍率。",
|
||||
"Save personal multipliers": "保存专属倍率",
|
||||
"Group tags": "分组标签",
|
||||
"Manual labels only; they do not change pricing, permissions or routing. Save new groups before editing their tags.": "标签由管理员手动标注,不改变价格、权限或路由。新增分组需先保存,再设置标签。",
|
||||
"Custom tag": "自定义标签",
|
||||
"Add tag": "添加标签",
|
||||
"Use at most 20 tags, each no longer than 40 characters.": "最多 20 个标签,每个标签不超过 40 个字符。",
|
||||
"Show recent degradation tag": "显示近期降智标签",
|
||||
"Automatic tag uses the latest valid Astra fingerprint or pelican result within 6 hours. Hiding it does not change routing or detection.": "自动标签依据近 6 小时内指纹或鹈鹕的最新有效结论。隐藏仅影响展示,不改变路由或检测。",
|
||||
"Save group tags": "保存分组标签",
|
||||
"不降智": "不降智",
|
||||
"官Key": "官Key",
|
||||
"伪官Key": "伪官Key",
|
||||
"Pro池": "Pro池",
|
||||
"Plus池": "Plus池",
|
||||
"打野": "打野",
|
||||
"不稳定": "不稳定",
|
||||
"Loading API key settings. The empty fields below are not the saved configuration.": "正在加载令牌设置,下方空白字段并非已保存的配置。",
|
||||
"For Astra requests, skip groups whose latest valid fingerprint or pelican result indicates degradation. Failed checks do not replace valid results; each check must recover or be reset. Groups with no valid result remain eligible.": "Astra 请求会跳过最新有效指纹或鹈鹕检测为降智的分组。失败检测不覆盖有效结论,各项检测需恢复正常或由管理员重置;无有效检测历史的分组仍可使用。",
|
||||
"Prefer recent streaming model-test results: latest valid first-token time per channel within 6 hours, averaged across the group. Untested groups rank last.": "按最近6小时同模型流式测试排序:取各渠道最新有效首字时间,再按分组平均。无有效测试的分组排在后面。",
|
||||
"Speed uses recent streaming model tests; stability uses request statistics. Price uses effective multipliers.": "速度依据近期流式模型测试,稳定性依据请求统计,价格依据实际生效倍率。",
|
||||
"Prefer recent streaming model-test results: latest valid first-token time per channel within 1 hour, averaged across the group. Untested groups rank last.": "按最近1小时同模型流式测试排序:取各渠道最新有效首字时间,再按分组平均。无有效测试的分组排在后面。",
|
||||
"Meow quick test · 32 probes per channel.": "meow 快速检测 · 每个渠道 32 次请求。",
|
||||
"Valid probes {{count}}": "有效样本 {{count}}",
|
||||
"Meow strong evidence for Astra counts as matched; strong evidence for another model counts as downgraded. Inconclusive rounds are excluded. Pelican quality is assessed separately.": "meow 强指向 Astra 即为指纹通过;强指向其他模型才计入降级。证据不足的轮次不计入。鹈鹕能力表现独立评估。",
|
||||
"This sends 32 independent Meow probes per enabled Astra channel. Upstream costs are paid by the platform. Existing tasks will not be duplicated. Please allow at least 5 minutes between rounds.": "每个已启用的 Astra 渠道发送 32 次独立 meow 检测请求,费用由平台承担。不会重复创建已有任务,两轮至少间隔 5 分钟。",
|
||||
"Test benchmark": "检测基准",
|
||||
"Meow benchmark · auxiliary": "Meow 模型基准 · 辅助",
|
||||
"Meow results are auxiliary and do not affect the fingerprint downgrade rate.": "Meow 结果仅作辅助,不计入模型指纹降级率。",
|
||||
"Recent benchmark results": "最近基准结果",
|
||||
"ModelTrace · 3 independent probes per channel.": "ModelTrace · 每个渠道进行 3 次独立探测。",
|
||||
"ModelTrace fingerprint": "ModelTrace 模型指纹",
|
||||
"ModelTrace is the primary signal. Three valid probes, at least 80% candidate probability and a 0.5 score gap are required for classification. Other rounds are inconclusive. Probabilities are relative to the candidate bank.": "以 ModelTrace 指纹为主:3 次探测有效、候选概率至少 80% 且分差达到 0.5 才计入判定,其余为不确定。概率仅代表候选库内的相对归属。",
|
||||
"Live group status, ModelTrace fingerprints and auxiliary Meow benchmarks.": "分组运行状态、ModelTrace 模型指纹与 Meow 辅助基准。",
|
||||
"Reset fingerprint and benchmark history for {{group}}? Statistics restart from zero. Earlier runs stay excluded when they finish. Billing records and other groups are preserved.": "重置 {{group}} 的指纹和基准记录?统计将从零开始,此前任务完成后也不会计入。计费记录和其他分组不受影响。",
|
||||
"Candy test": "糖果题测试",
|
||||
"Passed": "通过",
|
||||
"Not passed": "未通过",
|
||||
"{{normal}} passed / {{degraded}} not passed / {{failed}} failed to run": "通过 {{normal}} / 未通过 {{degraded}} / 运行失败 {{failed}}",
|
||||
"Candy question · one request per channel. Only an answer of 21 passes.": "每个渠道每轮请求 1 次,仅答案为 21 时通过。",
|
||||
"Recent candy test results": "最近糖果题结果"
|
||||
},
|
||||
"Retry a server error once in the same group. Switch and cool down for 10 minutes only when a backup is available; otherwise keep the current group. Streaming and non-streaming cooldowns are separate.": "遇到服务错误时在当前分组重试一次。有可用备用组才切换并冷却10分钟,否则保留当前分组。流式与非流式请求的冷却互不影响。"
|
||||
}
|
||||
+11
-1
@@ -60,7 +60,17 @@ export async function getUserModels(): Promise<{
|
||||
export async function getUserGroups(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, { desc: string; ratio: number | string }>
|
||||
data?: Record<
|
||||
string,
|
||||
{
|
||||
desc: string
|
||||
ratio: number | string
|
||||
base_ratio?: number
|
||||
has_personal_ratio?: boolean
|
||||
tags?: string[]
|
||||
recent_degraded?: boolean
|
||||
}
|
||||
>
|
||||
}> {
|
||||
const res = await api.get('/api/user/self/groups')
|
||||
return res.data
|
||||
|
||||
@@ -37,9 +37,9 @@ export const MOTION_TRANSITION: Record<string, Transition> = {
|
||||
|
||||
export const MOTION_VARIANTS = {
|
||||
pageEnter: {
|
||||
initial: { opacity: 0, y: 8, filter: 'blur(4px)' },
|
||||
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
|
||||
exit: { opacity: 0, y: -4, filter: 'blur(2px)' },
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -4 },
|
||||
},
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
|
||||
Reference in new issue
Block a user