feat(web): 提供模型价格响应式集合

This commit is contained in:
tbphp committed 2026-07-29 02:17:16 +08:00
1 parent 7912b3d219
commit 7b03216363
8 files changed
+613 -234

No files matched your search

@@ -0,0 +1,58 @@
import { QueryClient } from '@tanstack/vue-query'
import type { ModelPriceRuleDto } from '@/api/control/model-prices'
import { mountApp } from '@/test/mount-app'
import ModelPriceCollection from './ModelPriceCollection.vue'
const rule: ModelPriceRuleDto = {
pattern: `vendor-${'segment-'.repeat(14)}*`,
source: 'builtin',
prices: {
uncached_input: null,
cache_read: 0,
cache_write_5m: 1.25,
cache_write_1h: null,
output: 8,
},
source_url: null,
updated_at: '2026-07-29T00:00:00Z',
pricing_policy: null,
}
describe('ModelPriceCollection', () => {
it('renders mobile identity and action outside native price details with a plain missing source', async () => {
vi.stubGlobal('matchMedia', () => ({
matches: true,
media: '(max-width: 767px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
const { wrapper } = await mountApp(ModelPriceCollection, {
api: { request: vi.fn() },
queryClient: new QueryClient(),
locale: 'en-US',
mounting: {
props: {
rules: [rule],
source: 'builtin',
},
},
})
const card = wrapper.get('[data-test="builtin-price-card-0"]')
expect(card.get('h3').text()).toBe(rule.pattern)
expect(card.get('[data-test="builtin-price-edit-0"]').text()).toContain('Create override')
expect(card.get('details').attributes()).not.toHaveProperty('open')
expect(card.get('[data-test="builtin-source-0"]').element.tagName).toBe('SPAN')
expect(card.find('a[data-test="builtin-source-0"]').exists()).toBe(false)
expect(card.get('time').attributes('datetime')).toBe('2026-07-29T00:00:00Z')
wrapper.unmount()
vi.unstubAllGlobals()
})
})
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { ExternalLink, Pencil, TriangleAlert } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ModelPriceRuleDto, ModelPriceSource } from '@/api/control/model-prices'
import AppButton from '@/components/ui/AppButton.vue'
import AppDateTime from '@/components/ui/AppDateTime.vue'
import DataTable from '@/components/ui/DataTable.vue'
import MobileRecordCard from '@/components/ui/MobileRecordCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelPriceResetDialog from './ModelPriceResetDialog.vue'
import { modelPricePatternKind } from './model-price-form'
import { presentModelPriceRule } from './model-price-presenter'
const props = defineProps<{
rules: ModelPriceRuleDto[]
source: ModelPriceSource
}>()
const emit = defineEmits<{
edit: [rule: ModelPriceRuleDto, trigger: HTMLElement]
}>()
const { locale, n, t } = useI18n()
const mobile = ref(false)
let mediaQuery: MediaQueryList | undefined
try {
mediaQuery = window.matchMedia('(max-width: 767px)')
mobile.value = mediaQuery.matches
} catch {
mediaQuery = undefined
}
const presentations = computed(() =>
props.rules.map((rule) =>
presentModelPriceRule(rule, {
fieldLabels: {
uncached_input: t('modelPrices.fields.uncached_input'),
cache_read: t('modelPrices.fields.cache_read'),
cache_write_5m: t('modelPrices.fields.cache_write_5m'),
cache_write_1h: t('modelPrices.fields.cache_write_1h'),
output: t('modelPrices.fields.output'),
},
notConfigured: t('modelPrices.notConfigured'),
explicitlyFree: t('modelPrices.explicitlyFree'),
configuredPrice: (price) => t('modelPrices.configuredPrice', { price }),
kindLabel: (pattern) => t(`modelPrices.kind.${modelPricePatternKind(pattern)}`),
sourceLabel: (source) => t(`modelPrices.source.${source}`),
policySummary: (policy) =>
t('modelPrices.builtin.longContext.summary', {
threshold: n(policy.input_threshold_tokens),
inputMultiplier: n(policy.input_multiplier),
outputMultiplier: n(policy.output_multiplier),
}),
}),
),
)
const testPrefix = computed(() => (props.source === 'user' ? 'override' : 'builtin'))
function sourceRule(index: number): ModelPriceRuleDto {
const rule = props.rules[index]
if (!rule) throw new Error(`MODEL_PRICE_SOURCE_MISSING:${index}`)
return rule
}
function edit(index: number, trigger: HTMLElement): void {
emit('edit', sourceRule(index), trigger)
}
function updateMedia(event: MediaQueryListEvent): void {
mobile.value = event.matches
}
onMounted(() => mediaQuery?.addEventListener('change', updateMedia))
onBeforeUnmount(() => mediaQuery?.removeEventListener('change', updateMedia))
</script>
<template>
<div class="model-price-collection">
<div v-if="mobile" class="model-price-collection__cards">
<MobileRecordCard
v-for="(record, index) in presentations"
:key="record.pattern"
:label="record.pattern"
:data-test="`${testPrefix}-price-card-${index}`"
>
<template #header>
<div class="model-price-card__identity">
<h3>{{ record.pattern }}</h3>
<StatusBadge>{{ record.kind }}</StatusBadge>
</div>
<StatusBadge>{{ record.source }}</StatusBadge>
</template>
<p
v-if="record.globalOverride"
class="model-price-card__warning"
data-test="model-price-global-row-warning"
>
<TriangleAlert :size="14" aria-hidden="true" />
{{ t('modelPrices.globalUserOverride') }}
</p>
<details>
<summary>{{ t('modelPrices.details') }}</summary>
<dl>
<template v-for="price in record.priceRows" :key="price.field">
<dt>{{ price.label }}</dt>
<dd :data-state="price.state">{{ price.value }}</dd>
</template>
<dt>{{ t('modelPrices.table.source') }}</dt>
<dd>
<a
v-if="record.sourceUrl"
:data-test="`${testPrefix}-source-${index}`"
class="model-price-collection__source"
:href="record.sourceUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ t('modelPrices.builtin.source') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
<span v-else :data-test="`${testPrefix}-source-${index}`">
{{
source === 'builtin'
? t('modelPrices.sourceUnavailable')
: t('modelPrices.source.user')
}}
</span>
</dd>
<dt>{{ t('modelPrices.table.updatedAt') }}</dt>
<dd>
<AppDateTime :instant="record.updatedAt" :locale="locale" time-zone="UTC" />
</dd>
</dl>
<p
v-if="record.policySummary"
:data-test="`builtin-pricing-policy-${index}`"
class="model-price-collection__policy"
>
<StatusBadge>{{ t('modelPrices.builtin.longContext.label') }}</StatusBadge>
<span>{{ record.policySummary }}</span>
</p>
</details>
<template #actions>
<AppButton
:data-test="`${testPrefix}-price-edit-${index}`"
variant="ghost"
@click="edit(index, $event.currentTarget as HTMLElement)"
>
<Pencil :size="15" aria-hidden="true" />{{
source === 'user'
? t('modelPrices.overrides.edit')
: t('modelPrices.builtin.createOverride')
}}
</AppButton>
<ModelPriceResetDialog v-if="source === 'user'" :rule="sourceRule(index)" />
</template>
</MobileRecordCard>
</div>
<DataTable
v-else
:caption="
source === 'user' ? t('modelPrices.overrides.caption') : t('modelPrices.builtin.caption')
"
:scroll-hint="t('modelPrices.scrollHint')"
dense
>
<thead>
<tr>
<th scope="col" data-column-priority="high">{{ t('modelPrices.table.pattern') }}</th>
<th scope="col" data-column-priority="high">{{ t('modelPrices.table.kind') }}</th>
<th v-for="field in presentations[0]?.priceRows ?? []" :key="field.field" scope="col">
{{ field.label }}
</th>
<th scope="col">{{ t('modelPrices.table.source') }}</th>
<th scope="col">{{ t('modelPrices.table.updatedAt') }}</th>
<th scope="col" data-column-priority="high">{{ t('modelPrices.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(record, index) in presentations"
:key="record.pattern"
:data-test="`${testPrefix}-price-row-${index}`"
>
<td>
<code>{{ record.pattern }}</code>
<span
v-if="record.globalOverride"
class="model-price-collection__global-warning"
data-test="model-price-global-row-warning"
>
<TriangleAlert :size="14" aria-hidden="true" />
{{ t('modelPrices.globalUserOverride') }}
</span>
<div
v-if="record.policySummary"
class="model-price-collection__policy"
:data-test="`builtin-pricing-policy-${index}`"
>
<StatusBadge>{{ t('modelPrices.builtin.longContext.label') }}</StatusBadge>
<span>{{ record.policySummary }}</span>
</div>
</td>
<td>
<StatusBadge>{{ record.kind }}</StatusBadge>
<span class="model-price-collection__source-label">{{ record.source }}</span>
</td>
<td
v-for="price in record.priceRows"
:key="price.field"
:data-test="`${testPrefix}-${index}-${price.field}`"
:data-state="price.state"
>
{{ price.value }}
</td>
<td>
<a
v-if="record.sourceUrl"
:data-test="`${testPrefix}-source-${index}`"
class="model-price-collection__source"
:href="record.sourceUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ t('modelPrices.builtin.source') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
<span v-else :data-test="`${testPrefix}-source-${index}`">
{{
source === 'builtin'
? t('modelPrices.sourceUnavailable')
: t('modelPrices.source.user')
}}
</span>
</td>
<td>
<AppDateTime :instant="record.updatedAt" :locale="locale" time-zone="UTC" />
</td>
<td>
<div class="model-price-collection__row-actions">
<AppButton
:data-test="`${testPrefix}-price-edit-${index}`"
variant="ghost"
@click="edit(index, $event.currentTarget as HTMLElement)"
>
<Pencil :size="15" aria-hidden="true" />{{
source === 'user'
? t('modelPrices.overrides.edit')
: t('modelPrices.builtin.createOverride')
}}
</AppButton>
<ModelPriceResetDialog v-if="source === 'user'" :rule="sourceRule(index)" />
</div>
</td>
</tr>
</tbody>
</DataTable>
</div>
</template>
<style scoped>
.model-price-collection {
min-width: 0;
}
.model-price-collection__cards {
display: grid;
gap: var(--space-3);
}
.model-price-card__identity {
min-width: 0;
}
.model-price-card__identity h3 {
margin: 0 0 var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-lg);
overflow-wrap: anywhere;
}
.model-price-card__warning,
.model-price-collection__global-warning {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--color-warning);
font-size: var(--text-xs);
font-weight: 650;
}
.model-price-card__warning {
margin: 0;
}
.model-price-collection details {
border-top: 1px solid var(--color-border-subtle);
padding-top: var(--space-3);
}
.model-price-collection summary {
min-height: var(--touch-target);
color: var(--color-action);
cursor: pointer;
font-weight: 650;
}
.model-price-collection details dl {
display: grid;
grid-template-columns: minmax(8rem, auto) minmax(0, 1fr);
gap: var(--space-2);
margin: var(--space-3) 0 0;
}
.model-price-collection details dt {
color: var(--color-text-muted);
}
.model-price-collection details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.model-price-collection code {
color: var(--color-code);
font-family: var(--font-mono);
white-space: nowrap;
}
.model-price-collection td:not(:first-child, :last-child) {
font-variant-numeric: tabular-nums;
}
.model-price-collection__source,
.model-price-collection__row-actions {
display: inline-flex;
align-items: center;
}
.model-price-collection__source {
min-height: var(--touch-target);
gap: var(--space-1);
color: var(--color-action);
font-weight: 650;
white-space: nowrap;
}
.model-price-collection__source-label {
display: block;
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.model-price-collection__policy {
display: grid;
max-width: 22rem;
gap: var(--space-1);
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-xs);
line-height: var(--line-normal);
white-space: normal;
}
.model-price-collection__policy > :first-child {
width: fit-content;
}
.model-price-collection__global-warning {
margin-top: var(--space-1);
white-space: normal;
}
.model-price-collection__row-actions {
gap: var(--space-2);
white-space: nowrap;
}
</style>
+19 -234
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, ExternalLink, Pencil, Plus, Tags, TriangleAlert } from 'lucide-vue-next'
import { ArrowLeft, Plus, Tags } from 'lucide-vue-next'
import { useQuery } from '@tanstack/vue-query'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -8,19 +8,17 @@ import { useApiClient } from '@/api/client-context'
import { getModelPrices, type ModelPriceRuleDto } from '@/api/control/model-prices'
import { controlQueryKeys } from '@/app/query-keys'
import AppButton from '@/components/ui/AppButton.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import SurfaceCard from '@/components/ui/SurfaceCard.vue'
import ModelPriceCollection from './ModelPriceCollection.vue'
import ModelPriceDrawer from './ModelPriceDrawer.vue'
import ModelPriceResetDialog from './ModelPriceResetDialog.vue'
import { modelPricePatternKind, type ModelPriceField } from './model-price-form'
const client = useApiClient()
const { n, t } = useI18n()
const { t } = useI18n()
const drawerOpen = ref(false)
const selected = ref<ModelPriceRuleDto | null>(null)
let restoreFocus: HTMLElement | null = null
@@ -36,9 +34,9 @@ function addOverride(): void {
drawerOpen.value = true
}
function editRule(rule: ModelPriceRuleDto, event: Event): void {
function editRule(rule: ModelPriceRuleDto, trigger: HTMLElement): void {
selected.value = rule
restoreFocus = event.currentTarget as HTMLElement
restoreFocus = trigger
drawerOpen.value = true
}
@@ -52,30 +50,6 @@ async function setDrawerOpen(open: boolean): Promise<void> {
target?.focus()
}
}
function formatPrice(value: number | null): string {
if (value === null) return t('modelPrices.notConfigured')
if (value === 0) return t('modelPrices.explicitlyFree')
return t('modelPrices.configuredPrice', { price: value })
}
function kindLabel(pattern: string): string {
return t(`modelPrices.kind.${modelPricePatternKind(pattern)}`)
}
function value(rule: ModelPriceRuleDto, field: ModelPriceField): string {
return formatPrice(rule.prices[field])
}
function pricingPolicySummary(rule: ModelPriceRuleDto): string {
const policy = rule.pricing_policy
if (policy === null) return ''
return t('modelPrices.builtin.longContext.summary', {
threshold: n(policy.input_threshold_tokens),
inputMultiplier: n(policy.input_multiplier),
outputMultiplier: n(policy.output_multiplier),
})
}
</script>
<template>
@@ -150,76 +124,12 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string {
:title="t('modelPrices.overrides.empty')"
:description="t('modelPrices.overrides.emptyDescription')"
/>
<DataTable v-else :caption="t('modelPrices.overrides.caption')" dense>
<thead>
<tr>
<th scope="col">{{ t('modelPrices.table.pattern') }}</th>
<th scope="col">{{ t('modelPrices.table.kind') }}</th>
<th scope="col">{{ t('modelPrices.fields.uncached_input') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_read') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_write_5m') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_write_1h') }}</th>
<th scope="col">{{ t('modelPrices.fields.output') }}</th>
<th scope="col">{{ t('modelPrices.table.source') }}</th>
<th scope="col">{{ t('modelPrices.table.updatedAt') }}</th>
<th scope="col">{{ t('modelPrices.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(rule, index) in pricesQuery.data.value.overrides"
:key="rule.pattern"
:data-test="`override-price-row-${index}`"
>
<td>
<code>{{ rule.pattern }}</code>
<span
v-if="rule.pattern === '*'"
class="model-prices__global-warning"
data-test="model-price-global-row-warning"
>
<TriangleAlert :size="14" aria-hidden="true" />
{{ t('modelPrices.globalUserOverride') }}
</span>
</td>
<td>
<StatusBadge>{{ kindLabel(rule.pattern) }}</StatusBadge>
<span class="model-prices__source-label">{{ t('modelPrices.source.user') }}</span>
</td>
<td :data-test="`override-${index}-uncached_input`">
{{ value(rule, 'uncached_input') }}
</td>
<td :data-test="`override-${index}-cache_read`">
{{ value(rule, 'cache_read') }}
</td>
<td :data-test="`override-${index}-cache_write_5m`">
{{ value(rule, 'cache_write_5m') }}
</td>
<td :data-test="`override-${index}-cache_write_1h`">
{{ value(rule, 'cache_write_1h') }}
</td>
<td :data-test="`override-${index}-output`">{{ value(rule, 'output') }}</td>
<td :data-test="`override-source-${index}`">
{{ t('modelPrices.source.user') }}
</td>
<td>
<time :datetime="rule.updated_at">{{ rule.updated_at }}</time>
</td>
<td>
<div class="model-prices__row-actions">
<AppButton
:data-test="`override-price-edit-${index}`"
variant="ghost"
@click="editRule(rule, $event)"
>
<Pencil :size="15" aria-hidden="true" />{{ t('modelPrices.overrides.edit') }}
</AppButton>
<ModelPriceResetDialog :rule="rule" />
</div>
</td>
</tr>
</tbody>
</DataTable>
<ModelPriceCollection
v-else
:rules="pricesQuery.data.value.overrides"
source="user"
@edit="editRule"
/>
</section>
<section
@@ -240,86 +150,12 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string {
:title="t('modelPrices.builtin.empty')"
:description="t('modelPrices.builtin.emptyDescription')"
/>
<DataTable v-else :caption="t('modelPrices.builtin.caption')" dense>
<thead>
<tr>
<th scope="col">{{ t('modelPrices.table.pattern') }}</th>
<th scope="col">{{ t('modelPrices.table.kind') }}</th>
<th scope="col">{{ t('modelPrices.fields.uncached_input') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_read') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_write_5m') }}</th>
<th scope="col">{{ t('modelPrices.fields.cache_write_1h') }}</th>
<th scope="col">{{ t('modelPrices.fields.output') }}</th>
<th scope="col">{{ t('modelPrices.table.source') }}</th>
<th scope="col">{{ t('modelPrices.table.updatedAt') }}</th>
<th scope="col">{{ t('modelPrices.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(rule, index) in pricesQuery.data.value.builtin"
:key="rule.pattern"
:data-test="`builtin-price-row-${index}`"
>
<td>
<code>{{ rule.pattern }}</code>
<div
v-if="rule.pricing_policy"
class="model-prices__pricing-policy"
:data-test="`builtin-pricing-policy-${index}`"
>
<StatusBadge>{{ t('modelPrices.builtin.longContext.label') }}</StatusBadge>
<span>{{ pricingPolicySummary(rule) }}</span>
</div>
</td>
<td>
<StatusBadge>{{ kindLabel(rule.pattern) }}</StatusBadge>
<span class="model-prices__source-label">{{
t('modelPrices.source.builtin')
}}</span>
</td>
<td :data-test="`builtin-${index}-uncached_input`">
{{ value(rule, 'uncached_input') }}
</td>
<td :data-test="`builtin-${index}-cache_read`">
{{ value(rule, 'cache_read') }}
</td>
<td :data-test="`builtin-${index}-cache_write_5m`">
{{ value(rule, 'cache_write_5m') }}
</td>
<td :data-test="`builtin-${index}-cache_write_1h`">
{{ value(rule, 'cache_write_1h') }}
</td>
<td :data-test="`builtin-${index}-output`">{{ value(rule, 'output') }}</td>
<td>
<a
:data-test="`builtin-source-${index}`"
class="model-prices__source"
:href="rule.source_url ?? undefined"
target="_blank"
rel="noopener noreferrer"
>
{{ t('modelPrices.builtin.source') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
</td>
<td>
<time :datetime="rule.updated_at">{{ rule.updated_at }}</time>
</td>
<td>
<AppButton
:data-test="`builtin-price-edit-${index}`"
variant="ghost"
@click="editRule(rule, $event)"
>
<Pencil :size="15" aria-hidden="true" />{{
t('modelPrices.builtin.createOverride')
}}
</AppButton>
</td>
</tr>
</tbody>
</DataTable>
<ModelPriceCollection
v-else
:rules="pricesQuery.data.value.builtin"
source="builtin"
@edit="editRule"
/>
</section>
</template>
</section>
@@ -331,9 +167,7 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string {
min-width: 0;
gap: var(--space-5);
}
.model-prices__back,
.model-prices__source,
.model-prices__row-actions {
.model-prices__back {
display: inline-flex;
align-items: center;
}
@@ -344,8 +178,7 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string {
color: var(--color-text-muted);
font-weight: 650;
}
.model-prices__back:hover,
.model-prices__source:hover {
.model-prices__back:hover {
color: var(--color-primary);
}
.model-prices__notice {
@@ -377,54 +210,6 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string {
margin: 0;
font-size: 1.125rem;
}
.model-prices code {
color: var(--color-code);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: nowrap;
}
.model-prices td:not(:first-child, :last-child) {
font-variant-numeric: tabular-nums;
}
.model-prices__source {
min-height: 44px;
gap: var(--space-1);
color: var(--color-primary);
font-weight: 650;
white-space: nowrap;
}
.model-prices__source-label {
display: block;
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: 0.75rem;
}
.model-prices__pricing-policy {
display: grid;
max-width: 22rem;
gap: var(--space-1);
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.5;
white-space: normal;
}
.model-prices__pricing-policy > :first-child {
width: fit-content;
}
.model-prices__global-warning {
display: flex;
align-items: center;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--color-warning);
font-size: 0.75rem;
font-weight: 650;
white-space: normal;
}
.model-prices__row-actions {
gap: var(--space-2);
white-space: nowrap;
}
@media (max-width: 640px) {
.model-prices__section-heading {
align-items: stretch;
@@ -0,0 +1,55 @@
import type { ModelPriceRuleDto } from '@/api/control/model-prices'
import { presentModelPriceRule } from './model-price-presenter'
const rule: ModelPriceRuleDto = {
pattern: 'gpt-*',
source: 'builtin',
prices: {
uncached_input: null,
cache_read: 0,
cache_write_5m: 1.25,
cache_write_1h: null,
output: 8,
},
source_url: null,
updated_at: '2026-07-29T00:00:00Z',
pricing_policy: {
input_threshold_tokens: 272000,
input_multiplier: 2,
output_multiplier: 1.5,
},
}
describe('Model Price presenter', () => {
it('keeps null, explicit zero, and configured prices distinct', () => {
const presentation = presentModelPriceRule(rule, {
fieldLabels: {
uncached_input: 'Uncached input',
cache_read: 'Cache read',
cache_write_5m: '5-minute cache write',
cache_write_1h: '1-hour cache write',
output: 'Output',
},
notConfigured: 'Not configured',
explicitlyFree: '$0 · Explicitly free',
configuredPrice: (value) => `$${value} / 1M`,
kindLabel: () => 'Prefix rule',
sourceLabel: () => 'Built-in',
policySummary: (policy) =>
`More than ${policy.input_threshold_tokens}: ×${policy.input_multiplier}/×${policy.output_multiplier}`,
})
expect(presentation.priceRows.map(({ state, value }) => ({ state, value }))).toEqual([
{ state: 'not-configured', value: 'Not configured' },
{ state: 'free', value: '$0 · Explicitly free' },
{ state: 'configured', value: '$1.25 / 1M' },
{ state: 'not-configured', value: 'Not configured' },
{ state: 'configured', value: '$8 / 1M' },
])
expect(presentation.kind).toBe('Prefix rule')
expect(presentation.source).toBe('Built-in')
expect(presentation.sourceUrl).toBeUndefined()
expect(presentation.policySummary).toContain('272000')
})
})
@@ -0,0 +1,87 @@
import type {
ModelPricePolicyDto,
ModelPriceRuleDto,
ModelPriceSource,
ModelPriceValues,
} from '@/api/control/model-prices'
export type ModelPriceField = keyof ModelPriceValues
export type ModelPriceValueState = 'not-configured' | 'free' | 'configured'
export interface ModelPriceRowPresentation {
field: ModelPriceField
label: string
value: string
state: ModelPriceValueState
}
export interface ModelPricePresentation {
pattern: string
source: string
kind: string
priceRows: readonly ModelPriceRowPresentation[]
updatedAt: string
sourceUrl?: string
policySummary?: string
globalOverride: boolean
}
export interface ModelPricePresenterOptions {
fieldLabels: Record<ModelPriceField, string>
notConfigured: string
explicitlyFree: string
configuredPrice(value: number): string
kindLabel(pattern: string): string
sourceLabel(source: ModelPriceSource): string
policySummary(policy: ModelPricePolicyDto): string
}
const priceFields: readonly ModelPriceField[] = [
'uncached_input',
'cache_read',
'cache_write_5m',
'cache_write_1h',
'output',
]
export function presentModelPriceRule(
rule: ModelPriceRuleDto,
options: ModelPricePresenterOptions,
): ModelPricePresentation {
const priceRows = priceFields.map((field): ModelPriceRowPresentation => {
const price = rule.prices[field]
if (price === null) {
return {
field,
label: options.fieldLabels[field],
value: options.notConfigured,
state: 'not-configured',
}
}
if (price === 0) {
return {
field,
label: options.fieldLabels[field],
value: options.explicitlyFree,
state: 'free',
}
}
return {
field,
label: options.fieldLabels[field],
value: options.configuredPrice(price),
state: 'configured',
}
})
return {
pattern: rule.pattern,
source: options.sourceLabel(rule.source),
kind: options.kindLabel(rule.pattern),
priceRows,
updatedAt: rule.updated_at,
...(rule.source_url ? { sourceUrl: rule.source_url } : {}),
...(rule.pricing_policy ? { policySummary: options.policySummary(rule.pricing_policy) } : {}),
globalOverride: rule.source === 'user' && rule.pattern === '*',
}
}
@@ -16,6 +16,9 @@ export default {
precedenceNote: 'User overrides take precedence over every built-in exact and prefix rule.',
wholeRuleNote:
'A user override replaces the whole five-slot rule. Unset slots do not fall back to built-in values.',
scrollHint: 'Scroll horizontally to inspect every model price column.',
details: 'Price fields and metadata',
sourceUnavailable: 'Source unavailable',
notConfigured: 'Not configured',
explicitlyFree: '$0 · Explicitly free',
configuredPrice: '${price} / 1M',
@@ -16,6 +16,9 @@ export default {
'ユーザー上書きは、すべての組み込み完全一致ルールとプレフィックスルールより優先されます。',
wholeRuleNote:
'ユーザー上書きは 5 枠のルール全体を置き換え、未設定の枠は組み込み値へフォールバックしません。',
scrollHint: '横方向にスクロールすると、モデル価格のすべての列を確認できます。',
details: '価格フィールドとメタデータ',
sourceUnavailable: 'ソースを利用できません',
notConfigured: '未設定',
explicitlyFree: '$0 · 明示的に無料',
configuredPrice: '${price} / 1M',
@@ -12,6 +12,9 @@ export default {
modelIdentityNote: '规则匹配路由后的上游模型 ID;相同模型 ID 在所有 Group 间共用一个全局价格。',
precedenceNote: '所有用户覆盖都优先于内置精确规则和前缀规则。',
wholeRuleNote: '用户覆盖会替换完整的五槽位规则;未配置槽位不会回退到内置值。',
scrollHint: '可横向滚动查看模型价格的全部列。',
details: '价格字段与元数据',
sourceUnavailable: '来源不可用',
notConfigured: '未配置',
explicitlyFree: '$0 · 显式免费',
configuredPrice: '${price} / 1M',