feat(web): 提供模型价格响应式集合
This commit is contained in:
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>
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user