Feat/auto group (#6590)

* feat(token): support custom auto group order

* feat(keys): enhance auto group presentation

* fix(keys): rework Auto flow border and compact inherited order

The Auto group highlight previously tinted the whole control surface
with a gradient and animated only a 1px top sweep, which read as a
background color rather than a flowing border. Replace it with a
border-only effect: an aria-hidden, pointer-events-none overlay whose
conic gradient is masked down to a thin ring hugging the rounded
perimeter, so the highlight travels around all four edges and corners
every 3.2s. The interior stays neutral with a restrained static
primary border and glow; prefers-reduced-motion hides the moving
layer while keeping the static emphasis.

The inherited global Auto order also rendered as spacious two-line
rows with circular sequence markers, wasting drawer space. Render it
as a compact wrapping strip of one-line chips (index, name, ratio
badge) with descriptions kept accessible via title and sr-only text,
scrolling only past a much smaller max height.

Custom add/remove/reorder editing, empty-array inheritance semantics,
and the submit payload are unchanged.

* fix(keys): preserve Auto inheritance and unify effects

* refactor(keys): temporarily disable AutoGroupBadge in api-key-group-cell
This commit is contained in:
Calcium-Ion
2026-08-01 23:19:01 +08:00
committed by GitHub
parent bd585d78ef
commit 0ab0202060
57 changed files with 3922 additions and 210 deletions
+9
View File
@@ -25,6 +25,7 @@ import type {
GetApiKeysResponse,
SearchApiKeysParams,
ApiKeyFormData,
TokenAutoGroupsConfig,
} from './types'
// ============================================================================
@@ -60,6 +61,14 @@ export async function getApiKey(id: number): Promise<ApiResponse<ApiKey>> {
return res.data
}
// Get the current user's global Auto order and the per-token selection limit.
export async function getTokenAutoGroups(): Promise<
ApiResponse<TokenAutoGroupsConfig>
> {
const res = await api.get('/api/token/auto-groups')
return res.data
}
// Create a new API key
export async function createApiKey(
data: ApiKeyFormData
@@ -0,0 +1,236 @@
/*
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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'SVGElement',
'Node',
'Element',
'Event',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { TooltipProvider } = await import('@/components/ui/tooltip')
const { ApiKeyGroupCell } = await import('../api-key-group-cell')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
Auto: 'Auto',
'Cross-group': 'Cross-group',
Ratio: 'Ratio',
'Automatically selects the best available group with circuit breaker mechanism':
'Automatically selects the best available group with circuit breaker mechanism',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
function CellHarness(props: {
group: string
ratio?: number | string
crossGroupRetry?: boolean
shouldReduceMotion?: boolean
}) {
return (
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<ApiKeyGroupCell
group={props.group}
ratio={props.ratio}
crossGroupRetry={props.crossGroupRetry ?? false}
shouldReduceMotion={props.shouldReduceMotion ?? false}
/>
</TooltipProvider>
</I18nextProvider>
)
}
describe('API key group table cell', () => {
after(() => {
domWindow.close()
})
test('renders two unclipped rings and a localized Auto ratio when API data uses a nonlocalized string', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(
<CellHarness
group='auto'
ratio='自动'
crossGroupRetry
shouldReduceMotion={false}
/>
)
)
const badgeCell = container.querySelector<HTMLElement>(
'[data-api-key-group-cell="auto"]'
)
assert.ok(badgeCell)
assert.equal(badgeCell.classList.contains('overflow-visible'), true)
assert.equal(badgeCell.classList.contains('overflow-hidden'), false)
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
assert.equal(frames.length, 2)
assert.equal(movingRings.length, 2)
for (const frame of frames) {
assert.equal(frame.classList.contains('relative'), true)
assert.equal(frame.classList.contains('overflow-visible'), true)
assert.equal(frame.classList.contains('rounded-4xl'), true)
assert.equal(frame.classList.contains('p-px'), true)
}
const ratio = container.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(ratio)
assert.equal(ratio.textContent, 'Auto Ratio')
assert.equal(ratio.textContent?.includes('x'), false)
assert.equal(container.textContent?.includes('自动'), false)
assert.equal(container.textContent?.includes('Cross-group'), true)
const crossGroupBadge = [
...container.querySelectorAll<HTMLElement>('[data-slot="status-badge"]'),
].find((badge) => badge.textContent === 'Cross-group')
assert.ok(crossGroupBadge)
assert.equal(crossGroupBadge.closest('[data-auto-group-frame]'), null)
await act(async () => root.unmount())
container.remove()
})
test('keeps static Auto frames but omits both moving layers for reduced motion', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<CellHarness group='auto' ratio='Auto' shouldReduceMotion />)
)
assert.equal(
container.querySelectorAll('[data-auto-group-frame]').length,
2
)
assert.equal(
container.querySelectorAll('[data-auto-group-flow-border]').length,
0
)
await act(async () => root.unmount())
container.remove()
})
test('shows only the Auto badge when ratio data is unavailable', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<CellHarness group='auto' shouldReduceMotion={false} />)
)
assert.equal(
container.querySelectorAll('[data-auto-group-frame]').length,
1
)
assert.equal(
container.querySelectorAll('[data-auto-group-flow-border]').length,
1
)
assert.equal(
container.querySelector('[data-auto-group-effect="ratio"]'),
null
)
assert.equal(container.textContent?.includes('Auto'), true)
assert.equal(container.textContent?.includes('Ratio'), false)
await act(async () => root.unmount())
container.remove()
})
test('narrows normal group ratios to numbers and never applies Auto rings', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(
<CellHarness group='vip' ratio='自动' shouldReduceMotion={false} />
)
)
assert.equal(container.textContent?.includes('vip'), true)
assert.equal(container.textContent?.includes('自动'), false)
assert.equal(container.querySelector('[data-auto-group-frame]'), null)
assert.equal(container.querySelector('[data-auto-group-flow-border]'), null)
await act(async () =>
root.render(
<CellHarness group='vip' ratio={3} shouldReduceMotion={false} />
)
)
assert.equal(container.textContent?.includes('3x'), true)
assert.equal(container.querySelector('[data-auto-group-frame]'), null)
await act(async () => root.unmount())
container.remove()
})
})
@@ -0,0 +1,294 @@
/*
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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
let shouldReduceMotion = false
const reducedMotionMediaQuery = domWindow.matchMedia('(prefers-reduced-motion)')
Object.defineProperty(reducedMotionMediaQuery, 'matches', {
configurable: true,
get: () => shouldReduceMotion,
})
Object.defineProperty(domWindow, 'matchMedia', {
configurable: true,
value: () => reducedMotionMediaQuery,
})
function setReducedMotion(value: boolean) {
shouldReduceMotion = value
reducedMotionMediaQuery.dispatchEvent(new domWindow.Event('change'))
}
const { act, useState } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { ApiKeyGroupCombobox } = await import('../api-key-group-combobox')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
Auto: 'Auto',
Ratio: 'Ratio',
'Search...': 'Search...',
'No group found.': 'No group found.',
'Select a group': 'Select a group',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
const options = [
{
value: 'auto',
label: 'auto',
desc: 'Global automatic routing',
ratio: '自动',
},
{ value: 'default', label: 'default', desc: 'User group', ratio: 1 },
{ value: 'vip', label: 'vip', desc: 'Priority group', ratio: 3 },
]
function Harness(props: { initialValue: string }) {
const [value, setValue] = useState(props.initialValue)
return (
<I18nextProvider i18n={i18n}>
<ApiKeyGroupCombobox
options={options}
value={value}
onValueChange={setValue}
/>
<output data-testid='selected-group'>{value}</output>
</I18nextProvider>
)
}
function getTrigger(container: ParentNode): HTMLButtonElement {
const trigger = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(trigger)
return trigger
}
function getCommandItem(label: string): HTMLElement {
const item = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(label))
assert.ok(item)
return item
}
describe('API key group combobox Auto effect', () => {
after(() => {
domWindow.close()
})
test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', async () => {
setReducedMotion(false)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
assert.equal(trigger.getAttribute('aria-expanded'), 'false')
assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
assert.equal(trigger.classList.contains('bg-linear-to-r'), false)
assert.equal(trigger.classList.contains('overflow-hidden'), false)
assert.equal(trigger.classList.contains('overflow-visible'), true)
const triggerFlowBorder = trigger.querySelector<HTMLElement>(
'[data-auto-group-flow-border]'
)
assert.ok(triggerFlowBorder)
assert.equal(triggerFlowBorder.getAttribute('aria-hidden'), 'true')
assert.equal(
triggerFlowBorder.classList.contains('pointer-events-none'),
true
)
assert.equal(
triggerFlowBorder.classList.contains('auto-group-flow-border'),
true
)
const triggerRatio = trigger.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(triggerRatio)
assert.equal(triggerRatio.textContent, 'Auto Ratio')
assert.equal(triggerRatio.textContent?.includes('Auto'), true)
assert.equal(triggerRatio.textContent?.includes('x'), false)
assert.equal(trigger.textContent?.includes('自动'), false)
assert.equal(triggerRatio.classList.contains('relative'), true)
assert.equal(triggerRatio.classList.contains('overflow-visible'), true)
assert.equal(triggerRatio.classList.contains('rounded-4xl'), true)
assert.ok(triggerRatio.querySelector('[data-auto-group-flow-border]'))
await act(async () => trigger.click())
assert.equal(trigger.getAttribute('aria-expanded'), 'true')
const autoOption = getCommandItem('Global automatic routing')
assert.equal(autoOption.dataset.autoGroupEffect, 'option')
assert.equal(autoOption.getAttribute('aria-selected'), 'true')
assert.equal(autoOption.classList.contains('bg-linear-to-r'), false)
assert.equal(autoOption.classList.contains('overflow-visible'), true)
assert.ok(autoOption.querySelector('[data-auto-group-flow-border]'))
const optionRatio = autoOption.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(optionRatio)
assert.equal(optionRatio.textContent, 'Auto Ratio')
assert.ok(optionRatio.querySelector('[data-auto-group-flow-border]'))
const defaultOption = getCommandItem('User group')
assert.equal(defaultOption.hasAttribute('data-auto-group-effect'), false)
assert.equal(
defaultOption.querySelector('[data-auto-group-flow-border]'),
null
)
assert.equal(defaultOption.textContent?.includes('1x Ratio'), true)
assert.equal(
defaultOption.querySelector('[data-auto-group-effect="ratio"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('keeps search and selection behavior while leaving normal groups unstyled', async () => {
setReducedMotion(false)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
await act(async () => trigger.click())
const searchInput = document.querySelector<HTMLInputElement>(
'input[placeholder="Search..."]'
)
assert.ok(searchInput)
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
domWindow.HTMLInputElement.prototype,
'value'
)?.set
assert.ok(valueSetter)
valueSetter.call(searchInput, 'vip')
searchInput.dispatchEvent(
new domWindow.Event('input', { bubbles: true }) as unknown as Event
)
})
const visibleOptions = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
]
assert.equal(
visibleOptions.some((option) =>
option.textContent?.includes('Global automatic routing')
),
false
)
const vipOption = getCommandItem('Priority group')
await act(async () => vipOption.click())
assert.equal(
container.querySelector('[data-testid="selected-group"]')?.textContent,
'vip'
)
assert.equal(trigger.getAttribute('aria-expanded'), 'false')
assert.equal(trigger.hasAttribute('data-auto-group-effect'), false)
assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
await act(async () => root.unmount())
container.remove()
})
test('preserves the static Auto treatment but omits moving layers for reduced motion', async () => {
setReducedMotion(true)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
assert.ok(trigger.querySelector('[data-auto-group-effect="ratio"]'))
await act(async () => trigger.click())
const autoOption = getCommandItem('Global automatic routing')
assert.equal(autoOption.dataset.autoGroupEffect, 'option')
assert.equal(
autoOption.querySelector('[data-auto-group-flow-border]'),
null
)
assert.ok(autoOption.querySelector('[data-auto-group-effect="ratio"]'))
await act(async () => root.unmount())
container.remove()
setReducedMotion(false)
})
})
@@ -0,0 +1,371 @@
/*
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 assert from 'node:assert/strict'
import { after, afterEach, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'HTMLFormElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'MouseEvent',
'FocusEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { QueryClient, QueryClientProvider } =
await import('@tanstack/react-query')
const { api } = await import('@/lib/api')
const { ApiKeysProvider } = await import('../api-keys-provider')
const { ApiKeysMutateDrawer } = await import('../api-keys-mutate-drawer')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: { en: { translation: {} } },
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }>
type MockableApi = {
get: ApiMethod
post: ApiMethod
}
type RenderedDrawer = {
host: HTMLDivElement
queryClient: InstanceType<typeof QueryClient>
root: ReturnType<typeof createRoot>
}
const apiClient = api as unknown as MockableApi
const originalGet = apiClient.get
const originalPost = apiClient.post
let renderedDrawer: RenderedDrawer | null = null
function installApiFixtures(createdPayloads: Array<Record<string, unknown>>) {
apiClient.get = async (url) => {
switch (url) {
case '/api/status':
return { data: { data: { default_use_auto_group: true } } }
case '/api/user/models':
return { data: { success: true, data: [] } }
case '/api/user/self/groups':
return {
data: {
success: true,
data: {
auto: { desc: 'Automatic routing', ratio: 'auto' },
default: { desc: 'Standard access', ratio: 1 },
vip: { desc: 'Priority access', ratio: 2 },
},
},
}
case '/api/token/auto-groups':
return {
data: {
success: true,
data: { groups: ['vip', 'default'], max_count: 3 },
},
}
default:
throw new Error(`Unexpected GET ${url}`)
}
}
apiClient.post = async (url, data) => {
assert.equal(url, '/api/token/')
assert.ok(data && typeof data === 'object')
createdPayloads.push(data as Record<string, unknown>)
return { data: { success: true, data: {} } }
}
}
async function waitForCondition(
condition: () => boolean,
failureMessage: string
): Promise<void> {
if (condition()) return
await new Promise<void>((resolve, reject) => {
const observer = new MutationObserver(() => {
if (!condition()) return
clearTimeout(timeoutId)
observer.disconnect()
resolve()
})
const timeoutId = setTimeout(() => {
observer.disconnect()
reject(new Error(`${failureMessage}: ${document.body.textContent}`))
}, 1500)
observer.observe(document, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
})
}
async function renderCreateDrawer(): Promise<void> {
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const freshAt = Date.now() + 60_000
queryClient.setQueryData(
['status'],
{ default_use_auto_group: true },
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['user-models'],
{ success: true, data: [] },
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['user-groups'],
{
success: true,
data: {
auto: { desc: 'Automatic routing', ratio: 'auto' },
default: { desc: 'Standard access', ratio: 1 },
vip: { desc: 'Priority access', ratio: 2 },
},
},
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['token-auto-groups'],
{
success: true,
data: { groups: ['vip', 'default'], max_count: 3 },
},
{ updatedAt: freshAt }
)
renderedDrawer = { host, queryClient, root }
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<ApiKeysProvider>
<ApiKeysMutateDrawer open onOpenChange={() => undefined} />
</ApiKeysProvider>
</I18nextProvider>
</QueryClientProvider>
)
)
await act(async () =>
waitForCondition(() => {
const saveButton = findButton('Save changes', false)
return saveButton !== null && !saveButton.disabled
}, 'API key drawer did not finish initializing')
)
}
function findButton(text: string, required: true): HTMLButtonElement
function findButton(text: string, required: false): HTMLButtonElement | null
function findButton(text: string, required = true): HTMLButtonElement | null {
const button = [
...document.querySelectorAll<HTMLButtonElement>('button'),
].find((candidate) => candidate.textContent?.includes(text))
if (required) assert.ok(button, `Expected button containing "${text}"`)
return button ?? null
}
function getControlByLabel<T extends HTMLElement>(labelText: string): T {
const label = [...document.querySelectorAll<HTMLLabelElement>('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
assert.ok(label, `Expected label "${labelText}"`)
assert.ok(label.htmlFor)
const control =
label.control ??
label
.closest('[data-slot="form-item"]')
?.querySelector<HTMLElement>(
'[data-slot="form-control"], input, textarea, button[role="combobox"], [role="group"]'
)
assert.ok(control)
return control as T
}
async function changeInput(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
domWindow.HTMLInputElement.prototype,
'value'
)?.set
assert.ok(valueSetter)
valueSetter.call(input, value)
input.dispatchEvent(
new domWindow.Event('input', { bubbles: true }) as unknown as Event
)
})
}
async function selectComboboxOption(
trigger: HTMLButtonElement,
optionDescription: string
) {
await act(async () => trigger.click())
const option = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(optionDescription))
assert.ok(option, `Expected option containing "${optionDescription}"`)
await act(async () => option.click())
}
afterEach(async () => {
apiClient.get = originalGet
apiClient.post = originalPost
domWindow.localStorage.clear()
if (renderedDrawer) {
await act(async () => renderedDrawer?.root.unmount())
renderedDrawer.queryClient.clear()
renderedDrawer.host.remove()
renderedDrawer = null
}
document.body.replaceChildren()
})
after(() => {
domWindow.close()
})
describe('API keys mutate drawer Auto group integration', () => {
test('inherits the root Auto order and sends an empty override for every batch-created key', async () => {
const createdPayloads: Array<Record<string, unknown>> = []
installApiFixtures(createdPayloads)
await renderCreateDrawer()
const groupTrigger = getControlByLabel<HTMLButtonElement>('Group')
assert.equal(groupTrigger.textContent?.includes('auto'), true)
assert.equal(
document.body.textContent?.includes(
'Using the complete global Auto order (2 groups)'
),
true
)
assert.deepEqual(
[
...document.querySelectorAll('[data-slot="global-auto-order-name"]'),
].map((item) => item.textContent),
['vip', 'default']
)
assert.equal(findButton('Restore global Auto', true).disabled, true)
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'batch')
await changeInput(getControlByLabel<HTMLInputElement>('Quantity'), '2')
await act(async () => findButton('Save changes', true).click())
await act(async () =>
waitForCondition(
() => createdPayloads.length === 2,
'batch API keys were not created'
)
)
assert.equal(createdPayloads.length, 2)
assert.equal(createdPayloads[0]?.name, 'batch')
for (const payload of createdPayloads) {
assert.equal(payload.group, 'auto')
assert.deepEqual(payload.auto_groups, [])
assert.equal(payload.cross_group_retry, true)
}
})
test('preserves an unsaved custom order and mode after Auto to ordinary to Auto changes', async () => {
const createdPayloads: Array<Record<string, unknown>> = []
installApiFixtures(createdPayloads)
await renderCreateDrawer()
const autoOrderControl = getControlByLabel<HTMLElement>('Auto group order')
const addGroupTrigger = autoOrderControl.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addGroupTrigger)
await selectComboboxOption(addGroupTrigger, 'Priority access')
assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
assert.equal(
document.body.textContent?.includes('1 / 3 groups selected'),
true
)
assert.equal(findButton('Restore global Auto', true).disabled, false)
const groupTrigger = getControlByLabel<HTMLButtonElement>('Group')
await selectComboboxOption(groupTrigger, 'Standard access')
assert.equal(
document.querySelector('button[aria-label="Remove vip"]'),
null
)
await selectComboboxOption(groupTrigger, 'Automatic routing')
assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
assert.equal(
document.body.textContent?.includes('1 / 3 groups selected'),
true
)
assert.equal(findButton('Restore global Auto', true).disabled, false)
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'custom')
await act(async () => findButton('Save changes', true).click())
await act(async () =>
waitForCondition(
() => createdPayloads.length === 1,
'custom-order API key was not created'
)
)
assert.deepEqual(createdPayloads[0]?.auto_groups, ['vip'])
})
})
@@ -0,0 +1,540 @@
/*
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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act, useState } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { AutoGroupOrderEditor } = await import('../auto-group-order-editor')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
'{{count}} / {{max}} groups selected':
'{{count}} / {{max}} groups selected',
'Add Auto group': 'Add Auto group',
'Auto group order': 'Auto group order',
'Drag {{group}} to reorder': 'Drag {{group}} to reorder',
'Inherit global Auto order': 'Inherit global Auto order',
'Maximum {{max}} groups selected': 'Maximum {{max}} groups selected',
'Move {{group}} down': 'Move {{group}} down',
'Move {{group}} up': 'Move {{group}} up',
'No available groups in the global Auto order.':
'No available groups in the global Auto order.',
'No valid custom Auto groups remain. Add a group or restore global Auto.':
'No valid custom Auto groups remain. Add a group or restore global Auto.',
'No custom groups. Saving will inherit the complete global Auto order.':
'No custom groups. Saving will inherit the complete global Auto order.',
'Remove {{group}}': 'Remove {{group}}',
'Restore global Auto': 'Restore global Auto',
Ratio: 'Ratio',
'Search...': 'Search...',
'No group found.': 'No group found.',
'Select a group': 'Select a group',
'Using the complete global Auto order ({{count}} groups)':
'Using the complete global Auto order ({{count}} groups)',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
const globalOptions = [
{ value: 'vip', label: 'VIP', desc: 'Priority access', ratio: 3 },
{ value: 'default', label: 'Default', desc: 'Standard access', ratio: 1 },
{ value: 'team', label: 'Team', desc: 'Shared access', ratio: 2 },
]
function Harness(props: { initialGroups?: string[] }) {
const [groups, setGroups] = useState(
props.initialGroups ?? ['default', 'vip']
)
const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[
{ value: 'auto', label: 'auto' },
{ value: 'default', label: 'default', ratio: 1 },
{ value: 'vip', label: 'vip', ratio: 2 },
{ value: 'team', label: 'team', ratio: 3 },
]}
globalOptions={globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function InheritanceHarness(props: { globalOptions?: typeof globalOptions }) {
const [groups, setGroups] = useState<string[]>([])
const [mode, setMode] = useState<'inherit' | 'custom'>('inherit')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[{ value: 'auto', label: 'auto' }, ...globalOptions]}
globalOptions={props.globalOptions ?? globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function CustomEmptyHarness() {
const [groups, setGroups] = useState<string[]>([])
const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[{ value: 'auto', label: 'auto' }, ...globalOptions]}
globalOptions={globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function findButton(container: ParentNode, label: string): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`
)
assert.ok(button)
return button
}
describe('Auto group order editor', () => {
after(() => {
domWindow.close()
})
test('enforces the limit and exposes accessible reorder controls', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness />))
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
assert.equal(addButton.disabled, true)
assert.equal(container.textContent?.includes('2 / 2 groups selected'), true)
assert.ok(
container.querySelector('[role="group"][aria-label="Auto group order"]')
)
assert.equal(
findButton(container, 'Drag default to reorder').type,
'button'
)
await act(async () => findButton(container, 'Move default down').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'vip,default'
)
await act(async () => {
findButton(container, 'Drag vip to reorder').dispatchEvent(
new domWindow.KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
}) as unknown as KeyboardEvent
)
})
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default,vip'
)
await act(async () => root.unmount())
container.remove()
})
test('adds and removes groups, then restores inheritance as an empty value', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness />))
await act(async () => findButton(container, 'Remove vip').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default'
)
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
assert.equal(addButton.disabled, false)
await act(async () => addButton.click())
const teamOption = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((option) => option.textContent?.includes('team'))
assert.ok(teamOption)
await act(async () => teamOption.click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default,team'
)
assert.equal(addButton.disabled, true)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
await act(async () => restoreButton.click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
''
)
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'inherit'
)
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (3 groups)'
),
true
)
const inheritedItems = container.querySelectorAll(
'[data-slot="global-auto-order"] > li'
)
assert.deepEqual(
[...inheritedItems].map(
(item) =>
item.querySelector('[data-slot="global-auto-order-name"]')
?.textContent
),
['VIP', 'Default', 'Team']
)
await act(async () => root.unmount())
container.remove()
})
test('shows the complete inherited order with metadata beyond the custom limit', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<InheritanceHarness />))
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (3 groups)'
),
true
)
assert.equal(
container.textContent?.includes('0 / 2 groups selected'),
false
)
const order = container.querySelector<HTMLOListElement>(
'[data-slot="global-auto-order"]'
)
assert.ok(order)
assert.equal(order.classList.contains('overflow-y-auto'), true)
assert.equal(order.classList.contains('flex-wrap'), true)
const items = [...order.querySelectorAll('li')]
assert.equal(items.length, 3)
assert.equal(
order.querySelectorAll('[data-slot="global-auto-order-connector"]')
.length,
2
)
assert.deepEqual(
items.map((item) => ({
index: item.querySelector('[data-slot="global-auto-order-index"]')
?.textContent,
name: item.querySelector('[data-slot="global-auto-order-name"]')
?.textContent,
title: item
.querySelector('[data-slot="global-auto-order-chip"]')
?.getAttribute('title'),
description: item.querySelector(
'[data-slot="global-auto-order-description"]'
)?.textContent,
ratio: item.querySelector('[data-slot="badge"]')?.textContent,
})),
[
{
index: '1',
name: 'VIP',
title: 'Priority access',
description: 'Priority access',
ratio: '3x Ratio',
},
{
index: '2',
name: 'Default',
title: 'Standard access',
description: 'Standard access',
ratio: '1x Ratio',
},
{
index: '3',
name: 'Team',
title: 'Shared access',
description: 'Shared access',
ratio: '2x Ratio',
},
]
)
for (const item of items) {
const chip = item.querySelector('[data-slot="global-auto-order-chip"]')
assert.ok(chip)
const description = item.querySelector(
'[data-slot="global-auto-order-description"]'
)
assert.ok(description)
assert.equal(description.classList.contains('sr-only'), true)
}
assert.equal(
items[0]?.querySelector('[data-slot="global-auto-order-connector"]'),
null
)
for (const item of items.slice(1)) {
const connector = item.querySelector(
'[data-slot="global-auto-order-connector"]'
)
assert.ok(connector)
assert.equal(connector.getAttribute('aria-hidden'), 'true')
}
assert.equal(container.querySelector('[aria-label^="Drag "]'), null)
assert.equal(container.querySelector('[aria-label^="Move "]'), null)
assert.equal(container.querySelector('[aria-label^="Remove "]'), null)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
assert.equal(restoreButton.disabled, true)
await act(async () => root.unmount())
container.remove()
})
test('shows an explicit empty state when the global Auto order has no groups', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<InheritanceHarness globalOptions={[]} />)
)
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (0 groups)'
),
true
)
assert.equal(
container.textContent?.includes(
'No available groups in the global Auto order.'
),
true
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('keeps an empty custom order distinct from global inheritance', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<CustomEmptyHarness />))
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.textContent?.includes(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
),
true
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
assert.equal(restoreButton.disabled, false)
await act(async () => restoreButton.click())
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'inherit'
)
assert.ok(container.querySelector('[data-slot="global-auto-order"]'))
await act(async () => root.unmount())
container.remove()
})
test('adding a group from inheritance explicitly creates a custom order', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<InheritanceHarness />))
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
await act(async () => addButton.click())
const vipOption = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((option) => option.textContent?.includes('VIP'))
assert.ok(vipOption)
await act(async () => vipOption.click())
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'vip'
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('removing the last custom group does not silently enable inheritance', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialGroups={['default']} />))
await act(async () => findButton(container, 'Remove default').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
''
)
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.textContent?.includes(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
),
true
)
await act(async () => root.unmount())
container.remove()
})
})
@@ -0,0 +1,90 @@
/*
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 { useTranslation } from 'react-i18next'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
// AutoGroupBadge,
GroupRatioBadge,
type GroupRatio,
} from './auto-group-visuals'
type ApiKeyGroupCellProps = {
crossGroupRetry: boolean
group: string
ratio?: GroupRatio
shouldReduceMotion: boolean
}
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation()
if (props.group !== 'auto') {
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={props.group || '-'}
tooltipClassName='break-all'
>
<GroupBadge group={props.group} ratio={ratio} />
</TruncatedCell>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<BadgeCell
data-api-key-group-cell='auto'
className='gap-1.5 overflow-visible text-xs'
/>
}
>
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge
ratio={props.ratio}
isAuto
shouldReduceMotion={props.shouldReduceMotion}
/>
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
@@ -20,7 +20,6 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -35,8 +34,15 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
AUTO_GROUP_FRAME_CLASS_NAME,
AutoGroupFlowBorder,
GroupRatioBadge,
} from './auto-group-visuals'
export type ApiKeyGroupOption = {
value: string
label: string
@@ -52,50 +58,6 @@ type ApiKeyGroupComboboxProps = {
disabled?: boolean
}
function formatGroupRatio(
ratio: ApiKeyGroupOption['ratio'],
ratioLabel: string
) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge
variant='outline'
className={cn(
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
getRatioBadgeClassName(ratio)
)}
>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
@@ -106,7 +68,9 @@ export function ApiKeyGroupCombobox({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const selectedOption = options.find((option) => option.value === value)
const isAutoSelected = selectedOption?.value === 'auto'
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
@@ -138,11 +102,22 @@ export function ApiKeyGroupCombobox({
variant='outline'
role='combobox'
aria-expanded={open}
data-auto-group-effect={isAutoSelected ? 'trigger' : undefined}
disabled={disabled}
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
className={cn(
'border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 relative h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3',
isAutoSelected &&
cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'hover:border-primary/55 data-popup-open:border-primary/55 data-popup-open:ring-primary/20'
)
)}
/>
}
>
{isAutoSelected && (
<AutoGroupFlowBorder shouldReduceMotion={shouldReduceMotion} />
)}
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
@@ -155,10 +130,17 @@ export function ApiKeyGroupCombobox({
)}
</span>
<span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} />
<GroupRatioBadge
ratio={selectedOption?.ratio}
isAuto={isAutoSelected}
shouldReduceMotion={shouldReduceMotion}
/>
</span>
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
<ChevronsUpDown
aria-hidden='true'
className='size-4 shrink-0 opacity-50'
/>
</PopoverTrigger>
<PopoverContent
className='data-closed:zoom-out-100 data-open:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--anchor-width)] overflow-hidden rounded-xl p-0 shadow-lg data-closed:duration-75 data-open:duration-100'
@@ -175,32 +157,54 @@ export function ApiKeyGroupCombobox({
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
>
<Check
{filteredOptions.map((option) => {
const isAutoOption = option.value === 'auto'
return (
<CommandItem
key={option.value}
value={option.value}
data-auto-group-effect={isAutoOption ? 'option' : undefined}
onSelect={() => handleSelect(option.value)}
className={cn(
'mt-0.5 h-4 w-4',
value === option.value ? 'opacity-100' : 'opacity-0'
'data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors',
isAutoOption &&
cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'border-primary/35 data-[selected=true]:border-primary/55'
)
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.label}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
>
{isAutoOption && (
<AutoGroupFlowBorder
shouldReduceMotion={shouldReduceMotion}
/>
)}
<Check
aria-hidden='true'
className={cn(
'mt-0.5 size-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.label}
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
</CommandItem>
))}
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge
ratio={option.ratio}
isAuto={isAutoOption}
shouldReduceMotion={shouldReduceMotion}
/>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
@@ -20,8 +20,6 @@ import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
@@ -30,6 +28,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs'
@@ -38,6 +37,7 @@ import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
@@ -53,16 +53,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function useGroupRatios(): Record<string, number> {
function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
staleTime: 0,
select: (res) => {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
const ratios: Record<string, number | string> = {}
for (const [group, info] of Object.entries(res.data)) {
if (typeof info.ratio === 'number') {
if (typeof info.ratio === 'number' || typeof info.ratio === 'string') {
ratios[group] = info.ratio
}
}
@@ -76,6 +76,7 @@ function useGroupRatios(): Record<string, number> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
@@ -195,44 +196,16 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const apiKey = row.original
const group = row.getValue('group') as string
const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
if (group === 'auto') {
return (
<Tooltip>
<TooltipTrigger
render={<BadgeCell className='gap-1.5 text-xs' />}
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
)}
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={group || '-'}
tooltipClassName='break-all'
>
<GroupBadge group={group} ratio={ratio} />
</TruncatedCell>
<ApiKeyGroupCell
group={group}
ratio={groupRatios[group]}
crossGroupRetry={apiKey.cross_group_retry}
shouldReduceMotion={shouldReduceMotion}
/>
)
},
size: 160,
size: 220,
meta: { mobileHidden: true },
},
{
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -67,7 +67,12 @@ import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { createApiKey, updateApiKey, getApiKey } from '../api'
import {
createApiKey,
updateApiKey,
getApiKey,
getTokenAutoGroups,
} from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
getApiKeyFormSchema,
@@ -82,6 +87,7 @@ import {
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider'
import { AutoGroupOrderEditor } from './auto-group-order-editor'
type ApiKeyMutateDrawerProps = {
open: boolean
@@ -96,10 +102,14 @@ export function ApiKeysMutateDrawer({
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
const currentRowId = currentRow?.id
const { triggerRefresh } = useApiKeys()
const { status } = useStatus()
const { status, loading: statusLoading } = useStatus()
const [isSubmitting, setIsSubmitting] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [initializedTarget, setInitializedTarget] = useState<string | null>(
null
)
const defaultUseAutoGroup = status?.default_use_auto_group === true
// Fetch models
@@ -111,25 +121,77 @@ export function ApiKeysMutateDrawer({
})
// Fetch groups
const { data: groupsData } = useQuery({
const {
data: groupsData,
isFetched: groupsFetched,
isFetching: groupsFetching,
} = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
enabled: open,
staleTime: 0,
})
const {
data: apiKeyData,
isFetched: apiKeyFetched,
isFetching: apiKeyFetching,
} = useQuery({
queryKey: ['api-key', currentRowId],
queryFn: () => getApiKey(currentRowId ?? 0),
enabled: open && isUpdate && currentRowId !== undefined,
staleTime: 0,
})
const {
data: autoGroupsData,
isFetched: autoGroupsFetched,
isFetching: autoGroupsFetching,
} = useQuery({
queryKey: ['token-auto-groups'],
queryFn: getTokenAutoGroups,
enabled: open,
staleTime: 0,
})
const models = modelsData?.data || []
const groupsRaw = groupsData?.data || {}
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
([key, info]) => ({
value: key,
label: key,
desc: info.desc || key,
ratio: info.ratio,
})
const groups = useMemo<ApiKeyGroupOption[]>(
() =>
Object.entries(groupsData?.data || {}).map(([key, info]) => ({
value: key,
label: key,
desc: info.desc || key,
ratio: info.ratio,
})),
[groupsData]
)
const backendHasAuto = groups.some((g) => g.value === 'auto')
const schema = getApiKeyFormSchema(t)
const availableAutoGroupNames = useMemo(
() => groups.filter((group) => group.value !== 'auto').map((g) => g.value),
[groups]
)
const globalAutoGroups = useMemo(() => {
const available = new Set(availableAutoGroupNames)
return (autoGroupsData?.data?.groups || []).filter((group) =>
available.has(group)
)
}, [autoGroupsData, availableAutoGroupNames])
const globalAutoGroupOptions = useMemo(() => {
const groupsByValue = new Map(groups.map((group) => [group.value, group]))
return globalAutoGroups.flatMap((group) => {
const option = groupsByValue.get(group)
return option ? [option] : []
})
}, [globalAutoGroups, groups])
const maxAutoGroups =
Number.isInteger(autoGroupsData?.data?.max_count) &&
Number(autoGroupsData?.data?.max_count) > 0
? Number(autoGroupsData?.data?.max_count)
: 5
const schema = useMemo(
() => getApiKeyFormSchema(t, maxAutoGroups),
[t, maxAutoGroups]
)
const form = useForm<ApiKeyFormValues>({
resolver: zodResolver(schema),
@@ -138,23 +200,69 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
void getApiKey(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformApiKeyToFormDefaults(result.data))
}
})
} else if (open && !isUpdate) {
if (!open) {
setInitializedTarget(null)
return
}
if (
!groupsFetched ||
groupsFetching ||
!autoGroupsFetched ||
autoGroupsFetching
) {
return
}
if (isUpdate && (!apiKeyFetched || apiKeyFetching)) return
if (!isUpdate && statusLoading) return
const target = isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
if (initializedTarget === target) return
if (isUpdate && currentRow) {
if (apiKeyData?.success && apiKeyData.data) {
form.reset(
transformApiKeyToFormDefaults(
apiKeyData.data,
availableAutoGroupNames,
maxAutoGroups
)
)
setInitializedTarget(target)
}
} else {
form.reset(
getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)
)
setInitializedTarget(target)
}
}, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
}, [
open,
isUpdate,
currentRow,
form,
defaultUseAutoGroup,
statusLoading,
backendHasAuto,
groupsFetched,
groupsFetching,
autoGroupsFetched,
autoGroupsFetching,
apiKeyData,
apiKeyFetched,
apiKeyFetching,
availableAutoGroupNames,
maxAutoGroups,
initializedTarget,
])
const formTarget =
isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
const isFormInitialized = initializedTarget === formTarget
const selectedGroup = form.watch('group')
// Correct group after groups load: if the form value is not in available groups, fall back
useEffect(() => {
if (groups.length === 0) return
const currentGroup = form.getValues('group')
const currentGroup = selectedGroup
if (currentGroup && !groups.some((g) => g.value === currentGroup)) {
const fallback =
groups.find((g) => g.value === 'default')?.value ??
@@ -162,10 +270,12 @@ export function ApiKeysMutateDrawer({
''
form.setValue('group', fallback)
if (currentGroup === 'auto') {
form.setValue('auto_groups', [])
form.setValue('auto_groups_mode', 'inherit')
form.setValue('cross_group_retry', false)
}
}
}, [groups, form])
}, [groups, form, selectedGroup])
const onSubmit = async (data: ApiKeyFormValues) => {
setIsSubmitting(true)
@@ -247,7 +357,7 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const autoGroupsMode = form.watch('auto_groups_mode')
const unlimitedQuota = form.watch('unlimited_quota')
return (
@@ -277,6 +387,8 @@ export function ApiKeysMutateDrawer({
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
aria-busy={!isFormInitialized}
inert={!isFormInitialized || isSubmitting ? true : undefined}
className={sideDrawerFormClassName('gap-5')}
>
<SideDrawerSection>
@@ -310,7 +422,18 @@ export function ApiKeysMutateDrawer({
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
onValueChange={(group) => {
field.onChange(group)
if (group === 'auto') {
form.setValue('cross_group_retry', true, {
shouldDirty: true,
})
return
}
form.setValue('cross_group_retry', false, {
shouldDirty: true,
})
}}
placeholder={t('Select a group')}
/>
</FormControl>
@@ -319,6 +442,47 @@ export function ApiKeysMutateDrawer({
)}
/>
{selectedGroup === 'auto' && (
<FormField
control={form.control}
name='auto_groups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto group order')}</FormLabel>
<FormDescription>
{t(
'Choose and order the groups this API key will try.'
)}
</FormDescription>
<FormControl>
<AutoGroupOrderEditor
value={field.value}
mode={autoGroupsMode}
options={groups}
globalOptions={globalAutoGroupOptions}
maxCount={maxAutoGroups}
onChange={(value) => {
form.setValue('auto_groups_mode', value.mode, {
shouldDirty: true,
shouldValidate: false,
})
form.setValue(
'auto_groups',
value.groups.slice(0, maxAutoGroups),
{
shouldDirty: true,
shouldValidate: true,
}
)
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{selectedGroup === 'auto' && (
<FormField
control={form.control}
@@ -595,7 +759,7 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
onClick={form.handleSubmit(onSubmit, onInvalid)}
disabled={isSubmitting}
disabled={!isFormInitialized || isSubmitting}
className='w-full sm:w-auto'
>
{isSubmitting ? t('Saving...') : t('Save changes')}
@@ -0,0 +1,338 @@
/*
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 {
ArrowDown01Icon,
ArrowRight01Icon,
ArrowUp01Icon,
Cancel01Icon,
Drag01Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { Reorder, useDragControls } from 'motion/react'
import {
useMemo,
type ComponentProps,
type KeyboardEvent,
type PointerEvent,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty'
import { cn } from '@/lib/utils'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { GroupRatioBadge } from './auto-group-visuals'
type AutoGroupOrderEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
value: string[]
mode: 'inherit' | 'custom'
options: ApiKeyGroupOption[]
globalOptions: ApiKeyGroupOption[]
maxCount: number
onChange: (value: { groups: string[]; mode: 'inherit' | 'custom' }) => void
'data-slot'?: string
'data-form-root'?: string
}
type AutoGroupOrderItemProps = {
group: string
index: number
count: number
onMove: (index: number, direction: 'up' | 'down') => void
onRemove: (group: string) => void
}
function AutoGroupOrderItem(props: AutoGroupOrderItemProps) {
const { t } = useTranslation()
const dragControls = useDragControls()
const handleDragStart = (event: PointerEvent<HTMLButtonElement>) => {
dragControls.start(event)
}
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
props.onMove(props.index, 'up')
}
if (event.key === 'ArrowDown') {
event.preventDefault()
props.onMove(props.index, 'down')
}
}
return (
<Reorder.Item
value={props.group}
dragListener={false}
dragControls={dragControls}
className='bg-background flex items-center gap-2 rounded-lg border p-2'
>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground cursor-grab touch-none font-mono active:cursor-grabbing'
aria-label={t('Drag {{group}} to reorder', { group: props.group })}
onPointerDown={handleDragStart}
onKeyDown={handleDragKeyDown}
>
<HugeiconsIcon icon={Drag01Icon} strokeWidth={2} aria-hidden='true' />
</Button>
<span className='min-w-0 flex-1 truncate text-sm font-medium'>
{props.group}
</span>
<div className='flex shrink-0 gap-1'>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === 0}
aria-label={t('Move {{group}} up', { group: props.group })}
onClick={() => props.onMove(props.index, 'up')}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === props.count - 1}
aria-label={t('Move {{group}} down', { group: props.group })}
onClick={() => props.onMove(props.index, 'down')}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
aria-label={t('Remove {{group}}', { group: props.group })}
onClick={() => props.onRemove(props.group)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
</div>
</Reorder.Item>
)
}
export function AutoGroupOrderEditor(props: AutoGroupOrderEditorProps) {
const { t } = useTranslation()
const maxCount =
Number.isInteger(props.maxCount) && props.maxCount > 0 ? props.maxCount : 5
const isInheriting = props.mode === 'inherit'
const atLimit = props.value.length >= maxCount
const candidates = useMemo(
() =>
props.options.filter(
(option) =>
option.value !== 'auto' && !props.value.includes(option.value)
),
[props.options, props.value]
)
const handleAdd = (group: string) => {
if (atLimit || props.value.includes(group)) return
props.onChange({
groups: [...props.value, group],
mode: 'custom',
})
}
const handleRemove = (group: string) => {
props.onChange({
groups: props.value.filter((item) => item !== group),
mode: 'custom',
})
}
const handleMove = (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= props.value.length) return
const next = [...props.value]
;[next[index], next[targetIndex]] = [next[targetIndex], next[index]]
props.onChange({ groups: next, mode: 'custom' })
}
return (
<div
id={props.id}
data-slot={props['data-slot']}
data-form-root={props['data-form-root']}
role='group'
tabIndex={-1}
aria-label={props['aria-label'] || t('Auto group order')}
aria-describedby={props['aria-describedby']}
aria-invalid={props['aria-invalid']}
className={cn('flex flex-col gap-3', props.className)}
>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-xs' aria-live='polite'>
{isInheriting
? t('Using the complete global Auto order ({{count}} groups)', {
count: props.globalOptions.length,
})
: t('{{count}} / {{max}} groups selected', {
count: props.value.length,
max: maxCount,
})}
</p>
<Button
type='button'
variant='outline'
size='sm'
disabled={isInheriting}
onClick={() => {
props.onChange({ groups: [], mode: 'inherit' })
}}
>
{t('Restore global Auto')}
</Button>
</div>
<ApiKeyGroupCombobox
options={candidates}
value={undefined}
onValueChange={handleAdd}
placeholder={
atLimit
? t('Maximum {{max}} groups selected', { max: maxCount })
: t('Add Auto group')
}
disabled={atLimit || candidates.length === 0}
/>
{isInheriting && props.globalOptions.length === 0 && (
<Empty className='min-h-28 border'>
<EmptyHeader>
<EmptyTitle>{t('Inherit global Auto order')}</EmptyTitle>
<EmptyDescription>
{t('No available groups in the global Auto order.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{isInheriting && props.globalOptions.length > 0 && (
<ol
data-slot='global-auto-order'
aria-label={t('Inherit global Auto order')}
className='flex max-h-24 flex-wrap content-start gap-1.5 overflow-y-auto'
>
{props.globalOptions.map((option, index) => (
<li key={option.value} className='flex min-w-0 items-center gap-1'>
{index > 0 && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
aria-hidden='true'
data-slot='global-auto-order-connector'
className='text-muted-foreground size-3.5 shrink-0'
/>
)}
<span
data-slot='global-auto-order-chip'
title={option.desc}
className='bg-muted/30 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1'
>
<span
data-slot='global-auto-order-index'
aria-hidden='true'
className='bg-primary/10 text-primary flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums'
>
{index + 1}
</span>
<span
data-slot='global-auto-order-name'
className='max-w-40 truncate text-xs font-medium'
>
{option.label}
</span>
{option.desc && (
<span
data-slot='global-auto-order-description'
className='sr-only'
>
{option.desc}
</span>
)}
<GroupRatioBadge ratio={option.ratio} />
</span>
</li>
))}
</ol>
)}
{!isInheriting && props.value.length === 0 && (
<Empty className='min-h-24 border'>
<EmptyHeader>
<EmptyTitle>{t('Auto group order')}</EmptyTitle>
<EmptyDescription>
{t(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{!isInheriting && props.value.length > 0 && (
<Reorder.Group
axis='y'
values={props.value}
onReorder={(groups) => props.onChange({ groups, mode: 'custom' })}
className='flex flex-col gap-2'
>
{props.value.map((group, index) => (
<AutoGroupOrderItem
key={group}
group={group}
index={index}
count={props.value.length}
onMove={handleMove}
onRemove={handleRemove}
/>
))}
</Reorder.Group>
)}
</div>
)
}
@@ -0,0 +1,140 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export type GroupRatio = number | string | null | undefined
export const AUTO_GROUP_FRAME_CLASS_NAME =
'border-primary/40 relative overflow-visible border shadow-sm shadow-primary/10'
type AutoGroupFlowBorderProps = {
shouldReduceMotion: boolean
}
export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
if (props.shouldReduceMotion) return null
return (
<span
aria-hidden='true'
data-auto-group-flow-border='true'
className='auto-group-flow-border pointer-events-none absolute -inset-px'
/>
)
}
type AutoGroupFrameProps = {
children: ReactNode
className?: string
effect: 'badge' | 'ratio'
shouldReduceMotion: boolean
}
export function AutoGroupFrame(props: AutoGroupFrameProps) {
return (
<span
data-auto-group-frame='true'
data-auto-group-effect={props.effect}
className={cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'inline-flex max-w-full shrink-0 rounded-4xl p-px',
props.className
)}
>
<AutoGroupFlowBorder shouldReduceMotion={props.shouldReduceMotion} />
{props.children}
</span>
)
}
function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'border-warning/30 bg-warning/10 text-warning'
}
if (ratio > 1) {
return 'border-info/30 bg-info/10 text-info'
}
return 'border-success/30 bg-success/10 text-success'
}
type GroupRatioBadgeProps = {
isAuto?: boolean
ratio: GroupRatio
shouldReduceMotion?: boolean
}
export function GroupRatioBadge(props: GroupRatioBadgeProps) {
const { t } = useTranslation()
if (props.ratio === undefined || props.ratio === null || props.ratio === '') {
return null
}
const label =
typeof props.ratio === 'number'
? `${props.ratio}x ${t('Ratio')}`
: `${t('Auto')} ${t('Ratio')}`
const badge = (
<Badge
variant='outline'
className={cn(
'max-w-full truncate text-[10px] sm:text-xs',
getRatioBadgeClassName(props.ratio, props.isAuto === true)
)}
>
{label}
</Badge>
)
if (!props.isAuto) {
return <span className='max-w-24 shrink-0 sm:max-w-none'>{badge}</span>
}
return (
<AutoGroupFrame
effect='ratio'
shouldReduceMotion={props.shouldReduceMotion ?? false}
className='max-w-24 sm:max-w-none'
>
{badge}
</AutoGroupFrame>
)
}
export function AutoGroupBadge(props: AutoGroupFlowBorderProps) {
return (
<AutoGroupFrame
effect='badge'
shouldReduceMotion={props.shouldReduceMotion}
>
<GroupBadge group='auto' />
</AutoGroupFrame>
)
}
@@ -0,0 +1,189 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { TFunction } from 'i18next'
import { apiKeySchema, type ApiKey } from '../../types'
import {
getApiKeyFormDefaultValues,
getApiKeyFormSchema,
transformApiKeyToFormDefaults,
transformFormDataToPayload,
} from '../api-key-form'
const t = ((key: string, options?: Record<string, unknown>) => {
if (options?.max !== undefined) {
return key.replace('{{max}}', String(options.max))
}
return key
}) as TFunction
const baseApiKey: ApiKey = {
id: 1,
name: 'test',
key: 'sk-test',
status: 1,
remain_quota: 0,
used_quota: 0,
unlimited_quota: true,
expired_time: -1,
created_time: 1,
accessed_time: 0,
group: 'auto',
auto_groups: null,
cross_group_retry: true,
model_limits_enabled: false,
model_limits: '',
allow_ips: '',
}
describe('API key Auto group form mapping', () => {
test('treats legacy token responses without auto_groups as inheritance', () => {
const legacyApiKey: Record<string, unknown> = { ...baseApiKey }
delete legacyApiKey.auto_groups
assert.equal(apiKeySchema.parse(legacyApiKey).auto_groups, null)
})
test('creates an Auto token that inherits the global order', () => {
const defaults = getApiKeyFormDefaultValues(true)
assert.equal(defaults.group, 'auto')
assert.equal(defaults.auto_groups_mode, 'inherit')
assert.deepEqual(defaults.auto_groups, [])
assert.deepEqual(transformFormDataToPayload(defaults).auto_groups, [])
})
test('maps omitted, null, and empty snapshots to inheritance on edit', () => {
const legacyApiKey: Record<string, unknown> = { ...baseApiKey }
delete legacyApiKey.auto_groups
const inheritedApiKeys = [
apiKeySchema.parse(legacyApiKey),
baseApiKey,
{ ...baseApiKey, auto_groups: [] },
]
for (const apiKey of inheritedApiKeys) {
const defaults = transformApiKeyToFormDefaults(
apiKey,
['default', 'vip'],
2
)
assert.equal(defaults.auto_groups_mode, 'inherit')
assert.deepEqual(defaults.auto_groups, [])
}
})
test('filters a stored snapshot before applying a lowered limit', () => {
const defaults = transformApiKeyToFormDefaults(
{
...baseApiKey,
auto_groups: ['revoked', 'vip', 'default'],
},
['default', 'vip'],
2
)
assert.equal(defaults.auto_groups_mode, 'custom')
assert.deepEqual(defaults.auto_groups, ['vip', 'default'])
})
test('keeps a fully filtered snapshot custom and rejects it until resolved', () => {
const defaults = transformApiKeyToFormDefaults(
{ ...baseApiKey, auto_groups: ['revoked'] },
['default'],
2
)
assert.equal(defaults.auto_groups_mode, 'custom')
assert.deepEqual(defaults.auto_groups, [])
const result = getApiKeyFormSchema(t, 2).safeParse(defaults)
assert.equal(result.success, false)
if (result.success) return
assert.deepEqual(result.error.issues[0]?.path, ['auto_groups'])
assert.equal(
result.error.issues[0]?.message,
'Select at least one Auto group or restore global Auto.'
)
})
test('submits a valid custom snapshot in its configured order', () => {
const custom = {
...getApiKeyFormDefaultValues(true),
auto_groups_mode: 'custom' as const,
auto_groups: ['vip', 'default'],
}
assert.deepEqual(transformFormDataToPayload(custom).auto_groups, [
'vip',
'default',
])
})
test('submits an empty array for inheritance and for non-Auto groups', () => {
const inherited = getApiKeyFormDefaultValues(true)
assert.deepEqual(transformFormDataToPayload(inherited).auto_groups, [])
const nonAuto = {
...inherited,
group: 'default',
auto_groups_mode: 'custom' as const,
auto_groups: ['vip'],
}
assert.deepEqual(transformFormDataToPayload(nonAuto).auto_groups, [])
assert.equal(transformFormDataToPayload(nonAuto).cross_group_retry, false)
})
test('rejects snapshots over the configured limit', () => {
const result = getApiKeyFormSchema(t, 1).safeParse({
...getApiKeyFormDefaultValues(true),
name: 'limited token',
auto_groups_mode: 'custom',
auto_groups: ['default', 'vip'],
})
assert.equal(result.success, false)
if (result.success) return
assert.equal(result.error.issues[0]?.path[0], 'auto_groups')
assert.equal(
result.error.issues[0]?.message,
'Select at most 1 Auto groups'
)
})
test('rejects duplicate custom groups', () => {
const result = getApiKeyFormSchema(t).safeParse({
...getApiKeyFormDefaultValues(true),
name: 'duplicate token',
auto_groups_mode: 'custom',
auto_groups: ['vip', 'vip'],
})
assert.equal(result.success, false)
if (result.success) return
assert.equal(
result.error.issues[0]?.message,
'Auto groups must not contain duplicates'
)
})
})
+60 -3
View File
@@ -22,13 +22,16 @@ import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { DEFAULT_GROUP } from '../constants'
import { type ApiKeyFormData, type ApiKey } from '../types'
import type { ApiKey, ApiKeyFormData } from '../types'
// ============================================================================
// Form Schema
// ============================================================================
export function getApiKeyFormSchema(t: TFunction) {
export function getApiKeyFormSchema(t: TFunction, maxAutoGroups = 5) {
const autoGroupLimit =
Number.isInteger(maxAutoGroups) && maxAutoGroups > 0 ? maxAutoGroups : 5
return z
.object({
name: z.string().min(1, t('Please enter a name')),
@@ -38,10 +41,45 @@ export function getApiKeyFormSchema(t: TFunction) {
model_limits: z.array(z.string()),
allow_ips: z.string().optional(),
group: z.string().optional(),
auto_groups_mode: z.enum(['inherit', 'custom']),
auto_groups: z.array(z.string()),
cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.group === 'auto') {
if (
data.auto_groups_mode === 'custom' &&
data.auto_groups.length === 0
) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t(
'Select at least one Auto group or restore global Auto.'
),
})
}
if (data.auto_groups.length > autoGroupLimit) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t('Select at most {{max}} Auto groups', {
max: autoGroupLimit,
}),
})
}
if (new Set(data.auto_groups).size !== data.auto_groups.length) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t('Auto groups must not contain duplicates'),
})
}
}
if (data.unlimited_quota) {
return
}
@@ -73,6 +111,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
model_limits: [],
allow_ips: '',
group: DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: true,
tokenCount: 1,
}
@@ -83,6 +123,8 @@ export function getApiKeyFormDefaultValues(
return {
...API_KEY_FORM_DEFAULT_VALUES,
group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: defaultUseAutoGroup,
}
}
@@ -110,6 +152,10 @@ export function transformFormDataToPayload(
model_limits: data.model_limits.join(','),
allow_ips: data.allow_ips || '',
group: data.group || '',
auto_groups:
data.group === 'auto' && data.auto_groups_mode === 'custom'
? data.auto_groups
: [],
cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
}
}
@@ -118,8 +164,17 @@ export function transformFormDataToPayload(
* Transform API key data to form defaults
*/
export function transformApiKeyToFormDefaults(
apiKey: ApiKey
apiKey: ApiKey,
availableAutoGroups: string[] = [],
maxAutoGroups = 5
): ApiKeyFormValues {
const availableSet = new Set(availableAutoGroups)
const storedAutoGroups = apiKey.auto_groups ?? []
const autoGroups = storedAutoGroups
.filter((group) => availableSet.has(group))
.slice(0, Math.max(0, maxAutoGroups))
const autoGroupsMode = storedAutoGroups.length > 0 ? 'custom' : 'inherit'
return {
name: apiKey.name,
remain_quota_dollars: apiKey.unlimited_quota
@@ -135,6 +190,8 @@ export function transformApiKeyToFormDefaults(
: [],
allow_ips: apiKey.allow_ips || '',
group: apiKey.group || DEFAULT_GROUP,
auto_groups_mode: autoGroupsMode,
auto_groups: autoGroups,
cross_group_retry: !!apiKey.cross_group_retry,
tokenCount: 1,
}
+7
View File
@@ -34,6 +34,7 @@ export const apiKeySchema = z.object({
created_time: z.number(),
accessed_time: z.number(),
group: z.string().nullish().default(''),
auto_groups: z.array(z.string()).nullish().default(null),
cross_group_retry: z
.preprocess((v) => {
if (v === 1) return true
@@ -91,9 +92,15 @@ export interface ApiKeyFormData {
model_limits: string
allow_ips: string
group: string
auto_groups: string[]
cross_group_retry: boolean
}
export interface TokenAutoGroupsConfig {
groups: string[]
max_count: number
}
// ============================================================================
// Dialog Types
// ============================================================================
@@ -319,6 +319,7 @@ export function ModelMutateDrawer({
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
CreateCacheRatio: '',
'group_ratio_setting.group_special_usable_group': '{}',
@@ -56,6 +56,7 @@ const defaultBillingSettings: BillingSettings = {
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
PayAddress: '',
@@ -46,6 +46,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({
UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups,
MaxTokenAutoGroups: settings.MaxTokenAutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'],
@@ -0,0 +1,40 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { positiveIntegerSchema } from '../../utils/numeric-field'
const t = (key: string) => key
const schema = positiveIntegerSchema(t('Enter a positive integer'))
describe('per-token Auto group limit validation', () => {
test('accepts any positive integer without a product upper bound', () => {
assert.equal(schema.safeParse(1000).success, true)
})
test('rejects zero, negative, and fractional limits', () => {
for (const maxTokenAutoGroups of [0, -1, 1.5]) {
const result = schema.safeParse(maxTokenAutoGroups)
assert.equal(result.success, false)
if (result.success) continue
assert.equal(result.error.issues[0]?.message, 'Enter a positive integer')
}
})
})
@@ -43,6 +43,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Sheet,
SheetContent,
@@ -59,6 +60,7 @@ import {
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
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'
@@ -68,6 +70,7 @@ type GroupFormValues = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
GroupSpecialUsableGroup: string
}
@@ -169,6 +172,34 @@ export const GroupRatioForm = memo(function GroupRatioForm({
userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')}
maxTokenAutoGroupsField={
<FormField
control={form.control}
name='MaxTokenAutoGroups'
render={({ field, fieldState }) => (
<FormItem data-invalid={fieldState.invalid}>
<FormLabel>
{t('Maximum custom groups per token')}
</FormLabel>
<FormControl>
<Input
{...safeNumberFieldProps(field)}
type='number'
min={1}
step={1}
aria-invalid={fieldState.invalid}
/>
</FormControl>
<FormDescription>
{t(
'Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
}
groupSpecialUsableGroup={form.watch('GroupSpecialUsableGroup')}
onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value)
@@ -339,6 +370,31 @@ export const GroupRatioForm = memo(function GroupRatioForm({
)}
/>
<FormField
control={form.control}
name='MaxTokenAutoGroups'
render={({ field, fieldState }) => (
<FormItem data-invalid={fieldState.invalid}>
<FormLabel>{t('Maximum custom groups per token')}</FormLabel>
<FormControl>
<Input
{...safeNumberFieldProps(field)}
type='number'
min={1}
step={1}
aria-invalid={fieldState.invalid}
/>
</FormControl>
<FormDescription>
{t(
'Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupSpecialUsableGroup'
@@ -24,7 +24,14 @@ import {
Plus,
Trash2,
} from 'lucide-react'
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
import {
useState,
useMemo,
useEffect,
useCallback,
memo,
type ReactNode,
} from 'react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
@@ -76,6 +83,7 @@ type GroupRatioVisualEditorProps = {
userUsableGroups: string
groupGroupRatio: string
autoGroups: string
maxTokenAutoGroupsField: ReactNode
groupSpecialUsableGroup: string
onChange: (field: string, value: string) => void
}
@@ -257,6 +265,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
userUsableGroups,
groupGroupRatio,
autoGroups,
maxTokenAutoGroupsField,
groupSpecialUsableGroup,
onChange,
}: GroupRatioVisualEditorProps) {
@@ -351,6 +360,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
</CardHeader>
<CardContent>
<div className='space-y-4'>
{maxTokenAutoGroupsField}
<GroupNameSelect
options={autoGroupCandidates}
value={null}
@@ -60,6 +60,7 @@ const defaultModelSettings: ModelSettings = {
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
RetryTimes: 0,
@@ -31,6 +31,7 @@ import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { positiveIntegerSchema } from '../utils/numeric-field'
import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings'
@@ -130,6 +131,7 @@ const createGroupSchema = (t: Translate) =>
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
}),
MaxTokenAutoGroups: positiveIntegerSchema(t('Enter a positive integer')),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: createJsonStringField(t),
})
@@ -204,6 +206,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
@@ -290,6 +293,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
@@ -360,6 +364,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
MaxTokenAutoGroups: values.MaxTokenAutoGroups,
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup
@@ -382,6 +387,8 @@ export function RatioSettingsCard({
const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
groupNormalizedDefaults.current = normalized
},
[updateOption]
)
@@ -223,6 +223,7 @@ export type ModelSettings = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
RetryTimes: number
@@ -277,6 +278,7 @@ export type BillingSettings = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
PayAddress: string
@@ -22,6 +22,11 @@ import type {
FieldPath,
FieldValues,
} from 'react-hook-form'
import { z } from 'zod'
export function positiveIntegerSchema(message: string) {
return z.number().int(message).positive(message)
}
/**
* Props produced by {@link safeNumberFieldProps} for a native