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
@@ -0,0 +1,369 @@
1
+ import net from 'node:net';
2
+ import os from 'node:os';
3
+ import { isPrivateIPv4 } from './sanitizer.js';
4
+
5
+ export const DEFAULT_LLM_PORTS = Object.freeze([1234, 8000, 11434, 8080]);
6
+
7
+ const CACHE_TTL_MS = 60_000;
8
+
9
+ const SERVER_TYPE_LABELS = Object.freeze({
10
+ lmstudio: 'LM Studio',
11
+ vllm: 'vLLM',
12
+ ollama: 'Ollama',
13
+ 'openai-compatible': 'OpenAI-Compatible',
14
+ });
15
+
16
+ export function mapServerTypeToProvider(serverType) {
17
+ if (serverType === 'lmstudio') return 'lmstudio';
18
+ if (serverType === 'vllm') return 'vllm';
19
+ return 'custom';
20
+ }
21
+
22
+ function parseIpv4(host) {
23
+ const parts = String(host || '').split('.');
24
+ if (parts.length !== 4) return null;
25
+ const octets = parts.map(p => Number(p));
26
+ if (octets.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
27
+ return octets;
28
+ }
29
+
30
+ export function getLanCandidateSubnets(interfaces = os.networkInterfaces()) {
31
+ const seen = new Set();
32
+ const subnets = [];
33
+ for (const [name, addresses] of Object.entries(interfaces || {})) {
34
+ for (const entry of addresses || []) {
35
+ if (!entry || entry.internal || entry.family !== 'IPv4') continue;
36
+ const octets = parseIpv4(entry.address);
37
+ if (!octets || octets[0] === 127) continue;
38
+ if (octets[0] === 169 && octets[1] === 254) continue;
39
+ if (!isPrivateIPv4(entry.address)) continue;
40
+ const cidr = `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
41
+ if (seen.has(cidr)) continue;
42
+ seen.add(cidr);
43
+ subnets.push({ cidr, interfaceName: name, address: entry.address });
44
+ }
45
+ }
46
+ return subnets;
47
+ }
48
+
49
+ export function expandCidrToHosts(cidr) {
50
+ const base = String(cidr || '').split('/')[0];
51
+ const octets = parseIpv4(base);
52
+ if (!octets) throw new Error(`Invalid CIDR "${cidr}"`);
53
+ const hosts = [];
54
+ for (let last = 1; last <= 254; last++) {
55
+ hosts.push(`${octets[0]}.${octets[1]}.${octets[2]}.${last}`);
56
+ }
57
+ return hosts;
58
+ }
59
+
60
+ export const DEFAULT_MAX_SCAN_HOSTS = 4096;
61
+
62
+ function ipv4ToUint32(octets) {
63
+ return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
64
+ }
65
+
66
+ function uint32ToIp(value) {
67
+ return `${(value >>> 24) & 255}.${(value >>> 16) & 255}.${(value >>> 8) & 255}.${value & 255}`;
68
+ }
69
+
70
+ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {}) {
71
+ const list = Array.isArray(specs) ? specs : String(specs || '').split(',');
72
+ const out = [];
73
+ const seen = new Set();
74
+ let planned = 0;
75
+
76
+ const pushIp = value => {
77
+ const ip = uint32ToIp(value);
78
+ if (!seen.has(ip)) {
79
+ seen.add(ip);
80
+ out.push(ip);
81
+ }
82
+ };
83
+
84
+ for (const raw of list) {
85
+ const spec = String(raw || '').trim();
86
+ if (!spec) continue;
87
+
88
+ if (spec.includes('/')) {
89
+ const [base, prefixStr] = spec.split('/');
90
+ const octets = parseIpv4(base);
91
+ const prefix = Number(prefixStr);
92
+ if (!octets || !Number.isInteger(prefix) || prefix < 8 || prefix > 32) {
93
+ throw new Error(`Invalid CIDR "${spec}" (expected a.b.c.d/8..32)`);
94
+ }
95
+ const network = prefix === 0 ? 0 : (ipv4ToUint32(octets) & (0xffffffff << (32 - prefix))) >>> 0;
96
+ const size = 2 ** (32 - prefix);
97
+ const usable = prefix <= 30 ? size - 2 : size;
98
+ planned += usable;
99
+ if (planned > maxHosts) {
100
+ throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
101
+ }
102
+ const first = prefix <= 30 ? network + 1 : network;
103
+ const last = prefix <= 30 ? network + size - 2 : network + size - 1;
104
+ for (let ip = first; ip <= last; ip++) pushIp(ip >>> 0);
105
+ } else if (spec.includes('-')) {
106
+ const [startStr, endStr] = spec.split('-');
107
+ const startOctets = parseIpv4(startStr);
108
+ const endOctets = parseIpv4(endStr);
109
+ if (!startOctets || !endOctets) {
110
+ throw new Error(`Invalid IP range "${spec}" (expected a.b.c.d-e.f.g.h)`);
111
+ }
112
+ const start = ipv4ToUint32(startOctets);
113
+ const end = ipv4ToUint32(endOctets);
114
+ if (end < start) throw new Error(`Invalid IP range "${spec}" (start is after end)`);
115
+ const count = end - start + 1;
116
+ planned += count;
117
+ if (planned > maxHosts) {
118
+ throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
119
+ }
120
+ for (let ip = start; ip <= end; ip++) pushIp(ip >>> 0);
121
+ } else {
122
+ const octets = parseIpv4(spec);
123
+ if (!octets) throw new Error(`Invalid IP address "${spec}"`);
124
+ planned += 1;
125
+ pushIp(ipv4ToUint32(octets));
126
+ }
127
+ }
128
+
129
+ return out;
130
+ }
131
+
132
+ function assertAllPrivate(hosts) {
133
+ const offenders = hosts.filter(h => {
134
+ const octets = parseIpv4(h);
135
+ if (!octets) return true;
136
+ if (octets[0] === 127) return false;
137
+ return !isPrivateIPv4(h);
138
+ });
139
+ if (offenders.length > 0) {
140
+ throw new Error(`LAN discovery only scans private network ranges (RFC1918). Rejected: ${offenders.slice(0, 5).join(', ')}`);
141
+ }
142
+ }
143
+
144
+ function abortError() {
145
+ const error = new Error('LAN scan cancelled');
146
+ error.name = 'AbortError';
147
+ return error;
148
+ }
149
+
150
+ function defaultTcpProbe(host, port, timeoutMs, signal) {
151
+ return new Promise(resolve => {
152
+ if (signal?.aborted) {
153
+ resolve(false);
154
+ return;
155
+ }
156
+ const socket = new net.Socket();
157
+ let settled = false;
158
+ const onAbort = () => done(false);
159
+ const done = result => {
160
+ if (settled) return;
161
+ settled = true;
162
+ signal?.removeEventListener('abort', onAbort);
163
+ socket.destroy();
164
+ resolve(result);
165
+ };
166
+ socket.setTimeout(timeoutMs);
167
+ socket.once('connect', () => done(true));
168
+ socket.once('timeout', () => done(false));
169
+ socket.once('error', () => done(false));
170
+ signal?.addEventListener('abort', onAbort, { once: true });
171
+ socket.connect(port, host);
172
+ });
173
+ }
174
+
175
+ export async function classifyModelsEndpoint({ host, port, timeoutMs = 2500, signal, fetchImpl = fetch }) {
176
+ const started = performance.now();
177
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
178
+ const fetchSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
179
+ const record = {
180
+ host,
181
+ port,
182
+ url: `http://${host}:${port}/v1`,
183
+ reachable: false,
184
+ serverType: 'openai-compatible',
185
+ serverName: SERVER_TYPE_LABELS['openai-compatible'],
186
+ models: [],
187
+ modelCount: 0,
188
+ authRequired: false,
189
+ latencyMs: null,
190
+ };
191
+
192
+ let modelsPayload = null;
193
+ try {
194
+ const res = await fetchImpl(`http://${host}:${port}/v1/models`, {
195
+ headers: { Accept: 'application/json' },
196
+ signal: fetchSignal,
197
+ });
198
+ if (res.status === 401 || res.status === 403) {
199
+ record.reachable = true;
200
+ record.authRequired = true;
201
+ record.latencyMs = Math.round(performance.now() - started);
202
+ return record;
203
+ }
204
+ if (res.ok) {
205
+ const json = await res.json().catch(() => null);
206
+ if (json && Array.isArray(json.data)) {
207
+ record.reachable = true;
208
+ modelsPayload = json;
209
+ record.models = json.data.map(m => m?.id).filter(Boolean);
210
+ }
211
+ }
212
+ } catch {
213
+ return record;
214
+ }
215
+
216
+ const ownedBy = (modelsPayload?.data || []).map(m => String(m?.owned_by || '').toLowerCase()).join(' ');
217
+ if (ownedBy.includes('lmstudio')) record.serverType = 'lmstudio';
218
+ else if (ownedBy.includes('vllm')) record.serverType = 'vllm';
219
+ else {
220
+ try {
221
+ const tagsRes = await fetchImpl(`http://${host}:${port}/api/tags`, {
222
+ headers: { Accept: 'application/json' },
223
+ signal: fetchSignal,
224
+ });
225
+ if (tagsRes.ok) {
226
+ const tags = await tagsRes.json().catch(() => null);
227
+ if (tags && Array.isArray(tags.models)) {
228
+ record.reachable = true;
229
+ record.serverType = 'ollama';
230
+ if (record.models.length === 0) {
231
+ record.models = tags.models.map(m => m?.name || m?.model).filter(Boolean);
232
+ }
233
+ }
234
+ }
235
+ } catch {}
236
+ }
237
+
238
+ if (record.reachable && record.serverType === 'openai-compatible') {
239
+ try {
240
+ const v0Res = await fetchImpl(`http://${host}:${port}/api/v0/models`, {
241
+ headers: { Accept: 'application/json' },
242
+ signal: fetchSignal,
243
+ });
244
+ if (v0Res.ok) {
245
+ const v0 = await v0Res.json().catch(() => null);
246
+ if (v0 && Array.isArray(v0.data)) {
247
+ record.serverType = 'lmstudio';
248
+ if (record.models.length === 0) {
249
+ record.models = v0.data.map(m => m?.id).filter(Boolean);
250
+ }
251
+ }
252
+ }
253
+ } catch {}
254
+ }
255
+
256
+ if (record.reachable && record.serverType === 'openai-compatible') {
257
+ try {
258
+ const versionRes = await fetchImpl(`http://${host}:${port}/version`, {
259
+ headers: { Accept: 'application/json' },
260
+ signal: fetchSignal,
261
+ });
262
+ if (versionRes.ok) {
263
+ const version = await versionRes.json().catch(() => null);
264
+ if (version && version.version) {
265
+ record.serverType = 'vllm';
266
+ }
267
+ }
268
+ } catch {}
269
+ }
270
+
271
+ record.serverName = SERVER_TYPE_LABELS[record.serverType] || record.serverType;
272
+ record.modelCount = record.models.length;
273
+ record.latencyMs = Math.round(performance.now() - started);
274
+ return record;
275
+ }
276
+
277
+ async function runPool(items, concurrency, worker, signal) {
278
+ const results = [];
279
+ let cursor = 0;
280
+ const lanes = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
281
+ while (cursor < items.length) {
282
+ if (signal?.aborted) throw abortError();
283
+ const index = cursor++;
284
+ results[index] = await worker(items[index], index);
285
+ }
286
+ });
287
+ await Promise.all(lanes);
288
+ return results;
289
+ }
290
+
291
+ let scanCache = null;
292
+
293
+ export function clearLanScanCache() {
294
+ scanCache = null;
295
+ }
296
+
297
+ export async function scanLan({
298
+ ports = DEFAULT_LLM_PORTS,
299
+ timeoutMs = 900,
300
+ probeTimeoutMs = 2500,
301
+ concurrency = 128,
302
+ subnets = null,
303
+ extraHosts = [],
304
+ maxHosts = Number(process.env.KIKU_LAN_SCAN_MAX_HOSTS) || DEFAULT_MAX_SCAN_HOSTS,
305
+ connector = defaultTcpProbe,
306
+ prober = classifyModelsEndpoint,
307
+ now = Date.now,
308
+ force = false,
309
+ signal = null,
310
+ } = {}) {
311
+ if (signal?.aborted) throw abortError();
312
+ const portList = [...new Set((Array.isArray(ports) ? ports : DEFAULT_LLM_PORTS).map(Number))].filter(
313
+ p => Number.isInteger(p) && p >= 1 && p <= 65535
314
+ );
315
+ if (portList.length === 0) throw new Error('At least one valid port is required');
316
+
317
+ const subnetList = subnets && subnets.length > 0 ? subnets : getLanCandidateSubnets().map(s => s.cidr);
318
+ const detectedHosts = expandTargets(subnetList, { maxHosts });
319
+ const hostList = [...new Set(['127.0.0.1', ...detectedHosts, ...extraHosts.map(String)])];
320
+ assertAllPrivate(hostList);
321
+
322
+ const cacheKey = JSON.stringify({ ports: portList, hosts: hostList });
323
+ if (!force && scanCache && scanCache.key === cacheKey && now() - scanCache.at < CACHE_TTL_MS) {
324
+ return { ...scanCache.result, cached: true };
325
+ }
326
+
327
+ const targets = [];
328
+ for (const host of hostList) {
329
+ for (const port of portList) {
330
+ targets.push({ host, port });
331
+ }
332
+ }
333
+
334
+ const started = now();
335
+ const openTargets = [];
336
+ await runPool(targets, concurrency, async ({ host, port }) => {
337
+ const isOpen = await connector(host, port, timeoutMs, signal);
338
+ if (isOpen) openTargets.push({ host, port });
339
+ }, signal);
340
+ openTargets.sort((a, b) => a.host.localeCompare(b.host, undefined, { numeric: true }) || a.port - b.port);
341
+ if (signal?.aborted) throw abortError();
342
+
343
+ const endpoints = await runPool(openTargets, Math.min(concurrency, 16), async ({ host, port }) => {
344
+ try {
345
+ return await prober({ host, port, timeoutMs: probeTimeoutMs, signal });
346
+ } catch (error) {
347
+ if (signal?.aborted) throw error;
348
+ return { host, port, url: `http://${host}:${port}/v1`, reachable: false, serverType: 'openai-compatible', serverName: 'OpenAI-Compatible', models: [], modelCount: 0, authRequired: false, latencyMs: null };
349
+ }
350
+ }, signal);
351
+
352
+ const found = endpoints
353
+ .filter(e => e && e.reachable)
354
+ .sort((a, b) => (a.latencyMs ?? 9e9) - (b.latencyMs ?? 9e9) || a.host.localeCompare(b.host, undefined, { numeric: true }));
355
+
356
+ const result = {
357
+ scannedAt: new Date(started).toISOString(),
358
+ subnets: subnetList,
359
+ hostsScanned: hostList.length,
360
+ ports: portList,
361
+ targetsScanned: targets.length,
362
+ openPorts: openTargets.length,
363
+ durationMs: now() - started,
364
+ endpoints: found,
365
+ };
366
+
367
+ scanCache = { key: cacheKey, at: now(), result };
368
+ return { ...result, cached: false };
369
+ }
@@ -1,6 +1,15 @@
1
- import { config, CLOUD_PROVIDERS, normalizeProviderModel, matchModelIdentifier, extractModelQuantization, savePersistedProviderKey, savePersistedActiveProvider, savePersistedProviderModel, getPersistedActiveProvider } from './config.js';
1
+ import { config, CLOUD_PROVIDERS, normalizeProviderModel, matchModelIdentifier, extractModelQuantization, savePersistedProviderKey, savePersistedActiveProvider, savePersistedProviderModel, getPersistedActiveProvider, getProviderBaseUrl, savePersistedProviderBaseUrl, setActiveProvider } from './config.js';
2
2
  import { parseApiErrorText } from './errors.js';
