feat(memoro): add transcription fallback chain, AI provider fallbacks, and error tracking

Audio: WhisperX → Azure Realtime → FFmpeg → Azure Batch fallback chain with diarization.
Server: mana-llm → Gemini → Azure OpenAI fallback, rate limiting middleware.
Web: GlitchTip error tracking, error page, security headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 14:55:52 +02:00
parent bee8bcb234
commit 90f6c0db39
10 changed files with 823 additions and 426 deletions

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@ import { cleanupRoutes } from './routes/cleanup';
import { meetingRoutes } from './routes/meetings';
import { meetingWebhookRoutes } from './routes/meetings-webhooks';
import { COSTS } from './lib/credits';
import { rateLimiter } from './middleware/rate-limiter';
const app = new Hono();
@ -46,6 +47,16 @@ app.use(
})
);
// ── Rate limiting ─────────────────────────────────────────────────────────────
app.use(
'/api/v1/*',
rateLimiter({
windowMs: 60_000,
max: 100,
})
);
// ── Health check ───────────────────────────────────────────────────────────────
app.get('/health', (c) =>

View file

@ -1,17 +1,25 @@
/**
* AI text generation with Gemini (primary) Azure OpenAI (fallback).
* AI text generation with mana-llm (primary) Gemini Azure OpenAI (fallbacks).
*
* Mirrors the NestJS AiService without the DI framework.
* Fallback chain:
* 1. mana-llm (self-hosted, OpenAI-compatible API on port 3025)
* 2. Gemini (Google Cloud)
* 3. Azure OpenAI (Microsoft Cloud)
*/
// Self-hosted mana-llm service
const MANA_LLM_URL = process.env.MANA_LLM_URL || '';
const MANA_LLM_MODEL = process.env.MANA_LLM_MODEL || 'ollama/gemma3:4b';
// Gemini (cloud fallback)
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
const GEMINI_MODEL = 'gemini-2.0-flash-001';
const GEMINI_DEFAULT_TEMPERATURE = 0.7;
const GEMINI_DEFAULT_MAX_TOKENS = 1024;
// Azure OpenAI (cloud fallback)
const AZURE_API_VERSION = '2024-02-01';
const AZURE_DEFAULT_TEMPERATURE = 0.7;
const AZURE_DEFAULT_MAX_TOKENS = 1024;
const DEFAULT_TEMPERATURE = 0.7;
const DEFAULT_MAX_TOKENS = 1024;
export interface GenerateOptions {
temperature?: number;
@ -20,28 +28,79 @@ export interface GenerateOptions {
}
/**
* Generate text using Gemini with Azure OpenAI as fallback.
* Generate text using mana-llm Gemini Azure OpenAI fallback chain.
*/
export async function generateText(prompt: string, options?: GenerateOptions): Promise<string> {
const geminiKey = process.env.GEMINI_API_KEY;
// Attempt 1: Self-hosted mana-llm
if (MANA_LLM_URL) {
const result = await callManaLLM(prompt, options);
if (result !== null) return result;
console.warn('[ai] mana-llm failed, falling back to Gemini');
}
// Attempt 2: Gemini
const geminiKey = process.env.GEMINI_API_KEY;
if (geminiKey) {
const result = await callGemini(prompt, geminiKey, options);
if (result !== null) return result;
console.warn('[ai] Gemini failed, falling back to Azure OpenAI');
} else {
console.warn('[ai] No GEMINI_API_KEY, using Azure OpenAI directly');
}
// Attempt 3: Azure OpenAI
const azureKey = process.env.AZURE_OPENAI_KEY;
if (!azureKey) {
throw new Error('No AI provider available: both GEMINI_API_KEY and AZURE_OPENAI_KEY are missing');
if (azureKey) {
const result = await callAzure(prompt, azureKey, options);
if (result !== null) return result;
}
const result = await callAzure(prompt, azureKey, options);
if (result !== null) return result;
throw new Error('All AI providers failed (mana-llm, Gemini, Azure OpenAI)');
}
throw new Error('All AI providers failed');
/**
* Call self-hosted mana-llm service (OpenAI-compatible API).
*/
async function callManaLLM(prompt: string, options?: GenerateOptions): Promise<string | null> {
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
try {
const url = `${MANA_LLM_URL}/v1/chat/completions`;
const start = Date.now();
const messages: Array<{ role: string; content: string }> = [];
if (options?.systemInstruction) {
messages.push({ role: 'system', content: options.systemInstruction });
}
messages.push({ role: 'user', content: prompt });
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MANA_LLM_MODEL,
messages,
temperature,
max_tokens: maxTokens,
stream: false,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[ai] mana-llm error (${response.status}): ${errorText}`);
return null;
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content?.trim() ?? '';
console.debug(`[ai] mana-llm responded in ${Date.now() - start}ms (${content.length} chars)`);
return content || null;
} catch (error) {
console.error(`[ai] mana-llm call failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
async function callGemini(
@ -49,8 +108,8 @@ async function callGemini(
apiKey: string,
options?: GenerateOptions
): Promise<string | null> {
const temperature = options?.temperature ?? GEMINI_DEFAULT_TEMPERATURE;
const maxOutputTokens = options?.maxTokens ?? GEMINI_DEFAULT_MAX_TOKENS;
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
const maxOutputTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
try {
const url = `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${apiKey}`;
@ -102,8 +161,8 @@ async function callAzure(
return null;
}
const temperature = options?.temperature ?? AZURE_DEFAULT_TEMPERATURE;
const maxTokens = options?.maxTokens ?? AZURE_DEFAULT_MAX_TOKENS;
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
try {
const url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${AZURE_API_VERSION}`;

View file

@ -0,0 +1,63 @@
import type { MiddlewareHandler } from 'hono';
interface RateLimiterOptions {
/** Time window in milliseconds (default: 60000 = 1 minute) */
windowMs?: number;
/** Max requests per window per IP (default: 100) */
max?: number;
}
interface RateLimitEntry {
count: number;
resetAt: number;
}
/**
* Simple in-memory rate limiter middleware for Hono.
* Limits requests per IP address within a sliding time window.
*/
export function rateLimiter(options: RateLimiterOptions = {}): MiddlewareHandler {
const windowMs = options.windowMs ?? 60_000;
const max = options.max ?? 100;
const store = new Map<string, RateLimitEntry>();
// Periodic cleanup of expired entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now >= entry.resetAt) {
store.delete(key);
}
}
}, 5 * 60_000);
return async (c, next) => {
const ip =
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
c.req.header('x-real-ip') ||
'unknown';
const now = Date.now();
let entry = store.get(ip);
if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
store.set(ip, entry);
}
entry.count++;
c.header('X-RateLimit-Limit', String(max));
c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
if (entry.count > max) {
return c.json(
{ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000) },
429
);
}
await next();
};
}

View file

@ -33,6 +33,5 @@ PUBLIC_APPLE_REDIRECT_URI=http://localhost:5173/auth/apple-callback # Change to
PUBLIC_POSTHOG_KEY=your-posthog-key
PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Sentry Error Tracking (optional)
# SENTRY_AUTH_TOKEN=your-sentry-auth-token
# PUBLIC_SENTRY_DSN=https://YOUR_DSN@sentry.io/PROJECT_ID
# GlitchTip Error Tracking (self-hosted, Sentry-compatible)
PUBLIC_GLITCHTIP_DSN=

View file

@ -27,11 +27,7 @@ declare module '$env/static/public' {
export const PUBLIC_APPLE_REDIRECT_URI: string;
export const PUBLIC_POSTHOG_KEY: string;
export const PUBLIC_POSTHOG_HOST: string;
export const PUBLIC_SENTRY_DSN: string;
}
declare module '$env/static/private' {
export const SENTRY_AUTH_TOKEN: string;
export const PUBLIC_GLITCHTIP_DSN: string;
}
export {};

View file

@ -0,0 +1,12 @@
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
import type { HandleClientError } from '@sveltejs/kit';
initErrorTracking({
serviceName: 'memoro-web',
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
environment: import.meta.env.MODE,
});
export const handleError: HandleClientError = ({ error }) => {
handleSvelteError(error);
};

View file

@ -1,26 +1,32 @@
/**
* Server-side hooks for SvelteKit
* Implements custom CSRF protection that allows OAuth callbacks
* - Injects runtime environment variables for client-side use
* - Custom CSRF protection that allows OAuth callbacks
* - GlitchTip error tracking DSN injection
*/
import type { Handle } from '@sveltejs/kit';
import { injectUmamiAnalytics } from '@manacore/shared-utils/analytics-server';
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_MEMORO_SERVER_URL = process.env.PUBLIC_MEMORO_SERVER_URL || '';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
// Routes that are allowed to receive cross-origin POST requests
// (OAuth callbacks from external providers)
const ALLOWED_PATHS = [
'/auth/apple-callback-handler', // Apple Sign-In OAuth callback (server endpoint)
'/auth/apple-callback', // Apple Sign-In OAuth callback (legacy/fallback)
'/auth/google-callback', // Google Sign-In OAuth callback (if needed)
'/auth/apple-callback-handler',
'/auth/apple-callback',
'/auth/google-callback',
];
/**
* Custom CSRF protection that allows specific OAuth callback routes
* while protecting all other routes
*/
export const handle: Handle = async ({ event, resolve }) => {
const { request, url } = event;
// Only check POST, PATCH, PUT, DELETE requests
// CSRF protection: block cross-origin mutations except OAuth callbacks
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method)) {
const origin = request.headers.get('origin');
const forbidden =
@ -29,20 +35,35 @@ export const handle: Handle = async ({ event, resolve }) => {
!ALLOWED_PATHS.some((path) => url.pathname === path);
if (forbidden) {
// Log the blocked request for debugging
console.warn('CSRF: Blocked cross-origin request:', {
method: request.method,
path: url.pathname,
origin: origin,
expectedOrigin: url.origin,
});
return new Response('Cross-site POST form submissions are forbidden', {
status: 403,
});
return new Response('Cross-site POST form submissions are forbidden', { status: 403 });
}
}
// Allow the request to proceed
return resolve(event);
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
},
});
setSecurityHeaders(response, {
connectSrc: [
PUBLIC_MANA_CORE_AUTH_URL_CLIENT || 'http://localhost:3001',
PUBLIC_MEMORO_SERVER_URL || 'http://localhost:3015',
PUBLIC_GLITCHTIP_DSN ? new URL(PUBLIC_GLITCHTIP_DSN).origin : '',
'http://localhost:3050', // mana-sync server
].filter(Boolean),
});
return response;
};

View file

@ -13,7 +13,7 @@ import {
PUBLIC_APPLE_REDIRECT_URI,
PUBLIC_POSTHOG_KEY,
PUBLIC_POSTHOG_HOST,
PUBLIC_SENTRY_DSN,
PUBLIC_GLITCHTIP_DSN,
} from '$env/static/public';
export const env = {
@ -48,16 +48,16 @@ export const env = {
},
},
// Error tracking (optional)
sentry: {
dsn: PUBLIC_SENTRY_DSN || '',
// Error tracking (GlitchTip — Sentry-compatible, self-hosted)
glitchtip: {
dsn: PUBLIC_GLITCHTIP_DSN || '',
},
} as const;
// Helper to check if optional features are enabled
export const features = {
hasPosthog: !!PUBLIC_POSTHOG_KEY,
hasSentry: !!PUBLIC_SENTRY_DSN,
hasGlitchtip: !!PUBLIC_GLITCHTIP_DSN,
} as const;
// Log environment configuration on startup (useful for debugging deployment issues)
@ -69,7 +69,7 @@ if (typeof window !== 'undefined') {
appleRedirectUri: env.oauth.appleRedirectUri || '❌ NOT SET',
googleOAuth: !!env.oauth.googleClientId ? '✅ Configured' : '❌ Missing',
posthog: features.hasPosthog ? '✅ Enabled' : '⚪ Disabled',
sentry: features.hasSentry ? '✅ Enabled' : '⚪ Disabled',
glitchtip: features.hasGlitchtip ? '✅ Enabled' : '⚪ Disabled',
});
// Specific warning for Apple Sign-In if not configured

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
</script>
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 class="text-6xl font-bold text-amber-500 mb-4">{$page.status}</h1>
<p class="text-xl text-muted-foreground mb-8">{$page.error?.message || $_('error.notFound')}</p>
<a href="/" class="btn btn-primary">{$_('error.backToHome')}</a>
</div>