Skip to content
74 changes: 74 additions & 0 deletions surfsense_local/backend/modules/chat/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import enum

import httpx


class ChatErrorKind(enum.StrEnum):
"""Buckets a failed generation call so the UI can offer the right fix.

Add new kinds here as new failure shapes come up; keep classify_chat_error
the single place that maps an exception to one.
"""

PROVIDER_AUTH = "provider_auth"
PROVIDER_NOT_FOUND = "provider_not_found"
PROVIDER_RATE_LIMITED = "provider_rate_limited"
PROVIDER_UNAVAILABLE = "provider_unavailable"
NETWORK = "network"
TIMEOUT = "timeout"
UNKNOWN = "unknown"


_MESSAGES: dict[ChatErrorKind, str] = {
ChatErrorKind.PROVIDER_AUTH: "Your model connection needs a new API key.",
ChatErrorKind.PROVIDER_NOT_FOUND: (
"The selected model couldn't be found — pick another in Model setup."
),
ChatErrorKind.PROVIDER_RATE_LIMITED: (
"The model provider is rate-limiting requests right now. "
"Try again in a moment."
),
ChatErrorKind.PROVIDER_UNAVAILABLE: (
"The model provider is temporarily unavailable. Try again shortly."
),
ChatErrorKind.TIMEOUT: "The model took too long to respond. Try again.",
ChatErrorKind.UNKNOWN: "Something went wrong generating a reply. Try again.",
}

# `network` is the one kind whose fix depends on the provider: a bad base URL
# is a Model setup problem, an unreachable local Ollama is not.
_NETWORK_MESSAGES: dict[str, str] = {
"ollama": "Couldn't reach Ollama — make sure it's running locally.",
}
_DEFAULT_NETWORK_MESSAGE = (
"Couldn't reach the model provider — "
"check the connection's URL in Model setup."
)

_AUTH_STATUS_CODES = {401, 403}


