merge: integrate usage response models with service tier badges

Merge upstream PR #837, preserving its original commits and resolving the UsageModelDisplay layout conflict after #841. Cover response models alongside dynamic service-tier badges in detail tests.
This commit is contained in:
ZheFox
2026-09-22 23:31:22 +08:00
24 changed files with 666 additions and 85 deletions
+1
View File
@@ -219,6 +219,7 @@ export interface RequestDetail {
has_format_conversion?: boolean | null
model: string
target_model?: string | null // 映射后的目标模型名
response_model?: string | null // 上游响应体实际返回的模型名
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
+3
View File
@@ -60,6 +60,7 @@ export interface UsageRecordDetail {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -392,6 +393,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -438,6 +440,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+3
View File
@@ -20,6 +20,7 @@ export interface UsageRecord {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -618,6 +619,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -685,6 +687,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+1
View File
@@ -19,6 +19,7 @@ export interface UsageRecord {
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
response_model?: string | null // 上游响应体实际返回的模型名
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
@@ -1016,6 +1016,7 @@ const emit = defineEmits<{
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1158,6 +1159,7 @@ type HeaderModelTextField =
| 'model'
| 'target_model'
| 'model_version'
| 'response_model'
| 'request_type'
| 'requested_reasoning_effort'
| 'reasoning_effort'
@@ -1166,6 +1168,7 @@ type HeaderModelTextField =
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
'target_model',
'response_model',
'reasoning_effort',
'service_tier',
'actual_service_tier',
@@ -1252,6 +1255,7 @@ watch(
props.summaryRecord?.model,
props.summaryRecord?.target_model,
props.summaryRecord?.model_version,
props.summaryRecord?.response_model,
props.summaryRecord?.request_type,
props.summaryRecord?.requested_reasoning_effort,
props.summaryRecord?.reasoning_effort,
@@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
const responseModel = resolveHeaderModelTextField('response_model', nextDetail)
emit('requestState', {
id,
@@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
...(reasoningEffort ? { reasoningEffort } : {}),
...(serviceTier ? { serviceTier } : {}),
...(actualServiceTier ? { actualServiceTier } : {}),
...(responseModel ? { responseModel } : {}),
errorMessage: nextDetail.error_message ?? undefined,
updatedAt: nextDetail.updated_at ?? undefined,
})
@@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => {
return {
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
target_model: resolveHeaderModelTextField('target_model', currentDetail),
model_version: resolveHeaderModelTextField('model_version', currentDetail),
response_model: resolveHeaderModelTextField('response_model', currentDetail),
request_type: resolveHeaderModelTextField('request_type', currentDetail),
requested_reasoning_effort: resolveHeaderModelTextField(
'requested_reasoning_effort',
@@ -16,11 +16,22 @@
:class="modelClass"
data-usage-model-source
>{{ record.model }}</span>
<template v-if="actualModel">
<span
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground"
data-usage-model-target
><span class="mr-1">-&gt;</span>{{ actualModel }}</span>
<template v-if="hasModelFacts">
<div
class="order-last basis-full flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-muted-foreground"
data-usage-model-facts
>
<span
v-if="mappingModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-mapping
><span class="mr-1 text-[10px] text-muted-foreground/70">映射模型</span>{{ mappingModel }}</span>
<span
v-if="responseModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-response
><span class="mr-1 text-[10px] text-muted-foreground/70">响应模型</span>{{ responseModel }}</span>
</div>
</template>
<template v-if="!shouldStackBadges">
<Badge
@@ -83,7 +94,7 @@ interface ModelBadgePresentation {
interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -113,15 +124,20 @@ const props = withDefaults(defineProps<{
showReasoningBadge: true,
})
const actualModel = computed(() => {
const mappingModel = computed(() => {
const targetModel = normalizeText(props.record.target_model)
if (targetModel && targetModel !== props.record.model) return targetModel
const modelVersion = normalizeText(props.record.model_version)
if (modelVersion && modelVersion !== props.record.model) return modelVersion
if (targetModel && targetModel !== normalizeText(props.record.model)) return targetModel
return null
})
const responseModel = computed(() => {
const response = normalizeText(props.record.response_model)
if (response && response !== normalizeText(props.record.model)) return response
return null
})
const hasModelFacts = computed(() => mappingModel.value !== null || responseModel.value !== null)
const reasoningLabel = computed(() => {
const requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort)
@@ -191,7 +207,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
const shouldStackBadges = computed(() => (
actualModel.value === null && modelBadges.value.length >= 3
!hasModelFacts.value && modelBadges.value.length >= 3
))
function normalizeText(value: string | null | undefined): string | null {
@@ -1656,20 +1656,6 @@ function getApiFormatTooltip(record: UsageRecord): string {
return displayFormat
}
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
function getActualModel(record: UsageRecord): string | null {
// 优先显示模型映射
if (record.target_model && record.target_model !== record.model) {
return record.target_model
}
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
if (record.model_version && record.model_version !== record.model) {
return record.model_version
}
return null
}
function getReasoningEffort(record: UsageRecord): string | null {
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
@@ -1744,15 +1730,18 @@ function getServiceTierTitle(record: UsageRecord): string {
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
return `${record.model}${suffix}`
const requestModel = record.model.trim()
const mappingModel = record.target_model?.trim()
const responseModel = record.response_model?.trim()
return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
}
</script>
@@ -313,13 +313,14 @@ describe('RequestDetailDrawer settlement pricing', () => {
{ tier: 'priority', label: 'Fast', badgeKey: 'fast' },
{ tier: 'ultrafast', label: 'ultrafast', badgeKey: 'service-tier' },
{ tier: 'future-tier', label: 'future-tier', badgeKey: 'service-tier' },
])('shows mapping, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
])('shows mapping, response model, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-risk-demo',
request_id: 'req_usage-cyber-risk-demo',
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
request_body: {
@@ -382,10 +383,11 @@ describe('RequestDetailDrawer settlement pricing', () => {
)
const modelRow = modelLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
.toBe(true)
expect(modelRow?.classList.contains('flex-wrap')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent)
.toContain('gpt-5.2')
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(modelRow?.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent)
@@ -462,7 +464,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
@@ -573,8 +575,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1')
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent?.trim())
.toBe('映射模型gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -585,7 +587,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
})
it('uses detail model_version when the lightweight summary has null model facts', async () => {
it('does not use legacy model_version as a response-model fallback', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-version-summary',
@@ -628,8 +630,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1-2026-07-17')
expect(document.body.querySelector('[data-usage-model-response]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
})
})
@@ -689,7 +691,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
}
await nextTick()
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -461,10 +461,11 @@ describe('UsageRecordsTable', () => {
.toBe('会话压缩')
})
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
it('shows mapping and response models as separately labelled facts', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
@@ -477,6 +478,8 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('gpt-5.1')
expect(root.textContent).toContain('映射模型')
expect(root.textContent).toContain('响应模型')
expect(root.textContent).toContain('xhigh -> max')
expect(root.textContent).toContain('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
@@ -510,15 +513,35 @@ describe('UsageRecordsTable', () => {
expect(inlineLayout).not.toBeNull()
const modelRow = inlineLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('order-last')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent).toContain('映射模型')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast')
expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber')
})
it('shows a response model without inventing a mapping arrow or using model_version', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5.1',
model_version: 'legacy-version',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(root.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(root.textContent).not.toContain('legacy-version')
expect(root.textContent).not.toContain('->')
})
it('hides a response model when it matches the request model', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-response]')).toBeNull()
})
it('stacks three model badges even without a model mapping', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
@@ -716,7 +716,13 @@ export function useUsageData(options: UseUsageDataOptions) {
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier
: null)
: existing.actual_service_tier
: existing.actual_service_tier,
// 终态列表快照是最终候选的权威事实;空值必须清除旧候选的响应模型。
response_model: statusProgressed
? (typeof record.response_model === 'string' && record.response_model.trim()
? record.response_model
: null)
: existing.response_model
}
})
}
+10
View File
@@ -651,6 +651,10 @@ async function pollActiveRequests() {
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier
: null
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
? update.response_model
: null
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
@@ -1150,6 +1154,7 @@ function handleDetailRequestState(update: {
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1292,6 +1297,11 @@ function handleDetailRequestState(update: {
? update.actualServiceTier
: null
}
if ('responseModel' in update) {
record.response_model = typeof update.responseModel === 'string'
? update.responseModel
: null
}
if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) {