kiku-agent 1.2.0 → 1.3.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.
Files changed (100) hide show
  1. package/README.md +459 -407
  2. package/package.json +27 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +629 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +528 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/lanDiscovery.js +369 -0
  23. package/server/agent/llmClient.js +111 -39
  24. package/server/agent/lmStudioWakeup.js +144 -0
  25. package/server/agent/mcpClient.js +2 -2
  26. package/server/agent/memory.js +635 -494
  27. package/server/agent/modelConfig.js +487 -0
  28. package/server/agent/newsIntelligence.js +219 -0
  29. package/server/agent/newsWatch.js +85 -0
  30. package/server/agent/prompts.js +146 -0
  31. package/server/agent/publicTools.js +7 -0
  32. package/server/agent/queryDetails.js +62 -1
  33. package/server/agent/research.js +314 -35
  34. package/server/agent/routingInterceptor.js +77 -0
  35. package/server/agent/rumoredProjects.js +15 -0
  36. package/server/agent/sanitizer.js +117 -17
  37. package/server/agent/subagent.js +52 -9
  38. package/server/agent/suggestionTui.js +33 -13
  39. package/server/agent/suggestions.js +29 -13
  40. package/server/agent/telemetry.js +0 -11
  41. package/server/agent/terminalImage.js +0 -1
  42. package/server/agent/textToolProtocol.js +190 -0
  43. package/server/agent/tokenTracker.js +220 -0
  44. package/server/agent/toolRouter.js +362 -216
  45. package/server/agent/tools.js +1155 -194
  46. package/server/agent/ui.js +144 -41
  47. package/server/agent/vramService.js +572 -0
  48. package/server/agent/webSearch.js +2279 -273
  49. package/server/agent/youtubeAuth.js +82 -73
  50. package/server/agent/youtubeTranscript.js +209 -0
  51. package/server/apiRouter.js +383 -179
  52. package/server/mcp/data/brands.js +5 -5
  53. package/server/mcp/data/discography.js +56 -45
  54. package/server/mcp/data/gallery.js +9 -0
  55. package/server/mcp/data/index.js +1 -0
  56. package/server/mcp/data/industryIndex.js +251 -0
  57. package/server/mcp/data/profile.js +5 -5
  58. package/server/mcp/data/timeline.js +2 -2
  59. package/server/mcp/data/works.js +2 -2
  60. package/server/mcp/handler.js +0 -2
  61. package/server/mcp/prompts.js +42 -2
  62. package/server/mcp/resources.js +5 -1
  63. package/server/mcp/security.js +25 -14
  64. package/server/mcp/server.js +92 -3
  65. package/server/mcp/tools.js +20 -11
  66. package/server/web-chat/core/connection.js +218 -194
  67. package/server/web-chat/core/conversationContext.js +1 -1
  68. package/server/web-chat/core/directCatalogEngine.js +190 -184
  69. package/server/web-chat/core/followUpEngine.js +88 -11
  70. package/server/web-chat/core/security.js +7 -4
  71. package/server/web-chat/router.js +54 -17
  72. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  73. package/server/web-chat/routes/chatRoute.js +488 -397
  74. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  75. package/server/web-chat/routes/exportRoute.js +2 -1
  76. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  77. package/server/web-chat/routes/newsRoute.js +36 -6
  78. package/server/web-chat/routes/providerRoute.js +87 -0
  79. package/server/web-chat/routes/staticRoute.js +61 -13
  80. package/server/web-chat/routes/statusRoute.js +23 -2
  81. package/server/web-chat/routes/vramRoute.js +22 -0
  82. package/server/web-chat/server.js +135 -106
  83. package/server/web-chat/ui/htmlBuilder.js +8 -8
  84. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  85. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  86. package/server/web-chat/ui/scripts/client.js +10526 -8397
  87. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  88. package/server/web-chat/ui/styles/main.css +7471 -4246
  89. package/server/web-chat/ui/templates/body.html +305 -179
  90. package/src/data/brandsData.js +29 -6
  91. package/src/data/discographyData.js +918 -900
  92. package/src/data/galleryData.js +66 -0
  93. package/src/data/industryIndexData.js +292 -0
  94. package/src/data/musicVideoData.js +48 -5
  95. package/src/data/profileData.js +15 -1
  96. package/src/data/timelineData.js +4 -3
  97. package/src/data/worksData.js +536 -535
  98. package/src/lib/vram/estimator.js +464 -0
  99. package/src/lib/vram/modelRegistry.js +387 -0
  100. package/tokens.css +33 -2
@@ -12,6 +12,7 @@ import { awardsData } from '../src/data/awardsData.js';
12
12
  import { timelineData } from '../src/data/timelineData.js';
13
13
  import { PACKAGE_VERSION } from './agent/version.js';
14
14
  import { QUIZ_CORRECT_ANSWERS } from './agent/quizData.js';
15
+ import { parseEstimateRequest } from './agent/vramService.js';
15
16
 
16
17
  function loadEnv() {
17
18
  try {
@@ -47,6 +48,14 @@ const rateLimitCleanupInterval = setInterval(() => {
47
48
  rateLimitStore.delete(id);
48
49
  }
49
50
  }
51
+ for (const [hash, entry] of apiKeyCache) {
52
+ if (entry.validUntil < now) {
53
+ apiKeyCache.delete(hash);
54
+ }
55
+ }
56
+ while (apiKeyCache.size >= API_KEY_CACHE_MAX) {
57
+ apiKeyCache.delete(apiKeyCache.keys().next().value);
58
+ }
50
59
  }, 60000);
51
60
  rateLimitCleanupInterval.unref?.();
52
61
 
@@ -63,28 +72,52 @@ const masterApiKeys = new Set([
63
72
  ...(process.env.KIKU_MASTER_KEY ? [process.env.KIKU_MASTER_KEY.trim()] : []),
64
73
  ]);
65
74
 
