From 2d72692e14fd362b896a2221283c0e55b813d753 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 20 Sep 2026 10:17:55 +0800 Subject: [PATCH 01/11] update --- .../components/dynamic-pricing-breakdown.tsx | 2 +- .../components/model-details.runtime.test.tsx | 244 ++++++++++++ .../pricing/components/model-details.tsx | 10 +- .../pricing/components/model-tier-pricing.tsx | 370 ++++++++++++++++++ .../lib/tier-condition-display.test.ts | 174 ++++++++ .../pricing/lib/tier-condition-display.ts | 164 ++++++++ .../models/billing-time-help.tsx | 85 ++++ .../tiered-pricing-editor.runtime.test.tsx | 194 +++++++++ .../models/tiered-pricing-request-rules.tsx | 14 +- .../models/tiered-pricing-visual-editor.tsx | 20 +- web/default/src/i18n/locales/en.json | 21 + web/default/src/i18n/locales/fr.json | 21 + web/default/src/i18n/locales/ja.json | 21 + web/default/src/i18n/locales/ru.json | 21 + web/default/src/i18n/locales/vi.json | 21 + web/default/src/i18n/locales/zh.json | 23 +- 16 files changed, 1398 insertions(+), 7 deletions(-) create mode 100644 web/default/src/features/pricing/components/model-tier-pricing.tsx create mode 100644 web/default/src/features/pricing/lib/tier-condition-display.test.ts create mode 100644 web/default/src/features/pricing/lib/tier-condition-display.ts create mode 100644 web/default/src/features/system-settings/models/billing-time-help.tsx diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index 366e1098..1eaccc98 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -81,7 +81,7 @@ const TIME_FUNC_LABELS: Record = { hour: 'Hour', minute: 'Minute', weekday: 'Weekday', - month: 'Month', + month: 'Month number', day: 'Day', } diff --git a/web/default/src/features/pricing/components/model-details.runtime.test.tsx b/web/default/src/features/pricing/components/model-details.runtime.test.tsx index 4be1239a..36a5b807 100644 --- a/web/default/src/features/pricing/components/model-details.runtime.test.tsx +++ b/web/default/src/features/pricing/components/model-details.runtime.test.tsx @@ -20,10 +20,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' import { within } from '@testing-library/react' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { after, before, describe, test } from 'node:test' +import { useSystemConfigStore } from '@/stores/system-config-store' import type { PricingModel } from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsContent } from './model-details' +import { ModelTierPricing } from './model-tier-pricing' const testEnv = createReactTestEnvironment() @@ -31,6 +34,247 @@ before(() => testEnv.setup()) after(() => testEnv.teardown()) +describe('Model square expression pricing', () => { + const displayOptions = { + tokenUnit: 'M' as const, + priceRate: 1, + usdExchangeRate: 1, + showRechargePrice: false, + } + const expression = + '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 && weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") < 6) || (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18 && weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") < 6) ? tier("peak", p * 2 + c * 8 + cr * 0.04) : tier("off", p * 1 + c * 4 + cr * 0)' + + for (const locale of ['en', 'zh', 'fr', 'ja', 'ru', 'vi']) { + test(`provides model square display translations in ${locale}`, () => { + const resource = JSON.parse( + readFileSync( + new URL(`../../../i18n/locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ).translation + for (const key of [ + 'Tier applicability', + 'Tiers are checked in order; the first match applies.', + 'When no earlier tier matches', + 'All conditions in a group must match.', + 'These conditions cannot match together.', + 'OR', + 'Base tier prices, before group and request multipliers. A dash means no separate coefficient in this tier.', + ]) { + assert.ok(resource[key]?.trim(), `${locale}: ${key}`) + assert.doesNotMatch(resource[key], /\?{2,}/, `${locale}: ${key}`) + if (locale !== 'en') assert.notEqual(resource[key], key) + } + }) + } + + test('preserves zero, absent and tiny coefficients and follows model square unit selection', async () => { + const view = await testEnv.render( + + ) + try { + const content = within(view.container) + const table = within(content.getByRole('table')) + assert.deepEqual( + table.getAllByRole('columnheader').map((node) => node.textContent), + ['Tier', 'Input', 'Output', 'Cache Read', 'Cache Write'] + ) + assert.deepEqual( + within(table.getByRole('row', { name: /^free / })) + .getAllByRole('cell') + .map((node) => node.textContent), + ['free', '$0', '$0', '$0', '—'] + ) + assert.deepEqual( + within(table.getByRole('row', { name: /^base / })) + .getAllByRole('cell') + .map((node) => node.textContent), + ['base', '$0.002', '$0.004', '—', '$0.00001'] + ) + assert.match(view.container.textContent || '', /1K tokens/) + } finally { + await view.unmount() + } + }) + + test('keeps unsupported formulas and request multipliers available without partial pricing claims', async () => { + const source = + '(tier("custom", max(p, 1) * 3 + c * 15)) * (param("fast") == true ? 2 : 1)' + const view = await testEnv.render( + + ) + try { + assert.equal(within(view.container).queryByRole('table'), null) + assert.ok( + within(view.container).getByText('Unable to parse structured pricing') + ) + assert.ok(view.container.querySelector('details[open]')) + assert.equal( + view.container.querySelector('details code')?.textContent, + source + ) + assert.ok(within(view.container).getByText('Conditional multipliers')) + } finally { + await view.unmount() + } + }) + + test('uses model square currency and recharge-price settings consistently', async () => { + const previous = useSystemConfigStore.getState().config + useSystemConfigStore.setState({ + config: { + ...previous, + currency: { + ...previous.currency, + quotaDisplayType: 'CNY', + usdExchangeRate: 7, + }, + }, + }) + try { + for (const [showRechargePrice, expected] of [ + [false, '¥14'], + [true, '¥7'], + ] as const) { + const view = await testEnv.render( + + ) + try { + const row = within(view.container).getByRole('row', { + name: /^base /, + }) + assert.equal( + within(row).getAllByRole('cell')[1].textContent, + expected + ) + } finally { + await view.unmount() + } + } + } finally { + useSystemConfigStore.setState({ config: previous }) + } + }) + + test('shows all request multipliers without disguising an OR time condition as an AND range', async () => { + const source = + '(tier("base", p * 2)) * ((hour("UTC") >= 22 || hour("UTC") < 2) ? 0.5 : 1) * (param("fast") == true ? 2 : 1)' + const view = await testEnv.render( + + ) + try { + const content = within(view.container) + assert.ok(content.getByText('Hour ≥ 22 OR < 2 (UTC)')) + assert.ok(content.getByText('Body param fast = true')) + assert.ok(content.getByText('0.5×')) + assert.ok(content.getByText('2×')) + assert.equal( + view.container.querySelector('details code')?.textContent, + source + ) + } finally { + await view.unmount() + } + }) + + test('warns on impossible time conditions and never invents a matching tier', async () => { + const view = await testEnv.render( + + ) + try { + assert.ok( + within(view.container).getByText( + 'These conditions cannot match together.' + ) + ) + assert.doesNotMatch(view.container.textContent || '', /09:00–12:00/) + assert.equal(within(view.container).queryByText('Matched'), null) + } finally { + await view.unmount() + } + }) + + test('separates prices from conditions in the actual model square without changing the log layout', async () => { + const queryClient = new QueryClient() + const view = await testEnv.render( + + + + ) + try { + const square = within(view.container).getByRole('region', { + name: 'Tiered price table', + }) + const prices = within(square).getByRole('table') + assert.doesNotMatch(prices.textContent || '', /Asia\/Shanghai|&&|\|\|/) + assert.match(prices.textContent || '', /peak/) + const conditions = within(square).getByRole('region', { + name: 'Tier applicability', + }) + assert.match(conditions.textContent || '', /09:00–12:00/) + assert.match(conditions.textContent || '', /14:00–18:00/) + assert.match(conditions.textContent || '', /OR/) + assert.match(conditions.textContent || '', /When no earlier tier matches/) + assert.match(square.textContent || '', /1M tokens/) + assert.equal( + square.querySelector('details code')?.textContent, + expression + ) + } finally { + await view.unmount() + queryClient.clear() + } + + const log = await testEnv.render( + + ) + try { + assert.match( + within(log.container).getByRole('table').textContent || '', + /Asia\/Shanghai/ + ) + assert.equal( + within(log.container).queryByRole('region', { + name: 'Tier applicability', + }), + null + ) + } finally { + await log.unmount() + } + }) +}) + for (const expression of [ 'len < 100 && c == 5 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)', 'len < 100 ? tier("peak", p * 2 + c * 4) : max(p, 1)', diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 1eb7313b..9e4e01fe 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -84,11 +84,11 @@ import type { PricingModel, TokenUnit, } from '../types' -import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' import { ModalityIcons } from './model-details-modalities' import { ModelDetailsPerformance } from './model-details-performance' import { ModelDetailsQuickStats } from './model-details-quick-stats' +import { ModelTierPricing } from './model-tier-pricing' // ---------------------------------------------------------------------------- // Local UI helpers @@ -1389,7 +1389,13 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { /> )} {isDynamic && ( - + )} . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useTranslation } from 'react-i18next' +import { useSystemConfigStore } from '@/stores/system-config-store' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + parseTiersFromExpr, + splitBillingExprAndRequestRules, + tryParseRequestRuleExpr, + type ParsedTier, + type RequestCondition, +} from '../lib/billing-expr' +import { getDynamicPriceEntries } from '../lib/dynamic-price' +import { + type summarizeTierConditionGroup, + summarizeTierConditionAlternatives, + TIER_CONDITION_LABELS, +} from '../lib/tier-condition-display' +import type { TokenUnit } from '../types' + +type ModelTierPricingProps = { + billingExpr: string + tokenUnit: TokenUnit + priceRate: number + usdExchangeRate: number + showRechargePrice: boolean +} + +function describeRequestCondition( + condition: RequestCondition, + t: (key: string) => string +): string { + const comparison: Record = { + eq: '=', + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + } + if (condition.source === 'time') { + const label = t(TIER_CONDITION_LABELS[condition.timeFunc]) + // The shared parser represents this form as an OR, including wraparound. + // Do not infer an AND range merely from the order of its bounds. + const value = + condition.mode === 'range' + ? `≥ ${condition.rangeStart} ${t('OR')} < ${condition.rangeEnd}` + : `${comparison[condition.mode] ?? '='} ${condition.value}` + return `${label} ${value} (${condition.timezone})` + } + const label = `${t(condition.source === 'header' ? 'Header' : 'Body param')} ${condition.path}` + if (condition.mode === 'exists') return `${label} ${t('Exists')}` + if (condition.mode === 'contains') + return `${label} ${t('Contains')} ${condition.value}` + return `${label} ${comparison[condition.mode] ?? '='} ${condition.value}` +} + +function ConditionGroup(props: { + summary: ReturnType +}) { + const { t } = useTranslation() + const summary = props.summary + const timezones = [ + ...new Set( + summary.lines.flatMap((line) => + line.timezone === undefined ? [] : [line.timezone] + ) + ), + ] + return ( +
+
    + {summary.lines.map((line, index) => ( +
  • + {t(TIER_CONDITION_LABELS[line.variable])}{' '} + {line.value} + {timezones.length > 1 && + line.timezone !== undefined && + ` (${line.timezone})`} +
  • + ))} +
