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