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 <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-03-20 14:06:07 +00:00
committed by GitHub
parent e0ab1a8b43
commit 8cd3f9fc47
18 changed files with 324 additions and 42 deletions

View File

@@ -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)
}

View File

@@ -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 })
})
})

View File

@@ -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 })
})
})

View File

@@ -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()
@@ -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()

View File

@@ -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 {

View File

@@ -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>
)
}

View File

@@ -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))
@@ -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>

View File

@@ -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 = ''
@@ -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()
@@ -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) {
@@ -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()
@@ -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({

View File

@@ -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" />

View File

@@ -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']
@@ -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)
@@ -32,6 +34,7 @@ export default function ImageGen() {
setLoading(true)
setImages([])
setError(null)
let combinedPrompt = prompt.trim()
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
@@ -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)
}
@@ -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) => (

View File

@@ -25,6 +25,7 @@ const SECTIONS = [
export default function Settings() {
const { addToast } = useOutletContext()
const [settings, setSettings] = useState(null)
const [initialSettings, setInitialSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [resources, setResources] = useState(null)
@@ -38,6 +39,7 @@ export default function Settings() {
try {
const data = await settingsApi.get()
setSettings(data)
setInitialSettings(structuredClone(data))
} catch (err) {
addToast(`Failed to load settings: ${err.message}`, 'error')
} finally {
@@ -56,6 +58,7 @@ export default function Settings() {
setSaving(true)
try {
await settingsApi.save(settings)
setInitialSettings(structuredClone(settings))
addToast('Settings saved successfully', 'success')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
@@ -97,6 +100,7 @@ export default function Settings() {
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!settings) return <div className="page"><div className="empty-state"><p className="empty-state-text">Settings not available</p></div></div>
const isDirty = settings && initialSettings && JSON.stringify(settings) !== JSON.stringify(initialSettings)
const watchdogEnabled = settings.watchdog_idle_enabled || settings.watchdog_busy_enabled
return (
@@ -110,8 +114,8 @@ export default function Settings() {
<h1 className="page-title">Settings</h1>
<p className="page-subtitle">Configure LocalAI runtime settings</p>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
</button>
</div>

View File

@@ -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 { soundApi } from '../utils/api'
export default function Sound() {
@@ -21,6 +22,7 @@ export default function Sound() {
const [language, setLanguage] = useState('')
const [timesignature, setTimesignature] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [audioUrl, setAudioUrl] = useState(null)
const audioRef = useRef(null)
@@ -49,6 +51,7 @@ export default function Sound() {
setLoading(true)
setAudioUrl(null)
setError(null)
try {
const blob = await soundApi.generate(body)
@@ -57,7 +60,7 @@ export default function Sound() {
addToast('Sound generated', 'success')
setTimeout(() => audioRef.current?.play().catch(() => {}), 100)
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
setError(err.message)
} finally {
setLoading(false)
}
@@ -132,6 +135,8 @@ export default function Sound() {
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : error ? (
<ErrorWithTraceLink message={error} />
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%', maxWidth: '400px' }} />

View File

@@ -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 { ttsApi } from '../utils/api'
export default function TTS() {
@@ -10,6 +11,7 @@ export default function TTS() {
const [model, setModel] = useState(urlModel || '')
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [audioUrl, setAudioUrl] = useState(null)
const audioRef = useRef(null)
@@ -20,6 +22,7 @@ export default function TTS() {
setLoading(true)
setAudioUrl(null)
setError(null)
try {
const blob = await ttsApi.generate({ model, input: text.trim() })
@@ -29,7 +32,7 @@ export default function TTS() {
// Auto-play
setTimeout(() => audioRef.current?.play(), 100)
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
setError(err.message)
} finally {
setLoading(false)
}
@@ -67,6 +70,8 @@ export default function TTS() {
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : error ? (
<ErrorWithTraceLink message={error} />
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%' }} />

View File

@@ -74,7 +74,7 @@ export default function Talk() {
if (!voiceEdited) setVoice(models[0].voice || '')
}
})
.catch(err => addToast(`Failed to load pipeline models: ${err.message}`, 'error'))
.catch(err => addToast(`Failed to load pipeline models: ${err.message}`, 'error', 5000, { link: { href: '/app/traces?tab=backend', text: 'View traces' } }))
.finally(() => setModelsLoading(false))
}, [])
@@ -461,6 +461,11 @@ export default function Talk() {
}}>
<i className={statusStyle.icon} style={{ color: statusStyle.color }} />
<span style={{ fontWeight: 500, color: statusStyle.color }}>{statusText}</span>
{status === 'error' && (
<a href="/app/traces?tab=backend" className="chat-error-trace-link" style={{ marginLeft: 'auto' }}>
<i className="fas fa-wave-square" /> View traces
</a>
)}
</div>
{/* Info note */}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext } from 'react-router-dom'
import { useOutletContext, useSearchParams } from 'react-router-dom'
import { tracesApi, settingsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
@@ -283,13 +283,15 @@ function ApiTraceDetail({ trace }) {
export default function Traces() {
const { addToast } = useOutletContext()
const [activeTab, setActiveTab] = useState('api')
const [searchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState(() => searchParams.get('tab') === 'backend' ? 'backend' : 'api')
const [traces, setTraces] = useState([])
const [apiCount, setApiCount] = useState(0)
const [backendCount, setBackendCount] = useState(0)
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
const [tracingEnabled, setTracingEnabled] = useState(null)
const [backendLoggingEnabled, setBackendLoggingEnabled] = useState(null)
const [settings, setSettings] = useState(null)
const [settingsExpanded, setSettingsExpanded] = useState(false)
const [saving, setSaving] = useState(false)
@@ -299,6 +301,7 @@ export default function Traces() {
settingsApi.get()
.then(data => {
setTracingEnabled(!!data.enable_tracing)
setBackendLoggingEnabled(!!data.enable_backend_logging)
setSettings(data)
if (!data.enable_tracing) setSettingsExpanded(true)
})
@@ -310,6 +313,7 @@ export default function Traces() {
try {
await settingsApi.save(settings)
setTracingEnabled(!!settings.enable_tracing)
setBackendLoggingEnabled(!!settings.enable_backend_logging)
addToast('Tracing settings saved', 'success')
if (settings.enable_tracing) setSettingsExpanded(false)
} catch (err) {
@@ -397,9 +401,11 @@ export default function Traces() {
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
</div>
{settings && (
{settings && (() => {
const allEnabled = tracingEnabled && backendLoggingEnabled
return (
<div style={{
border: `1px solid ${tracingEnabled ? 'var(--color-success-border)' : 'var(--color-warning-border)'}`,
border: `1px solid ${allEnabled ? 'var(--color-success-border)' : 'var(--color-warning-border)'}`,
borderRadius: 'var(--radius-md)',
marginBottom: 'var(--spacing-md)',
overflow: 'hidden',
@@ -409,16 +415,17 @@ export default function Traces() {
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: tracingEnabled ? 'var(--color-success-light)' : 'var(--color-warning-light)',
background: allEnabled ? 'var(--color-success-light)' : 'var(--color-warning-light)',
border: 'none', cursor: 'pointer',
color: 'var(--color-text-primary)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className={`fas ${tracingEnabled ? 'fa-circle-check' : 'fa-exclamation-triangle'}`}
style={{ color: tracingEnabled ? 'var(--color-success)' : 'var(--color-warning)', flexShrink: 0 }} />
<i className={`fas ${allEnabled ? 'fa-circle-check' : 'fa-exclamation-triangle'}`}
style={{ color: allEnabled ? 'var(--color-success)' : 'var(--color-warning)', flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem', textAlign: 'left' }}>
Tracing is <strong>{tracingEnabled ? 'enabled' : 'disabled'}</strong>
{' · Backend logging is '}<strong>{backendLoggingEnabled ? 'enabled' : 'disabled'}</strong>
{!tracingEnabled && ' — new requests will not be recorded'}
</span>
</div>
@@ -444,6 +451,12 @@ export default function Traces() {
disabled={!settings.enable_tracing}
/>
</SettingRow>
<SettingRow label="Enable Backend Logging" description="Capture backend process output per model (without requiring debug mode)">
<Toggle
checked={settings.enable_backend_logging}
onChange={(v) => setSettings(prev => ({ ...prev, enable_backend_logging: v }))}
/>
</SettingRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--spacing-sm)' }}>
<button className="btn btn-primary btn-sm" onClick={handleSaveSettings} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
@@ -452,7 +465,8 @@ export default function Traces() {
</div>
)}
</div>
)}
)
})()}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>

View File

@@ -2,6 +2,7 @@ import { useState } 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 { videoApi, fileToBase64 } from '../utils/api'
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
@@ -20,6 +21,7 @@ export default function VideoGen() {
const [seed, setSeed] = useState('')
const [cfgScale, setCfgScale] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [videos, setVideos] = useState([])
const [showAdvanced, setShowAdvanced] = useState(false)
const [showImageInputs, setShowImageInputs] = useState(false)
@@ -33,6 +35,7 @@ export default function VideoGen() {
setLoading(true)
setVideos([])
setError(null)
const [w, h] = size.split('x').map(Number)
const body = { model, prompt: prompt.trim(), width: w, height: h, fps: parseInt(fps) || 16 }
@@ -50,7 +53,7 @@ export default function VideoGen() {
setVideos(data?.data || [])
if (!data?.data?.length) addToast('No videos generated', 'warning')
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
setError(err.message)
} finally {
setLoading(false)
}
@@ -129,6 +132,8 @@ export default function VideoGen() {
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : error ? (
<ErrorWithTraceLink message={error} />
) : videos.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)', width: '100%' }}>
{videos.map((v, i) => (

View File

@@ -52,6 +52,9 @@ func (m *MockBackend) LoadModel(ctx context.Context, in *pb.ModelOptions) (*pb.R
func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.Reply, error) {
xlog.Debug("Predict called", "prompt", in.Prompt)
if strings.Contains(in.Prompt, "MOCK_ERROR") {
return nil, fmt.Errorf("mock backend predict error: simulated failure")
}
var response string
toolName := mockToolNameFromRequest(in)
if toolName != "" && !promptHasToolResults(in.Prompt) {
@@ -74,6 +77,17 @@ func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.R
func (m *MockBackend) PredictStream(in *pb.PredictOptions, stream pb.Backend_PredictStreamServer) error {
xlog.Debug("PredictStream called", "prompt", in.Prompt)
if strings.Contains(in.Prompt, "MOCK_ERROR_IMMEDIATE") {
return fmt.Errorf("mock backend stream error: simulated failure")
}
if strings.Contains(in.Prompt, "MOCK_ERROR_MIDSTREAM") {
for _, r := range "Partial resp" {
if err := stream.Send(&pb.Reply{Message: []byte(string(r))}); err != nil {
return err
}
}
return fmt.Errorf("mock backend stream error: simulated mid-stream failure")
}
var toStream string
toolName := mockToolNameFromRequest(in)
if toolName != "" && !promptHasToolResults(in.Prompt) {

View File

@@ -55,6 +55,65 @@ var _ = Describe("Mock Backend E2E Tests", Label("MockBackend"), func() {
})
})
Describe("Error Handling", func() {
Context("Non-streaming errors", func() {
It("should return error for request with error trigger", func() {
_, err := client.Chat.Completions.New(
context.TODO(),
openai.ChatCompletionNewParams{
Model: "mock-model",
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("MOCK_ERROR"),
},
},
)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated failure"))
})
})
Context("Streaming errors", func() {
It("should return error for streaming request with immediate error trigger", func() {
stream := client.Chat.Completions.NewStreaming(
context.TODO(),
openai.ChatCompletionNewParams{
Model: "mock-model",
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("MOCK_ERROR_IMMEDIATE"),
},
},
)
for stream.Next() {
// drain
}
Expect(stream.Err()).To(HaveOccurred())
})
It("should return structured error for mid-stream failure", func() {
body := `{"model":"mock-model","messages":[{"role":"user","content":"MOCK_ERROR_MIDSTREAM"}],"stream":true}`
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
Expect(err).ToNot(HaveOccurred())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
bodyStr := string(data)
// Should contain a structured error event
Expect(bodyStr).To(ContainSubstring(`"error"`))
Expect(bodyStr).To(ContainSubstring(`"message"`))
Expect(bodyStr).To(ContainSubstring("simulated mid-stream failure"))
// Should also contain [DONE]
Expect(bodyStr).To(ContainSubstring("[DONE]"))
})
})
})
Describe("Embeddings API", func() {
It("should return mocked embeddings", func() {
resp, err := client.Embeddings.New(