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:
southseact-3d
2026-02-08 19:20:23 +00:00
parent eb7aa29c0c
commit 541b6bc946
2 changed files with 268 additions and 5 deletions

View File

@@ -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');