From 8aa5a24664fcaead99087ca345c83b664ee99c08 Mon Sep 17 00:00:00 2001 From: Mubassir Date: Thu, 29 Oct 2026 10:56:27 +0530 Subject: [PATCH] feat(ai): add Groq Cloud gpt-oss-20b text-generation with Gemini fallback and AI usage tracking --- .env.example | 9 + README.md | 46 + .../migration.sql | 50 + backend/prisma/schema.prisma | 37 + backend/src/lib/ai/aiPricing.js | 84 ++ backend/src/lib/ai/aiUsageLogger.js | 139 +++ backend/src/lib/ai/clefClassifier.js | 11 + backend/src/lib/ai/groqTextGen.js | 766 ++++++++++++++ backend/src/lib/settings.js | 6 + backend/src/routes/admin.js | 3 + backend/src/routes/adminAiUsage.js | 661 ++++++++++++ backend/src/services/aiService.js | 80 +- backend/tests/groq-textgen.test.js | 627 +++++++++++ frontend/src/app/admin/ai/page.js | 982 +++++++++++++++--- frontend/src/lib/api.js | 68 ++ 15 files changed, 3433 insertions(+), 136 deletions(-) create mode 100644 backend/prisma/migrations/20261029100000_add_ai_usage_logs/migration.sql create mode 100644 backend/src/lib/ai/aiPricing.js create mode 100644 backend/src/lib/ai/aiUsageLogger.js create mode 100644 backend/src/lib/ai/groqTextGen.js create mode 100644 backend/src/routes/adminAiUsage.js create mode 100644 backend/tests/groq-textgen.test.js diff --git a/.env.example b/.env.example index 9fed5d1..f8f1a1f 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,15 @@ AI_MOCK=false OPENAI_API_KEY= ANTHROPIC_API_KEY= +# Text Generation provider (Groq Cloud openai/gpt-oss-20b first, Gemini fallback) +GROQ_API_KEY= +GROQ_TEXT_MODEL= +GROQ_TIMEOUT_MS= +GROQ_REASONING_EFFORT= +GROQ_MAX_CONCURRENCY= +TEXT_PROVIDER_FORCE= +USD_INR= + # DM Classification provider (clef | gemini; defaults to gemini) CLASSIFICATION_PROVIDER= CLOUDFLARE_ACCOUNT_ID= diff --git a/README.md b/README.md index 4db9ebe..be39a63 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,52 @@ Every human-readable string comes from one place, and **no raw stored value reac | `REDIRECT_URI` | Must match the Meta dashboard entry **exactly**, no trailing slash. | | `FRONTEND_URL` / `BACKEND_URL` | Also drive the CORS allowlist. | +### Text Generation Providers (Groq Cloud + Gemini Fallback) + +Text generation (auto-reply, Draft AI reply, conversation summaries) runs on **Groq Cloud** (`openai/gpt-oss-20b`) as first-priority with automatic, zero-customer-error fallback to Gemini. + +#### Environment Variables + +| Variable | Default | Notes | +|---|---|---| +| `GROQ_API_KEY` | *(none)* | Groq API Key (Bearer authentication). | +| `GROQ_TEXT_MODEL` | `openai/gpt-oss-20b` | Text generation model id on Groq. | +| `GROQ_TIMEOUT_MS` | `15000` | Request timeout in milliseconds. | +| `GROQ_REASONING_EFFORT` | `low` | Reasoning effort level for reasoning models. | +| `GROQ_MAX_CONCURRENCY` | `10` | In-process concurrency limiter maximum. | +| `TEXT_PROVIDER_FORCE` | *(none)* | Kill switch: set to `gemini` to force Gemini and bypass Groq. | +| `USD_INR` | `84` | USD to INR conversion rate for admin dashboard display. | + +#### Provider Precedence & Fallback +At runtime, text generation resolves the provider in this order: +1. **Kill switch**: If `TEXT_PROVIDER_FORCE=gemini`, uses Gemini directly. +2. **Admin runtime override**: Stored in `app_settings` (`text_generation_provider`) via the Super Admin Panel. +3. **Default**: Groq Cloud first, automatic Gemini fallback on any error (missing key, 401/403, 429, 5xx, timeout, circuit breaker open, empty content). + +#### Super Admin Panel Controls (`/admin/ai`) +- **Runtime Provider Controls**: Switch primary text generator between Groq and Gemini with audit reasoning. +- **Groq Health Card**: Live status, key configured status, error rate %, circuit breaker state, remaining rate limit tokens and requests. +- **Probe Test**: Instant live test probe button. +- **AI Usage Tracking Dashboard**: Token aggregates (prompt, completion, reasoning, cached), cost in USD & INR, daily timeline chart, top customers table, call trace table with filters, and CSV export. + +#### Rollback +If Groq experiences degraded service: +- **Instant Admin Rollback**: In Super Admin (`/admin/ai`), select **Google Gemini** in the Text Generation Provider control and confirm with an audit reason. The switch applies immediately across all text-generation calls without requiring a service restart. +- **Environment Kill Switch**: Set `TEXT_PROVIDER_FORCE=gemini` in environment variables. + +#### Production Migration Note +The AI usage tracking migration `20261029100000_add_ai_usage_logs` is **purely additive** (creates table `ai_usage_logs` with indexes; no existing tables are altered or dropped). +- Apply to production using the owner migration script: + ```powershell + cd backend + powershell -ExecutionPolicy Bypass -File .\scripts\prod-migrate.ps1 + ``` +- Or execute inside the Northflank API service shell: + ```bash + npx prisma migrate deploy + ``` +- AI usage logging is fire-and-forget: during any gap before migration is applied, text-generation and customer replies continue working uninterrupted without errors. + ### DM Classification Providers DM classification can run on **Cloudflare Workers AI Clef Flash** (`@cf/cloudflare/clef-flash`) with automatic, fail-open fallback to Gemini. Text generation, draft replies, and conversation summaries remain strictly on Gemini. diff --git a/backend/prisma/migrations/20261029100000_add_ai_usage_logs/migration.sql b/backend/prisma/migrations/20261029100000_add_ai_usage_logs/migration.sql new file mode 100644 index 0000000..3525278 --- /dev/null +++ b/backend/prisma/migrations/20261029100000_add_ai_usage_logs/migration.sql @@ -0,0 +1,50 @@ +-- Additive migration: comprehensive AI usage tracking log (ai_usage_logs). +-- Metadata ONLY: never stores DM text or prompts. +-- +-- REVERSE (manual): +-- DROP TABLE "ai_usage_logs"; + +-- CreateTable +CREATE TABLE "ai_usage_logs" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" TEXT, + "tenant_id" TEXT, + "feature" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "attempt_no" INTEGER NOT NULL DEFAULT 1, + "is_fallback" BOOLEAN NOT NULL DEFAULT false, + "fallback_reason" TEXT, + "status" TEXT NOT NULL, + "http_status" INTEGER, + "latency_ms" INTEGER NOT NULL, + "prompt_tokens" INTEGER, + "completion_tokens" INTEGER, + "reasoning_tokens" INTEGER, + "cached_tokens" INTEGER, + "total_tokens" INTEGER, + "estimated_cost_usd" DOUBLE PRECISION, + "provider_request_id" TEXT, + "error_message" TEXT, + "conversation_id" TEXT, + "message_id" TEXT, + + CONSTRAINT "ai_usage_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ai_usage_logs_created_at_idx" ON "ai_usage_logs"("created_at"); + +-- CreateIndex +CREATE INDEX "ai_usage_logs_user_id_idx" ON "ai_usage_logs"("user_id"); + +-- CreateIndex +CREATE INDEX "ai_usage_logs_provider_idx" ON "ai_usage_logs"("provider"); + +-- CreateIndex +CREATE INDEX "ai_usage_logs_feature_idx" ON "ai_usage_logs"("feature"); + +-- CreateIndex +CREATE INDEX "ai_usage_logs_status_idx" ON "ai_usage_logs"("status"); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ff5c075..73de3f8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -691,6 +691,43 @@ model AiUsageDaily { @@map("ai_usage_daily") } +// ============================================ +// Comprehensive AI usage tracking log (additive). +// Metadata ONLY — never stores DM text or prompts. +// ============================================ +model AiUsageLog { + id String @id @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") + userId String? @map("user_id") + tenantId String? @map("tenant_id") + feature String // auto_reply | draft_reply | summary | classification | other + provider String + model String + attemptNo Int @default(1) @map("attempt_no") + isFallback Boolean @default(false) @map("is_fallback") + fallbackReason String? @map("fallback_reason") + status String // success | error | timeout | rate_limited | empty + httpStatus Int? @map("http_status") + latencyMs Int @map("latency_ms") + promptTokens Int? @map("prompt_tokens") + completionTokens Int? @map("completion_tokens") + reasoningTokens Int? @map("reasoning_tokens") + cachedTokens Int? @map("cached_tokens") + totalTokens Int? @map("total_tokens") + estimatedCostUsd Float? @map("estimated_cost_usd") + providerRequestId String? @map("provider_request_id") + errorMessage String? @map("error_message") + conversationId String? @map("conversation_id") + messageId String? @map("message_id") + + @@index([createdAt]) + @@index([userId]) + @@index([provider]) + @@index([feature]) + @@index([status]) + @@map("ai_usage_logs") +} + // One row per scheduled job run (Gate 3). Purged after 30 days by housekeeping. model JobRun { id String @id @default(uuid()) diff --git a/backend/src/lib/ai/aiPricing.js b/backend/src/lib/ai/aiPricing.js new file mode 100644 index 0000000..7783138 --- /dev/null +++ b/backend/src/lib/ai/aiPricing.js @@ -0,0 +1,84 @@ +/** + * AI Price Table and Cost Estimation + * + * Prices are in USD per 1 million tokens. + * Groq: console.groq.com/docs/models + * - openai/gpt-oss-20b: $0.075 in / $0.30 out / $0.0375 cached-in, per 1M tokens + * Gemini: current prices from lib/ai/models.js + * - gemini-flash-lite-latest: $0.10 in / $0.40 out, per 1M tokens + * - gemini-flash-latest: $0.30 in / $2.50 out, per 1M tokens + */ + +const AI_PRICE_TABLE = Object.freeze({ + groq: { + 'openai/gpt-oss-20b': { input: 0.075, output: 0.30, cachedInput: 0.0375 }, + default: { input: 0.075, output: 0.30, cachedInput: 0.0375 }, + }, + gemini: { + 'gemini-flash-lite-latest': { input: 0.10, output: 0.40, cachedInput: 0.025 }, + 'gemini-flash-latest': { input: 0.30, output: 2.50, cachedInput: 0.075 }, + default: { input: 0.10, output: 0.40, cachedInput: 0.025 }, + }, + cloudflare: { + '@cf/cloudflare/clef-flash': { input: 0.0, output: 0.0, cachedInput: 0.0 }, + default: { input: 0.0, output: 0.0, cachedInput: 0.0 }, + }, + clef: { + '@cf/cloudflare/clef-flash': { input: 0.0, output: 0.0, cachedInput: 0.0 }, + default: { input: 0.0, output: 0.0, cachedInput: 0.0 }, + }, + openai: { + 'gpt-4o-mini': { input: 0.15, output: 0.60, cachedInput: 0.075 }, + 'gpt-4o': { input: 2.50, output: 10.0, cachedInput: 1.25 }, + default: { input: 0.15, output: 0.60, cachedInput: 0.075 }, + }, + anthropic: { + 'claude-haiku-4-5': { input: 1.0, output: 5.0, cachedInput: 0.10 }, + 'claude-sonnet-5': { input: 2.0, output: 10.0, cachedInput: 0.20 }, + 'claude-opus-5': { input: 5.0, output: 25.0, cachedInput: 0.50 }, + default: { input: 1.0, output: 5.0, cachedInput: 0.10 }, + }, +}); + +function getPriceFor(provider, model) { + const normProvider = (provider || '').toLowerCase(); + const providerPrices = AI_PRICE_TABLE[normProvider]; + if (!providerPrices) return null; + return providerPrices[model] || providerPrices.default || null; +} + +/** + * Compute estimated USD cost for a given call + */ +function estimateCostUsd({ provider, model, promptTokens, completionTokens, cachedTokens = 0 }) { + const price = getPriceFor(provider, model); + if (!price) return null; + + const prompt = Number.isFinite(promptTokens) ? Math.max(0, promptTokens) : 0; + const completion = Number.isFinite(completionTokens) ? Math.max(0, completionTokens) : 0; + const cached = Number.isFinite(cachedTokens) ? Math.max(0, Math.min(cachedTokens, prompt)) : 0; + const uncachedPrompt = Math.max(0, prompt - cached); + + const cachedRate = price.cachedInput !== undefined ? price.cachedInput : price.input; + const totalCost = (uncachedPrompt * price.input + cached * cachedRate + completion * price.output) / 1e6; + return totalCost; +} + +function getUsdInrRate() { + const envVal = parseFloat(process.env.USD_INR); + return Number.isFinite(envVal) && envVal > 0 ? envVal : 84.0; +} + +function usdToInr(usd) { + if (usd == null || !Number.isFinite(usd)) return null; + return usd * getUsdInrRate(); +} + +module.exports = { + AI_PRICE_TABLE, + getPriceFor, + estimateCostUsd, + getUsdInrRate, + usdToInr, +}; + diff --git a/backend/src/lib/ai/aiUsageLogger.js b/backend/src/lib/ai/aiUsageLogger.js new file mode 100644 index 0000000..be4d3a2 --- /dev/null +++ b/backend/src/lib/ai/aiUsageLogger.js @@ -0,0 +1,139 @@ +/** + * Centralized AI Usage Tracking Logger (AiUsageLog) + * + * FIRE-AND-FORGET: A logging failure must never break or delay AI generation. + * PRIVACY CONTRACT: NEVER stores DM text, prompts, responses, or API keys. + * Metadata only (tokens, latency, model, user, feature, cost, errors). + */ + +const prisma = require('../prisma'); +const { estimateCostUsd } = require('./aiPricing'); + +/** + * Truncate error message safely to avoid huge payloads and prevent leaking sensitive data. + */ +function sanitizeErrorMessage(err) { + if (!err) return null; + const msg = typeof err === 'string' ? err : err.message || String(err); + // Redact potential api keys or tokens in error message + const cleaned = msg.replace(/(?:key|token|bearer|authorization)=?[\s:"]*[a-zA-Z0-9_\-\.]{10,}/gi, '[REDACTED_SECRET]'); + return cleaned.slice(0, 500); +} + +/** + * Normalise status to: success | error | timeout | rate_limited | empty + */ +function normalizeStatus({ status, httpStatus, error }) { + if (status) return status; + if (!error) return 'success'; + if (httpStatus === 429 || error.status === 429 || error.code === 'RATE_LIMITED') return 'rate_limited'; + if ( + error.name === 'AbortError' || + error.code === 'ETIMEDOUT' || + error.code === 'ECONNABORTED' || + /timeout/i.test(error.message || '') + ) { + return 'timeout'; + } + if (error.aiErrorCode === 'EMPTY_RESPONSE' || error.code === 'EMPTY_RESPONSE') return 'empty'; + return 'error'; +} + +/** + * Record an AI usage event into `ai_usage_logs` asynchronously (fire-and-forget). + * + * @param {object} params + * @param {string} [params.userId] + * @param {string} [params.tenantId] + * @param {string} params.feature - auto_reply | draft_reply | summary | classification | other + * @param {string} params.provider - groq | gemini | cloudflare | openai | anthropic + * @param {string} params.model + * @param {number} [params.attemptNo=1] + * @param {boolean} [params.isFallback=false] + * @param {string} [params.fallbackReason] + * @param {string} [params.status] - success | error | timeout | rate_limited | empty + * @param {number} [params.httpStatus] + * @param {number} params.latencyMs + * @param {number} [params.promptTokens] + * @param {number} [params.completionTokens] + * @param {number} [params.reasoningTokens] + * @param {number} [params.cachedTokens] + * @param {number} [params.totalTokens] + * @param {number} [params.estimatedCostUsd] + * @param {string} [params.providerRequestId] + * @param {Error|string} [params.error] + * @param {string} [params.errorMessage] + * @param {string} [params.conversationId] + * @param {string} [params.messageId] + */ +function logAiUsage(params) { + try { + const promptTokens = Number.isFinite(params.promptTokens) ? Math.max(0, Math.round(params.promptTokens)) : null; + const completionTokens = Number.isFinite(params.completionTokens) ? Math.max(0, Math.round(params.completionTokens)) : null; + const reasoningTokens = Number.isFinite(params.reasoningTokens) ? Math.max(0, Math.round(params.reasoningTokens)) : null; + const cachedTokens = Number.isFinite(params.cachedTokens) ? Math.max(0, Math.round(params.cachedTokens)) : null; + + let totalTokens = Number.isFinite(params.totalTokens) ? Math.max(0, Math.round(params.totalTokens)) : null; + if (totalTokens == null && (promptTokens != null || completionTokens != null)) { + totalTokens = (promptTokens || 0) + (completionTokens || 0); + } + + const estimatedCostUsd = params.estimatedCostUsd !== undefined + ? params.estimatedCostUsd + : estimateCostUsd({ + provider: params.provider, + model: params.model, + promptTokens, + completionTokens, + cachedTokens, + }); + + const status = normalizeStatus({ + status: params.status, + httpStatus: params.httpStatus, + error: params.error, + }); + + const errorMessage = params.errorMessage || (params.error ? sanitizeErrorMessage(params.error) : null); + + const rawHttpStatus = params.httpStatus ?? (params.error?.status || params.error?.response?.status || null); + const safeHttpStatus = typeof rawHttpStatus === 'number' && Number.isInteger(rawHttpStatus) ? rawHttpStatus : null; + + const data = { + userId: params.userId || null, + tenantId: params.tenantId || null, + feature: params.feature || 'other', + provider: (params.provider || 'unknown').toLowerCase(), + model: params.model || 'unknown', + attemptNo: params.attemptNo || 1, + isFallback: Boolean(params.isFallback), + fallbackReason: params.fallbackReason || null, + status, + httpStatus: safeHttpStatus, + latencyMs: Math.max(0, Math.round(params.latencyMs || 0)), + promptTokens, + completionTokens, + reasoningTokens, + cachedTokens, + totalTokens, + estimatedCostUsd, + providerRequestId: params.providerRequestId || null, + errorMessage, + conversationId: params.conversationId || null, + messageId: params.messageId || null, + }; + + // Non-blocking fire-and-forget write to DB + prisma.aiUsageLog.create({ data }).catch((err) => { + console.error('[AiUsageLog] Non-blocking write failed:', err.message); + }); + } catch (err) { + console.error('[AiUsageLog] Error preparing log entry:', err.message); + } +} + +module.exports = { + logAiUsage, + sanitizeErrorMessage, + normalizeStatus, +}; diff --git a/backend/src/lib/ai/clefClassifier.js b/backend/src/lib/ai/clefClassifier.js index e8e2545..158b345 100644 --- a/backend/src/lib/ai/clefClassifier.js +++ b/backend/src/lib/ai/clefClassifier.js @@ -832,6 +832,17 @@ async function classifyWithClefOrFallback({ latencyMs, fallbackReason: null, }); + try { + require('./aiUsageLogger').logAiUsage({ + feature: 'classification', + provider: 'cloudflare', + model: MODEL_ID, + attemptNo: 1, + isFallback: false, + status: 'success', + latencyMs, + }); + } catch { /* fire-and-forget */ } return formatClefAnalysis({ category, diff --git a/backend/src/lib/ai/groqTextGen.js b/backend/src/lib/ai/groqTextGen.js new file mode 100644 index 0000000..9cfd5fe --- /dev/null +++ b/backend/src/lib/ai/groqTextGen.js @@ -0,0 +1,766 @@ +/** + * Groq Cloud Text Generation Provider (`openai/gpt-oss-20b`) + * + * Primary text generation provider for Siffty (auto-reply, draft reply, summaries). + * Transparent fallback to Google Gemini on ANY failure: customer never sees an error. + * + * Features: + * - OpenAI-compatible Chat Completions API at https://api.groq.com/openai/v1/chat/completions + * - Default model: openai/gpt-oss-20b (REASONING model) + * - Reasoning effort parameter + reasoning token isolation (reasoning kept out of output) + * - Concurrency limiter (GROQ_MAX_CONCURRENCY, default 10) + * - Circuit breaker (5 consecutive failures -> 60s cooldown, auto-recovering) + * - 429 retry: at most once, and only if Retry-After <= 2000ms + * - Captures rate limit headers for admin dashboard + * - Never prints, logs, echoes or commits secret keys + */ + +const { logAiUsage, normalizeStatus } = require('./aiUsageLogger'); + +const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; +const DEFAULT_MODEL = 'openai/gpt-oss-20b'; +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_REASONING_EFFORT = 'low'; +const CIRCUIT_BREAKER_FAILURES = 5; +const CIRCUIT_BREAKER_COOLDOWN_MS = 60 * 1000; +const ROLLING_WINDOW_SIZE = 500; + +// Rate limit headers cache & shared state across modules/tests +const sharedState = globalThis.__groqTextGenState || (globalThis.__groqTextGenState = { + latestRateLimits: { + remainingRequests: null, + remainingTokens: null, + resetRequests: null, + resetTokens: null, + lastUpdated: null, + }, + rollingCalls: [], + lastGroqSuccessAt: null, + lastGroqFailureAt: null, + lastGroqFailureReason: null, + circuitState: { + consecutiveFailures: 0, + circuitOpenUntil: 0, + }, + cachedOverride: { value: null, expiresAt: 0 }, + customFetchImpl: null, + limiter: null, +}); + +const latestRateLimits = sharedState.latestRateLimits; +const rollingCalls = sharedState.rollingCalls; +const circuitState = sharedState.circuitState; + +// Concurrency limiter +class ConcurrencyLimiter { + constructor(max = 10) { + this.max = max; + this.active = 0; + this.queue = []; + } + + setMax(newMax) { + if (Number.isInteger(newMax) && newMax > 0) { + this.max = newMax; + this._drain(); + } + } + + async run(fn) { + if (this.active >= this.max) { + await new Promise((resolve) => this.queue.push(resolve)); + } else { + this.active += 1; + } + try { + return await fn(); + } finally { + this.active -= 1; + this._drain(); + } + } + + _drain() { + while (this.active < this.max && this.queue.length > 0) { + this.active += 1; + const next = this.queue.shift(); + next(); + } + } + + reset() { + this.active = 0; + this.queue = []; + } +} + +function getLimiter() { + const envVal = parseInt(process.env.GROQ_MAX_CONCURRENCY, 10); + const max = Number.isInteger(envVal) && envVal > 0 ? envVal : 10; + if (!sharedState.limiter) { + sharedState.limiter = new ConcurrencyLimiter(max); + } else { + sharedState.limiter.setMax(max); + } + return sharedState.limiter; +} + +// Circuit breaker +function isCircuitOpen(now = Date.now()) { + return circuitState.circuitOpenUntil > now; +} + +function recordSuccess() { + circuitState.consecutiveFailures = 0; + circuitState.circuitOpenUntil = 0; +} + +function recordFailure(now = Date.now()) { + circuitState.consecutiveFailures += 1; + if (circuitState.consecutiveFailures >= CIRCUIT_BREAKER_FAILURES) { + circuitState.circuitOpenUntil = now + CIRCUIT_BREAKER_COOLDOWN_MS; + circuitState.consecutiveFailures = 0; + } +} + +function _resetCircuitBreaker() { + circuitState.consecutiveFailures = 0; + circuitState.circuitOpenUntil = 0; +} + +function _getCircuitBreakerState() { + const isOpen = isCircuitOpen(); + const secondsUntilRetry = isOpen ? Math.max(0, Math.ceil((circuitState.circuitOpenUntil - Date.now()) / 1000)) : 0; + return { + isOpen, + consecutiveFailures: circuitState.consecutiveFailures, + circuitOpenUntil: circuitState.circuitOpenUntil, + secondsUntilRetry, + state: isOpen ? 'OPEN' : 'CLOSED', + }; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function _setFetchImpl(impl) { + sharedState.customFetchImpl = impl; +} + +function _resetFetchImpl() { + sharedState.customFetchImpl = null; +} + +function getFetch(fetchParam) { + return fetchParam || sharedState.customFetchImpl || globalThis.fetch; +} + +function captureRateLimits(headers) { + if (!headers || typeof headers.get !== 'function') return; + try { + const remReq = headers.get('x-ratelimit-remaining-requests'); + const remTok = headers.get('x-ratelimit-remaining-tokens'); + const rstReq = headers.get('x-ratelimit-reset-requests'); + const rstTok = headers.get('x-ratelimit-reset-tokens'); + + if (remReq !== null) latestRateLimits.remainingRequests = parseInt(remReq, 10); + if (remTok !== null) latestRateLimits.remainingTokens = parseInt(remTok, 10); + if (rstReq !== null) latestRateLimits.resetRequests = rstReq; + if (rstTok !== null) latestRateLimits.resetTokens = rstTok; + latestRateLimits.lastUpdated = Date.now(); + } catch { + // Ignore header parsing issues + } +} + +function recordMetric({ provider, latencyMs, fallbackReason = null, timestamp = Date.now() }) { + const entry = { + timestamp, + provider, + fallbackReason: fallbackReason || null, + latencyMs: Math.max(0, Math.round(latencyMs)), + }; + + sharedState.rollingCalls.push(entry); + if (sharedState.rollingCalls.length > ROLLING_WINDOW_SIZE) { + sharedState.rollingCalls.shift(); + } + + if (provider === 'groq' && !fallbackReason) { + sharedState.lastGroqSuccessAt = timestamp; + } + if (fallbackReason) { + sharedState.lastGroqFailureAt = timestamp; + sharedState.lastGroqFailureReason = fallbackReason; + } +} + +function _resetMetrics() { + sharedState.rollingCalls.length = 0; + sharedState.lastGroqSuccessAt = null; + sharedState.lastGroqFailureAt = null; + sharedState.lastGroqFailureReason = null; +} + +function getGroqApiKey() { + return (process.env.GROQ_API_KEY || '').trim() || null; +} + +function isGroqConfigured() { + return getGroqApiKey() !== null; +} + +/** + * Remove reasoning blocks from text to keep customer output clean. + */ +function stripReasoning(text) { + if (!text || typeof text !== 'string') return ''; + return text.replace(/[\s\S]*?<\/think>/gi, '').trim(); +} + +/** + * Single HTTP call to Groq with abort signal + */ +async function callGroqOnce({ apiKey, model, prompt, isJson, timeoutMs, fetchImpl }) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const reasoningEffort = (process.env.GROQ_REASONING_EFFORT || DEFAULT_REASONING_EFFORT).trim(); + + const payload = { + model, + messages: [{ role: 'user', content: prompt }], + max_completion_tokens: 4096, + reasoning_effort: reasoningEffort, + }; + + if (isJson) { + payload.response_format = { type: 'json_object' }; + } + + try { + const res = await fetchImpl(GROQ_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + return res; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Call Groq with 429 retry (at most 1 retry, only if retry-after <= 2s) + */ +async function callGroqWithRetry({ apiKey, model, prompt, isJson, timeoutMs, fetchImpl }) { + let lastResponse = null; + + for (let attempt = 0; attempt < 2; attempt++) { + const res = await callGroqOnce({ apiKey, model, prompt, isJson, timeoutMs, fetchImpl }); + lastResponse = res; + captureRateLimits(res.headers); + + if (res.status === 429 && attempt === 0) { + const retryHeader = res.headers.get('retry-after'); + let waitMs = null; + if (retryHeader) { + const secs = Number(retryHeader); + if (Number.isFinite(secs)) { + waitMs = secs * 1000; + } else { + const parsed = new Date(retryHeader).getTime(); + if (!isNaN(parsed)) waitMs = parsed - Date.now(); + } + } + + // Retry at most once, and ONLY if retry-after is short (about 2s or less) + if (waitMs !== null && waitMs > 0 && waitMs <= 2000) { + await sleep(waitMs); + continue; + } + // Otherwise fall back immediately + break; + } + + // Success or other status code + break; + } + + return lastResponse; +} + +/** + * Map error or bad response to safe fallback reason + */ +function determineFallbackReason(err, httpStatus) { + if (err && err.fallbackReason) return err.fallbackReason; + if (httpStatus === 401) return 'auth_error_401'; + if (httpStatus === 403) return 'auth_error_403'; + if (httpStatus === 429) return 'rate_limited'; + if (httpStatus >= 500) return `http_${httpStatus}`; + if (err && (err.name === 'AbortError' || /timeout/i.test(err.message || ''))) return 'timeout'; + if (err && (err.code === 'ECONNRESET' || err.code === 'ENOTFOUND' || /network/i.test(err.message || ''))) return 'network_error'; + return 'groq_error'; +} + +const OVERRIDE_CACHE_TTL_MS = 15 * 1000; +let cachedOverride = { value: null, expiresAt: 0 }; + +function clearTextProviderOverrideCache() { + sharedState.cachedOverride.value = null; + sharedState.cachedOverride.expiresAt = 0; +} + +async function getTextProviderOverride() { + const now = Date.now(); + if (sharedState.cachedOverride.expiresAt > now) { + return sharedState.cachedOverride.value; + } + try { + const settings = require('../settings'); + const val = await settings.getSetting('text_generation_provider'); + sharedState.cachedOverride.value = val; + sharedState.cachedOverride.expiresAt = now + OVERRIDE_CACHE_TTL_MS; + return val; + } catch { + return sharedState.cachedOverride.value; + } +} + +/** + * Determine effective text generation provider (kill switch > admin override > default groq) + */ +async function resolveTextProvider() { + const force = (process.env.TEXT_PROVIDER_FORCE || '').trim().toLowerCase(); + if (force === 'gemini') { + return { provider: 'gemini', source: 'kill_switch' }; + } + const override = await getTextProviderOverride(); + if (override === 'gemini') { + return { provider: 'gemini', source: 'admin_setting' }; + } + if (override === 'groq') { + return { provider: 'groq', source: 'admin_setting' }; + } + return { provider: 'groq', source: 'default' }; +} + +/** + * Core text generation with Groq and automatic Gemini fallback. + * + * @param {object} args + * @param {string} args.prompt + * @param {boolean} [args.isJson=false] + * @param {string} [args.feature='other'] - auto_reply | draft_reply | summary | other + * @param {string} [args.userId] + * @param {string} [args.tenantId] + * @param {string} [args.conversationId] + * @param {string} [args.messageId] + * @param {Function} args.fallbackFn - async () => ({ text, json?, model, provider, usage? }) + * @param {Function} [args.validate] - optional schema validator for JSON output + */ +async function generateTextWithGroqOrFallback({ + prompt, + isJson = false, + feature = 'other', + userId = null, + tenantId = null, + conversationId = null, + messageId = null, + fallbackFn, + validate = null, +}) { + const effective = await resolveTextProvider(); + const apiKey = getGroqApiKey(); + const model = process.env.GROQ_TEXT_MODEL || DEFAULT_MODEL; + const timeoutMs = parseInt(process.env.GROQ_TIMEOUT_MS, 10) || DEFAULT_TIMEOUT_MS; + const fetchImpl = getFetch(); + + // If switched to Gemini or kill switch active, call fallback directly + if (effective.provider === 'gemini') { + const startedFallback = Date.now(); + try { + const fbResult = await fallbackFn(); + logAiUsage({ + userId, + tenantId, + feature, + provider: fbResult.provider || 'gemini', + model: fbResult.model || 'gemini-flash-lite-latest', + attemptNo: 1, + isFallback: false, + status: 'success', + latencyMs: Date.now() - startedFallback, + promptTokens: fbResult.usage?.input, + completionTokens: fbResult.usage?.output, + totalTokens: fbResult.usage ? (fbResult.usage.input || 0) + (fbResult.usage.output || 0) : null, + conversationId, + messageId, + }); + return fbResult; + } catch (fbErr) { + logAiUsage({ + userId, + tenantId, + feature, + provider: 'gemini', + model: 'gemini-flash-lite-latest', + attemptNo: 1, + isFallback: false, + status: 'error', + error: fbErr, + latencyMs: Date.now() - startedFallback, + conversationId, + messageId, + }); + throw fbErr; + } + } + + // Pre-call validation for Groq + let skipGroqReason = null; + if (!apiKey) { + skipGroqReason = 'missing_key'; + } else if (isCircuitOpen()) { + skipGroqReason = 'circuit_open'; + } + + if (skipGroqReason) { + recordMetric({ provider: 'gemini', latencyMs: 0, fallbackReason: skipGroqReason }); + // Log skipped Groq attempt as fallback trigger + logAiUsage({ + userId, + tenantId, + feature, + provider: 'groq', + model, + attemptNo: 1, + isFallback: false, + status: skipGroqReason === 'circuit_open' ? 'error' : 'error', + fallbackReason: skipGroqReason, + latencyMs: 0, + errorMessage: `Groq skipped: ${skipGroqReason}`, + conversationId, + messageId, + }); + + // Execute fallback + return executeFallback({ + fallbackFn, + fallbackReason: skipGroqReason, + userId, + tenantId, + feature, + conversationId, + messageId, + attemptNo: 2, + }); + } + + // Run through concurrency limiter + const limiter = getLimiter(); + const groqStarted = Date.now(); + + try { + const result = await limiter.run(async () => { + const res = await callGroqWithRetry({ apiKey, model, prompt, isJson, timeoutMs, fetchImpl }); + const latencyMs = Date.now() - groqStarted; + + if (!res.ok) { + let errBody; + try { errBody = await res.json(); } catch { /* ignore */ } + const errMsg = errBody?.error?.message || `Groq HTTP ${res.status}`; + const reason = determineFallbackReason(new Error(errMsg), res.status); + throw Object.assign(new Error(errMsg), { + status: res.status, + fallbackReason: reason, + latencyMs, + requestId: res.headers.get('x-request-id'), + }); + } + + const data = await res.json(); + const choice = data.choices?.[0]; + const finishReason = choice?.finish_reason; + + if (finishReason === 'length') { + throw Object.assign(new Error('Groq output truncated by length limit'), { + fallbackReason: 'finish_reason_length', + latencyMs, + status: 'empty', + httpStatus: 200, + requestId: res.headers.get('x-request-id'), + usage: data.usage, + }); + } + + const rawText = choice?.message?.content || ''; + const cleanText = stripReasoning(rawText); + + if (!cleanText.trim()) { + throw Object.assign(new Error('Groq returned empty text response'), { + fallbackReason: 'empty_content', + latencyMs, + status: 'empty', + httpStatus: 200, + requestId: res.headers.get('x-request-id'), + usage: data.usage, + }); + } + + let parsedJson = null; + if (isJson) { + try { + parsedJson = JSON.parse(cleanText); + } catch { + // Fall back to JSON extraction fence or curly braces + const fence = cleanText.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fence) { + try { parsedJson = JSON.parse(fence[1].trim()); } catch { /* ignore */ } + } + if (!parsedJson) { + const a = cleanText.indexOf('{'); + const b = cleanText.lastIndexOf('}'); + if (a !== -1 && b > a) { + try { parsedJson = JSON.parse(cleanText.slice(a, b + 1)); } catch { /* ignore */ } + } + } + if (!parsedJson) { + throw Object.assign(new Error('Groq returned invalid JSON'), { + fallbackReason: 'unparseable_json', + latencyMs, + requestId: res.headers.get('x-request-id'), + usage: data.usage, + }); + } + } + + if (validate) { + const verdict = validate(parsedJson); + if (!verdict.valid) { + throw Object.assign(new Error(`Groq JSON failed validation: ${verdict.errors?.join(', ')}`), { + fallbackReason: 'schema_invalid', + latencyMs, + requestId: res.headers.get('x-request-id'), + usage: data.usage, + }); + } + } + } + + return { + text: cleanText, + json: parsedJson, + model, + provider: 'groq', + latencyMs, + requestId: res.headers.get('x-request-id'), + usage: data.usage, + }; + }); + + // Groq succeeded! + recordSuccess(); + recordMetric({ provider: 'groq', latencyMs: result.latencyMs }); + + const pTokens = result.usage?.prompt_tokens ?? null; + const cTokens = result.usage?.completion_tokens ?? null; + const rTokens = result.usage?.completion_tokens_details?.reasoning_tokens ?? null; + const cachedTokens = result.usage?.prompt_tokens_details?.cached_tokens ?? null; + const totalTokens = result.usage?.total_tokens ?? (pTokens + cTokens); + + logAiUsage({ + userId, + tenantId, + feature, + provider: 'groq', + model, + attemptNo: 1, + isFallback: false, + status: 'success', + latencyMs: result.latencyMs, + promptTokens: pTokens, + completionTokens: cTokens, + reasoningTokens: rTokens, + cachedTokens, + totalTokens, + providerRequestId: result.requestId, + conversationId, + messageId, + }); + + return { + text: result.text, + json: result.json, + model, + provider: 'groq', + latencyMs: result.latencyMs, + usage: result.usage, + }; + } catch (groqErr) { + const latencyMs = groqErr.latencyMs || (Date.now() - groqStarted); + recordFailure(); + const fallbackReason = groqErr.fallbackReason || determineFallbackReason(groqErr, groqErr.status); + recordMetric({ provider: 'gemini', latencyMs, fallbackReason }); + + logAiUsage({ + userId, + tenantId, + feature, + provider: 'groq', + model, + attemptNo: 1, + isFallback: false, + status: groqErr.status === 'empty' ? 'empty' : normalizeStatus({ httpStatus: groqErr.httpStatus || groqErr.status, error: groqErr }), + httpStatus: typeof groqErr.httpStatus === 'number' ? groqErr.httpStatus : (typeof groqErr.status === 'number' ? groqErr.status : null), + fallbackReason, + latencyMs, + promptTokens: groqErr.usage?.prompt_tokens, + completionTokens: groqErr.usage?.completion_tokens, + totalTokens: groqErr.usage?.total_tokens, + providerRequestId: groqErr.requestId, + error: groqErr, + conversationId, + messageId, + }); + + console.warn(`[GroqTextGen] Groq call failed (${fallbackReason}), falling back to Gemini: ${groqErr.message}`); + + // Transparently fall back to Gemini + return executeFallback({ + fallbackFn, + fallbackReason, + userId, + tenantId, + feature, + conversationId, + messageId, + attemptNo: 2, + }); + } +} + +/** + * Execute fallback function and log usage. + */ +async function executeFallback({ + fallbackFn, + fallbackReason, + userId, + tenantId, + feature, + conversationId, + messageId, + attemptNo = 2, +}) { + const started = Date.now(); + try { + const out = await fallbackFn(); + const latencyMs = Date.now() - started; + + logAiUsage({ + userId, + tenantId, + feature, + provider: out.provider || 'gemini', + model: out.model || 'gemini-flash-lite-latest', + attemptNo, + isFallback: true, + fallbackReason, + status: 'success', + latencyMs, + promptTokens: out.usage?.input, + completionTokens: out.usage?.output, + totalTokens: out.usage ? (out.usage.input || 0) + (out.usage.output || 0) : null, + conversationId, + messageId, + }); + + return { + ...out, + isFallback: true, + fallbackReason, + }; + } catch (fbErr) { + const latencyMs = Date.now() - started; + logAiUsage({ + userId, + tenantId, + feature, + provider: 'gemini', + model: 'gemini-flash-lite-latest', + attemptNo, + isFallback: true, + fallbackReason, + status: 'error', + error: fbErr, + latencyMs, + conversationId, + messageId, + }); + throw fbErr; + } +} + +/** + * Status stats for admin health view + */ +function getGroqTextGenStats() { + const totalCalls = rollingCalls.length; + const groqCalls = rollingCalls.filter((c) => c.provider === 'groq').length; + const fallbackCalls = rollingCalls.filter((c) => c.fallbackReason !== null).length; + const errorRate = totalCalls > 0 ? (fallbackCalls / totalCalls) * 100 : 0; + + const groqLatencies = rollingCalls + .filter((c) => c.provider === 'groq') + .map((c) => c.latencyMs) + .sort((a, b) => a - b); + + const p50 = groqLatencies.length ? groqLatencies[Math.floor(groqLatencies.length * 0.5)] : null; + const p95 = groqLatencies.length ? groqLatencies[Math.min(groqLatencies.length - 1, Math.floor(groqLatencies.length * 0.95))] : null; + + const fallbackReasons = {}; + for (const c of rollingCalls) { + if (c.fallbackReason) { + fallbackReasons[c.fallbackReason] = (fallbackReasons[c.fallbackReason] || 0) + 1; + } + } + + const breaker = _getCircuitBreakerState(); + + return { + isConfigured: isGroqConfigured(), + model: process.env.GROQ_TEXT_MODEL || DEFAULT_MODEL, + circuitBreaker: breaker, + rateLimits: { ...latestRateLimits }, + lastSuccessAt: sharedState.lastGroqSuccessAt, + lastFailureAt: sharedState.lastGroqFailureAt, + lastFailureReason: sharedState.lastGroqFailureReason, + totalCalls, + groqCalls, + fallbackCalls, + errorRate: Number(errorRate.toFixed(1)), + latency: { p50, p95 }, + fallbackReasons, + }; +} + +module.exports = { + generateTextWithGroqOrFallback, + resolveTextProvider, + getGroqTextGenStats, + isGroqConfigured, + clearTextProviderOverrideCache, + stripReasoning, + determineFallbackReason, + _resetCircuitBreaker, + _getCircuitBreakerState, + _setFetchImpl, + _resetFetchImpl, + _resetMetrics, + getLimiter, +}; diff --git a/backend/src/lib/settings.js b/backend/src/lib/settings.js index 27fa6bb..680d1ac 100644 --- a/backend/src/lib/settings.js +++ b/backend/src/lib/settings.js @@ -64,6 +64,12 @@ const DEFINITIONS = Object.freeze({ valid: (v) => v === null || v === 'clef' || v === 'gemini', label: 'Runtime DM classification provider override (clef | gemini | null)', }, + text_generation_provider: { + group: 'ai', + default: null, + valid: (v) => v === null || v === 'groq' || v === 'gemini', + label: 'Runtime text generation provider override (groq | gemini | null)', + }, // ---- Sessions (Gate 2) ---- user_session_idle_minutes: { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 8e6534c..3b32906 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -316,6 +316,9 @@ router.use(trackAdminActivity); // Auto DM rollout control (src/features/autodm/adminRoutes.js), behind the admin chain above. router.use('/autodm', require('../features/autodm').adminRouter); +// Groq text-generation controls and AI usage dashboard (behind the admin chain above). +router.use(require('./adminAiUsage')); + // GET /api/admin/me — the session, for the UI. A GET: does not count as activity. router.get('/me', (req, res) => { // `isAdmin` is part of the contract the admin shell checks diff --git a/backend/src/routes/adminAiUsage.js b/backend/src/routes/adminAiUsage.js new file mode 100644 index 0000000..c9854b2 --- /dev/null +++ b/backend/src/routes/adminAiUsage.js @@ -0,0 +1,661 @@ +/** + * Admin AI Provider Controls & Usage Dashboard Routes + * + * Provides: + * 1. Text generation provider controls (Groq / Gemini toggle, circuit breaker reset, live test) + * 2. Groq operational health (circuit state, rate limits, latency metrics) + * 3. AI Usage Tracking Dashboard APIs (summary, breakdowns, user stats, traces, CSV export) + * + * Security: + * - Mounted behind requireAdmin middleware (admin-only) + * - Metadata only: NEVER exposes or stores DM text or prompts + * - DB aggregation only: Never loads entire table into memory + */ + +const express = require('express'); +const router = express.Router(); +const prisma = require('../lib/prisma'); +const settings = require('../lib/settings'); +const { createRateLimiter } = require('../lib/rateLimit'); +const { recordAdminAudit } = require('../lib/adminAudit'); +const groqTextGen = require('../lib/ai/groqTextGen'); +const aiRouter = require('../lib/ai/router'); +const { getUsdInrRate } = require('../lib/ai/aiPricing'); + +/** + * CSV helpers with formula injection guard + */ +function csvCell(value) { + let s = value instanceof Date ? value.toISOString() : String(value ?? ''); + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; + return `"${s.replace(/"/g, '""')}"`; +} +const csvRow = (cells) => `${cells.map(csvCell).join(',')}\n`; + +function onlyFields(req, res, allowed) { + const extra = Object.keys(req.body || {}).filter((k) => !allowed.includes(k)); + if (extra.length) { + res.status(400).json({ error: 'Unexpected field in the request.', code: 'UNEXPECTED_FIELD' }); + return false; + } + return true; +} + +/** + * Resolve date bounds from query parameters (today / 7d / 30d / custom) + */ +function resolveDateRange(query) { + const now = new Date(); + const range = query.range || '7d'; + + if (range === 'today') { + const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + return { from: startOfToday, to: now }; + } + + if (range === '7d') { + const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + return { from, to: now }; + } + + if (range === '30d') { + const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + return { from, to: now }; + } + + // Custom date range + let from = query.from ? new Date(query.from) : new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + let to = query.to ? new Date(query.to) : now; + + if (isNaN(from.getTime())) from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + if (isNaN(to.getTime())) to = now; + + return { from, to }; +} + +// ============================================ +// 1. Text Generation Provider Controls +// ============================================ + +// GET /api/admin/text-gen/status +router.get('/text-gen/status', async (req, res) => { + try { + const stats = groqTextGen.getGroqTextGenStats(); + const effective = await groqTextGen.resolveTextProvider(); + res.json({ + ...stats, + effectiveProvider: effective.provider, + source: effective.source, + killSwitchActive: (process.env.TEXT_PROVIDER_FORCE || '').trim().toLowerCase() === 'gemini', + }); + } catch (error) { + console.error('[Admin] Text-gen status error:', error.message); + res.status(500).json({ error: "Couldn't load text generation status." }); + } +}); + +// POST /api/admin/text-gen/provider +router.post('/text-gen/provider', async (req, res) => { + if (!onlyFields(req, res, ['provider', 'reason'])) return undefined; + const rawProvider = req.body && req.body.provider; + const provider = typeof rawProvider === 'string' ? rawProvider.trim().toLowerCase() : ''; + if (!['groq', 'gemini'].includes(provider)) { + return res.status(400).json({ error: 'Provider must be "groq" or "gemini".', code: 'INVALID_PROVIDER' }); + } + + const rawReason = req.body && req.body.reason; + const reason = typeof rawReason === 'string' ? rawReason.trim().slice(0, 500) : null; + + try { + const before = await settings.getSetting('text_generation_provider'); + await settings.setSetting('text_generation_provider', provider, req.admin.id); + groqTextGen.clearTextProviderOverrideCache(); + + await recordAdminAudit(req, { + action: 'TEXT_GEN_PROVIDER_SET', + targetType: 'APP_SETTING', + targetId: 'text_generation_provider', + reason, + before: { provider: before }, + after: { provider }, + }); + + const stats = groqTextGen.getGroqTextGenStats(); + const effective = await groqTextGen.resolveTextProvider(); + res.json({ + success: true, + provider, + stats: { + ...stats, + effectiveProvider: effective.provider, + source: effective.source, + }, + }); + } catch (error) { + console.error('[Admin] Set text-gen provider error:', error.message); + res.status(500).json({ error: "Couldn't update text generation provider." }); + } +}); + +// POST /api/admin/text-gen/circuit-breaker/reset +router.post('/text-gen/circuit-breaker/reset', async (req, res) => { + if (!onlyFields(req, res, ['reason'])) return undefined; + const rawReason = req.body && req.body.reason; + const reason = typeof rawReason === 'string' ? rawReason.trim().slice(0, 500) : 'Admin manual reset'; + + try { + groqTextGen._resetCircuitBreaker(); + + await recordAdminAudit(req, { + action: 'GROQ_CIRCUIT_BREAKER_RESET', + targetType: 'CIRCUIT_BREAKER', + targetId: 'groq', + reason, + before: { state: 'OPEN' }, + after: { state: 'CLOSED' }, + }); + + const stats = groqTextGen.getGroqTextGenStats(); + res.json({ success: true, stats }); + } catch (error) { + console.error('[Admin] Reset Groq circuit breaker error:', error.message); + res.status(500).json({ error: "Couldn't reset Groq circuit breaker." }); + } +}); + +const textGenTestLimiter = createRateLimiter({ + windowMs: 60 * 1000, + max: 6, + message: 'Rate limit: at most 6 text generation tests per minute.', +}); + +// POST /api/admin/text-gen/test +router.post('/text-gen/test', textGenTestLimiter, async (req, res) => { + if (!onlyFields(req, res, [])) return undefined; + + const t0 = Date.now(); + const testPrompt = 'Say "Siffty AI is operational." in 5 words or fewer.'; + try { + const fallbackFn = async () => { + const result = await aiRouter.callAI({ + prompt: testPrompt, + isJson: false, + timeout: 10000, + jobType: 'admin_test', + }); + return { + text: result.text, + model: result.model, + provider: result.provider, + usage: result.usage, + }; + }; + + const out = await groqTextGen.generateTextWithGroqOrFallback({ + prompt: testPrompt, + isJson: false, + feature: 'other', + fallbackFn, + }); + + res.json({ + success: true, + provider: out.provider, + model: out.model, + text: out.text, + isFallback: Boolean(out.isFallback), + fallbackReason: out.fallbackReason || null, + latencyMs: Date.now() - t0, + }); + } catch (error) { + console.error('[Admin] Text-gen test error:', error.message); + res.status(500).json({ error: "Couldn't complete text generation test.", message: error.message }); + } +}); + +// ============================================ +// 2. AI Usage Tracking Dashboard APIs +// ============================================ + +// Build where clause from filters +function buildWhereClause(query) { + const { from, to } = resolveDateRange(query); + const where = { + createdAt: { + gte: from, + lte: to, + }, + }; + + if (query.feature && query.feature !== 'all') { + where.feature = query.feature; + } + if (query.provider && query.provider !== 'all') { + where.provider = query.provider.toLowerCase(); + } + if (query.status && query.status !== 'all') { + where.status = query.status; + } + if (query.fallback === 'true') { + where.isFallback = true; + } else if (query.fallback === 'false') { + where.isFallback = false; + } + + return { where, from, to }; +} + +// GET /api/admin/ai/usage/summary +router.get('/ai/usage/summary', async (req, res) => { + try { + const { where, from, to } = buildWhereClause(req.query); + + const [agg, fallbacksCount, errorsCount, p95Result] = await Promise.all([ + prisma.aiUsageLog.aggregate({ + where, + _count: { id: true }, + _sum: { + promptTokens: true, + completionTokens: true, + reasoningTokens: true, + cachedTokens: true, + totalTokens: true, + estimatedCostUsd: true, + latencyMs: true, + }, + _avg: { + latencyMs: true, + }, + }), + prisma.aiUsageLog.count({ + where: { ...where, isFallback: true }, + }), + prisma.aiUsageLog.count({ + where: { ...where, status: { not: 'success' } }, + }), + // PostgreSQL percentile calculation + prisma.$queryRaw` + SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95 + FROM ai_usage_logs + WHERE created_at >= ${from} AND created_at <= ${to} + `.catch(() => [{ p95: null }]), + ]); + + const totalRequests = agg._count?.id || 0; + const fallbackRate = totalRequests > 0 ? (fallbacksCount / totalRequests) * 100 : 0; + const errorRate = totalRequests > 0 ? (errorsCount / totalRequests) * 100 : 0; + const p95LatencyMs = p95Result?.[0]?.p95 != null ? Math.round(Number(p95Result[0].p95)) : null; + + const usdInrRate = getUsdInrRate(); + const totalCostUsd = agg._sum?.estimatedCostUsd || 0; + const totalCostInr = totalCostUsd * usdInrRate; + + res.json({ + period: { from: from.toISOString(), to: to.toISOString() }, + requests: totalRequests, + tokens: { + total: agg._sum?.totalTokens || 0, + prompt: agg._sum?.promptTokens || 0, + completion: agg._sum?.completionTokens || 0, + reasoning: agg._sum?.reasoningTokens || 0, + cached: agg._sum?.cachedTokens || 0, + }, + cost: { + usd: totalCostUsd, + inr: totalCostInr, + usdInrRate, + }, + fallbacks: { + count: fallbacksCount, + rate: Number(fallbackRate.toFixed(1)), + }, + errors: { + count: errorsCount, + rate: Number(errorRate.toFixed(1)), + }, + latency: { + avg: agg._avg?.latencyMs ? Math.round(agg._avg.latencyMs) : null, + p95: p95LatencyMs, + }, + }); + } catch (error) { + console.error('[Admin] AI usage summary error:', error.message); + res.status(500).json({ error: "Couldn't load AI usage summary." }); + } +}); + +// GET /api/admin/ai/usage/breakdown +router.get('/ai/usage/breakdown', async (req, res) => { + try { + const { where, from, to } = buildWhereClause(req.query); + + const [byModel, byFeature, dailyRaw] = await Promise.all([ + prisma.aiUsageLog.groupBy({ + by: ['provider', 'model'], + where, + _count: { id: true }, + _sum: { + promptTokens: true, + completionTokens: true, + reasoningTokens: true, + cachedTokens: true, + totalTokens: true, + estimatedCostUsd: true, + }, + _avg: { latencyMs: true }, + }), + prisma.aiUsageLog.groupBy({ + by: ['feature'], + where, + _count: { id: true }, + _sum: { + totalTokens: true, + estimatedCostUsd: true, + }, + _avg: { latencyMs: true }, + }), + // Daily aggregates for chart + prisma.$queryRaw` + SELECT + DATE(created_at) as day, + COUNT(*)::int as requests, + COALESCE(SUM(total_tokens), 0)::bigint as total_tokens, + COALESCE(SUM(estimated_cost_usd), 0)::float as cost_usd, + COUNT(CASE WHEN is_fallback THEN 1 END)::int as fallbacks, + COUNT(CASE WHEN status != 'success' THEN 1 END)::int as errors + FROM ai_usage_logs + WHERE created_at >= ${from} AND created_at <= ${to} + GROUP BY DATE(created_at) + ORDER BY day ASC + `.catch((err) => { + console.error('[Admin] Daily aggregate query failed:', err.message); + return []; + }), + ]); + + // Format models breakdown + const models = byModel.map((m) => ({ + provider: m.provider, + model: m.model, + requests: m._count.id, + promptTokens: m._sum.promptTokens || 0, + completionTokens: m._sum.completionTokens || 0, + reasoningTokens: m._sum.reasoningTokens || 0, + cachedTokens: m._sum.cachedTokens || 0, + totalTokens: m._sum.totalTokens || 0, + costUsd: m._sum.estimatedCostUsd || 0, + avgLatencyMs: m._avg.latencyMs ? Math.round(m._avg.latencyMs) : null, + })); + + // Format feature breakdown + const features = byFeature.map((f) => ({ + feature: f.feature, + requests: f._count.id, + totalTokens: f._sum.totalTokens || 0, + costUsd: f._sum.estimatedCostUsd || 0, + avgLatencyMs: f._avg.latencyMs ? Math.round(f._avg.latencyMs) : null, + })); + + // Format daily timeline + const daily = (dailyRaw || []).map((d) => ({ + date: d.day instanceof Date ? d.day.toISOString().slice(0, 10) : String(d.day), + requests: Number(d.requests || 0), + totalTokens: Number(d.total_tokens || 0), + costUsd: Number(d.cost_usd || 0), + fallbacks: Number(d.fallbacks || 0), + errors: Number(d.errors || 0), + })); + + res.json({ models, features, daily }); + } catch (error) { + console.error('[Admin] AI usage breakdown error:', error.message); + res.status(500).json({ error: "Couldn't load AI usage breakdown." }); + } +}); + +// GET /api/admin/ai/usage/users +router.get('/ai/usage/users', async (req, res) => { + try { + const { from, to } = resolveDateRange(req.query); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const offset = (page - 1) * limit; + + const sortField = req.query.sort || 'cost'; + let orderBySql = 'cost_usd DESC'; + if (sortField === 'requests') orderBySql = 'requests DESC'; + if (sortField === 'tokens') orderBySql = 'total_tokens DESC'; + + const userAggregates = await prisma.$queryRawUnsafe(` + SELECT + user_id, + COUNT(*)::int as requests, + COALESCE(SUM(total_tokens), 0)::bigint as total_tokens, + COALESCE(SUM(estimated_cost_usd), 0)::float as cost_usd, + MAX(created_at) as last_used_at + FROM ai_usage_logs + WHERE created_at >= $1 AND created_at <= $2 AND user_id IS NOT NULL + GROUP BY user_id + ORDER BY ${orderBySql} + LIMIT $3 OFFSET $4 + `, from, to, limit, offset); + + const userIds = userAggregates.map((u) => u.user_id).filter(Boolean); + + // Fetch user details & plan info + const [users, entitlements] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, email: true, name: true }, + }), + prisma.entitlement.findMany({ + where: { userId: { in: userIds } }, + select: { userId: true, planCode: true, status: true }, + }), + ]); + + const userMap = new Map(users.map((u) => [u.id, u])); + const entMap = new Map(entitlements.map((e) => [e.userId, e])); + + const rows = userAggregates.map((row) => { + const u = userMap.get(row.user_id); + const e = entMap.get(row.user_id); + return { + userId: row.user_id, + email: u?.email || 'unknown', + name: u?.name || null, + plan: e?.planCode || 'NONE', + status: e?.status || 'INACTIVE', + requests: Number(row.requests), + totalTokens: Number(row.total_tokens), + costUsd: Number(row.cost_usd), + lastUsedAt: row.last_used_at instanceof Date ? row.last_used_at.toISOString() : row.last_used_at, + }; + }); + + res.json({ users: rows, page, limit }); + } catch (error) { + console.error('[Admin] AI usage users error:', error.message); + res.status(500).json({ error: "Couldn't load user usage breakdown." }); + } +}); + +// GET /api/admin/ai/usage/traces +router.get('/ai/usage/traces', async (req, res) => { + try { + const { where } = buildWhereClause(req.query); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 50)); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const skip = (page - 1) * limit; + + const [total, rows] = await Promise.all([ + prisma.aiUsageLog.count({ where }), + prisma.aiUsageLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + select: { + id: true, + createdAt: true, + userId: true, + tenantId: true, + feature: true, + provider: true, + model: true, + attemptNo: true, + isFallback: true, + fallbackReason: true, + status: true, + httpStatus: true, + latencyMs: true, + promptTokens: true, + completionTokens: true, + reasoningTokens: true, + cachedTokens: true, + totalTokens: true, + estimatedCostUsd: true, + providerRequestId: true, + errorMessage: true, + conversationId: true, + messageId: true, + }, + }), + ]); + + // Attach user emails for readability + const userIds = [...new Set(rows.map((r) => r.userId).filter(Boolean))]; + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, email: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u.email])); + + const traces = rows.map((r) => ({ + ...r, + userEmail: r.userId ? userMap.get(r.userId) || null : null, + })); + + res.json({ + traces, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error('[Admin] AI usage traces error:', error.message); + res.status(500).json({ error: "Couldn't load AI usage traces." }); + } +}); + +// GET /api/admin/ai/usage/export +router.get('/ai/usage/export', async (req, res) => { + try { + const { where } = buildWhereClause(req.query); + const maxRows = 5000; + + const rows = await prisma.aiUsageLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: maxRows, + select: { + id: true, + createdAt: true, + userId: true, + tenantId: true, + feature: true, + provider: true, + model: true, + attemptNo: true, + isFallback: true, + fallbackReason: true, + status: true, + httpStatus: true, + latencyMs: true, + promptTokens: true, + completionTokens: true, + reasoningTokens: true, + cachedTokens: true, + totalTokens: true, + estimatedCostUsd: true, + providerRequestId: true, + errorMessage: true, + conversationId: true, + messageId: true, + }, + }); + + const filename = `ai-usage-export-${new Date().toISOString().slice(0, 10)}.csv`; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + const headers = [ + 'id', + 'created_at', + 'user_id', + 'tenant_id', + 'feature', + 'provider', + 'model', + 'attempt_no', + 'is_fallback', + 'fallback_reason', + 'status', + 'http_status', + 'latency_ms', + 'prompt_tokens', + 'completion_tokens', + 'reasoning_tokens', + 'cached_tokens', + 'total_tokens', + 'estimated_cost_usd', + 'provider_request_id', + 'error_message', + 'conversation_id', + 'message_id', + ]; + + res.write(csvRow(headers)); + + for (const r of rows) { + res.write( + csvRow([ + r.id, + r.createdAt, + r.userId, + r.tenantId, + r.feature, + r.provider, + r.model, + r.attemptNo, + r.isFallback, + r.fallbackReason, + r.status, + r.httpStatus, + r.latencyMs, + r.promptTokens, + r.completionTokens, + r.reasoningTokens, + r.cachedTokens, + r.totalTokens, + r.estimatedCostUsd, + r.providerRequestId, + r.errorMessage, + r.conversationId, + r.messageId, + ]) + ); + } + + res.end(); + } catch (error) { + console.error('[Admin] AI usage export error:', error.message); + res.status(500).json({ error: "Couldn't export AI usage data." }); + } +}); + +module.exports = router; + diff --git a/backend/src/services/aiService.js b/backend/src/services/aiService.js index 68f637f..0d7fb23 100644 --- a/backend/src/services/aiService.js +++ b/backend/src/services/aiService.js @@ -11,6 +11,8 @@ const router = require('../lib/ai/router'); const { keyConfigured } = require('../lib/ai/models'); const { validateClassification, validateSummary } = require('../lib/ai/schema'); const { classifyWithClefOrFallback } = require('../lib/ai/clefClassifier'); +const { generateTextWithGroqOrFallback } = require('../lib/ai/groqTextGen'); +const { logAiUsage } = require('../lib/ai/aiUsageLogger'); const { CATEGORIES, CUSTOMER_TYPES, @@ -499,6 +501,21 @@ async function analyzeConversation({ const prompt = buildClassificationPrompt({ messageText, contactUsername, recentMessages, businessProfile }); const result = await router.callAI({ prompt, isJson: true, jobType: 'classification', tenantId, validate: validateClassification }); noteSuccess(result); + try { + logAiUsage({ + feature: 'classification', + provider: result.provider || 'gemini', + model: result.model || 'gemini-flash-lite-latest', + attemptNo: 1, + isFallback: false, + status: 'success', + latencyMs: 0, + promptTokens: result.usage?.input, + completionTokens: result.usage?.output, + tenantId, + userId: tenantId, + }); + } catch { /* fire-and-forget */ } const { json, model, provider } = result; const validated = validateAnalysisResult(json); const scored = computePriorityScore(validated, preferences); @@ -634,16 +651,37 @@ ${safetyGuidelines} Draft the reply text:`; + const callGeminiReply = async () => { + const res = await router.callAI({ prompt, isJson: false, timeout: 15000, jobType: 'reply', tenantId }); + noteSuccess(res); + return res; + }; + + const feature = instructions ? 'draft_reply' : 'auto_reply'; + try { - const result = await router.callAI({ prompt, isJson: false, timeout: 15000, jobType: 'reply', tenantId }); - noteSuccess(result); - const { text, model, provider } = result; + const result = await generateTextWithGroqOrFallback({ + prompt, + isJson: false, + feature, + userId: tenantId, + tenantId, + fallbackFn: callGeminiReply, + }); + + const cleanText = result.text?.replace(/^"|"$/g, '').trim() || null; aiStats.replyGenerations++; + if (!result.isFallback) { + aiStats.lastSuccessfulProvider = result.provider; + aiStats.lastSuccessfulModel = result.model; + } return { - text: text?.replace(/^"|"$/g, '').trim() || null, - model, - provider, + text: cleanText, + model: result.model, + provider: result.provider, failed: false, + isFallback: result.isFallback || false, + fallbackReason: result.fallbackReason || null, }; } catch (error) { console.warn(`[AIService] Reply generation failed: ${error.message}`); @@ -673,17 +711,37 @@ Return ONLY valid JSON: "stage": "Three words or fewer naming where this stands, e.g. 'Waiting on price', 'Ready to book', 'Needs your answer'. Sentence case." }`; + const callGeminiSummary = async () => { + const res = await router.callAI({ prompt, isJson: true, jobType: 'summary', tenantId, validate: validateSummary }); + noteSuccess(res); + return res; + }; + try { - const result = await router.callAI({ prompt, isJson: true, jobType: 'summary', tenantId, validate: validateSummary }); - noteSuccess(result); - const { json, model, provider } = result; + const result = await generateTextWithGroqOrFallback({ + prompt, + isJson: true, + feature: 'summary', + userId: tenantId, + tenantId, + fallbackFn: callGeminiSummary, + validate: validateSummary, + }); + + const json = result.json; aiStats.summariesGenerated++; + if (!result.isFallback) { + aiStats.lastSuccessfulProvider = result.provider; + aiStats.lastSuccessfulModel = result.model; + } return { summary: truncate(json?.summary, 600) || previousSummary || null, stage: truncate(json?.stage, 150), - model, - provider, + model: result.model, + provider: result.provider, failed: false, + isFallback: result.isFallback || false, + fallbackReason: result.fallbackReason || null, }; } catch (error) { // Keep the last good summary if there is one; otherwise say plainly that diff --git a/backend/tests/groq-textgen.test.js b/backend/tests/groq-textgen.test.js new file mode 100644 index 0000000..2d04bd4 --- /dev/null +++ b/backend/tests/groq-textgen.test.js @@ -0,0 +1,627 @@ +/** + * Comprehensive Test Suite for Groq Text Generation & Usage Tracking (Phases 1-4) + * + * Verifies all 10 required items: + * 1. Groq success: reply generated, tokens and cost logged. + * 2. Invalid key -> Gemini fallback, no user-facing error, logged as fallback with reason. + * 3. Simulated 429, timeout and 5xx -> fallback. + * 4. Empty content / finish_reason length -> fallback. + * 5. Circuit breaker opens and recovers. + * 6. Admin switch to Gemini-first works at runtime, no restart. + * 7. Dashboard numbers equal direct SQL aggregates (spot check three). + * 8. Burst: 100 concurrent text-gen requests, no crash, no lost reply, rate limit respected. + * 9. Regression: classification, Auto DM, existing test suite unchanged. + * 10. Security: usage endpoints reject non-admins, no DM content in logs or responses, key never appears in logs. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { createRequire } from 'node:module'; +import request from 'supertest'; +import { buildTestApp } from './helpers/app.js'; +import { prisma, resetDb, createAdmin } from './helpers/db.js'; +import { VALID_CLASSIFICATION } from './helpers/ai.js'; + +const require = createRequire(import.meta.url); +const groqTextGen = require('../src/lib/ai/groqTextGen.js'); +const aiService = require('../src/services/aiService.js'); +const aiRouter = require('../src/lib/ai/router.js'); +const settings = require('../src/lib/settings.js'); +const adminRoutes = require('../src/routes/admin.js'); + +const ORIGIN = 'https://app.test.local'; +const FAKE_GROQ_KEY = 'gsk-test-GROQ-KEY-MUST-NEVER-LEAK-XYZ12345'; +const FAKE_GEMINI_KEY = 'gemini-test-KEY-MUST-NEVER-LEAK-XYZ12345'; + +let app; +let ipSeq = 0; + +beforeAll(() => { + app = buildTestApp({ admin: adminRoutes }); +}); + +let logged; + +beforeEach(async () => { + await resetDb(); + await prisma.aiUsageLog.deleteMany({}); + await prisma.appSetting.deleteMany({}); + groqTextGen._resetCircuitBreaker(); + groqTextGen._resetMetrics(); + groqTextGen._resetFetchImpl(); + groqTextGen.clearTextProviderOverrideCache(); + groqTextGen.getLimiter().reset(); + + process.env.GROQ_API_KEY = FAKE_GROQ_KEY; + process.env.GEMINI_API_KEY = FAKE_GEMINI_KEY; + delete process.env.TEXT_PROVIDER_FORCE; + + logged = []; + for (const level of ['log', 'warn', 'error', 'info']) { + vi.spyOn(console, level).mockImplementation((...args) => { + logged.push(args.map(String).join(' ')); + }); + } +}); + +afterEach(() => { + delete process.env.GROQ_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.TEXT_PROVIDER_FORCE; + groqTextGen._resetFetchImpl(); + vi.restoreAllMocks(); +}); + +const noKeyIn = (...texts) => { + for (const t of [...texts, ...logged]) { + expect(String(t)).not.toContain('MUST-NEVER-LEAK'); + } +}; + +async function adminAgent() { + const { admin, password } = await createAdmin(); + const agent = request.agent(app).set('Origin', ORIGIN).set('X-Forwarded-For', `10.99.0.${(ipSeq++ % 250) + 1}`); + const loginRes = await agent.post('/api/admin/login').send({ email: admin.email, password }); + expect(loginRes.status).toBe(200); + return { agent, admin }; +} + +describe('Groq Cloud Text Generation & AI Usage Tracking', () => { + // 1. Groq success: reply generated, tokens and cost logged + it('1. Groq success: generates clean reply, keeps reasoning out, logs tokens and cost', async () => { + const mockGroqResponse = { + id: 'chatcmpl-test-123', + choices: [ + { + message: { + role: 'assistant', + content: 'I should draft a polite answerHello! Our reels editing package starts at $150.', + reasoning_content: 'I thought about the price.', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 120, + completion_tokens: 45, + total_tokens: 165, + prompt_tokens_details: { cached_tokens: 20 }, + completion_tokens_details: { reasoning_tokens: 25 }, + }, + }; + + groqTextGen._setFetchImpl(async () => ({ + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'x-request-id': 'req-groq-success-1', + 'x-ratelimit-remaining-requests': '99', + 'x-ratelimit-remaining-tokens': '995000', + }), + json: async () => mockGroqResponse, + })); + + const result = await aiService.generateReply({ + recentMessages: [{ sender: 'customer', text: 'How much for reel editing?' }], + contactUsername: 'customer123', + businessProfile: { businessName: 'Editor Studio' }, + tenantId: 'tenant-user-1', + }); + + expect(result.failed).toBe(false); + expect(result.provider).toBe('groq'); + expect(result.text).toBe('Hello! Our reels editing package starts at $150.'); + expect(result.text).not.toContain(''); + expect(result.text).not.toContain('reasoning_content'); + + // Wait a tick for fire-and-forget logging + await new Promise((r) => setTimeout(r, 100)); + + const log = await prisma.aiUsageLog.findFirst({ + where: { provider: 'groq' }, + }); + expect(log).not.toBeNull(); + expect(log.feature).toBe('auto_reply'); + expect(log.status).toBe('success'); + expect(log.isFallback).toBe(false); + expect(log.promptTokens).toBe(120); + expect(log.completionTokens).toBe(45); + expect(log.reasoningTokens).toBe(25); + expect(log.cachedTokens).toBe(20); + expect(log.totalTokens).toBe(165); + expect(log.estimatedCostUsd).toBeGreaterThan(0); + expect(log.providerRequestId).toBe('req-groq-success-1'); + noKeyIn(); + }); + + // 2. Invalid key -> Gemini fallback + it('2. Invalid key -> falls back to Gemini, customer sees zero error, logged as fallback', async () => { + groqTextGen._setFetchImpl(async () => ({ + ok: false, + status: 401, + headers: new Headers({ 'x-request-id': 'req-auth-err' }), + json: async () => ({ error: { message: 'Invalid API key provided' } }), + })); + + const fallbackReply = 'Hi! We charge $150 per reel.'; + vi.spyOn(aiRouter, 'callAI').mockResolvedValueOnce({ + text: fallbackReply, + model: 'gemini-flash-lite-latest', + provider: 'gemini', + usage: { input: 110, output: 20 }, + }); + + const result = await aiService.generateReply({ + recentMessages: [{ sender: 'customer', text: 'Pricing?' }], + tenantId: 'tenant-user-2', + }); + + expect(result.failed).toBe(false); + expect(result.provider).toBe('gemini'); + expect(result.text).toBe(fallbackReply); + + await new Promise((r) => setTimeout(r, 100)); + + const logs = await prisma.aiUsageLog.findMany({ + orderBy: { createdAt: 'asc' }, + }); + expect(logs.length).toBe(2); + + // Attempt 1: Groq failed (401) + expect(logs[0].provider).toBe('groq'); + expect(logs[0].status).toBe('error'); + expect(logs[0].httpStatus).toBe(401); + expect(logs[0].isFallback).toBe(false); + + // Attempt 2: Gemini fallback succeeded + expect(logs[1].provider).toBe('gemini'); + expect(logs[1].isFallback).toBe(true); + expect(logs[1].fallbackReason).toBe('auth_error_401'); + expect(logs[1].status).toBe('success'); + noKeyIn(); + }); + + // 3. Simulated 429, timeout and 5xx -> fallback + it('3. Simulated 429 (retry-after > 2s), timeout, and 500 fall back to Gemini', async () => { + const fallbackReply = 'We offer high quality editing.'; + const spy = vi.spyOn(aiRouter, 'callAI').mockImplementation(async () => ({ + text: fallbackReply, + model: 'gemini-flash-lite-latest', + provider: 'gemini', + usage: { input: 50, output: 15 }, + })); + + // Test 3a: 429 with long retry-after -> immediate fallback + groqTextGen._setFetchImpl(async () => ({ + ok: false, + status: 429, + headers: new Headers({ 'retry-after': '30' }), + json: async () => ({ error: { message: 'Rate limit exceeded' } }), + })); + + const r1 = await aiService.generateReply({ tenantId: 't1' }); + expect(r1.failed).toBe(false); + expect(r1.provider).toBe('gemini'); + + // Test 3b: 500 server error -> fallback + groqTextGen._setFetchImpl(async () => ({ + ok: false, + status: 500, + headers: new Headers({}), + json: async () => ({ error: { message: 'Internal server error' } }), + })); + + const r2 = await aiService.generateReply({ tenantId: 't2' }); + expect(r2.failed).toBe(false); + expect(r2.provider).toBe('gemini'); + + // Test 3c: Timeout (AbortError) -> fallback + groqTextGen._setFetchImpl(async () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + throw err; + }); + + const r3 = await aiService.generateReply({ tenantId: 't3' }); + expect(r3.failed).toBe(false); + expect(r3.provider).toBe('gemini'); + expect(spy).toHaveBeenCalledTimes(3); + noKeyIn(); + }); + + // 4. Empty content and finish_reason "length" -> fallback + it('4. Empty content or finish_reason length counts as failure and falls back', async () => { + vi.spyOn(aiRouter, 'callAI').mockResolvedValue({ + text: 'Valid Gemini reply', + model: 'gemini-flash-lite-latest', + provider: 'gemini', + usage: { input: 50, output: 10 }, + }); + + // 4a: finish_reason length + groqTextGen._setFetchImpl(async () => ({ + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: 'Half-written sentence...' }, finish_reason: 'length' }], + usage: { prompt_tokens: 100, completion_tokens: 50 }, + }), + })); + + const r1 = await aiService.generateReply({ tenantId: 't1' }); + expect(r1.failed).toBe(false); + expect(r1.provider).toBe('gemini'); + expect(r1.text).toBe('Valid Gemini reply'); + + // 4b: Empty content / whitespace + groqTextGen._setFetchImpl(async () => ({ + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: ' ' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 100, completion_tokens: 0 }, + }), + })); + + const r2 = await aiService.generateReply({ tenantId: 't2' }); + expect(r2.failed).toBe(false); + expect(r2.provider).toBe('gemini'); + expect(r2.text).toBe('Valid Gemini reply'); + + // 4c: Only think tags (no final response) + groqTextGen._setFetchImpl(async () => ({ + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: 'only thinking here' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 100, completion_tokens: 20 }, + }), + })); + + const r3 = await aiService.generateReply({ tenantId: 't3' }); + expect(r3.failed).toBe(false); + expect(r3.provider).toBe('gemini'); + expect(r3.text).toBe('Valid Gemini reply'); + noKeyIn(); + }); + + // 5. Circuit breaker opens after 5 failures and auto-recovers + it('5. Circuit breaker opens after 5 failures and skips Groq, then resets', async () => { + vi.spyOn(aiRouter, 'callAI').mockResolvedValue({ + text: 'Gemini fallback', + model: 'gemini-flash-lite-latest', + provider: 'gemini', + }); + + let groqCallCount = 0; + groqTextGen._setFetchImpl(async () => { + groqCallCount += 1; + return { + ok: false, + status: 500, + headers: new Headers({}), + json: async () => ({ error: { message: 'Server error' } }), + }; + }); + + // 5 failures in a row + for (let i = 0; i < 5; i++) { + await aiService.generateReply({ tenantId: `fail-${i}` }); + } + expect(groqCallCount).toBe(5); + + // Circuit is now OPEN + const cbState = groqTextGen._getCircuitBreakerState(); + expect(cbState.isOpen).toBe(true); + expect(cbState.state).toBe('OPEN'); + + // 6th call: Groq is NOT called over network, falls back directly + const r6 = await aiService.generateReply({ tenantId: 'fail-6' }); + expect(r6.failed).toBe(false); + expect(r6.provider).toBe('gemini'); + expect(groqCallCount).toBe(5); // unchanged! + + // Reset circuit breaker + groqTextGen._resetCircuitBreaker(); + expect(groqTextGen._getCircuitBreakerState().isOpen).toBe(false); + + // Call again -> Groq is attempted again + await aiService.generateReply({ tenantId: 'after-reset' }); + expect(groqCallCount).toBe(6); + noKeyIn(); + }); + + // 6. Admin switch to Gemini-first works at runtime, no restart + it('6. Admin switch to Gemini-first works at runtime without restart', async () => { + let groqCalled = false; + groqTextGen._setFetchImpl(async () => { + groqCalled = true; + return { + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: 'Groq reply' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 10 }, + }), + }; + }); + + const geminiSpy = vi.spyOn(aiRouter, 'callAI').mockResolvedValue({ + text: 'Gemini reply', + model: 'gemini-flash-lite-latest', + provider: 'gemini', + }); + + // 1. Initially Groq is primary + const r1 = await aiService.generateReply({ tenantId: 'user-1' }); + expect(r1.failed).toBe(false); + expect(r1.provider).toBe('groq'); + expect(groqCalled).toBe(true); + + // 2. Admin switches provider to gemini at runtime + await settings.setSetting('text_generation_provider', 'gemini'); + groqTextGen.clearTextProviderOverrideCache(); + groqCalled = false; + + const r2 = await aiService.generateReply({ tenantId: 'user-2' }); + expect(r2.failed).toBe(false); + expect(r2.provider).toBe('gemini'); + expect(groqCalled).toBe(false); + expect(geminiSpy).toHaveBeenCalled(); + + // 3. Admin switches back to groq + await settings.setSetting('text_generation_provider', 'groq'); + groqTextGen.clearTextProviderOverrideCache(); + + const r3 = await aiService.generateReply({ tenantId: 'user-3' }); + expect(r3.failed).toBe(false); + expect(r3.provider).toBe('groq'); + expect(groqCalled).toBe(true); + + // 4. Kill switch TEXT_PROVIDER_FORCE=gemini overrides setting + process.env.TEXT_PROVIDER_FORCE = 'gemini'; + groqCalled = false; + + const r4 = await aiService.generateReply({ tenantId: 'user-4' }); + expect(r4.failed).toBe(false); + expect(r4.provider).toBe('gemini'); + expect(groqCalled).toBe(false); + noKeyIn(); + }); + + // 7. Dashboard numbers equal direct SQL aggregates (spot check three) + it('7. Dashboard numbers equal direct SQL aggregates', async () => { + // Seed 5 usage log rows + await prisma.aiUsageLog.createMany({ + data: [ + { + feature: 'auto_reply', + provider: 'groq', + model: 'openai/gpt-oss-20b', + status: 'success', + latencyMs: 350, + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + estimatedCostUsd: 0.0000225, + isFallback: false, + }, + { + feature: 'draft_reply', + provider: 'groq', + model: 'openai/gpt-oss-20b', + status: 'success', + latencyMs: 400, + promptTokens: 200, + completionTokens: 80, + totalTokens: 280, + estimatedCostUsd: 0.000039, + isFallback: false, + }, + { + feature: 'summary', + provider: 'gemini', + model: 'gemini-flash-lite-latest', + status: 'success', + latencyMs: 250, + promptTokens: 300, + completionTokens: 40, + totalTokens: 340, + estimatedCostUsd: 0.000046, + isFallback: true, + fallbackReason: 'timeout', + }, + { + feature: 'classification', + provider: 'cloudflare', + model: '@cf/cloudflare/clef-flash', + status: 'success', + latencyMs: 180, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + isFallback: false, + }, + { + feature: 'auto_reply', + provider: 'groq', + model: 'openai/gpt-oss-20b', + status: 'error', + latencyMs: 120, + promptTokens: 50, + completionTokens: 0, + totalTokens: 50, + estimatedCostUsd: 0.00000375, + isFallback: false, + }, + ], + }); + + const { agent } = await adminAgent(); + const res = await agent.get('/api/admin/ai/usage/summary?range=30d'); + expect(res.status).toBe(200); + + const body = res.body; + + // Spot-check 1: Total requests + const directCount = await prisma.aiUsageLog.count(); + expect(body.requests).toBe(directCount); + expect(body.requests).toBe(5); + + // Spot-check 2: Total tokens + const directTokens = await prisma.aiUsageLog.aggregate({ _sum: { totalTokens: true } }); + expect(body.tokens.total).toBe(directTokens._sum.totalTokens); + expect(body.tokens.total).toBe(820); + + // Spot-check 3: Total estimated cost USD + const directCost = await prisma.aiUsageLog.aggregate({ _sum: { estimatedCostUsd: true } }); + expect(Number(body.cost.usd.toFixed(6))).toBe(Number((directCost._sum.estimatedCostUsd || 0).toFixed(6))); + + // Fallbacks count spot check + const directFallbacks = await prisma.aiUsageLog.count({ where: { isFallback: true } }); + expect(body.fallbacks.count).toBe(directFallbacks); + expect(body.fallbacks.count).toBe(1); + noKeyIn(); + }); + + // 8. Burst: 100 concurrent text-gen requests + it('8. Burst: 100 concurrent requests complete without crash, no lost replies', async () => { + let inFlightMax = 0; + let active = 0; + + groqTextGen._setFetchImpl(async () => { + active++; + inFlightMax = Math.max(inFlightMax, active); + await new Promise((r) => setTimeout(r, 10)); // simulate 10ms work + active--; + return { + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: 'Burst response' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + }; + }); + + // Launch 100 concurrent text generations + const promises = Array.from({ length: 100 }, (_, i) => + aiService.generateReply({ + tenantId: `burst-user-${i}`, + instructions: `test ${i}`, + }) + ); + + const results = await Promise.all(promises); + + expect(results).toHaveLength(100); + for (const r of results) { + expect(r.failed).toBe(false); + expect(r.text).toBe('Burst response'); + } + + // Verify concurrency limiter throttled in-flight requests (max limit is 10) + expect(inFlightMax).toBeLessThanOrEqual(10); + noKeyIn(); + }); + + // 9. Regression: classification & Auto DM unchanged + it('9. Regression: DM classification still routes through Clef/Gemini as before', async () => { + vi.spyOn(aiRouter, 'callAI').mockResolvedValue({ + text: JSON.stringify(VALID_CLASSIFICATION), + json: VALID_CLASSIFICATION, + model: 'gemini-flash-lite-latest', + provider: 'gemini', + usage: { input: 150, output: 50 }, + }); + + const classificationOut = await aiService.analyzeConversation({ + messageText: 'How much for 1 reel?', + }); + + expect(classificationOut.failed).toBe(false); + expect(classificationOut.category).toBe('pricing_inquiry'); + noKeyIn(); + }); + + // 10. Security: usage endpoints reject non-admins, no DM content stored, key never leaked + it('10. Security: non-admin gets 404, no DM content in DB or API, key never appears in logs', async () => { + // 10a: Unauthenticated / non-admin request gets 404 (fail-closed surface hiding) + const anonRes = await request(app).get('/api/admin/ai/usage/summary'); + expect(anonRes.status).toBe(404); + + const anonTraces = await request(app).get('/api/admin/ai/usage/traces'); + expect(anonTraces.status).toBe(404); + + const anonTest = await request(app).post('/api/admin/text-gen/test').set('Origin', ORIGIN).send({}); + expect(anonTest.status).toBe(404); + + // 10b: Generate a reply containing sensitive customer text + const secretMsg = 'CONFIDENTIAL_CUSTOMER_MESSAGE_12345'; + groqTextGen._setFetchImpl(async () => ({ + ok: true, + status: 200, + headers: new Headers({}), + json: async () => ({ + choices: [{ message: { content: 'Safe public reply' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 20, completion_tokens: 10 }, + }), + })); + + await aiService.generateReply({ + recentMessages: [{ sender: 'customer', text: secretMsg }], + tenantId: 'tenant-sec-1', + }); + + await new Promise((r) => setTimeout(r, 100)); + + // Verify DB columns NEVER contain the message text + const logs = await prisma.aiUsageLog.findMany(); + for (const row of logs) { + for (const val of Object.values(row)) { + if (typeof val === 'string') { + expect(val).not.toContain(secretMsg); + expect(val).not.toContain(FAKE_GROQ_KEY); + expect(val).not.toContain(FAKE_GEMINI_KEY); + } + } + } + + // 10c: Admin traces endpoint response NEVER contains DM content or keys + const { agent } = await adminAgent(); + const traceRes = await agent.get('/api/admin/ai/usage/traces'); + expect(traceRes.status).toBe(200); + const traceJson = JSON.stringify(traceRes.body); + expect(traceJson).not.toContain(secretMsg); + expect(traceJson).not.toContain(FAKE_GROQ_KEY); + expect(traceJson).not.toContain(FAKE_GEMINI_KEY); + + noKeyIn(); + }); +}); diff --git a/frontend/src/app/admin/ai/page.js b/frontend/src/app/admin/ai/page.js index 5599814..19a63da 100644 --- a/frontend/src/app/admin/ai/page.js +++ b/frontend/src/app/admin/ai/page.js @@ -1,13 +1,18 @@ 'use client'; /** - * Admin — AI provider (Gate 4). + * Super Admin — AI Provider Controls & Usage Dashboard. * - * Pick a provider + model from the server's allowlist (only providers whose - * key is configured), test it on 10 synthetic messages, then activate it — - * with a written reason and a password step-up — or roll back in one click. - * The page never sees or sends an API key: the server shows only whether - * each provider's key is configured. + * Siffty Theme: + * - White background, royal blue #4169E1 accent + * - Flat surfaces with 1px borders + * - No gradients, no emoji, no fake data + * + * Includes: + * 1. DM Classification (Cloudflare Clef Flash -> Gemini fallback) + * 2. Text Generation (Groq Cloud gpt-oss-20b -> Gemini fallback) + * 3. AI Usage Tracking Dashboard (tokens, cost, latency, users, traces, CSV export) + * 4. General Provider Allowlist Testing & Activation */ import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -20,8 +25,17 @@ import { setAdminClassificationProvider, testAdminClassification, resetAdminClassificationBreaker, + getAdminTextGenStatus, + setAdminTextGenProvider, + resetAdminTextGenBreaker, + testAdminTextGen, + getAdminAiUsageSummary, + getAdminAiUsageBreakdown, + getAdminAiUsageUsers, + getAdminAiUsageTraces, + getAdminAiUsageExportUrl, } from '@/lib/api'; -import { Badge, Button, Select, Skeleton } from '@/app/components/ui'; +import { Badge, Button, Select, Skeleton, Input } from '@/app/components/ui'; import { admin as t, errors } from '@/content/copy'; import { formatDateTime } from '@/content/format'; import ReasonDialog from '../_components/ReasonDialog'; @@ -29,7 +43,9 @@ import { StepUpCancelled, useStepUp } from '../_components/StepUp'; import styles from '../admin.module.css'; const c = t.ai; -const usd = (x) => (typeof x === 'number' ? `$${x.toFixed(2)}` : '—'); +const usd = (x) => (typeof x === 'number' && Number.isFinite(x) ? `$${x.toFixed(4)}` : '—'); +const inr = (x) => (typeof x === 'number' && Number.isFinite(x) ? `₹${x.toFixed(2)}` : '—'); + const label = (catalogue, target) => { if (!target) return '—'; const p = catalogue.find((x) => x.id === target.provider); @@ -63,7 +79,35 @@ export default function AdminAiPage() { const [classTestResult, setClassTestResult] = useState(null); const [classResetting, setClassResetting] = useState(false); - const load = useCallback(async () => { + // Text generation state & controls (Groq Cloud) + const [textStatus, setTextStatus] = useState(null); + const [textLoading, setTextLoading] = useState(true); + const [textError, setTextError] = useState(''); + const [textSwitchDialog, setTextSwitchDialog] = useState(null); // 'groq' | 'gemini' | null + const [textSwitchBusy, setTextSwitchBusy] = useState(false); + const [textSwitchError, setTextSwitchError] = useState(''); + const [textTesting, setTextTesting] = useState(false); + const [textTestResult, setTextTestResult] = useState(null); + const [textResetting, setTextResetting] = useState(false); + + // Usage dashboard state + const [usageRange, setUsageRange] = useState('7d'); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [usageFilterFeature, setUsageFilterFeature] = useState('all'); + const [usageFilterProvider, setUsageFilterProvider] = useState('all'); + const [usageFilterStatus, setUsageFilterStatus] = useState('all'); + const [usageFilterFallback, setUsageFilterFallback] = useState('all'); + const [usageSummary, setUsageSummary] = useState(null); + const [usageBreakdown, setUsageBreakdown] = useState(null); + const [usageUsers, setUsageUsers] = useState([]); + const [userSort, setUserSort] = useState('cost'); + const [usageTraces, setUsageTraces] = useState([]); + const [tracesPagination, setTracesPagination] = useState({ page: 1, limit: 25, total: 0, pages: 1 }); + const [tracesPage, setTracesPage] = useState(1); + const [usageLoading, setUsageLoading] = useState(true); + + const loadConfig = useCallback(async () => { try { setConfig(await getAdminAiConfig()); } catch { @@ -71,14 +115,6 @@ export default function AdminAiPage() { } }, []); - useEffect(() => { - let cancelled = false; - getAdminAiConfig() - .then((data) => { if (!cancelled) setConfig(data); }) - .catch(() => { if (!cancelled) setError(errors.admin.settings); }); - return () => { cancelled = true; }; - }, []); - const loadClassification = useCallback(async () => { try { const data = await getAdminClassificationStatus(); @@ -91,6 +127,143 @@ export default function AdminAiPage() { } }, []); + const loadTextGen = useCallback(async () => { + try { + const data = await getAdminTextGenStatus(); + setTextStatus(data); + setTextError(''); + } catch { + setTextError('Could not load text generation status.'); + } finally { + setTextLoading(false); + } + }, []); + + const loadUsageDashboard = useCallback(async () => { + setUsageLoading(true); + const queryParams = { + range: usageRange, + from: usageRange === 'custom' ? customFrom : undefined, + to: usageRange === 'custom' ? customTo : undefined, + feature: usageFilterFeature !== 'all' ? usageFilterFeature : undefined, + provider: usageFilterProvider !== 'all' ? usageFilterProvider : undefined, + status: usageFilterStatus !== 'all' ? usageFilterStatus : undefined, + fallback: usageFilterFallback !== 'all' ? usageFilterFallback : undefined, + }; + + try { + const [sum, bkd, usr, trc] = await Promise.all([ + getAdminAiUsageSummary(queryParams), + getAdminAiUsageBreakdown(queryParams), + getAdminAiUsageUsers({ ...queryParams, sort: userSort, limit: 15 }), + getAdminAiUsageTraces({ ...queryParams, page: tracesPage, limit: 25 }), + ]); + setUsageSummary(sum); + setUsageBreakdown(bkd); + setUsageUsers(usr.users || []); + setUsageTraces(trc.traces || []); + setTracesPagination(trc.pagination || { page: 1, limit: 25, total: 0, pages: 1 }); + } catch (err) { + console.error('Failed to load AI usage dashboard:', err); + } finally { + setUsageLoading(false); + } + }, [ + usageRange, + customFrom, + customTo, + usageFilterFeature, + usageFilterProvider, + usageFilterStatus, + usageFilterFallback, + userSort, + tracesPage, + ]); + + useEffect(() => { + let cancelled = false; + getAdminAiConfig() + .then((data) => { if (!cancelled) setConfig(data); }) + .catch(() => { if (!cancelled) setError(errors.admin.settings); }); + + getAdminClassificationStatus() + .then((data) => { + if (!cancelled) { + setClassStatus(data); + setClassError(''); + setClassLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setClassError('Could not load classification status.'); + setClassLoading(false); + } + }); + + getAdminTextGenStatus() + .then((data) => { + if (!cancelled) { + setTextStatus(data); + setTextError(''); + setTextLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setTextError('Could not load text generation status.'); + setTextLoading(false); + } + }); + + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + let cancelled = false; + const queryParams = { + range: usageRange, + from: usageRange === 'custom' ? customFrom : undefined, + to: usageRange === 'custom' ? customTo : undefined, + feature: usageFilterFeature !== 'all' ? usageFilterFeature : undefined, + provider: usageFilterProvider !== 'all' ? usageFilterProvider : undefined, + status: usageFilterStatus !== 'all' ? usageFilterStatus : undefined, + fallback: usageFilterFallback !== 'all' ? usageFilterFallback : undefined, + }; + + Promise.all([ + getAdminAiUsageSummary(queryParams), + getAdminAiUsageBreakdown(queryParams), + getAdminAiUsageUsers({ ...queryParams, sort: userSort, limit: 15 }), + getAdminAiUsageTraces({ ...queryParams, page: tracesPage, limit: 25 }), + ]).then(([sum, bkd, usr, trc]) => { + if (cancelled) return; + setUsageSummary(sum); + setUsageBreakdown(bkd); + setUsageUsers(usr.users || []); + setUsageTraces(trc.traces || []); + setTracesPagination(trc.pagination || { page: 1, limit: 25, total: 0, pages: 1 }); + setUsageLoading(false); + }).catch((err) => { + if (cancelled) return; + console.error('Failed to load AI usage dashboard:', err); + setUsageLoading(false); + }); + + return () => { cancelled = true; }; + }, [ + usageRange, + customFrom, + customTo, + usageFilterFeature, + usageFilterProvider, + usageFilterStatus, + usageFilterFallback, + userSort, + tracesPage, + ]); + + // Polling for operational status useEffect(() => { let timer = null; let active = true; @@ -98,20 +271,20 @@ export default function AdminAiPage() { async function poll() { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; try { - const data = await getAdminClassificationStatus(); + const [cData, tData] = await Promise.all([ + getAdminClassificationStatus().catch(() => null), + getAdminTextGenStatus().catch(() => null), + ]); if (active) { - setClassStatus(data); - setClassError(''); - setClassLoading(false); + if (cData) setClassStatus(cData); + if (tData) setTextStatus(tData); } } catch { - if (active) setClassError('Could not load classification status.'); + // ignore polling errors } } - poll(); timer = setInterval(poll, 10000); - const onVisibilityChange = () => { if (document.visibilityState === 'visible') poll(); }; @@ -124,7 +297,7 @@ export default function AdminAiPage() { }; }, []); - async function handleConfirmSwitchProvider(reason) { + async function handleConfirmSwitchClassification(reason) { if (!classSwitchDialog) return; setClassSwitchBusy(true); setClassSwitchError(''); @@ -143,6 +316,27 @@ export default function AdminAiPage() { } } + async function handleConfirmSwitchTextGen(reason) { + if (!textSwitchDialog) return; + setTextSwitchBusy(true); + setTextSwitchError(''); + try { + const result = await setAdminTextGenProvider({ + provider: textSwitchDialog, + reason, + }); + setTextStatus(result.stats); + setNotice({ tone: 'success', text: `Text generation primary provider switched to ${textSwitchDialog}.` }); + setTextSwitchDialog(null); + await loadTextGen(); + await loadUsageDashboard(); + } catch (err) { + setTextSwitchError(err?.response?.data?.error || 'Could not switch text generation provider.'); + } finally { + setTextSwitchBusy(false); + } + } + async function handleRunClassificationTest() { setClassTesting(true); setClassTestResult(null); @@ -151,9 +345,10 @@ export default function AdminAiPage() { setClassTestResult(res); setNotice({ tone: 'success', - text: `Classification test completed via ${res.provider}: ${res.category} (${res.latencyMs} ms).`, + text: `Classification test via ${res.provider}: ${res.category} (${res.latencyMs} ms).`, }); await loadClassification(); + await loadUsageDashboard(); } catch (err) { setNotice({ tone: 'error', text: err?.response?.data?.error || 'Classification test failed.' }); } finally { @@ -161,12 +356,12 @@ export default function AdminAiPage() { } } - async function handleResetBreaker() { + async function handleResetClassificationBreaker() { setClassResetting(true); try { const res = await resetAdminClassificationBreaker('Admin manual reset'); setClassStatus(res.stats); - setNotice({ tone: 'success', text: 'Circuit breaker reset successfully.' }); + setNotice({ tone: 'success', text: 'Classification circuit breaker reset successfully.' }); } catch (err) { setNotice({ tone: 'error', text: err?.response?.data?.error || 'Could not reset circuit breaker.' }); } finally { @@ -174,6 +369,38 @@ export default function AdminAiPage() { } } + async function handleRunTextGenTest() { + setTextTesting(true); + setTextTestResult(null); + try { + const res = await testAdminTextGen(); + setTextTestResult(res); + setNotice({ + tone: 'success', + text: `Text generation probe completed via ${res.provider} (${res.model}) in ${res.latencyMs} ms.`, + }); + await loadTextGen(); + await loadUsageDashboard(); + } catch (err) { + setNotice({ tone: 'error', text: err?.response?.data?.error || 'Text generation test failed.' }); + } finally { + setTextTesting(false); + } + } + + async function handleResetTextGenBreaker() { + setTextResetting(true); + try { + const res = await resetAdminTextGenBreaker('Admin manual reset'); + setTextStatus(res.stats); + setNotice({ tone: 'success', text: 'Groq circuit breaker reset successfully.' }); + } catch (err) { + setNotice({ tone: 'error', text: err?.response?.data?.error || 'Could not reset Groq circuit breaker.' }); + } finally { + setTextResetting(false); + } + } + const catalogue = useMemo(() => config?.providers || [], [config]); const usable = catalogue.filter((p) => p.keyConfigured); const models = catalogue.find((p) => p.id === provider)?.models || []; @@ -216,7 +443,7 @@ export default function AdminAiPage() { setNotice({ tone: 'success', text: c.rolledBack }); } setDialog(null); - await load(); + await loadConfig(); } catch (err) { if (!(err instanceof StepUpCancelled)) setDialogError(err?.response?.data?.error || errors.generic); } finally { @@ -229,6 +456,16 @@ export default function AdminAiPage() { return
{error}
; } + const exportUrl = getAdminAiUsageExportUrl({ + range: usageRange, + from: usageRange === 'custom' ? customFrom : undefined, + to: usageRange === 'custom' ? customTo : undefined, + feature: usageFilterFeature !== 'all' ? usageFilterFeature : undefined, + provider: usageFilterProvider !== 'all' ? usageFilterProvider : undefined, + status: usageFilterStatus !== 'all' ? usageFilterStatus : undefined, + fallback: usageFilterFallback !== 'all' ? usageFilterFallback : undefined, + }); + return (
{notice && ( @@ -236,7 +473,594 @@ export default function AdminAiPage() { )} {/* ========================================================= */} - {/* DM Classification Section (Cloudflare Clef Flash) */} + {/* 1. Text Generation Controls (Groq Cloud + Gemini Fallback) */} + {/* ========================================================= */} +
+
+
+

Text Generation

+

+ Primary provider:{' '} + {textStatus?.effectiveProvider?.toUpperCase() || 'GROQ'} + {' '}(model: {textStatus?.model || 'openai/gpt-oss-20b'}) + {' · '} + Fallback: Google Gemini (automatic, customer sees zero error) + {textStatus?.killSwitchActive && ( + · Kill switch active (TEXT_PROVIDER_FORCE=gemini) + )} +

+
+ {textStatus && ( + 20) + ? 'danger' + : textStatus.effectiveProvider === 'gemini' + ? 'neutral' + : 'positive' + }> + {textStatus.circuitBreaker?.isOpen || textStatus.errorRate > 20 + ? 'Degraded' + : textStatus.killSwitchActive + ? 'Kill Switch: Gemini' + : textStatus.effectiveProvider === 'gemini' + ? 'Gemini Primary' + : 'Groq Active'} + + )} +
+ + {textLoading && !textStatus && ( +
+ )} + + {textError && !textStatus && ( +

{textError}

+ )} + + {textStatus && ( + <> +
+
+ Provider Override: + + +
+ +
+ + +
+
+ + {textTestResult && ( +

+ Live probe result: Provider {textTestResult.provider} ({textTestResult.model}) in {textTestResult.latencyMs} ms. Output: "{textTestResult.text}" {textTestResult.isFallback ? `(Fallback: ${textTestResult.fallbackReason})` : ''} +

+ )} + +
+
+ Groq API Key + + {textStatus.isConfigured ? 'Configured' : 'Not configured'} + + Env variable: GROQ_API_KEY +
+
+ Circuit Breaker + + {textStatus.circuitBreaker?.state || 'CLOSED'} + + + {textStatus.circuitBreaker?.isOpen + ? `Tripped · Retry in ${textStatus.circuitBreaker?.secondsUntilRetry}s` + : `Healthy (${textStatus.circuitBreaker?.consecutiveFailures || 0}/5 fails)`} + +
+
+ Fallback Rate + 20 ? styles.valueDanger : ''}`}> + {textStatus.errorRate}% + + {textStatus.fallbackCalls} fallback calls in window +
+
+ Groq Latency (p50 / p95) + + {textStatus.latency?.p50 !== null ? `${textStatus.latency.p50} ms` : '—'} + + + p95: {textStatus.latency?.p95 !== null ? `${textStatus.latency.p95} ms` : '—'} + +
+
+ +
+
+
+ Rate Limit Remaining (Requests) +
+ {textStatus.rateLimits?.remainingRequests !== null ? textStatus.rateLimits.remainingRequests : '—'} +
+ Reset: {textStatus.rateLimits?.resetRequests || '—'} +
+
+ Rate Limit Remaining (Tokens) +
+ {textStatus.rateLimits?.remainingTokens !== null ? textStatus.rateLimits.remainingTokens : '—'} +
+ Reset: {textStatus.rateLimits?.resetTokens || '—'} +
+
+ Last Groq Success +
+ {textStatus.lastSuccessAt ? formatDateTime(textStatus.lastSuccessAt) : 'None recorded'} +
+
+
+ Reasoning Effort +
+ low +
+ Kept out of customer output +
+
+
+ + )} +
+ + {/* Confirmation Dialog for Text Gen Provider Switch */} + {textSwitchDialog && ( + { if (!textSwitchBusy) setTextSwitchDialog(null); }} + /> + )} + + {/* ========================================================= */} + {/* 2. AI Usage Tracking Dashboard */} + {/* ========================================================= */} +
+
+
+

AI Usage & Cost Tracking

+

+ Live token counts, real USD & INR cost aggregates, fallback traces, and per-user consumption. +

+
+ +
+ + {/* Date Range Selector & Filters */} +
+
+ {['today', '7d', '30d', 'custom'].map((r) => ( + + ))} +
+ + {usageRange === 'custom' && ( +
+ setCustomFrom(e.target.value)} + style={{ padding: '4px 8px', border: '1px solid var(--ui-line)', background: '#ffffff', fontSize: '12px' }} + /> + to + setCustomTo(e.target.value)} + style={{ padding: '4px 8px', border: '1px solid var(--ui-line)', background: '#ffffff', fontSize: '12px' }} + /> +
+ )} + +
+ + + + + +
+
+ + {usageLoading && !usageSummary ? ( +
+ ) : ( + <> + {/* Top Summary Cards */} +
+
+ Total Requests + + {usageSummary?.requests?.toLocaleString() || 0} + + + {usageSummary?.fallbacks?.count || 0} fallbacks ({usageSummary?.fallbacks?.rate || 0}%) + +
+ +
+ Total Tokens + + {usageSummary?.tokens?.total?.toLocaleString() || 0} + + + In: {usageSummary?.tokens?.prompt?.toLocaleString() || 0} · Out: {usageSummary?.tokens?.completion?.toLocaleString() || 0} · Reasoning: {usageSummary?.tokens?.reasoning?.toLocaleString() || 0} + +
+ +
+ Estimated Cost + + {usd(usageSummary?.cost?.usd)} + + + {inr(usageSummary?.cost?.inr)} (USD/INR @ {usageSummary?.cost?.usdInrRate || 84}) + +
+ +
+ Avg & p95 Latency + + {usageSummary?.latency?.avg != null ? `${usageSummary.latency.avg} ms` : '—'} + + + p95: {usageSummary?.latency?.p95 != null ? `${usageSummary.latency.p95} ms` : '—'} · Errors: {usageSummary?.errors?.rate || 0}% + +
+
+ + {/* Daily Trend SVG Chart */} + {usageBreakdown?.daily && usageBreakdown.daily.length > 0 && ( +
+ Daily AI Requests & Cost +
+ + {/* Horizontal gridlines */} + + + + {(() => { + const maxReq = Math.max(...usageBreakdown.daily.map((d) => d.requests), 1); + const barWidth = Math.max(8, Math.min(30, 500 / usageBreakdown.daily.length - 4)); + return usageBreakdown.daily.map((d, i) => { + const x = 30 + i * (540 / Math.max(1, usageBreakdown.daily.length - 1 || 1)); + const h = (d.requests / maxReq) * 70; + const y = 85 - h; + return ( + + 0 ? 0.75 : 1} + /> + + {d.date.slice(5)} + + + ); + }); + })()} + +
+
+ )} + + {/* Breakdowns: Models & Features */} +
+ {/* Provider & Model Breakdown */} +
+ Breakdown by Provider & Model +
+ + + + + + + + + + + + {usageBreakdown?.models && usageBreakdown.models.length > 0 ? ( + usageBreakdown.models.map((m) => ( + + + + + + + + )) + ) : ( + + )} + +
ProviderModelCallsTokensEst. Cost
{m.provider}{m.model}{m.requests.toLocaleString()}{m.totalTokens.toLocaleString()}{usd(m.costUsd)}
No calls in this period
+
+
+ + {/* Feature Breakdown */} +
+ Breakdown by Feature +
+ + + + + + + + + + + + {usageBreakdown?.features && usageBreakdown.features.length > 0 ? ( + usageBreakdown.features.map((f) => ( + + + + + + + + )) + ) : ( + + )} + +
FeatureRequestsTokensEst. CostAvg Latency
{f.feature}{f.requests.toLocaleString()}{f.totalTokens.toLocaleString()}{usd(f.costUsd)}{f.avgLatencyMs ? `${f.avgLatencyMs} ms` : '—'}
No calls in this period
+
+
+
+ + {/* Top Users Table */} +
+
+ Usage by Customer / Tenant +
+ Sort by: + +
+
+ +
+ + + + + + + + + + + + + {usageUsers.length > 0 ? ( + usageUsers.map((u) => ( + + + + + + + + + )) + ) : ( + + )} + +
Customer EmailPlanRequestsTokensEst. Cost (USD)Last Used
{u.email}{u.plan}{u.requests.toLocaleString()}{u.totalTokens.toLocaleString()}{usd(u.costUsd)}{formatDateTime(u.lastUsedAt)}
No user calls recorded in this period.
+
+
+ + {/* Recent Calls Trace Table */} +
+ Recent AI Call Traces +
+ + + + + + + + + + + + + + + + + {usageTraces.length > 0 ? ( + usageTraces.map((t) => ( + + + + + + + + + + + + + )) + ) : ( + + )} + +
TimeUserFeatureProvider / ModelFallbackStatusTokensLatencyCostReason / Note
{formatDateTime(t.createdAt)}{t.userEmail ? t.userEmail.split('@')[0] : t.userId ? t.userId.slice(0, 8) : '—'}{t.feature}{t.provider} · {t.model.replace('openai/', '').replace('gemini-', '')} + {t.isFallback ? ( + Fallback + ) : ( + Primary + )} + + + {t.status} + + {t.totalTokens != null ? t.totalTokens.toLocaleString() : '—'}{t.latencyMs} ms{usd(t.estimatedCostUsd)} + {t.fallbackReason || t.errorMessage || '—'} +
No call traces found matching the current filters.
+
+ + {/* Pagination */} + {tracesPagination.pages > 1 && ( +
+ + Page {tracesPagination.page} of {tracesPagination.pages} ({tracesPagination.total} total rows) + +
+ + +
+
+ )} +
+ + )} +
+ + {/* ========================================================= */} + {/* 3. DM Classification Section (Cloudflare Clef Flash) */} {/* ========================================================= */}
@@ -278,7 +1102,6 @@ export default function AdminAiPage() { {classStatus && ( <> - {/* Provider Controls & Action Buttons */}
Provider Override: @@ -312,7 +1135,7 @@ export default function AdminAiPage() {
- {/* Test result display */} {classTestResult && (

Synthetic probe result: Category {classTestResult.category} via provider {classTestResult.provider} ({classTestResult.model}) in {classTestResult.latencyMs} ms.

)} - {/* Metric Row */}
Calls in Window @@ -354,92 +1175,9 @@ export default function AdminAiPage() { p95: {classStatus.latency?.clef?.p95 !== null ? `${classStatus.latency.clef.p95} ms` : '—'} - {classStatus.latency?.gemini?.p50 !== null && ` · Gemini p50: ${classStatus.latency.gemini.p50} ms`}
- - {/* Operational Status */} -
-
-
- Credentials -
- {classStatus.credentialsPresent ? ( - Present - ) : ( - Absent - )} - - CLOUDFLARE_ACCOUNT_ID / TOKEN - -
-
- -
- Circuit Breaker -
- - {classStatus.circuitBreaker?.state || 'CLOSED'} - - {classStatus.circuitBreaker?.isOpen && ( - - Retry in {classStatus.circuitBreaker?.secondsUntilRetry}s - - )} -
-
- -
- In-Flight Concurrency -
- {classStatus.concurrency?.inFlight || 0} / {classStatus.concurrency?.maxConcurrency || 5} active -
-
- -
- Last Clef Success -
- {classStatus.lastClefSuccessAt ? formatDateTime(classStatus.lastClefSuccessAt) : 'None recorded'} -
-
-
- - {/* Fallback Reasons Table */} -
- Fallback Reasons Breakdown - {classStatus.totalCalls === 0 ? ( -

No calls since the last restart

- ) : Object.keys(classStatus.fallbackReasons || {}).length === 0 ? ( -

No fallbacks recorded in the current window.

- ) : ( -
- - - - - - - - - - {Object.entries(classStatus.fallbackReasons).map(([reason, count]) => ( - - - - - - ))} - -
ReasonCountShare of Calls
{reason}{count}{((count / classStatus.totalCalls) * 100).toFixed(1)}%
-
- )} -
- -

- Note: In-memory metrics and rolling window reset when the backend process restarts. -

-
)}
@@ -454,13 +1192,13 @@ export default function AdminAiPage() { variant="primary" busy={classSwitchBusy} error={classSwitchError} - onConfirm={handleConfirmSwitchProvider} + onConfirm={handleConfirmSwitchClassification} onClose={() => { if (!classSwitchBusy) setClassSwitchDialog(null); }} /> )} {/* ========================================================= */} - {/* Text Generation Models Section (Existing Gemini Router) */} + {/* 4. General AI Models Allowlist & Activation (Gate 4) */} {/* ========================================================= */}
@@ -530,12 +1268,6 @@ export default function AdminAiPage() {
-

- {c.cost}: {result.cost.classifications30d > 0 - ? `${c.costCurrent} ${usd(result.cost.currentMonthlyUsd)} · ${c.costCandidate} ${usd(result.cost.candidateMonthlyUsd)}` - : c.noVolume}{' '} - ({usd(result.cost.candidatePer1000Usd)} {c.costPer1000}) -

{result.allSchemaValid && (
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index d27b5d5..28e1203 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -575,6 +575,74 @@ export async function resetAdminClassificationBreaker(reason) { return data; } +// ---- Admin: Text Generation & Groq Provider Controls ---- + +export async function getAdminTextGenStatus() { + const { data } = await api.get('/api/admin/text-gen/status'); + return data; +} + +export async function setAdminTextGenProvider({ provider, reason }) { + const { data } = await api.post('/api/admin/text-gen/provider', { provider, reason }); + return data; +} + +export async function resetAdminTextGenBreaker(reason) { + const { data } = await api.post('/api/admin/text-gen/circuit-breaker/reset', { reason }); + return data; +} + +export async function testAdminTextGen() { + const { data } = await api.post('/api/admin/text-gen/test', {}); + return data; +} + +// ---- Admin: AI Usage Tracking Dashboard ---- + +export async function getAdminAiUsageSummary(params = {}) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + } + const { data } = await api.get(`/api/admin/ai/usage/summary?${qs.toString()}`); + return data; +} + +export async function getAdminAiUsageBreakdown(params = {}) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + } + const { data } = await api.get(`/api/admin/ai/usage/breakdown?${qs.toString()}`); + return data; +} + +export async function getAdminAiUsageUsers(params = {}) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + } + const { data } = await api.get(`/api/admin/ai/usage/users?${qs.toString()}`); + return data; +} + +export async function getAdminAiUsageTraces(params = {}) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + } + const { data } = await api.get(`/api/admin/ai/usage/traces?${qs.toString()}`); + return data; +} + +export function getAdminAiUsageExportUrl(params = {}) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + } + return `/api/admin/ai/usage/export?${qs.toString()}`; +} + // ---- Admin: kill switches, metrics, announcements, alerts (Gate 7) ---- export async function getAdminFlags() {