Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions core/http/endpoints/openai/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
106 changes: 106 additions & 0 deletions core/http/react-ui/e2e/chat-errors.spec.js
Original file line number Diff line number Diff line change
@@ -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 })
})
})
9 changes: 7 additions & 2 deletions core/http/react-ui/e2e/settings-backend-logging.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
9 changes: 5 additions & 4 deletions core/http/react-ui/e2e/traces.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ test.describe('Traces Settings', () => {
await expect(page.locator('text=Enable Tracing')).toBeVisible()

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

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

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

// Save
await page.locator('button', { hasText: 'Save' }).click()
Expand Down
24 changes: 24 additions & 0 deletions core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,30 @@
padding: 2px;
}
.toast-close:hover { opacity: 1; }
.toast-link {
font-size: 0.75rem;
color: inherit;
opacity: 0.8;
text-decoration: underline;
white-space: nowrap;
margin-left: var(--spacing-xs);
}
.toast-link:hover { opacity: 1; }

/* Chat error trace link */
.chat-error-trace-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-decoration: none;
margin-top: var(--spacing-xs);
}
.chat-error-trace-link:hover {
color: var(--color-primary);
text-decoration: underline;
}

/* Spinner */
.spinner {
Expand Down
11 changes: 11 additions & 0 deletions core/http/react-ui/src/components/ErrorWithTraceLink.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function ErrorWithTraceLink({ message, style }) {
return (
<div style={{ textAlign: 'center', color: 'var(--color-error)', ...style }}>
<i className="fas fa-circle-exclamation" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.6 }} />
<p>Error: {message}</p>
<a href="/app/traces?tab=backend" className="chat-error-trace-link" style={{ justifyContent: 'center' }}>
<i className="fas fa-wave-square" /> View traces for details
</a>
</div>
)
}
7 changes: 5 additions & 2 deletions core/http/react-ui/src/components/Toast.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ let toastId = 0
export function useToast() {
const [toasts, setToasts] = useState([])

const addToast = useCallback((message, type = 'info', duration = 5000) => {
const addToast = useCallback((message, type = 'info', duration = 5000, options = {}) => {
const id = ++toastId
setToasts(prev => [...prev, { id, message, type }])
setToasts(prev => [...prev, { id, message, type, link: options.link }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
Expand Down Expand Up @@ -69,6 +69,9 @@ function ToastItem({ toast, onRemove }) {
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'} ${toast.exiting ? 'toast-exit' : ''}`}>
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
<span>{toast.message}</span>
{toast.link && (
<a href={toast.link.href} className="toast-link">{toast.link.text}</a>
)}
<button onClick={() => onRemove(toast.id)} className="toast-close" aria-label="Dismiss notification">
<i className="fas fa-xmark" />
</button>
Expand Down
25 changes: 22 additions & 3 deletions core/http/react-ui/src/hooks/useChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/t
const openThinkTagRegex = /<thinking>|<think>/
const closeThinkTagRegex = /<\/thinking>|<\/think>/

async function extractHttpError(response) {
let errorMsg = `HTTP ${response.status}`
try {
const errorData = await response.json()
if (errorData.error?.message) errorMsg = errorData.error.message
} catch (_) {}
return errorMsg
}

function extractThinking(text) {
let regularContent = ''
let thinkingContent = ''
Expand Down Expand Up @@ -316,7 +325,7 @@ export function useChat(initialModel = '') {
clearTimeout(timeoutId)

if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
throw new Error(await extractHttpError(response))
}

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

case 'error':
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message}` })
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message || eventData.error?.message || 'Unknown error'}` })
break
}
} catch (_e) {
Expand Down Expand Up @@ -461,7 +470,7 @@ export function useChat(initialModel = '') {
})

if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
throw new Error(await extractHttpError(response))
}

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

// Handle structured error events
if (parsed.error) {
const errMsg = typeof parsed.error === 'string'
? parsed.error
: parsed.error.message || 'Unknown error'
rawContent += `\n\nError: ${errMsg}`
setStreamingContent(rawContent)
continue
}

// Handle MCP tool result events
if (parsed?.type === 'mcp_tool_result') {
currentToolCalls.push({
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/src/pages/Chat.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,11 @@ export default function Chat() {
}} />
)}
</div>
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
<i className="fas fa-wave-square" /> View traces for details
</a>
)}
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
Expand Down
7 changes: 6 additions & 1 deletion core/http/react-ui/src/pages/ImageGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import { imageApi, fileToBase64 } from '../utils/api'

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

setLoading(true)
setImages([])
setError(null)

let combinedPrompt = prompt.trim()
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
Expand All @@ -47,7 +50,7 @@ export default function ImageGen() {
setImages(data?.data || [])
if (!data?.data?.length) addToast('No images generated', 'warning')
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
setError(err.message)
} finally {
setLoading(false)
}
Expand Down Expand Up @@ -131,6 +134,8 @@ export default function ImageGen() {
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : error ? (
<ErrorWithTraceLink message={error} />
) : images.length > 0 ? (
<div className="media-result-grid">
{images.map((img, i) => (
Expand Down
Loading
Loading