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

This commit is contained in:
cneicy committed 2026-10-06 00:07:10 +08:00
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('')
})
+11 -3
View File
@@ -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()
})
+108 -76
View File
@@ -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}
+3 -2
View File
@@ -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
+120 -182
View File
@@ -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 ||
+5 -1
View File
@@ -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'
)
})
})
+79 -3
View File
@@ -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."
}
+23 -2
View File
@@ -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"
}
}
+23 -2
View File
@@ -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"
}
}
+23 -2
View File
@@ -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"
}
}
+23 -2
View File
@@ -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"
}
}
+23 -2
View File
@@ -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": "最近糖果题结果"
}
}
+79 -3
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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 },