+ {timezones.length === 1 && ( +

+ {t('Timezone')}: {timezones[0]} +

+ )} + {summary.impossible && ( +

+ {t('These conditions cannot match together.')} +

+ )} +
+ ) +} + +function TierApplicability(props: { tiers: ParsedTier[] }) { + const { t, i18n } = useTranslation() + return ( +
+
+

{t('Tier applicability')}

+

+ {t('Tiers are checked in order; the first match applies.')} +

+
+
+ {props.tiers.map((tier, index) => { + const groups = + tier.conditionGroups ?? + (tier.conditions.length ? [tier.conditions] : []) + const summaries = summarizeTierConditionAlternatives( + groups, + i18n.language, + t('OR') + ) + return ( + + + + {index + 1} + + {tier.label || t('Default')} + + + + + {groups.length === 0 ? ( +

+ {index === 0 + ? t('Always matches (default tier).') + : t('When no earlier tier matches')} +

+ ) : ( + <> +

+ {t('All conditions in a group must match.')} +

+ {summaries.map((summary, groupIndex) => ( +
+ {groupIndex > 0 && ( + {t('OR')} + )} + +
+ ))} + + )} +
+
+ ) + })} +
+
+ ) +} + +/** Model-square-only presentation; the admin editor and historical logs stay separate. */ +export function ModelTierPricing(props: ModelTierPricingProps) { + const { t } = useTranslation() + // Price formatters read the same config; subscribe so live currency changes rerender. + useSystemConfigStore((state) => state.config.currency) + const split = splitBillingExprAndRequestRules(props.billingExpr) + const tiers = parseTiersFromExpr(split.billingExpr) + const requestGroups = tryParseRequestRuleExpr(split.requestRuleExpr) + const prices = tiers.map((tier) => + getDynamicPriceEntries(tier, { ...props, groupRatioMultiplier: 1 }) + ) + const fields = [ + ...new Map(prices.flat().map((entry) => [entry.field, entry])).values(), + ] + + return ( +
+ {tiers.length > 0 ? ( + <> +
+
+

{t('Tiered price table')}

+ + {t('Prices shown per')} {props.tokenUnit === 'K' ? '1K' : '1M'}{' '} + tokens + +
+
+ 4 && 'min-w-[64rem]' + )} + > + + + + {t('Tier')} + + {fields.map((field) => ( + + {t(field.shortLabel)} + + ))} + + + + {tiers.map((tier, index) => { + const entries = new Map( + prices[index].map((entry) => [entry.field, entry]) + ) + return ( + + + + {tier.label || t('Default')} + + + {fields.map((field) => ( + + {entries.get(field.field)?.formatted ?? '—'} + + ))} + + ) + })} + +
+
+
+ {tiers.map((tier, index) => { + const entries = new Map( + prices[index].map((entry) => [entry.field, entry]) + ) + return ( +
+ + {tier.label || t('Default')} + +
+ {fields.map((field) => ( +
+
+ {t(field.shortLabel)} +
+
+ {entries.get(field.field)?.formatted ?? '—'} +
+
+ ))} +
+
+ ) + })} +
+

+ {t( + 'Base tier prices, before group and request multipliers. A dash means no separate coefficient in this tier.' + )} +

+
+ + + ) : ( +

+ {t('Unable to parse structured pricing')} +

+ )} + {split.requestRuleExpr && ( +
+

+ {t('Conditional multipliers')} +

+

+ {t( + 'When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.' + )} +

+ {requestGroups?.length ? ( +
    + {requestGroups.map((group, index) => ( +
  • +
    +

    + {t('All conditions in a group must match.')} +

    + {group.conditions.map((condition, conditionIndex) => ( +

    + {describeRequestCondition(condition, t)} +

    + ))} +
    + {group.multiplier}× +
  • + ))} +
+ ) : ( + + {split.requestRuleExpr} + + )} +
+ )} +
+ + {t('Raw expression')} + + + {props.billingExpr} + +
+
+ ) +} diff --git a/web/default/src/features/pricing/lib/tier-condition-display.test.ts b/web/default/src/features/pricing/lib/tier-condition-display.test.ts new file mode 100644 index 00000000..d5448312 --- /dev/null +++ b/web/default/src/features/pricing/lib/tier-condition-display.test.ts @@ -0,0 +1,174 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { TierCondition } from './billing-expr' +import { + summarizeTierConditionGroup, + summarizeTierConditionAlternatives, +} from './tier-condition-display' + +function hour( + op: TierCondition['op'], + value: number, + timezone = 'Asia/Shanghai' +): TierCondition { + return { var: 'hour', op, value, timezone } +} + +test('formats hour intervals with exact inclusive and exclusive semantics', () => { + for (const [upperOp, end] of [ + ['<', '12:00'], + ['<=', '13:00'], + ] as const) { + const source = [hour('>=', 9), hour(upperOp, 12)] + const snapshot = structuredClone(source) + assert.deepEqual(summarizeTierConditionGroup(source, 'en'), { + impossible: false, + lines: [ + { variable: 'hour', value: `09:00–${end}`, timezone: 'Asia/Shanghai' }, + ], + }) + assert.deepEqual(source, snapshot) + } + assert.equal( + summarizeTierConditionGroup([hour('>', 9.5), hour('<', 12.5)], 'en') + .lines[0].value, + '10:00–13:00' + ) + assert.equal( + summarizeTierConditionGroup([hour('>=', 22)], 'en').lines[0].value, + '22:00–24:00' + ) +}) + +test('never turns incompatible AND conditions into alternative time windows', () => { + const result = summarizeTierConditionGroup( + [hour('>=', 9), hour('<', 12), hour('>=', 14), hour('<', 18)], + 'en' + ) + assert.equal(result.impossible, true) + assert.equal(result.lines[0].value, '≥ 9 ∧ < 12 ∧ ≥ 14 ∧ < 18') +}) + +test('shares weekday restrictions only when every OR window has identical restrictions', () => { + const weekday: TierCondition = { + var: 'weekday', + op: '>=', + value: 1, + timezone: 'Asia/Shanghai', + } + const groups = [ + [hour('>=', 9), hour('<', 12), weekday], + [hour('>=', 14), hour('<', 18), weekday], + ] + const snapshot = structuredClone(groups) + const summaries = summarizeTierConditionAlternatives(groups, 'zh', '或') + assert.equal(summaries.length, 1) + assert.equal(summaries[0].lines[0].value, '09:00–12:00 或 14:00–18:00') + assert.deepEqual(groups, snapshot) + groups[1][2] = { ...weekday, value: 5 } + assert.equal(summarizeTierConditionAlternatives(groups, 'zh', '或').length, 2) + assert.equal( + summarizeTierConditionAlternatives( + [[hour('>=', 9), hour('<', 3)], groups[0]], + 'en', + 'OR' + ).length, + 2 + ) + assert.equal( + summarizeTierConditionAlternatives( + [[hour('>=', 9, 'UTC')], [hour('>=', 14)]], + 'en', + 'OR' + ).length, + 2 + ) +}) + +test('does not intersect different timezones or drop non-time conditions', () => { + const result = summarizeTierConditionGroup( + [ + hour('>=', 9), + hour('<', 3, 'UTC'), + { var: 'len', op: '<=', value: 200001 }, + ], + 'en' + ) + assert.equal(result.impossible, false) + assert.deepEqual( + result.lines.map((line) => line.value), + ['09:00–24:00', '00:00–03:00', '≤ 200001'] + ) + assert.deepEqual( + result.lines.map((line) => line.timezone), + ['Asia/Shanghai', 'UTC', undefined] + ) +}) + +test('keeps calendar months, month days, weekdays and minutes distinct', () => { + const result = summarizeTierConditionGroup( + [ + { var: 'month', op: '>=', value: 9, timezone: 'UTC' }, + { var: 'day', op: '<=', value: 15, timezone: 'UTC' }, + { var: 'minute', op: '<', value: 30, timezone: 'UTC' }, + { var: 'weekday', op: '>=', value: 1, timezone: 'UTC' }, + { var: 'weekday', op: '<', value: 6, timezone: 'UTC' }, + ], + 'zh' + ) + assert.deepEqual( + result.lines.slice(0, 3).map((line) => line.value), + ['9–12', '1–15', '0–29'] + ) + assert.equal(result.lines[3].value, '周一 · 周二 · 周三 · 周四 · 周五') + assert.equal(result.impossible, false) +}) + +test('all summarized hour ranges agree with the original integer comparisons', () => { + const evaluate = ( + value: number, + op: TierCondition['op'], + boundary: number + ): boolean => { + if (op === '<') return value < boundary + if (op === '<=') return value <= boundary + if (op === '>') return value > boundary + return value >= boundary + } + for (const op of ['<', '<=', '>', '>='] as const) { + for (const boundary of [0, 1, 9, 9.5, 12, 23, 24, 30]) { + const summary = summarizeTierConditionGroup([hour(op, boundary)], 'en') + const range = summary.lines[0].value.match(/^(\d+):00–(\d+):00$/) + for (let value = 0; value < 24; value++) { + const displayedMatch = + !summary.impossible && + range !== null && + value >= Number(range[1]) && + value < Number(range[2]) + assert.equal( + displayedMatch, + evaluate(value, op, boundary), + `${value} ${op} ${boundary}` + ) + } + } + } +}) diff --git a/web/default/src/features/pricing/lib/tier-condition-display.ts b/web/default/src/features/pricing/lib/tier-condition-display.ts new file mode 100644 index 00000000..28c4fbc9 --- /dev/null +++ b/web/default/src/features/pricing/lib/tier-condition-display.ts @@ -0,0 +1,164 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { TierCondition } from './billing-expr' +import { getTierConditionBounds } from './tier-expr' + +export const TIER_CONDITION_LABELS = { + p: 'Input', + c: 'Output', + len: 'Full input length', + hour: 'Hour', + minute: 'Minute', + weekday: 'Weekday', + month: 'Month number', + day: 'Day', +} as const + +type ConditionDisplayLine = { + variable: TierCondition['var'] + value: string + timezone?: string +} + +const operators = { '>=': '≥', '<=': '≤', '>': '>', '<': '<' } + +function matches(value: number, condition: TierCondition): boolean { + switch (condition.op) { + case '>=': + return value >= condition.value + case '>': + return value > condition.value + case '<=': + return value <= condition.value + case '<': + return value < condition.value + } +} + +function weekdayName(day: number, locale: string): string { + const date = new Date(Date.UTC(2024, 0, 7 + day)) // Sunday + weekday index + try { + return new Intl.DateTimeFormat(locale, { + weekday: 'short', + timeZone: 'UTC', + }).format(date) + } catch { + return new Intl.DateTimeFormat('en', { + weekday: 'short', + timeZone: 'UTC', + }).format(date) + } +} + +/** Read-only summaries of an AND group. Never merge groups or rewrite rules. */ +export function summarizeTierConditionGroup( + conditions: TierCondition[], + locale: string +): { + lines: ConditionDisplayLine[] + impossible: boolean +} { + const dimensions = new Map() + for (const condition of conditions) { + const key = JSON.stringify([condition.var, condition.timezone]) + dimensions.set(key, [...(dimensions.get(key) ?? []), condition]) + } + let impossible = false + const lines = Array.from(dimensions.values()).map( + (constraints): ConditionDisplayLine => { + const first = constraints[0] + const rawValue = constraints + .map((condition) => `${operators[condition.op]} ${condition.value}`) + .join(' ∧ ') + const line: ConditionDisplayLine = { + variable: first.var, + value: rawValue, + ...(first.timezone !== undefined ? { timezone: first.timezone } : {}), + } + const bounds = getTierConditionBounds(first.var) + if (!bounds) return line + + // Time helpers return integers. Intersect their bounded domains to preserve + // < versus <=, fractional boundaries and contradictory conditions exactly. + const allowed = Array.from( + { length: bounds.max - bounds.min + 1 }, + (_, i) => bounds.min + i + ).filter((value) => + constraints.every((condition) => matches(value, condition)) + ) + if (!allowed.length) { + impossible = true + return line + } + const start = allowed[0] + const end = allowed[allowed.length - 1] + if (first.var === 'hour') { + line.value = `${String(start).padStart(2, '0')}:00–${String(end + 1).padStart(2, '0')}:00` + } else if (first.var === 'weekday') { + line.value = allowed.map((day) => weekdayName(day, locale)).join(' · ') + } else { + line.value = start === end ? String(start) : `${start}–${end}` + } + return line + } + ) + return { lines, impossible } +} + +/** Factor identical restrictions in alternative hour windows, and nothing else. */ +export function summarizeTierConditionAlternatives( + groups: TierCondition[][], + locale: string, + orLabel: string +): ReturnType[] { + const summaries = groups.map((group) => + summarizeTierConditionGroup(group, locale) + ) + if (summaries.length < 2 || summaries.some((summary) => summary.impossible)) + return summaries + const first = summaries[0] + const hours = first.lines.filter((line) => line.variable === 'hour') + if (hours.length !== 1) return summaries + const common = JSON.stringify( + first.lines.filter((line) => line.variable !== 'hour') + ) + const windows: string[] = [] + for (const summary of summaries) { + const hourLines = summary.lines.filter((line) => line.variable === 'hour') + if ( + hourLines.length !== 1 || + hourLines[0].timezone !== hours[0].timezone || + JSON.stringify( + summary.lines.filter((line) => line.variable !== 'hour') + ) !== common + ) + return summaries + windows.push(hourLines[0].value) + } + return [ + { + impossible: false, + lines: first.lines.map((line) => + line.variable === 'hour' + ? { ...line, value: [...new Set(windows)].join(` ${orLabel} `) } + : line + ), + }, + ] +} diff --git a/web/default/src/features/system-settings/models/billing-time-help.tsx b/web/default/src/features/system-settings/models/billing-time-help.tsx new file mode 100644 index 00000000..32c4552c --- /dev/null +++ b/web/default/src/features/system-settings/models/billing-time-help.tsx @@ -0,0 +1,85 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { ReactElement } from 'react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' +import { FieldDescription } from '@/components/ui/field' +import { TIME_FUNCS, type TimeFunc } from '@/features/pricing/lib/billing-expr' + +function describeTimeValue(timeFunc: TimeFunc, t: TFunction): string { + switch (timeFunc) { + case 'hour': + return t('Hour: 0–23 (24-hour clock); 0 is midnight.') + case 'minute': + return t('Minute: 0–59 within the current hour.') + case 'weekday': + return t( + 'Weekday: 0 = Sunday, 1 = Monday, 2 = Tuesday, 3 = Wednesday, 4 = Thursday, 5 = Friday, 6 = Saturday.' + ) + case 'month': + return t('Month: 1–12; January is 1.') + case 'day': + return t( + 'Day of month: 1–31, depending on the month and leap year; this is not a duration.' + ) + } +} + +type BillingTimeHelpProps = { + id: string + timeFunc?: TimeFunc + mode: 'visual' | 'expression' +} + +export function BillingTimeHelp(props: BillingTimeHelpProps): ReactElement { + const { t } = useTranslation() + const timeFunctions = props.timeFunc ? [props.timeFunc] : TIME_FUNCS + + return ( + + {timeFunctions.map((timeFunc) => ( + + {props.mode === 'expression' && {timeFunc}(tz): } + {describeTimeValue(timeFunc, t)} + + ))} + {timeFunctions.includes('weekday') && ( + + {t( + 'Weekday < 6 includes Sunday through Friday. For Monday–Friday, use >= 1 AND <= 5 in the same condition group.' + )} + + )} + + {t( + 'Time values use the configured timezone. Asia/Shanghai is China Standard Time (UTC+8).' + )} + + + {props.mode === 'visual' + ? t( + 'An empty timezone in this visual editor uses Asia/Shanghai when generating the expression. Unrecognized timezones are evaluated as UTC.' + ) + : t( + 'In expressions, an empty or unrecognized timezone is evaluated as UTC.' + )} + + + ) +} diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx index ff9e6662..2f5c6d18 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx @@ -23,6 +23,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createInstance } from 'i18next' import { JSDOM } from 'jsdom' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { after, before, describe, test } from 'node:test' import { I18nextProvider } from 'react-i18next' import { @@ -39,6 +40,7 @@ let within: typeof import('@testing-library/react').within let ModelPricingEditorPanel: typeof import('./model-pricing-sheet').ModelPricingEditorPanel let ModelRatioVisualEditor: typeof import('./model-ratio-visual-editor').ModelRatioVisualEditor let TieredPricingEditor: typeof import('./tiered-pricing-editor').TieredPricingEditor +let DynamicPricingBreakdown: typeof import('@/features/pricing/components/dynamic-pricing-breakdown').DynamicPricingBreakdown let TieredBillingSettings: typeof import('./tiered-billing-settings').TieredBillingSettings let TaskRateCardSettings: typeof import('./task-rate-card-settings').TaskRateCardSettings let SettingsPageProvider: typeof import('../components/settings-page-context').SettingsPageProvider @@ -865,6 +867,8 @@ before(async () => { ;({ ModelPricingEditorPanel } = await import('./model-pricing-sheet')) ;({ ModelRatioVisualEditor } = await import('./model-ratio-visual-editor')) ;({ TieredPricingEditor } = await import('./tiered-pricing-editor')) + ;({ DynamicPricingBreakdown } = + await import('@/features/pricing/components/dynamic-pricing-breakdown')) ;({ TieredBillingSettings } = await import('./tiered-billing-settings')) ;({ TaskRateCardSettings } = await import('./task-rate-card-settings')) ;({ SettingsPageProvider } = @@ -877,6 +881,196 @@ after(() => { }) describe('TieredPricingEditor runtime behavior', () => { + test('explains weekday values and visual timezone defaults without changing pricing', async (): Promise => { + const container = createContainer() + const root = createRoot(container) + const billingExpr = + 'weekday("Asia/Shanghai") < 6 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + const requestRuleExpr = '(weekday("Asia/Shanghai") == 0 ? 0.8 : 1)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={(next) => changes.push(next)} + /> + + ) + ) + const values = within(container).getAllByLabelText('Condition Value') + assert.deepEqual( + values.map((input) => (input as HTMLInputElement).value), + ['6', '0'] + ) + for (const input of values) { + const descriptionId = input.getAttribute('aria-describedby') + assert.ok(descriptionId) + const description = dom.window.document.getElementById(descriptionId) + assert.ok(description) + assert.match(description.textContent ?? '', /0 = Sunday, 1 = Monday/) + assert.match(description.textContent ?? '', /6 = Saturday/) + assert.match( + description.textContent ?? '', + /< 6 includes Sunday through Friday/ + ) + assert.match(description.textContent ?? '', />= 1 AND <= 5/) + assert.match( + description.textContent ?? '', + /empty timezone in this visual editor uses Asia\/Shanghai/ + ) + } + const timezone = within(container).getByLabelText('Timezone') + assert.equal( + timezone.getAttribute('aria-describedby'), + values[0].getAttribute('aria-describedby') + ) + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + + test('provides all time ranges and expression timezone semantics in raw mode', async (): Promise => { + const container = createContainer() + const root = createRoot(container) + const source = + 'weekday("") == 0 ? tier("sunday", p * 2 + c * 4) : tier("other", p * 1 + c * 2)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={(next) => changes.push(next)} + /> + + ) + ) + const textarea = container.querySelector('textarea') + assert.ok(textarea) + assert.equal(textarea.value, source) + const descriptionId = textarea.getAttribute('aria-describedby') + assert.ok(descriptionId) + const description = dom.window.document.getElementById(descriptionId) + assert.ok(description) + for (const helper of ['hour', 'minute', 'weekday', 'month', 'day']) { + assert.ok(description.textContent?.includes(`${helper}(tz)`)) + } + for (const range of ['0–23', '0–59', '1–12', '1–31']) { + assert.ok(description.textContent?.includes(range)) + } + assert.match( + description.textContent ?? '', + /empty or unrecognized timezone is evaluated as UTC/ + ) + assert.doesNotMatch( + description.textContent ?? '', + /visual editor uses Asia\/Shanghai/ + ) + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + + const conditionLocaleKeys = [ + 'Add condition group', + 'Condition group', + 'Remove condition group', + 'Each tier supports multiple conditions. The last tier without conditions is the fallback.', + 'Month number', + ] + const conditionLocales = Object.fromEntries( + ['en', 'zh', 'fr', 'ja', 'ru', 'vi'].map((locale) => [ + locale, + JSON.parse( + readFileSync( + new URL(`../../../i18n/locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ) as { translation: Record }, + ]) + ) + + for (const [locale, resource] of Object.entries(conditionLocales)) { + test(`provides condition group translations in ${locale}`, () => { + for (const key of conditionLocaleKeys) { + assert.ok(resource.translation[key]?.trim(), `${locale}: ${key}`) + if (locale !== 'en') { + assert.notEqual(resource.translation[key], key, `${locale}: ${key}`) + } + } + }) + } + + test('localizes condition groups and calendar months without changing the expression', async (): Promise => { + const localizedI18n = createInstance() + await localizedI18n.init({ + lng: 'zh', + fallbackLng: 'en', + resources: conditionLocales, + interpolation: { escapeValue: false }, + }) + const container = createContainer() + const root = createRoot(container) + const source = + 'month("Asia/Shanghai") >= 9 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={() => undefined} + /> + + + ) + ) + const editor = within(container) + assert.ok(editor.getAllByRole('button', { name: '新增条件组' }).length) + assert.ok(editor.getByText('条件组 1')) + assert.ok(editor.getByRole('button', { name: '删除条件组' })) + assert.ok( + editor.getByText('每个档位支持多个条件,最后一个无条件档位作为兜底。') + ) + const monthSelector = editor + .getAllByRole('combobox') + .find((element) => element.textContent?.trim() === '月') + assert.ok(monthSelector) + assert.ok(editor.getByText('1–12')) + assert.ok(editor.getAllByText('月 ≥ 9 (Asia/Shanghai)').length) + assert.equal(editor.queryByText('本月'), null) + for (const key of conditionLocaleKeys.slice(0, 4)) { + assert.equal(container.textContent?.includes(key), false) + } + // The generic key can still describe a current-month dashboard filter. + assert.equal(localizedI18n.t('Month'), '本月') + assert.deepEqual(changes, []) + + await act(async () => { + await localizedI18n.changeLanguage('en') + }) + assert.ok( + editor.getAllByRole('button', { name: 'Add condition group' }).length + ) + assert.equal(monthSelector.textContent?.trim(), 'Month number') + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + test('keeps an empty timezone editable and applies the default only to the expression', async (): Promise => { const container = createContainer() const root = createRoot(container) diff --git a/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx b/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx index 29408fc2..f5be180f 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx @@ -57,6 +57,7 @@ import { type TimeCondition, type TimeFunc, } from '@/features/pricing/lib/billing-expr' +import { BillingTimeHelp } from './billing-time-help' import { DraftNumberInput } from './tiered-pricing-fields' // --------------------------------------------------------------------------- @@ -75,6 +76,7 @@ function RuleConditionRow({ onRemove, }: RuleConditionRowProps) { const { t } = useTranslation() + const timeHelpId = useId() const matchOptions = getRequestRuleMatchOptions(condition.source) const getMatchLabel = (mode: string) => { switch (mode) { @@ -175,7 +177,7 @@ function RuleConditionRow({ value !== null && onChange({ ...timeCond, timezone: value }) } > - + {COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone) ?.label ?? timeCond.timezone} @@ -222,6 +224,7 @@ function RuleConditionRow({ onChange({ ...timeCond, rangeStart: String(value) }) } aria-label={t('Start')} + aria-describedby={timeHelpId} placeholder={t('Start')} className='w-20' /> @@ -232,6 +235,7 @@ function RuleConditionRow({ onChange({ ...timeCond, rangeEnd: String(value) }) } aria-label={t('End')} + aria-describedby={timeHelpId} placeholder={t('End')} className='w-20' /> @@ -243,6 +247,7 @@ function RuleConditionRow({ onChange({ ...timeCond, value: String(value) }) } aria-label={t('Condition Value')} + aria-describedby={timeHelpId} placeholder={t('Value')} className='w-24' /> @@ -333,6 +338,13 @@ function RuleConditionRow({ > + {condition.source === SOURCE_TIME && ( + + )} ) } diff --git a/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx b/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx index 43299e33..a996078a 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx @@ -38,6 +38,7 @@ import { Textarea } from '@/components/ui/textarea' import { BILLING_EXTRA_VARS, type BillingVar, + type TimeFunc, } from '@/features/pricing/lib/billing-expr' import { CACHE_MODE_GENERIC, @@ -53,6 +54,7 @@ import { normalizeVisualConfig, normalizeVisualTier, } from '@/features/pricing/lib/tier-expr' +import { BillingTimeHelp } from './billing-time-help' import { DraftNumberInput } from './tiered-pricing-fields' import { formatTokenHint, @@ -78,8 +80,8 @@ const CONDITION_INPUT_OPTIONS: { { value: 'hour', labelKey: 'Hour' }, { value: 'minute', labelKey: 'Minute' }, { value: 'weekday', labelKey: 'Weekday' }, - { value: 'month', labelKey: 'Month' }, - { value: 'day', labelKey: 'Day' }, + { value: 'month', labelKey: 'Month number' }, + { value: 'day', labelKey: 'Day of month' }, ] const OPS: TierConditionInput['op'][] = ['<', '<=', '>', '>='] @@ -101,6 +103,7 @@ function ConditionRow({ removeDisabled, }: ConditionRowProps) { const { t } = useTranslation() + const timeHelpId = useId() const currentInputOption = CONDITION_INPUT_OPTIONS.find( (option) => option.value === condition.var ) @@ -169,6 +172,7 @@ function ConditionRow({ onChange({ ...condition, timezone: event.target.value }) } aria-label={t('Timezone')} + aria-describedby={timeHelpId} placeholder='Asia/Shanghai' className='w-40' /> @@ -207,6 +211,7 @@ function ConditionRow({ }) } aria-label={t('Condition Value')} + aria-describedby={isTimeCondition ? timeHelpId : undefined} placeholder={ timeBounds ? `${timeBounds.min}-${timeBounds.max}` : t('tokens') } @@ -228,6 +233,13 @@ function ConditionRow({ > + {isTimeCondition && ( + + )} ) } @@ -792,6 +804,7 @@ type RawExprEditorProps = { export function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { const { t } = useTranslation() + const timeHelpId = useId() return (
@@ -808,9 +821,12 @@ export function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { abs, header(name),{' '} param(path), has(source, text)
+
{t('Time condition reference')}
+