wolverine-ai 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/wolverine.js +4 -0
- package/package.json +1 -2
- package/src/agent/agent-engine.js +58 -4
- package/src/brain/brain.js +9 -2
- package/src/core/ai-client.js +4 -3
- package/src/core/error-parser.js +2 -2
- package/src/core/init-server.js +58 -0
- package/src/core/runner.js +52 -3
- package/server/routes/inference.js +0 -324
- /package/{server → src/templates/server}/config/settings.json +0 -0
- /package/{server → src/templates/server}/index.js +0 -0
- /package/{server → src/templates/server}/routes/api.js +0 -0
- /package/{server → src/templates/server}/routes/health.js +0 -0
- /package/{server → src/templates/server}/routes/time.js +0 -0
package/bin/wolverine.js
CHANGED
|
@@ -127,6 +127,10 @@ if (args.includes("--backups")) {
|
|
|
127
127
|
|
|
128
128
|
const scriptPath = args.find(a => !a.startsWith("--")) || "server/index.js";
|
|
129
129
|
|
|
130
|
+
// Initialize server/ from template if it doesn't exist (first run)
|
|
131
|
+
const { initServer } = require("../src/core/init-server");
|
|
132
|
+
initServer(process.cwd(), scriptPath);
|
|
133
|
+
|
|
130
134
|
// System detection (for analytics + dashboard, NOT for forking)
|
|
131
135
|
// Wolverine runs as a single process manager. If users want clustering,
|
|
132
136
|
// they handle it inside their server (e.g. @fastify/cluster, pm2 cluster mode).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
"files": [
|
|
48
48
|
"bin/",
|
|
49
49
|
"src/",
|
|
50
|
-
"server/",
|
|
51
50
|
"examples/",
|
|
52
51
|
".env.example"
|
|
53
52
|
],
|
|
@@ -250,7 +250,7 @@ const TOOL_DEFINITIONS = [
|
|
|
250
250
|
type: "function",
|
|
251
251
|
function: {
|
|
252
252
|
name: "run_db_fix",
|
|
253
|
-
description: "Run a write query on a SQLite database to fix data issues:
|
|
253
|
+
description: "Run a write query on a SQLite database to fix data issues. IMPORTANT: Always use inspect_db FIRST to see the current state before writing. This tool auto-snapshots affected rows before and after the write. Creates a backup. Returns before/after state so you can verify the fix is correct.",
|
|
254
254
|
parameters: {
|
|
255
255
|
type: "object",
|
|
256
256
|
properties: {
|
|
@@ -953,15 +953,60 @@ class AgentEngine {
|
|
|
953
953
|
if (upper.startsWith("DROP DATABASE") || upper.includes("DROP TABLE sqlite_")) {
|
|
954
954
|
return { content: "BLOCKED: Cannot drop system tables" };
|
|
955
955
|
}
|
|
956
|
+
|
|
956
957
|
// Backup the DB file first
|
|
957
958
|
const backupPath = dbPath + ".wolverine-backup";
|
|
958
959
|
fs.copyFileSync(dbPath, backupPath);
|
|
960
|
+
|
|
959
961
|
const db = new Database(dbPath);
|
|
962
|
+
|
|
963
|
+
// SAFETY: Snapshot affected rows BEFORE the write
|
|
964
|
+
// Extract table name and WHERE clause to SELECT the rows that will change
|
|
965
|
+
let beforeSnapshot = "";
|
|
966
|
+
try {
|
|
967
|
+
const tableMatch = upper.match(/(?:UPDATE|DELETE\s+FROM|INSERT\s+INTO)\s+(\w+)/i);
|
|
968
|
+
const whereMatch = args.sql.match(/WHERE\s+(.+?)(?:;|$)/i);
|
|
969
|
+
if (tableMatch) {
|
|
970
|
+
const table = tableMatch[1];
|
|
971
|
+
const whereClause = whereMatch ? `WHERE ${whereMatch[1]}` : "";
|
|
972
|
+
const selectSql = `SELECT * FROM ${table} ${whereClause} LIMIT 20`;
|
|
973
|
+
try {
|
|
974
|
+
const before = db.prepare(selectSql).all();
|
|
975
|
+
if (before.length > 0) {
|
|
976
|
+
beforeSnapshot = `\n\nBEFORE STATE (${before.length} rows affected):\n${JSON.stringify(before, null, 2).slice(0, 2000)}`;
|
|
977
|
+
console.log(chalk.gray(` 🗃️ Snapshot: ${before.length} rows from ${table} ${whereClause ? whereClause.slice(0, 40) : "(all)"}`));
|
|
978
|
+
}
|
|
979
|
+
} catch { /* SELECT failed, might be INSERT into new table — that's fine */ }
|
|
980
|
+
}
|
|
981
|
+
} catch { /* snapshot failed, proceed with caution */ }
|
|
982
|
+
|
|
983
|
+
// Execute the fix
|
|
960
984
|
const result = db.prepare(args.sql).run();
|
|
985
|
+
|
|
986
|
+
// SAFETY: Snapshot AFTER to show what changed
|
|
987
|
+
let afterSnapshot = "";
|
|
988
|
+
try {
|
|
989
|
+
const tableMatch = upper.match(/(?:UPDATE|DELETE\s+FROM|INSERT\s+INTO)\s+(\w+)/i);
|
|
990
|
+
const whereMatch = args.sql.match(/WHERE\s+(.+?)(?:;|$)/i);
|
|
991
|
+
if (tableMatch) {
|
|
992
|
+
const table = tableMatch[1];
|
|
993
|
+
const whereClause = whereMatch ? `WHERE ${whereMatch[1]}` : "";
|
|
994
|
+
const selectSql = `SELECT * FROM ${table} ${whereClause} LIMIT 20`;
|
|
995
|
+
try {
|
|
996
|
+
const after = db.prepare(selectSql).all();
|
|
997
|
+
afterSnapshot = `\n\nAFTER STATE (${after.length} rows):\n${JSON.stringify(after, null, 2).slice(0, 2000)}`;
|
|
998
|
+
} catch {}
|
|
999
|
+
}
|
|
1000
|
+
} catch {}
|
|
1001
|
+
|
|
961
1002
|
db.close();
|
|
962
1003
|
this.filesModified.push(args.db_path);
|
|
1004
|
+
|
|
1005
|
+
const summary = `SQL executed. Changes: ${result.changes}. Backup at: ${backupPath}${beforeSnapshot}${afterSnapshot}`;
|
|
963
1006
|
console.log(chalk.green(` 🗃️ DB fix applied: ${args.sql.slice(0, 60)} (changes: ${result.changes})`));
|
|
964
|
-
|
|
1007
|
+
if (beforeSnapshot) console.log(chalk.gray(` 🗃️ Before/after snapshot captured for audit`));
|
|
1008
|
+
|
|
1009
|
+
return { content: summary };
|
|
965
1010
|
} catch (e) { return { content: `DB error: ${e.message}` }; }
|
|
966
1011
|
}
|
|
967
1012
|
|
|
@@ -1094,14 +1139,23 @@ FAST FIXES (act immediately, don't investigate):
|
|
|
1094
1139
|
- Missing env var → check_env → report it → done
|
|
1095
1140
|
|
|
1096
1141
|
INVESTIGATION (only when cause is unclear):
|
|
1097
|
-
- Database error → inspect_db
|
|
1142
|
+
- Database error → inspect_db FIRST to see current state → understand what went wrong → run_db_fix with targeted fix
|
|
1098
1143
|
- Unknown errors → grep_code, list_dir to find root cause
|
|
1099
1144
|
|
|
1145
|
+
DATABASE SAFETY:
|
|
1146
|
+
- ALWAYS inspect_db before run_db_fix — never write blind
|
|
1147
|
+
- run_db_fix auto-snapshots affected rows before/after — check the response to verify your fix
|
|
1148
|
+
- For bad data: understand WHY the data is wrong before changing it
|
|
1149
|
+
- For NaN/null errors: check if the data was corrupted or if the code should handle it
|
|
1150
|
+
- Prefer fixing code to handle edge cases over modifying production data
|
|
1151
|
+
- A database backup is created automatically before every write
|
|
1152
|
+
|
|
1100
1153
|
RULES:
|
|
1101
1154
|
1. Fix on turn 1-2 when possible. Investigation is a last resort.
|
|
1102
1155
|
2. For ENOENT config files: read the code that requires the file, then create it with the expected structure.
|
|
1103
1156
|
3. bash_exec for operational fixes, edit_file for code, write_file for missing files, run_db_fix for data
|
|
1104
|
-
4.
|
|
1157
|
+
4. For database errors: inspect first, fix data only when code can't reasonably handle the edge case
|
|
1158
|
+
5. Always call done with summary when finished — never end without calling done.
|
|
1105
1159
|
${primaryFile ? `\nFile: ${primaryFile}` : ""}
|
|
1106
1160
|
Project: ${cwd}`;
|
|
1107
1161
|
}
|
package/src/brain/brain.js
CHANGED
|
@@ -218,7 +218,7 @@ const SEED_DOCS = [
|
|
|
218
218
|
metadata: { topic: "error-monitor" },
|
|
219
219
|
},
|
|
220
220
|
{
|
|
221
|
-
text: "Agent tool details: read_file supports offset/limit for large files. edit_file does surgical find-and-replace (preferred for small fixes). glob_files discovers files by pattern (**/*.js). grep_code does regex search with context lines. list_dir shows directory contents with file sizes. move_file relocates/renames files. bash_exec runs shell commands (30s default timeout, 60s hard cap, dangerous commands blocked: rm -rf /, git push --force, npm publish). inspect_db reads SQLite: action=tables (list), action=schema (CREATE statements), action=query (SELECT/PRAGMA only). run_db_fix writes SQLite: UPDATE/DELETE
|
|
221
|
+
text: "Agent tool details: read_file supports offset/limit for large files. edit_file does surgical find-and-replace (preferred for small fixes). glob_files discovers files by pattern (**/*.js). grep_code does regex search with context lines. list_dir shows directory contents with file sizes. move_file relocates/renames files. bash_exec runs shell commands (30s default timeout, 60s hard cap, dangerous commands blocked: rm -rf /, git push --force, npm publish). inspect_db reads SQLite: action=tables (list), action=schema (CREATE statements), action=query (SELECT/PRAGMA only). run_db_fix writes SQLite with SAFETY: auto-snapshots affected rows BEFORE write (SELECT WHERE matching the UPDATE/DELETE), executes the fix, snapshots AFTER, returns before/after comparison so agent can verify. Always backs up the DB file. Agent MUST inspect_db before run_db_fix — never write blind. For NaN/null data errors: prefer fixing code to handle edge cases over modifying production data. check_port finds what process is using a port (netstat/lsof). check_env lists environment variables with values redacted. audit_deps runs full npm health check. check_migration returns known upgrade paths. web_fetch retrieves URL content.",
|
|
222
222
|
metadata: { topic: "agent-tools-detail" },
|
|
223
223
|
},
|
|
224
224
|
{
|
|
@@ -242,7 +242,7 @@ const SEED_DOCS = [
|
|
|
242
242
|
metadata: { topic: "backup-skill" },
|
|
243
243
|
},
|
|
244
244
|
{
|
|
245
|
-
text: "CRITICAL: Never run raw 'npm install wolverine-ai' or 'git pull' to update — these OVERWRITE server/, .wolverine/ (brain, backups, events), and .env.local. Always use the safe update skill: wolverine --update (CLI), safeUpdate(cwd) (programmatic), or let auto-update handle it.
|
|
245
|
+
text: "CRITICAL: Never run raw 'npm install wolverine-ai' or 'git pull' to update — these OVERWRITE server/, .wolverine/ (brain, backups, events), and .env.local. Always use the safe update skill: wolverine --update (CLI), safeUpdate(cwd) (programmatic), or let auto-update handle it. Startup backup: wolverine creates a safety snapshot of server/ before first spawn on every start. If the server crashes immediately after a bad update and healing fails/is blocked, wolverine auto-rollbacks to the startup snapshot after max retries — prevents permanent breakage from corrupted server/ files. ALL backups (heal snapshots + update snapshots + startup snapshots) stored in ~/.wolverine-safe-backups/ (OUTSIDE project, survives git clean, rm -rf, project deletion). Restore with: wolverine --restore <name>. List: wolverine --backups.",
|
|
246
246
|
metadata: { topic: "safe-update-warning" },
|
|
247
247
|
},
|
|
248
248
|
{
|
|
@@ -304,6 +304,13 @@ class Brain {
|
|
|
304
304
|
console.log(chalk.gray(" 🧠 Framework updated — merging new seed docs..."));
|
|
305
305
|
await this._mergeSeedDocs();
|
|
306
306
|
try { fs.unlinkSync(seedRefreshPath); } catch {}
|
|
307
|
+
} else {
|
|
308
|
+
// Auto-detect new seeds: if SEED_DOCS count > docs namespace count, merge
|
|
309
|
+
const docsCount = (this.store.getNamespace("docs") || []).length;
|
|
310
|
+
if (SEED_DOCS.length > docsCount) {
|
|
311
|
+
console.log(chalk.gray(` 🧠 New seed docs detected (${SEED_DOCS.length} vs ${docsCount}) — merging...`));
|
|
312
|
+
await this._mergeSeedDocs();
|
|
313
|
+
}
|
|
307
314
|
}
|
|
308
315
|
|
|
309
316
|
// 2. Scan project for live function map
|
package/src/core/ai-client.js
CHANGED
|
@@ -42,12 +42,13 @@ function getClient(provider) {
|
|
|
42
42
|
|
|
43
43
|
function _getWolverineClient() {
|
|
44
44
|
if (!_wolverineClient) {
|
|
45
|
-
// Wolverine inference: direct to GPU
|
|
46
|
-
//
|
|
45
|
+
// Wolverine inference: direct to GPU or via proxy
|
|
46
|
+
// WOLVERINE_GPU_KEY = internal key for direct GPU access (llama.cpp --api-key)
|
|
47
|
+
// WOLVERINE_API_KEY = user key for billed proxy access (api.wolverinenode.xyz)
|
|
47
48
|
const baseURL = process.env.WOLVERINE_INFERENCE_URL
|
|
48
49
|
? process.env.WOLVERINE_INFERENCE_URL + "/v1"
|
|
49
50
|
: "https://api.wolverinenode.xyz/v1";
|
|
50
|
-
const apiKey = process.env.WOLVERINE_API_KEY || "none";
|
|
51
|
+
const apiKey = process.env.WOLVERINE_GPU_KEY || process.env.WOLVERINE_API_KEY || "none";
|
|
51
52
|
_wolverineClient = new OpenAI({ apiKey, baseURL });
|
|
52
53
|
}
|
|
53
54
|
return _wolverineClient;
|
package/src/core/error-parser.js
CHANGED
|
@@ -97,11 +97,11 @@ function classifyError(errorMessage, fullStderr) {
|
|
|
97
97
|
const full = (fullStderr || "").toLowerCase();
|
|
98
98
|
|
|
99
99
|
// Missing npm package: Cannot find module 'cors' (not a relative path)
|
|
100
|
-
if (/cannot find module '(?![./\\])/.test(msg) || /module_not_found/.test(full)) {
|
|
100
|
+
if (/cannot find module ['"](?![./\\])/.test(msg) || /module_not_found/.test(full)) {
|
|
101
101
|
return "missing_module";
|
|
102
102
|
}
|
|
103
103
|
// Missing local file: Cannot find module './routes/api'
|
|
104
|
-
if (/cannot find module '[./\\]/.test(msg) || /enoent/.test(msg)) {
|
|
104
|
+
if (/cannot find module ['"][./\\]/.test(msg) || /enoent/.test(msg)) {
|
|
105
105
|
return "missing_file";
|
|
106
106
|
}
|
|
107
107
|
// Permission denied
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Initialize the server/ directory from the built-in template.
|
|
7
|
+
*
|
|
8
|
+
* Called on first run if server/ doesn't exist. NEVER overwrites existing files.
|
|
9
|
+
* This is why wolverine ships without a server/ directory in the npm package —
|
|
10
|
+
* so `npm install` and `git pull` can never destroy user code.
|
|
11
|
+
*
|
|
12
|
+
* The template lives in src/templates/server/ and contains a minimal Fastify
|
|
13
|
+
* server with health, api, and time routes + default settings.json.
|
|
14
|
+
*/
|
|
15
|
+
function initServer(cwd, scriptPath) {
|
|
16
|
+
const serverDir = path.join(cwd, "server");
|
|
17
|
+
const scriptFile = path.resolve(cwd, scriptPath);
|
|
18
|
+
|
|
19
|
+
// If the script file already exists, nothing to do
|
|
20
|
+
if (fs.existsSync(scriptFile)) return false;
|
|
21
|
+
|
|
22
|
+
// If server/ exists but the specific script doesn't, don't create — user has their own structure
|
|
23
|
+
if (fs.existsSync(serverDir) && fs.readdirSync(serverDir).length > 0) {
|
|
24
|
+
console.log(chalk.yellow(` ⚠️ ${scriptPath} not found but server/ exists — skipping template init`));
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Create server/ from template
|
|
29
|
+
const templateDir = path.join(__dirname, "..", "templates", "server");
|
|
30
|
+
if (!fs.existsSync(templateDir)) {
|
|
31
|
+
console.log(chalk.yellow(" ⚠️ No server template found — create server/index.js manually"));
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(chalk.blue(" 📦 Creating default server/ from template..."));
|
|
36
|
+
_copyDir(templateDir, serverDir);
|
|
37
|
+
console.log(chalk.green(" ✅ Server initialized. Edit server/ to build your app."));
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _copyDir(src, dest) {
|
|
42
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
43
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
44
|
+
const srcPath = path.join(src, entry.name);
|
|
45
|
+
const destPath = path.join(dest, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
_copyDir(srcPath, destPath);
|
|
48
|
+
} else {
|
|
49
|
+
// NEVER overwrite existing files
|
|
50
|
+
if (!fs.existsSync(destPath)) {
|
|
51
|
+
fs.copyFileSync(srcPath, destPath);
|
|
52
|
+
console.log(chalk.gray(` + ${path.relative(dest, destPath)}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { initServer };
|
package/src/core/runner.js
CHANGED
|
@@ -246,6 +246,15 @@ class WolverineRunner {
|
|
|
246
246
|
console.log(chalk.gray(" 🔄 Auto-update: disabled"));
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// Create startup backup — safety net for corrupted server/ from bad updates
|
|
250
|
+
// If the child crashes immediately after this, we can rollback to this known state
|
|
251
|
+
try {
|
|
252
|
+
this._startupBackupId = this.backupManager.createBackup("pre-start (safety snapshot)");
|
|
253
|
+
console.log(chalk.gray(` 📸 Startup backup: ${this._startupBackupId}`));
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.log(chalk.yellow(` ⚠️ Startup backup failed (non-fatal): ${err.message}`));
|
|
256
|
+
}
|
|
257
|
+
|
|
249
258
|
this._spawn();
|
|
250
259
|
}
|
|
251
260
|
|
|
@@ -566,6 +575,20 @@ class WolverineRunner {
|
|
|
566
575
|
console.log(chalk.yellow(" Retrying...\n"));
|
|
567
576
|
this._spawn();
|
|
568
577
|
} else {
|
|
578
|
+
// Max retries — try rolling back to startup backup as last resort
|
|
579
|
+
if (this._startupBackupId) {
|
|
580
|
+
console.log(chalk.yellow(`\n 🔄 Max retries reached — rolling back to startup backup ${this._startupBackupId}...`));
|
|
581
|
+
try {
|
|
582
|
+
this.backupManager.rollbackTo(this._startupBackupId);
|
|
583
|
+
console.log(chalk.green(" ✅ Rolled back to startup state. Restarting..."));
|
|
584
|
+
this.retryCount = 0;
|
|
585
|
+
this._startupBackupId = null; // don't rollback again if this also fails
|
|
586
|
+
this._spawn();
|
|
587
|
+
return;
|
|
588
|
+
} catch (rbErr) {
|
|
589
|
+
console.log(chalk.red(` ❌ Rollback failed: ${rbErr.message}`));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
569
592
|
console.log(chalk.red(" Max retries reached."));
|
|
570
593
|
this._logRollbackHint();
|
|
571
594
|
this.running = false;
|
|
@@ -590,11 +613,37 @@ class WolverineRunner {
|
|
|
590
613
|
this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
|
|
591
614
|
this.logger.info("heal.error_monitor", `Healing caught 500 on ${routePath}`, { route: routePath });
|
|
592
615
|
|
|
593
|
-
// Build
|
|
616
|
+
// Build synthetic stderr that matches the error parser's expected format
|
|
617
|
+
// If IPC didn't include a file, try to resolve from the route path or stack
|
|
618
|
+
let file = errorDetails.file;
|
|
619
|
+
let line = errorDetails.line || 1;
|
|
620
|
+
if (!file && errorDetails.stack) {
|
|
621
|
+
// Try to find user-land file in stack (not node_modules, not node:)
|
|
622
|
+
const frames = (errorDetails.stack || "").split("\n");
|
|
623
|
+
for (const frame of frames) {
|
|
624
|
+
const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
|
|
625
|
+
if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
|
|
626
|
+
file = m[1]; line = parseInt(m[2], 10); break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!file && routePath) {
|
|
631
|
+
// Last resort: map route path to likely file (e.g., /breakable → server/routes/breakable.js)
|
|
632
|
+
const routeName = routePath.split("/").filter(Boolean).pop();
|
|
633
|
+
if (routeName) {
|
|
634
|
+
const path = require("path");
|
|
635
|
+
const guess = path.join(this.cwd, "server", "routes", routeName + ".js");
|
|
636
|
+
if (require("fs").existsSync(guess)) { file = guess; line = 1; }
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const msg = errorDetails.message || "Unknown error";
|
|
641
|
+
const hasErrorPrefix = /^\w*Error:/.test(msg);
|
|
594
642
|
const stderr = [
|
|
595
|
-
|
|
643
|
+
file ? `${file}:${line}` : "",
|
|
644
|
+
hasErrorPrefix ? msg : `Error: ${msg}`,
|
|
596
645
|
errorDetails.stack || "",
|
|
597
|
-
|
|
646
|
+
file ? ` at ${file}:${line}:1` : "",
|
|
598
647
|
].filter(Boolean).join("\n");
|
|
599
648
|
|
|
600
649
|
try {
|
|
@@ -1,324 +0,0 @@
|
|
|
1
|
-
const https = require("https");
|
|
2
|
-
const http = require("http");
|
|
3
|
-
const crypto = require("crypto");
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Wolverine Inference API
|
|
7
|
-
*
|
|
8
|
-
* Credit system: $1 = 100 credits. 1 credit = $0.01 of compute.
|
|
9
|
-
* Token pricing (in credits per million tokens):
|
|
10
|
-
* wolverine-test-1: 1 credit input / 4 credits output per 1M tokens
|
|
11
|
-
* (= $0.01/$0.04 per 1M — 15x cheaper than gpt-4o-mini, 80x cheaper than haiku)
|
|
12
|
-
*
|
|
13
|
-
* Rate limiting: per API key, configurable per tier.
|
|
14
|
-
* Queue: when GPU is at capacity, requests queue with timeout.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const INFERENCE_URL = process.env.WOLVERINE_INFERENCE_URL || "https://clips-third-players-binding.trycloudflare.com";
|
|
18
|
-
|
|
19
|
-
// Pricing in CREDITS per million tokens ($1 = 100 credits)
|
|
20
|
-
const MODEL_PRICING = {
|
|
21
|
-
"wolverine-test-1": { input: 1.0, output: 4.0 }, // $0.01/$0.04 per 1M
|
|
22
|
-
"wolverine-coding": { input: 1.0, output: 4.0 },
|
|
23
|
-
"wolverine-reasoning": { input: 2.5, output: 10.0 }, // heavier model when available
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const MODEL_MAP = {
|
|
27
|
-
"wolverine-test-1": "wolverine-test-1",
|
|
28
|
-
"wolverine-coding": "wolverine-test-1",
|
|
29
|
-
"wolverine-reasoning": "wolverine-test-1",
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const TIER_LIMITS = {
|
|
33
|
-
free: { rpm: 10, maxTokens: 1024 },
|
|
34
|
-
starter: { rpm: 60, maxTokens: 4096 },
|
|
35
|
-
pro: { rpm: 300, maxTokens: 4096 },
|
|
36
|
-
admin: { rpm: 9999, maxTokens: 4096 },
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function tokenCost(model, inputTokens, outputTokens) {
|
|
40
|
-
const p = MODEL_PRICING[model] || MODEL_PRICING["wolverine-test-1"];
|
|
41
|
-
return ((inputTokens / 1_000_000) * p.input) + ((outputTokens / 1_000_000) * p.output);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── Request Queue (handles GPU saturation) ──
|
|
45
|
-
const queue = [];
|
|
46
|
-
let activeRequests = 0;
|
|
47
|
-
const MAX_CONCURRENT = 8; // vLLM max-num-seqs
|
|
48
|
-
const QUEUE_TIMEOUT_MS = 30000;
|
|
49
|
-
|
|
50
|
-
function enqueue() {
|
|
51
|
-
return new Promise((resolve, reject) => {
|
|
52
|
-
if (activeRequests < MAX_CONCURRENT) {
|
|
53
|
-
activeRequests++;
|
|
54
|
-
resolve();
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const timer = setTimeout(() => {
|
|
58
|
-
const idx = queue.indexOf(entry);
|
|
59
|
-
if (idx >= 0) queue.splice(idx, 1);
|
|
60
|
-
reject(new Error("Queue timeout — GPU at capacity. Try again in a few seconds."));
|
|
61
|
-
}, QUEUE_TIMEOUT_MS);
|
|
62
|
-
const entry = { resolve: () => { clearTimeout(timer); activeRequests++; resolve(); }, reject };
|
|
63
|
-
queue.push(entry);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function dequeue() {
|
|
68
|
-
activeRequests = Math.max(0, activeRequests - 1);
|
|
69
|
-
if (queue.length > 0) {
|
|
70
|
-
const next = queue.shift();
|
|
71
|
-
next.resolve();
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function routes(fastify) {
|
|
76
|
-
const { pool } = require("../lib/db");
|
|
77
|
-
|
|
78
|
-
// Rate limit state (in-memory)
|
|
79
|
-
const rateWindows = new Map();
|
|
80
|
-
|
|
81
|
-
async function authenticate(request, reply) {
|
|
82
|
-
const apiKey = request.headers.authorization?.replace("Bearer ", "") || request.headers["x-api-key"];
|
|
83
|
-
if (!apiKey) return reply.code(401).send({ error: { message: "API key required. Pass via Authorization: Bearer <key>", type: "auth_error" } });
|
|
84
|
-
|
|
85
|
-
// Platform key bypass
|
|
86
|
-
let settings = {};
|
|
87
|
-
try { settings = require("../config/settings.json"); } catch {}
|
|
88
|
-
if (apiKey === settings.platform?.apiKey) {
|
|
89
|
-
request.account = { api_key: apiKey, owner: "platform", tier: "admin", credits_remaining: 999999, rate_limit_rpm: 9999 };
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const result = await pool.query("SELECT * FROM api_credits WHERE api_key = $1", [apiKey]);
|
|
94
|
-
if (result.rows.length === 0) return reply.code(401).send({ error: { message: "Invalid API key", type: "auth_error" } });
|
|
95
|
-
|
|
96
|
-
const account = result.rows[0];
|
|
97
|
-
|
|
98
|
-
// Credit check
|
|
99
|
-
if (parseFloat(account.credits_remaining) <= 0) {
|
|
100
|
-
return reply.code(402).send({ error: { message: "Insufficient credits. Add credits at wolverinenode.xyz", type: "billing_error", credits_remaining: 0 } });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Rate limit
|
|
104
|
-
const now = Date.now();
|
|
105
|
-
const window = rateWindows.get(apiKey) || { count: 0, resetAt: now + 60000 };
|
|
106
|
-
if (now > window.resetAt) { window.count = 0; window.resetAt = now + 60000; }
|
|
107
|
-
const limit = account.rate_limit_rpm || TIER_LIMITS[account.tier]?.rpm || 10;
|
|
108
|
-
if (window.count >= limit) {
|
|
109
|
-
const retryAfter = Math.ceil((window.resetAt - now) / 1000);
|
|
110
|
-
return reply.code(429).send({ error: { message: `Rate limit: ${limit} requests/min. Retry in ${retryAfter}s`, type: "rate_limit", retry_after: retryAfter } });
|
|
111
|
-
}
|
|
112
|
-
window.count++;
|
|
113
|
-
rateWindows.set(apiKey, window);
|
|
114
|
-
|
|
115
|
-
request.account = account;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── POST /chat/completions ──
|
|
119
|
-
fastify.post("/chat/completions", { preHandler: authenticate }, async (request, reply) => {
|
|
120
|
-
const body = request.body || {};
|
|
121
|
-
const requestedModel = body.model || "wolverine-test-1";
|
|
122
|
-
const account = request.account;
|
|
123
|
-
const tier = TIER_LIMITS[account.tier] || TIER_LIMITS.free;
|
|
124
|
-
const startMs = Date.now();
|
|
125
|
-
|
|
126
|
-
// Enforce max tokens per tier
|
|
127
|
-
if (body.max_tokens && body.max_tokens > tier.maxTokens) {
|
|
128
|
-
body.max_tokens = tier.maxTokens;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Map model name for backend
|
|
132
|
-
const backendBody = { ...body, model: MODEL_MAP[requestedModel] || requestedModel };
|
|
133
|
-
|
|
134
|
-
// Queue if GPU saturated
|
|
135
|
-
try {
|
|
136
|
-
await enqueue();
|
|
137
|
-
} catch (err) {
|
|
138
|
-
return reply.code(503).send({ error: { message: err.message, type: "capacity_error", queue_length: queue.length } });
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const result = await proxyToInference("/v1/chat/completions", backendBody);
|
|
143
|
-
const latencyMs = Date.now() - startMs;
|
|
144
|
-
|
|
145
|
-
const usage = result.usage || {};
|
|
146
|
-
const inputTokens = usage.prompt_tokens || 0;
|
|
147
|
-
const outputTokens = usage.completion_tokens || 0;
|
|
148
|
-
const cost = tokenCost(requestedModel, inputTokens, outputTokens);
|
|
149
|
-
|
|
150
|
-
// Bill credits (skip for platform)
|
|
151
|
-
if (account.owner !== "platform") {
|
|
152
|
-
await pool.query(
|
|
153
|
-
"UPDATE api_credits SET credits_remaining = credits_remaining - $1, credits_used = credits_used + $1, last_used = NOW() WHERE api_key = $2",
|
|
154
|
-
[cost, account.api_key]
|
|
155
|
-
);
|
|
156
|
-
await pool.query(
|
|
157
|
-
"INSERT INTO api_usage_log (api_key, model, input_tokens, output_tokens, total_tokens, cost, latency_ms, success, endpoint) VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8)",
|
|
158
|
-
[account.api_key, requestedModel, inputTokens, outputTokens, inputTokens + outputTokens, cost, latencyMs, "/v1/chat/completions"]
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Rewrite response
|
|
163
|
-
if (result.model) result.model = requestedModel;
|
|
164
|
-
result.x_wolverine = {
|
|
165
|
-
credits_used: Math.round(cost * 1000000) / 1000000,
|
|
166
|
-
credits_remaining: Math.max(0, parseFloat(account.credits_remaining) - cost),
|
|
167
|
-
latency_ms: latencyMs,
|
|
168
|
-
queued: activeRequests > MAX_CONCURRENT,
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
return result;
|
|
172
|
-
} catch (err) {
|
|
173
|
-
if (account.owner !== "platform") {
|
|
174
|
-
await pool.query(
|
|
175
|
-
"INSERT INTO api_usage_log (api_key, model, input_tokens, output_tokens, total_tokens, cost, latency_ms, success, endpoint) VALUES ($1, $2, 0, 0, 0, 0, $3, false, $4)",
|
|
176
|
-
[account.api_key, requestedModel, Date.now() - startMs, "/v1/chat/completions"]
|
|
177
|
-
).catch(() => {});
|
|
178
|
-
}
|
|
179
|
-
return reply.code(502).send({ error: { message: `Inference error: ${err.message}`, type: "inference_error" } });
|
|
180
|
-
} finally {
|
|
181
|
-
dequeue();
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// ── GET /models ──
|
|
186
|
-
fastify.get("/models", async () => ({
|
|
187
|
-
object: "list",
|
|
188
|
-
data: Object.entries(MODEL_PRICING).map(([id, p]) => ({
|
|
189
|
-
id, object: "model", owned_by: "wolverine",
|
|
190
|
-
created: Math.floor(Date.now() / 1000),
|
|
191
|
-
pricing: { input_credits_per_million: p.input, output_credits_per_million: p.output, usd_per_credit: 0.01 },
|
|
192
|
-
})),
|
|
193
|
-
}));
|
|
194
|
-
|
|
195
|
-
// ── POST /keys/create — generate new API key ──
|
|
196
|
-
fastify.post("/keys/create", { preHandler: authenticate }, async (request, reply) => {
|
|
197
|
-
const account = request.account;
|
|
198
|
-
if (account.tier !== "admin") return reply.code(403).send({ error: { message: "Only admins can create API keys", type: "auth_error" } });
|
|
199
|
-
|
|
200
|
-
const { owner, email, credits, tier, rpm } = request.body || {};
|
|
201
|
-
if (!owner) return reply.code(400).send({ error: { message: "owner required", type: "validation_error" } });
|
|
202
|
-
|
|
203
|
-
const newKey = "wlv_" + crypto.randomBytes(24).toString("hex");
|
|
204
|
-
const keyTier = tier || "free";
|
|
205
|
-
const keyCredits = credits || (keyTier === "free" ? 10 : 0);
|
|
206
|
-
const keyRpm = rpm || TIER_LIMITS[keyTier]?.rpm || 10;
|
|
207
|
-
|
|
208
|
-
await pool.query(
|
|
209
|
-
"INSERT INTO api_credits (api_key, owner, email, credits_remaining, tier, plan_name, rate_limit_rpm) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
210
|
-
[newKey, owner, email || null, keyCredits, keyTier, keyTier, keyRpm]
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
return { api_key: newKey, owner, tier: keyTier, credits: keyCredits, rate_limit_rpm: keyRpm };
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// ── POST /keys/add-credits — add credits to a key ──
|
|
217
|
-
fastify.post("/keys/add-credits", { preHandler: authenticate }, async (request, reply) => {
|
|
218
|
-
const account = request.account;
|
|
219
|
-
if (account.tier !== "admin") return reply.code(403).send({ error: { message: "Only admins can add credits", type: "auth_error" } });
|
|
220
|
-
|
|
221
|
-
const { api_key, credits } = request.body || {};
|
|
222
|
-
if (!api_key || !credits) return reply.code(400).send({ error: { message: "api_key and credits required" } });
|
|
223
|
-
|
|
224
|
-
await pool.query("UPDATE api_credits SET credits_remaining = credits_remaining + $1 WHERE api_key = $2", [credits, api_key]);
|
|
225
|
-
const updated = await pool.query("SELECT credits_remaining FROM api_credits WHERE api_key = $1", [api_key]);
|
|
226
|
-
return { api_key, credits_added: credits, credits_remaining: parseFloat(updated.rows[0]?.credits_remaining || 0) };
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// ── GET /keys — list all keys (admin only) ──
|
|
230
|
-
fastify.get("/keys", { preHandler: authenticate }, async (request, reply) => {
|
|
231
|
-
if (request.account.tier !== "admin") return reply.code(403).send({ error: { message: "Admin only" } });
|
|
232
|
-
const { rows } = await pool.query("SELECT api_key, owner, email, tier, credits_remaining, credits_used, rate_limit_rpm, created_at, last_used FROM api_credits ORDER BY created_at DESC");
|
|
233
|
-
return { keys: rows };
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// ── GET /credits ──
|
|
237
|
-
fastify.get("/credits", { preHandler: authenticate }, async (request, reply) => {
|
|
238
|
-
const a = request.account;
|
|
239
|
-
return {
|
|
240
|
-
credits_remaining: parseFloat(a.credits_remaining),
|
|
241
|
-
credits_used: parseFloat(a.credits_used || 0),
|
|
242
|
-
usd_remaining: parseFloat(a.credits_remaining) * 0.01,
|
|
243
|
-
usd_used: parseFloat(a.credits_used || 0) * 0.01,
|
|
244
|
-
tier: a.tier, rate_limit_rpm: a.rate_limit_rpm, owner: a.owner,
|
|
245
|
-
};
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// ── GET /usage ──
|
|
249
|
-
fastify.get("/usage", { preHandler: authenticate }, async (request, reply) => {
|
|
250
|
-
const apiKey = request.account.api_key;
|
|
251
|
-
const period = request.query.period || "7d";
|
|
252
|
-
const interval = { "1h": "1 hour", "1d": "1 day", "7d": "7 days", "30d": "30 days" }[period] || "7 days";
|
|
253
|
-
|
|
254
|
-
const summary = await pool.query(
|
|
255
|
-
`SELECT model, COUNT(*) AS calls, SUM(input_tokens) AS input, SUM(output_tokens) AS output,
|
|
256
|
-
SUM(total_tokens) AS tokens, SUM(cost) AS credits_spent, AVG(latency_ms) AS avg_latency,
|
|
257
|
-
COUNT(*) FILTER (WHERE success) AS successes
|
|
258
|
-
FROM api_usage_log WHERE api_key = $1 AND timestamp > NOW() - $2::interval
|
|
259
|
-
GROUP BY model ORDER BY credits_spent DESC`, [apiKey, interval]
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
const timeline = await pool.query(
|
|
263
|
-
`SELECT date_trunc('hour', timestamp) AS hour, SUM(cost) AS credits, SUM(total_tokens) AS tokens, COUNT(*) AS calls
|
|
264
|
-
FROM api_usage_log WHERE api_key = $1 AND timestamp > NOW() - $2::interval
|
|
265
|
-
GROUP BY hour ORDER BY hour`, [apiKey, interval]
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
const totalCredits = summary.rows.reduce((s, r) => s + parseFloat(r.credits_spent || 0), 0);
|
|
269
|
-
|
|
270
|
-
return {
|
|
271
|
-
period,
|
|
272
|
-
total_credits_spent: Math.round(totalCredits * 1000000) / 1000000,
|
|
273
|
-
total_usd_spent: Math.round(totalCredits * 0.01 * 1000000) / 1000000,
|
|
274
|
-
byModel: summary.rows.map(r => ({
|
|
275
|
-
model: r.model, calls: parseInt(r.calls), input: parseInt(r.input || 0), output: parseInt(r.output || 0),
|
|
276
|
-
tokens: parseInt(r.tokens || 0), credits_spent: parseFloat(r.credits_spent || 0),
|
|
277
|
-
usd_spent: parseFloat(r.credits_spent || 0) * 0.01,
|
|
278
|
-
avgLatencyMs: Math.round(parseFloat(r.avg_latency || 0)),
|
|
279
|
-
successRate: parseInt(r.calls) > 0 ? parseFloat(((parseInt(r.successes) / parseInt(r.calls)) * 100).toFixed(2)) : 0,
|
|
280
|
-
})),
|
|
281
|
-
timeline: timeline.rows.map(r => ({
|
|
282
|
-
hour: r.hour, credits: parseFloat(r.credits), tokens: parseInt(r.tokens), calls: parseInt(r.calls),
|
|
283
|
-
})),
|
|
284
|
-
queue: { active: activeRequests, waiting: queue.length, max: MAX_CONCURRENT },
|
|
285
|
-
};
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// ── GET /health ──
|
|
289
|
-
fastify.get("/health", async () => {
|
|
290
|
-
try {
|
|
291
|
-
const result = await proxyToInference("/health", null, "GET");
|
|
292
|
-
return { status: "ok", inference: result, queue: { active: activeRequests, waiting: queue.length, max: MAX_CONCURRENT } };
|
|
293
|
-
} catch (err) {
|
|
294
|
-
return { status: "down", error: err.message, queue: { active: activeRequests, waiting: queue.length, max: MAX_CONCURRENT } };
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function proxyToInference(path, body, method = "POST") {
|
|
300
|
-
return new Promise((resolve, reject) => {
|
|
301
|
-
const url = new (require("url").URL)(INFERENCE_URL + path);
|
|
302
|
-
const client = url.protocol === "https:" ? https : http;
|
|
303
|
-
const bodyStr = body ? JSON.stringify(body) : null;
|
|
304
|
-
|
|
305
|
-
const req = client.request({
|
|
306
|
-
hostname: url.hostname,
|
|
307
|
-
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
308
|
-
path: url.pathname,
|
|
309
|
-
method,
|
|
310
|
-
timeout: 120000,
|
|
311
|
-
headers: { "Content-Type": "application/json", ...(bodyStr ? { "Content-Length": Buffer.byteLength(bodyStr) } : {}) },
|
|
312
|
-
}, (res) => {
|
|
313
|
-
let data = "";
|
|
314
|
-
res.on("data", (c) => { data += c; });
|
|
315
|
-
res.on("end", () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } });
|
|
316
|
-
});
|
|
317
|
-
req.on("error", reject);
|
|
318
|
-
req.on("timeout", () => { req.destroy(); reject(new Error("Inference timeout")); });
|
|
319
|
-
if (bodyStr) req.write(bodyStr);
|
|
320
|
-
req.end();
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
module.exports = routes;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|