feat: configurable tool pricing, Sub2API channel, and alpha search billing
Add admin-configurable tool-call prices with cross-provider surcharge settlement, Sub2API channel support, /v1/alpha/search relay, and usage-log surcharge UI.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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 { useId, type SVGProps } from 'react'
|
||||
|
||||
type IconSub2apiProps = SVGProps<SVGSVGElement> & {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function IconSub2api({ size = 20, ...props }: IconSub2apiProps) {
|
||||
const gradientId = useId()
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
width={size}
|
||||
height={size}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1='4'
|
||||
y1='4'
|
||||
x2='20'
|
||||
y2='20'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#67EDB1' />
|
||||
<stop offset='.48' stopColor='#2FD3E1' />
|
||||
<stop offset='1' stopColor='#2E68EA' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
fill='none'
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2.7'
|
||||
>
|
||||
<path d='m19.25 7.65-2.55-3.2a1.33 1.33 0 0 0-1.03-.5H8.58c-.34 0-.67.13-.91.37L4.15 7.65c-.93.88-.6 1.52.65 2.3l9.55 5.97' />
|
||||
<path d='m4.75 16.35 2.55 3.2c.25.31.63.5 1.03.5h7.09c.34 0 .67-.13.91-.37l3.52-3.33c.93-.88.6-1.52-.65-2.3L9.65 8.08' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,8 @@ const badgeVariants = cva(
|
||||
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
|
||||
warning:
|
||||
'border-warning/40 bg-warning/10 text-warning focus-visible:ring-warning/20',
|
||||
outline:
|
||||
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost:
|
||||
|
||||
@@ -77,12 +77,13 @@ export const CHANNEL_TYPES = {
|
||||
56: 'Replicate',
|
||||
57: 'ChatGPT Subscription (Codex)',
|
||||
58: 'Advanced Custom',
|
||||
59: 'Sub2API',
|
||||
} as const
|
||||
|
||||
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
|
||||
1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46,
|
||||
23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36,
|
||||
50, 51, 52, 53, 54, 55, 56,
|
||||
23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2, 5,
|
||||
36, 50, 51, 52, 53, 54, 55, 56,
|
||||
]
|
||||
|
||||
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
|
||||
@@ -380,6 +381,7 @@ export const FIELD_DESCRIPTIONS = {
|
||||
|
||||
export const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
|
||||
59,
|
||||
])
|
||||
|
||||
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||
@@ -391,6 +393,7 @@ export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||
50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
|
||||
51: 'Format: Access Key ID|Secret Access Key',
|
||||
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
|
||||
59: 'Enter API key for this channel',
|
||||
}
|
||||
|
||||
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
|
||||
|
||||
@@ -107,6 +107,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
||||
value: '/v1/responses/compact',
|
||||
label: 'OpenAI Responses Compact',
|
||||
},
|
||||
{
|
||||
value: '/v1/alpha/search',
|
||||
label: 'OpenAI Alpha Search',
|
||||
},
|
||||
{
|
||||
value: ADVANCED_CUSTOM_MODEL_LIST_PATH,
|
||||
label: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
|
||||
@@ -834,6 +838,7 @@ function isConverterPathAllowed(
|
||||
converter: AdvancedCustomConverter
|
||||
): boolean {
|
||||
if (converter === 'none') return true
|
||||
if (incomingPath === '/v1/alpha/search') return false
|
||||
if (converter === 'anthropic_messages_to_openai_chat_completions') {
|
||||
return incomingPath === '/v1/messages'
|
||||
}
|
||||
|
||||
@@ -144,6 +144,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
|
||||
models: 'Models exposed by this channel',
|
||||
},
|
||||
},
|
||||
59: {
|
||||
id: 59,
|
||||
name: CHANNEL_TYPES[59],
|
||||
icon: 'Sub2API',
|
||||
hints: {
|
||||
baseUrl: 'Sub2API gateway base URL',
|
||||
key: 'Sub2API API Key',
|
||||
models: 'Models fetched from upstream /v1/models',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ export function getChannelTypeIcon(type: number): string {
|
||||
7: 'OpenAI', // OhMyGPT
|
||||
8: 'OpenAI', // Custom
|
||||
58: 'NewAPI', // Advanced Custom
|
||||
59: 'Sub2API', // Sub2API
|
||||
3: 'Azure', // Azure
|
||||
|
||||
// Anthropic
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
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',
|
||||
'HTMLInputElement',
|
||||
'SVGElement',
|
||||
'Node',
|
||||
'Element',
|
||||
'Event',
|
||||
'CustomEvent',
|
||||
'MutationObserver',
|
||||
'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 { QueryClient, QueryClientProvider } =
|
||||
await import('@tanstack/react-query')
|
||||
const { createInstance } = await import('i18next')
|
||||
const { I18nextProvider, initReactI18next } = await import('react-i18next')
|
||||
const { ToolPriceSettings } = await import('../tool-price-settings')
|
||||
|
||||
const i18n = createInstance()
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
'Price ($/1K calls)': 'Price ($/1K calls)',
|
||||
'Please enter a valid number': 'Please enter a valid number',
|
||||
'Tool identifier': 'Tool identifier',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const reactTestGlobals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean
|
||||
}
|
||||
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
function changeInputValue(input: HTMLInputElement, value: string) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
describe('tool price validation', () => {
|
||||
after(() => {
|
||||
domWindow.close()
|
||||
})
|
||||
|
||||
test('blocks an empty price without converting it to an explicit zero', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ToolPriceSettings defaultValue='{"web_search":10}' />
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
const priceInput = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Price ($/1K calls): web_search"]'
|
||||
)
|
||||
assert.ok(priceInput)
|
||||
|
||||
await act(async () => {
|
||||
changeInputValue(priceInput, '')
|
||||
})
|
||||
|
||||
assert.equal(priceInput.getAttribute('aria-invalid'), 'true')
|
||||
assert.equal(
|
||||
priceInput.closest('[data-slot="field"]')?.querySelector('[role="alert"]')
|
||||
?.textContent,
|
||||
'Please enter a valid number'
|
||||
)
|
||||
const saveButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Save tool prices'
|
||||
)
|
||||
assert.ok(saveButton)
|
||||
assert.equal(saveButton.disabled, true)
|
||||
|
||||
await act(async () => {
|
||||
changeInputValue(priceInput, '0')
|
||||
})
|
||||
|
||||
assert.equal(priceInput.getAttribute('aria-invalid'), 'false')
|
||||
assert.equal(saveButton.disabled, false)
|
||||
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
queryClient.clear()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { StaticDataTable } from '@/components/data-table'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
@@ -40,12 +41,20 @@ const DEFAULT_PRICES: Record<string, number> = {
|
||||
'web_search_preview:gpt-4.1-mini*': 25.0,
|
||||
file_search: 2.5,
|
||||
google_search: 14.0,
|
||||
image_generation: 150.0,
|
||||
}
|
||||
|
||||
type ToolPriceRow = {
|
||||
id: number
|
||||
key: string
|
||||
price: number
|
||||
price: string
|
||||
}
|
||||
|
||||
function parseToolPrice(value: string): number | null {
|
||||
if (value.trim() === '') return null
|
||||
const price = Number(value)
|
||||
if (!Number.isFinite(price) || price < 0) return null
|
||||
return price
|
||||
}
|
||||
|
||||
function rowsToObject(rows: ToolPriceRow[]): Record<string, number> {
|
||||
@@ -53,7 +62,9 @@ function rowsToObject(rows: ToolPriceRow[]): Record<string, number> {
|
||||
for (const row of rows) {
|
||||
const k = row.key.trim()
|
||||
if (!k) continue
|
||||
prices[k] = Number(row.price) || 0
|
||||
const price = parseToolPrice(row.price)
|
||||
if (price === null) continue
|
||||
prices[k] = price
|
||||
}
|
||||
return prices
|
||||
}
|
||||
@@ -62,7 +73,7 @@ function objectToRows(prices: Record<string, number>): ToolPriceRow[] {
|
||||
return Object.entries(prices).map(([key, price], index) => ({
|
||||
id: index + 1,
|
||||
key,
|
||||
price: Number(price) || 0,
|
||||
price: String(price),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -78,7 +89,18 @@ function parseInitialPrices(
|
||||
!Array.isArray(parsed) &&
|
||||
Object.keys(parsed as object).length > 0
|
||||
) {
|
||||
return parsed as Record<string, number>
|
||||
const validPrices: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
validPrices[key] = value
|
||||
}
|
||||
}
|
||||
// Merge defaults first so newly introduced tools appear for old stored
|
||||
// configs, while explicit stored values (including 0) still win.
|
||||
return {
|
||||
...DEFAULT_PRICES,
|
||||
...validPrices,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to defaults
|
||||
@@ -111,6 +133,15 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
}, [defaultValue])
|
||||
|
||||
const currentPrices = useMemo(() => rowsToObject(rows), [rows])
|
||||
const invalidRowIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
rows
|
||||
.filter((row) => parseToolPrice(row.price) === null)
|
||||
.map((row) => row.id)
|
||||
),
|
||||
[rows]
|
||||
)
|
||||
|
||||
const syncFromRows = useCallback((nextRows: ToolPriceRow[]) => {
|
||||
setRows(nextRows)
|
||||
@@ -127,7 +158,19 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
setJsonError(t('JSON must be an object'))
|
||||
return
|
||||
}
|
||||
const nextRows = objectToRows(parsed as Record<string, number>)
|
||||
const prices: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
value < 0
|
||||
) {
|
||||
setJsonError(t('Please enter a valid number'))
|
||||
return
|
||||
}
|
||||
prices[key] = value
|
||||
}
|
||||
const nextRows = objectToRows(prices)
|
||||
setRows(nextRows)
|
||||
setNextRowId(nextRows.length + 1)
|
||||
setJsonError('')
|
||||
@@ -139,7 +182,7 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
)
|
||||
|
||||
const updateRow = useCallback(
|
||||
(id: number, field: 'key' | 'price', value: string | number) => {
|
||||
(id: number, field: 'key' | 'price', value: string) => {
|
||||
syncFromRows(
|
||||
rows.map((r) => (r.id === id ? { ...r, [field]: value } : r))
|
||||
)
|
||||
@@ -148,7 +191,7 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
)
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
const newRow: ToolPriceRow = { id: nextRowId, key: '', price: 0 }
|
||||
const newRow: ToolPriceRow = { id: nextRowId, key: '', price: '0' }
|
||||
setNextRowId((prev) => prev + 1)
|
||||
syncFromRows([...rows, newRow])
|
||||
}, [nextRowId, rows, syncFromRows])
|
||||
@@ -178,6 +221,10 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
}, [jsonText, t])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (invalidRowIds.size > 0) {
|
||||
toast.error(t('Please enter a valid number'))
|
||||
return
|
||||
}
|
||||
if (editMode === 'json' && jsonError) {
|
||||
toast.error(t('Please fix JSON errors before saving'))
|
||||
return
|
||||
@@ -186,7 +233,7 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
key: OPTION_KEY,
|
||||
value: JSON.stringify(currentPrices),
|
||||
})
|
||||
}, [currentPrices, editMode, jsonError, t, updateOption])
|
||||
}, [currentPrices, editMode, invalidRowIds.size, jsonError, t, updateOption])
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
||||
@@ -276,17 +323,29 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
id: 'price',
|
||||
header: t('Price ($/1K calls)'),
|
||||
className: 'w-[200px]',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'price', Number(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: (row) => {
|
||||
const isInvalid = invalidRowIds.has(row.id)
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
aria-invalid={isInvalid}
|
||||
aria-label={`${t('Price ($/1K calls)')}: ${row.key || t('Tool identifier')}`}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'price', e.target.value)
|
||||
}
|
||||
/>
|
||||
{isInvalid ? (
|
||||
<FieldError>
|
||||
{t('Please enter a valid number')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -322,7 +381,9 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
updateOption.isPending || (editMode === 'json' && !!jsonError)
|
||||
updateOption.isPending ||
|
||||
invalidRowIds.size > 0 ||
|
||||
(editMode === 'json' && !!jsonError)
|
||||
}
|
||||
>
|
||||
{t('Save tool prices')}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
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'
|
||||
import type React from 'react'
|
||||
|
||||
const domWindow = new Window()
|
||||
const domGlobals = [
|
||||
'window',
|
||||
'document',
|
||||
'navigator',
|
||||
'HTMLElement',
|
||||
'SVGElement',
|
||||
'Node',
|
||||
'Element',
|
||||
'Event',
|
||||
'CustomEvent',
|
||||
'MutationObserver',
|
||||
'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 i18n = createInstance()
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
Subscription: 'Subscription',
|
||||
'Deducted by subscription': 'Deducted by subscription',
|
||||
'Includes tool-call surcharge': 'Includes tool-call surcharge',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { LogCostDisplay } = await import('../log-cost-display')
|
||||
const { formatLogQuota } = await import('@/lib/format')
|
||||
const reactTestGlobals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean
|
||||
}
|
||||
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type RenderedCost = {
|
||||
container: HTMLDivElement
|
||||
root: ReturnType<typeof createRoot>
|
||||
}
|
||||
|
||||
async function renderCost(
|
||||
props: React.ComponentProps<typeof LogCostDisplay>
|
||||
): Promise<RenderedCost> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LogCostDisplay {...props} />
|
||||
</I18nextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
async function unmountCost(rendered: RenderedCost) {
|
||||
await act(async () => rendered.root.unmount())
|
||||
rendered.container.remove()
|
||||
}
|
||||
|
||||
function normalizedText(value: string | null): string {
|
||||
return (value ?? '').replaceAll(/\s/g, '')
|
||||
}
|
||||
|
||||
describe('log cost display', () => {
|
||||
after(() => {
|
||||
domWindow.close()
|
||||
})
|
||||
|
||||
test('keeps the regular cost visible and adds an accessible surcharge marker', async () => {
|
||||
const rendered = await renderCost({
|
||||
quota: 12500,
|
||||
other: {
|
||||
tool_surcharges: [{ name: 'lookup_customer', count: 1, price: 5 }],
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
normalizedText(rendered.container.textContent).includes(
|
||||
normalizedText(formatLogQuota(12500))
|
||||
),
|
||||
true
|
||||
)
|
||||
const marker = rendered.container.querySelector(
|
||||
'[data-tool-surcharge-indicator="true"]'
|
||||
)
|
||||
assert.ok(marker)
|
||||
assert.equal(
|
||||
marker.getAttribute('aria-label'),
|
||||
'Includes tool-call surcharge'
|
||||
)
|
||||
assert.equal(marker.getAttribute('tabindex'), '0')
|
||||
|
||||
await unmountCost(rendered)
|
||||
})
|
||||
|
||||
test('preserves the subscription badge and adds the same legacy surcharge marker', async () => {
|
||||
const rendered = await renderCost({
|
||||
quota: 5000,
|
||||
other: {
|
||||
billing_source: 'subscription',
|
||||
web_search: true,
|
||||
web_search_call_count: 1,
|
||||
web_search_price: 10,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(rendered.container.textContent?.includes('Subscription'), true)
|
||||
assert.ok(
|
||||
rendered.container.querySelector('[data-tool-surcharge-indicator="true"]')
|
||||
)
|
||||
|
||||
await unmountCost(rendered)
|
||||
})
|
||||
})
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from '../../lib/utils'
|
||||
import type { LogOtherData } from '../../types'
|
||||
import { DetailsDialog } from '../dialogs/details-dialog'
|
||||
import { LogCostDisplay } from '../log-cost-display'
|
||||
import { ModelBadge } from '../model-badge'
|
||||
import { TimingMetricsCell, StreamTpsCell } from '../timing-metrics-cell'
|
||||
import { useUsageLogsContext } from '../usage-logs-provider'
|
||||
@@ -93,12 +94,6 @@ function getGroupRatio(other: LogOtherData | null): number | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
|
||||
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
|
||||
if (!match) return { prefix: '', amount: value }
|
||||
return { prefix: match[1], amount: match[2] }
|
||||
}
|
||||
|
||||
function buildDetailSegments(
|
||||
log: UsageLog,
|
||||
other: LogOtherData | null,
|
||||
@@ -703,46 +698,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
|
||||
const quota = row.getValue('quota') as number
|
||||
const other = parseLogOther(log.other)
|
||||
const isSubscription = other?.billing_source === 'subscription'
|
||||
|
||||
if (isSubscription) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={t('Subscription')}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<span>
|
||||
{t('Deducted by subscription')}: {formatLogQuota(quota)}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const quotaStr = formatLogQuota(quota)
|
||||
const quotaDisplay = splitQuotaDisplay(quotaStr)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 [font-family:var(--font-body)] text-sm leading-none font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix && (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
)}
|
||||
<span>{quotaDisplay.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return <LogCostDisplay quota={quota} other={other} />
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
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 { Wrench01Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatLogQuota } from '@/lib/format'
|
||||
|
||||
import { hasToolSurcharge } from '../lib/format'
|
||||
import type { LogOtherData } from '../types'
|
||||
|
||||
interface LogCostDisplayProps {
|
||||
quota: number
|
||||
other: LogOtherData | null
|
||||
}
|
||||
|
||||
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
|
||||
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
|
||||
if (!match) return { prefix: '', amount: value }
|
||||
return { prefix: match[1], amount: match[2] }
|
||||
}
|
||||
|
||||
function ToolSurchargeMarker() {
|
||||
const { t } = useTranslation()
|
||||
const label = t('Includes tool-call surcharge')
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge
|
||||
variant='warning'
|
||||
className='h-5 min-w-5 cursor-help gap-0 rounded-full px-1'
|
||||
role='img'
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
data-tool-surcharge-indicator='true'
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Wrench01Icon}
|
||||
strokeWidth={2}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
className='text-[9px] leading-none font-bold'
|
||||
aria-hidden='true'
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function QuotaBadge(props: { quota: number }) {
|
||||
const quotaDisplay = splitQuotaDisplay(formatLogQuota(props.quota))
|
||||
|
||||
return (
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 [font-family:var(--font-body)] text-sm leading-none font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix ? (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
) : null}
|
||||
<span>{quotaDisplay.amount}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SubscriptionBadge(props: { quota: number }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={t('Subscription')}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<span>
|
||||
{t('Deducted by subscription')}: {formatLogQuota(props.quota)}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogCostDisplay(props: LogCostDisplayProps) {
|
||||
const isSubscription = props.other?.billing_source === 'subscription'
|
||||
const showToolSurcharge = hasToolSurcharge(props.other)
|
||||
|
||||
if (!isSubscription && !showToolSurcharge) {
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<QuotaBadge quota={props.quota} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className='inline-flex items-center gap-1'>
|
||||
{isSubscription ? (
|
||||
<SubscriptionBadge quota={props.quota} />
|
||||
) : (
|
||||
<QuotaBadge quota={props.quota} />
|
||||
)}
|
||||
{showToolSurcharge ? <ToolSurchargeMarker /> : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
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 { LogOtherData } from '../../types'
|
||||
import { hasToolSurcharge } from '../format'
|
||||
|
||||
describe('tool surcharge detection', () => {
|
||||
test('shows the marker for a charged structured tool surcharge', () => {
|
||||
assert.equal(
|
||||
hasToolSurcharge({
|
||||
tool_surcharges: [{ name: 'lookup_customer', count: 2, price: 5 }],
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
const legacyCases: Array<{
|
||||
name: string
|
||||
other: LogOtherData
|
||||
}> = [
|
||||
{
|
||||
name: 'Web Search',
|
||||
other: {
|
||||
web_search: true,
|
||||
web_search_call_count: 1,
|
||||
web_search_price: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'File Search',
|
||||
other: {
|
||||
file_search: true,
|
||||
file_search_call_count: 2,
|
||||
file_search_price: 2.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Image Generation',
|
||||
other: {
|
||||
image_generation_call: true,
|
||||
image_generation_call_price: 0.04,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of legacyCases) {
|
||||
test(`keeps the marker visible for legacy ${scenario.name} charges`, () => {
|
||||
assert.equal(hasToolSurcharge(scenario.other), true)
|
||||
})
|
||||
}
|
||||
|
||||
test('hides the marker when surcharge entries are empty or not chargeable', () => {
|
||||
const invalidCases: Array<LogOtherData | null> = [
|
||||
null,
|
||||
{},
|
||||
{ tool_surcharges: [] },
|
||||
{
|
||||
tool_surcharges: [{ name: 'lookup_customer', count: 0, price: 5 }],
|
||||
},
|
||||
{
|
||||
tool_surcharges: [{ name: 'lookup_customer', count: 1, price: 0 }],
|
||||
},
|
||||
{
|
||||
tool_surcharges: [{ name: ' ', count: 1, price: 5 }],
|
||||
},
|
||||
{
|
||||
web_search: true,
|
||||
web_search_call_count: 1,
|
||||
web_search_price: 0,
|
||||
},
|
||||
{
|
||||
image_generation_call: false,
|
||||
image_generation_call_price: 0.04,
|
||||
},
|
||||
]
|
||||
|
||||
for (const other of invalidCases) {
|
||||
assert.equal(hasToolSurcharge(other), false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -92,6 +92,67 @@ export function isViolationFeeLog(other: LogOtherData | null): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function hasLegacySearchSurcharge(
|
||||
enabled: boolean | undefined,
|
||||
count: number | undefined,
|
||||
price: number | undefined
|
||||
): boolean {
|
||||
return (
|
||||
enabled === true &&
|
||||
isPositiveFiniteNumber(count) &&
|
||||
isPositiveFiniteNumber(price)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a consume log includes an actual tool-call surcharge.
|
||||
* Structured surcharge items cover current logs, while the legacy fields keep
|
||||
* historical Web Search, File Search, and Image Generation logs visible.
|
||||
*/
|
||||
export function hasToolSurcharge(other: LogOtherData | null): boolean {
|
||||
if (!other) return false
|
||||
|
||||
const hasStructuredSurcharge =
|
||||
Array.isArray(other.tool_surcharges) &&
|
||||
other.tool_surcharges.some(
|
||||
(item) =>
|
||||
typeof item?.name === 'string' &&
|
||||
item.name.trim() !== '' &&
|
||||
isPositiveFiniteNumber(item.count) &&
|
||||
isPositiveFiniteNumber(item.price)
|
||||
)
|
||||
if (hasStructuredSurcharge) return true
|
||||
|
||||
if (
|
||||
hasLegacySearchSurcharge(
|
||||
other.web_search,
|
||||
other.web_search_call_count,
|
||||
other.web_search_price
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
hasLegacySearchSurcharge(
|
||||
other.file_search,
|
||||
other.file_search_call_count,
|
||||
other.file_search_price
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
other.image_generation_call === true &&
|
||||
isPositiveFiniteNumber(other.image_generation_call_price)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 'other' field from JSON string to object
|
||||
*/
|
||||
|
||||
@@ -106,6 +106,12 @@ export const USAGE_BILLING_PATH = {
|
||||
export type UsageBillingPath =
|
||||
(typeof USAGE_BILLING_PATH)[keyof typeof USAGE_BILLING_PATH]
|
||||
|
||||
export interface ToolSurchargeItem {
|
||||
name: string
|
||||
count: number
|
||||
price: number
|
||||
}
|
||||
|
||||
export interface LogOtherData {
|
||||
admin_info?: {
|
||||
is_multi_key?: boolean
|
||||
@@ -197,11 +203,13 @@ export interface LogOtherData {
|
||||
file_search?: boolean
|
||||
file_search_call_count?: number
|
||||
file_search_price?: number
|
||||
tool_surcharges?: ToolSurchargeItem[]
|
||||
audio_input_seperate_price?: boolean
|
||||
audio_input_token_count?: number
|
||||
audio_input_price?: number
|
||||
image_generation_call?: boolean
|
||||
image_generation_call_price?: number
|
||||
image_generation_call_count?: number
|
||||
is_system_prompt_overwritten?: boolean
|
||||
po?: string[]
|
||||
billing_source?: string
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "Include Model",
|
||||
"Include Rule Name": "Include Rule Name",
|
||||
"Includes request rules": "Includes request rules",
|
||||
"Includes tool-call surcharge": "Includes tool-call surcharge",
|
||||
"Including failed requests, 0 = unlimited": "Including failed requests, 0 = unlimited",
|
||||
"Incoming path": "Incoming path",
|
||||
"Incoming path is required": "Incoming path is required",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "Inclure le modèle",
|
||||
"Include Rule Name": "Inclure le nom de la règle",
|
||||
"Includes request rules": "Inclut des règles de requête",
|
||||
"Includes tool-call surcharge": "Inclut un supplément pour appel d’outil",
|
||||
"Including failed requests, 0 = unlimited": "Y compris les requêtes échouées, 0 = illimité",
|
||||
"Incoming path": "Chemin entrant",
|
||||
"Incoming path is required": "Le chemin entrant est requis",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "モデルを含む",
|
||||
"Include Rule Name": "ルール名を含む",
|
||||
"Includes request rules": "リクエストルールを含む",
|
||||
"Includes tool-call surcharge": "ツール呼び出しの追加料金を含む",
|
||||
"Including failed requests, 0 = unlimited": "失敗したリクエストを含む、0 = 無制限",
|
||||
"Incoming path": "受信パス",
|
||||
"Incoming path is required": "受信パスは必須です",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "Включить модель",
|
||||
"Include Rule Name": "Включить имя правила",
|
||||
"Includes request rules": "Включает правила запросов",
|
||||
"Includes tool-call surcharge": "Включает доплату за вызов инструмента",
|
||||
"Including failed requests, 0 = unlimited": "Включая неудачные запросы, 0 = без ограничений",
|
||||
"Incoming path": "Входящий путь",
|
||||
"Incoming path is required": "Входящий путь обязателен",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "Bao gồm mô hình",
|
||||
"Include Rule Name": "Bao gồm tên quy tắc",
|
||||
"Includes request rules": "Bao gồm quy tắc yêu cầu",
|
||||
"Includes tool-call surcharge": "Bao gồm phụ phí gọi công cụ",
|
||||
"Including failed requests, 0 = unlimited": "Bao gồm các yêu cầu thất bại, 0 = không giới hạn",
|
||||
"Incoming path": "Path đầu vào",
|
||||
"Incoming path is required": "Bắt buộc path đầu vào",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "包含模型",
|
||||
"Include Rule Name": "包含規則名",
|
||||
"Includes request rules": "包含請求規則",
|
||||
"Includes tool-call surcharge": "包含工具呼叫附加費",
|
||||
"Including failed requests, 0 = unlimited": "包括失敗的請求,0 = 無限制",
|
||||
"Incoming path": "入口路徑",
|
||||
"Incoming path is required": "入口路徑不能為空",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"Include Model": "包含模型",
|
||||
"Include Rule Name": "包含规则名",
|
||||
"Includes request rules": "包含请求规则",
|
||||
"Includes tool-call surcharge": "包含工具调用附加费",
|
||||
"Including failed requests, 0 = unlimited": "包括失败的请求,0 = 无限制",
|
||||
"Incoming path": "入口路径",
|
||||
"Incoming path is required": "入口路径不能为空",
|
||||
|
||||
@@ -26,6 +26,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
* - Size parameter: getLobeIcon("OpenAI", 20)
|
||||
*/
|
||||
import * as LobeIcons from '@lobehub/icons'
|
||||
import type React from 'react'
|
||||
|
||||
import { IconSub2api } from '@/assets/custom/icon-sub2api'
|
||||
|
||||
const CUSTOM_ICONS: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||
Sub2API: IconSub2api,
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a property value from string to appropriate type
|
||||
@@ -102,6 +109,10 @@ export function getLobeIcon(
|
||||
// Parse component path and chained properties
|
||||
const segments = trimmedName.split('.')
|
||||
const baseKey = segments[0]
|
||||
const CustomIcon = CUSTOM_ICONS[baseKey]
|
||||
if (CustomIcon) {
|
||||
return <CustomIcon size={size} />
|
||||
}
|
||||
const BaseIcon = (LobeIcons as Record<string, unknown>)[baseKey] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
Reference in New Issue
Block a user