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..b0e6c161 100644
--- a/src/app/(dashboard)/dashboard/profile/page.js
+++ b/src/app/(dashboard)/dashboard/profile/page.js
@@ -24,6 +24,25 @@ 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")
@@ -35,6 +54,10 @@ export default function ProfilePage() {
outboundProxyUrl: data?.outboundProxyUrl || "",
outboundNoProxy: data?.outboundNoProxy || "",
});
+ // Load model limits
+ if (data?.modelLimits) {
+ setModelLimits(data.modelLimits);
+ }
setLoading(false);
})
.catch((err) => {
@@ -43,6 +66,7 @@ export default function ProfilePage() {
});
}, []);
+
const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
@@ -262,11 +286,65 @@ export default function ProfilePage() {
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);
+ }
+ };
+
+
const handleExportDatabase = async () => {
setDbLoading(true);
setDbStatus({ type: "", message: "" });
@@ -722,6 +800,57 @@ export default function ProfilePage() {
+ {/* 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.
+ Model Input Limits
+
{APP_CONFIG.name} v{APP_CONFIG.version}
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/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 }; 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), + }; +}