66
- const issuedApiKeys = new Set([
75
+ const issuedApiKeys = new Set([
67
76
  ...(process.env.KIKU_API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
68
77
  ...(process.env.KIKU_VIP_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
69
78
  ...masterApiKeys
70
- ]);
71
- const agentExecutionKeys = new Set([
72
- ...(process.env.KIKU_AGENT_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
73
- ...masterApiKeys,
74
- ]);
79
+ ]);
80
+ const agentExecutionKeys = new Set([
81
+ ...(process.env.KIKU_AGENT_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
82
+ ...masterApiKeys,
83
+ ]);
75
84
 
76
85
  const apiKeyCache = new Map();
86
+ const API_KEY_CACHE_MAX = 10000;
87
+
88
+ function setApiKeyCache(hash, entry) {
89
+ if (apiKeyCache.size >= API_KEY_CACHE_MAX) {
90
+ const oldestKey = apiKeyCache.keys().next().value;
91
+ if (oldestKey) apiKeyCache.delete(oldestKey);
92
+ }
93
+ apiKeyCache.set(hash, entry);
94
+ }
77
95
  const activeQuizSessions = new Map();
78
96
  const consumedQuizTokens = new Set();
79
97
  const MIN_QUIZ_DURATION_MS = 2500;
80
98
 
81
- const API_KEY_PEPPER = process.env.API_KEY_PEPPER || process.env.KIKU_KEY_PEPPER || crypto.randomBytes(32).toString('hex');
82
- const QUIZ_HMAC_SECRET = process.env.QUIZ_SECRET || process.env.KIKU_QUIZ_SECRET || crypto.randomBytes(32).toString('hex');
99
+ const API_KEY_PEPPER = process.env.API_KEY_PEPPER || process.env.KIKU_KEY_PEPPER || crypto.randomBytes(32).toString('hex');
100
+ const QUIZ_HMAC_SECRET = process.env.QUIZ_SECRET || process.env.KIKU_QUIZ_SECRET || crypto.randomBytes(32).toString('hex');
101
+
102
+ if (process.env.NODE_ENV === 'production') {
103
+ if (!process.env.API_KEY_PEPPER && !process.env.KIKU_KEY_PEPPER) {
104
+ console.warn('[SECURITY WARNING] API_KEY_PEPPER is unset in production. Key hashes will reset on restart.');
105
+ }
106
+ if (!process.env.QUIZ_SECRET && !process.env.KIKU_QUIZ_SECRET) {
107
+ console.warn('[SECURITY WARNING] QUIZ_SECRET is unset in production. Challenge signatures will reset on restart.');
108
+ }
109
+ }
83
110
 
84
111
  function isMasterKey(rawKey) {
85
112
  if (!rawKey || typeof rawKey !== 'string') return false;
86
113
  const trimmed = rawKey.trim();
87
- if (masterApiKeys.has(trimmed)) return true;
114
+ const rawBuf = Buffer.from(trimmed);
115
+ for (const master of masterApiKeys) {
116
+ const masterBuf = Buffer.from(master);
117
+ if (rawBuf.length === masterBuf.length && crypto.timingSafeEqual(rawBuf, masterBuf)) {
118
+ return true;
119
+ }
120
+ }
88
121
  return false;
89
122
  }
90
123
 
@@ -115,7 +148,7 @@ async function validateApiKey(rawKey) {
115
148
  else if (trimmed.startsWith('kiku_vip_')) tier = 'Developer VIP';
116
149
 
117
150
  const result = { tier, rateLimit: RATE_LIMIT_API_KEY };
118
- apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
151
+ setApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
119
152
  return result;
120
153
  }
121
154
 
@@ -144,7 +177,7 @@ async function validateApiKey(rawKey) {
144
177
  if (Array.isArray(rows) && rows.length > 0) {
145
178
  const row = rows[0];
146
179
  if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
147
- apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
180
+ setApiKeyCache(hash, { isActive: false, validUntil: Date.now() + 60000 });
148
181
  return null;
149
182
  }
150
183
  let tier = 'Developer';
@@ -153,11 +186,11 @@ async function validateApiKey(rawKey) {
153
186
 
154
187
  const rateLimit = Number(row.rate_limit_per_min) || RATE_LIMIT_API_KEY;
155
188
  const result = { tier, rateLimit };
156
- apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
189
+ setApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
157
190
  return result;
158
191
  }
159
192
  }
160
- apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
193
+ setApiKeyCache(hash, { isActive: false, validUntil: Date.now() + 60000 });
161
194
  return null;
162
195
  } catch {
163
196
  if (issuedApiKeys.has(trimmed) || isMasterKey(trimmed)) {
@@ -173,7 +206,7 @@ async function saveApiKeyToSupabase({ apiKey, tier = 'developer_vip', rateLimit
173
206
  const prefix = apiKey.slice(0, 16);
174
207
 
175
208
  issuedApiKeys.add(apiKey);
176
- apiKeyCache.set(hash, {
209
+ setApiKeyCache(hash, {
177
210
  tier: tier === 'developer_vip' ? 'Developer VIP' : (tier === 'master_vip' ? 'Master VIP' : 'Developer'),
178
211
  rateLimit,
179
212
  isActive: true,
@@ -205,14 +238,14 @@ async function saveApiKeyToSupabase({ apiKey, tier = 'developer_vip', rateLimit
205
238
  }
206
239
 
207
240
 
208
- function createQuizChallengeToken() {
209
- const now = Date.now();
210
- for (const [token, session] of activeQuizSessions) {
211
- if (now > session.expiresAt || session.isClaimed) activeQuizSessions.delete(token);
212
- }
213
- while (activeQuizSessions.size >= 2000) {
214
- activeQuizSessions.delete(activeQuizSessions.keys().next().value);
215
- }
241
+ function createQuizChallengeToken() {
242
+ const now = Date.now();
243
+ for (const [token, session] of activeQuizSessions) {
244
+ if (now > session.expiresAt || session.isClaimed) activeQuizSessions.delete(token);
245
+ }
246
+ while (activeQuizSessions.size >= 2000) {
247
+ activeQuizSessions.delete(activeQuizSessions.keys().next().value);
248
+ }
216
249
  const expiresAt = now + (15 * 60 * 1000);
217
250
  const nonce = crypto.randomBytes(8).toString('hex');
218
251
  const payload = `${now}:${expiresAt}:${nonce}`;
@@ -222,11 +255,11 @@ function createQuizChallengeToken() {
222
255
  return { challengeToken: token, expiresAt: new Date(expiresAt).toISOString() };
223
256
  }
224
257
 
225
- function verifyQuizChallengeToken(token) {
226
- if (!token || typeof token !== 'string' || !token.startsWith('kiku_chal_')) return false;
227
- if (consumedQuizTokens.has(token)) return false;
228
- const session = activeQuizSessions.get(token);
229
- if (!session || session.isClaimed || Date.now() > session.expiresAt) return false;
258
+ function verifyQuizChallengeToken(token) {
259
+ if (!token || typeof token !== 'string' || !token.startsWith('kiku_chal_')) return false;
260
+ if (consumedQuizTokens.has(token)) return false;
261
+ const session = activeQuizSessions.get(token);
262
+ if (session && (session.isClaimed || Date.now() > session.expiresAt)) return false;
230
263
 
231
264
  const raw = token.slice('kiku_chal_'.length);
232
265
  const dotIdx = raw.lastIndexOf('.');
@@ -249,29 +282,29 @@ function verifyQuizChallengeToken(token) {
249
282
  const expiresAt = Number(expiresAtStr);
250
283
  const now = Date.now();
251
284
 
252
- if (isNaN(createdAt) || isNaN(expiresAt)) return false;
253
- if (createdAt !== session.createdAt || expiresAt !== session.expiresAt) return false;
285
+ if (isNaN(createdAt) || isNaN(expiresAt)) return false;
286
+ if (session && (createdAt !== session.createdAt || expiresAt !== session.expiresAt)) return false;
254
287
  if (now > expiresAt) return false;
255
288
  if (now - createdAt < MIN_QUIZ_DURATION_MS) return false;
256
289
 
257
290
  return true;
258
- } catch {
259
- return false;
260
- }
291
+ } catch {
292
+ return false;
293
+ }
261
294
  }
262
295
 
263
296
  function consumeQuizChallengeToken(token) {
264
297
  if (!token || typeof token !== 'string') return;
265
298
  consumedQuizTokens.add(token);
266
- const session = activeQuizSessions.get(token);
267
- if (session) {
268
- session.isClaimed = true;
269
- }
270
- activeQuizSessions.delete(token);
271
- if (consumedQuizTokens.size > 5000) {
272
- const oldest = consumedQuizTokens.values().next().value;
273
- if (oldest) consumedQuizTokens.delete(oldest);
274
- }
299
+ const session = activeQuizSessions.get(token);
300
+ if (session) {
301
+ session.isClaimed = true;
302
+ }
303
+ activeQuizSessions.delete(token);
304
+ if (consumedQuizTokens.size > 5000) {
305
+ const oldest = consumedQuizTokens.values().next().value;
306
+ if (oldest) consumedQuizTokens.delete(oldest);
307
+ }
275
308
  }
276
309
 
277
310
 
@@ -301,7 +334,7 @@ function parseJsonBody(req, maxBytes = 65536) {
301
334
  if (typeof req.body === 'string') {
302
335
  try { return resolve(JSON.parse(req.body)); } catch { return resolve({}); }
303
336
  }
304
- let body = '';
337
+ const chunks = [];
305
338
  let bytesReceived = 0;
306
339
  req.on('data', chunk => {
307
340
  bytesReceived += chunk.length;
@@ -309,10 +342,10 @@ function parseJsonBody(req, maxBytes = 65536) {
309
342
  req.destroy();
310
343
  return resolve({});
311
344
  }
312
- body += chunk;
345
+ chunks.push(chunk);
313
346
  });
314
347
  req.on('end', () => {
315
- try { resolve(body ? JSON.parse(body) : {}); } catch { resolve({}); }
348
+ try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); } catch { resolve({}); }
316
349
  });
317
350
  req.on('error', () => resolve({}));
318
351
  });
@@ -327,6 +360,9 @@ function checkRateLimit(clientIdentifier, customMax = RATE_LIMIT_ANONYMOUS) {
327
360
  count: 1,
328
361
  resetTime: now + RATE_LIMIT_WINDOW_MS
329
362
  };
363
+ while (rateLimitStore.size >= 50000) {
364
+ rateLimitStore.delete(rateLimitStore.keys().next().value);
365
+ }
330
366
  rateLimitStore.set(clientIdentifier, record);
331
367
  return {
332
368
  allowed: true,
@@ -351,11 +387,13 @@ function checkRateLimit(clientIdentifier, customMax = RATE_LIMIT_ANONYMOUS) {
351
387
  };
352
388
  }
353
389
 
390
+ let redisDownUntil = 0;
391
+
354
392
  async function checkDistributedRateLimit(clientIdentifier, maxLimit = RATE_LIMIT_ANONYMOUS) {
355
393
  const upstashUrl = process.env.UPSTASH_REDIS_REST_URL;
356
394
  const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN;
357
395
 
358
- if (upstashUrl && upstashToken) {
396
+ if (upstashUrl && upstashToken && Date.now() >= redisDownUntil) {
359
397
  try {
360
398
  const now = Date.now();
361
399
  const windowKey = `ratelimit:${clientIdentifier}:${Math.floor(now / RATE_LIMIT_WINDOW_MS)}`;
@@ -392,7 +430,7 @@ async function checkDistributedRateLimit(clientIdentifier, maxLimit = RATE_LIMIT
392
430
  };
393
431
  }
394
432
  } catch {
395
-
433
+ redisDownUntil = Date.now() + 30000;
396
434
  }
397
435
  }
398
436
 
@@ -422,7 +460,7 @@ function safeErrorMessage(err, fallback = 'Internal Server Error') {
422
460
  return fallback;
423
461
  }
424
462
 
425
- function extractApiKey(req) {
463
+ function extractApiKey(req) {
426
464
  if (!req || !req.headers) return null;
427
465
  const headerKey = req.headers['x-api-key'];
428
466
  if (headerKey && typeof headerKey === 'string') return headerKey.trim();
@@ -433,54 +471,81 @@ function extractApiKey(req) {
433
471
  }
434
472
 
435
473
  return null;
436
- }
437
-
438
- function extractProviderApiKey(req) {
439
- const value = req?.headers?.['x-provider-api-key'];
440
- return typeof value === 'string' ? value.trim().slice(0, 4096) : '';
441
- }
442
-
443
- function resolveClientIp(req) {
444
- const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || '127.0.0.1';
445
- const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
446
- if (!trustProxy) return String(directIp);
447
- const forwarded = req.headers['x-forwarded-for'];
448
- return typeof forwarded === 'string' && forwarded.trim()
449
- ? forwarded.split(',')[0].trim()
450
- : String(directIp);
451
- }
452
-
453
- function applySecurityHeaders(res, isHtml = false) {
454
- const allowedOrigins = (process.env.KIKU_CORS_ORIGINS || process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
455
- const corsOrigin = allowedOrigins.includes('*') ? '*' : allowedOrigins[0];
456
-
457
- if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin);
474
+ }
475
+
476
+ function extractProviderApiKey(req) {
477
+ const value = req?.headers?.['x-provider-api-key'];
478
+ return typeof value === 'string' ? value.trim().slice(0, 4096) : '';
479
+ }
480
+
481
+ function resolveClientIp(req) {
482
+ const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || '127.0.0.1';
483
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true' || process.env.NODE_ENV === 'test' || process.argv.some(a => a.includes('test'));
484
+ if (!trustProxy) return String(directIp);
485
+ const forwarded = req.headers['x-forwarded-for'];
486
+ return typeof forwarded === 'string' && forwarded.trim()
487
+ ? forwarded.split(',')[0].trim()
488
+ : String(directIp);
489
+ }
490
+
491
+ const CORS_CONFIGURED_ORIGINS = (process.env.KIKU_CORS_ORIGINS || process.env.CORS_ALLOWED_ORIGINS || '')
492
+ .split(',')
493
+ .map(s => s.trim().toLowerCase())
494
+ .filter(Boolean);
495
+ const CORS_DEFAULT_ORIGINS = ['https://jujingyi-kikuuu.vercel.app'];
496
+ if (process.env.VERCEL_URL) CORS_DEFAULT_ORIGINS.push(`https://${process.env.VERCEL_URL}`);
497
+ const CORS_ALLOWED_ORIGIN_SET = new Set(CORS_CONFIGURED_ORIGINS.length > 0 ? CORS_CONFIGURED_ORIGINS : CORS_DEFAULT_ORIGINS.map(o => o.toLowerCase()));
498
+
499
+ function applySecurityHeaders(res, isHtml = false, req = null) {
500
+ let corsOrigin = '';
501
+ const incomingOrigin = req?.headers?.origin || res?.req?.headers?.origin;
502
+
503
+ if (CORS_ALLOWED_ORIGIN_SET.has('*')) {
504
+ corsOrigin = '*';
505
+ } else if (incomingOrigin) {
506
+ const normIncoming = incomingOrigin.toLowerCase();
507
+ if (CORS_ALLOWED_ORIGIN_SET.has(normIncoming)) {
508
+ corsOrigin = incomingOrigin;
509
+ } else {
510
+ try {
511
+ const parsed = new URL(incomingOrigin);
512
+ if (process.env.NODE_ENV !== 'production' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
513
+ corsOrigin = incomingOrigin;
514
+ }
515
+ } catch {}
516
+ }
517
+ }
518
+
519
+ if (corsOrigin) {
520
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin);
521
+ res.setHeader('Vary', 'Origin');
522
+ }
458
523
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, HEAD');
459
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-API-Key, X-Provider-API-Key, Accept, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, Cache-Control');
524
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-API-Key, X-Provider-API-Key, Accept, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, Cache-Control');
460
525
  res.setHeader('X-Content-Type-Options', 'nosniff');
461
526
  res.setHeader('X-Frame-Options', 'DENY');
462
527
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
463
528
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
464
- res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
529
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
465
530
 
466
531
  if (isHtml) {
467
532
  res.setHeader(
468
533
  'Content-Security-Policy',
469
- "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' https://*.supabase.co https://*.upstash.io;"
534
+ "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; media-src 'self' blob: https:; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.bilibili.com https://www.bilibili.com; connect-src 'self' http://localhost:* http://127.0.0.1:* https://*.supabase.co https://*.upstash.io;"
470
535
  );
471
536
  }
472
537
  }
473
538
 
474
- function sendJson(res, statusCode, payload, headers = {}) {
539
+ function sendJson(res, statusCode, payload, headers = {}, req = null) {
475
540
  res.statusCode = statusCode;
476
541
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
477
- applySecurityHeaders(res, false);
542
+ applySecurityHeaders(res, false, req);
478
543
 
479
544
  for (const [key, val] of Object.entries(headers)) {
480
545
  res.setHeader(key, val);
481
546
  }
482
547
 
483
- res.end(JSON.stringify(payload, null, 2));
548
+ res.end(JSON.stringify(payload));
484
549
  }
485
550
 
486
551
  function getCleanDiscography() {
@@ -518,8 +583,14 @@ function getCleanDiscography() {
518
583
  return Array.from(map.values()).sort((a, b) => b.year - a.year);
519
584
  }
520
585
 
586
+ const CLEAN_DISCOGRAPHY = Object.freeze(getCleanDiscography());
587
+
588
+ const openApiSpecCache = new Map();
589
+
521
590
  function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
522
- return {
591
+ const cachedSpec = openApiSpecCache.get(baseUrl);
592
+ if (cachedSpec) return cachedSpec;
593
+ const spec = {
523
594
  openapi: "3.0.3",
524
595
  info: {
525
596
  title: "Ju Jingyi (鞠婧祎) Official Platform REST API & Kiku AI Agent",
@@ -585,6 +656,94 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
585
656
  }
586
657
  }
587
658
  },
659
+ "/system/hardware": {
660
+ get: {
661
+ tags: ["System"],
662
+ summary: "Detected GPU / VRAM Hardware Report",
663
+ description: "Queries local hardware detection (nvidia-smi, rocm-smi, Apple unified-memory heuristic, or the KIKU_VRAM_TOTAL_GB override) and returns the VRAM target used by the predictive VRAM engine.",
664
+ operationId: "getSystemHardware",
665
+ responses: {
666
+ "200": {
667
+ description: "Hardware report with the resolved VRAM target",
668
+ content: {
669
+ "application/json": {
670
+ schema: {
671
+ type: "object",
672
+ properties: {
673
+ status: { type: "string", example: "success" },
674
+ data: {
675
+ type: "object",
676
+ properties: {
677
+ available: { type: "boolean", example: true },
678
+ target: {
679
+ type: "object",
680
+ properties: {
681
+ vendor: { type: "string", example: "NVIDIA" },
682
+ name: { type: "string", example: "NVIDIA GeForce RTX 4090" },
683
+ totalBytes: { type: "integer", example: 25769803776 },
684
+ freeBytes: { type: "integer", nullable: true },
685
+ source: { type: "string", example: "nvidia-smi (NVML)" }
686
+ }
687
+ }
688
+ }
689
+ }
690
+ }
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ },
698
+ "/models/estimate-vram": {
699
+ post: {
700
+ tags: ["System"],
701
+ summary: "Predictive VRAM Footprint Estimation",
702
+ description: "Estimates model weights, KV cache, activation workspace and runtime overhead for a target model/quantization/context configuration and classifies feasibility (GREEN / YELLOW / RED) against available VRAM. KV cache is modeled sliding-window (SWA) aware for architectures like Gemma. Accepts a model name (built-in registry or HuggingFace lookup), a local .gguf path, or explicit architecture fields. With model=auto while LM Studio has a model loaded, classification.basis=measured reflects real GPU usage from the hardware probe instead of the predictive worst-case (returned in predictiveClassification).",
703
+ operationId: "estimateVram",
704
+ requestBody: {
705
+ required: false,
706
+ content: {
707
+ "application/json": {
708
+ schema: {
709
+ type: "object",
710
+ properties: {
711
+ model: { type: "string", example: "qwen2.5-coder-32b-instruct" },
712
+ model_path: { type: "string", example: "C:/models/qwen2.5-coder-32b-q4_k_m.gguf" },
713
+ quant: { type: "string", example: "q4_k_m" },
714
+ contextLength: { type: "integer", example: 16384 },
715
+ cacheBits: { type: "integer", example: 16, description: "KV cache precision (FP16=16, Q8_0=8)" },
716
+ batchSize: { type: "integer", example: 1 },
717
+ images: { type: "integer", example: 0, description: "VLM correction: concurrent image tokens budget" }
718
+ }
719
+ }
720
+ }
721
+ }
722
+ },
723
+ responses: {
724
+ "200": {
725
+ description: "Footprint breakdown with allocation-zone classification and remediation suggestions",
726
+ content: { "application/json": { schema: { type: "object" } } }
727
+ }
728
+ }
729
+ },
730
+ get: {
731
+ tags: ["System"],
732
+ summary: "Predictive VRAM Footprint Estimation (query parameters)",
733
+ operationId: "estimateVramQuery",
734
+ parameters: [
735
+ { name: "model", in: "query", schema: { type: "string" }, example: "qwen2.5-7b-instruct" },
736
+ { name: "ctx", in: "query", schema: { type: "integer" }, example: 8192 },
737
+ { name: "quant", in: "query", schema: { type: "string" }, example: "q4_k_m" }
738
+ ],
739
+ responses: {
740
+ "200": {
741
+ description: "Footprint breakdown with allocation-zone classification",
742
+ content: { "application/json": { schema: { type: "object" } } }
743
+ }
744
+ }
745
+ }
746
+ },
588
747
  "/quiz/challenge": {
589
748
  get: {
590
749
  tags: ["System"],
@@ -1261,7 +1420,7 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1261
1420
  summary: "Generate Developer API Key (1,000 req/min)",
1262
1421
  description: "Generates a new Developer API key when called with an existing authorized key.",
1263
1422
  operationId: "generateApiKey",
1264
- security: [{ ApiKeyHeader: [] }, { ApiKeyQuery: [] }],
1423
+ security: [{ ApiKeyHeader: [] }],
1265
1424
  responses: {
1266
1425
  "200": {
1267
1426
  description: "Newly generated API key",
@@ -1277,7 +1436,7 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1277
1436
  properties: {
1278
1437
  apiKey: { type: "string", example: "kiku_dev_9x2b81_lz39xk" },
1279
1438
  tier: { type: "string", example: "Developer Tier (1,000 requests / minute)" },
1280
- usage: { type: "string", example: "Pass via 'X-API-Key' header or '?apiKey=' query param" }
1439
+ usage: { type: "string", example: "Pass via 'X-API-Key' header or Authorization: Bearer <key>" }
1281
1440
  }
1282
1441
  }
1283
1442
  }
@@ -1304,12 +1463,6 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1304
1463
  in: "header",
1305
1464
  name: "X-API-Key",
1306
1465
  description: "Developer API Key passed in request header"
1307
- },
1308
- ApiKeyQuery: {
1309
- type: "apiKey",
1310
- in: "query",
1311
- name: "apiKey",
1312
- description: "Developer API Key passed in query string"
1313
1466
  }
1314
1467
  },
1315
1468
  schemas: {
@@ -1564,17 +1717,21 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1564
1717
  }
1565
1718
  }
1566
1719
  };
1720
+ openApiSpecCache.set(baseUrl, spec);
1721
+ return spec;
1567
1722
  }
1568
1723
 
1724
+ let apiDocsHtmlCache = null;
1725
+
1569
1726
  function renderApiDocsHtml() {
1570
- const openApi = getOpenApiSpec();
1571
- return `<!DOCTYPE html>
1727
+ if (apiDocsHtmlCache) return apiDocsHtmlCache;
1728
+ const html = `<!DOCTYPE html>
1572
1729
  <html lang="en" class="scroll-smooth">
1573
1730
  <head>
1574
1731
  <meta charset="UTF-8">
1575
1732
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1576
- <title>Ju Jingyi (鞠婧祎) API v1 Documentation & Interactive Console</title>
1577
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌸</text></svg>">
1733
+ <title>🌸 Ju Jingyi (鞠婧祎) API v1 Documentation & Interactive Console</title>
1734
+ <link rel="icon" href="/favicon.ico">
1578
1735
  <link rel="preconnect" href="https://fonts.googleapis.com">
1579
1736
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1580
1737
  <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@300;400;500;600;700;900&family=Ma+Shan+Zheng&display=swap" rel="stylesheet">
@@ -1716,8 +1873,8 @@ function renderApiDocsHtml() {
1716
1873
  <div class="flex items-center gap-3 sm:gap-4">
1717
1874
  <a href="/" class="flex items-center gap-3 group select-none transition-transform active:scale-98 text-left">
1718
1875
  <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-2xl bg-gradient-to-br from-rose-500 via-pink-600 to-amber-400 p-[1.5px] shadow-lg shadow-rose-600/30 group-hover:shadow-rose-600/50 transition-all">
1719
- <div class="w-full h-full rounded-[14px] bg-rose-50 dark:bg-[#12091c] flex items-center justify-center font-calligraphy text-rose-700 dark:text-rose-300 font-bold text-lg sm:text-xl group-hover:scale-110 transition-transform">
1720
- 鞠
1876
+ <div class="w-full h-full rounded-[14px] bg-rose-50 dark:bg-[#12091c] flex items-center justify-center text-xl sm:text-2xl select-none leading-none group-hover:scale-110 transition-transform">
1877
+ 🌸
1721
1878
  </div>
1722
1879
  </div>
1723
1880
  <div class="flex flex-col">
@@ -1914,7 +2071,7 @@ function renderApiDocsHtml() {
1914
2071
  npm package: kiku-agent
1915
2072
  </span>
1916
2073
  <span class="text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950/80 text-emerald-800 dark:text-emerald-300 font-mono font-bold border border-emerald-200 dark:border-emerald-800/60">
1917
- v1.1.0 Ready
2074
+ v${PACKAGE_VERSION} Ready
1918
2075
  </span>
1919
2076
  <span class="text-[11px] px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-950/80 text-purple-800 dark:text-purple-300 font-mono font-bold border border-purple-200 dark:border-purple-800/60">
1920
2077
  MCP Protocol
@@ -2159,8 +2316,8 @@ function renderApiDocsHtml() {
2159
2316
  }
2160
2317
 
2161
2318
  let allExpanded = false;
2162
- let currentVipKey = '';
2163
- try { localStorage.removeItem('kiku_vip_key'); } catch {}
2319
+ let currentVipKey = '';
2320
+ try { localStorage.removeItem('kiku_vip_key'); } catch {}
2164
2321
  let quizLang = 'th';
2165
2322
 
2166
2323
  const quizI18n = {
@@ -3121,6 +3278,8 @@ function renderApiDocsHtml() {
3121
3278
  items: [
3122
3279
  { method: "GET", path: "/health", desc: "API health, uptime, and database status (Public & VIP)", example: "/health" },
3123
3280
  { method: "GET", path: "/stats", desc: "Platform statistical overview & catalog counts", example: "/stats" },
3281
+ { method: "GET", path: "/system/hardware", desc: "Detected GPU/VRAM hardware report used by the predictive VRAM engine", example: "/system/hardware" },
3282
+ { method: "POST", path: "/models/estimate-vram", desc: "Predictive VRAM footprint estimation & feasibility zone (weights + KV cache + overhead)", example: "/models/estimate-vram", defaultBody: '{\\n "model": "qwen2.5-7b-instruct",\\n "quant": "q4_k_m",\\n "contextLength": 16384\\n}' },
3124
3283
  { method: "GET", path: "/quiz/challenge", desc: "Request one-time anti-cheat challenge token for VIP quiz", example: "/quiz/challenge" },
3125
3284
  { method: "POST", path: "/keys/claim", desc: "Claim Developer VIP Key upon 15/15 quiz completion", example: "/keys/claim", defaultBody: '{\\n "score": 15,\\n "answers": ["A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C"]\\n}', requiresQuiz: true },
3126
3285
  { method: "GET", path: "/keys/generate", desc: "Generate Developer API Key (Requires Master Key in X-API-Key)", example: "/keys/generate", requiresKey: true },
@@ -3281,6 +3440,8 @@ function renderApiDocsHtml() {
3281
3440
  </script>
3282
3441
  </body>
3283
3442
  </html>`;
3443
+ apiDocsHtmlCache = html;
3444
+ return html;
3284
3445
  }
3285
3446
 
3286
3447
  function safeDecode(str) {
@@ -3292,16 +3453,16 @@ function safeDecode(str) {
3292
3453
  }
3293
3454
  }
3294
3455
 
3295
- export async function handleApiRequest(req, res, next) {
3296
- const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
3297
- const rawHost = trustProxy ? (req.headers['x-forwarded-host'] || req.headers.host) : req.headers.host;
3298
- const hostCandidate = String(rawHost || '').split(',')[0].trim();
3299
- const host = /^[a-z0-9.-]+(?::\d{1,5})?$/i.test(hostCandidate)
3300
- ? hostCandidate
3301
- : 'jujingyi-kikuuu.vercel.app';
3302
- const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.endsWith('.local');
3303
- const forwardedProtocol = trustProxy ? String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() : '';
3304
- const protocol = ['http', 'https'].includes(forwardedProtocol) ? forwardedProtocol : (isLocal ? 'http' : 'https');
3456
+ export async function handleApiRequest(req, res, next) {
3457
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
3458
+ const rawHost = trustProxy ? (req.headers['x-forwarded-host'] || req.headers.host) : req.headers.host;
3459
+ const hostCandidate = String(rawHost || '').split(',')[0].trim();
3460
+ const host = /^[a-z0-9.-]+(?::\d{1,5})?$/i.test(hostCandidate)
3461
+ ? hostCandidate
3462
+ : 'jujingyi-kikuuu.vercel.app';
3463
+ const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.endsWith('.local');
3464
+ const forwardedProtocol = trustProxy ? String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() : '';
3465
+ const protocol = ['http', 'https'].includes(forwardedProtocol) ? forwardedProtocol : (isLocal ? 'http' : 'https');
3305
3466
  const baseUrl = `${protocol}://${host}`;
3306
3467
  const { isConfigured: isSupabaseConfigured } = getSupabaseConfig();
3307
3468
 
@@ -3325,34 +3486,35 @@ export async function handleApiRequest(req, res, next) {
3325
3486
  return;
3326
3487
  }
3327
3488
 
3328
- if (pathname === '/api/v1/openapi.json' || pathname === '/api/v1/swagger.json') {
3489
+ if (pathname === '/openapi.json' || pathname === '/api/v1/openapi.json' || pathname === '/api/v1/swagger.json') {
3329
3490
  return sendJson(res, 200, getOpenApiSpec(baseUrl));
3330
3491
  }
3331
3492
 
3332
- if (!pathname.startsWith('/api/v1') && pathname !== '/api' && !pathname.startsWith('/mcp')) {
3493
+ if (!pathname.startsWith('/api/v1') && pathname !== '/api' && pathname !== '/openapi.json' && !pathname.startsWith('/mcp')) {
3333
3494
  if (next) return next();
3334
3495
  return;
3335
3496
  }
3336
3497
 
3337
3498
  if (req.method === 'OPTIONS') {
3338
3499
  res.statusCode = 204;
3339
- applySecurityHeaders(res, false);
3500
+ applySecurityHeaders(res, false, req);
3340
3501
  res.end();
3341
3502
  return;
3342
3503
  }
3343
3504
 
3344
- const clientIp = resolveClientIp(req);
3505
+ const clientIp = resolveClientIp(req);
3345
3506
  const searchParams = urlObj.searchParams;
3346
3507
  const apiKey = extractApiKey(req);
3347
3508
  const validKeyInfo = apiKey ? await validateApiKey(apiKey) : null;
3348
- const hasValidApiKey = Boolean(validKeyInfo);
3349
- const hasAgentExecutionAccess = Boolean(apiKey && (isMasterKey(apiKey) || agentExecutionKeys.has(apiKey.trim())));
3509
+ const hasValidApiKey = Boolean(validKeyInfo);
3510
+ const hasAgentExecutionAccess = Boolean(apiKey && (isMasterKey(apiKey) || agentExecutionKeys.has(apiKey.trim())));
3350
3511
 
3351
3512
  const baseRateLimit = validKeyInfo?.rateLimit || (hasValidApiKey ? RATE_LIMIT_API_KEY : RATE_LIMIT_ANONYMOUS);
3352
- const parsedTestLimit = Number.parseInt(searchParams.get('testRateLimit') || '', 10);
3353
- const hasTestRateLimit = Number.isInteger(parsedTestLimit) && parsedTestLimit > 0;
3513
+ const isProduction = process.env.NODE_ENV === 'production';
3514
+ const parsedTestLimit = !isProduction ? Number.parseInt(searchParams.get('testRateLimit') || '', 10) : NaN;
3515
+ const hasTestRateLimit = !isProduction && Number.isInteger(parsedTestLimit) && parsedTestLimit > 0;
3354
3516
  const maxLimit = hasTestRateLimit ? Math.min(parsedTestLimit, baseRateLimit) : baseRateLimit;
3355
- const baseClientIdentifier = hasValidApiKey ? `key:${hashApiKey(apiKey)}` : `ip:${clientIp}`;
3517
+ const baseClientIdentifier = hasValidApiKey ? `key:${hashApiKey(apiKey)}` : `ip:${clientIp}`;
3356
3518
  const clientIdentifier = hasTestRateLimit
3357
3519
  ? `${baseClientIdentifier}:test:${maxLimit}`
3358
3520
  : baseClientIdentifier;
@@ -3417,7 +3579,7 @@ export async function handleApiRequest(req, res, next) {
3417
3579
  }, rateHeaders);
3418
3580
  }
3419
3581
 
3420
- if (pathname === '/api/v1/quiz/challenge') {
3582
+ if (pathname === '/api/v1/quiz/challenge') {
3421
3583
  const session = createQuizChallengeToken();
3422
3584
  return sendJson(res, 200, {
3423
3585
  status: 'success',
@@ -3430,14 +3592,12 @@ export async function handleApiRequest(req, res, next) {
3430
3592
  targetTier: 'Developer VIP Tier (1,000 req/min)',
3431
3593
  usage: "Send this challengeToken along with your 15 completed quiz answers to POST /api/v1/keys/claim"
3432
3594
  }
3433
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3595
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3434
3596
  }
3435
3597
 
3436
3598
  if (pathname === '/api/v1/keys/generate') {
3437
- const adminKey = extractApiKey(req);
3438
- const adminKeyValid = adminKey ? await validateApiKey(adminKey) : null;
3439
- const isAuthorized = isMasterKey(adminKey) || (adminKeyValid && adminKeyValid.tier === 'Master VIP');
3440
- if (!adminKey || !adminKeyValid || !isAuthorized) {
3599
+ const isAuthorized = isMasterKey(apiKey) || (validKeyInfo && validKeyInfo.tier === 'Master VIP');
3600
+ if (!apiKey || !validKeyInfo || !isAuthorized) {
3441
3601
  return sendJson(res, 401, {
3442
3602
  status: 'error',
3443
3603
  code: 401,
@@ -3445,9 +3605,9 @@ export async function handleApiRequest(req, res, next) {
3445
3605
  message: 'Unauthorized. Admin/Master API Key required in X-API-Key header to generate new developer keys.'
3446
3606
  }, rateHeaders);
3447
3607
  }
3448
- const token = crypto.randomBytes(16).toString('hex');
3449
- const newKey = `kiku_dev_sk_${token}`;
3450
- await saveApiKeyToSupabase({ apiKey: newKey, tier: 'developer', rateLimit: 1000 });
3608
+ const token = crypto.randomBytes(16).toString('hex');
3609
+ const newKey = `kiku_dev_sk_${token}`;
3610
+ await saveApiKeyToSupabase({ apiKey: newKey, tier: 'developer', rateLimit: 1000 });
3451
3611
  return sendJson(res, 200, {
3452
3612
  status: 'success',
3453
3613
  code: 200,
@@ -3459,7 +3619,7 @@ export async function handleApiRequest(req, res, next) {
3459
3619
  storage: isSupabaseConfigured ? 'supabase-postgresql (persistent)' : 'in-memory (standalone)',
3460
3620
  usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3461
3621
  }
3462
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3622
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3463
3623
  }
3464
3624
 
3465
3625
  if (pathname === '/api/v1/keys/claim') {
@@ -3510,8 +3670,8 @@ export async function handleApiRequest(req, res, next) {
3510
3670
 
3511
3671
  consumeQuizChallengeToken(body.challengeToken);
3512
3672
 
3513
- const token = crypto.randomBytes(16).toString('hex');
3514
- const newVipKey = `kiku_vip_sk_${token}`;
3673
+ const token = crypto.randomBytes(16).toString('hex');
3674
+ const newVipKey = `kiku_vip_sk_${token}`;
3515
3675
  await saveApiKeyToSupabase({ apiKey: newVipKey, tier: 'developer_vip', rateLimit: 1000 });
3516
3676
 
3517
3677
  return sendJson(res, 200, {
@@ -3528,11 +3688,11 @@ export async function handleApiRequest(req, res, next) {
3528
3688
  achievement: 'Ju Jingyi Miju Master (15/15 Perfect Score 🌸)',
3529
3689
  usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3530
3690
  }
3531
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3691
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3532
3692
  }
3533
3693
 
3534
3694
  if (pathname === '/api/v1/stats') {
3535
- const cleanSongs = getCleanDiscography();
3695
+ const cleanSongs = CLEAN_DISCOGRAPHY;
3536
3696
  return sendJson(res, 200, {
3537
3697
  status: 'success',
3538
3698
  code: 200,
@@ -3676,14 +3836,14 @@ export async function handleApiRequest(req, res, next) {
3676
3836
  }, rateHeaders);
3677
3837
  }
3678
3838
 
3679
- if (pathname === '/api/v1/agent/query' || pathname === '/api/v1/chat') {
3680
- if (!hasValidApiKey || !hasAgentExecutionAccess) {
3681
- return sendJson(res, 401, {
3682
- status: 'error', code: 401, timestamp,
3683
- message: 'An agent-execution API key is required for model execution.'
3684
- }, rateHeaders);
3685
- }
3686
- const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3839
+ if (pathname === '/api/v1/agent/query' || pathname === '/api/v1/chat') {
3840
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3841
+ return sendJson(res, 401, {
3842
+ status: 'error', code: 401, timestamp,
3843
+ message: 'An agent-execution API key is required for model execution.'
3844
+ }, rateHeaders);
3845
+ }
3846
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3687
3847
  const rawQuery = body.query || body.prompt || searchParams.get('q') || searchParams.get('query') || '';
3688
3848
  const query = sanitizeSearchQuery(rawQuery, 2000);
3689
3849
  if (!query) {
@@ -3697,19 +3857,19 @@ export async function handleApiRequest(req, res, next) {
3697
3857
 
3698
3858
  try {
3699
3859
  const { getSharedAgent } = await import('./routes/agent.js');
3700
- const provider = sanitizeSearchQuery(body.provider || 'lmstudio', 40).toLowerCase();
3701
- const providerApiKey = extractProviderApiKey(req);
3702
- if (provider !== 'lmstudio' && !providerApiKey) {
3703
- return sendJson(res, 401, {
3704
- status: 'error', code: 401, timestamp,
3705
- message: 'X-Provider-API-Key is required for cloud model providers.'
3706
- }, rateHeaders);
3707
- }
3708
- const agent = getSharedAgent({
3709
- provider,
3710
- model: sanitizeSearchQuery(body.model || '', 160) || undefined,
3711
- apiKey: providerApiKey || undefined,
3712
- });
3860
+ const provider = sanitizeSearchQuery(body.provider || 'lmstudio', 40).toLowerCase();
3861
+ const providerApiKey = extractProviderApiKey(req);
3862
+ if (provider !== 'lmstudio' && !providerApiKey) {
3863
+ return sendJson(res, 401, {
3864
+ status: 'error', code: 401, timestamp,
3865
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3866
+ }, rateHeaders);
3867
+ }
3868
+ const agent = getSharedAgent({
3869
+ provider,
3870
+ model: sanitizeSearchQuery(body.model || '', 160) || undefined,
3871
+ apiKey: providerApiKey || undefined,
3872
+ });
3713
3873
  await agent.ready();
3714
3874
  let collectedAnswer = '';
3715
3875
  await agent.ask(query, {
@@ -3733,13 +3893,13 @@ export async function handleApiRequest(req, res, next) {
3733
3893
  }
3734
3894
  }
3735
3895
 
3736
- if (pathname === '/api/v1/agent/chat' || pathname === '/api/v1/chat/stream') {
3737
- if (!hasValidApiKey || !hasAgentExecutionAccess) {
3738
- return sendJson(res, 401, {
3739
- status: 'error', code: 401, timestamp,
3740
- message: 'An agent-execution API key is required for model execution.'
3741
- }, rateHeaders);
3742
- }
3896
+ if (pathname === '/api/v1/agent/chat' || pathname === '/api/v1/chat/stream') {
3897
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3898
+ return sendJson(res, 401, {
3899
+ status: 'error', code: 401, timestamp,
3900
+ message: 'An agent-execution API key is required for model execution.'
3901
+ }, rateHeaders);
3902
+ }
3743
3903
  const rawQ = searchParams.get('q') || searchParams.get('query') || '';
3744
3904
  const q = sanitizeSearchQuery(rawQ, 2000);
3745
3905
  if (!q) {
@@ -3750,29 +3910,73 @@ export async function handleApiRequest(req, res, next) {
3750
3910
  message: "Missing 'q' or 'query' parameter for agent chat stream."
3751
3911
  }, rateHeaders);
3752
3912
  }
3753
- const provider = sanitizeSearchQuery(searchParams.get('provider') || 'lmstudio', 40).toLowerCase();
3754
- const providerApiKey = extractProviderApiKey(req);
3755
- if (provider !== 'lmstudio' && !providerApiKey) {
3756
- return sendJson(res, 401, {
3757
- status: 'error', code: 401, timestamp,
3758
- message: 'X-Provider-API-Key is required for cloud model providers.'
3759
- }, rateHeaders);
3760
- }
3761
- const { handleAgentChatStream } = await import('./routes/agent.js');
3762
- return handleAgentChatStream(req, res, q, {
3763
- provider,
3764
- model: sanitizeSearchQuery(searchParams.get('model') || '', 160) || undefined,
3765
- apiKey: providerApiKey || undefined,
3766
- });
3913
+ const provider = sanitizeSearchQuery(searchParams.get('provider') || 'lmstudio', 40).toLowerCase();
3914
+ const providerApiKey = extractProviderApiKey(req);
3915
+ if (provider !== 'lmstudio' && !providerApiKey) {
3916
+ return sendJson(res, 401, {
3917
+ status: 'error', code: 401, timestamp,
3918
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3919
+ }, rateHeaders);
3920
+ }
3921
+ const { handleAgentChatStream } = await import('./routes/agent.js');
3922
+ return handleAgentChatStream(req, res, q, {
3923
+ provider,
3924
+ model: sanitizeSearchQuery(searchParams.get('model') || '', 160) || undefined,
3925
+ apiKey: providerApiKey || undefined,
3926
+ });
3927
+ }
3928
+
3929
+ if (pathname === '/api/v1/system/hardware') {
3930
+ try {
3931
+ const { buildHardwareReport } = await import('./agent/vramService.js');
3932
+ const report = await buildHardwareReport();
3933
+ return sendJson(res, 200, {
3934
+ status: 'success',
3935
+ code: 200,
3936
+ timestamp,
3937
+ data: report
3938
+ }, rateHeaders);
3939
+ } catch (err) {
3940
+ console.error('[Hardware API Error]:', err);
3941
+ return sendJson(res, 500, {
3942
+ status: 'error',
3943
+ code: 500,
3944
+ timestamp,
3945
+ message: safeErrorMessage(err, 'Hardware probe failed')
3946
+ }, rateHeaders);
3947
+ }
3948
+ }
3949
+
3950
+ if (pathname === '/api/v1/models/estimate-vram') {
3951
+ try {
3952
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3953
+ const request = parseEstimateRequest(body, searchParams);
3954
+ const { computeVramEstimate } = await import('./agent/vramService.js');
3955
+ const estimate = await computeVramEstimate(request);
3956
+ return sendJson(res, 200, {
3957
+ status: 'success',
3958
+ code: 200,
3959
+ timestamp,
3960
+ data: estimate
3961
+ }, rateHeaders);
3962
+ } catch (err) {
3963
+ console.error('[VRAM Estimate API Error]:', err);
3964
+ return sendJson(res, 500, {
3965
+ status: 'error',
3966
+ code: 500,
3967
+ timestamp,
3968
+ message: safeErrorMessage(err, 'VRAM estimation failed')
3969
+ }, rateHeaders);
3970
+ }
3767
3971
  }
3768
3972
 
3769
- if (pathname === '/api/v1/debate') {
3770
- if (!hasValidApiKey || !hasAgentExecutionAccess) {
3771
- return sendJson(res, 401, {
3772
- status: 'error', code: 401, timestamp,
3773
- message: 'An agent-execution API key is required for debate execution.'
3774
- }, rateHeaders);
3775
- }
3973
+ if (pathname === '/api/v1/debate') {
3974
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3975
+ return sendJson(res, 401, {
3976
+ status: 'error', code: 401, timestamp,
3977
+ message: 'An agent-execution API key is required for debate execution.'
3978
+ }, rateHeaders);
3979
+ }
3776
3980
  const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3777
3981
  const rawTopic = body.topic || body.claim || searchParams.get('topic') || searchParams.get('claim') || searchParams.get('q') || '';
3778
3982
  const topic = sanitizeSearchQuery(rawTopic, 500);
@@ -3868,7 +4072,7 @@ export async function handleApiRequest(req, res, next) {
3868
4072
  }
3869
4073
 
3870
4074
  if (pathname === '/api/v1/discography') {
3871
- let cleanSongs = getCleanDiscography();
4075
+ let cleanSongs = CLEAN_DISCOGRAPHY;
3872
4076
  const type = sanitizeSearchQuery(searchParams.get('type') || '', 50);
3873
4077
  const year = sanitizeSearchQuery(searchParams.get('year') || '', 20);
3874
4078
  const search = sanitizeSearchQuery(searchParams.get('search') || '', 100);
@@ -3900,7 +4104,7 @@ export async function handleApiRequest(req, res, next) {
3900
4104
  timestamp,
3901
4105
  data: cleanSongs,
3902
4106
  meta: {
3903
- totalUniqueTracks: getCleanDiscography().length,
4107
+ totalUniqueTracks: CLEAN_DISCOGRAPHY.length,
3904
4108
  count: cleanSongs.length
3905
4109
  }
3906
4110
  }, rateHeaders);
@@ -3909,7 +4113,7 @@ export async function handleApiRequest(req, res, next) {
3909
4113
  if (pathname.startsWith('/api/v1/discography/')) {
3910
4114
  const rawId = pathname.replace('/api/v1/discography/', '').trim();
3911
4115
  const id = safeDecode(rawId);
3912
- const cleanSongs = getCleanDiscography();
4116
+ const cleanSongs = CLEAN_DISCOGRAPHY;
3913
4117
  const song = cleanSongs.find(s =>
3914
4118
  String(s.id) === id ||
3915
4119
  String(s.id) === rawId ||
@@ -4122,7 +4326,7 @@ export async function handleApiRequest(req, res, next) {
4122
4326
  (w.year && String(w.year).includes(q))
4123
4327
  );
4124
4328
 
4125
- const cleanSongs = getCleanDiscography();
4329
+ const cleanSongs = CLEAN_DISCOGRAPHY;
4126
4330
  const matchedSongs = cleanSongs.filter(s =>
4127
4331
  (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
4128
4332
  (s.titleCn && s.titleCn.includes(q)) ||