fix: pony-alpha model status and SSE stream heartbeat issue
- Change openrouter/pony-alpha model status from 'alpha' to 'beta' to prevent deletion - Fix ReferenceError where heartbeat was used before initialization in cleanupStream - Declare heartbeat and streamTimeout with let before cleanupStream function - Change const assignments to let assignments for timer variables
This commit is contained in:
271
chat/server.js
271
chat/server.js
@@ -15,6 +15,7 @@ const jwt = require('jsonwebtoken');
|
||||
const nodemailer = require('nodemailer');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const security = require('./security');
|
||||
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
|
||||
|
||||
let sharp = null;
|
||||
try {
|
||||
@@ -165,6 +166,7 @@ const CHUTES_API_URL = process.env.CHUTES_API_URL || 'https://api.chutes.ai/v1';
|
||||
const PROVIDER_LIMITS_FILE = path.join(STATE_DIR, 'provider-limits.json');
|
||||
const PROVIDER_USAGE_FILE = path.join(STATE_DIR, 'provider-usage.json');
|
||||
const TOKEN_USAGE_FILE = path.join(STATE_DIR, 'token-usage.json');
|
||||
const EXTERNAL_TEST_USAGE_FILE = path.join(STATE_DIR, 'external-test-usage.json');
|
||||
const TOPUP_SESSIONS_FILE = path.join(STATE_DIR, 'topup-sessions.json');
|
||||
const TOPUP_PENDING_FILE = path.join(STATE_DIR, 'topup-pending.json');
|
||||
const PAYG_SESSIONS_FILE = path.join(STATE_DIR, 'payg-sessions.json');
|
||||
@@ -427,6 +429,12 @@ const PLAN_TOKEN_LIMITS = {
|
||||
professional: 5_000_000,
|
||||
enterprise: 20_000_000,
|
||||
};
|
||||
const EXTERNAL_TEST_LIMITS = {
|
||||
hobby: 3,
|
||||
starter: 50,
|
||||
professional: Infinity,
|
||||
enterprise: Infinity,
|
||||
};
|
||||
|
||||
// Default token rates (price per 1M tokens in minor units/cents)
|
||||
const DEFAULT_TOKEN_RATES = {
|
||||
@@ -1544,6 +1552,9 @@ let providerUsage = {};
|
||||
let tokenUsage = {};
|
||||
let processedTopups = {};
|
||||
let pendingTopups = {};
|
||||
let externalTestUsage = {};
|
||||
|
||||
const externalWpTester = createExternalWpTester({ logger: log });
|
||||
let processedPayg = {};
|
||||
let pendingPayg = {};
|
||||
let processedSubscriptions = {};
|
||||
@@ -4967,6 +4978,7 @@ async function serializeAccount(user) {
|
||||
uploadZipBytes: MAX_UPLOAD_ZIP_SIZE,
|
||||
},
|
||||
tokenUsage: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN),
|
||||
externalTestingUsage: getExternalTestUsageSummary(user.id, user.plan || DEFAULT_PLAN),
|
||||
paymentMethod,
|
||||
};
|
||||
}
|
||||
@@ -5919,6 +5931,46 @@ function ensureTokenUsageBucket(userId) {
|
||||
return tokenUsage[key];
|
||||
}
|
||||
|
||||
function ensureExternalTestUsageBucket(userId) {
|
||||
const key = String(userId || '');
|
||||
if (!key) return null;
|
||||
const month = currentMonthKey();
|
||||
if (!externalTestUsage[key] || externalTestUsage[key].month !== month) {
|
||||
externalTestUsage[key] = { month, count: 0 };
|
||||
} else {
|
||||
const entry = externalTestUsage[key];
|
||||
entry.count = typeof entry.count === 'number' ? entry.count : 0;
|
||||
}
|
||||
return externalTestUsage[key];
|
||||
}
|
||||
|
||||
function getExternalTestUsageSummary(userId, plan) {
|
||||
const bucket = ensureExternalTestUsageBucket(userId) || { count: 0, month: currentMonthKey() };
|
||||
const normalizedPlan = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||||
const limit = EXTERNAL_TEST_LIMITS[normalizedPlan] ?? EXTERNAL_TEST_LIMITS[DEFAULT_PLAN];
|
||||
const used = Math.max(0, Number(bucket.count || 0));
|
||||
const remaining = Number.isFinite(limit) ? Math.max(0, limit - used) : 0;
|
||||
const percent = Number.isFinite(limit) && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
return {
|
||||
month: bucket.month,
|
||||
plan: normalizedPlan,
|
||||
used,
|
||||
limit,
|
||||
remaining,
|
||||
percent,
|
||||
};
|
||||
}
|
||||
|
||||
function canUseExternalTesting(userId, plan, unlimited = false) {
|
||||
if (unlimited) return { allowed: true, summary: getExternalTestUsageSummary(userId, plan) };
|
||||
const summary = getExternalTestUsageSummary(userId, plan);
|
||||
if (!Number.isFinite(summary.limit)) return { allowed: true, summary };
|
||||
if (summary.used >= summary.limit) {
|
||||
return { allowed: false, summary };
|
||||
}
|
||||
return { allowed: true, summary };
|
||||
}
|
||||
|
||||
function normalizeTier(tier) {
|
||||
const normalized = (tier || '').toLowerCase();
|
||||
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
||||
@@ -5943,6 +5995,20 @@ async function loadTokenUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalTestUsage() {
|
||||
try {
|
||||
await ensureStateFile();
|
||||
const raw = await fs.readFile(EXTERNAL_TEST_USAGE_FILE, 'utf8').catch(() => null);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') externalTestUsage = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
log('Failed to load external test usage, starting empty', { error: String(error) });
|
||||
externalTestUsage = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function persistTokenUsage() {
|
||||
await ensureStateFile();
|
||||
const payload = JSON.stringify(tokenUsage, null, 2);
|
||||
@@ -5953,6 +6019,16 @@ async function persistTokenUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistExternalTestUsage() {
|
||||
await ensureStateFile();
|
||||
const payload = JSON.stringify(externalTestUsage, null, 2);
|
||||
try {
|
||||
await safeWriteFile(EXTERNAL_TEST_USAGE_FILE, payload);
|
||||
} catch (err) {
|
||||
log('Failed to persist external test usage', { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopupSessions() {
|
||||
try {
|
||||
await ensureStateFile();
|
||||
@@ -6579,6 +6655,14 @@ function getTokenUsageSummary(userId, plan) {
|
||||
};
|
||||
}
|
||||
|
||||
async function recordExternalTestUsage(userId) {
|
||||
const bucket = ensureExternalTestUsageBucket(userId);
|
||||
if (!bucket) return null;
|
||||
bucket.count += 1;
|
||||
await persistExternalTestUsage();
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function resolveUserCurrency(user) {
|
||||
const currency = String(user?.subscriptionCurrency || user?.billingCurrency || '').toLowerCase();
|
||||
return SUPPORTED_CURRENCIES.includes(currency) ? currency : 'usd';
|
||||
@@ -7353,6 +7437,45 @@ function decodeBase64Payload(raw) {
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
async function loadExternalTestingSpec(workspaceDir) {
|
||||
if (!workspaceDir) return null;
|
||||
const specPath = path.join(workspaceDir, 'external-wp-tests.json');
|
||||
try {
|
||||
const raw = await fs.readFile(specPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePluginRoot(workspaceDir) {
|
||||
if (!workspaceDir) return null;
|
||||
const validFiles = [];
|
||||
await collectValidFiles(workspaceDir, workspaceDir, validFiles, [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.data',
|
||||
'uploads',
|
||||
'*.zip',
|
||||
'*.log'
|
||||
]);
|
||||
|
||||
for (const fileInfo of validFiles) {
|
||||
if (!fileInfo.fullPath.endsWith('.php')) continue;
|
||||
try {
|
||||
const content = await fs.readFile(fileInfo.fullPath, 'utf8');
|
||||
if (content.includes('Plugin Name:') && content.includes('Plugin URI:')) {
|
||||
return path.dirname(fileInfo.fullPath);
|
||||
}
|
||||
} catch (_) {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
// Basic ZIP signature check: PK\x03\x04 (local header) or PK\x05\x06 (empty archives)
|
||||
function isLikelyZip(buffer) {
|
||||
if (!buffer || buffer.length < 4) return false;
|
||||
@@ -10015,6 +10138,53 @@ async function queueMessage(sessionId, message) {
|
||||
sessionQueues.set(sessionId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatExternalTestingSummary(result) {
|
||||
if (!result) return '';
|
||||
if (result.skipped) {
|
||||
const limit = Number.isFinite(result.summary?.limit) ? result.summary.limit : 'unlimited';
|
||||
return `\n\n---\nExternal WP CLI Testing\nStatus: Skipped\nReason: ${result.reason || 'Not available'}\nUsage: ${result.summary?.used || 0} / ${limit}`;
|
||||
}
|
||||
if (!result.ok) {
|
||||
const errorText = Array.isArray(result.errors) && result.errors.length
|
||||
? result.errors.join(' | ')
|
||||
: 'Unknown error';
|
||||
return `\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${errorText}`;
|
||||
}
|
||||
const cli = result.test_results?.cli_tests || { passed: 0, failed: 0 };
|
||||
const usage = result.usageSummary;
|
||||
const limit = Number.isFinite(usage?.limit) ? usage.limit : 'unlimited';
|
||||
return `\n\n---\nExternal WP CLI Testing\nStatus: ${cli.failed === 0 ? 'Passed' : 'Failed'}\nCLI Tests: ${cli.passed} passed, ${cli.failed} failed\nSubsite: ${result.subsite_url || 'n/a'}\nUsage: ${usage?.used || 0} / ${limit}`;
|
||||
}
|
||||
|
||||
async function runExternalTestingForSession(session, message, plan) {
|
||||
if (!message?.externalTestingEnabled) return null;
|
||||
if (!session?.workspaceDir) return { ok: false, errors: ['Workspace directory not available'] };
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
const limitCheck = canUseExternalTesting(session.userId, plan, user?.unlimitedUsage === true);
|
||||
if (!limitCheck.allowed) {
|
||||
return { skipped: true, reason: 'External testing limit reached. Upgrade to continue.', summary: limitCheck.summary };
|
||||
}
|
||||
|
||||
const pluginRoot = await resolvePluginRoot(session.workspaceDir);
|
||||
const spec = await loadExternalTestingSpec(session.workspaceDir);
|
||||
const testInput = {
|
||||
plugin_path: pluginRoot,
|
||||
plugin_slug: session.pluginSlug,
|
||||
test_mode: 'cli',
|
||||
required_plugins: spec?.required_plugins || [],
|
||||
test_scenarios: spec?.test_scenarios || [],
|
||||
};
|
||||
|
||||
await recordExternalTestUsage(session.userId);
|
||||
trackFeatureUsage('external_wp_testing', session.userId, plan);
|
||||
|
||||
const result = await externalWpTester.runTest(testInput, { configOverrides: {} });
|
||||
result.usageSummary = getExternalTestUsageSummary(session.userId, plan);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function processMessage(sessionId, message) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) return;
|
||||
@@ -10117,7 +10287,7 @@ async function processMessage(sessionId, message) {
|
||||
log('opencode session ensured (or pending)', { sessionId, opencodeSessionId, model: message.model, workspaceDir: session.workspaceDir });
|
||||
|
||||
const opencodeResult = await sendToOpencodeWithFallback({ session, model: message.model, content: message.content, message, cli: activeCli, opencodeSessionId, plan: sessionPlan });
|
||||
const reply = opencodeResult.reply;
|
||||
let reply = opencodeResult.reply;
|
||||
if (opencodeResult.model) {
|
||||
message.model = opencodeResult.model;
|
||||
}
|
||||
@@ -10252,6 +10422,17 @@ async function processMessage(sessionId, message) {
|
||||
message.potentiallyIncomplete = true;
|
||||
}
|
||||
|
||||
if (message.isProceedWithBuild && message.externalTestingEnabled) {
|
||||
try {
|
||||
const testingResult = await runExternalTestingForSession(session, message, sessionPlan);
|
||||
message.externalTesting = testingResult;
|
||||
reply = `${reply || ''}${formatExternalTestingSummary(testingResult)}`.trim();
|
||||
} catch (testErr) {
|
||||
message.externalTesting = { ok: false, errors: [testErr.message || String(testErr)] };
|
||||
reply = `${reply || ''}\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${testErr.message || String(testErr)}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
message.status = 'done';
|
||||
message.reply = reply;
|
||||
message.finishedAt = new Date().toISOString();
|
||||
@@ -11031,10 +11212,11 @@ async function handleAccountUsage(req, res) {
|
||||
const user = findUserById(resolvedUserId);
|
||||
const plan = user?.plan || DEFAULT_PLAN;
|
||||
const summary = getTokenUsageSummary(resolvedUserId, plan);
|
||||
const externalTesting = getExternalTestUsageSummary(resolvedUserId, plan);
|
||||
const payg = PAYG_ENABLED && isPaidPlan(plan) && !user?.unlimitedUsage
|
||||
? computePaygSummary(resolvedUserId, plan)
|
||||
: null;
|
||||
return sendJson(res, 200, { ok: true, summary, payg, legacy: !authed });
|
||||
return sendJson(res, 200, { ok: true, summary: { ...summary, externalTesting }, payg, legacy: !authed });
|
||||
}
|
||||
|
||||
async function handleAccountPlans(_req, res) {
|
||||
@@ -14819,6 +15001,72 @@ async function handleAdminResources(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminExternalTestingStatus(req, res) {
|
||||
const adminSession = requireAdminAuth(req, res);
|
||||
if (!adminSession) return;
|
||||
const config = getExternalTestingConfig();
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
config: {
|
||||
wpHost: config.wpHost || '',
|
||||
wpPath: config.wpPath || '',
|
||||
wpBaseUrl: config.wpBaseUrl || '',
|
||||
enableMultisite: !!config.enableMultisite,
|
||||
subsiteMode: config.subsiteMode,
|
||||
subsiteDomain: config.subsiteDomain || '',
|
||||
maxConcurrentTests: config.maxConcurrentTests,
|
||||
autoCleanup: !!config.autoCleanup,
|
||||
cleanupDelayMs: config.cleanupDelayMs,
|
||||
sshKeyConfigured: Boolean(config.wpSshKey),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAdminExternalTestingSelfTest(req, res) {
|
||||
const adminSession = requireAdminAuth(req, res);
|
||||
if (!adminSession) return;
|
||||
|
||||
let tempDir;
|
||||
try {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pc-external-test-'));
|
||||
const pluginSlug = 'pc-external-test';
|
||||
const pluginDir = path.join(tempDir, pluginSlug);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
const pluginFile = path.join(pluginDir, `${pluginSlug}.php`);
|
||||
const pluginContents = `<?php\n/**\n * Plugin Name: External Testing Self Check\n * Plugin URI: https://plugincompass.com\n * Author: Plugin Compass\n * Version: 0.1.0\n */\n\nfunction pc_external_test_activate() {\n update_option('pc_external_test_flag', 'ok');\n}\nregister_activation_hook(__FILE__, 'pc_external_test_activate');\n`;
|
||||
await fs.writeFile(pluginFile, pluginContents, 'utf8');
|
||||
|
||||
const testInput = {
|
||||
plugin_path: pluginDir,
|
||||
plugin_slug: pluginSlug,
|
||||
test_mode: 'cli',
|
||||
test_scenarios: [
|
||||
{
|
||||
name: 'Activate self-test plugin',
|
||||
type: 'custom',
|
||||
wp_cli_command: `plugin activate ${pluginSlug}`,
|
||||
assertions: { wp_cli_success: true, not_contains: ['Fatal error', 'Parse error'] }
|
||||
},
|
||||
{
|
||||
name: 'Activation hook wrote option',
|
||||
type: 'custom',
|
||||
wp_cli_command: 'eval "echo get_option(\'pc_external_test_flag\') ? get_option(\'pc_external_test_flag\') : \"missing\";"',
|
||||
assertions: { contains: ['ok'] }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = await externalWpTester.runTest(testInput, { configOverrides: {} });
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { ok: false, error: error.message || 'Self-test failed' });
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminOpenRouterSettingsPost(req, res) {
|
||||
const session = requireAdminAuth(req, res);
|
||||
if (!session) return;
|
||||
@@ -14975,6 +15223,10 @@ async function handleNewMessage(req, res, sessionId, userId) {
|
||||
const cli = normalizeCli(body.cli || session.cli);
|
||||
const now = new Date().toISOString();
|
||||
const message = { id: randomUUID(), role: 'user', content, displayContent, model, cli, status: 'queued', createdAt: now, updatedAt: now, opencodeTokensUsed: null };
|
||||
if (body.isProceedWithBuild) message.isProceedWithBuild = true;
|
||||
if (body.externalTestingEnabled !== undefined) {
|
||||
message.externalTestingEnabled = body.externalTestingEnabled === true;
|
||||
}
|
||||
// Copy continuation-related fields for background continuations
|
||||
if (body.isContinuation) message.isContinuation = true;
|
||||
if (body.isBackgroundContinuation) message.isBackgroundContinuation = true;
|
||||
@@ -15086,6 +15338,10 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) {
|
||||
|
||||
log('SSE stream opened', { sessionId, messageId, activeStreams: activeStreams.size });
|
||||
|
||||
// Declare timers before cleanupStream to avoid hoisting issues
|
||||
let heartbeat = null;
|
||||
let streamTimeout = null;
|
||||
|
||||
// Helper to cleanup this specific stream
|
||||
const cleanupStream = () => {
|
||||
clearInterval(heartbeat);
|
||||
@@ -15147,7 +15403,7 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) {
|
||||
|
||||
// Stream timeout - close streams that have been open too long (30 minutes max)
|
||||
const STREAM_MAX_DURATION_MS = 30 * 60 * 1000;
|
||||
const streamTimeout = setTimeout(() => {
|
||||
streamTimeout = setTimeout(() => {
|
||||
try {
|
||||
const timeoutData = JSON.stringify({
|
||||
type: 'timeout',
|
||||
@@ -15164,7 +15420,7 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) {
|
||||
// Keep connection alive with heartbeat/pings.
|
||||
// Send a small data event periodically so proxies/load balancers don't treat the stream as idle.
|
||||
let heartbeatCount = 0;
|
||||
const heartbeat = setInterval(() => {
|
||||
heartbeat = setInterval(() => {
|
||||
try {
|
||||
heartbeatCount++;
|
||||
|
||||
@@ -16603,6 +16859,8 @@ async function routeInternal(req, res, url, pathname) {
|
||||
if (req.method === 'PUT' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalUpdate(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/tracking') return handleAdminTrackingStats(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/resources') return handleAdminResources(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/external-testing-status') return handleAdminExternalTestingStatus(req, res);
|
||||
if (req.method === 'POST' && pathname === '/api/admin/external-testing-self-test') return handleAdminExternalTestingSelfTest(req, res);
|
||||
if (req.method === 'POST' && pathname === '/api/upgrade-popup-tracking') return handleUpgradePopupTracking(req, res);
|
||||
if (req.method === 'POST' && pathname === '/api/admin/cancel-messages') return handleAdminCancelMessages(req, res);
|
||||
const adminDeleteMatch = pathname.match(/^\/api\/admin\/models\/([a-f0-9\-]+)$/i);
|
||||
@@ -16797,6 +17055,11 @@ async function routeInternal(req, res, url, pathname) {
|
||||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||||
return serveFile(res, safeStaticPath('admin-resources.html'), 'text/html');
|
||||
}
|
||||
if (pathname === '/admin/external-testing') {
|
||||
const session = getAdminSession(req);
|
||||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||||
return serveFile(res, safeStaticPath('admin-external-testing.html'), 'text/html');
|
||||
}
|
||||
if (pathname === '/admin/contact-messages') {
|
||||
const session = getAdminSession(req);
|
||||
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
|
||||
|
||||
Reference in New Issue
Block a user