From ba9b26b6201be398371c5aec18f26842c50f5bf5 Mon Sep 17 00:00:00 2001 From: CloudWaddie Date: Thu, 5 Mar 2026 12:08:31 +1030 Subject: [PATCH 1/5] fix: Add authentication to high-risk API endpoints to prevent data leaks - Add API authentication middleware (src/lib/apiAuth.js) supporting JWT cookies and Bearer API keys - Add response sanitization utility (src/lib/sanitize.js) to mask/remove sensitive fields - Protect /api/usage/* endpoints (stats, history, logs, providers, chart, stream, request-details, request-logs, [connectionId]) - Protect /api/keys and /api/keys/[id] endpoints - Protect /api/shutdown endpoint - Protect /api/cloud/auth and /api/cloud/credentials/update endpoints - Sanitize usage stats responses to mask emails and API keys - All protected endpoints now return 401 for unauthenticated requests Fixes critical security vulnerability where sensitive data (emails, API keys, credentials) was exposed without authentication. --- src/app/api/cloud/auth/route.js | 8 +- src/app/api/cloud/credentials/update/route.js | 8 +- src/app/api/keys/[id]/route.js | 20 +- src/app/api/keys/route.js | 19 +- src/app/api/shutdown/route.js | 9 +- src/app/api/usage/[connectionId]/route.js | 8 +- src/app/api/usage/chart/route.js | 6 + src/app/api/usage/history/route.js | 13 +- src/app/api/usage/logs/route.js | 8 +- src/app/api/usage/providers/route.js | 8 +- src/app/api/usage/request-details/route.js | 6 + src/app/api/usage/request-logs/route.js | 8 +- src/app/api/usage/stats/route.js | 11 +- src/app/api/usage/stream/route.js | 15 +- src/lib/apiAuth.js | 59 +++++ src/lib/sanitize.js | 211 ++++++++++++++++++ 16 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 src/lib/apiAuth.js create mode 100644 src/lib/sanitize.js diff --git a/src/app/api/cloud/auth/route.js b/src/app/api/cloud/auth/route.js index 52abd5fe..7a9edc3a 100644 --- a/src/app/api/cloud/auth/route.js +++ b/src/app/api/cloud/auth/route.js @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; import { validateApiKey, getProviderConnections, getModelAliases } from "@/models"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; // Verify API key and return provider credentials export async function POST(request) { + // Require authentication (validates API key) + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse("Invalid API key"); + } try { const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { @@ -11,7 +17,7 @@ export async function POST(request) { const apiKey = authHeader.slice(7); - // Validate API key + // API key already validated by requireAuth, get connections const isValid = await validateApiKey(apiKey); if (!isValid) { return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); diff --git a/src/app/api/cloud/credentials/update/route.js b/src/app/api/cloud/credentials/update/route.js index fa25ba3c..b5b9df9a 100644 --- a/src/app/api/cloud/credentials/update/route.js +++ b/src/app/api/cloud/credentials/update/route.js @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; // Update provider credentials (for cloud token refresh) export async function PUT(request) { + // Require authentication (validates API key) + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse("Invalid API key"); + } try { const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { @@ -17,7 +23,7 @@ export async function PUT(request) { return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 }); } - // Validate API key + // API key already validated by requireAuth const isValid = await validateApiKey(apiKey); if (!isValid) { return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js index 1d22596a..cc273f91 100644 --- a/src/app/api/keys/[id]/route.js +++ b/src/app/api/keys/[id]/route.js @@ -1,15 +1,23 @@ import { NextResponse } from "next/server"; import { deleteApiKey, getApiKeyById, updateApiKey } from "@/lib/localDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; +import { sanitizeApiKeyData } from "@/lib/sanitize.js"; // GET /api/keys/[id] - Get single key export async function GET(request, { params }) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { id } = await params; const key = await getApiKeyById(id); if (!key) { return NextResponse.json({ error: "Key not found" }, { status: 404 }); } - return NextResponse.json({ key }); + const sanitized = sanitizeApiKeyData(key); + return NextResponse.json({ key: sanitized }); } catch (error) { console.log("Error fetching key:", error); return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 }); @@ -18,6 +26,11 @@ export async function GET(request, { params }) { // PUT /api/keys/[id] - Update key export async function PUT(request, { params }) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { id } = await params; const body = await request.json(); @@ -42,6 +55,11 @@ export async function PUT(request, { params }) { // DELETE /api/keys/[id] - Delete API key export async function DELETE(request, { params }) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { id } = await params; diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js index 98d25408..db069737 100644 --- a/src/app/api/keys/route.js +++ b/src/app/api/keys/route.js @@ -1,12 +1,21 @@ import { NextResponse } from "next/server"; import { getApiKeys, createApiKey } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; +import { sanitizeApiKeyData } from "@/lib/sanitize.js"; // GET /api/keys - List API keys -export async function GET() { +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } + try { const keys = await getApiKeys(); - return NextResponse.json({ keys }); + const sanitized = sanitizeApiKeyData(keys); + return NextResponse.json({ keys: sanitized }); } catch (error) { console.log("Error fetching keys:", error); return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); @@ -15,6 +24,12 @@ export async function GET() { // POST /api/keys - Create new API key export async function POST(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } + try { const body = await request.json(); const { name } = body; diff --git a/src/app/api/shutdown/route.js b/src/app/api/shutdown/route.js index 7ce76ef9..592fca7c 100644 --- a/src/app/api/shutdown/route.js +++ b/src/app/api/shutdown/route.js @@ -1,6 +1,13 @@ import { NextResponse } from "next/server"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; + +export async function POST(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } -export async function POST() { const response = NextResponse.json({ success: true, message: "Shutting down..." }); setTimeout(() => { diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 80e52403..e2f27694 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -1,7 +1,6 @@ // Ensure proxyFetch is loaded to patch globalThis.fetch import "open-sse/index.js"; - -import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; import { getUsageForProvider } from "open-sse/services/usage.js"; import { getExecutor } from "open-sse/executors/index.js"; /** @@ -91,6 +90,11 @@ async function refreshAndUpdateCredentials(connection) { * GET /api/usage/[connectionId] - Get usage data for a specific connection */ export async function GET(request, { params }) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { connectionId } = await params; diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js index 3289ca84..fad2cc18 100644 --- a/src/app/api/usage/chart/route.js +++ b/src/app/api/usage/chart/route.js @@ -1,9 +1,15 @@ import { NextResponse } from "next/server"; import { getChartData } from "@/lib/usageDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d"]); export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { searchParams } = new URL(request.url); const period = searchParams.get("period") || "7d"; diff --git a/src/app/api/usage/history/route.js b/src/app/api/usage/history/route.js index 16a5d407..44219dda 100644 --- a/src/app/api/usage/history/route.js +++ b/src/app/api/usage/history/route.js @@ -1,10 +1,19 @@ import { NextResponse } from "next/server"; import { getUsageStats } from "@/lib/usageDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; +import { sanitizeUsageStats } from "@/lib/sanitize.js"; + +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } -export async function GET() { try { const stats = await getUsageStats(); - return NextResponse.json(stats); + const sanitized = sanitizeUsageStats(stats); + return NextResponse.json(sanitized); } catch (error) { console.error("Error fetching usage stats:", error); return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 }); diff --git a/src/app/api/usage/logs/route.js b/src/app/api/usage/logs/route.js index b5b875ec..260ded0b 100644 --- a/src/app/api/usage/logs/route.js +++ b/src/app/api/usage/logs/route.js @@ -1,7 +1,13 @@ import { NextResponse } from "next/server"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; import { getRecentLogs } from "@/lib/usageDb"; -export async function GET() { +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const logs = await getRecentLogs(200); return NextResponse.json(logs); diff --git a/src/app/api/usage/providers/route.js b/src/app/api/usage/providers/route.js index baa1cffb..6c689f92 100644 --- a/src/app/api/usage/providers/route.js +++ b/src/app/api/usage/providers/route.js @@ -2,12 +2,18 @@ import { NextResponse } from "next/server"; import { getRequestDetailsDb } from "@/lib/requestDetailsDb"; import { getProviderNodes } from "@/lib/localDb"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; /** * GET /api/usage/providers * Returns list of unique providers from request details */ -export async function GET() { +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const db = await getRequestDetailsDb(); diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 73a3ceb2..a11e57d2 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -1,11 +1,17 @@ import { NextResponse } from "next/server"; import { getRequestDetails } from "@/lib/usageDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; /** * GET /api/usage/request-details * Query parameters: page, pageSize (1-100), provider, model, connectionId, status, startDate, endDate */ export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const { searchParams } = new URL(request.url); diff --git a/src/app/api/usage/request-logs/route.js b/src/app/api/usage/request-logs/route.js index 0ae5e961..ae3ef474 100644 --- a/src/app/api/usage/request-logs/route.js +++ b/src/app/api/usage/request-logs/route.js @@ -1,7 +1,13 @@ import { NextResponse } from "next/server"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; import { getRecentLogs } from "@/lib/usageDb"; -export async function GET() { +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } try { const logs = await getRecentLogs(200); return NextResponse.json(logs); diff --git a/src/app/api/usage/stats/route.js b/src/app/api/usage/stats/route.js index 2e8c88ad..d827b52a 100644 --- a/src/app/api/usage/stats/route.js +++ b/src/app/api/usage/stats/route.js @@ -1,11 +1,19 @@ import { NextResponse } from "next/server"; import { getUsageStats } from "@/lib/usageDb"; +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; +import { sanitizeUsageStats } from "@/lib/sanitize.js"; const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d", "all"]); export const dynamic = "force-dynamic"; export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } + try { const { searchParams } = new URL(request.url); const period = searchParams.get("period") || "7d"; @@ -15,7 +23,8 @@ export async function GET(request) { } const stats = await getUsageStats(period); - return NextResponse.json(stats); + const sanitized = sanitizeUsageStats(stats); + return NextResponse.json(sanitized); } catch (error) { console.error("[API] Failed to get usage stats:", error); return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 }); diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js index acfad388..732288b9 100644 --- a/src/app/api/usage/stream/route.js +++ b/src/app/api/usage/stream/route.js @@ -1,8 +1,14 @@ import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb"; - +import { requireAuth, unauthorizedResponse } from "@/lib/apiAuth.js"; +import { sanitizeUsageStats } from "@/lib/sanitize.js"; export const dynamic = "force-dynamic"; -export async function GET() { +export async function GET(request) { + // Require authentication + const auth = await requireAuth(request); + if (!auth.authenticated) { + return unauthorizedResponse(); + } const encoder = new TextEncoder(); const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null }; @@ -20,8 +26,9 @@ export async function GET() { } // Then do full recalc and update cache const stats = await getUsageStats(); - state.cachedStats = stats; - controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`)); + const sanitized = sanitizeUsageStats(stats); + state.cachedStats = sanitized; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(sanitized)}\n\n`)); } catch { state.closed = true; statsEmitter.off("update", state.send); diff --git a/src/lib/apiAuth.js b/src/lib/apiAuth.js new file mode 100644 index 00000000..2c87027f --- /dev/null +++ b/src/lib/apiAuth.js @@ -0,0 +1,59 @@ +import { jwtVerify } from "jose"; +import { getApiKeys } from "@/lib/localDb.js"; + +const SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "9router-default-secret-change-me" +); + +/** + * Authentication middleware for API routes + * Supports both JWT cookie (for dashboard users) and Bearer API key + * + * @param {Request} request - Next.js request object + * @returns {Promise<{authenticated: boolean, user?: object, apiKey?: string, error?: string}>} + */ +export async function requireAuth(request) { + // Try JWT cookie first (dashboard users) + const token = request.cookies.get("auth_token")?.value; + + if (token) { + try { + const { payload } = await jwtVerify(token, SECRET); + return { authenticated: true, user: payload }; + } catch (err) { + // JWT invalid, continue to try API key + } + } + + // Try Bearer API key + const authHeader = request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const apiKey = authHeader.substring(7); + + try { + const apiKeys = await getApiKeys(); + const validKey = apiKeys.find(k => k.key === apiKey); + + if (validKey) { + return { authenticated: true, apiKey: validKey.key, keyName: validKey.name }; + } + } catch (err) { + console.error("[apiAuth] Error validating API key:", err); + } + } + + return { authenticated: false, error: "Authentication required" }; +} + +/** + * Helper to return 401 Unauthorized response + */ +export function unauthorizedResponse(message = "Authentication required") { + return new Response( + JSON.stringify({ error: message }), + { + status: 401, + headers: { "Content-Type": "application/json" } + } + ); +} diff --git a/src/lib/sanitize.js b/src/lib/sanitize.js new file mode 100644 index 00000000..ec0ce319 --- /dev/null +++ b/src/lib/sanitize.js @@ -0,0 +1,211 @@ +/** + * Response sanitization utility to prevent sensitive data leaks + * Removes or masks sensitive fields from API responses + */ + +/** + * Fields that should be completely removed from responses + */ +const SENSITIVE_FIELDS = [ + 'apiKey', + 'key', + 'accessToken', + 'refreshToken', + 'token', + 'idToken', + 'credentials', + 'secret', + 'password', + 'clientSecret', +]; + +/** + * Mask an email address (show first char + *** + domain) + * Example: user@example.com -> u***@example.com + */ +function maskEmail(email) { + if (!email || typeof email !== 'string') return email; + if (!email.includes('@')) return email; + + const [local, domain] = email.split('@'); + if (local.length === 0) return email; + + return `${local[0]}***@${domain}`; +} + +/** + * Mask an API key (show first 8 chars + ***) + * Example: sk_1234567890abcdef -> sk_12345*** + */ +function maskApiKey(key) { + if (!key || typeof key !== 'string') return key; + if (key.length <= 8) return '***'; + + return `${key.substring(0, 8)}***`; +} + +/** + * Recursively sanitize an object by removing sensitive fields + * @param {any} data - Data to sanitize (object, array, or primitive) + * @param {object} options - Sanitization options + * @param {boolean} options.maskEmails - Whether to mask email addresses in accountName fields + * @param {boolean} options.maskApiKeys - Whether to mask API keys instead of removing them + * @param {string[]} options.additionalFields - Additional field names to remove + * @returns {any} Sanitized data + */ +export function sanitizeResponse(data, options = {}) { + const { + maskEmails = true, + maskApiKeys = false, + additionalFields = [], + } = options; + + const fieldsToRemove = [...SENSITIVE_FIELDS, ...additionalFields]; + + function sanitize(obj) { + // Handle null/undefined + if (obj === null || obj === undefined) { + return obj; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map(item => sanitize(item)); + } + + // Handle non-objects (primitives) + if (typeof obj !== 'object') { + return obj; + } + + // Handle objects + const sanitized = {}; + + for (const [key, value] of Object.entries(obj)) { + // Remove sensitive fields + if (fieldsToRemove.includes(key)) { + if (maskApiKeys && (key === 'apiKey' || key === 'key')) { + sanitized[key] = maskApiKey(value); + } + // Otherwise skip (remove) the field + continue; + } + + // Mask email addresses in accountName fields + if (maskEmails && key === 'accountName' && typeof value === 'string' && value.includes('@')) { + sanitized[key] = maskEmail(value); + } + // Mask email field itself + else if (maskEmails && key === 'email' && typeof value === 'string') { + sanitized[key] = maskEmail(value); + } + // Recursively sanitize nested objects/arrays + else if (value !== null && typeof value === 'object') { + sanitized[key] = sanitize(value); + } + // Keep primitive values as-is + else { + sanitized[key] = value; + } + } + + return sanitized; + } + + return sanitize(data); +} + +/** + * Sanitize usage stats response specifically + * Removes full API keys from byApiKey section and masks account emails + */ +export function sanitizeUsageStats(stats) { + if (!stats || typeof stats !== 'object') return stats; + + const sanitized = { ...stats }; + + // Remove or mask byApiKey entries + if (sanitized.byApiKey && typeof sanitized.byApiKey === 'object') { + const sanitizedByApiKey = {}; + + for (const [key, value] of Object.entries(sanitized.byApiKey)) { + // Mask the API key in the entry + const sanitizedEntry = { ...value }; + if (sanitizedEntry.apiKey) { + sanitizedEntry.apiKey = maskApiKey(sanitizedEntry.apiKey); + } + if (sanitizedEntry.apiKeyKey) { + sanitizedEntry.apiKeyKey = maskApiKey(sanitizedEntry.apiKeyKey); + } + + // Use masked key as the new key + const maskedKey = key.includes('|') + ? key.split('|').map((part, i) => i === 0 ? maskApiKey(part) : part).join('|') + : maskApiKey(key); + + sanitizedByApiKey[maskedKey] = sanitizedEntry; + } + + sanitized.byApiKey = sanitizedByApiKey; + } + + // Mask emails in byAccount + if (sanitized.byAccount && typeof sanitized.byAccount === 'object') { + const sanitizedByAccount = {}; + + for (const [key, value] of Object.entries(sanitized.byAccount)) { + const sanitizedEntry = { ...value }; + if (sanitizedEntry.accountName && sanitizedEntry.accountName.includes('@')) { + sanitizedEntry.accountName = maskEmail(sanitizedEntry.accountName); + } + sanitizedByAccount[key] = sanitizedEntry; + } + + sanitized.byAccount = sanitizedByAccount; + } + + // Mask emails in activeRequests + if (Array.isArray(sanitized.activeRequests)) { + sanitized.activeRequests = sanitized.activeRequests.map(req => { + const sanitizedReq = { ...req }; + if (sanitizedReq.account && sanitizedReq.account.includes('@')) { + sanitizedReq.account = maskEmail(sanitizedReq.account); + } + return sanitizedReq; + }); + } + + return sanitized; +} + +/** + * Sanitize provider connection data + * Removes tokens and credentials + */ +export function sanitizeProviderConnection(connection) { + return sanitizeResponse(connection, { + maskEmails: true, + maskApiKeys: false, + additionalFields: ['code', 'codeVerifier', 'state'], + }); +} + +/** + * Sanitize API key data + * Masks the actual key value + */ +export function sanitizeApiKeyData(keyData) { + if (!keyData) return keyData; + + if (Array.isArray(keyData)) { + return keyData.map(k => ({ + ...k, + key: maskApiKey(k.key), + })); + } + + return { + ...keyData, + key: maskApiKey(keyData.key), + }; +} From 424c4081ece9a26fd3985490a757399b84a293ea Mon Sep 17 00:00:00 2001 From: CloudWaddie Date: Thu, 5 Mar 2026 13:41:55 +1030 Subject: [PATCH 2/5] feat: Add input token validation to prevent 400 errors from upstream providers - Add modelLimits.js with per-provider token limits (Claude 200K, GPT-4 128K, GLM 1M, etc.) - Add tokenEstimator.js to estimate input tokens from request bodies - Add validation in chatCore.js before forwarding to upstream providers - Reject requests exceeding model limits with clear 400 error message - Add modelLimits setting to localDb for user overrides - Add UI in profile settings to customize limits per provider --- open-sse/config/modelLimits.js | 238 ++++++++++++++++++ open-sse/handlers/chatCore.js | 28 +++ open-sse/utils/tokenEstimator.js | 124 +++++++++ src/app/(dashboard)/dashboard/profile/page.js | 155 ++++++++++++ src/lib/localDb.js | 5 +- 5 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 open-sse/config/modelLimits.js create mode 100644 open-sse/utils/tokenEstimator.js diff --git a/open-sse/config/modelLimits.js b/open-sse/config/modelLimits.js new file mode 100644 index 00000000..d1f70ce0 --- /dev/null +++ b/open-sse/config/modelLimits.js @@ -0,0 +1,238 @@ +// Model input token limits - defaults for each provider/model +// Users can override these in settings + +// Default limits by provider (used when model-specific not defined) +export const PROVIDER_DEFAULT_LIMITS = { + // Claude models (Anthropic) + cc: 200000, // Claude Code - 200K context + anthropic: 200000, + + // OpenAI Codex + cx: 200000, // Codex - 200K context + openai: 128000, // GPT-4o - 128K + gh: 200000, // GitHub Copilot - 200K + + // Google + gc: 200000, // Gemini CLI - 200K + gemini: 200000, // Gemini API - 200K + + // Chinese providers + qw: 32000, // Qwen - 32K + if: 128000, // iFlow - varies by model + ag: 200000, // Antigravity - 200K (uses Gemini backend) + kr: 200000, // Kiro - 200K (uses Claude) + + // Other providers + cu: 200000, // Cursor + kmc: 1000000, // Kimi Coding - 1M! + kc: 200000, // KiloCode + cl: 200000, // Cline + + // API Key providers + glm: 1000000, // GLM - 1M! + "glm-cn": 1000000, + kimi: 1000000, // Kimi - 1M! + minimax: 1000000, // MiniMax - 1M! + "minimax-cn": 1000000, + deepseek: 64000, // DeepSeek - 64K + alicode: 128000, + + // Other API providers + groq: 8192, + xai: 131072, + mistral: 128000, + perplexity: 128000, + together: 128000, + fireworks: 128000, + cerebras: 128000, + cohere: 128000, + nvidia: 128000, + nebius: 128000, + siliconflow: 128000, + hyperbolic: 128000, +}; + +// Model-specific overrides (more specific limits for particular models) +export const MODEL_SPECIFIC_LIMITS = { + // Claude specific + "cc:claude-haiku-4-5-20251001": 200000, + "cc:claude-sonnet-4-5-20250929": 200000, + "cc:claude-opus-4-5-20251101": 200000, + "cc:claude-opus-4-6": 200000, + "cc:claude-sonnet-4-6": 200000, + + // OpenAI specific + "openai:o1": 200000, + "openai:o1-mini": 128000, + "openai:gpt-4-turbo": 128000, + "openai:gpt-4o": 128000, + "openai:gpt-4o-mini": 128000, + + // Gemini specific + "gemini:gemini-2.5-flash-lite": 100000, + + // Qwen specific (some have larger context) + "qw:qwen3-coder-plus": 32000, + "qw:qwen3-coder-flash": 32000, + + // iFlow specific + "if:kimi-k2": 128000, + "if:kimi-k2-thinking": 128000, + "if:kimi-k2.5": 128000, + "if:deepseek-r1": 64000, + "if:deepseek-v3.2-chat": 64000, + "if:minimax-m2.1": 1000000, + "if:minimax-m2.5": 1000000, + "if:glm-4.7": 1000000, + "if:glm-4.6": 1000000, + "if:glm-5": 1000000, + "if:qwen3-coder-plus": 32000, + + // GLM specific + "glm:glm-5": 1000000, + "glm:glm-4.7": 1000000, + "glm:glm-4.6": 1000000, + + // MiniMax specific + "minimax:MiniMax-M2.5": 1000000, + "minimax:MiniMax-M2.1": 1000000, + + // Kimi specific + "kimi:kimi-k2.5": 1000000, + "kimi:kimi-k2.5-thinking": 1000000, + "kimi:kimi-latest": 1000000, + + // DeepSeek specific + "deepseek:deepseek-chat": 64000, + "deepseek:deepseek-reasoner": 64000, + + // Kiro specific + "kr:claude-sonnet-4.5": 200000, + "kr:claude-haiku-4.5": 200000, + + // Cursor specific + "cu:claude-4.5-opus-high-thinking": 200000, + "cu:claude-4.5-opus-high": 200000, + "cu:claude-4.5-sonnet-thinking": 200000, + "cu:claude-4.5-sonnet": 200000, + "cu:claude-4.5-haiku": 200000, + "cu:claude-4.5-opus": 200000, + "cu:claude-4.6-opus-max": 200000, + "cu:claude-4.6-sonnet-medium-thinking": 200000, + "cu:kimi-k2.5": 1000000, + "cu:gemini-3-flash-preview": 200000, + "cu:gpt-5.2-codex": 200000, + "cu:gpt-5.2": 200000, + "cu:gpt-5.3-codex": 200000, + + // Kimi Coding specific + "kmc:kimi-k2.5": 1000000, + "kmc:kimi-k2.5-thinking": 1000000, + "kmc:kimi-latest": 1000000, + + // KiloCode specific + "kc:anthropic/claude-sonnet-4-20250514": 200000, + "kc:anthropic/claude-opus-4-20250514": 200000, + "kc:google/gemini-2.5-pro": 200000, + "kc:google/gemini-2.5-flash": 100000, + "kc:openai/gpt-4.1": 128000, + "kc:openai/o3": 200000, + "kc:deepseek/deepseek-chat": 64000, + "kc:deepseek/deepseek-reasoner": 64000, + + // Cline specific + "cl:anthropic/claude-sonnet-4-20250514": 200000, + "cl:anthropic/claude-opus-4-20250514": 200000, + "cl:google/gemini-2.5-pro": 200000, + "cl:google/gemini-2.5-flash": 100000, + "cl:openai/gpt-4.1": 128000, + "cl:openai/o3": 200000, + "cl:deepseek/deepseek-chat": 64000, + + // GitHub Copilot specific + "gh:gpt-3.5-turbo": 16385, + "gh:gpt-4": 8192, + "gh:gpt-4o": 128000, + "gh:gpt-4o-mini": 128000, + "gh:gpt-4.1": 128000, + "gh:gpt-5": 200000, + "gh:gpt-5-mini": 200000, + "gh:gpt-5-codex": 200000, + "gh:gpt-5.1": 200000, + "gh:gpt-5.1-codex": 200000, + "gh:gpt-5.1-codex-mini": 200000, + "gh:gpt-5.1-codex-max": 200000, + "gh:gpt-5.2": 200000, + "gh:gpt-5.2-codex": 200000, + "gh:gpt-5.3-codex": 200000, + "gh:claude-haiku-4.5": 200000, + "gh:claude-opus-4.1": 200000, + "gh:claude-opus-4.5": 200000, + "gh:claude-sonnet-4": 200000, + "gh:claude-sonnet-4.5": 200000, + "gh:claude-sonnet-4.6": 200000, + "gh:claude-opus-4.6": 200000, + "gh:gemini-2.5-pro": 200000, + "gh:gemini-3-flash-preview": 200000, + "gh:gemini-3-pro-preview": 200000, + "gh:grok-code-fast-1": 131072, + "gh:oswe-vscode-prime": 131072, + + // Codex specific + "cx:gpt-5.3-codex": 200000, + "cx:gpt-5.3-codex-xhigh": 200000, + "cx:gpt-5.3-codex-high": 200000, + "cx:gpt-5.3-codex-low": 200000, + "cx:gpt-5.3-codex-none": 200000, + "cx:gpt-5.3-codex-spark": 200000, + "cx:gpt-5.1-codex-mini": 200000, + "cx:gpt-5.1-codex-mini-high": 200000, + "cx:gpt-5.2-codex": 200000, + "cx:gpt-5.2": 200000, + "cx:gpt-5.1-codex-max": 200000, + "cx:gpt-5.1-codex": 200000, + "cx:gpt-5.1": 200000, + "cx:gpt-5-codex": 200000, + "cx:gpt-5-codex-mini": 200000, + + // Gemini CLI specific + "gc:gemini-3-flash-preview": 200000, + "gc:gemini-3-pro-preview": 200000, + + // Antigravity specific + "ag:gemini-3.1-pro-high": 200000, + "ag:gemini-3.1-pro-low": 200000, + "ag:gemini-3-flash": 200000, + "ag:claude-sonnet-4-6": 200000, + "ag:claude-opus-4-6-thinking": 200000, + "ag:gpt-oss-120b-medium": 128000, +}; + +/** + * Get max input tokens for a model + * @param {string} provider - Provider alias (e.g., "cc", "openai", "if") + * @param {string} model - Model ID + * @param {object} customLimits - Optional custom limits from user settings + * @returns {number} Max input tokens + */ +export function getMaxInputTokens(provider, model, customLimits = null) { + // Check custom limits first (user overrides) + if (customLimits) { + const key = `${provider}:${model}`; + if (customLimits[key] !== undefined) { + return customLimits[key]; + } + if (customLimits[provider] !== undefined) { + return customLimits[provider]; + } + } + + // Check model-specific limit + const modelKey = `${provider}:${model}`; + if (MODEL_SPECIFIC_LIMITS[modelKey] !== undefined) { + return MODEL_SPECIFIC_LIMITS[modelKey]; + } + + // Fall back to provider default + return PROVIDER_DEFAULT_LIMITS[provider] || 100000; // Default 100K if unknown +} diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 83e10fe5..c8a198ea 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -6,6 +6,9 @@ import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; +import { getMaxInputTokens } from "../config/modelLimits.js"; +import { estimateInputTokens } from "../utils/tokenEstimator.js"; +import { getSettings } from "@/lib/localDb"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS } from "../config/constants.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; @@ -67,6 +70,31 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log, provider, model }); + // Validate input token count against model limits + try { + let customLimits = null; + try { + const settings = await getSettings(); + customLimits = settings?.modelLimits || null; + } catch (settingsErr) { + // getSettings may not be available (e.g., cloud workers) - use defaults only + } + + const maxInputTokens = getMaxInputTokens(alias, model, customLimits); + const estimatedTokens = estimateInputTokens(translatedBody); + + if (estimatedTokens > maxInputTokens) { + const errorMsg = `Input too long: ~${estimatedTokens} tokens (max: ${maxInputTokens}). Please reduce your input or truncate the conversation.`; + log?.warn?.("TOKEN_LIMIT", `${alias}/${model}: ${estimatedTokens} > ${maxInputTokens}`); + trackPendingRequest(model, provider, connectionId, false, true); + appendRequestLog({ model, provider, connectionId, status: "FAILED 400" }).catch(() => {}); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, errorMsg); + } + } catch (err) { + // Don't fail the request if limit check fails - just log + log?.warn?.("TOKEN_LIMIT", `Check failed: ${err.message}`); + } + // Execute request let providerResponse, providerUrl, providerHeaders, finalBody; try { diff --git a/open-sse/utils/tokenEstimator.js b/open-sse/utils/tokenEstimator.js new file mode 100644 index 00000000..eaa053fd --- /dev/null +++ b/open-sse/utils/tokenEstimator.js @@ -0,0 +1,124 @@ +/** + * Token estimation utilities + * Estimates token count from request bodies without making API calls + */ + +// Rough estimate: ~4 characters per token (common approximation) +const CHARS_PER_TOKEN = 4; + +/** + * Estimate token count from a message content + * @param {string|object|array} content - Message content + * @returns {number} Estimated token count + */ +function estimateContentTokens(content) { + if (!content) return 0; + + if (typeof content === "string") { + return Math.ceil(content.length / CHARS_PER_TOKEN); + } + + if (Array.isArray(content)) { + return content.reduce((sum, part) => sum + estimateContentTokens(part), 0); + } + + if (typeof content === "object") { + // Handle different content block types + if (content.type === "text" && content.text) { + return Math.ceil(content.text.length / CHARS_PER_TOKEN); + } + + if (content.type === "image_url" || content.type === "image") { + // Images are expensive - estimate based on detail level + // Low detail: ~85 tokens + // High detail: ~1000+ tokens (very rough) + const detail = content.detail || content.image_url?.detail || "high"; + return detail === "low" ? 85 : 1000; + } + + if (content.type === "tool_use" || content.type === "tool_result") { + // Tool calls have overhead + return 50 + estimateContentTokens(content.input || content.content); + } + + // Generic object - serialize and estimate + return Math.ceil(JSON.stringify(content).length / CHARS_PER_TOKEN); + } + + return 0; +} + +/** + * Estimate tokens from a single message + * @param {object} message - Message object with role and content + * @returns {number} Estimated token count + */ +function estimateMessageTokens(message) { + if (!message) return 0; + + // Each message has overhead (~4 tokens for role formatting) + const roleOverhead = 4; + + const contentTokens = estimateContentTokens(message.content); + + // Handle tool messages specially + if (message.tool_calls && Array.isArray(message.tool_calls)) { + const toolCallTokens = message.tool_calls.reduce((sum, tc) => { + return sum + 20 + Math.ceil(JSON.stringify(tc.function || tc).length / CHARS_PER_TOKEN); + }, 0); + return roleOverhead + contentTokens + toolCallTokens; + } + + return roleOverhead + contentTokens; +} + +/** + * Estimate total input tokens from a request body + * @param {object} body - Request body (OpenAI format) + * @returns {number} Estimated token count + */ +export function estimateInputTokens(body) { + if (!body) return 0; + + let total = 0; + + // Handle messages array (standard OpenAI format) + if (body.messages && Array.isArray(body.messages)) { + total = body.messages.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0); + } + + // Handle input array (Claude format, some other providers) + if (body.input && Array.isArray(body.input)) { + total = body.input.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0); + } + + // Handle contents array (Google Gemini format) + if (body.contents && Array.isArray(body.contents)) { + total = body.contents.reduce((sum, msg) => sum + estimateContentTokens(msg), 0); + } + + // Add overhead for tools (if present) + if (body.tools && Array.isArray(body.tools)) { + total += body.tools.reduce((sum, tool) => { + return sum + Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN); + }, 0); + } + + // Add overhead for system prompt + if (body.system && typeof body.system === "string") { + total += Math.ceil(body.system.length / CHARS_PER_TOKEN); + } + + return total; +} + +/** + * Estimate tokens from a message array only (simpler version) + * @param {array} messages - Array of messages + * @returns {number} Estimated token count + */ +export function estimateMessageArrayTokens(messages) { + if (!messages || !Array.isArray(messages)) return 0; + + return messages.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0); +} diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 60e991d8..3cdfb9ea 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -24,8 +24,47 @@ export default function ProfilePage() { const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); + const [modelLimits, setModelLimits] = useState({}); + const [modelLimitsLoading, setModelLimitsLoading] = useState(false); + const [modelLimitsStatus, setModelLimitsStatus] = useState({ type: "", message: "" }); + + // Provider defaults for model limits UI + const modelLimitProviders = [ + { id: "cc", name: "Claude Code", default: 200000 }, + { id: "openai", name: "OpenAI", default: 128000 }, + { id: "cx", name: "Codex", default: 200000 }, + { id: "gc", name: "Gemini CLI", default: 200000 }, + { id: "gemini", name: "Gemini API", default: 200000 }, + { id: "qw", name: "Qwen", default: 32000 }, + { id: "if", name: "iFlow", default: 128000 }, + { id: "kr", name: "Kiro", default: 200000 }, + { id: "glm", name: "GLM", default: 1000000 }, + { id: "kimi", name: "Kimi", default: 1000000 }, + { id: "minimax", name: "MiniMax", default: 1000000 }, + { id: "deepseek", name: "DeepSeek", default: 64000 }, + ]; useEffect(() => { + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setProxyForm({ + outboundProxyEnabled: data?.outboundProxyEnabled === true, + outboundProxyUrl: data?.outboundProxyUrl || "", + outboundNoProxy: data?.outboundNoProxy || "", + }); + // Load model limits + if (data?.modelLimits) { + setModelLimits(data.modelLimits); + } + setLoading(false); + }) + .catch((err) => { + console.error("Failed to fetch settings:", err); + setLoading(false); + }); + }, []); fetch("/api/settings") .then((res) => res.json()) .then((data) => { @@ -257,6 +296,68 @@ export default function ProfilePage() { }; const reloadSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (!res.ok) return; + const data = await res.json(); + setSettings(data); + // Also load model limits + if (data?.modelLimits) { + setModelLimits(data.modelLimits); + } + } catch (err) { + console.error("Failed to reload settings:", err); + } + }; + + const updateModelLimit = (provider, value) => { + if (value === "" || value === null) { + const newLimits = { ...modelLimits }; + delete newLimits[provider]; + setModelLimits(newLimits); + } else { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + setModelLimits({ ...modelLimits, [provider]: numValue }); + } + } + }; + + const saveModelLimits = async () => { + setModelLimitsLoading(true); + setModelLimitsStatus({ type: "", message: "" }); + + try { + // Filter out empty values + const cleanLimits = {}; + for (const [key, value] of Object.entries(modelLimits)) { + if (value !== "" && value !== null && value !== undefined) { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + cleanLimits[key] = numValue; + } + } + } + + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelLimits: Object.keys(cleanLimits).length > 0 ? cleanLimits : null }), + }); + + if (res.ok) { + setModelLimitsStatus({ type: "success", message: "Limits saved" }); + await reloadSettings(); + } else { + const data = await res.json(); + setModelLimitsStatus({ type: "error", message: data.error || "Failed to save" }); + } + } catch (err) { + setModelLimitsStatus({ type: "error", message: "An error occurred" }); + } finally { + setModelLimitsLoading(false); + } + }; try { const res = await fetch("/api/settings"); if (!res.ok) return; @@ -722,6 +823,60 @@ export default function ProfilePage() { + {/* Model Input Limits */} + +
+
+ text_fields +
+

Model Input Limits

+
+
+

+ Set custom input token limits per provider. Requests exceeding these limits will be rejected with a 400 error. + Leave empty to use default limits. +

+ + {modelLimitProviders.map((p) => ( +
+
+ {p.name} +
+ updateModelLimit(p.id, e.target.value)} + disabled={loading} + className="flex-1" + /> + + default: {p.default.toLocaleString()} + +
+ ))} + +
+ + {modelLimitsStatus.message && ( + + {modelLimitsStatus.message} + + )} +
+
+
+ + {/* App Info */} + + + {/* App Info */}

{APP_CONFIG.name} v{APP_CONFIG.version}

diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 19ccf8e3..c047fb9b 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -60,7 +60,10 @@ const defaultData = { observabilityMaxJsonSize: 1024, outboundProxyEnabled: false, outboundProxyUrl: "", - outboundNoProxy: "" + outboundNoProxy: "", + // Model input token limits (user overrides) + // Format: { "provider:model": limit } or { "provider": limit } + modelLimits: null }, pricing: {} // NEW: pricing configuration }; From cbd799fe5a642d17747aeb3e4b76e10164146662 Mon Sep 17 00:00:00 2001 From: CloudWaddie Date: Thu, 5 Mar 2026 13:49:54 +1030 Subject: [PATCH 3/5] fix: Remove duplicate useEffect block causing syntax error --- src/app/(dashboard)/dashboard/profile/page.js | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 3cdfb9ea..b1048eee 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -65,22 +65,7 @@ export default function ProfilePage() { setLoading(false); }); }, []); - fetch("/api/settings") - .then((res) => res.json()) - .then((data) => { - setSettings(data); - setProxyForm({ - outboundProxyEnabled: data?.outboundProxyEnabled === true, - outboundProxyUrl: data?.outboundProxyUrl || "", - outboundNoProxy: data?.outboundNoProxy || "", - }); - setLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch settings:", err); - setLoading(false); - }); - }, []); + const updateOutboundProxy = async (e) => { e.preventDefault(); From 5d183ba6de3190a381a7433907a7b6542664a0b3 Mon Sep 17 00:00:00 2001 From: CloudWaddie Date: Thu, 5 Mar 2026 13:50:53 +1030 Subject: [PATCH 4/5] fix: Remove duplicate reloadSettings function --- src/app/(dashboard)/dashboard/profile/page.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index b1048eee..13849ebf 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -343,15 +343,7 @@ export default function ProfilePage() { setModelLimitsLoading(false); } }; - try { - const res = await fetch("/api/settings"); - if (!res.ok) return; - const data = await res.json(); - setSettings(data); - } catch (err) { - console.error("Failed to reload settings:", err); - } - }; + const handleExportDatabase = async () => { setDbLoading(true); From 72a0632af826e2ebb1cd30a7ce13caf7a5a9dffa Mon Sep 17 00:00:00 2001 From: CloudWaddie Date: Thu, 5 Mar 2026 14:12:02 +1030 Subject: [PATCH 5/5] fix: Remove duplicate App Info section causing JSX error --- src/app/(dashboard)/dashboard/profile/page.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 13849ebf..b0e6c161 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -850,9 +850,6 @@ export default function ProfilePage() {
- {/* App Info */} - - {/* App Info */}