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:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user