@@ -102,8 +111,8 @@
{{ t('admin.ops.errorDetail.message') }}
-
- {{ detail.message || '—' }}
+
+ {{ rootCauseMessage || '—' }}
@@ -116,10 +125,20 @@
-
+
+
{{ t('admin.ops.errorDetail.rootCause') }}
+
{{ rootCauseMessage }}
+
+
-
{{ t('admin.ops.errorDetail.responseBody') }}
-
{{ prettyJSON(primaryResponseBody || '') }}
+
{{ t('admin.ops.errorDetail.diagnosticPayloads') }}
+
{{ t('common.noData') }}
+
+
+
{{ diagnosticPayloadLabel(section.key) }}
+
{{ prettyJSON(section.value) }}
+
+
@@ -203,7 +222,7 @@ import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format'
-import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../utils/errorDetailResponse'
+import { resolveUpstreamPayload } from '../utils/errorDetailResponse'
interface Props {
show: boolean
@@ -228,12 +247,41 @@ const showUpstreamList = computed(() => props.errorType === 'request')
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
-const primaryResponseBody = computed(() => {
- return resolvePrimaryResponseBody(detail.value, props.errorType)
+type DiagnosticPayloadKey = 'client' | 'upstream_message' | 'upstream_detail' | 'upstream_events'
+
+const rootCauseMessage = computed(() => {
+ const current = detail.value
+ if (!current) return ''
+ for (const candidate of [current.upstream_error_message, current.upstream_error_detail, current.message, current.error_body]) {
+ const value = meaningfulPayload(candidate)
+ if (value) return value
+ }
+ return ''
})
+const diagnosticPayloadSections = computed(() => {
+ const current = detail.value
+ if (!current) return []
+ const candidates: Array<{ key: DiagnosticPayloadKey; value: string }> = [
+ { key: 'client', value: meaningfulPayload(current.error_body) },
+ { key: 'upstream_message', value: meaningfulPayload(current.upstream_error_message) },
+ { key: 'upstream_detail', value: meaningfulPayload(current.upstream_error_detail) },
+ { key: 'upstream_events', value: meaningfulPayload(current.upstream_errors) }
+ ]
+ return candidates.filter((section, index, all) => {
+ return section.value && all.findIndex(candidate => candidate.value === section.value) === index
+ })
+})
+function meaningfulPayload(candidate: unknown): string {
+ const value = String(candidate || '').trim()
+ if (!value || value === '[]' || value === '{}' || value.toLowerCase() === 'null') return ''
+ return value
+}
+function diagnosticPayloadLabel(key: DiagnosticPayloadKey): string {
+ return t(`admin.ops.errorDetail.payloads.${key}`)
+}
const title = computed(() => {
if (!props.errorId) return t('admin.ops.errorDetail.title')
@@ -358,12 +406,15 @@ watch(
{ immediate: true }
)
-const statusClass = computed(() => {
- const code = detail.value?.status_code ?? 0
+function statusBadgeClass(code: number): string {
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
-})
+}
+
+const statusClass = computed(() => statusBadgeClass(detail.value?.status_code ?? 0))
+
+const upstreamStatusClass = computed(() => statusBadgeClass(detail.value?.upstream_status_code ?? 0))
diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts
new file mode 100644
index 000000000..188c150e0
--- /dev/null
+++ b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts
@@ -0,0 +1,79 @@
+import { flushPromises, shallowMount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import OpsErrorDetailModal from '../OpsErrorDetailModal.vue'
+
+const mocks = vi.hoisted(() => ({
+ getRequestErrorDetail: vi.fn(),
+ listRequestErrorUpstreamErrors: vi.fn()
+}))
+
+vi.mock('@/api/admin/ops', () => ({
+ opsAPI: {
+ getRequestErrorDetail: mocks.getRequestErrorDetail,
+ getUpstreamErrorDetail: vi.fn(),
+ listRequestErrorUpstreamErrors: mocks.listRequestErrorUpstreamErrors
+ }
+}))
+
+vi.mock('@/stores', () => ({
+ useAppStore: () => ({ showError: vi.fn() })
+}))
+
+vi.mock('vue-i18n', async (importOriginal) => {
+ const actual = await importOriginal
()
+ return {
+ ...actual,
+ useI18n: () => ({ t: (key: string) => key })
+ }
+})
+
+describe('OpsErrorDetailModal', () => {
+ beforeEach(() => {
+ mocks.getRequestErrorDetail.mockReset()
+ mocks.listRequestErrorUpstreamErrors.mockReset()
+ mocks.listRequestErrorUpstreamErrors.mockResolvedValue({ items: [] })
+ })
+
+ it('prioritizes upstream root cause and deduplicates diagnostic payloads', async () => {
+ mocks.getRequestErrorDetail.mockResolvedValue({
+ id: 1,
+ created_at: '2026-08-19T00:00:00Z',
+ phase: 'request',
+ type: 'upstream_error',
+ error_owner: 'provider',
+ error_source: 'gateway',
+ severity: 'P1',
+ status_code: 502,
+ upstream_status_code: 429,
+ platform: 'openai',
+ model: 'gpt-5.6',
+ resolved: false,
+ request_id: 'rid-1',
+ message: 'All available accounts exhausted',
+ error_body: '{"error":"same"}',
+ upstream_error_message: 'provider rate limit exhausted',
+ upstream_error_detail: '{"error":"same"}',
+ upstream_errors: '[]',
+ account_name: 'account',
+ group_name: 'group',
+ is_business_limited: false
+ })
+
+ const wrapper = shallowMount(OpsErrorDetailModal, {
+ props: { show: true, errorId: 1, errorType: 'request' },
+ global: {
+ stubs: {
+ BaseDialog: { template: '
' },
+ Icon: true
+ }
+ }
+ })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('provider rate limit exhausted')
+ expect(wrapper.text()).toContain('admin.ops.errorDetail.upstreamStatus')
+ expect(wrapper.text()).toContain('429')
+ expect(wrapper.findAll('pre')).toHaveLength(2)
+ expect(wrapper.text()).not.toContain('admin.ops.errorDetail.payloads.upstream_detail')
+ })
+})