diff --git a/apps/web/src/features/monitoring/ModelPricesPage.module.scss b/apps/web/src/features/monitoring/ModelPricesPage.module.scss index 938cde077..1af6a5c7a 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.module.scss +++ b/apps/web/src/features/monitoring/ModelPricesPage.module.scss @@ -47,16 +47,6 @@ min-width: 0; } -.tierClearNotice { - grid-column: 1 / -1; - padding: 7px 9px; - border: 1px solid color-mix(in srgb, var(--warning-color) 35%, var(--pricing-line)); - border-radius: 10px; - background: color-mix(in srgb, var(--warning-color) 8%, var(--pricing-surface)); - color: var(--pricing-muted); - font-size: 11px; -} - .titleGroup { flex-wrap: nowrap; } @@ -255,6 +245,96 @@ padding-bottom: 1px; } +.pricingRulesEditor { + display: grid; + grid-column: 1 / -1; + gap: 8px; + min-width: 0; +} + +.ruleSection { + display: grid; + gap: 8px; + min-width: 0; + padding: 8px; + border: 1px solid var(--pricing-line); + border-radius: 10px; + background: color-mix(in srgb, var(--pricing-surface) 72%, var(--pricing-surface-muted)); +} + +.ruleSectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ruleSectionHeader > div { + display: grid; + gap: 2px; +} + +.ruleSectionHeader strong { + font-size: 12px; +} + +.ruleSectionHeader span, +.ruleEmpty { + color: var(--pricing-muted); + font-size: 11px; +} + +.ruleSectionHeader :global(.btn > span) { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.ruleList { + display: grid; + gap: 7px; + min-width: 0; + overflow-x: auto; +} + +.contextRuleRow, +.serviceRuleRow { + display: grid; + align-items: end; + gap: 7px; + min-width: 920px; + padding-top: 7px; + border-top: 1px solid color-mix(in srgb, var(--pricing-line) 70%, transparent); +} + +.contextRuleRow { + grid-template-columns: minmax(120px, 1.1fr) repeat(5, minmax(105px, 1fr)) auto; +} + +.serviceRuleRow { + grid-template-columns: repeat(2, minmax(110px, 1.1fr)) repeat(5, minmax(105px, 1fr)) auto; + min-width: 1060px; +} + +.ruleDeleteButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 32px; + padding: 0; + border: 1px solid var(--pricing-line); + border-radius: 9px; + background: var(--pricing-surface); + color: var(--pricing-muted); + cursor: pointer; +} + +.ruleDeleteButton:hover { + border-color: color-mix(in srgb, var(--pricing-red) 35%, var(--pricing-line)); + color: var(--pricing-red); +} + .tableWrap { overflow-x: auto; border: 1px solid var(--pricing-line); @@ -475,4 +555,9 @@ .candidateControl { grid-template-columns: 1fr; } + + .ruleSectionHeader { + align-items: flex-start; + flex-direction: column; + } } diff --git a/apps/web/src/features/monitoring/ModelPricesPage.test.tsx b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx index 8c8fde5f1..9d4cb20f4 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.test.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx @@ -84,9 +84,7 @@ describe('ModelPricesPage Attention UI', () => { acknowledgeSnapshot: vi.fn().mockResolvedValue(undefined), }; - vi.spyOn(attentionHook, 'useModelPriceAttention').mockImplementation( - () => mockAttentionState - ); + vi.spyOn(attentionHook, 'useModelPriceAttention').mockImplementation(() => mockAttentionState); }); it('renders pending count badge on Sync Prices button when pendingCount > 0', async () => { @@ -339,4 +337,98 @@ describe('ModelPricesPage Attention UI', () => { expect(mockAttentionState.check).toHaveBeenCalledWith({ force: true }); }); + + it('preserves special pricing rules and refreshes attention after editing an existing price', async () => { + const setModelPrices = vi.fn().mockResolvedValue(undefined); + vi.mocked(usageDataHook.useUsageData).mockReturnValue({ + loading: false, + modelPrices: { + 'tiered-model': { + prompt: 5, + completion: 25, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 128_000, + prompt: 10, + completion: 0, + cache: 0, + promptConfigured: true, + completionConfigured: false, + cacheConfigured: false, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 0, + completion: 50, + cache: 0, + promptConfigured: true, + completionConfigured: true, + cacheConfigured: false, + }, + ], + }, + }, + setModelPrices, + syncModelPrices: mockSyncModelPrices, + usageServiceAvailable: true, + } as unknown as ReturnType); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + + + + ); + }); + + const root = renderer!.root; + const editButton = root + .findAllByType('button') + .find((button) => button.props['aria-label'] === 'common.edit'); + if (!editButton) throw new Error('edit button not found'); + + await act(async () => { + editButton.props.onClick(); + }); + const inputPrice = root.findByProps({ 'data-testid': 'draft-input-price' }); + await act(async () => { + inputPrice.props.onChange({ target: { value: '6' } }); + }); + + const saveButton = root.findByProps({ 'data-testid': 'save-draft-button' }); + await act(async () => { + saveButton.props.onClick(); + }); + + expect(setModelPrices).toHaveBeenCalledWith({ + 'tiered-model': expect.objectContaining({ + prompt: 6, + source: 'manual', + contextTiers: [ + expect.objectContaining({ + thresholdTokens: 128_000, + prompt: 10, + promptConfigured: true, + completionConfigured: false, + }), + ], + serviceTiers: [ + expect.objectContaining({ + mode: 'fast', + serviceTier: 'priority', + prompt: 0, + completion: 50, + promptConfigured: true, + completionConfigured: true, + }), + ], + }), + }); + expect(mockAttentionState.check).toHaveBeenCalledWith({ force: true }); + }); }); diff --git a/apps/web/src/features/monitoring/ModelPricesPage.tsx b/apps/web/src/features/monitoring/ModelPricesPage.tsx index 517149d2b..99c1b5302 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.tsx @@ -3,7 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { IconPencil, IconSearch, IconTrash2, IconX } from '@/components/ui/icons'; +import { IconPencil, IconPlus, IconSearch, IconTrash2, IconX } from '@/components/ui/icons'; import { usePanelFeatureAvailability } from '@/hooks/usePanelFeatureAvailability'; import { usageServiceApi, @@ -24,7 +24,9 @@ import { buildModelPriceSummary, buildPriceFromDraft, buildSyncPriceModelsFromSummary, + createEmptyContextTierDraft, createEmptyPriceDraft, + createEmptyServiceTierDraft, createPriceDraft, filterModelPriceRows, formatContextThreshold, @@ -34,8 +36,12 @@ import { groupModelPriceCandidatesBySource, resolveContextTierDisplayPrice, resolveServiceTierDisplayPrice, + validatePriceDraft, + type ContextTierDraft, type ModelPriceFilter, type PriceDraft, + type PriceRuleDraft, + type ServiceTierDraft, } from './model/modelPricesPageModel'; import { readModelPricesPageUiState, writeModelPricesPageUiState } from './modelPricesPageUiState'; import { resolveModelPriceSyncNotification } from './model/modelPriceSyncFeedback'; @@ -43,6 +49,22 @@ import styles from './ModelPricesPage.module.scss'; const FILTERS: ModelPriceFilter[] = ['all', 'missing', 'candidates', 'saved']; +const RULE_PRICE_FIELDS = [ + { field: 'prompt', labelKey: 'usage_stats.model_price_prompt' }, + { field: 'completion', labelKey: 'usage_stats.model_price_completion' }, + { field: 'cache', labelKey: 'usage_stats.model_price_cache' }, + { field: 'cacheRead', labelKey: 'usage_stats.model_price_cache_read' }, + { field: 'cacheCreation', labelKey: 'usage_stats.model_price_cache_creation' }, +] as const satisfies readonly { field: keyof PriceRuleDraft; labelKey: string }[]; + +type BasePriceDraftField = + | 'model' + | 'prompt' + | 'completion' + | 'cache' + | 'cacheRead' + | 'cacheCreation'; + const resolveErrorMessage = (error: unknown, fallback: string) => { const rawMessage = error instanceof Error ? error.message : String(error || fallback); return rawMessage === 'model_price_sync_requires_usage_service' @@ -195,6 +217,11 @@ export function ModelPricesPage() { }; const handleSaveDraft = async () => { + const validationError = validatePriceDraft(draft); + if (validationError) { + showNotification(t(`model_prices.rule_error_${validationError}`), 'warning'); + return; + } const price = buildPriceFromDraft(draft); const model = draft.model.trim(); if (!model || !price) { @@ -223,10 +250,56 @@ export function ModelPricesPage() { } }; - const setDraftField = (field: keyof PriceDraft, value: string) => { + const setDraftField = (field: BasePriceDraftField, value: string) => { setDraft((previous) => ({ ...previous, [field]: value })); }; + const addContextTier = () => { + setDraft((previous) => ({ + ...previous, + contextTiers: [...previous.contextTiers, createEmptyContextTierDraft()], + })); + }; + + const updateContextTier = (index: number, field: keyof ContextTierDraft, value: string) => { + setDraft((previous) => ({ + ...previous, + contextTiers: previous.contextTiers.map((tier, tierIndex) => + tierIndex === index ? { ...tier, [field]: value } : tier + ), + })); + }; + + const removeContextTier = (index: number) => { + setDraft((previous) => ({ + ...previous, + contextTiers: previous.contextTiers.filter((_tier, tierIndex) => tierIndex !== index), + })); + }; + + const addServiceTier = () => { + setDraft((previous) => ({ + ...previous, + serviceTiers: [...previous.serviceTiers, createEmptyServiceTierDraft()], + })); + }; + + const updateServiceTier = (index: number, field: keyof ServiceTierDraft, value: string) => { + setDraft((previous) => ({ + ...previous, + serviceTiers: previous.serviceTiers.map((tier, tierIndex) => + tierIndex === index ? { ...tier, [field]: value } : tier + ), + })); + }; + + const removeServiceTier = (index: number) => { + setDraft((previous) => ({ + ...previous, + serviceTiers: previous.serviceTiers.filter((_tier, tierIndex) => tierIndex !== index), + })); + }; + const openManualEditor = (model = '', price = modelPrices[model]) => { setDraft(createPriceDraft(model, price)); setManualEditorOpen(true); @@ -400,21 +473,137 @@ export function ModelPricesPage() { > - - {(modelPrices[draft.model.trim()]?.contextTiers?.length ?? 0) + - (modelPrices[draft.model.trim()]?.serviceTiers?.length ?? 0) > - 0 ? ( -
- {t('model_prices.manual_clears_pricing_rules', { - count: - (modelPrices[draft.model.trim()]?.contextTiers?.length ?? 0) + - (modelPrices[draft.model.trim()]?.serviceTiers?.length ?? 0), - })} -
- ) : null} +
+
+
+
+ {t('model_prices.context_rules')} + {t('model_prices.context_rules_hint')} +
+ +
+ {draft.contextTiers.length === 0 ? ( + {t('model_prices.no_pricing_rules')} + ) : ( +
+ {draft.contextTiers.map((tier, index) => ( +
+ + updateContextTier(index, 'thresholdTokens', event.target.value) + } + placeholder="128000" + /> + {RULE_PRICE_FIELDS.map(({ field, labelKey }) => ( + + updateContextTier(index, field, event.target.value) + } + placeholder={t('model_prices.inherit_price_placeholder')} + /> + ))} + +
+ ))} +
+ )} +
+ +
+
+
+ {t('model_prices.service_tier_rules')} + {t('model_prices.service_tier_rules_hint')} +
+ +
+ {draft.serviceTiers.length === 0 ? ( + {t('model_prices.no_pricing_rules')} + ) : ( +
+ {draft.serviceTiers.map((tier, index) => ( +
+ updateServiceTier(index, 'mode', event.target.value)} + placeholder="fast" + /> + + updateServiceTier(index, 'serviceTier', event.target.value) + } + placeholder="priority" + /> + {RULE_PRICE_FIELDS.map(({ field, labelKey }) => ( + + updateServiceTier(index, field, event.target.value) + } + placeholder={t('model_prices.inherit_price_placeholder')} + /> + ))} + +
+ ))} +
+ )} +
+
) : null} @@ -450,8 +639,7 @@ export function ModelPricesPage() { candidates.find( (candidate) => getModelPriceCandidateIdentity(candidate) === requestedCandidateIdentity - ) ?? - candidates[0]; + ) ?? candidates[0]; const selectedCandidateIdentity = selectedCandidate ? getModelPriceCandidateIdentity(selectedCandidate) : ''; diff --git a/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts b/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts index b90aefbfd..ad4c72445 100644 --- a/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts +++ b/apps/web/src/features/monitoring/model/modelPricesPageModel.test.ts @@ -5,6 +5,7 @@ import { buildModelPriceRows, buildModelPriceSummary, buildSyncPriceModelsFromSummary, + createPriceDraft, filterModelPriceRows, formatContextThreshold, formatServiceTierRule, @@ -12,6 +13,7 @@ import { groupModelPriceCandidatesBySource, resolveContextTierDisplayPrice, resolveServiceTierDisplayPrice, + validatePriceDraft, } from './modelPricesPageModel'; const usageSummary = { @@ -59,12 +61,7 @@ describe('modelPricesPageModel', () => { }); it('includes runtime models without prior usage in row universe', () => { - const rows = buildModelPriceRows( - null, - {}, - [], - ['runtime-only-model'] - ); + const rows = buildModelPriceRows(null, {}, [], ['runtime-only-model']); expect(rows).toEqual([ expect.objectContaining({ model: 'runtime-only-model', @@ -168,6 +165,8 @@ describe('modelPricesPageModel', () => { cache: '', cacheRead: '', cacheCreation: '', + contextTiers: [], + serviceTiers: [], }) ).toMatchObject({ prompt: 1, @@ -192,6 +191,8 @@ describe('modelPricesPageModel', () => { cache: '', cacheRead: '0', cacheCreation: '0', + contextTiers: [], + serviceTiers: [], }) ).toMatchObject({ prompt: 0, @@ -205,6 +206,115 @@ describe('modelPricesPageModel', () => { }); }); + it('round-trips context and service-tier rules through the manual editor', () => { + const draft = createPriceDraft('gpt-6-astra', { + prompt: 5, + completion: 25, + cache: 0.5, + contextTiers: [ + { + thresholdTokens: 128_000, + prompt: 10, + completion: 0, + cache: 0, + promptConfigured: true, + completionConfigured: false, + cacheConfigured: false, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 0, + completion: 50, + cache: 0, + promptConfigured: true, + completionConfigured: true, + cacheConfigured: false, + }, + ], + }); + + expect(draft.contextTiers[0]).toMatchObject({ + thresholdTokens: '128000', + prompt: '10', + completion: '', + cache: '', + }); + expect(draft.serviceTiers[0]).toMatchObject({ + mode: 'fast', + serviceTier: 'priority', + prompt: '0', + completion: '50', + }); + expect(buildPriceFromDraft(draft)).toMatchObject({ + source: 'manual', + contextTiers: [ + { + thresholdTokens: 128_000, + prompt: 10, + promptConfigured: true, + completionConfigured: false, + cacheConfigured: false, + }, + ], + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: 0, + completion: 50, + promptConfigured: true, + completionConfigured: true, + }, + ], + }); + }); + + it('rejects duplicate and incomplete special pricing rules', () => { + const baseDraft = createPriceDraft('gpt-6-astra'); + expect( + validatePriceDraft({ + ...baseDraft, + contextTiers: [ + { + thresholdTokens: '128000', + prompt: '10', + completion: '', + cache: '', + cacheRead: '', + cacheCreation: '', + }, + { + thresholdTokens: '128000', + prompt: '', + completion: '20', + cache: '', + cacheRead: '', + cacheCreation: '', + }, + ], + }) + ).toBe('context_duplicate_threshold'); + expect( + validatePriceDraft({ + ...baseDraft, + serviceTiers: [ + { + mode: 'fast', + serviceTier: 'priority', + prompt: '', + completion: '', + cache: '', + cacheRead: '', + cacheCreation: '', + }, + ], + }) + ).toBe('service_price'); + }); + it('formats context tier thresholds compactly', () => { expect(formatContextThreshold(32_000)).toBe('32K'); expect(formatContextThreshold(1_000_000)).toBe('1M'); diff --git a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts index d7cf8fcc3..0130342f4 100644 --- a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts +++ b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts @@ -7,6 +7,23 @@ import type { export type ModelPriceFilter = 'all' | 'missing' | 'saved' | 'candidates'; +export type PriceRuleDraft = { + prompt: string; + completion: string; + cache: string; + cacheRead: string; + cacheCreation: string; +}; + +export type ContextTierDraft = PriceRuleDraft & { + thresholdTokens: string; +}; + +export type ServiceTierDraft = PriceRuleDraft & { + mode: string; + serviceTier: string; +}; + export type PriceDraft = { model: string; prompt: string; @@ -14,8 +31,18 @@ export type PriceDraft = { cache: string; cacheRead: string; cacheCreation: string; + contextTiers: ContextTierDraft[]; + serviceTiers: ServiceTierDraft[]; }; +export type PriceDraftValidationError = + | 'context_threshold' + | 'context_duplicate_threshold' + | 'context_price' + | 'service_identity' + | 'service_duplicate_identity' + | 'service_price'; + export type ModelPriceRow = { model: string; calls: number; @@ -45,11 +72,42 @@ export const createEmptyPriceDraft = (): PriceDraft => ({ cache: '', cacheRead: '', cacheCreation: '', + contextTiers: [], + serviceTiers: [], }); const createConfiguredDraftValue = (value: number | undefined, configured?: boolean): string => configured || Number(value) > 0 ? String(Number(value) || 0) : ''; +const createRulePriceDraft = ( + rule: ModelPriceContextTier | ModelPriceServiceTier +): PriceRuleDraft => ({ + prompt: createConfiguredDraftValue(rule.prompt, rule.promptConfigured), + completion: createConfiguredDraftValue(rule.completion, rule.completionConfigured), + cache: createConfiguredDraftValue(rule.cache, rule.cacheConfigured), + cacheRead: createConfiguredDraftValue(rule.cacheRead, rule.cacheReadConfigured), + cacheCreation: createConfiguredDraftValue(rule.cacheCreation, rule.cacheCreationConfigured), +}); + +export const createEmptyContextTierDraft = (): ContextTierDraft => ({ + thresholdTokens: '', + prompt: '', + completion: '', + cache: '', + cacheRead: '', + cacheCreation: '', +}); + +export const createEmptyServiceTierDraft = (): ServiceTierDraft => ({ + mode: '', + serviceTier: '', + prompt: '', + completion: '', + cache: '', + cacheRead: '', + cacheCreation: '', +}); + export const createPriceDraft = (model: string, price?: ModelPrice): PriceDraft => ({ model, prompt: price ? createConfiguredDraftValue(price.prompt, price.promptConfigured) : '', @@ -59,6 +117,15 @@ export const createPriceDraft = (model: string, price?: ModelPrice): PriceDraft cacheCreation: price ? createConfiguredDraftValue(price.cacheCreation, price.cacheCreationConfigured) : '', + contextTiers: (price?.contextTiers ?? []).map((tier) => ({ + thresholdTokens: String(tier.thresholdTokens), + ...createRulePriceDraft(tier), + })), + serviceTiers: (price?.serviceTiers ?? []).map((tier) => ({ + mode: tier.mode, + serviceTier: tier.serviceTier, + ...createRulePriceDraft(tier), + })), }); export const parsePriceValue = (value: string) => { @@ -66,9 +133,66 @@ export const parsePriceValue = (value: string) => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; }; +const PRICE_RULE_FIELDS = [ + 'prompt', + 'completion', + 'cache', + 'cacheRead', + 'cacheCreation', +] as const satisfies readonly (keyof PriceRuleDraft)[]; + +const hasConfiguredRulePrice = (draft: PriceRuleDraft) => + PRICE_RULE_FIELDS.some((field) => draft[field].trim() !== ''); + +const hasInvalidRulePrice = (draft: PriceRuleDraft) => + PRICE_RULE_FIELDS.some((field) => { + const value = draft[field].trim(); + if (!value) return false; + const parsed = Number(value); + return !Number.isFinite(parsed) || parsed < 0; + }); + +export const validatePriceDraft = (draft: PriceDraft): PriceDraftValidationError | null => { + const contextThresholds = new Set(); + for (const tier of draft.contextTiers) { + const threshold = Number(tier.thresholdTokens); + if (!Number.isSafeInteger(threshold) || threshold <= 0) return 'context_threshold'; + if (contextThresholds.has(threshold)) return 'context_duplicate_threshold'; + contextThresholds.add(threshold); + if (!hasConfiguredRulePrice(tier) || hasInvalidRulePrice(tier)) return 'context_price'; + } + + const serviceIdentifiers = new Set(); + for (const tier of draft.serviceTiers) { + const mode = tier.mode.trim().toLowerCase(); + const serviceTier = tier.serviceTier.trim().toLowerCase(); + if (!mode || !serviceTier) return 'service_identity'; + if (serviceIdentifiers.has(mode) || serviceIdentifiers.has(serviceTier)) { + return 'service_duplicate_identity'; + } + serviceIdentifiers.add(mode); + serviceIdentifiers.add(serviceTier); + if (!hasConfiguredRulePrice(tier) || hasInvalidRulePrice(tier)) return 'service_price'; + } + return null; +}; + +const buildRulePrice = (draft: PriceRuleDraft) => ({ + prompt: parsePriceValue(draft.prompt), + completion: parsePriceValue(draft.completion), + cache: parsePriceValue(draft.cache), + cacheRead: parsePriceValue(draft.cacheRead), + cacheCreation: parsePriceValue(draft.cacheCreation), + promptConfigured: draft.prompt.trim() !== '', + completionConfigured: draft.completion.trim() !== '', + cacheConfigured: draft.cache.trim() !== '', + cacheReadConfigured: draft.cacheRead.trim() !== '', + cacheCreationConfigured: draft.cacheCreation.trim() !== '', +}); + export const buildPriceFromDraft = (draft: PriceDraft): ModelPrice | null => { const model = draft.model.trim(); - if (!model) return null; + if (!model || validatePriceDraft(draft)) return null; const prompt = parsePriceValue(draft.prompt); const completion = parsePriceValue(draft.completion); const cache = draft.cache.trim() === '' ? prompt : parsePriceValue(draft.cache); @@ -83,8 +207,22 @@ export const buildPriceFromDraft = (draft: PriceDraft): ModelPrice | null => { cacheReadConfigured: draft.cacheRead.trim() !== '', cacheCreationConfigured: draft.cacheCreation.trim() !== '', source: 'manual', - contextTiers: [], - serviceTiers: [], + contextTiers: draft.contextTiers + .map((tier) => ({ + thresholdTokens: Number(tier.thresholdTokens), + ...buildRulePrice(tier), + })) + .sort((left, right) => left.thresholdTokens - right.thresholdTokens), + serviceTiers: draft.serviceTiers + .map((tier) => ({ + mode: tier.mode.trim().toLowerCase(), + serviceTier: tier.serviceTier.trim().toLowerCase(), + ...buildRulePrice(tier), + })) + .sort( + (left, right) => + left.mode.localeCompare(right.mode) || left.serviceTier.localeCompare(right.serviceTier) + ), }; }; diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index c82ebb8f3..b05e5ffcd 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -2248,7 +2248,23 @@ "calls": "Calls", "source": "Source", "pricing_rules": "Pricing rules", - "manual_clears_pricing_rules": "Saving a manual price removes {{count}} synchronized context or service-tier rule(s).", + "context_rules": "Long-context rules", + "context_rules_hint": "Override selected rates after the input token threshold is exceeded. Blank rates inherit the base price.", + "service_tier_rules": "Service-tier rules", + "service_tier_rules_hint": "Map a request mode to the provider's emitted service tier. Blank rates inherit the base price.", + "add_context_rule": "Add context rule", + "add_service_rule": "Add service-tier rule", + "no_pricing_rules": "No special pricing rules.", + "threshold_tokens": "Input threshold", + "service_mode": "Request mode", + "service_tier": "Provider tier", + "inherit_price_placeholder": "Inherit", + "rule_error_context_threshold": "Each context threshold must be a positive whole number.", + "rule_error_context_duplicate_threshold": "Context thresholds must be unique.", + "rule_error_context_price": "Each context rule needs at least one valid non-negative price.", + "rule_error_service_identity": "Each service-tier rule needs both a request mode and provider tier.", + "rule_error_service_duplicate_identity": "Request modes and provider tiers must be unique across rules.", + "rule_error_service_price": "Each service-tier rule needs at least one valid non-negative price.", "needs_confirmation": "Candidate confirmation needed", "pending_sync_badge": "New", "no_price": "No price set", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 6b578d41b..91a07daa2 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -2250,7 +2250,23 @@ "calls": "Вызовы", "source": "Источник", "pricing_rules": "Правила тарификации", - "manual_clears_pricing_rules": "Сохранение ручной цены удалит {{count}} синхронизированных контекстных правил или правил уровня обслуживания.", + "context_rules": "Правила длинного контекста", + "context_rules_hint": "Переопределяют выбранные цены после превышения порога входных токенов. Пустые поля наследуют базовую цену.", + "service_tier_rules": "Правила уровня обслуживания", + "service_tier_rules_hint": "Связывают режим запроса с уровнем обслуживания провайдера. Пустые поля наследуют базовую цену.", + "add_context_rule": "Добавить правило контекста", + "add_service_rule": "Добавить правило уровня", + "no_pricing_rules": "Специальных правил тарификации нет.", + "threshold_tokens": "Порог входных токенов", + "service_mode": "Режим запроса", + "service_tier": "Уровень провайдера", + "inherit_price_placeholder": "Наследовать", + "rule_error_context_threshold": "Каждый порог контекста должен быть положительным целым числом.", + "rule_error_context_duplicate_threshold": "Пороги контекста должны быть уникальными.", + "rule_error_context_price": "Для каждого правила контекста нужна хотя бы одна корректная неотрицательная цена.", + "rule_error_service_identity": "Для каждого правила уровня нужны режим запроса и уровень провайдера.", + "rule_error_service_duplicate_identity": "Режимы запросов и уровни провайдера должны быть уникальными.", + "rule_error_service_price": "Для каждого правила уровня нужна хотя бы одна корректная неотрицательная цена.", "needs_confirmation": "Нужно подтвердить цену", "pending_sync_badge": "Новый", "no_price": "Цена не задана", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 5d7a4c933..afe887e2e 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -2246,7 +2246,23 @@ "calls": "调用", "source": "来源", "pricing_rules": "计费规则", - "manual_clears_pricing_rules": "保存手动价格将移除 {{count}} 条已同步的上下文或服务层级规则。", + "context_rules": "长上下文规则", + "context_rules_hint": "输入 Token 超过阈值后覆盖指定价格;留空的价格沿用基础价格。", + "service_tier_rules": "服务层级规则", + "service_tier_rules_hint": "将请求模式映射到提供商返回的服务层级;留空的价格沿用基础价格。", + "add_context_rule": "添加上下文规则", + "add_service_rule": "添加服务层级规则", + "no_pricing_rules": "暂无特殊计费规则。", + "threshold_tokens": "输入 Token 阈值", + "service_mode": "请求模式", + "service_tier": "提供商层级", + "inherit_price_placeholder": "沿用基础价格", + "rule_error_context_threshold": "每个上下文阈值必须是正整数。", + "rule_error_context_duplicate_threshold": "上下文阈值不能重复。", + "rule_error_context_price": "每条上下文规则至少需要一个有效的非负价格。", + "rule_error_service_identity": "每条服务层级规则都需要填写请求模式和提供商层级。", + "rule_error_service_duplicate_identity": "不同规则的请求模式和提供商层级不能重复。", + "rule_error_service_price": "每条服务层级规则至少需要一个有效的非负价格。", "needs_confirmation": "需要确认价格", "pending_sync_badge": "待同步", "no_price": "未设置价格", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 1f16d7377..26c194abb 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -2246,7 +2246,23 @@ "calls": "呼叫", "source": "來源", "pricing_rules": "計費規則", - "manual_clears_pricing_rules": "儲存手動價格將移除 {{count}} 條已同步的上下文或服務層級規則。", + "context_rules": "長上下文規則", + "context_rules_hint": "輸入 Token 超過閾值後覆寫指定價格;留空的價格沿用基礎價格。", + "service_tier_rules": "服務層級規則", + "service_tier_rules_hint": "將請求模式對應到供應商回傳的服務層級;留空的價格沿用基礎價格。", + "add_context_rule": "新增上下文規則", + "add_service_rule": "新增服務層級規則", + "no_pricing_rules": "暫無特殊計費規則。", + "threshold_tokens": "輸入 Token 閾值", + "service_mode": "請求模式", + "service_tier": "供應商層級", + "inherit_price_placeholder": "沿用基礎價格", + "rule_error_context_threshold": "每個上下文閾值必須是正整數。", + "rule_error_context_duplicate_threshold": "上下文閾值不可重複。", + "rule_error_context_price": "每條上下文規則至少需要一個有效的非負價格。", + "rule_error_service_identity": "每條服務層級規則都需要填寫請求模式與供應商層級。", + "rule_error_service_duplicate_identity": "不同規則的請求模式與供應商層級不可重複。", + "rule_error_service_price": "每條服務層級規則至少需要一個有效的非負價格。", "needs_confirmation": "需要確認價格", "pending_sync_badge": "待同步", "no_price": "未設定價格",