Skip to content

Commit ce87b3b

Browse files
committed
feat(ui, openai): Structured errors and link to traces in error toast
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 <io@richiejp.com>
1 parent 9a9da06 commit ce87b3b

18 files changed

Lines changed: 324 additions & 42 deletions

File tree

core/http/endpoints/openai/chat.go

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -779,25 +779,17 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
779779
}
780780
xlog.Error("Stream ended with error", "error", err)
781781

782-
stopReason := FinishReasonStop
783-
resp := &schema.OpenAIResponse{
784-
ID: id,
785-
Created: created,
786-
Model: input.Model, // we have to return what the user sent here, due to OpenAI spec.
787-
Choices: []schema.Choice{
788-
{
789-
FinishReason: &stopReason,
790-
Index: 0,
791-
Delta: &schema.Message{Content: "Internal error: " + err.Error()},
792-
}},
793-
Object: "chat.completion.chunk",
794-
Usage: *usage,
782+
errorResp := schema.ErrorResponse{
783+
Error: &schema.APIError{
784+
Message: err.Error(),
785+
Type: "server_error",
786+
Code: "server_error",
787+
},
795788
}
796-
respData, marshalErr := json.Marshal(resp)
789+
respData, marshalErr := json.Marshal(errorResp)
797790
if marshalErr != nil {
798791
xlog.Error("Failed to marshal error response", "error", marshalErr)
799-
// Send a simple error message as fallback
800-
fmt.Fprintf(c.Response().Writer, "data: {\"error\":\"Internal error\"}\n\n")
792+
fmt.Fprintf(c.Response().Writer, "data: {\"error\":{\"message\":\"Internal error\",\"type\":\"server_error\"}}\n\n")
801793
} else {
802794
fmt.Fprintf(c.Response().Writer, "data: %s\n\n", respData)
803795
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
async function setupChatPage(page) {
4+
// Mock capabilities endpoint so ModelSelector auto-selects a model
5+
await page.route('**/api/models/capabilities', (route) => {
6+
route.fulfill({
7+
contentType: 'application/json',
8+
body: JSON.stringify({
9+
data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }],
10+
}),
11+
})
12+
})
13+
}
14+
15+
test.describe('Chat - Error Handling', () => {
16+
test('shows backend error message on HTTP error', async ({ page }) => {
17+
await setupChatPage(page)
18+
19+
await page.route('**/v1/chat/completions', (route) => {
20+
route.fulfill({
21+
status: 500,
22+
contentType: 'application/json',
23+
body: JSON.stringify({
24+
error: { message: 'Model failed to load', type: 'server_error', code: 500 },
25+
}),
26+
})
27+
})
28+
29+
await page.goto('/app/chat')
30+
// Wait for the model to be auto-selected (ModelSelector shows model name in button)
31+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
32+
33+
await page.locator('.chat-input').fill('Hello')
34+
await page.locator('.chat-send-btn').click()
35+
36+
await expect(page.locator('text=Model failed to load')).toBeVisible({ timeout: 10_000 })
37+
})
38+
39+
test('shows error with trace link on HTTP error', async ({ page }) => {
40+
await setupChatPage(page)
41+
42+
await page.route('**/v1/chat/completions', (route) => {
43+
route.fulfill({
44+
status: 500,
45+
contentType: 'application/json',
46+
body: JSON.stringify({
47+
error: { message: 'Backend crashed unexpectedly', type: 'server_error', code: 500 },
48+
}),
49+
})
50+
})
51+
52+
await page.goto('/app/chat')
53+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
54+
55+
await page.locator('.chat-input').fill('Hello')
56+
await page.locator('.chat-send-btn').click()
57+
58+
await expect(page.locator('text=Backend crashed unexpectedly')).toBeVisible({ timeout: 10_000 })
59+
await expect(page.locator('.chat-error-trace-link')).toBeVisible()
60+
})
61+
62+
test('shows error from SSE error event during streaming', async ({ page }) => {
63+
await setupChatPage(page)
64+
65+
await page.route('**/v1/chat/completions', (route) => {
66+
const body = [
67+
'data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n',
68+
'data: {"error":{"message":"Backend crashed mid-stream","type":"server_error"}}\n\n',
69+
'data: [DONE]\n\n',
70+
].join('')
71+
route.fulfill({
72+
status: 200,
73+
headers: { 'Content-Type': 'text/event-stream' },
74+
body,
75+
})
76+
})
77+
78+
await page.goto('/app/chat')
79+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
80+
81+
await page.locator('.chat-input').fill('Hello')
82+
await page.locator('.chat-send-btn').click()
83+
84+
await expect(page.locator('text=Backend crashed mid-stream')).toBeVisible({ timeout: 10_000 })
85+
})
86+
87+
test('shows generic HTTP error when no error body', async ({ page }) => {
88+
await setupChatPage(page)
89+
90+
await page.route('**/v1/chat/completions', (route) => {
91+
route.fulfill({
92+
status: 502,
93+
contentType: 'text/plain',
94+
body: 'Bad Gateway',
95+
})
96+
})
97+
98+
await page.goto('/app/chat')
99+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
100+
101+
await page.locator('.chat-input').fill('Hello')
102+
await page.locator('.chat-send-btn').click()
103+
104+
await expect(page.locator('text=HTTP 502')).toBeVisible({ timeout: 10_000 })
105+
})
106+
})