3
3
  import { estimateTokens } from './telemetry.js';
4
+ import { estimateTokenCount } from './contextCompactor.js';
5
+
6
+ function computeRetryDelay(attempt, headers) {
7
+ const retryAfter = Number(headers?.get?.('retry-after'));
8
+ if (Number.isFinite(retryAfter) && retryAfter > 0) {
9
+ return Math.min(retryAfter * 1000, 15000) + Math.floor(Math.random() * 400);
10
+ }
11
+ return Math.min(8000, 1200 * 2 ** attempt) + Math.floor(Math.random() * 400);
12
+ }
4
13
 
5
14
  export class LLMClient {
6
15
  constructor(options = {}) {
@@ -12,7 +21,7 @@ export class LLMClient {
12
21
 
13
22
  this.provider = providerId;
14
23
  this.providerName = providerDef.name || providerId;
15
- this.baseUrl = (options.baseUrl || providerDef.baseUrl || config.lmStudio.baseUrl).replace(/\/+$/, '');
24
+ this.baseUrl = (options.baseUrl || getProviderBaseUrl(providerId) || providerDef.baseUrl || config.lmStudio.baseUrl).replace(/\/+$/, '');
16
25
  this.apiKey = options.apiKey !== undefined
17
26
  ? options.apiKey
18
27
  : (providerDef.apiKey !== undefined ? providerDef.apiKey : (providerDef.isLocal ? (config.lmStudio.apiKey || 'lm-studio') : ''));
@@ -33,12 +42,21 @@ export class LLMClient {
33
42
 
34
43
  this.provider = pId;
35
44
  this.providerName = providerDef.name || pId;
36
- this.baseUrl = (options.baseUrl || providerDef.baseUrl).replace(/\/+$/, '');
45
+ this.baseUrl = (options.baseUrl || getProviderBaseUrl(pId) || providerDef.baseUrl).replace(/\/+$/, '');
37
46
  this.apiKey = options.apiKey !== undefined ? options.apiKey : (providerDef.apiKey || '');
38
47
  this.model = normalizeProviderModel(pId, options.model || providerDef.defaultModel);
39
48
  this.extraHeaders = { ...(providerDef.extraHeaders || {}), ...(options.extraHeaders || {}) };
40
49
 
41
- savePersistedActiveProvider(pId, this.model);
50
+ setActiveProvider(pId, { model: this.model, baseUrl: options.baseUrl || null });
51
+ return this.getProviderInfo();
52
+ }
53
+
54
+ setBaseUrl(baseUrl, providerId = null) {
55
+ const targetProvider = String(providerId || this.provider).toLowerCase().trim();
56
+ const cleanUrl = savePersistedProviderBaseUrl(targetProvider, baseUrl);
57
+ if (targetProvider === this.provider) {
58
+ this.baseUrl = cleanUrl;
59
+ }
42
60
  return this.getProviderInfo();
43
61
  }
44
62
 
@@ -91,6 +109,8 @@ export class LLMClient {
91
109
  }
92
110
  } else if (this.provider === 'lmstudio') {
93
111
  headers['Authorization'] = 'Bearer lm-studio';
112
+ } else if (this.provider === 'vllm') {
113
+ headers['Authorization'] = 'Bearer token-kiku';
94
114
  }
95
115
 
96
116
  return headers;
@@ -141,6 +161,9 @@ export class LLMClient {
141
161
  } else if (res.status === 429) {
142
162
  formattedError = `429 Rate Limit / Quota Exceeded: ${errSnippet}`;
143
163
  }
164
+ if (def.platformNotice && process.platform === 'win32') {
165
+ formattedError += `\n Notice: ${def.platformNotice}`;
166
+ }
144
167
 
145
168
  return {
146
169
  connected: false,
@@ -168,16 +191,19 @@ export class LLMClient {
168
191
  const exactMatch = models.find(m => m === this.model);
169
192
  const fuzzyMatch = models.find(m => matchModelIdentifier(this.model, m));
170
193
 
171
-
172
- const matchedGemma = (activeLoaded && /gemma/i.test(activeLoaded))
173
- ? activeLoaded
174
- : (fuzzyMatch || models.find(m =>
175
- m === 'google/gemma-4-e4b' ||
176
- m === 'gemma-4-e4b' ||
177
- m.toLowerCase().includes('gemma-4-e4b') ||
178
- (m.toLowerCase().includes('gemma') && m.toLowerCase().includes('e4b')) ||
179
- /gemma/i.test(m)
180
- ));
194
+
195
+ const modelIsGemmaOrUnset = !this.model || /gemma/i.test(this.model);
196
+ const matchedGemma = modelIsGemmaOrUnset
197
+ ? ((activeLoaded && /gemma/i.test(activeLoaded))
198
+ ? activeLoaded
199
+ : (fuzzyMatch || models.find(m =>
200
+ m === 'google/gemma-4-e4b' ||
201
+ m === 'gemma-4-e4b' ||
202
+ m.toLowerCase().includes('gemma-4-e4b') ||
203
+ (m.toLowerCase().includes('gemma') && m.toLowerCase().includes('e4b')) ||
204
+ /gemma/i.test(m)
205
+ )))
206
+ : null;
181
207
 
182
208
  const resolvedActive = exactMatch || (activeLoaded ? activeLoaded : (fuzzyMatch || matchedGemma || this.model));
183
209
 
@@ -214,32 +240,66 @@ export class LLMClient {
214
240
  };
215
241
  } catch (err) {
216
242
  const latencyMs = Date.now() - startTime;
243
+ let errMsg = err.name === 'AbortError'
244
+ ? `Connection timed out at ${this.baseUrl} (>8s)`
245
+ : `Cannot reach ${this.providerName} at ${this.baseUrl} (${err.message})`;
246
+ if (def.platformNotice && process.platform === 'win32') {
247
+ errMsg += `\n Notice: ${def.platformNotice}`;
248
+ }
217
249
  return {
218
250
  connected: false,
219
251
  provider: this.provider,
220
252
  providerName: this.providerName,
221
- error: err.name === 'AbortError'
222
- ? `Connection timed out at ${this.baseUrl} (>8s)`
223
- : `Cannot reach ${this.providerName} at ${this.baseUrl} (${err.message})`,
253
+ error: errMsg,
224
254
  models: [],
225
255
  latencyMs
226
256
  };
227
257
  }
228
258
  }
229
259
 
230
- async chat({
231
- messages,
232
- tools = null,
233
- temperature = 0.3,
234
- maxTokens = 8192,
235
- stream = true,
236
- signal = null,
237
- onToken = null,
238
- onThought = null
239
- }) {
240
- const isLegacyReasoningModel = /^(o1|o3|deepseek-reasoner)/i.test(this.model) || this.model.includes('reasoner');
260
+ async healthCheck() {
261
+ return this.checkConnection();
262
+ }
263
+
264
+ _isLegacyReasoningModel() {
265
+ return /^(o1|o3|deepseek-reasoner)/i.test(this.model) || this.model.includes('reasoner');
266
+ }
267
+
268
+
269
+ toolsWillBeOmitted() {
270
+ return this._isLegacyReasoningModel();
271
+ }
272
+
273
+ async chat(firstArg, secondArg = {}) {
274
+ let messages, tools, temperature, maxTokens, stream, signal, onToken, onThought;
275
+ if (Array.isArray(firstArg)) {
276
+ messages = firstArg;
277
+ ({
278
+ tools = null,
279
+ temperature = 0.3,
280
+ maxTokens = 8192,
281
+ stream = true,
282
+ signal = null,
283
+ onToken = null,
284
+ onThought = null
285
+ } = secondArg || {});
286
+ } else {
287
+ ({
288
+ messages,
289
+ tools = null,
290
+ temperature = 0.3,
291
+ maxTokens = 8192,
292
+ stream = true,
293
+ signal = null,
294
+ onToken = null,
295
+ onThought = null
296
+ } = firstArg || {});
297
+ }
298
+ const isLegacyReasoningModel = this._isLegacyReasoningModel();
241
299
  const usesCompletionTokenLimit = /^(o1|o3|gpt-5(?:\.|-|$))/i.test(this.model);
242
300
  const rejectsTemperature = /^(o1|o3|gpt-5(?:\.|-|$))/i.test(this.model);
301
+
302
+ this.toolsDisabledReason = null;
243
303
 
244
304
  const buildPayload = (includeTools = true, includeTemp = true) => {
245
305
  const p = {
@@ -258,9 +318,13 @@ export class LLMClient {
258
318
  p.max_tokens = maxTokens;
259
319
  }
260
320
 
261
- if (includeTools && tools && Array.isArray(tools) && tools.length > 0 && !isLegacyReasoningModel) {
262
- p.tools = tools;
263
- p.tool_choice = "auto";
321
+ if (includeTools && tools && Array.isArray(tools) && tools.length > 0) {
322
+ if (isLegacyReasoningModel) {
323
+ this.toolsDisabledReason = 'reasoning_model';
324
+ } else {
325
+ p.tools = tools;
326
+ p.tool_choice = "auto";
327
+ }
264
328
  }
265
329
 
266
330
  if (stream) {
@@ -278,7 +342,11 @@ export class LLMClient {
278
342
  const abortHandler = () => controller.abort();
279
343
  if (signal) signal.addEventListener('abort', abortHandler, { once: true });
280
344
 
345
+ let lastTimeoutReset = 0;
281
346
  const resetTimeout = () => {
347
+ const now = Date.now();
348
+ if (now - lastTimeoutReset < 500) return;
349
+ lastTimeoutReset = now;
282
350
  clearTimeout(timeoutId);
283
351
  timeoutId = setTimeout(() => controller.abort(), this.timeout);
284
352
  };
@@ -308,6 +376,7 @@ export class LLMClient {
308
376
 
309
377
  if ((lowerErr.includes('tool') || lowerErr.includes('function') || lowerErr.includes('schema')) && payload.tools) {
310
378
  payload = buildPayload(false, true);
379
+ this.toolsDisabledReason = 'provider_rejected_schema';
311
380
  retryNeeded = true;
312
381
  } else if (lowerErr.includes('temperature') && payload.temperature !== undefined) {
313
382
  payload = buildPayload(Boolean(payload.tools), false);
@@ -345,8 +414,7 @@ export class LLMClient {
345
414
  }
346
415
 
347
416
  if ([429, 500, 502, 503, 504].includes(res.status) && attempt < maxRetries && !controller.signal.aborted) {
348
- const backoffMs = (attempt + 1) * 1200;
349
- await new Promise(r => setTimeout(r, backoffMs));
417
+ await new Promise(r => setTimeout(r, computeRetryDelay(attempt, res.headers)));
350
418
  continue;
351
419
  }
352
420
 
@@ -362,8 +430,7 @@ export class LLMClient {
362
430
  if (controller.signal.aborted || attempt >= maxRetries || fetchErr.status) {
363
431
  throw fetchErr;
364
432
  }
365
- const backoffMs = (attempt + 1) * 1200;
366
- await new Promise(r => setTimeout(r, backoffMs));
433
+ await new Promise(r => setTimeout(r, computeRetryDelay(attempt, null)));
367
434
  }
368
435
  }
369
436
 
@@ -388,8 +455,7 @@ export class LLMClient {
388
455
  total_tokens: data.usage.total_tokens || ((data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0))
389
456
  };
390
457
  } else {
391
- const inputPrompt = messages.map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''))).join('\n');
392
- const pTokens = estimateTokens(inputPrompt);
458
+ const pTokens = estimateTokenCount(messages);
393
459
  const cTokens = estimateTokens(msg.content || '');
394
460
  msg.usage = {
395
461
  prompt_tokens: pTokens,
@@ -409,6 +475,7 @@ export class LLMClient {
409
475
  let toolCalls = [];
410
476
  let buffer = '';
411
477
  let reportedUsage = null;
478
+ let malformedChunks = 0;
412
479
 
413
480
  while (true) {
414
481
  const { done, value } = await reader.read();
@@ -460,19 +527,24 @@ export class LLMClient {
460
527
  }
461
528
  }
462
529
  } catch {
530
+ malformedChunks++;
463
531
  }
464
532
  }
465
533
  }
466
534
 
467
535
  clearTimeout(timeoutId);
468
536
 
537
+ if (malformedChunks > 0) {
538
+
539
+ console.warn(`[${this.providerName}] Dropped ${malformedChunks} malformed SSE chunk(s) during streaming.`);
540
+ }
541
+
469
542
  let promptTokens = reportedUsage?.prompt_tokens ?? 0;
470
543
  let completionTokens = reportedUsage?.completion_tokens ?? 0;
471
544
  let reasoningTokens = reportedUsage?.completion_tokens_details?.reasoning_tokens ?? 0;
472
545
 
473
546
  if (!promptTokens) {
474
- const inputPrompt = messages.map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''))).join('\n');
475
- promptTokens = estimateTokens(inputPrompt);
547
+ promptTokens = estimateTokenCount(messages);
476
548
  }
477
549
  if (!completionTokens) {
478
550
  completionTokens = estimateTokens(fullContent + (fullReasoning || ''));