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";
} 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";
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();
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(
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(
);
} 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,
+ );
}
}
masked_content: string | null;
secrets_detected: number | null;
secrets_types: string | null;
+ status_code: number | null;
+ error_message: string | null;
}
/**
)
`);
- // 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;
}>;
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(`
log(entry: Omit<RequestLog, "id">): 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(
entry.masked_content,
entry.secrets_detected ?? null,
entry.secrets_types ?? null,
+ entry.status_code ?? null,
+ entry.error_message ?? null,
);
}
maskedContent?: string;
secretsDetected?: boolean;
secretsTypes?: string[];
+ statusCode?: number;
+ errorMessage?: string;
}
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);
<th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
Time
</th>
+ <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+ Status
+ </th>
<th class="route-only bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
Provider
</th>
</thead>
<tbody id="logs-body">
<tr>
- <td colSpan={7}>
+ <td colSpan={8}>
<div class="flex flex-col justify-center items-center p-10 gap-3">
<div class="loader-bars">
<div class="loader-bar" />
const tbody = document.getElementById('logs-body');
if (data.logs.length === 0) {
- tbody.innerHTML = '<tr><td colspan="7"><div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📋</div><div class="text-sm">No requests yet</div></div></td></tr>';
+ tbody.innerHTML = '<tr><td colspan="8"><div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📋</div><div class="text-sm">No requests yet</div></div></td></tr>';
return;
}
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;
const logId = log.id || index;
const isExpanded = expandedRowId === logId;
+ const statusBadge = isError
+ ? '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-error/10 text-error">' + log.status_code + '</span>'
+ : '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-success/10 text-success">OK</span>';
+
const mainRow =
'<tr id="log-' + logId + '" class="cursor-pointer transition-colors hover:bg-elevated ' + (isExpanded ? 'log-row-expanded bg-elevated' : '') + '" onclick="toggleRow(' + logId + ')">' +
'<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
'<span id="arrow-' + logId + '" class="arrow-icon inline-flex items-center justify-center w-[18px] h-[18px] mr-2 rounded-sm bg-elevated text-text-muted text-[0.65rem] transition-transform ' + (isExpanded ? 'rotate-90 bg-accent/10 text-accent' : '') + '">â–¶</span>' +
'<span class="font-mono text-[0.7rem] text-text-secondary">' + time + '</span>' +
'</td>' +
+ '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + statusBadge + '</td>' +
'<td class="route-only text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
'<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide ' +
(log.provider === 'upstream' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
'<td class="font-mono text-[0.7rem] text-teal px-4 py-3 border-b border-border-subtle align-middle">' + log.scan_time_ms + 'ms</td>' +
'</tr>';
+ const detailContent = isError && log.error_message
+ ? '<div class="font-mono text-xs leading-relaxed text-error bg-error/10 border border-error/20 rounded-lg p-3 whitespace-pre-wrap break-words">' + log.error_message.replace(/</g, '<').replace(/>/g, '>') + '</div>'
+ : '<div class="font-mono text-xs leading-relaxed text-text-secondary bg-surface border border-border-subtle rounded-lg p-3 whitespace-pre-wrap break-words">' + formatMaskedPreview(log.masked_content, entities) + '</div>';
+
const detailRow =
'<tr id="detail-' + logId + '" class="' + (isExpanded ? 'detail-row-visible' : 'hidden') + '">' +
- '<td colspan="7" class="p-0 bg-detail border-b border-border-subtle">' +
- '<div class="p-4 px-5 animate-slide-down">' +
- '<div class="font-mono text-xs leading-relaxed text-text-secondary bg-surface border border-border-subtle rounded-lg p-3 whitespace-pre-wrap break-words">' + formatMaskedPreview(log.masked_content, entities) + '</div>' +
- '</div>' +
+ '<td colspan="8" class="p-0 bg-detail border-b border-border-subtle">' +
+ '<div class="p-4 px-5 animate-slide-down">' + detailContent + '</div>' +
'</td>' +
'</tr>';