kiku-agent 1.2.0 → 1.3.1

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 (131) hide show
  1. package/README.md +459 -407
  2. package/package.json +32 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +637 -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 +532 -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/ink/App.jsx +348 -0
  23. package/server/agent/ink/Panel.jsx +54 -0
  24. package/server/agent/ink/PromptInput.jsx +321 -0
  25. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  26. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  27. package/server/agent/ink/ToolResult.jsx +133 -0
  28. package/server/agent/ink/main.jsx +195 -0
  29. package/server/agent/ink/useEventBus.js +218 -0
  30. package/server/agent/lanDiscovery.js +369 -0
  31. package/server/agent/llmClient.js +111 -39
  32. package/server/agent/lmStudioWakeup.js +144 -0
  33. package/server/agent/mcpClient.js +2 -2
  34. package/server/agent/memory/contextAllocator.js +214 -0
  35. package/server/agent/memory/hierarchicalStore.js +523 -0
  36. package/server/agent/memory/reflectionWorker.js +110 -0
  37. package/server/agent/memory.js +635 -494
  38. package/server/agent/modelConfig.js +487 -0
  39. package/server/agent/newsIntelligence.js +219 -0
  40. package/server/agent/newsWatch.js +85 -0
  41. package/server/agent/prompts.js +146 -0
  42. package/server/agent/publicTools.js +7 -0
  43. package/server/agent/queryDetails.js +62 -1
  44. package/server/agent/research.js +314 -35
  45. package/server/agent/routingInterceptor.js +77 -0
  46. package/server/agent/rumoredProjects.js +15 -0
  47. package/server/agent/sanitizer.js +117 -17
  48. package/server/agent/subagent.js +52 -9
  49. package/server/agent/suggestionTui.js +33 -13
  50. package/server/agent/suggestions.js +29 -13
  51. package/server/agent/telemetry/hardwareTiers.js +332 -0
  52. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  53. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  54. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  55. package/server/agent/telemetry.js +0 -11
  56. package/server/agent/terminalImage.js +0 -1
  57. package/server/agent/textToolProtocol.js +190 -0
  58. package/server/agent/tokenTracker.js +220 -0
  59. package/server/agent/toolRouter.js +362 -216
  60. package/server/agent/tools.js +1155 -194
  61. package/server/agent/ui.js +144 -41
  62. package/server/agent/vramService.js +572 -0
  63. package/server/agent/webSearch.js +2279 -273
  64. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  65. package/server/agent/websearch/adapters/jina.js +77 -0
  66. package/server/agent/websearch/adapters/searxng.js +57 -0
  67. package/server/agent/websearch/core/canonical.js +89 -0
  68. package/server/agent/websearch/core/fusion.js +81 -0
  69. package/server/agent/websearch/core/types.js +29 -0
  70. package/server/agent/websearch/extract.js +605 -0
  71. package/server/agent/websearch/index.js +43 -0
  72. package/server/agent/websearch/orchestrator.js +266 -0
  73. package/server/agent/websearch/resilience/breaker.js +97 -0
  74. package/server/agent/websearch/resilience/cache.js +59 -0
  75. package/server/agent/websearch/resilience/limiter.js +93 -0
  76. package/server/agent/websearch/security/filter.js +36 -0
  77. package/server/agent/websearch/security/ssrf.js +289 -0
  78. package/server/agent/websearch/telemetry.js +71 -0
  79. package/server/agent/youtubeAuth.js +82 -73
  80. package/server/agent/youtubeTranscript.js +209 -0
  81. package/server/apiRouter.js +390 -190
  82. package/server/mcp/data/brands.js +5 -5
  83. package/server/mcp/data/discography.js +56 -45
  84. package/server/mcp/data/gallery.js +9 -0
  85. package/server/mcp/data/index.js +1 -0
  86. package/server/mcp/data/industryIndex.js +251 -0
  87. package/server/mcp/data/profile.js +5 -5
  88. package/server/mcp/data/timeline.js +2 -2
  89. package/server/mcp/data/works.js +2 -2
  90. package/server/mcp/handler.js +0 -2
  91. package/server/mcp/prompts.js +42 -2
  92. package/server/mcp/resources.js +5 -1
  93. package/server/mcp/security.js +25 -14
  94. package/server/mcp/server.js +92 -3
  95. package/server/mcp/tools.js +20 -11
  96. package/server/routes/agent.js +55 -0
  97. package/server/web-chat/core/connection.js +218 -194
  98. package/server/web-chat/core/conversationContext.js +1 -1
  99. package/server/web-chat/core/directCatalogEngine.js +190 -184
  100. package/server/web-chat/core/followUpEngine.js +88 -11
  101. package/server/web-chat/core/security.js +7 -4
  102. package/server/web-chat/router.js +54 -17
  103. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  104. package/server/web-chat/routes/chatRoute.js +488 -397
  105. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  106. package/server/web-chat/routes/exportRoute.js +2 -1
  107. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  108. package/server/web-chat/routes/newsRoute.js +36 -6
  109. package/server/web-chat/routes/providerRoute.js +87 -0
  110. package/server/web-chat/routes/staticRoute.js +61 -13
  111. package/server/web-chat/routes/statusRoute.js +23 -2
  112. package/server/web-chat/routes/vramRoute.js +22 -0
  113. package/server/web-chat/server.js +135 -106
  114. package/server/web-chat/ui/htmlBuilder.js +8 -8
  115. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  116. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  117. package/server/web-chat/ui/scripts/client.js +10526 -8397
  118. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  119. package/server/web-chat/ui/styles/main.css +7471 -4246
  120. package/server/web-chat/ui/templates/body.html +305 -179
  121. package/src/data/brandsData.js +29 -6
  122. package/src/data/discographyData.js +918 -900
  123. package/src/data/galleryData.js +66 -0
  124. package/src/data/industryIndexData.js +292 -0
  125. package/src/data/musicVideoData.js +48 -5
  126. package/src/data/profileData.js +15 -1
  127. package/src/data/timelineData.js +4 -3
  128. package/src/data/worksData.js +536 -535
  129. package/src/lib/vram/estimator.js +464 -0
  130. package/src/lib/vram/modelRegistry.js +387 -0
  131. package/tokens.css +33 -2
@@ -12,6 +12,9 @@ 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';
16
+ import { toolsSchema } from './agent/tools.js';
17
+ import { toolDefinitions as mcpToolDefinitions } from './mcp/tools.js';
15
18
 
