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