From 8cd3f9fc4744f7214d311f2571c8ff64b16ede11 Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Fri, 20 Mar 2026 14:06:07 +0000 Subject: [PATCH] feat(ui, openai): Structured errors and link to traces in error toast (#9068) First when sending errors over SSE we now clearly identify them as such instead of just sending the error string as a chat completion message. We use this in the UI to identify errors and link to them to the traces. Signed-off-by: Richard Palethorpe --- core/http/endpoints/openai/chat.go | 24 ++-- core/http/react-ui/e2e/chat-errors.spec.js | 106 ++++++++++++++++++ .../e2e/settings-backend-logging.spec.js | 9 +- core/http/react-ui/e2e/traces.spec.js | 9 +- core/http/react-ui/src/App.css | 24 ++++ .../src/components/ErrorWithTraceLink.jsx | 11 ++ core/http/react-ui/src/components/Toast.jsx | 7 +- core/http/react-ui/src/hooks/useChat.js | 25 ++++- core/http/react-ui/src/pages/Chat.jsx | 5 + core/http/react-ui/src/pages/ImageGen.jsx | 7 +- core/http/react-ui/src/pages/Settings.jsx | 8 +- core/http/react-ui/src/pages/Sound.jsx | 7 +- core/http/react-ui/src/pages/TTS.jsx | 7 +- core/http/react-ui/src/pages/Talk.jsx | 7 +- core/http/react-ui/src/pages/Traces.jsx | 30 +++-- core/http/react-ui/src/pages/VideoGen.jsx | 7 +- tests/e2e/mock-backend/main.go | 14 +++ tests/e2e/mock_backend_test.go | 59 ++++++++++ 18 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 core/http/react-ui/e2e/chat-errors.spec.js create mode 100644 core/http/react-ui/src/components/ErrorWithTraceLink.jsx diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index db7e2613b..ac8921d67 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -779,25 +779,17 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } xlog.Error("Stream ended with error", "error", err) - stopReason := FinishReasonStop - resp := &schema.OpenAIResponse{ - ID: id, - Created: created, - Model: input.Model, // we have to return what the user sent here, due to OpenAI spec. - Choices: []schema.Choice{ - { - FinishReason: &stopReason, - Index: 0, - Delta: &schema.Message{Content: "Internal error: " + err.Error()}, - }}, - Object: "chat.completion.chunk", - Usage: *usage, + errorResp := schema.ErrorResponse{ + Error: &schema.APIError{ + Message: err.Error(), + Type: "server_error", + Code: "server_error", + }, } - respData, marshalErr := json.Marshal(resp) + respData, marshalErr := json.Marshal(errorResp) if marshalErr != nil { xlog.Error("Failed to marshal error response", "error", marshalErr) - // Send a simple error message as fallback - fmt.Fprintf(c.Response().Writer, "data: {\"error\":\"Internal error\"}\n\n") + fmt.Fprintf(c.Response().Writer, "data: {\"error\":{\"message\":\"Internal error\",\"type\":\"server_error\"}}\n\n") } else { fmt.Fprintf(c.Response().Writer, "data: %s\n\n", respData) } diff --git a/core/http/react-ui/e2e/chat-errors.spec.js b/core/http/react-ui/e2e/chat-errors.spec.js new file mode 100644 index 000000000..6e7f210e8 --- /dev/null +++ b/core/http/react-ui/e2e/chat-errors.spec.js @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test' + +async function setupChatPage(page) { + // Mock capabilities endpoint so ModelSelector auto-selects a model + await page.route('**/api/models/capabilities', (route) => { + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }], + }), + }) + }) +} + +test.describe('Chat - Error Handling', () => { + test('shows backend error message on HTTP error', async ({ page }) => { + await setupChatPage(page) + + await page.route('**/v1/chat/completions', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + error: { message: 'Model failed to load', type: 'server_error', code: 500 }, + }), + }) + }) + + await page.goto('/app/chat') + // Wait for the model to be auto-selected (ModelSelector shows model name in button) + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + + await page.locator('.chat-input').fill('Hello') + await page.locator('.chat-send-btn').click() + + await expect(page.getByRole('paragraph').filter({ hasText: 'Model failed to load' })).toBeVisible({ timeout: 10_000 }) + }) + + test('shows error with trace link on HTTP error', async ({ page }) => { + await setupChatPage(page) + + await page.route('**/v1/chat/completions', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + error: { message: 'Backend crashed unexpectedly', type: 'server_error', code: 500 }, + }), + }) + }) + + await page.goto('/app/chat') + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + + await page.locator('.chat-input').fill('Hello') + await page.locator('.chat-send-btn').click() + + await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed unexpectedly' })).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('.chat-error-trace-link')).toBeVisible() + }) + + test('shows error from SSE error event during streaming', async ({ page }) => { + await setupChatPage(page) + + await page.route('**/v1/chat/completions', (route) => { + const body = [ + 'data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n', + 'data: {"error":{"message":"Backend crashed mid-stream","type":"server_error"}}\n\n', + 'data: [DONE]\n\n', + ].join('') + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body, + }) + }) + + await page.goto('/app/chat') + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + + await page.locator('.chat-input').fill('Hello') + await page.locator('.chat-send-btn').click() + + await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed mid-stream' })).toBeVisible({ timeout: 10_000 }) + }) + + test('shows generic HTTP error when no error body', async ({ page }) => { + await setupChatPage(page) + + await page.route('**/v1/chat/completions', (route) => { + route.fulfill({ + status: 502, + contentType: 'text/plain', + body: 'Bad Gateway', + }) + }) + + await page.goto('/app/chat') + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + + await page.locator('.chat-input').fill('Hello') + await page.locator('.chat-send-btn').click() + + await expect(page.getByRole('paragraph').filter({ hasText: 'HTTP 502' })).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/core/http/react-ui/e2e/settings-backend-logging.spec.js b/core/http/react-ui/e2e/settings-backend-logging.spec.js index b01b00f39..d88486c0a 100644 --- a/core/http/react-ui/e2e/settings-backend-logging.spec.js +++ b/core/http/react-ui/e2e/settings-backend-logging.spec.js @@ -27,10 +27,15 @@ test.describe('Settings - Backend Logging', () => { }) test('save shows toast', async ({ page }) => { + // Toggle a setting to enable the Save button (it's disabled when no changes) + const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') }) + const checkbox = section.locator('input[type="checkbox"]').last() + await checkbox.locator('..').click() + // Click save button - await page.locator('button', { hasText: 'Save' }).click() + await page.locator('button', { hasText: /Save Changes/ }).click() // Verify toast appears - await expect(page.locator('text=Settings saved')).toBeVisible({ timeout: 5_000 }) + await expect(page.locator('text=Settings saved successfully')).toBeVisible({ timeout: 5_000 }) }) }) diff --git a/core/http/react-ui/e2e/traces.spec.js b/core/http/react-ui/e2e/traces.spec.js index 788be95ba..4a247b5b3 100644 --- a/core/http/react-ui/e2e/traces.spec.js +++ b/core/http/react-ui/e2e/traces.spec.js @@ -31,8 +31,9 @@ test.describe('Traces Settings', () => { await expect(page.locator('text=Enable Tracing')).toBeVisible() // The Toggle component is a