diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 25e17be..4f80eb7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1824,6 +1824,7 @@ export const jaOverrides = { 'management.notConfigured': '未設定', 'management.error.upstream': '上流リクエストに失敗しました', 'management.error.upstreamHttp': '上流が HTTP {status} を返しました', + 'management.error.upstreamHttpDetail': 'HTTP {status}: {message}', 'model.error.invalidBaseUrl': 'Base URL が無効です。https://api.example.com のような URL を入力してください', 'model.error.unsupportedBaseUrl': 'Base URL は http:// または https:// のみ使用できます', 'model.error.baseUrlRequired': '先に Base URL を入力してください', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b673ba1..ce535ff 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1824,6 +1824,7 @@ export const en: Record = { 'management.notConfigured': 'Not configured', 'management.error.upstream': 'Upstream request failed', 'management.error.upstreamHttp': 'Upstream returned HTTP {status}', + 'management.error.upstreamHttpDetail': 'HTTP {status}: {message}', 'model.error.invalidBaseUrl': 'Base URL is invalid. Enter a URL such as https://api.example.com', 'model.error.unsupportedBaseUrl': 'Base URL must use http:// or https://', 'model.error.baseUrlRequired': 'Enter a Base URL first', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 4bb88db..9b275e0 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1823,6 +1823,7 @@ export const zhCN = { 'management.notConfigured': '未配置', 'management.error.upstream': '上游请求失败', 'management.error.upstreamHttp': '上游返回 HTTP {status}', + 'management.error.upstreamHttpDetail': 'HTTP {status}:{message}', 'model.error.invalidBaseUrl': 'Base URL 不是有效地址,请输入类似 https://api.example.com 的 URL', 'model.error.unsupportedBaseUrl': 'Base URL 只支持 http:// 或 https:// 地址', 'model.error.baseUrlRequired': '请先输入 Base URL', diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index 7d5a386..bd73208 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -180,44 +180,93 @@ export function normalizeAuthIndex(value: unknown): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''; } -const messageFromPayload = (value: unknown, depth = 0): string => { - if (value === null || value === undefined || depth > 3) return ''; - if (typeof value === 'string') { - const text = value.trim(); - if (!text) return ''; - try { - const parsed = JSON.parse(text) as unknown; - const nested = messageFromPayload(parsed, depth + 1); - if (nested) return nested; - } catch { - } - return text; - } - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) { - for (const item of value) { - const nested = messageFromPayload(item, depth + 1); - if (nested) return nested; - } - return ''; - } - if (isRecord(value)) { - for (const key of ['message', 'error', 'detail', 'error_description', 'title']) { - const nested = messageFromPayload(value[key], depth + 1); - if (nested) return nested; +type ApiErrorDetail = { message: string; code: string }; + +const emptyApiErrorDetail = (): ApiErrorDetail => ({ message: '', code: '' }); + +const mergeApiErrorDetail = (current: ApiErrorDetail, next: ApiErrorDetail): ApiErrorDetail => ({ + message: current.message || next.message, + code: current.code || next.code, +}); + +const errorCodeFromRecord = (value: Record): string => { + for (const key of ['code', 'error_code', 'errorCode']) { + const candidate = value[key]; + if (typeof candidate === 'number' && Number.isFinite(candidate)) return String(Math.trunc(candidate)); + if (typeof candidate === 'string') { + const text = candidate.trim(); + if (text && text.length <= 64) return text; } } return ''; }; +const detailFromPayload = (value: unknown, depth = 0): ApiErrorDetail => { + if (value === null || value === undefined || depth > 3) return emptyApiErrorDetail(); + if (typeof value === 'string') { + const text = value.trim(); + if (!text) return emptyApiErrorDetail(); + try { + const parsed = JSON.parse(text) as unknown; + const nested = detailFromPayload(parsed, depth + 1); + if (nested.message || nested.code) return nested; + } catch { + } + return { message: text, code: '' }; + } + if (typeof value === 'number' || typeof value === 'boolean') return { message: String(value), code: '' }; + if (Array.isArray(value)) { + let detail = emptyApiErrorDetail(); + for (const item of value) { + detail = mergeApiErrorDetail(detail, detailFromPayload(item, depth + 1)); + if (detail.message && detail.code) break; + } + return detail; + } + if (isRecord(value)) { + let detail: ApiErrorDetail = { message: '', code: errorCodeFromRecord(value) }; + for (const key of ['message', 'error', 'detail', 'error_description', 'title']) { + detail = mergeApiErrorDetail(detail, detailFromPayload(value[key], depth + 1)); + if (detail.message && detail.code) break; + } + return detail; + } + return emptyApiErrorDetail(); +}; + +const httpStatusFromResponse = (response: Record): number => { + const status = Number(response.status_code ?? response.statusCode ?? response.status ?? 0); + return Number.isFinite(status) && status > 0 ? Math.trunc(status) : 0; +}; + +const mentionsHttpStatus = (text: string, status: number): boolean => { + if (!status) return false; + if (text.trim() === String(status)) return true; + return new RegExp(`(?:\\bhttp\\s*|\\(|\\[|^|\\s)${status}(?:\\b|\\)|\\]|\\s|$)`, 'i').test(text); +}; + export function apiCallErrorMessage( response: Record, fallback = translate(getCurrentLocale(), 'management.error.upstream'), ): string { - const status = Number(response.status_code ?? response.statusCode ?? 0); - const message = messageFromPayload(response.body ?? response.bodyText); - if (message) return message; + const locale = getCurrentLocale(); + const status = httpStatusFromResponse(response); + const detail = detailFromPayload(response.body ?? response.bodyText); + const message = status > 0 && detail.message.trim() === String(status) ? '' : detail.message; + const code = detail.code.trim(); + const showCode = Boolean( + code + && code !== String(status) + && code.toLowerCase() !== message.toLowerCase() + && !message.toLowerCase().includes(code.toLowerCase()), + ); + const text = message ? (showCode ? `${message} (${code})` : message) : code; + const httpError = status > 0 && (status < 200 || status >= 300); + if (httpError && text && !mentionsHttpStatus(text, status)) { + return translate(locale, 'management.error.upstreamHttpDetail', { status, message: text }); + } + if (text) return text; return status > 0 - ? translate(getCurrentLocale(), 'management.error.upstreamHttp', { status }) + ? translate(locale, 'management.error.upstreamHttp', { status }) : fallback; } diff --git a/src/styles/usage-subpages.css b/src/styles/usage-subpages.css index 4bd9884..8763b47 100644 --- a/src/styles/usage-subpages.css +++ b/src/styles/usage-subpages.css @@ -122,15 +122,90 @@ padding: 0 20px 0 0; } -/* The default event view is intentionally compact; secondary diagnostics remain available via tooltips and Columns. */ +/* Request log reads as separate rounded cards, matching the reference layout. */ +.usage-records-page .usage-request-log { + --usage-log-canvas: transparent; + gap: 10px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-table-wrap, +.usage-records-page .usage-request-log .usage-table-top-scrollbar { + background: transparent; +} + +.usage-records-page .usage-request-log .usage-events-table { + border-collapse: separate; + border-spacing: 0 10px; + margin-top: -10px; +} + .usage-records-page .usage-request-log .usage-events-table th, .usage-records-page .usage-request-log .usage-events-table th .usage-th-content { - height: 38px; + height: 44px; +} + +.usage-records-page .usage-request-log .usage-events-table th { + position: sticky; + top: 0; + border: 0; + background: #fff; + color: #8b97a8; + font-size: 12px; + font-weight: 650; + letter-spacing: .01em; + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-events-table th .usage-th-content { + padding: 0 16px; +} + +.usage-records-page .usage-request-log .usage-events-table thead th:first-child { + border-radius: 14px 0 0 14px; +} + +.usage-records-page .usage-request-log .usage-events-table thead th:last-child { + border-radius: 0 14px 14px 0; } .usage-records-page .usage-request-log .usage-events-table td { - height: 60px; - padding: 7px 9px; + height: 78px; + padding: 14px 16px; + border: 0; + background: #fff; + box-shadow: 0 1px 2px rgb(16 24 40 / 4%); +} + +.usage-records-page .usage-request-log .usage-events-table tbody td:first-child { + border-radius: 16px 0 0 16px; +} + +.usage-records-page .usage-request-log .usage-events-table tbody td:last-child { + border-radius: 0 16px 16px 0; +} + +.usage-records-page .usage-request-log .usage-events-table tbody tr:hover td { + background: #fff !important; + box-shadow: 0 8px 22px rgb(16 24 40 / 7%); +} + +.usage-records-page .usage-request-log .usage-th-total, +.usage-records-page .usage-request-log .usage-td-total { + border-left: 0; +} + +.usage-records-page .usage-request-log .usage-events-footer { + min-height: 48px; + margin-top: 2px; + padding: 8px 4px 2px; + border: 0; + border-radius: 0; + background: transparent; } .usage-records-page .usage-request-log .usage-events-table td strong { @@ -151,7 +226,4 @@ display: none; } -.usage-records-page .usage-request-log .usage-events-table tbody tr:nth-child(even) td { - background: color-mix(in srgb, var(--usage-log-surface) 97%, var(--ui-accent)); -} diff --git a/src/styles/workspace-white.css b/src/styles/workspace-white.css index d80cbcc..577775e 100644 --- a/src/styles/workspace-white.css +++ b/src/styles/workspace-white.css @@ -129,9 +129,7 @@ :root:not([data-theme='dark']) .workspace :is( .usage-records-page, .usage-tab-panel, - .usage-filter-panel, - .usage-request-log, - .usage-events-footer + .usage-filter-panel ) { background: #fff; } @@ -160,7 +158,7 @@ .usage-request-log .usage-events-table tbody tr:nth-child(even) td, :root:not([data-theme='dark']) .workspace .usage-records-page .usage-request-log .usage-events-table tbody tr:hover td { - background: #fff !important; + background: #fff; } :root:not([data-theme='dark']) .workspace .usage-records-page diff --git a/tests/browserMockQuota.test.ts b/tests/browserMockQuota.test.ts index 2813b55..4d1518d 100644 --- a/tests/browserMockQuota.test.ts +++ b/tests/browserMockQuota.test.ts @@ -39,7 +39,7 @@ test('edge accounts expose exhausted, unknown, disabled and upstream-error state expect((await quota('antigravity-empty-quota.json')).status).toBe('error'); expect((await quota('xai-paid-api.json')).rows[0].remainingPercent).toBeNull(); expect((await quota('codex-disabled.json')).status).toBe('error'); - expect(await quota('claude-expired.json')).toMatchObject({ status: 'error', error: 'Mock OAuth token expired' }); + expect(await quota('claude-expired.json')).toMatchObject({ status: 'error', error: 'HTTP 401: Mock OAuth token expired' }); }); test('Codex and Claude resets consume credits and refresh only that account', async () => { diff --git a/tests/devin.test.ts b/tests/devin.test.ts index 7f81b59..a55b359 100644 --- a/tests/devin.test.ts +++ b/tests/devin.test.ts @@ -84,7 +84,7 @@ describe('Devin live quota', () => { it('reports upstream errors and empty successful responses', async () => { const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 401, body: { error: 'expired' } } as never); mocks.push(post); - expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'expired' }); + expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'HTTP 401: expired' }); post.mockResolvedValue({ status_code: 200, body: liveStatus({ planInfo: { planName: 'Pro' } }) } as never); expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [] }); }); diff --git a/tests/managementApi.test.ts b/tests/managementApi.test.ts index 06785a8..b13c96a 100644 --- a/tests/managementApi.test.ts +++ b/tests/managementApi.test.ts @@ -118,19 +118,28 @@ describe('v8 Management API requests', () => { }); describe('apiCallErrorMessage', () => { - it('从对象和 JSON 字符串错误体中提取可读消息', () => { + it('保留 HTTP 状态,并从对象和 JSON 字符串错误体中提取可读消息', () => { expect(apiCallErrorMessage({ status_code: 401, - body: { error: { message: 'token expired' } }, - })).toBe('token expired'); + body: { error: { message: 'token expired', code: 'invalid_api_key' } }, + })).toBe('HTTP 401: token expired (invalid_api_key)'); + + expect(apiCallErrorMessage({ + status_code: 401, + body: { error: { message: 'Encountered invalidated oauth token for user, failing request', code: 401 } }, + })).toBe('HTTP 401: Encountered invalidated oauth token for user, failing request'); expect(apiCallErrorMessage({ statusCode: 403, bodyText: JSON.stringify({ detail: 'permission denied' }), - })).toBe('permission denied'); + })).toBe('HTTP 403: permission denied'); }); - it('没有错误体时回退到 HTTP 状态', () => { + it('消息已经包含状态码时不重复,没有错误体时回退到 HTTP 状态', () => { + expect(apiCallErrorMessage({ + status_code: 401, + body: { error: { message: 'HTTP 401: token expired' } }, + })).toBe('HTTP 401: token expired'); expect(apiCallErrorMessage({ status_code: 429 })).toBe('Upstream returned HTTP 429'); }); }); diff --git a/tests/quotaConfirmation.test.ts b/tests/quotaConfirmation.test.ts index 45e64a6..56fb966 100644 --- a/tests/quotaConfirmation.test.ts +++ b/tests/quotaConfirmation.test.ts @@ -140,7 +140,7 @@ describe('quota action confirmation with fully mocked IPC', () => { consumeError = true; expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('error'); expect(getQuotaCacheSnapshot()[key]).toMatchObject({ - rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'reset denied' }, + rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'HTTP 409: reset denied' }, }); expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true); expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled'); @@ -154,7 +154,7 @@ describe('quota action confirmation with fully mocked IPC', () => { refreshError = true; expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('refresh-error'); expect(getQuotaCacheSnapshot()[key]).toMatchObject({ - rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'usage unavailable' }, + rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'HTTP 503: usage unavailable' }, }); expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true); expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled'); diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts index b7dc954..7e15ef1 100644 --- a/tests/quotaRequests.test.ts +++ b/tests/quotaRequests.test.ts @@ -40,7 +40,7 @@ describe('quota API compatibility', () => { ...codexFile, metadata: { id_token: { chatgpt_account_id: 'account-test', plan_type: 'pro', chatgpt_subscription_active_until: '2030-01-01T00:00:00Z' } }, }); - expect(result).toMatchObject({ status: 'success', plan: 'pro', creditBalance: '12.5', creditsUnlimited: false, resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'credits forbidden' }); + expect(result).toMatchObject({ status: 'success', plan: 'pro', creditBalance: '12.5', creditsUnlimited: false, resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'HTTP 403: credits forbidden' }); expect(result.subscriptionActiveUntil).toBe('2030-01-01T00:00:00Z'); expect(calls).toHaveLength(2); for (const call of calls) { @@ -168,7 +168,7 @@ describe('xAI quota queries aligned with Management Center', () => { it('账单和回退都失败时保留原始账单错误', async () => { handler = (request) => request.url.includes('cli-chat-proxy') ? { status_code: 403, body: 'billing denied' } : { status_code: 429, body: 'paid denied' }; - expect(await loadQuota(file)).toMatchObject({ status: 'error', error: 'billing denied' }); + expect(await loadQuota(file)).toMatchObject({ status: 'error', error: 'HTTP 403: billing denied' }); expect(calls).toHaveLength(4); }); @@ -176,7 +176,7 @@ describe('xAI quota queries aligned with Management Center', () => { handler = (request) => request.url.endsWith('/me') ? success({}) : { status_code: 429, body: 'chat denied' }; expect(await loadQuota({ ...file, using_api: true, prefix: 'paid' })) - .toMatchObject({ status: 'error', error: 'chat denied' }); + .toMatchObject({ status: 'error', error: 'HTTP 429: chat denied' }); expect(calls).toHaveLength(2); }); @@ -269,7 +269,7 @@ describe('quota request synchronization', () => { const reset = consumeCodexResetCredit(codexFile); const refresh = loadQuota(codexFile); await expect(reset).rejects.toThrow('reset denied'); - expect(await refresh).toMatchObject({ status: 'error', error: 'reset denied' }); + expect(await refresh).toMatchObject({ status: 'error', error: 'HTTP 409: reset denied' }); expect((await loadQuota(codexFile)).status).toBe('success'); }); });