def classify_chat_error(exc: Exception, provider: str) -> tuple[ChatErrorKind, str]:
"""Sort a generation failure into a kind, with the plain-language text to show.

Classification is by exception type and HTTP status only, never by parsing
the exception's text, so this holds for any provider that raises through
httpx (every provider in modules/llm/providers does).
"""
if isinstance(exc, httpx.HTTPStatusError):
status_code = exc.response.status_code
if status_code in _AUTH_STATUS_CODES:
kind = ChatErrorKind.PROVIDER_AUTH
elif status_code == 404:
kind = ChatErrorKind.PROVIDER_NOT_FOUND
elif status_code == 429:
kind = ChatErrorKind.PROVIDER_RATE_LIMITED
else:
kind = ChatErrorKind.PROVIDER_UNAVAILABLE
return kind, _MESSAGES[kind]
if isinstance(exc, httpx.TimeoutException):
return ChatErrorKind.TIMEOUT, _MESSAGES[ChatErrorKind.TIMEOUT]
if isinstance(exc, httpx.TransportError):
message = _NETWORK_MESSAGES.get(provider, _DEFAULT_NETWORK_MESSAGE)
return ChatErrorKind.NETWORK, message
return ChatErrorKind.UNKNOWN, _MESSAGES[ChatErrorKind.UNKNOWN]
60 changes: 50 additions & 10 deletions surfsense_local/backend/modules/chat/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from api.dependencies import SessionDep, transact
from modules.chat.dependencies import ThreadDep
from modules.chat.errors import classify_chat_error
from modules.chat.history import build_messages
from modules.chat.models import ChatMessage, ChatThread, MessageRole
from modules.chat.prompt import build_context, resolve_citations
Expand Down Expand Up @@ -129,8 +130,8 @@ async def send_message(

async def stream() -> AsyncIterator[bytes]:
parts: list[str] = []
cited: list[dict] = []
assistant_completed_at: str | None = None
failed = False
title: str | None = None
yield _frame(
{
"type": "accepted",
Expand All @@ -149,7 +150,9 @@ async def stream() -> AsyncIterator[bytes]:
try:
title = await generate_title(generator, selected.name, payload.text)
if title:
await transact(session, _rename, thread, title)
# Shown optimistically; the rename only commits below if
# this turn ends up with a real reply, keeping a thread
# from staying renamed with nothing in it after a reload.
yield _frame({"type": "thread-title-update", "title": title})
except Exception:
session.rollback()
Expand All @@ -158,20 +161,49 @@ async def stream() -> AsyncIterator[bytes]:
thread.id,
exc_info=True,
)
cited: list[dict] = []
answer = ""
assistant_completed_at: str | None = None
try:
try:
async for delta in generator.chat(selected.name, messages):
parts.append(delta)
yield _frame({"type": "delta", "text": delta})
except Exception as exc:
# Surfaced as an event; the partial turn is still stored below.
yield _frame({"type": "error", "message": str(exc)})
# Surfaced as an event; a turn with no content at all is
# discarded below rather than left as an empty, unexplained
# reply. `finally` still runs on a client disconnect (that
# raises outside Exception), so a partial reply is never lost.
kind, message = classify_chat_error(exc, selected.provider)
yield _frame(
{
"type": "error",
"kind": kind,
"message": message,
"provider": selected.provider,
}
)
failed = True
finally:
# Rewrite [n] to [citation:<chunk_id>]. Invented tokens are dropped.
answer, used = resolve_citations("".join(parts), citations)
cited = [asdict(citation) for citation in used]
await transact(session, _complete, assistant_message, answer, cited)
assistant_completed_at = _iso(assistant_message.completed_at)
if failed and not parts:
await transact(
session, _discard_turn, user_message, assistant_message
)
else:
# A turn worth keeping: commit the deferred rename alongside
# it, so a thread is never renamed unless it ends up with a
# real first reply.
if should_generate_title and title:
await transact(session, _rename, thread, title)
# Rewrite [n] to [citation:<chunk_id>]. Invented tokens are dropped.
answer, used = resolve_citations("".join(parts), citations)
cited = [asdict(citation) for citation in used]
await transact(session, _complete, assistant_message, answer, cited)
assistant_completed_at = _iso(assistant_message.completed_at)

if failed and not parts:
yield _DONE
return

if cited:
yield _frame({"type": "citations", "items": cited})
Expand Down Expand Up @@ -259,6 +291,14 @@ def _rename(_session: Session, thread: ChatThread, title: str) -> None:
thread.title = title


def _discard_turn(
session: Session, user_message: ChatMessage, assistant_message: ChatMessage
) -> None:
"""A turn that produced no content at all leaves no trace, not a blank reply."""
session.delete(assistant_message)
session.delete(user_message)


def _complete(
_session: Session, message: ChatMessage, answer: str, cited: list[dict]
) -> None:
Expand Down
29 changes: 29 additions & 0 deletions surfsense_local/backend/tests/integration/chat/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,32 @@ def ollama_server(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[dict]]:

server.shutdown()
server.server_close()


class StubOllamaUnauthorized(BaseHTTPRequestHandler):
"""A chat endpoint that always answers 401, as if the connection were bad."""

def do_POST(self) -> None:
self.rfile.read(int(self.headers["Content-Length"]))
body = b'{"error": "unauthorized"}'
self.send_response(401)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, *args: object) -> None:
"""Keep the request log out of the test output."""


@pytest.fixture
def ollama_server_unauthorized(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""A chat stand-in that fails every request, for exercising error handling."""
server = ThreadingHTTPServer(("127.0.0.1", 0), StubOllamaUnauthorized)
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{server.server_port}"
monkeypatch.setattr(get_llm_settings(), "ollama_base_url", url)

yield

server.shutdown()
server.server_close()
25 changes: 25 additions & 0 deletions surfsense_local/backend/tests/integration/chat/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ async def fail_title(*_args: object) -> None:
assert threads[0]["title"] == "New chat"


async def test_a_failed_reply_is_classified_and_leaves_no_trace(
client: AsyncClient,
engine: Engine,
real_model: object,
ollama_server_unauthorized: None,
) -> None:
"""A generation failure is classified, not shown raw, and the turn is discarded."""
workspace_id, _ = _seed(engine)
thread_id = await _open_thread(client, workspace_id)

events = await _send(client, thread_id, "what happened?")

error = next(event for event in events if event["type"] == "error")
assert error["kind"] == "provider_auth"
assert "HTTPStatusError" not in error["message"]
assert "401" not in error["message"]
assert not any(event["type"] == "completed" for event in events)
assert not any(event["type"] == "thread-title-update" for event in events)

stored = (await client.get(f"/chat/threads/{thread_id}/messages")).json()
assert stored == []
threads = (await client.get(f"/workspaces/{workspace_id}/chat/threads")).json()
assert threads[0]["title"] == "New chat"


async def test_a_thread_with_no_model_selected_is_a_409(client: AsyncClient) -> None:
"""Refused before retrieval, so the frontend can route the user to setup."""
workspace = (await client.post("/workspaces", json={"name": "w"})).json()
Expand Down
4 changes: 2 additions & 2 deletions surfsense_local/frontend/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:self-center *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
Expand Down Expand Up @@ -54,7 +54,7 @@ function AlertDescription({
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"text-sm text-balance text-muted-foreground group-has-[>svg]/alert:col-start-2 md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
Expand Down
70 changes: 70 additions & 0 deletions surfsense_local/frontend/src/features/chat/chat-error-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useAuiState } from "@assistant-ui/react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Alert02Icon } from "@/components/ui/icons"
import { Button } from "@/components/ui/button"
import type { ChatTurnError } from "./use-chat-runtime"

type Action = "model-setup" | "retry" | "none"

function actionFor(error: ChatTurnError): Action {
switch (error.kind) {
case "provider_auth":
case "provider_not_found":
return "model-setup"
case "network":
// A bad base URL is a Model setup fix; a local Ollama that isn't
// running isn't — there's no settings action that starts it.
return error.provider === "ollama" ? "none" : "model-setup"
default:
return "retry"
}
}

export function ChatErrorNotice({
onModelSetup,
onRetry,
}: {
onModelSetup: () => void
onRetry: (assistantId: string) => void
}) {
const messageId = useAuiState(({ message }) => message.id)
const error = useAuiState(({ message }) =>
message.status?.type === "incomplete" && message.status.reason === "error"
? (message.status.error as ChatTurnError | undefined)
: undefined
)

if (!error) {
return null
}

const action = actionFor(error)

return (
<Alert variant="destructive" className="mt-2 w-auto">
<Alert02Icon />
<AlertDescription className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span>{error.message}</span>
{action === "model-setup" ? (
<Button
variant="outline"
size="sm"
className="shrink-0 text-foreground"
onClick={onModelSetup}
>
Model setup
</Button>
) : action === "retry" ? (
<Button
variant="outline"
size="sm"
className="shrink-0 text-foreground"
onClick={() => onRetry(messageId)}
>
Retry
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
6 changes: 6 additions & 0 deletions surfsense_local/frontend/src/features/chat/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

import { ChatErrorNotice } from "./chat-error-notice"
import { preprocessCitationMarkdown } from "./citation-markdown"
import {
CitationProvider,
Expand Down Expand Up @@ -147,9 +148,13 @@ export function UserMessage() {
export function AssistantMessage({
citations,
onCitation,
onModelSetup,
onRetry,
}: {
citations: Citation[]
onCitation: (chunkId: number) => void
onModelSetup: () => void
onRetry: (assistantId: string) => void
}) {
return (
<MessagePrimitive.Root className="mx-auto flex w-full max-w-xl min-w-0 flex-col items-start px-6 py-4">
Expand All @@ -158,6 +163,7 @@ export function AssistantMessage({
<MessagePrimitive.Parts components={assistantMessageParts} />
</div>
</CitationProvider>
<ChatErrorNotice onModelSetup={onModelSetup} onRetry={onRetry} />
<MessageActions hideWhenRunning timestampRight className="top-0.5" />
</MessagePrimitive.Root>
)
Expand Down
17 changes: 16 additions & 1 deletion surfsense_local/frontend/src/features/chat/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,24 @@ export type ChatStreamEvent =
| { type: "delta"; text: string }
| { type: "citations"; items: Citation[] }
| { type: "completed"; assistant_completed_at: string; text?: string }
| { type: "error"; message: string }
| {
type: "error"
kind: ChatErrorKind
message: string
provider: string
}
| { type: "done" }

// Mirrors modules/chat/errors.py's ChatErrorKind — keep the two in sync.
export type ChatErrorKind =
| "provider_auth"
| "provider_not_found"
| "provider_rate_limited"
| "provider_unavailable"
| "network"
| "timeout"
| "unknown"

function parseFrame(frame: string): ChatStreamEvent | null {
const data = frame
.split(/\r?\n/)
Expand Down
Loading
Loading