diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index 81fe982c..a7edff96 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -167,6 +167,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' interface Props { show: boolean @@ -192,11 +193,7 @@ const showUpstreamList = computed(() => props.errorType === 'request') const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '') const primaryResponseBody = computed(() => { - if (!detail.value) return '' - if (props.errorType === 'upstream') { - return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || '' - } - return detail.value.error_body || '' + return resolvePrimaryResponseBody(detail.value, props.errorType) }) @@ -224,7 +221,9 @@ const correlatedUpstreamErrors = computed(() => correlatedUpst const expandedUpstreamDetailIds = ref(new Set()) function getUpstreamResponsePreview(ev: OpsErrorDetail): string { - return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim() + const upstreamPayload = resolveUpstreamPayload(ev) + if (upstreamPayload) return upstreamPayload + return String(ev.error_body || '').trim() } function toggleUpstreamDetail(id: number) { diff --git a/frontend/src/views/admin/ops/utils/__tests__/errorDetailResponse.spec.ts b/frontend/src/views/admin/ops/utils/__tests__/errorDetailResponse.spec.ts new file mode 100644 index 00000000..7d294e0c --- /dev/null +++ b/frontend/src/views/admin/ops/utils/__tests__/errorDetailResponse.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { OpsErrorDetail } from '@/api/admin/ops' +import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../errorDetailResponse' + +function makeDetail(overrides: Partial): OpsErrorDetail { + return { + id: 1, + created_at: '2026-01-01T00:00:00Z', + phase: 'request', + type: 'api_error', + error_owner: 'platform', + error_source: 'gateway', + severity: 'P2', + status_code: 502, + platform: 'openai', + model: 'gpt-4o-mini', + is_retryable: true, + retry_count: 0, + resolved: false, + client_request_id: 'crid-1', + request_id: 'rid-1', + message: 'Upstream request failed', + user_email: 'user@example.com', + account_name: 'acc', + group_name: 'group', + error_body: '', + user_agent: '', + request_body: '', + request_body_truncated: false, + is_business_limited: false, + ...overrides + } +} + +describe('errorDetailResponse', () => { + it('prefers upstream payload for request modal when error_body is generic gateway wrapper', () => { + const detail = makeDetail({ + error_body: JSON.stringify({ + type: 'error', + error: { + type: 'upstream_error', + message: 'Upstream request failed' + } + }), + upstream_error_detail: '{"provider_message":"real upstream detail"}' + }) + + expect(resolvePrimaryResponseBody(detail, 'request')).toBe('{"provider_message":"real upstream detail"}') + }) + + it('keeps error_body for request modal when body is not generic wrapper', () => { + const detail = makeDetail({ + error_body: JSON.stringify({ + type: 'error', + error: { + type: 'upstream_error', + message: 'Upstream authentication failed, please contact administrator' + } + }), + upstream_error_detail: '{"provider_message":"real upstream detail"}' + }) + + expect(resolvePrimaryResponseBody(detail, 'request')).toBe(detail.error_body) + }) + + it('uses upstream payload first in upstream modal', () => { + const detail = makeDetail({ + phase: 'upstream', + upstream_error_message: 'provider 503 overloaded', + error_body: '{"type":"error","error":{"type":"upstream_error","message":"Upstream request failed"}}' + }) + + expect(resolvePrimaryResponseBody(detail, 'upstream')).toBe('provider 503 overloaded') + }) + + it('falls back to upstream payload when request error_body is empty', () => { + const detail = makeDetail({ + error_body: '', + upstream_error_message: 'dial tcp timeout' + }) + + expect(resolvePrimaryResponseBody(detail, 'request')).toBe('dial tcp timeout') + }) + + it('resolves upstream payload by detail -> events -> message priority', () => { + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: 'detail payload', + upstream_errors: '[{"message":"event payload"}]', + upstream_error_message: 'message payload' + }))).toBe('detail payload') + + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: '[{"message":"event payload"}]', + upstream_error_message: 'message payload' + }))).toBe('[{"message":"event payload"}]') + + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: '', + upstream_error_message: 'message payload' + }))).toBe('message payload') + }) + + it('treats empty JSON placeholders in upstream payload as empty', () => { + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: '[]', + upstream_error_message: '' + }))).toBe('') + + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: '{}', + upstream_error_message: '' + }))).toBe('') + + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: 'null', + upstream_error_message: '' + }))).toBe('') + }) + + it('skips placeholder candidates and falls back to the next upstream field', () => { + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: '', + upstream_errors: '[]', + upstream_error_message: 'fallback message' + }))).toBe('fallback message') + + expect(resolveUpstreamPayload(makeDetail({ + upstream_error_detail: 'null', + upstream_errors: '', + upstream_error_message: 'fallback message' + }))).toBe('fallback message') + }) +}) diff --git a/frontend/src/views/admin/ops/utils/errorDetailResponse.ts b/frontend/src/views/admin/ops/utils/errorDetailResponse.ts new file mode 100644 index 00000000..8fd9aed9 --- /dev/null +++ b/frontend/src/views/admin/ops/utils/errorDetailResponse.ts @@ -0,0 +1,91 @@ +import type { OpsErrorDetail } from '@/api/admin/ops' + +const GENERIC_UPSTREAM_MESSAGES = new Set([ + 'upstream request failed', + 'upstream request failed after retries', + 'upstream gateway error', + 'upstream service temporarily unavailable' +]) + +type ParsedGatewayError = { + type: string + message: string +} + +function parseGatewayErrorBody(raw: string): ParsedGatewayError | null { + const text = String(raw || '').trim() + if (!text) return null + + try { + const parsed = JSON.parse(text) as Record + const err = parsed?.error as Record | undefined + if (!err || typeof err !== 'object') return null + + const type = typeof err.type === 'string' ? err.type.trim() : '' + const message = typeof err.message === 'string' ? err.message.trim() : '' + if (!type && !message) return null + + return { type, message } + } catch { + return null + } +} + +function isGenericGatewayUpstreamError(raw: string): boolean { + const parsed = parseGatewayErrorBody(raw) + if (!parsed) return false + if (parsed.type !== 'upstream_error') return false + return GENERIC_UPSTREAM_MESSAGES.has(parsed.message.toLowerCase()) +} + +export function resolveUpstreamPayload( + detail: Pick | null | undefined +): string { + if (!detail) return '' + + const candidates = [ + detail.upstream_error_detail, + detail.upstream_errors, + detail.upstream_error_message + ] + + for (const candidate of candidates) { + const payload = String(candidate || '').trim() + if (!payload) continue + + // Normalize common "empty but present" JSON placeholders. + if (payload === '[]' || payload === '{}' || payload.toLowerCase() === 'null') { + continue + } + + return payload + } + + return '' +} + +export function resolvePrimaryResponseBody( + detail: OpsErrorDetail | null, + errorType?: 'request' | 'upstream' +): string { + if (!detail) return '' + + const upstreamPayload = resolveUpstreamPayload(detail) + const errorBody = String(detail.error_body || '').trim() + + if (errorType === 'upstream') { + return upstreamPayload || errorBody + } + + if (!errorBody) { + return upstreamPayload + } + + // For request detail modal, keep client-visible body by default. + // But if that body is a generic gateway wrapper, show upstream payload first. + if (upstreamPayload && isGenericGatewayUpstreamError(errorBody)) { + return upstreamPayload + } + + return errorBody +}