mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
merge(main): 解决 usage 展示与生命周期同步冲突
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
import { createApp, h, nextTick, reactive, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
@@ -15,6 +15,18 @@ function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
return root
|
||||
}
|
||||
|
||||
function mountReactiveElapsedTimeText(initialProps: Record<string, unknown>) {
|
||||
const props = reactive(initialProps)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ElapsedTimeText, { ...props }),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return { props, root }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
@@ -53,4 +65,30 @@ describe('ElapsedTimeText', () => {
|
||||
|
||||
expect(root.textContent).toBe('4.00s')
|
||||
})
|
||||
|
||||
it('does not pause or move total time backwards when the first-byte clock arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-17T12:00:06.250Z'))
|
||||
|
||||
const { props, root } = mountReactiveElapsedTimeText({
|
||||
status: 'pending',
|
||||
createdAt: '2026-07-17T12:00:00Z',
|
||||
responseTimeUpdatedAt: null,
|
||||
responseTimeMs: null,
|
||||
})
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
// The first-byte snapshot implies 5.85s at the same instant because its
|
||||
// timestamp is truncated to seconds. The visible clock must stay continuous.
|
||||
props.status = 'streaming'
|
||||
props.responseTimeUpdatedAt = '2026-07-17T12:00:06Z'
|
||||
props.responseTimeMs = 5600
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(Number.parseFloat(root.textContent ?? '')).toBeGreaterThanOrEqual(6.74)
|
||||
})
|
||||
})
|
||||
|
||||
+60
-29
@@ -58,9 +58,13 @@ vi.mock('../JsonContentPanel.vue', async () => {
|
||||
type: null,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'JSON',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('pre', JSON.stringify(props.data))
|
||||
return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data))
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -517,7 +521,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
|
||||
})
|
||||
|
||||
it('shows upstream response JSON inside the error block on trace nodes', async () => {
|
||||
it('shows upstream response headers and body in one error envelope', async () => {
|
||||
const upstreamErrorMessage = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-upstream-response',
|
||||
@@ -527,21 +532,33 @@ describe('HorizontalRequestTimeline', () => {
|
||||
key_name: 'Upstream Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
error_message: 'execution runtime stream returned non-success status 302',
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 302,
|
||||
headers: { location: '/' },
|
||||
status_code: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
body_ref: 'usage://request/req-1/response_body',
|
||||
body_state: 'reference',
|
||||
},
|
||||
error_flow: {
|
||||
source: 'upstream_response',
|
||||
status_code: 302,
|
||||
status_code: 400,
|
||||
classification: 'use_default',
|
||||
decision: 'use_default',
|
||||
propagation: 'none',
|
||||
retryable: false,
|
||||
safe_to_expose: false,
|
||||
message: 'execution runtime stream returned non-success status 302',
|
||||
message: 'execution runtime stream returned non-success status 400',
|
||||
},
|
||||
client_response: {
|
||||
status_code: 502,
|
||||
@@ -555,15 +572,31 @@ describe('HorizontalRequestTimeline', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('HTTP 302')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
|
||||
expect(root.textContent).toContain('HTTP 400')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 400')
|
||||
const upstreamResponse = root.querySelector<HTMLElement>('.error-upstream-response-json pre')
|
||||
expect(upstreamResponse?.dataset.title).toBe('上游响应')
|
||||
expect(JSON.parse(upstreamResponse?.textContent ?? '{}')).toEqual({
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(upstreamResponse?.textContent).not.toContain('"status_code"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"headers"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_ref"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_state"')
|
||||
expect(root.textContent).not.toContain('上游真实响应')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 400')
|
||||
expect(root.textContent).not.toContain('真实请求错误')
|
||||
expect(root.textContent).not.toContain('返回客户端响应')
|
||||
expect(root.textContent).not.toContain('上游响应')
|
||||
expect(root.textContent).not.toContain('默认处理')
|
||||
expect(root.textContent).not.toContain('none')
|
||||
expect(root.textContent).not.toContain('不再重试')
|
||||
@@ -601,12 +634,12 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
|
||||
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.type"')
|
||||
expect(errorJsonText).toContain('"analysis_hint"')
|
||||
expect(errorJsonText).toContain('"raw"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.type"')
|
||||
expect(diagnosticText).toContain('"analysis_hint"')
|
||||
expect(diagnosticText).toContain('"raw"')
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(root.querySelector('.error-upstream-response-json')?.textContent).toContain('"header":{"content-type":"application/json"}')
|
||||
})
|
||||
|
||||
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
|
||||
@@ -664,9 +697,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('finish reason')
|
||||
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"')
|
||||
})
|
||||
|
||||
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||
@@ -701,10 +733,10 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 500')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.n"')
|
||||
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.n"')
|
||||
expect(diagnosticText).toContain('断点在请求/响应格式转换器')
|
||||
})
|
||||
|
||||
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
|
||||
@@ -735,9 +767,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.temperature"')
|
||||
})
|
||||
|
||||
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
||||
|
||||
@@ -97,4 +97,388 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('输出 $0/M')
|
||||
})
|
||||
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-risk-demo',
|
||||
request_id: 'req_usage-cyber-risk-demo',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
request_body: {
|
||||
model: 'gpt-5',
|
||||
reasoning: { effort: 'xhigh' },
|
||||
},
|
||||
service_tier: 'priority',
|
||||
// A response-side tier must not be used for the Fast badge or billing.
|
||||
actual_service_tier: 'default',
|
||||
provider_request_body: {
|
||||
model: 'gpt-5.1',
|
||||
reasoning: { effort: 'max' },
|
||||
service_tier: 'priority',
|
||||
},
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
response_body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk.',
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-risk-demo',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5')
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
const modelLayout = document.body.querySelector(
|
||||
'[data-request-detail-model-layout="stacked"]',
|
||||
)
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
|
||||
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
|
||||
.toBeNull()
|
||||
const modelBadgesRow = modelLayout?.querySelector(
|
||||
'[data-request-detail-model-badges-row]',
|
||||
)
|
||||
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(modelBadgesRow?.textContent).toContain('Fast')
|
||||
expect(modelBadgesRow?.textContent).toContain('Cyber')
|
||||
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
|
||||
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['上游请求层级', '计费层级'])
|
||||
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Cyber badge stable from the selected row while lightweight detail loads', async () => {
|
||||
let resolveDetail!: (value: RequestDetail) => void
|
||||
apiMocks.getRequestDetail.mockReturnValue(new Promise<RequestDetail>((resolve) => {
|
||||
resolveDetail = resolve
|
||||
}))
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const requestId = ref('usage-cyber-summary')
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: requestId.value,
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
|
||||
resolveDetail({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-summary',
|
||||
request_id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
// Lightweight detail can legitimately omit these final-provider facts.
|
||||
target_model: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
response_body: null,
|
||||
})
|
||||
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)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale summary Cyber badge when newer detail completed successfully', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-recovered',
|
||||
request_id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-recovered',
|
||||
summaryRecord: {
|
||||
id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]'))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses populated detail fallbacks without overriding a populated summary tier', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-standard-summary',
|
||||
request_id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
// The non-empty summary tier remains authoritative over this stale fact.
|
||||
service_tier: 'priority',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: 'default',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-standard-summary',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
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-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['default', 'default'])
|
||||
})
|
||||
})
|
||||
|
||||
it('uses detail model_version when the lightweight summary has null model facts', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-version-summary',
|
||||
request_id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: 'gpt-5.1-2026-07-17',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-version-summary',
|
||||
summaryRecord: {
|
||||
id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1-2026-07-17')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a newer final-provider summary clear facts cached from an earlier candidate', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-final-candidate',
|
||||
request_id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-final-candidate',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
summaryRecord.value = {
|
||||
...summaryRecord.value,
|
||||
target_model: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.querySelector('[data-usage-model-target]')).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()
|
||||
expect([...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd')]
|
||||
.some(node => node.textContent?.trim() === 'Fast')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,12 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ServiceTierFacts', () => {
|
||||
it('renders all three facts and marks a missing actual tier explicitly', () => {
|
||||
it('renders request and billing facts from the same request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: 'flex',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -28,30 +26,25 @@ describe('ServiceTierFacts', () => {
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-facts"]')).not.toBeNull()
|
||||
expect([...root.querySelectorAll('dt')].map(node => node.textContent?.trim())).toEqual([
|
||||
'请求层级',
|
||||
'实际层级',
|
||||
'上游请求层级',
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the same Fast label for raw priority and fast facts', () => {
|
||||
it('uses the Fast label for a raw fast request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'priority',
|
||||
requested: 'fast',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -60,12 +53,10 @@ describe('ServiceTierFacts', () => {
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,8 +66,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'fast',
|
||||
priceMultiplier: 2.5,
|
||||
}),
|
||||
})
|
||||
@@ -94,8 +83,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: null,
|
||||
priceMultiplier: null,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -290,19 +290,31 @@ describe('UsageRecordsTable', () => {
|
||||
})
|
||||
|
||||
it('shows reasoning effort next to the model name', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({ reasoning_effort: 'xhigh' })])
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('xhigh')
|
||||
const inlineLayout = root.querySelector('[data-usage-model-layout="inline"]')
|
||||
expect(inlineLayout).not.toBeNull()
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh')
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
})
|
||||
|
||||
it('shows request reasoning effort while the record is pending', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
reasoning_effort: 'max',
|
||||
requested_reasoning_effort: 'max',
|
||||
reasoning_effort: null,
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('max')
|
||||
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('max')
|
||||
})
|
||||
|
||||
it('marks Responses compaction while the record is pending', () => {
|
||||
@@ -311,65 +323,145 @@ describe('UsageRecordsTable', () => {
|
||||
request_type: 'compact',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('会话压缩')
|
||||
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
|
||||
.toBe('会话压缩')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['priority', 'priority'],
|
||||
['fast', 'fast'],
|
||||
['priority', 'fast'],
|
||||
['fast', 'priority'],
|
||||
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: actual,
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows fast to standard when the provider downgrades a priority request', () => {
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model area', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
// A conflicting response-side value must not affect the Fast badge.
|
||||
actual_service_tier: 'default',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast → standard')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:default',
|
||||
'计费档位:standard',
|
||||
].join('\n'))
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('gpt-5.1')
|
||||
expect(root.textContent).toContain('xhigh -> max')
|
||||
expect(root.textContent).toContain('Fast')
|
||||
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
|
||||
const fastBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="fast"]')
|
||||
const cyberBadges = root.querySelectorAll<HTMLElement>('[data-usage-model-badge="cyber"]')
|
||||
const cyberBadge = cyberBadges[0]
|
||||
for (const badge of [reasoningBadge, fastBadge, cyberBadge]) {
|
||||
expect(badge).not.toBeNull()
|
||||
expect(badge?.classList.contains('h-4')).toBe(true)
|
||||
expect(badge?.classList.contains('rounded-full')).toBe(true)
|
||||
expect(badge?.classList.contains('px-1.5')).toBe(true)
|
||||
expect(badge?.classList.contains('text-[10px]')).toBe(true)
|
||||
expect(badge?.classList.contains('leading-4')).toBe(true)
|
||||
}
|
||||
expect(reasoningBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('text-primary')).toBe(true)
|
||||
expect(fastBadge?.getAttribute('variant')).toBe('outline-transparent')
|
||||
expect(fastBadge?.classList.contains('border-amber-400/50')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('!bg-transparent')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('bg-amber-400/10')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('text-amber-700')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('text-rose-600')).toBe(true)
|
||||
expect(cyberBadges.length).toBeGreaterThan(0)
|
||||
expect([...cyberBadges].every(badge => badge.textContent?.trim() === 'Cyber')).toBe(true)
|
||||
expect([...cyberBadges].every(badge => badge.title === '上游 Cyber Policy 拒绝')).toBe(true)
|
||||
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout).not.toBeNull()
|
||||
const modelRow = stackedLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge]')).toBeNull()
|
||||
const badgesRow = stackedLayout?.querySelector('[data-usage-model-badges-row]')
|
||||
expect(badgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(badgesRow?.textContent).toContain('Fast')
|
||||
expect(badgesRow?.textContent).toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows fast to flex when the provider moves a priority request to flex', () => {
|
||||
it('stacks three model badges even without a model mapping', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'flex',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast → flex')
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout?.firstElementChild?.textContent?.trim()).toBe('gpt-5')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('xhigh')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows standard to fast when the provider upgrades a default request', () => {
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
'shows Fast from the final provider request tier %s',
|
||||
(requested) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'上游请求档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'上游请求档位:Fast,计费档位:Fast',
|
||||
)
|
||||
expect(badge.textContent).not.toContain('→')
|
||||
expect(badge.textContent).not.toContain('待确认')
|
||||
expect(badge.textContent).not.toContain('未确认')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['default', 'flex', null])(
|
||||
'ignores the response-side tier %s when the request tier is Fast',
|
||||
(actualServiceTier) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: actualServiceTier,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
expect(root.textContent).not.toContain('Fast →')
|
||||
},
|
||||
)
|
||||
|
||||
it('does not infer Fast from a response-side priority tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'default',
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'standard → Fast')
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not infer Fast when only the response has a tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: null,
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['pending', 'streaming'] as const)(
|
||||
'shows fast as pending confirmation while a priority request is %s',
|
||||
'keeps Fast stable while a priority request is %s',
|
||||
(status) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
@@ -377,18 +469,18 @@ describe('UsageRecordsTable', () => {
|
||||
status,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 待确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
},
|
||||
)
|
||||
|
||||
it('shows fast as unconfirmed when a completed priority request has no actual tier', () => {
|
||||
it('keeps Fast stable for a completed request without a response tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: null,
|
||||
status: 'completed',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 未确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
|
||||
Reference in New Issue
Block a user