mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
feat: expose upstream response model in usage records
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -9,18 +9,29 @@
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full items-center gap-1"
|
||||
:class="[modelRowClass, actualModel ? 'flex-wrap' : '']"
|
||||
:class="[modelRowClass, hasModelFacts ? 'flex-wrap' : '']"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
: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">-></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)
|
||||
@@ -189,7 +205,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 {
|
||||
|
||||
@@ -1620,20 +1620,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()
|
||||
@@ -1708,15 +1694,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>
|
||||
|
||||
+8
-9
@@ -382,8 +382,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
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-mapping]')).not.toBeNull()
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
@@ -460,7 +459,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')
|
||||
@@ -571,8 +570,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()
|
||||
@@ -583,7 +582,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',
|
||||
@@ -626,8 +625,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -687,7 +686,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()
|
||||
|
||||
@@ -460,10 +460,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',
|
||||
@@ -476,6 +477,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"]')
|
||||
@@ -509,15 +512,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user