16
19
  function loadEnv() {
17
20
  try {
@@ -47,6 +50,14 @@ const rateLimitCleanupInterval = setInterval(() => {
47
50
  rateLimitStore.delete(id);
48
51
  }
49
52
  }
53
+ for (const [hash, entry] of apiKeyCache) {
54
+ if (entry.validUntil < now) {
55
+ apiKeyCache.delete(hash);
56
+ }
57
+ }
58
+ while (apiKeyCache.size >= API_KEY_CACHE_MAX) {
59
+ apiKeyCache.delete(apiKeyCache.keys().next().value);
60
+ }
50
61
  }, 60000);
51
62
  rateLimitCleanupInterval.unref?.();
52
63
 
@@ -63,28 +74,52 @@ const masterApiKeys = new Set([
63
74
  ...(process.env.KIKU_MASTER_KEY ? [process.env.KIKU_MASTER_KEY.trim()] : []),
64
75
  ]);
65
76
 
66
- const issuedApiKeys = new Set([
77
+ const issuedApiKeys = new Set([
67
78
  ...(process.env.KIKU_API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
68
79
  ...(process.env.KIKU_VIP_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
69
80
  ...masterApiKeys
70
- ]);
71
- const agentExecutionKeys = new Set([
72
- ...(process.env.KIKU_AGENT_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
73
- ...masterApiKeys,
74
- ]);
81
+ ]);
82
+ const agentExecutionKeys = new Set([
83
+ ...(process.env.KIKU_AGENT_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
84
+ ...masterApiKeys,
85
+ ]);
75
86
 
76
87
  const apiKeyCache = new Map();
88
+ const API_KEY_CACHE_MAX = 10000;
89
+
90
+ function setApiKeyCache(hash, entry) {
91
+ if (apiKeyCache.size >= API_KEY_CACHE_MAX) {
92
+ const oldestKey = apiKeyCache.keys().next().value;
93
+ if (oldestKey) apiKeyCache.delete(oldestKey);
94
+ }
95
+ apiKeyCache.set(hash, entry);
96
+ }
77
97
  const activeQuizSessions = new Map();
78
98
  const consumedQuizTokens = new Set();
79
99
  const MIN_QUIZ_DURATION_MS = 2500;
80
100
 
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');
101
+ const API_KEY_PEPPER = process.env.API_KEY_PEPPER || process.env.KIKU_KEY_PEPPER || crypto.randomBytes(32).toString('hex');
102
+ const QUIZ_HMAC_SECRET = process.env.QUIZ_SECRET || process.env.KIKU_QUIZ_SECRET || crypto.randomBytes(32).toString('hex');
103
+
104
+ if (process.env.NODE_ENV === 'production') {
105
+ if (!process.env.API_KEY_PEPPER && !process.env.KIKU_KEY_PEPPER) {
106
+ console.warn('[SECURITY WARNING] API_KEY_PEPPER is unset in production. Key hashes will reset on restart.');
107
+ }
108
+ if (!process.env.QUIZ_SECRET && !process.env.KIKU_QUIZ_SECRET) {
109
+ console.warn('[SECURITY WARNING] QUIZ_SECRET is unset in production. Challenge signatures will reset on restart.');
110
+ }
111
+ }
83
112
 
84
113
  function isMasterKey(rawKey) {
85
114
  if (!rawKey || typeof rawKey !== 'string') return false;
86
115
  const trimmed = rawKey.trim();
87
- if (masterApiKeys.has(trimmed)) return true;
116
+ const rawBuf = Buffer.from(trimmed);
117
+ for (const master of masterApiKeys) {
118
+ const masterBuf = Buffer.from(master);
119
+ if (rawBuf.length === masterBuf.length && crypto.timingSafeEqual(rawBuf, masterBuf)) {
120
+ return true;
121
+ }
122
+ }
88
123
  return false;
89
124
  }
90
125
 
@@ -115,7 +150,7 @@ async function validateApiKey(rawKey) {
115
150
  else if (trimmed.startsWith('kiku_vip_')) tier = 'Developer VIP';
116
151
 
117
152
  const result = { tier, rateLimit: RATE_LIMIT_API_KEY };
118
- apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
153
+ setApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
119
154
  return result;
120
155
  }
121
156
 
@@ -144,7 +179,7 @@ async function validateApiKey(rawKey) {
144
179
  if (Array.isArray(rows) && rows.length > 0) {
145
180
  const row = rows[0];
146
181
  if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
147
- apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
182
+ setApiKeyCache(hash, { isActive: false, validUntil: Date.now() + 60000 });
148
183
  return null;
149
184
  }
150
185
  let tier = 'Developer';
@@ -153,11 +188,11 @@ async function validateApiKey(rawKey) {
153
188
 
154
189
  const rateLimit = Number(row.rate_limit_per_min) || RATE_LIMIT_API_KEY;
155
190
  const result = { tier, rateLimit };
156
- apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
191
+ setApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
157
192
  return result;
158
193
  }
159
194
  }
160
- apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
195
+ setApiKeyCache(hash, { isActive: false, validUntil: Date.now() + 60000 });
161
196
  return null;
162
197
  } catch {
163
198
  if (issuedApiKeys.has(trimmed) || isMasterKey(trimmed)) {
@@ -173,7 +208,7 @@ async function saveApiKeyToSupabase({ apiKey, tier = 'developer_vip', rateLimit
173
208
  const prefix = apiKey.slice(0, 16);
174
209
 
175
210
  issuedApiKeys.add(apiKey);
176
- apiKeyCache.set(hash, {
211
+ setApiKeyCache(hash, {
177
212
  tier: tier === 'developer_vip' ? 'Developer VIP' : (tier === 'master_vip' ? 'Master VIP' : 'Developer'),
178
213
  rateLimit,
179
214
  isActive: true,
@@ -205,14 +240,14 @@ async function saveApiKeyToSupabase({ apiKey, tier = 'developer_vip', rateLimit
205
240
  }
206
241
 
207
242
 
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
- }
243
+ function createQuizChallengeToken() {
244
+ const now = Date.now();
245
+ for (const [token, session] of activeQuizSessions) {
246
+ if (now > session.expiresAt || session.isClaimed) activeQuizSessions.delete(token);
247
+ }
248
+ while (activeQuizSessions.size >= 2000) {
249
+ activeQuizSessions.delete(activeQuizSessions.keys().next().value);
250
+ }
216
251
  const expiresAt = now + (15 * 60 * 1000);
217
252
  const nonce = crypto.randomBytes(8).toString('hex');
218
253
  const payload = `${now}:${expiresAt}:${nonce}`;
@@ -222,11 +257,11 @@ function createQuizChallengeToken() {
222
257
  return { challengeToken: token, expiresAt: new Date(expiresAt).toISOString() };
223
258
  }
224
259
 
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;
260
+ function verifyQuizChallengeToken(token) {
261
+ if (!token || typeof token !== 'string' || !token.startsWith('kiku_chal_')) return false;
262
+ if (consumedQuizTokens.has(token)) return false;
263
+ const session = activeQuizSessions.get(token);
264
+ if (session && (session.isClaimed || Date.now() > session.expiresAt)) return false;
230
265
 
231
266
  const raw = token.slice('kiku_chal_'.length);
232
267
  const dotIdx = raw.lastIndexOf('.');
@@ -249,29 +284,29 @@ function verifyQuizChallengeToken(token) {
249
284
  const expiresAt = Number(expiresAtStr);
250
285
  const now = Date.now();
251
286
 
252
- if (isNaN(createdAt) || isNaN(expiresAt)) return false;
253
- if (createdAt !== session.createdAt || expiresAt !== session.expiresAt) return false;
287
+ if (isNaN(createdAt) || isNaN(expiresAt)) return false;
288
+ if (session && (createdAt !== session.createdAt || expiresAt !== session.expiresAt)) return false;
254
289
  if (now > expiresAt) return false;
255
290
  if (now - createdAt < MIN_QUIZ_DURATION_MS) return false;
256
291
 
257
292
  return true;
258
- } catch {
259
- return false;
260
- }
293
+ } catch {
294
+ return false;
295
+ }
261
296
  }
262
297
 
263
298
  function consumeQuizChallengeToken(token) {
264
299
  if (!token || typeof token !== 'string') return;
265
300
  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
- }
301
+ const session = activeQuizSessions.get(token);
302
+ if (session) {
303
+ session.isClaimed = true;
304
+ }
305
+ activeQuizSessions.delete(token);
306
+ if (consumedQuizTokens.size > 5000) {
307
+ const oldest = consumedQuizTokens.values().next().value;
308
+ if (oldest) consumedQuizTokens.delete(oldest);
309
+ }
275
310
  }
276
311
 
277
312
 
@@ -301,7 +336,7 @@ function parseJsonBody(req, maxBytes = 65536) {
301
336
  if (typeof req.body === 'string') {
302
337
  try { return resolve(JSON.parse(req.body)); } catch { return resolve({}); }
303
338
  }
304
- let body = '';
339
+ const chunks = [];
305
340
  let bytesReceived = 0;
306
341
  req.on('data', chunk => {
307
342
  bytesReceived += chunk.length;
@@ -309,10 +344,10 @@ function parseJsonBody(req, maxBytes = 65536) {
309
344
  req.destroy();
310
345
  return resolve({});
311
346
  }
312
- body += chunk;
347
+ chunks.push(chunk);
313
348
  });
314
349
  req.on('end', () => {
315
- try { resolve(body ? JSON.parse(body) : {}); } catch { resolve({}); }
350
+ try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); } catch { resolve({}); }
316
351
  });
317
352
  req.on('error', () => resolve({}));
318
353
  });
@@ -327,6 +362,9 @@ function checkRateLimit(clientIdentifier, customMax = RATE_LIMIT_ANONYMOUS) {
327
362
  count: 1,
328
363
  resetTime: now + RATE_LIMIT_WINDOW_MS
329
364
  };
365
+ while (rateLimitStore.size >= 50000) {
366
+ rateLimitStore.delete(rateLimitStore.keys().next().value);
367
+ }
330
368
  rateLimitStore.set(clientIdentifier, record);
331
369
  return {
332
370
  allowed: true,
@@ -351,11 +389,13 @@ function checkRateLimit(clientIdentifier, customMax = RATE_LIMIT_ANONYMOUS) {
351
389
  };
352
390
  }
353
391
 
392
+ let redisDownUntil = 0;
393
+
354
394
  async function checkDistributedRateLimit(clientIdentifier, maxLimit = RATE_LIMIT_ANONYMOUS) {
355
395
  const upstashUrl = process.env.UPSTASH_REDIS_REST_URL;
356
396
  const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN;
357
397
 
358
- if (upstashUrl && upstashToken) {
398
+ if (upstashUrl && upstashToken && Date.now() >= redisDownUntil) {
359
399
  try {
360
400
  const now = Date.now();
361
401
  const windowKey = `ratelimit:${clientIdentifier}:${Math.floor(now / RATE_LIMIT_WINDOW_MS)}`;
@@ -392,7 +432,7 @@ async function checkDistributedRateLimit(clientIdentifier, maxLimit = RATE_LIMIT
392
432
  };
393
433
  }
394
434
  } catch {
395
-
435
+ redisDownUntil = Date.now() + 30000;
396
436
  }
397
437
  }
398
438
 
@@ -422,7 +462,7 @@ function safeErrorMessage(err, fallback = 'Internal Server Error') {
422
462
  return fallback;
423
463
  }
424
464
 
425
- function extractApiKey(req) {
465
+ function extractApiKey(req) {
426
466
  if (!req || !req.headers) return null;
427
467
  const headerKey = req.headers['x-api-key'];
428
468
  if (headerKey && typeof headerKey === 'string') return headerKey.trim();
@@ -433,54 +473,81 @@ function extractApiKey(req) {
433
473
  }
434
474
 
435
475
  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);
476
+ }
477
+
478
+ function extractProviderApiKey(req) {
479
+ const value = req?.headers?.['x-provider-api-key'];
480
+ return typeof value === 'string' ? value.trim().slice(0, 4096) : '';
481
+ }
482
+
483
+ function resolveClientIp(req) {
484
+ const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || '127.0.0.1';
485
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true' || process.env.NODE_ENV === 'test' || process.argv.some(a => a.includes('test'));
486
+ if (!trustProxy) return String(directIp);
487
+ const forwarded = req.headers['x-forwarded-for'];
488
+ return typeof forwarded === 'string' && forwarded.trim()
489
+ ? forwarded.split(',')[0].trim()
490
+ : String(directIp);
491
+ }
492
+
493
+ const CORS_CONFIGURED_ORIGINS = (process.env.KIKU_CORS_ORIGINS || process.env.CORS_ALLOWED_ORIGINS || '')
494
+ .split(',')
495
+ .map(s => s.trim().toLowerCase())
496
+ .filter(Boolean);
497
+ const CORS_DEFAULT_ORIGINS = ['https://jujingyi-kikuuu.vercel.app'];
498
+ if (process.env.VERCEL_URL) CORS_DEFAULT_ORIGINS.push(`https://${process.env.VERCEL_URL}`);
499
+ const CORS_ALLOWED_ORIGIN_SET = new Set(CORS_CONFIGURED_ORIGINS.length > 0 ? CORS_CONFIGURED_ORIGINS : CORS_DEFAULT_ORIGINS.map(o => o.toLowerCase()));
500
+
501
+ function applySecurityHeaders(res, isHtml = false, req = null) {
502
+ let corsOrigin = '';
503
+ const incomingOrigin = req?.headers?.origin || res?.req?.headers?.origin;
504
+
505
+ if (CORS_ALLOWED_ORIGIN_SET.has('*')) {
506
+ corsOrigin = '*';
507
+ } else if (incomingOrigin) {
508
+ const normIncoming = incomingOrigin.toLowerCase();
509
+ if (CORS_ALLOWED_ORIGIN_SET.has(normIncoming)) {
510
+ corsOrigin = incomingOrigin;
511
+ } else {
512
+ try {
513
+ const parsed = new URL(incomingOrigin);
514
+ if (process.env.NODE_ENV !== 'production' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
515
+ corsOrigin = incomingOrigin;
516
+ }
517
+ } catch {}
518
+ }
519
+ }
520
+
521
+ if (corsOrigin) {
522
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin);
523
+ res.setHeader('Vary', 'Origin');
524
+ }
458
525
  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');
526
+ 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
527
  res.setHeader('X-Content-Type-Options', 'nosniff');
461
528
  res.setHeader('X-Frame-Options', 'DENY');
462
529
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
463
530
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
464
- res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
531
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
465
532
 
466
533
  if (isHtml) {
467
534
  res.setHeader(
468
535
  '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;"
536
+ "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
537
  );
471
538
  }
472
539
  }
473
540
 
474
- function sendJson(res, statusCode, payload, headers = {}) {
541
+ function sendJson(res, statusCode, payload, headers = {}, req = null) {
475
542
  res.statusCode = statusCode;
476
543
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
477
- applySecurityHeaders(res, false);
544
+ applySecurityHeaders(res, false, req);
478
545
 
479
546
  for (const [key, val] of Object.entries(headers)) {
480
547
  res.setHeader(key, val);
481
548
  }
482
549
 
483
- res.end(JSON.stringify(payload, null, 2));
550
+ res.end(JSON.stringify(payload));
484
551
  }
485
552
 
486
553
  function getCleanDiscography() {
@@ -518,8 +585,14 @@ function getCleanDiscography() {
518
585
  return Array.from(map.values()).sort((a, b) => b.year - a.year);
519
586
  }
520
587
 
588
+ const CLEAN_DISCOGRAPHY = Object.freeze(getCleanDiscography());
589
+
590
+ const openApiSpecCache = new Map();
591
+
521
592
  function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
522
- return {
593
+ const cachedSpec = openApiSpecCache.get(baseUrl);
594
+ if (cachedSpec) return cachedSpec;
595
+ const spec = {
523
596
  openapi: "3.0.3",
524
597
  info: {
525
598
  title: "Ju Jingyi (鞠婧祎) Official Platform REST API & Kiku AI Agent",
@@ -585,6 +658,94 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
585
658
  }
586
659
  }
587
660
  },
661
+ "/system/hardware": {
662
+ get: {
663
+ tags: ["System"],
664
+ summary: "Detected GPU / VRAM Hardware Report",
665
+ 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.",
666
+ operationId: "getSystemHardware",
667
+ responses: {
668
+ "200": {
669
+ description: "Hardware report with the resolved VRAM target",
670
+ content: {
671
+ "application/json": {
672
+ schema: {
673
+ type: "object",
674
+ properties: {
675
+ status: { type: "string", example: "success" },
676
+ data: {
677
+ type: "object",
678
+ properties: {
679
+ available: { type: "boolean", example: true },
680
+ target: {
681
+ type: "object",
682
+ properties: {
683
+ vendor: { type: "string", example: "NVIDIA" },
684
+ name: { type: "string", example: "NVIDIA GeForce RTX 4090" },
685
+ totalBytes: { type: "integer", example: 25769803776 },
686
+ freeBytes: { type: "integer", nullable: true },
687
+ source: { type: "string", example: "nvidia-smi (NVML)" }
688
+ }
689
+ }
690
+ }
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+ },
700
+ "/models/estimate-vram": {
701
+ post: {
702
+ tags: ["System"],
703
+ summary: "Predictive VRAM Footprint Estimation",
704
+ 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).",
705
+ operationId: "estimateVram",
706
+ requestBody: {
707
+ required: false,
708
+ content: {
709
+ "application/json": {
710
+ schema: {
711
+ type: "object",
712
+ properties: {
713
+ model: { type: "string", example: "qwen2.5-coder-32b-instruct" },
714
+ model_path: { type: "string", example: "C:/models/qwen2.5-coder-32b-q4_k_m.gguf" },
715
+ quant: { type: "string", example: "q4_k_m" },
716
+ contextLength: { type: "integer", example: 16384 },
717
+ cacheBits: { type: "integer", example: 16, description: "KV cache precision (FP16=16, Q8_0=8)" },
718
+ batchSize: { type: "integer", example: 1 },
719
+ images: { type: "integer", example: 0, description: "VLM correction: concurrent image tokens budget" }
720
+ }
721
+ }
722
+ }
723
+ }
724
+ },
725
+ responses: {
726
+ "200": {
727
+ description: "Footprint breakdown with allocation-zone classification and remediation suggestions",
728
+ content: { "application/json": { schema: { type: "object" } } }
729
+ }
730
+ }
731
+ },
732
+ get: {
733
+ tags: ["System"],
734
+ summary: "Predictive VRAM Footprint Estimation (query parameters)",
735
+ operationId: "estimateVramQuery",
736
+ parameters: [
737
+ { name: "model", in: "query", schema: { type: "string" }, example: "qwen2.5-7b-instruct" },
738
+ { name: "ctx", in: "query", schema: { type: "integer" }, example: 8192 },
739
+ { name: "quant", in: "query", schema: { type: "string" }, example: "q4_k_m" }
740
+ ],
741
+ responses: {
742
+ "200": {
743
+ description: "Footprint breakdown with allocation-zone classification",
744
+ content: { "application/json": { schema: { type: "object" } } }
745
+ }
746
+ }
747
+ }
748
+ },
588
749
  "/quiz/challenge": {
589
750
  get: {
590
751
  tags: ["System"],
@@ -1261,7 +1422,7 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1261
1422
  summary: "Generate Developer API Key (1,000 req/min)",
1262
1423
  description: "Generates a new Developer API key when called with an existing authorized key.",
1263
1424
  operationId: "generateApiKey",
1264
- security: [{ ApiKeyHeader: [] }, { ApiKeyQuery: [] }],
1425
+ security: [{ ApiKeyHeader: [] }],
1265
1426
  responses: {
1266
1427
  "200": {
1267
1428
  description: "Newly generated API key",
@@ -1277,7 +1438,7 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1277
1438
  properties: {
1278
1439
  apiKey: { type: "string", example: "kiku_dev_9x2b81_lz39xk" },
1279
1440
  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" }
1441
+ usage: { type: "string", example: "Pass via 'X-API-Key' header or Authorization: Bearer <key>" }
1281
1442
  }
1282
1443
  }
1283
1444
  }
@@ -1304,12 +1465,6 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1304
1465
  in: "header",
1305
1466
  name: "X-API-Key",
1306
1467
  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
1468
  }
1314
1469
  },
1315
1470
  schemas: {
@@ -1564,17 +1719,21 @@ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
1564
1719
  }
1565
1720
  }
1566
1721
  };
1722
+ openApiSpecCache.set(baseUrl, spec);
1723
+ return spec;
1567
1724
  }
1568
1725
 
1726
+ let apiDocsHtmlCache = null;
1727
+
1569
1728
  function renderApiDocsHtml() {
1570
- const openApi = getOpenApiSpec();
1571
- return `<!DOCTYPE html>
1729
+ if (apiDocsHtmlCache) return apiDocsHtmlCache;
1730
+ const html = `<!DOCTYPE html>
1572
1731
  <html lang="en" class="scroll-smooth">
1573
1732
  <head>
1574
1733
  <meta charset="UTF-8">
1575
1734
  <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>">
1735
+ <title>🌸 Ju Jingyi (鞠婧祎) API v1 Documentation & Interactive Console</title>
1736
+ <link rel="icon" href="/favicon.ico">
1578
1737
  <link rel="preconnect" href="https://fonts.googleapis.com">
1579
1738
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1580
1739
  <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 +1875,8 @@ function renderApiDocsHtml() {
1716
1875
  <div class="flex items-center gap-3 sm:gap-4">
1717
1876
  <a href="/" class="flex items-center gap-3 group select-none transition-transform active:scale-98 text-left">
1718
1877
  <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
- 鞠
1878
+ <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">
1879
+ 🌸
1721
1880
  </div>
1722
1881
  </div>
1723
1882
  <div class="flex flex-col">
@@ -1914,7 +2073,7 @@ function renderApiDocsHtml() {
1914
2073
  npm package: kiku-agent
1915
2074
  </span>
1916
2075
  <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
2076
+ v${PACKAGE_VERSION} Ready
1918
2077
  </span>
1919
2078
  <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
2079
  MCP Protocol
@@ -1924,7 +2083,7 @@ function renderApiDocsHtml() {
1924
2083
  Kiku AI Agent CLI & MCP Server
1925
2084
  </h2>
1926
2085
  <p class="text-xs sm:text-sm text-stone-600 dark:text-stone-300 mt-1 max-w-3xl">
1927
- Need an autonomous AI assistant or want to connect Ju Jingyi's knowledge base to Cursor, Claude Desktop, or VS Code? Use the official <strong>kiku-agent</strong> CLI & <strong>kiku-mcp</strong> server with 45 validated tools, local LM Studio (<code class="font-mono text-xs">google/gemma-4-e4b</code>) or cloud LLMs, and multi-agent debate.
2086
+ Need an autonomous AI assistant or want to connect Ju Jingyi's knowledge base to Cursor, Claude Desktop, or VS Code? Use the official <strong>kiku-agent</strong> CLI & <strong>kiku-mcp</strong> server with ${toolsSchema.length} validated tools, local LM Studio (<code class="font-mono text-xs">google/gemma-4-e4b</code>) or cloud LLMs, and multi-agent debate.
1928
2087
  </p>
1929
2088
  </div>
1930
2089
  </div>
@@ -1934,12 +2093,6 @@ function renderApiDocsHtml() {
1934
2093
  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.981 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"/></svg>
1935
2094
  <span>View on npm</span>
1936
2095
  </a>
1937
- <a href="/chat" class="px-4 py-2 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700 text-stone-800 dark:text-stone-100 font-bold text-xs border border-stone-300 dark:border-stone-700 flex items-center gap-1.5 transition-all active:scale-95">
1938
- <span>💬 Web Chat</span>
1939
- </a>
1940
- <a href="https://github.com/ThemeHackers/jujingyi_kikuuu" target="_blank" rel="noopener noreferrer" class="px-4 py-2 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700 text-stone-800 dark:text-stone-100 font-bold text-xs border border-stone-300 dark:border-stone-700 flex items-center gap-1.5 transition-all active:scale-95">
1941
- <span>GitHub</span>
1942
- </a>
1943
2096
  </div>
1944
2097
  </div>
1945
2098
 
@@ -1952,7 +2105,7 @@ function renderApiDocsHtml() {
1952
2105
  <span class="w-2.5 h-2.5 rounded-full bg-rose-500 animate-pulse"></span>
1953
2106
  <h3 class="font-bold text-stone-900 dark:text-white text-base">Terminal AI Agent (CLI)</h3>
1954
2107
  </div>
1955
- <span class="font-mono text-xs px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-950 text-rose-700 dark:text-rose-300 border border-rose-200 dark:border-rose-800/60 font-semibold">45 Tools</span>
2108
+ <span class="font-mono text-xs px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-950 text-rose-700 dark:text-rose-300 border border-rose-200 dark:border-rose-800/60 font-semibold">${toolsSchema.length} Tools</span>
1956
2109
  </div>
1957
2110
  <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
1958
2111
  Autonomous ReAct agent with dynamic multiline input, live thinking reasoning streams, local/cloud LLM switching, background research jobs, YouTube music streaming, and multi-agent debate.
@@ -1994,7 +2147,7 @@ function renderApiDocsHtml() {
1994
2147
  <span class="w-2.5 h-2.5 rounded-full bg-purple-500 animate-pulse"></span>
1995
2148
  <h3 class="font-bold text-stone-900 dark:text-white text-base">Model Context Protocol (MCP)</h3>
1996
2149
  </div>
1997
- <span class="font-mono text-xs px-2 py-0.5 rounded bg-purple-50 dark:bg-purple-950 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60 font-semibold">43 MCP Tools</span>
2150
+ <span class="font-mono text-xs px-2 py-0.5 rounded bg-purple-50 dark:bg-purple-950 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60 font-semibold">${mcpToolDefinitions.length} MCP Tools</span>
1998
2151
  </div>
1999
2152
  <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
2000
2153
  Connect Cursor, Claude Desktop, VS Code, or Antigravity directly to Ju Jingyi's full knowledge base using stdio or Streamable HTTP.
@@ -2024,11 +2177,11 @@ function renderApiDocsHtml() {
2024
2177
  "mcpServers": {
2025
2178
  "kiku": {
2026
2179
  "command": "npx",
2027
- "args": ["-y", "kiku-agent", "kiku-mcp"]
2180
+ "args": ["-y", "-p", "kiku-agent", "kiku-mcp"]
2028
2181
  }
2029
2182
  }
2030
2183
  }</code></pre>
2031
- <button type="button" onclick="copySnippet('{\\n \\\"mcpServers\\\": {\\n \\\"kiku\\\": {\\n \\\"command\\\": \\\"npx\\\",\\n \\\"args\\\": [\\\"-y\\\", \\\"kiku-agent\\\", \\\"kiku-mcp\\\"]\\n }\\n }\\n}', this)" class="absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded bg-purple-800/80 hover:bg-purple-700 text-white cursor-pointer">Copy</button>
2184
+ <button type="button" onclick="copySnippet('{\\n \\\"mcpServers\\\": {\\n \\\"kiku\\\": {\\n \\\"command\\\": \\\"npx\\\",\\n \\\"args\\\": [\\\"-y\\\", \\\"-p\\\", \\\"kiku-agent\\\", \\\"kiku-mcp\\\"]\\n }\\n }\\n}', this)" class="absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded bg-purple-800/80 hover:bg-purple-700 text-white cursor-pointer">Copy</button>
2032
2185
  </div>
2033
2186
  </div>
2034
2187
  </div>
@@ -2159,8 +2312,8 @@ function renderApiDocsHtml() {
2159
2312
  }
2160
2313
 
2161
2314
  let allExpanded = false;
2162
- let currentVipKey = '';
2163
- try { localStorage.removeItem('kiku_vip_key'); } catch {}
2315
+ let currentVipKey = '';
2316
+ try { localStorage.removeItem('kiku_vip_key'); } catch {}
2164
2317
  let quizLang = 'th';
2165
2318
 
2166
2319
  const quizI18n = {
@@ -3121,6 +3274,8 @@ function renderApiDocsHtml() {
3121
3274
  items: [
3122
3275
  { method: "GET", path: "/health", desc: "API health, uptime, and database status (Public & VIP)", example: "/health" },
3123
3276
  { method: "GET", path: "/stats", desc: "Platform statistical overview & catalog counts", example: "/stats" },
3277
+ { method: "GET", path: "/system/hardware", desc: "Detected GPU/VRAM hardware report used by the predictive VRAM engine", example: "/system/hardware" },
3278
+ { 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
3279
  { method: "GET", path: "/quiz/challenge", desc: "Request one-time anti-cheat challenge token for VIP quiz", example: "/quiz/challenge" },
3125
3280
  { 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
3281
  { method: "GET", path: "/keys/generate", desc: "Generate Developer API Key (Requires Master Key in X-API-Key)", example: "/keys/generate", requiresKey: true },
@@ -3281,6 +3436,8 @@ function renderApiDocsHtml() {
3281
3436
  </script>
3282
3437
  </body>
3283
3438
  </html>`;
3439
+ apiDocsHtmlCache = html;
3440
+ return html;
3284
3441
  }
3285
3442
 
3286
3443
  function safeDecode(str) {
@@ -3292,16 +3449,16 @@ function safeDecode(str) {
3292
3449
  }
3293
3450
  }
3294
3451
 
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');
3452
+ export async function handleApiRequest(req, res, next) {
3453
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
3454
+ const rawHost = trustProxy ? (req.headers['x-forwarded-host'] || req.headers.host) : req.headers.host;
3455
+ const hostCandidate = String(rawHost || '').split(',')[0].trim();
3456
+ const host = /^[a-z0-9.-]+(?::\d{1,5})?$/i.test(hostCandidate)
3457
+ ? hostCandidate
3458
+ : 'jujingyi-kikuuu.vercel.app';
3459
+ const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.endsWith('.local');
3460
+ const forwardedProtocol = trustProxy ? String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() : '';
3461
+ const protocol = ['http', 'https'].includes(forwardedProtocol) ? forwardedProtocol : (isLocal ? 'http' : 'https');
3305
3462
  const baseUrl = `${protocol}://${host}`;
3306
3463
  const { isConfigured: isSupabaseConfigured } = getSupabaseConfig();
3307
3464
 
@@ -3325,34 +3482,35 @@ export async function handleApiRequest(req, res, next) {
3325
3482
  return;
3326
3483
  }
3327
3484
 
3328
- if (pathname === '/api/v1/openapi.json' || pathname === '/api/v1/swagger.json') {
3485
+ if (pathname === '/openapi.json' || pathname === '/api/v1/openapi.json' || pathname === '/api/v1/swagger.json') {
3329
3486
  return sendJson(res, 200, getOpenApiSpec(baseUrl));
3330
3487
  }
3331
3488
 
3332
- if (!pathname.startsWith('/api/v1') && pathname !== '/api' && !pathname.startsWith('/mcp')) {
3489
+ if (!pathname.startsWith('/api/v1') && pathname !== '/api' && pathname !== '/openapi.json' && !pathname.startsWith('/mcp')) {
3333
3490
  if (next) return next();
3334
3491
  return;
3335
3492
  }
3336
3493
 
3337
3494
  if (req.method === 'OPTIONS') {
3338
3495
  res.statusCode = 204;
3339
- applySecurityHeaders(res, false);
3496
+ applySecurityHeaders(res, false, req);
3340
3497
  res.end();
3341
3498
  return;
3342
3499
  }
3343
3500
 
3344
- const clientIp = resolveClientIp(req);
3501
+ const clientIp = resolveClientIp(req);
3345
3502
  const searchParams = urlObj.searchParams;
3346
3503
  const apiKey = extractApiKey(req);
3347
3504
  const validKeyInfo = apiKey ? await validateApiKey(apiKey) : null;
3348
- const hasValidApiKey = Boolean(validKeyInfo);
3349
- const hasAgentExecutionAccess = Boolean(apiKey && (isMasterKey(apiKey) || agentExecutionKeys.has(apiKey.trim())));
3505
+ const hasValidApiKey = Boolean(validKeyInfo);
3506
+ const hasAgentExecutionAccess = Boolean(apiKey && (isMasterKey(apiKey) || agentExecutionKeys.has(apiKey.trim())));
3350
3507
 
3351
3508
  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;
3509
+ const isProduction = process.env.NODE_ENV === 'production';
3510
+ const parsedTestLimit = !isProduction ? Number.parseInt(searchParams.get('testRateLimit') || '', 10) : NaN;
3511
+ const hasTestRateLimit = !isProduction && Number.isInteger(parsedTestLimit) && parsedTestLimit > 0;
3354
3512
  const maxLimit = hasTestRateLimit ? Math.min(parsedTestLimit, baseRateLimit) : baseRateLimit;
3355
- const baseClientIdentifier = hasValidApiKey ? `key:${hashApiKey(apiKey)}` : `ip:${clientIp}`;
3513
+ const baseClientIdentifier = hasValidApiKey ? `key:${hashApiKey(apiKey)}` : `ip:${clientIp}`;
3356
3514
  const clientIdentifier = hasTestRateLimit
3357
3515
  ? `${baseClientIdentifier}:test:${maxLimit}`
3358
3516
  : baseClientIdentifier;
@@ -3417,7 +3575,7 @@ export async function handleApiRequest(req, res, next) {
3417
3575
  }, rateHeaders);
3418
3576
  }
3419
3577
 
3420
- if (pathname === '/api/v1/quiz/challenge') {
3578
+ if (pathname === '/api/v1/quiz/challenge') {
3421
3579
  const session = createQuizChallengeToken();
3422
3580
  return sendJson(res, 200, {
3423
3581
  status: 'success',
@@ -3430,14 +3588,12 @@ export async function handleApiRequest(req, res, next) {
3430
3588
  targetTier: 'Developer VIP Tier (1,000 req/min)',
3431
3589
  usage: "Send this challengeToken along with your 15 completed quiz answers to POST /api/v1/keys/claim"
3432
3590
  }
3433
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3591
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3434
3592
  }
3435
3593
 
3436
3594
  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) {
3595
+ const isAuthorized = isMasterKey(apiKey) || (validKeyInfo && validKeyInfo.tier === 'Master VIP');
3596
+ if (!apiKey || !validKeyInfo || !isAuthorized) {
3441
3597
  return sendJson(res, 401, {
3442
3598
  status: 'error',
3443
3599
  code: 401,
@@ -3445,9 +3601,9 @@ export async function handleApiRequest(req, res, next) {
3445
3601
  message: 'Unauthorized. Admin/Master API Key required in X-API-Key header to generate new developer keys.'
3446
3602
  }, rateHeaders);
3447
3603
  }
3448
- const token = crypto.randomBytes(16).toString('hex');
3449
- const newKey = `kiku_dev_sk_${token}`;
3450
- await saveApiKeyToSupabase({ apiKey: newKey, tier: 'developer', rateLimit: 1000 });
3604
+ const token = crypto.randomBytes(16).toString('hex');
3605
+ const newKey = `kiku_dev_sk_${token}`;
3606
+ await saveApiKeyToSupabase({ apiKey: newKey, tier: 'developer', rateLimit: 1000 });
3451
3607
  return sendJson(res, 200, {
3452
3608
  status: 'success',
3453
3609
  code: 200,
@@ -3459,7 +3615,7 @@ export async function handleApiRequest(req, res, next) {
3459
3615
  storage: isSupabaseConfigured ? 'supabase-postgresql (persistent)' : 'in-memory (standalone)',
3460
3616
  usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3461
3617
  }
3462
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3618
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3463
3619
  }
3464
3620
 
3465
3621
  if (pathname === '/api/v1/keys/claim') {
@@ -3510,8 +3666,8 @@ export async function handleApiRequest(req, res, next) {
3510
3666
 
3511
3667
  consumeQuizChallengeToken(body.challengeToken);
3512
3668
 
3513
- const token = crypto.randomBytes(16).toString('hex');
3514
- const newVipKey = `kiku_vip_sk_${token}`;
3669
+ const token = crypto.randomBytes(16).toString('hex');
3670
+ const newVipKey = `kiku_vip_sk_${token}`;
3515
3671
  await saveApiKeyToSupabase({ apiKey: newVipKey, tier: 'developer_vip', rateLimit: 1000 });
3516
3672
 
3517
3673
  return sendJson(res, 200, {
@@ -3528,11 +3684,11 @@ export async function handleApiRequest(req, res, next) {
3528
3684
  achievement: 'Ju Jingyi Miju Master (15/15 Perfect Score 🌸)',
3529
3685
  usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3530
3686
  }
3531
- }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3687
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3532
3688
  }
3533
3689
 
3534
3690
  if (pathname === '/api/v1/stats') {
3535
- const cleanSongs = getCleanDiscography();
3691
+ const cleanSongs = CLEAN_DISCOGRAPHY;
3536
3692
  return sendJson(res, 200, {
3537
3693
  status: 'success',
3538
3694
  code: 200,
@@ -3676,14 +3832,14 @@ export async function handleApiRequest(req, res, next) {
3676
3832
  }, rateHeaders);
3677
3833
  }
3678
3834
 
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) : {};
3835
+ if (pathname === '/api/v1/agent/query' || pathname === '/api/v1/chat') {
3836
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3837
+ return sendJson(res, 401, {
3838
+ status: 'error', code: 401, timestamp,
3839
+ message: 'An agent-execution API key is required for model execution.'
3840
+ }, rateHeaders);
3841
+ }
3842
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3687
3843
  const rawQuery = body.query || body.prompt || searchParams.get('q') || searchParams.get('query') || '';
3688
3844
  const query = sanitizeSearchQuery(rawQuery, 2000);
3689
3845
  if (!query) {
@@ -3697,19 +3853,19 @@ export async function handleApiRequest(req, res, next) {
3697
3853
 
3698
3854
  try {
3699
3855
  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
- });
3856
+ const provider = sanitizeSearchQuery(body.provider || 'lmstudio', 40).toLowerCase();
3857
+ const providerApiKey = extractProviderApiKey(req);
3858
+ if (provider !== 'lmstudio' && !providerApiKey) {
3859
+ return sendJson(res, 401, {
3860
+ status: 'error', code: 401, timestamp,
3861
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3862
+ }, rateHeaders);
3863
+ }
3864
+ const agent = getSharedAgent({
3865
+ provider,
3866
+ model: sanitizeSearchQuery(body.model || '', 160) || undefined,
3867
+ apiKey: providerApiKey || undefined,
3868
+ });
3713
3869
  await agent.ready();
3714
3870
  let collectedAnswer = '';
3715
3871
  await agent.ask(query, {
@@ -3733,13 +3889,13 @@ export async function handleApiRequest(req, res, next) {
3733
3889
  }
3734
3890
  }
3735
3891
 
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
- }
3892
+ if (pathname === '/api/v1/agent/chat' || pathname === '/api/v1/chat/stream') {
3893
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3894
+ return sendJson(res, 401, {
3895
+ status: 'error', code: 401, timestamp,
3896
+ message: 'An agent-execution API key is required for model execution.'
3897
+ }, rateHeaders);
3898
+ }
3743
3899
  const rawQ = searchParams.get('q') || searchParams.get('query') || '';
3744
3900
  const q = sanitizeSearchQuery(rawQ, 2000);
3745
3901
  if (!q) {
@@ -3750,29 +3906,73 @@ export async function handleApiRequest(req, res, next) {
3750
3906
  message: "Missing 'q' or 'query' parameter for agent chat stream."
3751
3907
  }, rateHeaders);
3752
3908
  }
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
- });
3909
+ const provider = sanitizeSearchQuery(searchParams.get('provider') || 'lmstudio', 40).toLowerCase();
3910
+ const providerApiKey = extractProviderApiKey(req);
3911
+ if (provider !== 'lmstudio' && !providerApiKey) {
3912
+ return sendJson(res, 401, {
3913
+ status: 'error', code: 401, timestamp,
3914
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3915
+ }, rateHeaders);
3916
+ }
3917
+ const { handleAgentChatStream } = await import('./routes/agent.js');
3918
+ return handleAgentChatStream(req, res, q, {
3919
+ provider,
3920
+ model: sanitizeSearchQuery(searchParams.get('model') || '', 160) || undefined,
3921
+ apiKey: providerApiKey || undefined,
3922
+ });
3923
+ }
3924
+
3925
+ if (pathname === '/api/v1/system/hardware') {
3926
+ try {
3927
+ const { buildHardwareReport } = await import('./agent/vramService.js');
3928
+ const report = await buildHardwareReport();
3929
+ return sendJson(res, 200, {
3930
+ status: 'success',
3931
+ code: 200,
3932
+ timestamp,
3933
+ data: report
3934
+ }, rateHeaders);
3935
+ } catch (err) {
3936
+ console.error('[Hardware API Error]:', err);
3937
+ return sendJson(res, 500, {
3938
+ status: 'error',
3939
+ code: 500,
3940
+ timestamp,
3941
+ message: safeErrorMessage(err, 'Hardware probe failed')
3942
+ }, rateHeaders);
3943
+ }
3944
+ }
3945
+
3946
+ if (pathname === '/api/v1/models/estimate-vram') {
3947
+ try {
3948
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3949
+ const request = parseEstimateRequest(body, searchParams);
3950
+ const { computeVramEstimate } = await import('./agent/vramService.js');
3951
+ const estimate = await computeVramEstimate(request);
3952
+ return sendJson(res, 200, {
3953
+ status: 'success',
3954
+ code: 200,
3955
+ timestamp,
3956
+ data: estimate
3957
+ }, rateHeaders);
3958
+ } catch (err) {
3959
+ console.error('[VRAM Estimate API Error]:', err);
3960
+ return sendJson(res, 500, {
3961
+ status: 'error',
3962
+ code: 500,
3963
+ timestamp,
3964
+ message: safeErrorMessage(err, 'VRAM estimation failed')
3965
+ }, rateHeaders);
3966
+ }
3767
3967
  }
3768
3968
 
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
- }
3969
+ if (pathname === '/api/v1/debate') {
3970
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3971
+ return sendJson(res, 401, {
3972
+ status: 'error', code: 401, timestamp,
3973
+ message: 'An agent-execution API key is required for debate execution.'
3974
+ }, rateHeaders);
3975
+ }
3776
3976
  const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3777
3977
  const rawTopic = body.topic || body.claim || searchParams.get('topic') || searchParams.get('claim') || searchParams.get('q') || '';
3778
3978
  const topic = sanitizeSearchQuery(rawTopic, 500);
@@ -3868,7 +4068,7 @@ export async function handleApiRequest(req, res, next) {
3868
4068
  }
3869
4069
 
3870
4070
  if (pathname === '/api/v1/discography') {
3871
- let cleanSongs = getCleanDiscography();
4071
+ let cleanSongs = CLEAN_DISCOGRAPHY;
3872
4072
  const type = sanitizeSearchQuery(searchParams.get('type') || '', 50);
3873
4073
  const year = sanitizeSearchQuery(searchParams.get('year') || '', 20);
3874
4074
  const search = sanitizeSearchQuery(searchParams.get('search') || '', 100);
@@ -3900,7 +4100,7 @@ export async function handleApiRequest(req, res, next) {
3900
4100
  timestamp,
3901
4101
  data: cleanSongs,
3902
4102
  meta: {
3903
- totalUniqueTracks: getCleanDiscography().length,
4103
+ totalUniqueTracks: CLEAN_DISCOGRAPHY.length,
3904
4104
  count: cleanSongs.length
3905
4105
  }
3906
4106
  }, rateHeaders);
@@ -3909,7 +4109,7 @@ export async function handleApiRequest(req, res, next) {
3909
4109
  if (pathname.startsWith('/api/v1/discography/')) {
3910
4110
  const rawId = pathname.replace('/api/v1/discography/', '').trim();
3911
4111
  const id = safeDecode(rawId);
3912
- const cleanSongs = getCleanDiscography();
4112
+ const cleanSongs = CLEAN_DISCOGRAPHY;
3913
4113
  const song = cleanSongs.find(s =>
3914
4114
  String(s.id) === id ||
3915
4115
  String(s.id) === rawId ||
@@ -4122,7 +4322,7 @@ export async function handleApiRequest(req, res, next) {
4122
4322
  (w.year && String(w.year).includes(q))
4123
4323
  );
4124
4324
 
4125
- const cleanSongs = getCleanDiscography();
4325
+ const cleanSongs = CLEAN_DISCOGRAPHY;
4126
4326
  const matchedSongs = cleanSongs.filter(s =>
4127
4327
  (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
4128
4328
  (s.titleCn && s.titleCn.includes(q)) ||