import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue' import type { RequestDetail } from '@/api/dashboard' import RequestDetailDrawer from '../RequestDetailDrawer.vue' const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn(), })) vi.mock('@/api/dashboard', async (importOriginal) => { const actual = await importOriginal() return { ...actual, dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail, }, } }) const mountedApps: Array<{ app: App, root: HTMLElement }> = [] afterEach(() => { for (const { app, root } of mountedApps.splice(0)) { app.unmount() root.remove() } apiMocks.getRequestDetail.mockReset() }) function buildEmbeddingDetail(): RequestDetail { return { id: 'usage-embedding-1', request_id: 'req-embedding-1', user: { id: 'user-1', username: 'embedding-user', email: 'embedding@example.com', }, api_key: { id: 'key-1', name: 'test-key', display: 'test-key', }, provider: 'embedding-provider', model: 'embedding-model', tokens: { input: 100, output: 0, total: 100 }, cost: { input: 0.00001, output: 0, total: 0.00001 }, request_type: 'embedding', is_stream: false, status: 'completed', status_code: 200, response_time_ms: 10, created_at: '2026-07-16T00:00:00Z', request_headers: { 'content-type': 'application/json' }, settlement: { settlement_snapshot: { pricing_snapshot: { pricing_source: 'global_default', tiered_pricing: { tiers: [{ up_to: null, input_price_per_1m: 0.1 }], }, }, }, }, } } function buildFastTierDetail(): RequestDetail { return { ...buildEmbeddingDetail(), id: 'usage-fast-tier-1', request_id: 'req-fast-tier-1', model: 'gpt-5.6-sol', service_tier: 'fast', tokens: { input: 1116, output: 3028, total: 4144 }, input_tokens: 1116, output_tokens: 3028, cache_read_input_tokens: 205568, input_cost: 0.01395, output_cost: 0.2271, cache_creation_cost: 0, cache_read_cost: 0.25696, cost: { input: 0.01395, output: 0.2271, total: 0.49801 }, settlement: { settlement_snapshot: { pricing_snapshot: { billing_processing_tier: 'fast', processing_tier_price_multiplier: 2.5, tiered_pricing: { tiers: [{ up_to: null, input_price_per_1m: 12.5, output_price_per_1m: 75, cache_creation_price_per_1m: 15.625, cache_read_price_per_1m: 1.25, cache_ttl_pricing: [ { ttl_minutes: 5, cache_creation_price_per_1m: 15.625 }, { ttl_minutes: 60, cache_creation_price_per_1m: 31.25 }, ], }], }, }, }, billing_snapshot: { resolved_dimensions: { cache_ttl_minutes: 30, }, }, }, } } describe('RequestDetailDrawer settlement pricing', () => { it('labels an unmetered OpenAI Live WebSocket detail without rendering zero usage as billing', async () => { apiMocks.getRequestDetail.mockResolvedValue({ ...buildEmbeddingDetail(), id: 'usage-live-ws-1', request_id: 'req-live-ws-1', tokens: { input: 0, output: 0, total: 0 }, input_tokens: 0, output_tokens: 0, total_tokens: 0, cost: { input: 0, output: 0, total: 0 }, total_cost: 0, is_stream: true, is_websocket: true, websocket_transport: 'codex_live_sideband', usage_available: false, } satisfies RequestDetail) let isOpen!: Ref const Host = defineComponent({ setup() { isOpen = ref(false) return () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: 'usage-live-ws-1', }) }, }) 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(() => { const transport = document.body.querySelector('[data-usage-transport="websocket"]') expect(transport?.textContent?.trim()).toBe('WS') expect(transport?.title).toBe('OpenAI Live Sideband WebSocket') expect(document.body.querySelector('[data-usage-transport="http"]')).toBeNull() expect(document.body.querySelector('[data-request-lifecycle-status]')?.textContent?.trim()) .toBe('200') expect(document.body.querySelector('[data-request-detail-usage-unavailable]')).not.toBeNull() }) const totals = [...document.body.querySelectorAll('[data-request-detail-total-cost]')] expect(totals.length).toBe(2) expect(totals.every(total => total.textContent?.trim() === '不可用')).toBe(true) }) it('shows end-to-end latency while keeping output TPS scoped to candidate timing', async () => { apiMocks.getRequestDetail.mockResolvedValue({ ...buildEmbeddingDetail(), tokens: { input: 100, output: 50, total: 150 }, input_tokens: 100, output_tokens: 50, total_tokens: 150, is_stream: true, upstream_is_stream: true, response_time_ms: 626, first_byte_time_ms: 100, end_to_end_time_ms: 10_626, end_to_end_first_byte_time_ms: 10_120, } satisfies RequestDetail) let isOpen!: Ref const Host = defineComponent({ setup() { isOpen = ref(false) return () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: 'usage-embedding-1', }) }, }) 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-transport="http"]')?.textContent?.trim()) .toBe('HTTP 流式') expect(document.body.textContent).toContain('10.12s / 10.63s') expect(document.body.textContent).toContain('95.1tps') expect(document.body.textContent).not.toContain('98.8tps') }) }) it('renders an input-only embedding tier without treating the missing output price as zero', async () => { apiMocks.getRequestDetail.mockResolvedValue(buildEmbeddingDetail()) let isOpen!: Ref const Host = defineComponent({ setup() { isOpen = ref(false) return () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: 'usage-embedding-1', }) }, }) 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-transport="http"]')?.textContent?.trim()) .toBe('HTTP 标准') expect(document.body.textContent).toContain('输入 $0.1/M') expect(document.body.textContent).toContain('输出 -') }) expect(document.body.textContent).not.toContain('输出 $0/M') }) it('shows Fast pricing as the base token cost multiplied by the Fast tier', async () => { apiMocks.getRequestDetail.mockResolvedValue(buildFastTierDetail()) let isOpen!: Ref const Host = defineComponent({ setup() { isOpen = ref(false) return () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: 'usage-fast-tier-1', }) }, }) 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.textContent).toContain('$0.199204 × 2.5 (Fast 层级)') expect(document.body.textContent).toContain('输入 $5/M') expect(document.body.textContent).toContain('输出 $30/M') expect(document.body.textContent).toContain('缓存创建 $6.25/M') expect(document.body.textContent).toContain('缓存读取 $0.5/M') expect(document.body.textContent).not.toContain('缓存创建(30min)') expect(document.body.querySelector('[data-testid="service-tier-facts"]')).toBeNull() }) }) it('shows the compact request badge in the model header', async () => { apiMocks.getRequestDetail.mockResolvedValue({ ...buildEmbeddingDetail(), id: 'usage-compact-1', request_id: 'req-compact-1', request_type: 'compact', }) let isOpen!: Ref const Host = defineComponent({ setup() { isOpen = ref(false) return () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: 'usage-compact-1', }) }, }) 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-badge="compact"]')?.textContent?.trim()) .toBe('会话压缩') }) }) 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 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="inline"]', ) 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-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) .toContain('Fast') expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent) .toContain('Cyber') expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull() 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((resolve) => { resolveDetail = resolve })) let isOpen!: Ref const requestId = ref('usage-cyber-summary') const summaryRecord = ref>({ 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-mapping]')?.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 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 const summaryRecord = ref>({ 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-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() const tierValues = [ ...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'), ].map(node => node.textContent?.trim()) expect(tierValues).toEqual(['default', 'default']) }) }) it('does not use legacy model_version as a response-model fallback', 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 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-response]')).toBeNull() expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull() }) }) 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 const summaryRecord = ref>({ 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-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() expect([...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd')] .some(node => node.textContent?.trim() === 'Fast')).toBe(false) }) })