core/http/react-ui/e2e/settings-backend-logging.spec.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ test.describe('Settings - Backend Logging', () => {
2727
})
2828

2929
test('save shows toast', async ({ page }) => {
30+
// Toggle a setting to enable the Save button (it's disabled when no changes)
31+
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
32+
const checkbox = section.locator('input[type="checkbox"]').last()
33+
await checkbox.locator('..').click()
34+
3035
// Click save button
31-
await page.locator('button', { hasText: 'Save' }).click()
36+
await page.locator('button', { hasText: /Save Changes/ }).click()
3237

3338
// Verify toast appears
34-
await expect(page.locator('text=Settings saved')).toBeVisible({ timeout: 5_000 })
39+
await expect(page.locator('text=Settings saved successfully')).toBeVisible({ timeout: 5_000 })
3540
})
3641
})

core/http/react-ui/e2e/traces.spec.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ test.describe('Traces Settings', () => {
3131
await expect(page.locator('text=Enable Tracing')).toBeVisible()
3232

3333
// The Toggle component is a <label> wrapping a hidden checkbox.
34-
// Target the checkbox within the settings panel.
35-
const checkbox = page.locator('input[type="checkbox"]')
34+
// Use .first() on the checkbox to target the Enable Tracing toggle
35+
// (it appears before the Enable Backend Logging toggle in the DOM).
36+
const checkbox = page.locator('input[type="checkbox"]').first()
3637

3738
// Initially enabled (server starts with tracing on)
3839
await expect(checkbox).toBeChecked()
@@ -84,8 +85,8 @@ test.describe('Traces Settings', () => {
8485
await page.locator('button', { hasText: 'Tracing is' }).click()
8586
await expect(page.locator('text=Enable Tracing')).toBeVisible()
8687

87-
// Toggle tracing off
88-
await page.locator('input[type="checkbox"]').locator('..').click()
88+
// Toggle tracing off (first checkbox is the Enable Tracing toggle)
89+
await page.locator('input[type="checkbox"]').first().locator('..').click()
8990

9091
// Save
9192
await page.locator('button', { hasText: 'Save' }).click()

core/http/react-ui/src/App.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,30 @@
477477
padding: 2px;
478478
}
479479
.toast-close:hover { opacity: 1; }
480+
.toast-link {
481+
font-size: 0.75rem;
482+
color: inherit;
483+
opacity: 0.8;
484+
text-decoration: underline;
485+
white-space: nowrap;
486+
margin-left: var(--spacing-xs);
487+
}
488+
.toast-link:hover { opacity: 1; }
489+
490+
/* Chat error trace link */
491+
.chat-error-trace-link {
492+
display: inline-flex;
493+
align-items: center;
494+
gap: var(--spacing-xs);
495+
font-size: 0.8125rem;
496+
color: var(--color-text-secondary);
497+
text-decoration: none;
498+
margin-top: var(--spacing-xs);
499+
}
500+
.chat-error-trace-link:hover {
501+
color: var(--color-primary);
502+
text-decoration: underline;
503+
}
480504

481505
/* Spinner */
482506
.spinner {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function ErrorWithTraceLink({ message, style }) {
2+
return (
3+
<div style={{ textAlign: 'center', color: 'var(--color-error)', ...style }}>
4+
<i className="fas fa-circle-exclamation" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.6 }} />
5+
<p>Error: {message}</p>
6+
<a href="/app/traces?tab=backend" className="chat-error-trace-link" style={{ justifyContent: 'center' }}>
7+
<i className="fas fa-wave-square" /> View traces for details
8+
</a>
9+
</div>
10+
)
11+
}

core/http/react-ui/src/components/Toast.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ let toastId = 0
55
export function useToast() {
66
const [toasts, setToasts] = useState([])
77

8-
const addToast = useCallback((message, type = 'info', duration = 5000) => {
8+
const addToast = useCallback((message, type = 'info', duration = 5000, options = {}) => {
99
const id = ++toastId
10-
setToasts(prev => [...prev, { id, message, type }])
10+
setToasts(prev => [...prev, { id, message, type, link: options.link }])
1111
if (duration > 0) {
1212
setTimeout(() => {
1313
setToasts(prev => prev.filter(t => t.id !== id))
@@ -63,6 +63,9 @@ function ToastItem({ toast, onRemove }) {
6363
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'}`}>
6464
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
6565
<span>{toast.message}</span>
66+
{toast.link && (
67+
<a href={toast.link.href} className="toast-link">{toast.link.text}</a>
68+
)}
6669
<button onClick={() => onRemove(toast.id)} className="toast-close">
6770
<i className="fas fa-xmark" />
6871
</button>

core/http/react-ui/src/hooks/useChat.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/t
66
const openThinkTagRegex = /<thinking>|<think>/
77
const closeThinkTagRegex = /<\/thinking>|<\/think>/
88

9+
async function extractHttpError(response) {
10+
let errorMsg = `HTTP ${response.status}`
11+
try {
12+
const errorData = await response.json()
13+
if (errorData.error?.message) errorMsg = errorData.error.message
14+
} catch (_) {}
15+
return errorMsg
16+
}
17+
918
function extractThinking(text) {
1019
let regularContent = ''
1120
let thinkingContent = ''
@@ -316,7 +325,7 @@ export function useChat(initialModel = '') {
316325
clearTimeout(timeoutId)
317326

318327
if (!response.ok) {
319-
throw new Error(`HTTP ${response.status}`)
328+
throw new Error(await extractHttpError(response))
320329
}
321330

322331
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
@@ -404,7 +413,7 @@ export function useChat(initialModel = '') {
404413
break
405414

406415
case 'error':
407-
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message}` })
416+
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message || eventData.error?.message || 'Unknown error'}` })
408417
break
409418
}
410419
} catch (_e) {
@@ -461,7 +470,7 @@ export function useChat(initialModel = '') {
461470
})
462471

463472
if (!response.ok) {
464-
throw new Error(`HTTP ${response.status}`)
473+
throw new Error(await extractHttpError(response))
465474
}
466475

467476
const reader = response.body.getReader()
@@ -485,6 +494,16 @@ export function useChat(initialModel = '') {
485494
try {
486495
const parsed = JSON.parse(data)
487496

497+
// Handle structured error events
498+
if (parsed.error) {
499+
const errMsg = typeof parsed.error === 'string'
500+
? parsed.error
501+
: parsed.error.message || 'Unknown error'
502+
rawContent += `\n\nError: ${errMsg}`
503+
setStreamingContent(rawContent)
504+
continue
505+
}
506+
488507
// Handle MCP tool result events
489508
if (parsed?.type === 'mcp_tool_result') {
490509
currentToolCalls.push({

core/http/react-ui/src/pages/Chat.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,11 @@ export default function Chat() {
11081108
}} />
11091109
)}
11101110
</div>
1111+
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
1112+
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
1113+
<i className="fas fa-wave-square" /> View traces for details
1114+
</a>
1115+
)}
11111116
<div className="chat-message-actions">
11121117
<button onClick={() => copyMessage(msg.content)} title="Copy">
11131118
<i className="fas fa-copy" />

core/http/react-ui/src/pages/ImageGen.jsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
22
import { useParams, useOutletContext } from 'react-router-dom'
33
import ModelSelector from '../components/ModelSelector'
44
import LoadingSpinner from '../components/LoadingSpinner'
5+
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
56
import { imageApi, fileToBase64 } from '../utils/api'
67

78
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
@@ -17,6 +18,7 @@ export default function ImageGen() {
1718
const [steps, setSteps] = useState('')
1819
const [seed, setSeed] = useState('')
1920
const [loading, setLoading] = useState(false)
21+
const [error, setError] = useState(null)
2022
const [images, setImages] = useState([])
2123
const [showAdvanced, setShowAdvanced] = useState(false)
2224
const [showImageInputs, setShowImageInputs] = useState(false)
@@ -32,6 +34,7 @@ export default function ImageGen() {
3234

3335
setLoading(true)
3436
setImages([])
37+
setError(null)
3538

3639
let combinedPrompt = prompt.trim()
3740
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
@@ -47,7 +50,7 @@ export default function ImageGen() {
4750
setImages(data?.data || [])
4851
if (!data?.data?.length) addToast('No images generated', 'warning')
4952
} catch (err) {
50-
addToast(`Error: ${err.message}`, 'error')
53+
setError(err.message)
5154
} finally {
5255
setLoading(false)
5356
}
@@ -131,6 +134,8 @@ export default function ImageGen() {
131134
<div className="media-result">
132135
{loading ? (
133136
<LoadingSpinner size="lg" />
137+
) : error ? (
138+
<ErrorWithTraceLink message={error} />
134139
) : images.length > 0 ? (
135140
<div className="media-result-grid">
136141
{images.map((img, i) => (

0 commit comments

Comments
 (0)