From: Stefan Gasser Date: Fri, 16 Jan 2026 22:30:36 +0000 (+0100) Subject: fix: log and display API errors in dashboard (#40) X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=999d62f3eb0ba9458461184819ec74422be3c38e;p=sgasser-llm-shield.git fix: log and display API errors in dashboard (#40) Fixes #35 - Add LLMError class to preserve upstream status code and body - Add status_code and error_message columns to request logs - Add Status column to dashboard with OK/error badges - Pass through upstream errors (429, 401, etc.) with original status - Return OpenAI-compatible JSON format for all error responses - Set X-PasteGuard headers consistently via Hono context --- diff --git a/src/routes/proxy.ts b/src/routes/proxy.ts index 7e6407b..4700d2b 100644 --- a/src/routes/proxy.ts +++ b/src/routes/proxy.ts @@ -1,7 +1,6 @@ import { zValidator } from "@hono/zod-validator"; import type { Context } from "hono"; import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; import { proxy } from "hono/proxy"; import { z } from "zod"; import { getConfig, type MaskingConfig } from "../config"; @@ -12,11 +11,12 @@ import { } from "../secrets/detect"; import { type RedactionContext, redactSecrets, unredactResponse } from "../secrets/redact"; import { getRouter, type MaskDecision, type RoutingDecision } from "../services/decision"; -import type { - ChatCompletionRequest, - ChatCompletionResponse, - ChatMessage, - LLMResult, +import { + type ChatCompletionRequest, + type ChatCompletionResponse, + type ChatMessage, + LLMError, + type LLMResult, } from "../services/llm-client"; import { logRequest, type RequestLogData } from "../services/logger"; import { unmaskResponse } from "../services/masking"; @@ -48,6 +48,41 @@ function isMaskDecision(decision: RoutingDecision): decision is MaskDecision { return decision.mode === "mask"; } +/** + * Create log data for error responses + */ +function createErrorLogData( + body: ChatCompletionRequest, + startTime: number, + statusCode: number, + errorMessage: string, + decision?: RoutingDecision, + secretsResult?: SecretsDetectionResult, + maskedContent?: string, +): RequestLogData { + const config = getConfig(); + return { + timestamp: new Date().toISOString(), + mode: decision?.mode ?? config.mode, + provider: decision?.provider ?? "upstream", + model: body.model || "unknown", + piiDetected: decision?.piiResult.hasPII ?? false, + entities: decision + ? [...new Set(decision.piiResult.newEntities.map((e) => e.entity_type))] + : [], + latencyMs: Date.now() - startTime, + scanTimeMs: decision?.piiResult.scanTimeMs ?? 0, + language: decision?.piiResult.language ?? config.pii_detection.fallback_language, + languageFallback: decision?.piiResult.languageFallback ?? false, + detectedLanguage: decision?.piiResult.detectedLanguage, + maskedContent, + secretsDetected: secretsResult?.detected, + secretsTypes: secretsResult?.matches.map((m) => m.type), + statusCode, + errorMessage, + }; +} + proxyRoutes.get("/models", (c) => { const { upstream } = getRouter().getProvidersInfo(); @@ -153,7 +188,23 @@ proxyRoutes.post( decision = await router.decide(body.messages, secretsResult); } catch (error) { console.error("PII detection error:", error); - throw new HTTPException(503, { message: "PII detection service unavailable" }); + const errorMessage = "PII detection service unavailable"; + logRequest( + createErrorLogData(body, startTime, 503, errorMessage, undefined, secretsResult), + c.req.header("User-Agent") || null, + ); + + return c.json( + { + error: { + message: errorMessage, + type: "server_error", + param: null, + code: "service_unavailable", + }, + }, + 503, + ); } return handleCompletion( @@ -317,14 +368,31 @@ async function handleCompletion( maskedContent = formatMessagesForLog(decision.maskedMessages); } - try { - const result = await client.chatCompletion(request, authHeader); + // Determine secrets state + const secretsDetected = secretsResult?.detected ?? false; + const secretsTypes = secretsResult?.matches.map((m) => m.type) ?? []; - // Determine secrets state from passed result - const secretsDetected = secretsResult?.detected ?? false; - const secretsTypes = secretsResult?.matches.map((m) => m.type) ?? []; + // Set response headers (included automatically by c.json/c.body) + c.header("X-PasteGuard-Mode", decision.mode); + c.header("X-PasteGuard-Provider", decision.provider); + c.header("X-PasteGuard-PII-Detected", decision.piiResult.hasPII.toString()); + c.header("X-PasteGuard-Language", decision.piiResult.language); + if (decision.piiResult.languageFallback) { + c.header("X-PasteGuard-Language-Fallback", "true"); + } + if (decision.mode === "mask") { + c.header("X-PasteGuard-PII-Masked", decision.piiResult.hasPII.toString()); + } + if (secretsDetected && secretsTypes.length > 0) { + c.header("X-PasteGuard-Secrets-Detected", "true"); + c.header("X-PasteGuard-Secrets-Types", secretsTypes.join(",")); + } + if (secretsRedacted) { + c.header("X-PasteGuard-Secrets-Redacted", "true"); + } - setPasteGuardHeaders(c, decision, secretsDetected, secretsTypes, secretsRedacted); + try { + const result = await client.chatCompletion(request, authHeader); if (result.isStreaming) { return handleStreamingResponse( @@ -353,37 +421,56 @@ async function handleCompletion( ); } catch (error) { console.error("LLM request error:", error); + + // Pass through upstream LLM errors with original status code + if (error instanceof LLMError) { + logRequest( + createErrorLogData( + body, + startTime, + error.status, + error.message, + decision, + secretsResult, + maskedContent, + ), + c.req.header("User-Agent") || null, + ); + + // Pass through upstream error - must use Response for dynamic status code + return new Response(error.body, { + status: error.status, + headers: c.res.headers, + }); + } + + // For other errors (network, timeout, etc.), return 502 in OpenAI-compatible format const message = error instanceof Error ? error.message : "Unknown error"; - throw new HTTPException(502, { message: `LLM provider error: ${message}` }); - } -} + const errorMessage = `Provider error: ${message}`; + logRequest( + createErrorLogData( + body, + startTime, + 502, + errorMessage, + decision, + secretsResult, + maskedContent, + ), + c.req.header("User-Agent") || null, + ); -/** - * Set X-PasteGuard response headers - */ -function setPasteGuardHeaders( - c: Context, - decision: RoutingDecision, - secretsDetected?: boolean, - secretsTypes?: string[], - secretsRedacted?: boolean, -) { - c.header("X-PasteGuard-Mode", decision.mode); - c.header("X-PasteGuard-Provider", decision.provider); - c.header("X-PasteGuard-PII-Detected", decision.piiResult.hasPII.toString()); - c.header("X-PasteGuard-Language", decision.piiResult.language); - if (decision.piiResult.languageFallback) { - c.header("X-PasteGuard-Language-Fallback", "true"); - } - if (decision.mode === "mask") { - c.header("X-PasteGuard-PII-Masked", decision.piiResult.hasPII.toString()); - } - if (secretsDetected && secretsTypes && secretsTypes.length > 0) { - c.header("X-PasteGuard-Secrets-Detected", "true"); - c.header("X-PasteGuard-Secrets-Types", secretsTypes.join(",")); - } - if (secretsRedacted) { - c.header("X-PasteGuard-Secrets-Redacted", "true"); + return c.json( + { + error: { + message: errorMessage, + type: "server_error", + param: null, + code: "upstream_error", + }, + }, + 502, + ); } } diff --git a/src/services/llm-client.ts b/src/services/llm-client.ts index b986a14..4120add 100644 --- a/src/services/llm-client.ts +++ b/src/services/llm-client.ts @@ -58,6 +58,20 @@ export type LLMResult = provider: "upstream" | "local"; }; +/** + * Error from upstream LLM provider with original status code and response + */ +export class LLMError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: string, + ) { + super(`API error: ${status} ${statusText}`); + this.name = "LLMError"; + } +} + /** * LLM Client for OpenAI-compatible APIs (OpenAI, Ollama, etc.) */ @@ -134,7 +148,7 @@ export class LLMClient { if (!response.ok) { const errorText = await response.text(); - throw new Error(`LLM API error: ${response.status} ${response.statusText} - ${errorText}`); + throw new LLMError(response.status, response.statusText, errorText); } if (isStreaming) { diff --git a/src/services/logger.ts b/src/services/logger.ts index 7cf66b6..0d86880 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -21,6 +21,8 @@ export interface RequestLog { masked_content: string | null; secrets_detected: number | null; secrets_types: string | null; + status_code: number | null; + error_message: string | null; } /** @@ -84,7 +86,7 @@ export class Logger { ) `); - // Migrate existing databases: add secrets columns if missing + // Migrate existing databases: add missing columns const columns = this.db.prepare("PRAGMA table_info(request_logs)").all() as Array<{ name: string; }>; @@ -92,6 +94,10 @@ export class Logger { this.db.run("ALTER TABLE request_logs ADD COLUMN secrets_detected INTEGER"); this.db.run("ALTER TABLE request_logs ADD COLUMN secrets_types TEXT"); } + if (!columns.find((c) => c.name === "status_code")) { + this.db.run("ALTER TABLE request_logs ADD COLUMN status_code INTEGER"); + this.db.run("ALTER TABLE request_logs ADD COLUMN error_message TEXT"); + } // Create indexes for performance this.db.run(` @@ -108,9 +114,9 @@ export class Logger { log(entry: Omit): void { const stmt = this.db.prepare(` INSERT INTO request_logs - (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content, secrets_detected, secrets_types) + (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content, secrets_detected, secrets_types, status_code, error_message) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -131,6 +137,8 @@ export class Logger { entry.masked_content, entry.secrets_detected ?? null, entry.secrets_types ?? null, + entry.status_code ?? null, + entry.error_message ?? null, ); } @@ -288,6 +296,8 @@ export interface RequestLogData { maskedContent?: string; secretsDetected?: boolean; secretsTypes?: string[]; + statusCode?: number; + errorMessage?: string; } export function logRequest(data: RequestLogData, userAgent: string | null): void { @@ -321,6 +331,8 @@ export function logRequest(data: RequestLogData, userAgent: string | null): void masked_content: shouldLogContent ? (data.maskedContent ?? null) : null, secrets_detected: data.secretsDetected !== undefined ? (data.secretsDetected ? 1 : 0) : null, secrets_types: shouldLogSecretTypes ? data.secretsTypes!.join(",") : null, + status_code: data.statusCode ?? null, + error_message: data.errorMessage ?? null, }); } catch (error) { console.error("Failed to log request:", error); diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx index d1eb1ad..931a444 100644 --- a/src/views/dashboard/page.tsx +++ b/src/views/dashboard/page.tsx @@ -373,6 +373,9 @@ const LogsSection: FC = () => ( Time + + Status + Provider @@ -395,7 +398,7 @@ const LogsSection: FC = () => ( - +
@@ -554,7 +557,7 @@ async function fetchLogs() { const tbody = document.getElementById('logs-body'); if (data.logs.length === 0) { - tbody.innerHTML = '
📋
No requests yet
'; + tbody.innerHTML = '
📋
No requests yet
'; return; } @@ -563,6 +566,7 @@ async function fetchLogs() { const entities = log.entities ? log.entities.split(',').filter(e => e.trim()) : []; const secretsTypes = log.secrets_types ? log.secrets_types.split(',').filter(s => s.trim()) : []; const secretsDetected = log.secrets_detected === 1; + const isError = log.status_code && log.status_code >= 400; const lang = log.language || 'en'; const detectedLang = log.detected_language; @@ -575,12 +579,17 @@ async function fetchLogs() { const logId = log.id || index; const isExpanded = expandedRowId === logId; + const statusBadge = isError + ? '' + log.status_code + '' + : 'OK'; + const mainRow = '' + '' + '▶' + '' + time + '' + '' + + '' + statusBadge + '' + '' + '' + log.provider + '' + @@ -600,12 +609,14 @@ async function fetchLogs() { '' + log.scan_time_ms + 'ms' + ''; + const detailContent = isError && log.error_message + ? '
' + log.error_message.replace(//g, '>') + '
' + : '
' + formatMaskedPreview(log.masked_content, entities) + '
'; + const detailRow = '' + - '' + - '
' + - '
' + formatMaskedPreview(log.masked_content, entities) + '
' + - '
' + + '' + + '
' + detailContent + '
' + '' + '';