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.
- package/README.md +459 -407
- package/package.json +27 -3
- package/server/agent/agentCore.js +635 -267
- package/server/agent/appPaths.js +2 -0
- package/server/agent/catalogEntityResolver.js +629 -0
- package/server/agent/citations.js +0 -21
- package/server/agent/clarificationAnalytics.js +1 -1
- package/server/agent/clarifyingQuestions.js +286 -216
- package/server/agent/cli.js +528 -73
- package/server/agent/commandFeatures.js +123 -24
- package/server/agent/commandRegistry.js +128 -62
- package/server/agent/commands.js +1410 -95
- package/server/agent/config.js +253 -163
- package/server/agent/contextCompactor.js +239 -25
- package/server/agent/dynamicCatalog.js +192 -0
- package/server/agent/envBootstrap.js +46 -0
- package/server/agent/envManager.js +317 -0
- package/server/agent/eventBus.js +3 -3
- package/server/agent/feedbackLearning.js +0 -8
- package/server/agent/gpuMetrics.js +188 -55
- package/server/agent/ingestionWorker.js +339 -0
- package/server/agent/lanDiscovery.js +369 -0
- package/server/agent/llmClient.js +111 -39
- package/server/agent/lmStudioWakeup.js +144 -0
- package/server/agent/mcpClient.js +2 -2
- package/server/agent/memory.js +635 -494
- package/server/agent/modelConfig.js +487 -0
- package/server/agent/newsIntelligence.js +219 -0
- package/server/agent/newsWatch.js +85 -0
- package/server/agent/prompts.js +146 -0
- package/server/agent/publicTools.js +7 -0
- package/server/agent/queryDetails.js +62 -1
- package/server/agent/research.js +314 -35
- package/server/agent/routingInterceptor.js +77 -0
- package/server/agent/rumoredProjects.js +15 -0
- package/server/agent/sanitizer.js +117 -17
- package/server/agent/subagent.js +52 -9
- package/server/agent/suggestionTui.js +33 -13
- package/server/agent/suggestions.js +29 -13
- package/server/agent/telemetry.js +0 -11
- package/server/agent/terminalImage.js +0 -1
- package/server/agent/textToolProtocol.js +190 -0
- package/server/agent/tokenTracker.js +220 -0
- package/server/agent/toolRouter.js +362 -216
- package/server/agent/tools.js +1155 -194
- package/server/agent/ui.js +144 -41
- package/server/agent/vramService.js +572 -0
- package/server/agent/webSearch.js +2279 -273
- package/server/agent/youtubeAuth.js +82 -73
- package/server/agent/youtubeTranscript.js +209 -0
- package/server/apiRouter.js +383 -179
- package/server/mcp/data/brands.js +5 -5
- package/server/mcp/data/discography.js +56 -45
- package/server/mcp/data/gallery.js +9 -0
- package/server/mcp/data/index.js +1 -0
- package/server/mcp/data/industryIndex.js +251 -0
- package/server/mcp/data/profile.js +5 -5
- package/server/mcp/data/timeline.js +2 -2
- package/server/mcp/data/works.js +2 -2
- package/server/mcp/handler.js +0 -2
- package/server/mcp/prompts.js +42 -2
- package/server/mcp/resources.js +5 -1
- package/server/mcp/security.js +25 -14
- package/server/mcp/server.js +92 -3
- package/server/mcp/tools.js +20 -11
- package/server/web-chat/core/connection.js +218 -194
- package/server/web-chat/core/conversationContext.js +1 -1
- package/server/web-chat/core/directCatalogEngine.js +190 -184
- package/server/web-chat/core/followUpEngine.js +88 -11
- package/server/web-chat/core/security.js +7 -4
- package/server/web-chat/router.js +54 -17
- package/server/web-chat/routes/bilibiliRoute.js +34 -0
- package/server/web-chat/routes/chatRoute.js +488 -397
- package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
- package/server/web-chat/routes/exportRoute.js +2 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
- package/server/web-chat/routes/newsRoute.js +36 -6
- package/server/web-chat/routes/providerRoute.js +87 -0
- package/server/web-chat/routes/staticRoute.js +61 -13
- package/server/web-chat/routes/statusRoute.js +23 -2
- package/server/web-chat/routes/vramRoute.js +22 -0
- package/server/web-chat/server.js +135 -106
- package/server/web-chat/ui/htmlBuilder.js +8 -8
- package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
- package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
- package/server/web-chat/ui/scripts/client.js +10526 -8397
- package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
- package/server/web-chat/ui/styles/main.css +7471 -4246
- package/server/web-chat/ui/templates/body.html +305 -179
- package/src/data/brandsData.js +29 -6
- package/src/data/discographyData.js +918 -900
- package/src/data/galleryData.js +66 -0
- package/src/data/industryIndexData.js +292 -0
- package/src/data/musicVideoData.js +48 -5
- package/src/data/profileData.js +15 -1
- package/src/data/timelineData.js +4 -3
- package/src/data/worksData.js +536 -535
- package/src/lib/vram/estimator.js +464 -0
- package/src/lib/vram/modelRegistry.js +387 -0
- package/tokens.css +33 -2
package/server/apiRouter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
+
setApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
|
|
157
190
|
return result;
|
|
158
191
|
}
|
|
159
192
|
}
|
|
160
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
345
|
+
chunks.push(chunk);
|
|
313
346
|
});
|
|
314
347
|
req.on('end', () => {
|
|
315
|
-
try { 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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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: [] }
|
|
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
|
|
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
|
-
|
|
1571
|
-
|
|
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
|
|
1577
|
-
<link rel="icon"
|
|
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
|
|
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
|
-
|
|
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
|
|
3353
|
-
const
|
|
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
|
|
3438
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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)) ||
|