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 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.6.0",
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: UPDATE invalid entries, DELETE corrupt rows, ALTER schema. Creates a backup first.",
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
- return { content: `SQL executed. Changes: ${result.changes}. Backup at: ${backupPath}` };
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 then run_db_fix
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. Always call done with summary when finished never end without calling done.
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
  }
@@ -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/INSERT/ALTER, auto-backs up db file first. 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 (vulnerabilities, outdated, peer deps, unused, lock file). check_migration returns known upgrade paths with before/after code patterns. web_fetch retrieves URL content.",
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. ALL backups (heal snapshots + update snapshots) stored in ~/.wolverine-safe-backups/ (OUTSIDE project, survives git clean, rm -rf, project deletion). Structure: ~/.wolverine-safe-backups/snapshots/ (heal backups), ~/.wolverine-safe-backups/updates/ (pre-update snapshots), ~/.wolverine-safe-backups/manifest.json (backup registry). Old .wolverine/backups/ auto-migrated on first run. Restore with: wolverine --restore <name>. List: wolverine --backups.",
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
@@ -42,12 +42,13 @@ function getClient(provider) {
42
42
 
43
43
  function _getWolverineClient() {
44
44
  if (!_wolverineClient) {
45
- // Wolverine inference: direct to GPU (WOLVERINE_INFERENCE_URL) or via proxy (api.wolverinenode.xyz/v1)
46
- // Direct URL = no auth needed (Vast tunnel). Proxy URL = needs WOLVERINE_API_KEY for billing.
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;
@@ -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 };
@@ -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 a synthetic stderr from the error details
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
- errorDetails.message || "Unknown error",
643
+ file ? `${file}:${line}` : "",
644
+ hasErrorPrefix ? msg : `Error: ${msg}`,
596
645
  errorDetails.stack || "",
597
- errorDetails.file ? ` at ${errorDetails.file}:${errorDetails.line || 0}` : "",
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