kiku-agent 1.3.2 → 1.3.3

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 (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -1,690 +1,1088 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import { PROVIDER_KEYS_FILE, SETTINGS_FILE } from './appPaths.js';
5
-
6
- const PROVIDER_MODEL_ALIASES = {
7
- lmstudio: {
8
- 'q8': 'google/gemma-4-e4b-Q8_0',
9
- 'q8_0': 'google/gemma-4-e4b-Q8_0',
10
- 'q8-0': 'google/gemma-4-e4b-Q8_0',
11
- '8bit': 'google/gemma-4-e4b-Q8_0',
12
- '8-bit': 'google/gemma-4-e4b-Q8_0',
13
- 'gemma-q8': 'google/gemma-4-e4b-Q8_0',
14
- 'gemma-q8_0': 'google/gemma-4-e4b-Q8_0',
15
- 'gemma-4-e4b-q8': 'google/gemma-4-e4b-Q8_0',
16
- 'gemma-4-e4b-q8_0': 'google/gemma-4-e4b-Q8_0',
17
- 'google/gemma-4-e4b-q8_0': 'google/gemma-4-e4b-Q8_0',
18
- 'google/gemma-4-e4b-q8': 'google/gemma-4-e4b-Q8_0',
19
-
20
- 'q6': 'google/gemma-4-e4b-Q6_K',
21
- 'q6_k': 'google/gemma-4-e4b-Q6_K',
22
- 'q6-k': 'google/gemma-4-e4b-Q6_K',
23
- '6bit': 'google/gemma-4-e4b-Q6_K',
24
- '6-bit': 'google/gemma-4-e4b-Q6_K',
25
- 'gemma-q6': 'google/gemma-4-e4b-Q6_K',
26
- 'gemma-q6_k': 'google/gemma-4-e4b-Q6_K',
27
- 'gemma-4-e4b-q6': 'google/gemma-4-e4b-Q6_K',
28
- 'gemma-4-e4b-q6_k': 'google/gemma-4-e4b-Q6_K',
29
- 'google/gemma-4-e4b-q6_k': 'google/gemma-4-e4b-Q6_K',
30
- 'google/gemma-4-e4b-q6': 'google/gemma-4-e4b-Q6_K',
31
-
32
- 'q4': 'google/gemma-4-e4b-Q4_K_M',
33
- 'q4_k': 'google/gemma-4-e4b-Q4_K_M',
34
- 'q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
35
- 'q4-k-m': 'google/gemma-4-e4b-Q4_K_M',
36
- '4bit': 'google/gemma-4-e4b-Q4_K_M',
37
- '4-bit': 'google/gemma-4-e4b-Q4_K_M',
38
- 'gemma-q4': 'google/gemma-4-e4b-Q4_K_M',
39
- 'gemma-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
40
- 'gemma-4-e4b-q4': 'google/gemma-4-e4b-Q4_K_M',
41
- 'gemma-4-e4b-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
42
- 'google/gemma-4-e4b-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
43
- 'google/gemma-4-e4b-q4': 'google/gemma-4-e4b-Q4_K_M',
44
-
45
- 'gemma': 'google/gemma-4-e4b',
46
- 'gemma4': 'google/gemma-4-e4b',
47
- 'gemma-4': 'google/gemma-4-e4b',
48
- 'gemma-4-e4b': 'google/gemma-4-e4b',
49
- },
50
- vllm: {
51
- 'gemma': 'google/gemma-4-e4b',
52
- 'gemma4': 'google/gemma-4-e4b',
53
- 'gemma-4': 'google/gemma-4-e4b',
54
- 'gemma-4-e4b': 'google/gemma-4-e4b',
55
- 'qwen': 'Qwen/Qwen2.5-7B-Instruct',
56
- 'llama': 'meta-llama/Llama-3.1-8B-Instruct',
57
- },
58
- openai: {
59
- 'o1-mini': 'gpt-5.6-luna',
60
- 'o3-mini': 'gpt-5.6-luna',
61
- },
62
- openrouter: {
63
- auto: 'openrouter/auto',
64
- },
65
- groq: {
66
- 'gpt-oss-120b': 'openai/gpt-oss-120b',
67
- 'gpt-oss-20b': 'openai/gpt-oss-20b',
68
- },
69
- deepseek: {
70
- 'deepseek-chat': 'deepseek-v4-flash',
71
- 'deepseek-reasoner': 'deepseek-v4-flash',
72
- },
73
- gemini: {
74
- 'gemini-3.1-pro': 'gemini-3.1-pro-preview',
75
- },
76
- };
77
-
78
- const GENERIC_MODEL_TOKENS = new Set(['model', 'chat', 'default', 'base', 'instruct', 'text', 'embedding']);
79
-
80
- export function extractModelQuantization(modelId) {
81
- if (!modelId || typeof modelId !== 'string') return null;
82
- const clean = modelId.toUpperCase();
83
- const m = clean.match(/(Q[0-9]+_[A-Z0-9_]+|Q[0-9]+_[A-Z]|Q[0-9]+|[0-9]+BIT|FP16|FP32|BF16)/i);
84
- return m ? m[1].toUpperCase() : null;
85
- }
86
-
87
- export function cleanModelName(modelId) {
88
- if (!modelId || typeof modelId !== 'string') return '';
89
- let name = modelId.trim();
90
- name = name.replace(/\.(gguf|bin|safetensors)$/i, '');
91
- if (name.includes('/')) {
92
- const parts = name.split('/').filter(Boolean);
93
- name = parts[parts.length - 1] || parts[0];
94
- }
95
- return name.toLowerCase().replace(/[^a-z0-9_-]/g, '');
96
- }
97
-
98
- export function matchModelIdentifier(target, candidate) {
99
- if (!target || !candidate) return false;
100
- if (target === candidate) return true;
101
-
102
- const targetLower = String(target).toLowerCase().trim();
103
- const candLower = String(candidate).toLowerCase().trim();
104
- if (targetLower === candLower) return true;
105
-
106
- const targetClean = cleanModelName(target);
107
- const candClean = cleanModelName(candidate);
108
- if (targetClean && candClean && targetClean === candClean) return true;
109
-
110
- const targetQuant = extractModelQuantization(target);
111
- const candQuant = extractModelQuantization(candidate);
112
-
113
- const targetIsGemma = /gemma/i.test(target);
114
- const candIsGemma = /gemma/i.test(candidate);
115
-
116
- if (candIsGemma) {
117
- if (targetIsGemma) {
118
- if (targetQuant && candQuant) {
119
- const normTargetQ = targetQuant.replace(/[^A-Z0-9]/g, '');
120
- const normCandQ = candQuant.replace(/[^A-Z0-9]/g, '');
121
- if (normTargetQ === normCandQ || normCandQ.startsWith(normTargetQ) || normTargetQ.startsWith(normCandQ)) {
122
- return true;
123
- }
124
- } else if (!targetQuant) {
125
- if (targetClean.includes('gemma') && candClean.includes('gemma')) {
126
- return true;
127
- }
128
- }
129
- } else if (targetQuant && !GENERIC_MODEL_TOKENS.has(targetClean)) {
130
- if (candQuant) {
131
- const normTargetQ = targetQuant.replace(/[^A-Z0-9]/g, '');
132
- const normCandQ = candQuant.replace(/[^A-Z0-9]/g, '');
133
- if (normTargetQ === normCandQ || normCandQ.startsWith(normTargetQ) || normTargetQ.startsWith(normCandQ)) {
134
- return true;
135
- }
136
- }
137
- }
138
- }
139
-
140
- if (targetClean && candClean && !GENERIC_MODEL_TOKENS.has(targetClean) && targetClean.length >= 5) {
141
- if (candClean.includes(targetClean) || (candClean.length >= 5 && targetClean.includes(candClean))) {
142
- return true;
143
- }
144
- }
145
-
146
- return false;
147
- }
148
-
149
- export function normalizeProviderModel(providerId, modelId) {
150
- const normalizedProvider = String(providerId || '').toLowerCase().trim();
151
- const normalizedModel = String(modelId || '').trim();
152
- const aliasMatch = PROVIDER_MODEL_ALIASES[normalizedProvider]?.[normalizedModel.toLowerCase()]
153
- || PROVIDER_MODEL_ALIASES[normalizedProvider]?.[normalizedModel];
154
- return aliasMatch || normalizedModel;
155
- }
156
-
157
- export const DEFAULT_LOCAL_ENDPOINTS = Object.freeze({
158
- lmstudio: 'http://127.0.0.1:1234/v1',
159
- vllm: 'http://127.0.0.1:8000/v1',
160
- });
161
-
162
- export const CLOUD_PROVIDERS = {
163
- lmstudio: {
164
- id: 'lmstudio',
165
- name: 'LM Studio (Local)',
166
- baseUrl: process.env.LMSTUDIO_BASE_URL || process.env.OPENAI_BASE_URL || 'http://127.0.0.1:1234/v1',
167
- apiKey: process.env.LMSTUDIO_API_KEY || 'lm-studio',
168
- defaultModel: 'google/gemma-4-e4b',
169
- isLocal: true,
170
- envKey: 'LMSTUDIO_BASE_URL',
171
- docUrl: 'https://lmstudio.ai/docs',
172
- apiRefUrl: 'https://lmstudio.ai/docs/api/local-server',
173
- pricingSummary: '100% Free / Local (No API Key required)',
174
- features: ['local', 'private', 'offline', 'zero-cost', 'openai-compatible'],
175
- recommendedModels: [
176
- { id: 'google/gemma-4-e4b', name: 'Gemma 4 e4b (Default / Q4_K_M)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: 'Recommended default. Balanced 4-bit quantization (Q4_K_M) — fastest inference and lowest RAM usage.' },
177
- { id: 'google/gemma-4-e4b-Q4_K_M', name: 'Gemma 4 e4b (Q4_K_M Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '4-bit medium quantization (Q4_K_M) optimized for speed and low memory footprint (~2.5GB RAM).' },
178
- { id: 'google/gemma-4-e4b-Q6_K', name: 'Gemma 4 e4b (Q6_K Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '6-bit quantization (Q6_K) offering higher precision and near-original fidelity (~3.5GB RAM).' },
179
- { id: 'google/gemma-4-e4b-Q8_0', name: 'Gemma 4 e4b (Q8_0 Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '8-bit full quantization (Q8_0) for maximum accuracy and reasoning precision (~4.5GB RAM).' },
180
- ],
181
- },
182
- vllm: {
183
- id: 'vllm',
184
- name: 'vLLM (Local / WSL)',
185
- baseUrl: process.env.VLLM_BASE_URL || 'http://127.0.0.1:8000/v1',
186
- apiKey: process.env.VLLM_API_KEY || 'token-kiku',
187
- defaultModel: process.env.VLLM_MODEL || 'google/gemma-4-e4b',
188
- isLocal: true,
189
- envKey: 'VLLM_BASE_URL',
190
- docUrl: 'https://docs.vllm.ai',
191
- apiRefUrl: 'https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html',
192
- pricingSummary: '100% Free / Local (High-throughput PagedAttention server via WSL/Linux)',
193
- features: ['local', 'high-throughput', 'paged-attention', 'wsl-required', 'openai-compatible'],
194
- platformNotice: 'You cannot install vLLM natively on Windows. Please install and run vLLM via WSL (Windows Subsystem for Linux, e.g. wsl -d kali-linux or Ubuntu) using "vllm serve <model> --port 8000".',
195
- recommendedModels: [
196
- { id: 'google/gemma-4-e4b', name: 'Gemma 4 e4b (Default)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: 'Recommended default model served via vllm serve on port 8000.' },
197
- { id: 'Qwen/Qwen2.5-7B-Instruct', name: 'Qwen 2.5 7B Instruct', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'High capability open model with function calling on vLLM.' },
198
- { id: 'meta-llama/Llama-3.1-8B-Instruct', name: 'Llama 3.1 8B Instruct', tier: 'free', cost: '$0.00 (Local)', context: '128k', hasTools: true, description: 'Fast general-purpose open model hosted on vLLM.' },
199
- ],
200
- },
201
- openai: {
202
- id: 'openai',
203
- name: 'OpenAI Cloud',
204
- baseUrl: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
205
- apiKey: process.env.OPENAI_API_KEY || '',
206
- defaultModel: normalizeProviderModel('openai', process.env.OPENAI_MODEL || 'gpt-5.6-luna'),
207
- isLocal: false,
208
- envKey: 'OPENAI_API_KEY',
209
- docUrl: 'https://developers.openai.com/api/docs/models',
210
- apiRefUrl: 'https://developers.openai.com/api/reference',
211
- pricingSummary: 'GPT-5.6 Luna: $0.20/1M in, $1.20/1M out; Terra/Sol available for higher capability',
212
- features: ['tools', 'streaming', 'vision', 'reasoning', 'system-prompt'],
213
- recommendedModels: [
214
- { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', tier: 'cost-sensitive', cost: '$0.20 / 1M in', context: '1.05M', hasTools: true, description: 'Recommended default for efficient, high-volume multilingual agent workloads.' },
215
- { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', tier: 'balanced', cost: '$2.00 / 1M in', context: '1.05M', hasTools: true, description: 'Balances intelligence and cost for production reasoning and tool use.' },
216
- { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', tier: 'flagship', cost: '$4.00 / 1M in', context: '1.05M', hasTools: true, description: 'Flagship model for complex professional reasoning and coding.' },
217
- ],
218
- },
219
- openrouter: {
220
- id: 'openrouter',
221
- name: 'OpenRouter Cloud',
222
- baseUrl: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
223
- apiKey: process.env.OPENROUTER_API_KEY || '',
224
- defaultModel: normalizeProviderModel('openrouter', process.env.OPENROUTER_MODEL || 'openrouter/auto'),
225
- extraHeaders: {
226
- 'HTTP-Referer': 'https://jujingyi-kikuuu.vercel.app/',
227
- 'X-OpenRouter-Title': 'Kiku Agent',
228
- },
229
- isLocal: false,
230
- envKey: 'OPENROUTER_API_KEY',
231
- docUrl: 'https://openrouter.ai/docs/guides/routing/routers/auto-router',
232
- apiRefUrl: 'https://openrouter.ai/docs/api/reference/overview',
233
- pricingSummary: 'Auto Router selects a current model dynamically; usage is billed at the routed model rate',
234
- features: ['universal-routing', 'tools', 'streaming', 'free-tier', 'fallback'],
235
- recommendedModels: [
236
- { id: 'openrouter/auto', name: 'OpenRouter Auto Router', tier: 'dynamic', cost: 'Routed model rate', context: 'Model-dependent', hasTools: true, description: 'Recommended default. Chooses from the current catalog so a retired third-party model does not break startup.' },
237
- ],
238
- },
239
- groq: {
240
- id: 'groq',
241
- name: 'Groq Cloud',
242
- baseUrl: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1',
243
- apiKey: process.env.GROQ_API_KEY || '',
244
- defaultModel: normalizeProviderModel('groq', process.env.GROQ_MODEL || 'openai/gpt-oss-120b'),
245
- isLocal: false,
246
- envKey: 'GROQ_API_KEY',
247
- docUrl: 'https://console.groq.com/docs/quickstart',
248
- apiRefUrl: 'https://console.groq.com/docs/api-reference',
249
- pricingSummary: 'Ultra-fast LPU inference (GPT-OSS 20B: $0.075/1M in, GPT-OSS 120B: $0.15/1M in)',
250
- features: ['lpu-speed', 'tools', 'streaming', 'openai-compatible'],
251
- recommendedModels: [
252
- { id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B', tier: 'flagship', cost: '$0.15 / 1M in', context: '131k', hasTools: true, description: 'Recommended default. Production-grade 120B reasoning model with tool use on Groq.' },
253
- { id: 'openai/gpt-oss-20b', name: 'GPT-OSS 20B', tier: 'ultra-cheap', cost: '$0.075 / 1M in', context: '131k', hasTools: true, description: 'Fast production model for lower-cost, high-volume requests.' },
254
- ],
255
- },
256
- deepseek: {
257
- id: 'deepseek',
258
- name: 'DeepSeek Cloud',
259
- baseUrl: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
260
- apiKey: process.env.DEEPSEEK_API_KEY || '',
261
- defaultModel: normalizeProviderModel('deepseek', process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash'),
262
- isLocal: false,
263
- envKey: 'DEEPSEEK_API_KEY',
264
- docUrl: 'https://api-docs.deepseek.com',
265
- apiRefUrl: 'https://api-docs.deepseek.com/api/create-chat-completion',
266
- pricingSummary: 'V4 Flash: $0.22-$0.44/1M uncached input; V4 Pro: $0.66-$1.32/1M (off-peak/peak)',
267
- features: ['deepseek-v4', 'thinking', 'tools', 'streaming', 'context-cache', '1m-context'],
268
- recommendedModels: [
269
- { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', tier: 'balanced', cost: '$0.22-$0.44 / 1M uncached in', context: '1M', hasTools: true, description: 'Recommended default. Current V4 model with tool calls and thinking/non-thinking modes.' },
270
- { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', tier: 'flagship', cost: '$0.66-$1.32 / 1M uncached in', context: '1M', hasTools: true, description: 'Higher-capability V4 model with tool calls and thinking mode.' },
271
- ],
272
- },
273
- gemini: {
274
- id: 'gemini',
275
- name: 'Google Gemini',
276
- baseUrl: process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai',
277
- apiKey: process.env.GEMINI_API_KEY || '',
278
- defaultModel: normalizeProviderModel('gemini', process.env.GEMINI_MODEL || 'gemini-3.7-flash'),
279
- isLocal: false,
280
- envKey: 'GEMINI_API_KEY',
281
- docUrl: 'https://ai.google.dev/gemini-api/docs',
282
- apiRefUrl: 'https://ai.google.dev/api/generate-content',
283
- pricingSummary: 'Gemini 3.7 Flash: free tier available; paid standard $0.75/1M input through 2026',
284
- features: ['1m-context', 'multimodal', 'tools', 'streaming', 'thinking'],
285
- recommendedModels: [
286
- { id: 'gemini-3.7-flash', name: 'Gemini 3.7 Flash', tier: 'latest-stable', cost: '$0.75 / 1M in (2026)', context: '1M', hasTools: true, description: 'Recommended default. Latest stable Flash model for agentic workflows and multimodal reasoning.' },
287
- { id: 'gemini-3.5-flash-lite', name: 'Gemini 3.5 Flash-Lite', tier: 'lowest-cost', cost: '$0.30 / 1M in', context: '1M', hasTools: true, description: 'Stable, cost-efficient model for high-volume translation and simple processing.' },
288
- { id: 'gemini-3.6-flash', name: 'Gemini 3.6 Flash', tier: 'stable', cost: '$0.75 / 1M in (2026)', context: '1M', hasTools: true, description: 'Stable previous-generation Flash fallback with function calling and thinking.' },
289
- ],
290
- },
291
- custom: {
292
- id: 'custom',
293
- name: 'Custom OpenAI-Compatible',
294
- baseUrl: process.env.LLM_BASE_URL || '',
295
- apiKey: process.env.LLM_API_KEY || '',
296
- defaultModel: process.env.LLM_MODEL || 'default',
297
- isLocal: false,
298
- envKey: 'LLM_BASE_URL',
299
- docUrl: 'https://platform.openai.com/docs/api-reference',
300
- apiRefUrl: 'https://platform.openai.com/docs/api-reference/chat',
301
- pricingSummary: 'Self-hosted or Custom Gateway',
302
- features: ['custom-endpoint', 'openai-compatible', 'tools', 'streaming'],
303
- recommendedModels: [
304
- { id: 'default', name: 'Default Model', tier: 'custom', cost: 'Custom', context: 'Custom', hasTools: true, description: 'Custom model specified via LLM_MODEL or /model use <id>.' },
305
- ],
306
- },
307
- };
308
-
309
- let cachedSettings = null;
310
- let lastSettingsLoadTime = 0;
311
- const SETTINGS_CACHE_TTL_MS = 2000;
312
-
313
- export function invalidateSettingsCache() {
314
- cachedSettings = null;
315
- lastSettingsLoadTime = 0;
316
- }
317
-
318
- export function loadPersistedSettings(force = false) {
319
- const now = Date.now();
320
- if (!force && cachedSettings && (now - lastSettingsLoadTime < SETTINGS_CACHE_TTL_MS)) {
321
- return cachedSettings;
322
- }
323
- try {
324
- if (fs.existsSync(SETTINGS_FILE)) {
325
- const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
326
- const parsed = JSON.parse(data);
327
- if (parsed && typeof parsed === 'object') {
328
- if (parsed.providerModels && typeof parsed.providerModels === 'object') {
329
- for (const [pId, model] of Object.entries(parsed.providerModels)) {
330
- const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
331
- if (providerDef && typeof model === 'string' && model.trim()) {
332
- providerDef.defaultModel = model.trim();
333
- }
334
- }
335
- }
336
- if (parsed.providerBaseUrls && typeof parsed.providerBaseUrls === 'object') {
337
- for (const [pId, url] of Object.entries(parsed.providerBaseUrls)) {
338
- const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
339
- if (providerDef && typeof url === 'string' && url.trim()) {
340
- providerDef.baseUrl = url.trim().replace(/\/+$/, '');
341
- }
342
- }
343
- }
344
- cachedSettings = parsed;
345
- lastSettingsLoadTime = now;
346
- return parsed;
347
- }
348
- }
349
- } catch {}
350
- cachedSettings = {};
351
- lastSettingsLoadTime = now;
352
- return {};
353
- }
354
-
355
- export function getProviderBaseUrl(providerId) {
356
- const pId = String(providerId || '').toLowerCase().trim();
357
- const settings = loadPersistedSettings();
358
- if (settings.providerBaseUrls && typeof settings.providerBaseUrls[pId] === 'string' && settings.providerBaseUrls[pId].trim()) {
359
- return settings.providerBaseUrls[pId].trim().replace(/\/+$/, '');
360
- }
361
- const providerDef = CLOUD_PROVIDERS[pId];
362
- if (providerDef) {
363
- if (providerDef.envKey && process.env[providerDef.envKey]) {
364
- return process.env[providerDef.envKey].trim().replace(/\/+$/, '');
365
- }
366
- if (pId === 'lmstudio' && process.env.OPENAI_BASE_URL) {
367
- return process.env.OPENAI_BASE_URL.trim().replace(/\/+$/, '');
368
- }
369
- if (DEFAULT_LOCAL_ENDPOINTS[pId]) {
370
- return DEFAULT_LOCAL_ENDPOINTS[pId];
371
- }
372
- if (providerDef.baseUrl) {
373
- return providerDef.baseUrl.trim().replace(/\/+$/, '');
374
- }
375
- }
376
- return DEFAULT_LOCAL_ENDPOINTS.lmstudio;
377
- }
378
-
379
- export function savePersistedProviderBaseUrl(providerId, baseUrl) {
380
- const pId = String(providerId || '').toLowerCase().trim();
381
- const cleanUrl = String(baseUrl || '').trim().replace(/\/+$/, '');
382
- if (!CLOUD_PROVIDERS[pId] || !cleanUrl) return cleanUrl;
383
-
384
- const settings = loadPersistedSettings();
385
- if (!settings.providerBaseUrls) settings.providerBaseUrls = {};
386
- settings.providerBaseUrls[pId] = cleanUrl;
387
- if (CLOUD_PROVIDERS[pId]) {
388
- CLOUD_PROVIDERS[pId].baseUrl = cleanUrl;
389
- }
390
- if (config.activeProvider === pId && config.lmStudio) {
391
- config.lmStudio.baseUrl = cleanUrl;
392
- }
393
-
394
- try {
395
- const dir = path.dirname(SETTINGS_FILE);
396
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
397
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
398
- invalidateSettingsCache();
399
- } catch (err) {
400
- console.error(`Failed to save baseUrl to ${SETTINGS_FILE}:`, err.message);
401
- }
402
- return cleanUrl;
403
- }
404
-
405
- export function getPersistedActiveProvider() {
406
- const settings = loadPersistedSettings();
407
- if (settings.activeProvider && CLOUD_PROVIDERS[settings.activeProvider.toLowerCase()]) {
408
- return settings.activeProvider.toLowerCase();
409
- }
410
- return null;
411
- }
412
-
413
- export function savePersistedActiveProvider(providerId, model = null, baseUrl = null) {
414
- const pId = String(providerId || '').toLowerCase().trim();
415
- if (!CLOUD_PROVIDERS[pId]) return;
416
-
417
- const settings = loadPersistedSettings();
418
- settings.activeProvider = pId;
419
- if (!settings.providerModels) settings.providerModels = {};
420
- if (!settings.providerBaseUrls) settings.providerBaseUrls = {};
421
-
422
- if (model) {
423
- const cleanModel = String(model).trim();
424
- settings.providerModels[pId] = cleanModel;
425
- settings.activeModel = cleanModel;
426
- if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].defaultModel = cleanModel;
427
- }
428
-
429
- if (baseUrl) {
430
- const cleanUrl = String(baseUrl).trim().replace(/\/+$/, '');
431
- settings.providerBaseUrls[pId] = cleanUrl;
432
- if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].baseUrl = cleanUrl;
433
- }
434
-
435
- try {
436
- const dir = path.dirname(SETTINGS_FILE);
437
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
438
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
439
- invalidateSettingsCache();
440
- } catch (err) {
441
- console.error(`Failed to save settings to ${SETTINGS_FILE}:`, err.message);
442
- }
443
- }
444
-
445
- export function setActiveProvider(providerId, { model = null, baseUrl = null } = {}) {
446
- const pId = String(providerId || '').toLowerCase().trim();
447
- if (!CLOUD_PROVIDERS[pId]) {
448
- throw new Error(`Unknown provider "${providerId}".`);
449
- }
450
- savePersistedActiveProvider(pId, model, baseUrl);
451
- config.activeProvider = pId;
452
- const def = CLOUD_PROVIDERS[pId];
453
- if (config.lmStudio) {
454
- config.lmStudio.baseUrl = def.baseUrl;
455
- config.lmStudio.apiKey = def.apiKey;
456
- config.lmStudio.model = def.defaultModel;
457
- config.lmStudio.defaultModel = def.defaultModel;
458
- }
459
- return {
460
- provider: pId,
461
- name: def.name,
462
- baseUrl: def.baseUrl,
463
- model: def.defaultModel,
464
- isLocal: Boolean(def.isLocal),
465
- platformNotice: def.platformNotice || null,
466
- };
467
- }
468
-
469
- export function getUnifiedProviderCatalog() {
470
- const active = config.activeProvider || resolveDefaultProvider();
471
- return {
472
- activeProvider: active,
473
- activeBaseUrl: getProviderBaseUrl(active),
474
- activeModel: CLOUD_PROVIDERS[active]?.defaultModel || 'google/gemma-4-e4b',
475
- localEndpoints: {
476
- lmstudio: {
477
- id: 'lmstudio',
478
- name: CLOUD_PROVIDERS.lmstudio.name,
479
- baseUrl: getProviderBaseUrl('lmstudio'),
480
- defaultModel: CLOUD_PROVIDERS.lmstudio.defaultModel,
481
- isActive: active === 'lmstudio',
482
- },
483
- vllm: {
484
- id: 'vllm',
485
- name: CLOUD_PROVIDERS.vllm.name,
486
- baseUrl: getProviderBaseUrl('vllm'),
487
- defaultModel: CLOUD_PROVIDERS.vllm.defaultModel,
488
- platformNotice: CLOUD_PROVIDERS.vllm.platformNotice || null,
489
- isActive: active === 'vllm',
490
- }
491
- },
492
- providers: Object.values(CLOUD_PROVIDERS).map(p => ({
493
- id: p.id,
494
- name: p.name,
495
- baseUrl: getProviderBaseUrl(p.id),
496
- defaultModel: p.defaultModel,
497
- isLocal: Boolean(p.isLocal),
498
- isActive: p.id === active,
499
- }))
500
- };
501
- }
502
-
503
- export function savePersistedProviderModel(providerId, model) {
504
- const pId = String(providerId || '').toLowerCase().trim();
505
- const cleanModel = String(model || '').trim();
506
- if (!CLOUD_PROVIDERS[pId] || !cleanModel) return;
507
-
508
- const settings = loadPersistedSettings();
509
- if (!settings.providerModels) settings.providerModels = {};
510
- settings.providerModels[pId] = cleanModel;
511
- if (settings.activeProvider === pId) {
512
- settings.activeModel = cleanModel;
513
- }
514
- if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].defaultModel = cleanModel;
515
-
516
- try {
517
- const dir = path.dirname(SETTINGS_FILE);
518
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
519
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
520
- invalidateSettingsCache();
521
- } catch (err) {
522
- console.error(`Failed to save model to ${SETTINGS_FILE}:`, err.message);
523
- }
524
- }
525
-
526
- export function resolveDefaultProvider() {
527
- const explicit = String(process.env.LLM_PROVIDER || '').toLowerCase().trim();
528
- if (explicit && CLOUD_PROVIDERS[explicit]) return explicit;
529
-
530
- const persisted = getPersistedActiveProvider();
531
- if (persisted && CLOUD_PROVIDERS[persisted]) {
532
- const def = CLOUD_PROVIDERS[persisted];
533
- if (def.isLocal || def.apiKey) {
534
- return persisted;
535
- }
536
- }
537
-
538
- if (process.env.OPENROUTER_API_KEY && !process.env.OPENROUTER_API_KEY.includes('...')) return 'openrouter';
539
- if (process.env.OPENAI_API_KEY && !process.env.OPENAI_API_KEY.includes('...')) return 'openai';
540
- if (process.env.GEMINI_API_KEY && !process.env.GEMINI_API_KEY.includes('...')) return 'gemini';
541
- if (process.env.DEEPSEEK_API_KEY && !process.env.DEEPSEEK_API_KEY.includes('...')) return 'deepseek';
542
- if (process.env.GROQ_API_KEY && !process.env.GROQ_API_KEY.includes('...')) return 'groq';
543
- if (process.env.LLM_API_KEY && process.env.LLM_BASE_URL) return 'custom';
544
-
545
- return 'lmstudio';
546
- }
547
-
548
- export function getProvider(id) {
549
- const pId = String(id || '').toLowerCase().trim();
550
- return CLOUD_PROVIDERS[pId] || null;
551
- }
552
-
553
- export function getProviderList() {
554
- return Object.values(CLOUD_PROVIDERS);
555
- }
556
-
557
- export function getRecommendedModels(providerId) {
558
- const provider = getProvider(providerId);
559
- return provider ? (provider.recommendedModels || []) : [];
560
- }
561
-
562
- let persistedProviderKeys = {};
563
- const providerKeyEncryptionSecret = process.env.KIKU_PROVIDER_KEY_ENCRYPTION_KEY ||
564
- (process.execArgv.includes('--test') ? 'kiku-agent-test-process-only-key' : '');
565
-
566
- export function isProviderKeyPersistenceEnabled() {
567
- return Boolean(providerKeyEncryptionSecret);
568
- }
569
-
570
- function encryptProviderKeys(keys) {
571
- if (!providerKeyEncryptionSecret) return null;
572
- const key = crypto.createHash('sha256').update(providerKeyEncryptionSecret).digest();
573
- const iv = crypto.randomBytes(12);
574
- const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
575
- const ciphertext = Buffer.concat([cipher.update(JSON.stringify(keys), 'utf8'), cipher.final()]);
576
- return {
577
- version: 1,
578
- algorithm: 'aes-256-gcm',
579
- iv: iv.toString('base64'),
580
- tag: cipher.getAuthTag().toString('base64'),
581
- ciphertext: ciphertext.toString('base64'),
582
- };
583
- }
584
-
585
- function decryptProviderKeys(envelope) {
586
- if (!providerKeyEncryptionSecret || envelope?.version !== 1 || envelope?.algorithm !== 'aes-256-gcm') return {};
587
- const key = crypto.createHash('sha256').update(providerKeyEncryptionSecret).digest();
588
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
589
- decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
590
- const plaintext = Buffer.concat([
591
- decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
592
- decipher.final(),
593
- ]).toString('utf8');
594
- const parsed = JSON.parse(plaintext);
595
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
596
- }
597
-
598
- export function loadPersistedProviderKeys() {
599
- try {
600
- if (fs.existsSync(PROVIDER_KEYS_FILE)) {
601
- const data = fs.readFileSync(PROVIDER_KEYS_FILE, 'utf-8');
602
- const parsed = JSON.parse(data);
603
- const keys = parsed?.ciphertext
604
- ? decryptProviderKeys(parsed)
605
- : (process.env.KIKU_ALLOW_PLAINTEXT_PROVIDER_KEYS === 'true' ? parsed : {});
606
- if (keys && typeof keys === 'object') {
607
- persistedProviderKeys = { ...keys };
608
- for (const [pId, key] of Object.entries(keys)) {
609
- const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
610
- if (providerDef && !providerDef.isLocal && !providerDef.apiKey && typeof key === 'string' && key.trim()) {
611
- providerDef.apiKey = key.trim();
612
- }
613
- }
614
- return { ...persistedProviderKeys };
615
- }
616
- }
617
- } catch {}
618
- return {};
619
- }
620
-
621
- export function savePersistedProviderKey(providerId, apiKey) {
622
- const pId = String(providerId || '').toLowerCase().trim();
623
- const providerDef = CLOUD_PROVIDERS[pId];
624
- if (!providerDef) {
625
- throw new Error(`Unknown provider "${providerId}".`);
626
- }
627
- const cleanKey = String(apiKey || '').trim();
628
- providerDef.apiKey = cleanKey;
629
-
630
- const existing = { ...persistedProviderKeys };
631
-
632
- if (cleanKey) {
633
- existing[pId] = cleanKey;
634
- } else {
635
- delete existing[pId];
636
- }
637
-
638
- persistedProviderKeys = existing;
639
- try {
640
- const envelope = encryptProviderKeys(existing);
641
- if (!envelope) return { provider: pId, hasKey: Boolean(cleanKey), persisted: false };
642
- const dir = path.dirname(PROVIDER_KEYS_FILE);
643
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
644
- fs.writeFileSync(PROVIDER_KEYS_FILE, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
645
- } catch (err) {
646
- console.error(`Failed to save provider key to ${PROVIDER_KEYS_FILE}:`, err.message);
647
- }
648
-
649
- return { provider: pId, hasKey: Boolean(cleanKey) };
650
- }
651
-
652
- export function getPersistedProviderKeys() {
653
- return { ...persistedProviderKeys };
654
- }
655
-
656
- loadPersistedSettings();
657
- loadPersistedProviderKeys();
658
-
659
- const defaultProviderId = resolveDefaultProvider();
660
- const defaultProviderConfig = CLOUD_PROVIDERS[defaultProviderId] || CLOUD_PROVIDERS.lmstudio;
661
-
662
- export const config = {
663
- activeProvider: defaultProviderId,
664
- providers: CLOUD_PROVIDERS,
665
-
666
- lmStudio: {
667
- baseUrl: defaultProviderConfig.baseUrl,
668
- apiKey: defaultProviderConfig.apiKey,
669
- model: defaultProviderConfig.defaultModel,
670
- defaultModel: defaultProviderConfig.defaultModel,
671
- temperature: 0.3,
672
- maxTokens: 8192,
673
- requestTimeoutMs: 180000,
674
- },
675
-
676
- agent: {
677
- maxIterations: 6,
678
- slidingWindowSize: 12,
679
- language: 'auto',
680
-
681
- autoResearch: ['on', 'off', 'auto'].includes(String(process.env.KIKU_AUTO_RESEARCH || '').toLowerCase())
682
- ? String(process.env.KIKU_AUTO_RESEARCH).toLowerCase()
683
- : 'auto',
684
- systemPersona: `You are KIKU AGENT (菊), the authoritative AI encyclopedia agent and concierge for international artist Ju Jingyi (鞠婧祎).
685
- Speak with elegance, utmost respect, professional warmth, and meticulous factual rigor. You deliver concise, polite answers for simple facts, and structured, in-depth breakdowns for rich cultural and artistic inquiries.
686
-
687
- MULTI-ANGLE RETRIEVAL & SMART MULTI-TOOL PARALLELISM:
688
- Always retrieve verified facts through parallel multi-angle tool calls when addressing complex or cross-cutting queries.`,
689
- }
690
- };
1
+ import { randomUUID as settingsRandomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { PROVIDER_KEYS_FILE, SETTINGS_FILE } from './appPaths.js';
6
+
7
+ const PROVIDER_MODEL_ALIASES = {
8
+ lmstudio: {
9
+ 'q8': 'google/gemma-4-e4b-Q8_0',
10
+ 'q8_0': 'google/gemma-4-e4b-Q8_0',
11
+ 'q8-0': 'google/gemma-4-e4b-Q8_0',
12
+ '8bit': 'google/gemma-4-e4b-Q8_0',
13
+ '8-bit': 'google/gemma-4-e4b-Q8_0',
14
+ 'gemma-q8': 'google/gemma-4-e4b-Q8_0',
15
+ 'gemma-q8_0': 'google/gemma-4-e4b-Q8_0',
16
+ 'gemma-4-e4b-q8': 'google/gemma-4-e4b-Q8_0',
17
+ 'gemma-4-e4b-q8_0': 'google/gemma-4-e4b-Q8_0',
18
+ 'google/gemma-4-e4b-q8_0': 'google/gemma-4-e4b-Q8_0',
19
+ 'google/gemma-4-e4b-q8': 'google/gemma-4-e4b-Q8_0',
20
+
21
+ 'q6': 'google/gemma-4-e4b-Q6_K',
22
+ 'q6_k': 'google/gemma-4-e4b-Q6_K',
23
+ 'q6-k': 'google/gemma-4-e4b-Q6_K',
24
+ '6bit': 'google/gemma-4-e4b-Q6_K',
25
+ '6-bit': 'google/gemma-4-e4b-Q6_K',
26
+ 'gemma-q6': 'google/gemma-4-e4b-Q6_K',
27
+ 'gemma-q6_k': 'google/gemma-4-e4b-Q6_K',
28
+ 'gemma-4-e4b-q6': 'google/gemma-4-e4b-Q6_K',
29
+ 'gemma-4-e4b-q6_k': 'google/gemma-4-e4b-Q6_K',
30
+ 'google/gemma-4-e4b-q6_k': 'google/gemma-4-e4b-Q6_K',
31
+ 'google/gemma-4-e4b-q6': 'google/gemma-4-e4b-Q6_K',
32
+
33
+ 'q4': 'google/gemma-4-e4b-Q4_K_M',
34
+ 'q4_k': 'google/gemma-4-e4b-Q4_K_M',
35
+ 'q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
36
+ 'q4-k-m': 'google/gemma-4-e4b-Q4_K_M',
37
+ '4bit': 'google/gemma-4-e4b-Q4_K_M',
38
+ '4-bit': 'google/gemma-4-e4b-Q4_K_M',
39
+ 'gemma-q4': 'google/gemma-4-e4b-Q4_K_M',
40
+ 'gemma-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
41
+ 'gemma-4-e4b-q4': 'google/gemma-4-e4b-Q4_K_M',
42
+ 'gemma-4-e4b-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
43
+ 'google/gemma-4-e4b-q4_k_m': 'google/gemma-4-e4b-Q4_K_M',
44
+ 'google/gemma-4-e4b-q4': 'google/gemma-4-e4b-Q4_K_M',
45
+
46
+ 'gemma': 'google/gemma-4-e4b',
47
+ 'gemma4': 'google/gemma-4-e4b',
48
+ 'gemma-4': 'google/gemma-4-e4b',
49
+ 'gemma-4-e4b': 'google/gemma-4-e4b',
50
+ 'gemma-4-e4b-it-q4_k_m.gguf': 'google/gemma-4-e4b',
51
+ 'gemma-4-e4b-it-q4_k_m': 'google/gemma-4-e4b',
52
+ 'gemma-4-e4b-q4_k_m.gguf': 'google/gemma-4-e4b',
53
+ 'gemma-4-e4b-q6_k.gguf': 'google/gemma-4-e4b-Q6_K',
54
+ 'gemma-4-e4b-q8_0.gguf': 'google/gemma-4-e4b-Q8_0',
55
+ 'gemma-4-e4b.gguf': 'google/gemma-4-e4b',
56
+ 'qwen': 'Qwen/Qwen2.5-7B-Instruct-GGUF',
57
+ 'qwen-7b': 'Qwen/Qwen2.5-7B-Instruct-GGUF',
58
+ 'qwen2.5': 'Qwen/Qwen2.5-7B-Instruct-GGUF',
59
+ 'qwen2.5-7b': 'Qwen/Qwen2.5-7B-Instruct-GGUF',
60
+ 'qwen-3b': 'Qwen/Qwen2.5-3B-Instruct-GGUF',
61
+ 'qwen2.5-3b': 'Qwen/Qwen2.5-3B-Instruct-GGUF',
62
+ 'qwen-coder': 'Qwen/Qwen2.5-Coder-7B-Instruct-GGUF',
63
+ 'qwen2.5-coder': 'Qwen/Qwen2.5-Coder-7B-Instruct-GGUF',
64
+ 'qwen3': 'Qwen/Qwen3-8B',
65
+ 'qwq': 'Qwen/QwQ-32B',
66
+ },
67
+ vllm: {
68
+ 'gemma': 'google/gemma-4-e4b',
69
+ 'gemma4': 'google/gemma-4-e4b',
70
+ 'gemma-4': 'google/gemma-4-e4b',
71
+ 'gemma-4-e4b': 'google/gemma-4-e4b',
72
+ 'qwen': 'Qwen/Qwen2.5-7B-Instruct',
73
+ 'qwen-7b': 'Qwen/Qwen2.5-7B-Instruct',
74
+ 'qwen2.5': 'Qwen/Qwen2.5-7B-Instruct',
75
+ 'qwen2.5-7b': 'Qwen/Qwen2.5-7B-Instruct',
76
+ 'qwen-3b': 'Qwen/Qwen2.5-3B-Instruct',
77
+ 'qwen2.5-3b': 'Qwen/Qwen2.5-3B-Instruct',
78
+ 'qwen-coder': 'Qwen/Qwen2.5-Coder-7B-Instruct',
79
+ 'qwen2.5-coder': 'Qwen/Qwen2.5-Coder-7B-Instruct',
80
+ 'qwen3': 'Qwen/Qwen3-8B',
81
+ 'qwq': 'Qwen/QwQ-32B',
82
+ 'llama': 'meta-llama/Llama-3.1-8B-Instruct',
83
+ },
84
+ ollama: {
85
+ 'qwen': 'qwen2.5:7b',
86
+ 'qwen-7b': 'qwen2.5:7b',
87
+ 'qwen2.5': 'qwen2.5:7b',
88
+ 'qwen2.5-7b': 'qwen2.5:7b',
89
+ 'qwen-3b': 'qwen2.5:3b',
90
+ 'qwen2.5-3b': 'qwen2.5:3b',
91
+ 'qwen-coder': 'qwen2.5-coder:7b',
92
+ 'qwen2.5-coder': 'qwen2.5-coder:7b',
93
+ 'llama': 'llama3.1:8b',
94
+ 'llama3': 'llama3.1:8b',
95
+ 'deepseek': 'deepseek-r1:7b',
96
+ 'deepseek-r1': 'deepseek-r1:7b',
97
+ 'r1': 'deepseek-r1:7b',
98
+ 'gemma': 'gemma2:9b',
99
+ },
100
+ openai: {
101
+ 'o1-mini': 'gpt-5.6-luna',
102
+ 'o3-mini': 'gpt-5.6-luna',
103
+ },
104
+ openrouter: {
105
+ auto: 'openrouter/auto',
106
+ },
107
+ groq: {
108
+ 'gpt-oss-120b': 'openai/gpt-oss-120b',
109
+ 'gpt-oss-20b': 'openai/gpt-oss-20b',
110
+ },
111
+ deepseek: {
112
+ 'deepseek-chat': 'deepseek-v4-flash',
113
+ 'deepseek-reasoner': 'deepseek-v4-flash',
114
+ },
115
+ gemini: {
116
+ 'gemini-3.1-pro': 'gemini-3.1-pro-preview',
117
+ },
118
+ };
119
+
120
+ const GENERIC_MODEL_TOKENS = new Set(['model', 'chat', 'default', 'base', 'instruct', 'text', 'embedding']);
121
+
122
+ export function extractModelQuantization(modelId) {
123
+ if (!modelId || typeof modelId !== 'string') return null;
124
+ const clean = modelId.toUpperCase();
125
+ const m = clean.match(/(Q[0-9]+_[A-Z0-9_]+|Q[0-9]+_[A-Z]|Q[0-9]+|[0-9]+BIT|FP16|FP32|BF16)/i);
126
+ return m ? m[1].toUpperCase() : null;
127
+ }
128
+
129
+ export function cleanModelName(modelId) {
130
+ if (!modelId || typeof modelId !== 'string') return '';
131
+ let name = modelId.trim();
132
+ name = name.replace(/\.(gguf|bin|safetensors)$/i, '');
133
+ if (name.includes('/')) {
134
+ const parts = name.split('/').filter(Boolean);
135
+ name = parts[parts.length - 1] || parts[0];
136
+ }
137
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, '');
138
+ }
139
+
140
+ export function matchModelIdentifier(target, candidate) {
141
+ if (!target || !candidate) return false;
142
+ if (target === candidate) return true;
143
+
144
+ const targetLower = String(target).toLowerCase().trim();
145
+ const candLower = String(candidate).toLowerCase().trim();
146
+ if (targetLower === candLower) return true;
147
+
148
+ const targetClean = cleanModelName(target);
149
+ const candClean = cleanModelName(candidate);
150
+ if (targetClean && candClean && targetClean === candClean) return true;
151
+
152
+ const targetQuant = extractModelQuantization(target);
153
+ const candQuant = extractModelQuantization(candidate);
154
+
155
+ const targetIsGemma = /gemma/i.test(target);
156
+ const candIsGemma = /gemma/i.test(candidate);
157
+
158
+ if (candIsGemma) {
159
+ if (targetIsGemma) {
160
+ if (targetQuant && candQuant) {
161
+ const normTargetQ = targetQuant.replace(/[^A-Z0-9]/g, '');
162
+ const normCandQ = candQuant.replace(/[^A-Z0-9]/g, '');
163
+ if (normTargetQ === normCandQ || normCandQ.startsWith(normTargetQ) || normTargetQ.startsWith(normCandQ)) {
164
+ return true;
165
+ }
166
+ } else if (!targetQuant) {
167
+ if (targetClean.includes('gemma') && candClean.includes('gemma')) {
168
+ return true;
169
+ }
170
+ }
171
+ } else if (targetQuant && !GENERIC_MODEL_TOKENS.has(targetClean)) {
172
+ if (candQuant) {
173
+ const normTargetQ = targetQuant.replace(/[^A-Z0-9]/g, '');
174
+ const normCandQ = candQuant.replace(/[^A-Z0-9]/g, '');
175
+ if (normTargetQ === normCandQ || normCandQ.startsWith(normTargetQ) || normTargetQ.startsWith(normCandQ)) {
176
+ return true;
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if (targetClean && candClean && !GENERIC_MODEL_TOKENS.has(targetClean) && targetClean.length >= 5) {
183
+ if (candClean.includes(targetClean) || (candClean.length >= 5 && targetClean.includes(candClean))) {
184
+ return true;
185
+ }
186
+ }
187
+
188
+ return false;
189
+ }
190
+
191
+ export function normalizeProviderModel(providerId, modelId) {
192
+ const normalizedProvider = String(providerId || '').toLowerCase().trim();
193
+ const normalizedModel = String(modelId || '').trim();
194
+ const aliasMatch = PROVIDER_MODEL_ALIASES[normalizedProvider]?.[normalizedModel.toLowerCase()]
195
+ || PROVIDER_MODEL_ALIASES[normalizedProvider]?.[normalizedModel];
196
+ if (aliasMatch) return aliasMatch;
197
+
198
+ if (normalizedProvider === 'lmstudio') {
199
+ const clean = normalizedModel.toLowerCase().replace(/\\/g, '/');
200
+ const baseName = clean.split('/').pop();
201
+ if (baseName && PROVIDER_MODEL_ALIASES.lmstudio[baseName]) {
202
+ return PROVIDER_MODEL_ALIASES.lmstudio[baseName];
203
+ }
204
+ if (/(?=.*gemma)(?=.*e4b)/i.test(baseName || clean)) {
205
+ if (/(?:q6|6bit)/i.test(baseName || clean)) return 'google/gemma-4-e4b-Q6_K';
206
+ if (/(?:q8|8bit)/i.test(baseName || clean)) return 'google/gemma-4-e4b-Q8_0';
207
+ return 'google/gemma-4-e4b';
208
+ }
209
+ }
210
+
211
+ return normalizedModel;
212
+ }
213
+
214
+ export const DEFAULT_LOCAL_ENDPOINTS = Object.freeze({
215
+ lmstudio: 'http://127.0.0.1:1234/v1',
216
+ vllm: 'http://127.0.0.1:8000/v1',
217
+ ollama: 'http://127.0.0.1:11434/v1',
218
+ });
219
+
220
+ export const CLOUD_PROVIDERS = {
221
+ lmstudio: {
222
+ id: 'lmstudio',
223
+ name: 'LM Studio (Local)',
224
+ baseUrl: process.env.LMSTUDIO_BASE_URL || process.env.OPENAI_BASE_URL || 'http://127.0.0.1:1234/v1',
225
+ apiKey: process.env.LMSTUDIO_API_KEY || 'lm-studio',
226
+ defaultModel: 'google/gemma-4-e4b',
227
+ isLocal: true,
228
+ envKey: 'LMSTUDIO_BASE_URL',
229
+ docUrl: 'https://lmstudio.ai/docs',
230
+ apiRefUrl: 'https://lmstudio.ai/docs/api/local-server',
231
+ pricingSummary: '100% Free / Local (No API Key required)',
232
+ features: ['local', 'private', 'offline', 'zero-cost', 'openai-compatible'],
233
+ recommendedModels: [
234
+ { id: 'google/gemma-4-e4b', name: 'Gemma 4 e4b (Default / Q4_K_M)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: 'Recommended default. Balanced 4-bit quantization (Q4_K_M) — fastest inference and lowest RAM usage.' },
235
+ { id: 'google/gemma-4-e4b-Q4_K_M', name: 'Gemma 4 e4b (Q4_K_M Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '4-bit medium quantization (Q4_K_M) optimized for speed and low memory footprint (~2.5GB RAM).' },
236
+ { id: 'Qwen/Qwen2.5-7B-Instruct-GGUF', name: 'Qwen 2.5 7B Instruct (Q4_K_M)', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'High-capability open reasoning and tool-calling model (~4.8GB RAM/VRAM with Q4_K_M).' },
237
+ { id: 'Qwen/Qwen2.5-3B-Instruct-GGUF', name: 'Qwen 2.5 3B Instruct (Q4_K_M)', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Ultra-lightweight open model for low-VRAM machines (~2.2GB RAM/VRAM).' },
238
+ { id: 'google/gemma-4-e4b-Q6_K', name: 'Gemma 4 e4b (Q6_K Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '6-bit quantization (Q6_K) offering higher precision and near-original fidelity (~3.5GB RAM).' },
239
+ { id: 'google/gemma-4-e4b-Q8_0', name: 'Gemma 4 e4b (Q8_0 Quant)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: '8-bit full quantization (Q8_0) for maximum accuracy and reasoning precision (~4.5GB RAM).' },
240
+ ],
241
+ },
242
+ vllm: {
243
+ id: 'vllm',
244
+ name: 'vLLM (Local / WSL)',
245
+ baseUrl: process.env.VLLM_BASE_URL || 'http://127.0.0.1:8000/v1',
246
+ apiKey: process.env.VLLM_API_KEY || 'token-kiku',
247
+ defaultModel: process.env.VLLM_MODEL || 'google/gemma-4-e4b',
248
+ isLocal: true,
249
+ envKey: 'VLLM_BASE_URL',
250
+ docUrl: 'https://docs.vllm.ai',
251
+ apiRefUrl: 'https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html',
252
+ pricingSummary: '100% Free / Local (High-throughput PagedAttention server via WSL/Linux)',
253
+ features: ['local', 'high-throughput', 'paged-attention', 'wsl-required', 'openai-compatible'],
254
+ platformNotice: 'You cannot install vLLM natively on Windows. Please install and run vLLM via WSL (Windows Subsystem for Linux, e.g. wsl -d kali-linux or Ubuntu) using "vllm serve <model> --port 8000".',
255
+ recommendedModels: [
256
+ { id: 'google/gemma-4-e4b', name: 'Gemma 4 e4b (Default)', tier: 'free', cost: '$0.00 (Local)', context: '8k', hasTools: true, description: 'Recommended default model served via vllm serve on port 8000.' },
257
+ { id: 'Qwen/Qwen2.5-7B-Instruct', name: 'Qwen 2.5 7B Instruct', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'High capability open model with function calling on vLLM.' },
258
+ { id: 'Qwen/Qwen2.5-3B-Instruct', name: 'Qwen 2.5 3B Instruct', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Ultra-fast, low-memory open model for constrained vLLM instances (~3GB VRAM).' },
259
+ { id: 'meta-llama/Llama-3.1-8B-Instruct', name: 'Llama 3.1 8B Instruct', tier: 'free', cost: '$0.00 (Local)', context: '128k', hasTools: true, description: 'Fast general-purpose open model hosted on vLLM.' },
260
+ ],
261
+ },
262
+ ollama: {
263
+ id: 'ollama',
264
+ name: 'Ollama (Local)',
265
+ baseUrl: process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434/v1',
266
+ apiKey: process.env.OLLAMA_API_KEY || 'ollama',
267
+ defaultModel: process.env.OLLAMA_MODEL || 'qwen2.5:7b',
268
+ isLocal: true,
269
+ envKey: 'OLLAMA_BASE_URL',
270
+ docUrl: 'https://github.com/ollama/ollama',
271
+ apiRefUrl: 'https://github.com/ollama/ollama/blob/main/docs/openai.md',
272
+ pricingSummary: '100% Free / Local (Auto-quantization and model management)',
273
+ features: ['local', 'private', 'offline', 'zero-cost', 'openai-compatible', 'auto-quant'],
274
+ recommendedModels: [
275
+ { id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Recommended default for Ollama. 4-bit quant loaded on demand (~4.7GB VRAM).' },
276
+ { id: 'qwen2.5:3b', name: 'Qwen 2.5 3B', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Lightweight model for machines with <6GB VRAM (~2.0GB VRAM).' },
277
+ { id: 'qwen2.5-coder:7b', name: 'Qwen 2.5 Coder 7B', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Specialized coding and structured tool-calling model.' },
278
+ { id: 'deepseek-r1:7b', name: 'DeepSeek R1 Distill 7B', tier: 'free', cost: '$0.00 (Local)', context: '32k', hasTools: true, description: 'Distilled reasoning model with <think> chain-of-thought.' },
279
+ { id: 'llama3.1:8b', name: 'Llama 3.1 8B', tier: 'free', cost: '$0.00 (Local)', context: '128k', hasTools: true, description: 'General-purpose open model with large context.' },
280
+ ],
281
+ },
282
+ openai: {
283
+ id: 'openai',
284
+ name: 'OpenAI Cloud',
285
+ baseUrl: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
286
+ apiKey: process.env.OPENAI_API_KEY || '',
287
+ defaultModel: normalizeProviderModel('openai', process.env.OPENAI_MODEL || 'gpt-5.6-luna'),
288
+ isLocal: false,
289
+ envKey: 'OPENAI_API_KEY',
290
+ docUrl: 'https://developers.openai.com/api/docs/models',
291
+ apiRefUrl: 'https://developers.openai.com/api/reference',
292
+ pricingSummary: 'GPT-5.6 Luna: $0.20/1M in, $1.20/1M out; Terra/Sol available for higher capability',
293
+ features: ['tools', 'streaming', 'vision', 'reasoning', 'system-prompt'],
294
+ recommendedModels: [
295
+ { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', tier: 'cost-sensitive', cost: '$0.20 / 1M in', context: '1.05M', hasTools: true, description: 'Recommended default for efficient, high-volume multilingual agent workloads.' },
296
+ { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', tier: 'balanced', cost: '$2.00 / 1M in', context: '1.05M', hasTools: true, description: 'Balances intelligence and cost for production reasoning and tool use.' },
297
+ { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', tier: 'flagship', cost: '$4.00 / 1M in', context: '1.05M', hasTools: true, description: 'Flagship model for complex professional reasoning and coding.' },
298
+ ],
299
+ },
300
+ openrouter: {
301
+ id: 'openrouter',
302
+ name: 'OpenRouter Cloud',
303
+ baseUrl: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
304
+ apiKey: process.env.OPENROUTER_API_KEY || '',
305
+ defaultModel: normalizeProviderModel('openrouter', process.env.OPENROUTER_MODEL || 'openrouter/auto'),
306
+ extraHeaders: {
307
+ 'HTTP-Referer': 'https://jujingyi-kikuuu.vercel.app/',
308
+ 'X-OpenRouter-Title': 'Kiku Agent',
309
+ },
310
+ isLocal: false,
311
+ envKey: 'OPENROUTER_API_KEY',
312
+ docUrl: 'https://openrouter.ai/docs/guides/routing/routers/auto-router',
313
+ apiRefUrl: 'https://openrouter.ai/docs/api/reference/overview',
314
+ pricingSummary: 'Auto Router selects a current model dynamically; usage is billed at the routed model rate',
315
+ features: ['universal-routing', 'tools', 'streaming', 'free-tier', 'fallback'],
316
+ recommendedModels: [
317
+ { id: 'openrouter/auto', name: 'OpenRouter Auto Router', tier: 'dynamic', cost: 'Routed model rate', context: 'Model-dependent', hasTools: true, description: 'Recommended default. Chooses from the current catalog so a retired third-party model does not break startup.' },
318
+ ],
319
+ },
320
+ groq: {
321
+ id: 'groq',
322
+ name: 'Groq Cloud',
323
+ baseUrl: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1',
324
+ apiKey: process.env.GROQ_API_KEY || '',
325
+ defaultModel: normalizeProviderModel('groq', process.env.GROQ_MODEL || 'openai/gpt-oss-120b'),
326
+ isLocal: false,
327
+ envKey: 'GROQ_API_KEY',
328
+ docUrl: 'https://console.groq.com/docs/quickstart',
329
+ apiRefUrl: 'https://console.groq.com/docs/api-reference',
330
+ pricingSummary: 'Ultra-fast LPU inference (GPT-OSS 20B: $0.075/1M in, GPT-OSS 120B: $0.15/1M in)',
331
+ features: ['lpu-speed', 'tools', 'streaming', 'openai-compatible'],
332
+ recommendedModels: [
333
+ { id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B', tier: 'flagship', cost: '$0.15 / 1M in', context: '131k', hasTools: true, description: 'Recommended default. Production-grade 120B reasoning model with tool use on Groq.' },
334
+ { id: 'openai/gpt-oss-20b', name: 'GPT-OSS 20B', tier: 'ultra-cheap', cost: '$0.075 / 1M in', context: '131k', hasTools: true, description: 'Fast production model for lower-cost, high-volume requests.' },
335
+ ],
336
+ },
337
+ deepseek: {
338
+ id: 'deepseek',
339
+ name: 'DeepSeek Cloud',
340
+ baseUrl: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
341
+ apiKey: process.env.DEEPSEEK_API_KEY || '',
342
+ defaultModel: normalizeProviderModel('deepseek', process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash'),
343
+ isLocal: false,
344
+ envKey: 'DEEPSEEK_API_KEY',
345
+ docUrl: 'https://api-docs.deepseek.com',
346
+ apiRefUrl: 'https://api-docs.deepseek.com/api/create-chat-completion',
347
+ pricingSummary: 'V4 Flash: $0.22-$0.44/1M uncached input; V4 Pro: $0.66-$1.32/1M (off-peak/peak)',
348
+ features: ['deepseek-v4', 'thinking', 'tools', 'streaming', 'context-cache', '1m-context'],
349
+ recommendedModels: [
350
+ { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', tier: 'balanced', cost: '$0.22-$0.44 / 1M uncached in', context: '1M', hasTools: true, description: 'Recommended default. Current V4 model with tool calls and thinking/non-thinking modes.' },
351
+ { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', tier: 'flagship', cost: '$0.66-$1.32 / 1M uncached in', context: '1M', hasTools: true, description: 'Higher-capability V4 model with tool calls and thinking mode.' },
352
+ ],
353
+ },
354
+ gemini: {
355
+ id: 'gemini',
356
+ name: 'Google Gemini',
357
+ baseUrl: process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai',
358
+ apiKey: process.env.GEMINI_API_KEY || '',
359
+ defaultModel: normalizeProviderModel('gemini', process.env.GEMINI_MODEL || 'gemini-3.7-flash'),
360
+ isLocal: false,
361
+ envKey: 'GEMINI_API_KEY',
362
+ docUrl: 'https://ai.google.dev/gemini-api/docs',
363
+ apiRefUrl: 'https://ai.google.dev/api/generate-content',
364
+ pricingSummary: 'Gemini 3.7 Flash: free tier available; paid standard $0.75/1M input through 2026',
365
+ features: ['1m-context', 'multimodal', 'tools', 'streaming', 'thinking'],
366
+ recommendedModels: [
367
+ { id: 'gemini-3.7-flash', name: 'Gemini 3.7 Flash', tier: 'latest-stable', cost: '$0.75 / 1M in (2026)', context: '1M', hasTools: true, description: 'Recommended default. Latest stable Flash model for agentic workflows and multimodal reasoning.' },
368
+ { id: 'gemini-3.5-flash-lite', name: 'Gemini 3.5 Flash-Lite', tier: 'lowest-cost', cost: '$0.30 / 1M in', context: '1M', hasTools: true, description: 'Stable, cost-efficient model for high-volume translation and simple processing.' },
369
+ { id: 'gemini-3.6-flash', name: 'Gemini 3.6 Flash', tier: 'stable', cost: '$0.75 / 1M in (2026)', context: '1M', hasTools: true, description: 'Stable previous-generation Flash fallback with function calling and thinking.' },
370
+ ],
371
+ },
372
+ anthropic: {
373
+ id: 'anthropic',
374
+ name: 'Anthropic Claude',
375
+ baseUrl: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
376
+ apiKey: process.env.ANTHROPIC_API_KEY || '',
377
+ defaultModel: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-5',
378
+ isLocal: false,
379
+ envKey: 'ANTHROPIC_API_KEY',
380
+ docUrl: 'https://docs.anthropic.com/en/docs',
381
+ apiRefUrl: 'https://docs.anthropic.com/en/api/messages',
382
+ pricingSummary: 'Claude Messages API with native tool use and extended thinking support',
383
+ features: ['tools', 'streaming', 'vision', 'thinking', 'native-messages'],
384
+ extraHeaders: { 'anthropic-version': '2023-06-01' },
385
+ recommendedModels: [
386
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', tier: 'balanced', cost: 'Provider pricing', context: '200k', hasTools: true, description: 'Balanced Claude model for agentic tool use.' },
387
+ { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', tier: 'flagship', cost: 'Provider pricing', context: '200k', hasTools: true, description: 'Highest-capability Claude model for complex reasoning.' },
388
+ { id: 'claude-haiku-3-5', name: 'Claude Haiku 3.5', tier: 'fast', cost: 'Provider pricing', context: '200k', hasTools: true, description: 'Fast, lower-cost Claude model.' },
389
+ ],
390
+ },
391
+ custom: {
392
+ id: 'custom',
393
+ name: 'Custom OpenAI-Compatible',
394
+ baseUrl: process.env.LLM_BASE_URL || '',
395
+ apiKey: process.env.LLM_API_KEY || '',
396
+ defaultModel: process.env.LLM_MODEL || 'default',
397
+ isLocal: false,
398
+ envKey: 'LLM_API_KEY',
399
+ docUrl: 'https://platform.openai.com/docs/api-reference',
400
+ apiRefUrl: 'https://platform.openai.com/docs/api-reference/chat',
401
+ pricingSummary: 'Self-hosted or Custom Gateway',
402
+ features: ['custom-endpoint', 'openai-compatible', 'tools', 'streaming'],
403
+ recommendedModels: [
404
+ { id: 'default', name: 'Default Model', tier: 'custom', cost: 'Custom', context: 'Custom', hasTools: true, description: 'Custom model specified via LLM_MODEL or /model use <id>.' },
405
+ ],
406
+ },
407
+ };
408
+
409
+ let cachedSettings = null;
410
+ let lastSettingsLoadTime = 0;
411
+ const SETTINGS_CACHE_TTL_MS = 2000;
412
+
413
+ export function invalidateSettingsCache() {
414
+ cachedSettings = null;
415
+ lastSettingsLoadTime = 0;
416
+ }
417
+
418
+ export function loadPersistedSettings(force = false) {
419
+ const now = Date.now();
420
+ if (!force && cachedSettings && (now - lastSettingsLoadTime < SETTINGS_CACHE_TTL_MS)) {
421
+ return cachedSettings;
422
+ }
423
+ try {
424
+ if (fs.existsSync(SETTINGS_FILE)) {
425
+ const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
426
+ const parsed = JSON.parse(data);
427
+ if (parsed && typeof parsed === 'object') {
428
+ if (parsed.providerModels && typeof parsed.providerModels === 'object') {
429
+ for (const [pId, model] of Object.entries(parsed.providerModels)) {
430
+ const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
431
+ if (providerDef && typeof model === 'string' && model.trim()) {
432
+ providerDef.defaultModel = model.trim();
433
+ }
434
+ }
435
+ }
436
+ if (parsed.providerBaseUrls && typeof parsed.providerBaseUrls === 'object') {
437
+ for (const [pId, url] of Object.entries(parsed.providerBaseUrls)) {
438
+ const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
439
+ if (providerDef && typeof url === 'string' && url.trim()) {
440
+ providerDef.baseUrl = url.trim().replace(/\/+$/, '');
441
+ }
442
+ }
443
+ }
444
+ cachedSettings = parsed;
445
+ lastSettingsLoadTime = now;
446
+ return parsed;
447
+ }
448
+ }
449
+ } catch {}
450
+ cachedSettings = {};
451
+ lastSettingsLoadTime = now;
452
+ return {};
453
+ }
454
+
455
+ export function writeSettingsFileAtomic(settings) {
456
+ fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true, mode: 0o700 });
457
+ const temporary = SETTINGS_FILE + '.' + process.pid + '.' + settingsRandomUUID() + '.tmp';
458
+ try {
459
+ fs.writeFileSync(temporary, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
460
+ try {
461
+ fs.renameSync(temporary, SETTINGS_FILE);
462
+ } catch (err) {
463
+ if (err.code === 'EPERM' || err.code === 'EBUSY') {
464
+ fs.copyFileSync(temporary, SETTINGS_FILE);
465
+ } else {
466
+ throw err;
467
+ }
468
+ }
469
+ invalidateSettingsCache();
470
+ } finally {
471
+ if (fs.existsSync(temporary)) {
472
+ try { fs.unlinkSync(temporary); } catch {}
473
+ }
474
+ }
475
+ }
476
+
477
+ let settingsWriteQueue = Promise.resolve();
478
+ export function withSettingsWriteQueue(update) {
479
+ const pending = settingsWriteQueue.then(update);
480
+ settingsWriteQueue = pending.catch(() => {});
481
+ return pending;
482
+ }
483
+
484
+ export const INFERENCE_DEFAULTS = Object.freeze({
485
+ temperature: 0.7,
486
+ topP: 0.95,
487
+ contextLimit: 32768,
488
+ thinking: 'show',
489
+ persona: 'default',
490
+ stream: true,
491
+ });
492
+
493
+ export function normalizeInferenceSettings(partial = {}, base = INFERENCE_DEFAULTS) {
494
+ const result = { ...base };
495
+ for (const [key, min, max] of [
496
+ ['temperature', 0, 2],
497
+ ['topP', 0.01, 1],
498
+ ['contextLimit', 1024, 131072],
499
+ ]) {
500
+ if (partial[key] === null || partial[key] === '' || partial[key] === undefined || typeof partial[key] === 'boolean') continue;
501
+ const value = Number(partial[key]);
502
+ if (Number.isFinite(value)) result[key] = Math.max(min, Math.min(max, key === 'contextLimit' ? Math.floor(value) : value));
503
+ }
504
+ for (const [key, min, max] of [
505
+ ['minP', 0, 1],
506
+ ['repeatPenalty', 1.0, 2.0],
507
+ ]) {
508
+ if (partial[key] !== null && partial[key] !== '' && partial[key] !== undefined && typeof partial[key] !== 'boolean') {
509
+ const value = Number(partial[key]);
510
+ if (Number.isFinite(value)) result[key] = Math.max(min, Math.min(max, value));
511
+ } else if (base[key] !== undefined) {
512
+ result[key] = base[key];
513
+ }
514
+ }
515
+ if (['show', 'collapse', 'hide'].includes(partial.thinking)) result.thinking = partial.thinking;
516
+ if (['default', 'official', 'fan', 'researcher'].includes(partial.persona)) result.persona = partial.persona;
517
+ if (typeof partial.stream === 'boolean') result.stream = partial.stream;
518
+ return result;
519
+ }
520
+ export function getPersistedInferenceSettings() {
521
+ return normalizeInferenceSettings(loadPersistedSettings().inference || {});
522
+ }
523
+ export function savePersistedInferenceSettings(partial = {}) {
524
+ const settings = structuredClone(loadPersistedSettings(true));
525
+ settings.inference = normalizeInferenceSettings(partial, normalizeInferenceSettings(settings.inference || {}));
526
+ writeSettingsFileAtomic(settings);
527
+ return settings.inference;
528
+ }
529
+
530
+ export const MODEL_LOAD_DEFAULTS = Object.freeze({
531
+ activeEngine: 'lms',
532
+ gpuOffload: 'auto',
533
+ cpuThreads: 1,
534
+ contextLength: 32768,
535
+ autoContext: true,
536
+ splitMode: 'layer',
537
+ tensorSplit: null,
538
+ mainGpu: 0,
539
+ flashAttn: true,
540
+ batchSize: 2048,
541
+ ubatchSize: 512,
542
+ kvCacheDtype: 'auto',
543
+ vllm: Object.freeze({
544
+ gpuMemoryUtilization: 0.90,
545
+ maxModelLen: null,
546
+ tensorParallelSize: 1,
547
+ dtype: 'auto',
548
+ enforceEager: false,
549
+ enablePrefixCaching: true,
550
+ kvCacheDtype: 'auto',
551
+ }),
552
+ });
553
+
554
+ export function normalizeModelLoadSettings(partial = {}, base = MODEL_LOAD_DEFAULTS) {
555
+ const result = {
556
+ activeEngine: base.activeEngine || 'lms',
557
+ gpuOffload: base.gpuOffload || 'auto',
558
+ cpuThreads: base.cpuThreads !== undefined ? base.cpuThreads : 1,
559
+ contextLength: base.contextLength ?? null,
560
+ autoContext: base.autoContext ?? true,
561
+ splitMode: base.splitMode || 'layer',
562
+ tensorSplit: base.tensorSplit ?? null,
563
+ mainGpu: base.mainGpu !== undefined ? base.mainGpu : 0,
564
+ flashAttn: base.flashAttn ?? true,
565
+ batchSize: base.batchSize !== undefined ? base.batchSize : 2048,
566
+ ubatchSize: base.ubatchSize !== undefined ? base.ubatchSize : 512,
567
+ kvCacheDtype: base.kvCacheDtype || 'auto',
568
+ htMode: base.htMode || 'auto',
569
+ vllm: { ...base.vllm },
570
+ };
571
+
572
+ if (partial.activeEngine !== undefined && partial.activeEngine !== null) {
573
+ const eng = String(partial.activeEngine).toLowerCase().trim();
574
+ if (['lms', 'lmstudio', 'vllm'].includes(eng)) {
575
+ result.activeEngine = eng === 'lmstudio' ? 'lms' : eng;
576
+ }
577
+ }
578
+
579
+ if (partial.gpuOffload !== undefined && partial.gpuOffload !== null) {
580
+ const gpu = String(partial.gpuOffload).toLowerCase().trim();
581
+ if (['auto', 'off', 'max'].includes(gpu)) {
582
+ result.gpuOffload = gpu;
583
+ } else {
584
+ const ratio = Number(gpu);
585
+ if (Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
586
+ result.gpuOffload = ratio;
587
+ }
588
+ }
589
+ }
590
+
591
+ if (partial.cpuThreads !== undefined) {
592
+ if (partial.cpuThreads === null || partial.cpuThreads === '' || partial.cpuThreads === 'auto') {
593
+ result.cpuThreads = null;
594
+ } else {
595
+ const n = Number(partial.cpuThreads);
596
+ if (Number.isFinite(n) && n >= 1 && n <= 256) {
597
+ result.cpuThreads = Math.floor(n);
598
+ } else {
599
+ result.cpuThreads = null;
600
+ }
601
+ }
602
+ }
603
+
604
+ if (partial.contextLength !== undefined) {
605
+ if (partial.contextLength === null || partial.contextLength === '' || partial.contextLength === 'auto') {
606
+ result.contextLength = null;
607
+ } else {
608
+ const c = Number(partial.contextLength);
609
+ if (Number.isFinite(c) && c >= 512 && c <= 131072) {
610
+ result.contextLength = Math.floor(c);
611
+ }
612
+ }
613
+ }
614
+
615
+ if (partial.autoContext !== undefined && partial.autoContext !== null && partial.autoContext !== '') {
616
+ const text = String(partial.autoContext).trim().toLowerCase();
617
+ if (typeof partial.autoContext === 'boolean') {
618
+ result.autoContext = partial.autoContext;
619
+ } else if (['true', '1', 'yes', 'on', 'auto'].includes(text)) {
620
+ result.autoContext = true;
621
+ } else if (['false', '0', 'no', 'off'].includes(text)) {
622
+ result.autoContext = false;
623
+ }
624
+ }
625
+
626
+ if (partial.splitMode !== undefined && partial.splitMode !== null) {
627
+ const sm = String(partial.splitMode).toLowerCase().trim();
628
+ if (['layer', 'row', 'none'].includes(sm)) {
629
+ result.splitMode = sm;
630
+ }
631
+ }
632
+
633
+ if (partial.tensorSplit !== undefined) {
634
+ if (partial.tensorSplit === null || partial.tensorSplit === '' || partial.tensorSplit === 'auto') {
635
+ result.tensorSplit = null;
636
+ } else if (Array.isArray(partial.tensorSplit)) {
637
+ result.tensorSplit = partial.tensorSplit.map(n => Number(n)).filter(n => Number.isFinite(n) && n >= 0).join(',');
638
+ } else if (typeof partial.tensorSplit === 'string') {
639
+ const cleaned = partial.tensorSplit.split(',').map(s => s.trim()).filter(Boolean).join(',');
640
+ result.tensorSplit = cleaned || null;
641
+ }
642
+ }
643
+
644
+ if (partial.mainGpu !== undefined) {
645
+ const mg = Number(partial.mainGpu);
646
+ if (Number.isFinite(mg) && mg >= 0 && mg <= 16) {
647
+ result.mainGpu = Math.floor(mg);
648
+ }
649
+ }
650
+
651
+ if (partial.flashAttn !== undefined && partial.flashAttn !== null) {
652
+ if (typeof partial.flashAttn === 'boolean') {
653
+ result.flashAttn = partial.flashAttn;
654
+ } else {
655
+ const s = String(partial.flashAttn).toLowerCase().trim();
656
+ result.flashAttn = !['false', '0', 'off', 'no'].includes(s);
657
+ }
658
+ }
659
+
660
+ if (partial.batchSize !== undefined) {
661
+ const b = Number(partial.batchSize);
662
+ if (Number.isFinite(b) && b >= 128 && b <= 32768) {
663
+ result.batchSize = Math.floor(b);
664
+ }
665
+ }
666
+
667
+ if (partial.ubatchSize !== undefined) {
668
+ const ub = Number(partial.ubatchSize);
669
+ if (Number.isFinite(ub) && ub >= 64 && ub <= 8192) {
670
+ result.ubatchSize = Math.floor(ub);
671
+ }
672
+ }
673
+
674
+ if (partial.kvCacheDtype !== undefined && partial.kvCacheDtype !== null) {
675
+ const dt = String(partial.kvCacheDtype).toLowerCase().trim();
676
+ if (['auto', 'fp16', 'f16', 'q8_0', 'q8', 'q4_0', 'q4'].includes(dt)) {
677
+ result.kvCacheDtype = dt;
678
+ }
679
+ }
680
+
681
+ if (partial.htMode !== undefined && partial.htMode !== null) {
682
+ const ht = String(partial.htMode).toLowerCase().trim();
683
+ if (['auto', 'physical', 'smt', 'all', 'custom'].includes(ht)) {
684
+ result.htMode = ht === 'all' ? 'smt' : ht;
685
+ }
686
+ }
687
+
688
+ const vllmPart = {
689
+ ...(partial.vllm || {}),
690
+ ...(partial.gpuMemoryUtilization !== undefined ? { gpuMemoryUtilization: partial.gpuMemoryUtilization } : {}),
691
+ ...(partial.maxModelLen !== undefined ? { maxModelLen: partial.maxModelLen } : {}),
692
+ ...(partial.tensorParallelSize !== undefined ? { tensorParallelSize: partial.tensorParallelSize } : {}),
693
+ ...(partial.dtype !== undefined ? { dtype: partial.dtype } : {}),
694
+ ...(partial.enforceEager !== undefined ? { enforceEager: partial.enforceEager } : {}),
695
+ ...(partial.enablePrefixCaching !== undefined ? { enablePrefixCaching: partial.enablePrefixCaching } : {}),
696
+ ...(partial.kvCacheDtype !== undefined ? { kvCacheDtype: partial.kvCacheDtype } : {}),
697
+ };
698
+ if (typeof vllmPart === 'object' && vllmPart !== null) {
699
+ if (vllmPart.gpuMemoryUtilization !== undefined) {
700
+ const mem = Number(vllmPart.gpuMemoryUtilization);
701
+ if (Number.isFinite(mem) && mem >= 0.1 && mem <= 1.0) {
702
+ result.vllm.gpuMemoryUtilization = Math.round(mem * 100) / 100;
703
+ }
704
+ }
705
+ if (vllmPart.maxModelLen !== undefined) {
706
+ if (vllmPart.maxModelLen === null || vllmPart.maxModelLen === '' || vllmPart.maxModelLen === 'auto') {
707
+ result.vllm.maxModelLen = null;
708
+ } else {
709
+ const len = Number(vllmPart.maxModelLen);
710
+ if (Number.isFinite(len) && len >= 512 && len <= 131072) {
711
+ result.vllm.maxModelLen = Math.floor(len);
712
+ }
713
+ }
714
+ }
715
+ if (vllmPart.tensorParallelSize !== undefined) {
716
+ const tp = Number(vllmPart.tensorParallelSize);
717
+ if (Number.isFinite(tp) && tp >= 1 && tp <= 8) {
718
+ result.vllm.tensorParallelSize = Math.floor(tp);
719
+ }
720
+ }
721
+ if (vllmPart.dtype !== undefined) {
722
+ const dt = String(vllmPart.dtype).toLowerCase().trim();
723
+ if (['auto', 'float16', 'bfloat16', 'half', 'float'].includes(dt)) {
724
+ result.vllm.dtype = dt;
725
+ }
726
+ }
727
+ if (vllmPart.enforceEager !== undefined) {
728
+ result.vllm.enforceEager = Boolean(vllmPart.enforceEager);
729
+ }
730
+ if (vllmPart.enablePrefixCaching !== undefined) {
731
+ result.vllm.enablePrefixCaching = Boolean(vllmPart.enablePrefixCaching);
732
+ }
733
+ if (vllmPart.kvCacheDtype !== undefined) {
734
+ const kv = String(vllmPart.kvCacheDtype).toLowerCase().trim();
735
+ if (['auto', 'fp8', 'fp8_e5m2', 'fp8_e4m3'].includes(kv)) {
736
+ result.vllm.kvCacheDtype = kv;
737
+ }
738
+ }
739
+ }
740
+
741
+ return result;
742
+ }
743
+
744
+ export function getPersistedModelLoadSettings() {
745
+ return normalizeModelLoadSettings(loadPersistedSettings().modelLoad || {});
746
+ }
747
+
748
+ export function savePersistedModelLoadSettings(partial = {}) {
749
+ const settings = structuredClone(loadPersistedSettings(true));
750
+ settings.modelLoad = normalizeModelLoadSettings(partial, normalizeModelLoadSettings(settings.modelLoad || {}));
751
+ writeSettingsFileAtomic(settings);
752
+ return settings.modelLoad;
753
+ }
754
+
755
+ export function getActiveLocalEngine() {
756
+ const settings = getPersistedModelLoadSettings();
757
+ return settings.activeEngine || 'lms';
758
+ }
759
+
760
+ export function setActiveLocalEngine(engine) {
761
+ const norm = String(engine).toLowerCase().trim();
762
+ const valid = norm === 'vllm' ? 'vllm' : 'lms';
763
+ savePersistedModelLoadSettings({ activeEngine: valid });
764
+ return valid;
765
+ }
766
+
767
+ export function getProviderBaseUrl(providerId) {
768
+ const pId = String(providerId || '').toLowerCase().trim();
769
+ const settings = loadPersistedSettings();
770
+ if (settings.providerBaseUrls && typeof settings.providerBaseUrls[pId] === 'string' && settings.providerBaseUrls[pId].trim()) {
771
+ return settings.providerBaseUrls[pId].trim().replace(/\/+$/, '');
772
+ }
773
+ const providerDef = CLOUD_PROVIDERS[pId];
774
+ if (providerDef) {
775
+ if (providerDef.envKey?.endsWith('_BASE_URL') && process.env[providerDef.envKey]) {
776
+ return process.env[providerDef.envKey].trim().replace(/\/+$/, '');
777
+ }
778
+ if (pId === 'lmstudio' && process.env.OPENAI_BASE_URL) {
779
+ return process.env.OPENAI_BASE_URL.trim().replace(/\/+$/, '');
780
+ }
781
+ if (DEFAULT_LOCAL_ENDPOINTS[pId]) {
782
+ return DEFAULT_LOCAL_ENDPOINTS[pId];
783
+ }
784
+ if (providerDef.baseUrl) {
785
+ return providerDef.baseUrl.trim().replace(/\/+$/, '');
786
+ }
787
+ }
788
+ return DEFAULT_LOCAL_ENDPOINTS.lmstudio;
789
+ }
790
+
791
+ export function savePersistedProviderBaseUrl(providerId, baseUrl) {
792
+ const pId = String(providerId || '').toLowerCase().trim();
793
+ const cleanUrl = String(baseUrl || '').trim().replace(/\/+$/, '');
794
+ if (!CLOUD_PROVIDERS[pId] || !cleanUrl) return cleanUrl;
795
+
796
+ const settings = structuredClone(loadPersistedSettings(true));
797
+ if (!settings.providerBaseUrls) settings.providerBaseUrls = {};
798
+ settings.providerBaseUrls[pId] = cleanUrl;
799
+ if (CLOUD_PROVIDERS[pId]) {
800
+ CLOUD_PROVIDERS[pId].baseUrl = cleanUrl;
801
+ }
802
+ if (config.activeProvider === pId && config.lmStudio) {
803
+ config.lmStudio.baseUrl = cleanUrl;
804
+ }
805
+
806
+ writeSettingsFileAtomic(settings);
807
+ return cleanUrl;
808
+ }
809
+
810
+ export function getPersistedActiveProvider() {
811
+ const settings = loadPersistedSettings();
812
+ if (settings.activeProvider && CLOUD_PROVIDERS[settings.activeProvider.toLowerCase()]) {
813
+ return settings.activeProvider.toLowerCase();
814
+ }
815
+ return null;
816
+ }
817
+
818
+ export function savePersistedActiveProvider(providerId, model = null, baseUrl = null) {
819
+ const pId = String(providerId || '').toLowerCase().trim();
820
+ if (!CLOUD_PROVIDERS[pId]) return;
821
+
822
+ const settings = structuredClone(loadPersistedSettings(true));
823
+ settings.activeProvider = pId;
824
+ if (!settings.providerModels) settings.providerModels = {};
825
+ if (!settings.providerBaseUrls) settings.providerBaseUrls = {};
826
+
827
+ if (model) {
828
+ const cleanModel = String(model).trim();
829
+ settings.providerModels[pId] = cleanModel;
830
+ settings.activeModel = cleanModel;
831
+ if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].defaultModel = cleanModel;
832
+ }
833
+
834
+ if (baseUrl) {
835
+ const cleanUrl = String(baseUrl).trim().replace(/\/+$/, '');
836
+ settings.providerBaseUrls[pId] = cleanUrl;
837
+ if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].baseUrl = cleanUrl;
838
+ }
839
+
840
+ writeSettingsFileAtomic(settings);
841
+ }
842
+
843
+ export function setActiveProvider(providerId, { model = null, baseUrl = null } = {}) {
844
+ const pId = String(providerId || '').toLowerCase().trim();
845
+ if (!CLOUD_PROVIDERS[pId]) {
846
+ throw new Error(`Unknown provider "${providerId}".`);
847
+ }
848
+ savePersistedActiveProvider(pId, model, baseUrl);
849
+ config.activeProvider = pId;
850
+ const def = CLOUD_PROVIDERS[pId];
851
+ if (config.lmStudio) {
852
+ config.lmStudio.baseUrl = def.baseUrl;
853
+ config.lmStudio.apiKey = def.apiKey;
854
+ config.lmStudio.model = def.defaultModel;
855
+ config.lmStudio.defaultModel = def.defaultModel;
856
+ }
857
+ return {
858
+ provider: pId,
859
+ name: def.name,
860
+ baseUrl: def.baseUrl,
861
+ model: def.defaultModel,
862
+ isLocal: Boolean(def.isLocal),
863
+ platformNotice: def.platformNotice || null,
864
+ };
865
+ }
866
+
867
+ export function getUnifiedProviderCatalog() {
868
+ const active = config.activeProvider || resolveDefaultProvider();
869
+ return {
870
+ activeProvider: active,
871
+ activeBaseUrl: getProviderBaseUrl(active),
872
+ activeModel: CLOUD_PROVIDERS[active]?.defaultModel || 'google/gemma-4-e4b',
873
+ localEndpoints: {
874
+ lmstudio: {
875
+ id: 'lmstudio',
876
+ name: CLOUD_PROVIDERS.lmstudio.name,
877
+ baseUrl: getProviderBaseUrl('lmstudio'),
878
+ defaultModel: CLOUD_PROVIDERS.lmstudio.defaultModel,
879
+ isActive: active === 'lmstudio',
880
+ },
881
+ vllm: {
882
+ id: 'vllm',
883
+ name: CLOUD_PROVIDERS.vllm.name,
884
+ baseUrl: getProviderBaseUrl('vllm'),
885
+ defaultModel: CLOUD_PROVIDERS.vllm.defaultModel,
886
+ platformNotice: CLOUD_PROVIDERS.vllm.platformNotice || null,
887
+ isActive: active === 'vllm',
888
+ },
889
+ ollama: {
890
+ id: 'ollama',
891
+ name: CLOUD_PROVIDERS.ollama?.name || 'Ollama (Local)',
892
+ baseUrl: getProviderBaseUrl('ollama'),
893
+ defaultModel: CLOUD_PROVIDERS.ollama?.defaultModel || 'qwen2.5:7b',
894
+ isActive: active === 'ollama',
895
+ }
896
+ },
897
+ providers: Object.values(CLOUD_PROVIDERS).map(p => ({
898
+ id: p.id,
899
+ name: p.name,
900
+ baseUrl: getProviderBaseUrl(p.id),
901
+ defaultModel: p.defaultModel,
902
+ isLocal: Boolean(p.isLocal),
903
+ isActive: p.id === active,
904
+ }))
905
+ };
906
+ }
907
+
908
+ export function savePersistedProviderModel(providerId, model) {
909
+ const pId = String(providerId || '').toLowerCase().trim();
910
+ const cleanModel = String(model || '').trim();
911
+ if (!CLOUD_PROVIDERS[pId] || !cleanModel) return;
912
+
913
+ const settings = structuredClone(loadPersistedSettings(true));
914
+ if (!settings.providerModels) settings.providerModels = {};
915
+ settings.providerModels[pId] = cleanModel;
916
+ if (settings.activeProvider === pId) {
917
+ settings.activeModel = cleanModel;
918
+ }
919
+ if (CLOUD_PROVIDERS[pId]) CLOUD_PROVIDERS[pId].defaultModel = cleanModel;
920
+
921
+ writeSettingsFileAtomic(settings);
922
+ }
923
+
924
+ export function resolveDefaultProvider() {
925
+ const explicit = String(process.env.LLM_PROVIDER || '').toLowerCase().trim();
926
+ if (explicit && CLOUD_PROVIDERS[explicit]) return explicit;
927
+
928
+ const persisted = getPersistedActiveProvider();
929
+ if (persisted && CLOUD_PROVIDERS[persisted]) {
930
+ const def = CLOUD_PROVIDERS[persisted];
931
+ if (def.isLocal || def.apiKey) {
932
+ return persisted;
933
+ }
934
+ }
935
+
936
+ if (process.env.OPENROUTER_API_KEY && !process.env.OPENROUTER_API_KEY.includes('...')) return 'openrouter';
937
+ if (process.env.OPENAI_API_KEY && !process.env.OPENAI_API_KEY.includes('...')) return 'openai';
938
+ if (process.env.GEMINI_API_KEY && !process.env.GEMINI_API_KEY.includes('...')) return 'gemini';
939
+ if (process.env.DEEPSEEK_API_KEY && !process.env.DEEPSEEK_API_KEY.includes('...')) return 'deepseek';
940
+ if (process.env.GROQ_API_KEY && !process.env.GROQ_API_KEY.includes('...')) return 'groq';
941
+ if (process.env.LLM_API_KEY && process.env.LLM_BASE_URL) return 'custom';
942
+
943
+ return 'lmstudio';
944
+ }
945
+
946
+ export function getProvider(id) {
947
+ const pId = String(id || '').toLowerCase().trim();
948
+ return CLOUD_PROVIDERS[pId] || null;
949
+ }
950
+
951
+ export function getProviderList() {
952
+ return Object.values(CLOUD_PROVIDERS);
953
+ }
954
+
955
+ export function getRecommendedModels(providerId) {
956
+ const provider = getProvider(providerId);
957
+ return provider ? (provider.recommendedModels || []) : [];
958
+ }
959
+
960
+ let persistedProviderKeys = {};
961
+ const providerKeyEncryptionSecret = process.env.KIKU_PROVIDER_KEY_ENCRYPTION_KEY ||
962
+ (process.execArgv.includes('--test') ? 'kiku-agent-test-process-only-key' : '');
963
+
964
+ export function isProviderKeyPersistenceEnabled() {
965
+ return Boolean(providerKeyEncryptionSecret);
966
+ }
967
+
968
+ function encryptProviderKeys(keys) {
969
+ if (!providerKeyEncryptionSecret) return null;
970
+ const key = crypto.createHash('sha256').update(providerKeyEncryptionSecret).digest();
971
+ const iv = crypto.randomBytes(12);
972
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
973
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(keys), 'utf8'), cipher.final()]);
974
+ return {
975
+ version: 1,
976
+ algorithm: 'aes-256-gcm',
977
+ iv: iv.toString('base64'),
978
+ tag: cipher.getAuthTag().toString('base64'),
979
+ ciphertext: ciphertext.toString('base64'),
980
+ };
981
+ }
982
+
983
+ function decryptProviderKeys(envelope) {
984
+ if (!providerKeyEncryptionSecret || envelope?.version !== 1 || envelope?.algorithm !== 'aes-256-gcm') return {};
985
+ const key = crypto.createHash('sha256').update(providerKeyEncryptionSecret).digest();
986
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
987
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
988
+ const plaintext = Buffer.concat([
989
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
990
+ decipher.final(),
991
+ ]).toString('utf8');
992
+ const parsed = JSON.parse(plaintext);
993
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
994
+ }
995
+
996
+ export function loadPersistedProviderKeys() {
997
+ try {
998
+ if (fs.existsSync(PROVIDER_KEYS_FILE)) {
999
+ const data = fs.readFileSync(PROVIDER_KEYS_FILE, 'utf-8');
1000
+ const parsed = JSON.parse(data);
1001
+ const keys = parsed?.ciphertext
1002
+ ? decryptProviderKeys(parsed)
1003
+ : (process.env.KIKU_ALLOW_PLAINTEXT_PROVIDER_KEYS === 'true' ? parsed : {});
1004
+ if (keys && typeof keys === 'object') {
1005
+ persistedProviderKeys = { ...keys };
1006
+ for (const [pId, key] of Object.entries(keys)) {
1007
+ const providerDef = CLOUD_PROVIDERS[pId.toLowerCase()];
1008
+ if (providerDef && !providerDef.isLocal && !providerDef.apiKey && typeof key === 'string' && key.trim()) {
1009
+ providerDef.apiKey = key.trim();
1010
+ }
1011
+ }
1012
+ return { ...persistedProviderKeys };
1013
+ }
1014
+ }
1015
+ } catch {}
1016
+ return {};
1017
+ }
1018
+
1019
+ export function savePersistedProviderKey(providerId, apiKey) {
1020
+ const pId = String(providerId || '').toLowerCase().trim();
1021
+ const providerDef = CLOUD_PROVIDERS[pId];
1022
+ if (!providerDef) {
1023
+ throw new Error(`Unknown provider "${providerId}".`);
1024
+ }
1025
+ const cleanKey = String(apiKey || '').trim();
1026
+ providerDef.apiKey = cleanKey;
1027
+
1028
+ const existing = { ...persistedProviderKeys };
1029
+
1030
+ if (cleanKey) {
1031
+ existing[pId] = cleanKey;
1032
+ } else {
1033
+ delete existing[pId];
1034
+ }
1035
+
1036
+ persistedProviderKeys = existing;
1037
+ try {
1038
+ const envelope = encryptProviderKeys(existing);
1039
+ if (!envelope) return { provider: pId, hasKey: Boolean(cleanKey), persisted: false };
1040
+ const dir = path.dirname(PROVIDER_KEYS_FILE);
1041
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1042
+ fs.writeFileSync(PROVIDER_KEYS_FILE, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
1043
+ } catch (err) {
1044
+ console.error(`Failed to save provider key to ${PROVIDER_KEYS_FILE}:`, err.message);
1045
+ }
1046
+
1047
+ return { provider: pId, hasKey: Boolean(cleanKey) };
1048
+ }
1049
+
1050
+ export function getPersistedProviderKeys() {
1051
+ return { ...persistedProviderKeys };
1052
+ }
1053
+
1054
+ loadPersistedSettings();
1055
+ loadPersistedProviderKeys();
1056
+
1057
+ const defaultProviderId = resolveDefaultProvider();
1058
+ const defaultProviderConfig = CLOUD_PROVIDERS[defaultProviderId] || CLOUD_PROVIDERS.lmstudio;
1059
+
1060
+ export const config = {
1061
+ activeProvider: defaultProviderId,
1062
+ providers: CLOUD_PROVIDERS,
1063
+
1064
+ lmStudio: {
1065
+ baseUrl: defaultProviderConfig.baseUrl,
1066
+ apiKey: defaultProviderConfig.apiKey,
1067
+ model: defaultProviderConfig.defaultModel,
1068
+ defaultModel: defaultProviderConfig.defaultModel,
1069
+ temperature: 0.3,
1070
+ maxTokens: 8192,
1071
+ requestTimeoutMs: 180000,
1072
+ },
1073
+
1074
+ agent: {
1075
+ maxIterations: 6,
1076
+ slidingWindowSize: 12,
1077
+ language: 'auto',
1078
+
1079
+ autoResearch: ['on', 'off', 'auto'].includes(String(process.env.KIKU_AUTO_RESEARCH || '').toLowerCase())
1080
+ ? String(process.env.KIKU_AUTO_RESEARCH).toLowerCase()
1081
+ : 'auto',
1082
+ systemPersona: `You are KIKU AGENT (菊), the authoritative AI encyclopedia agent and concierge for international artist Ju Jingyi (鞠婧祎).
1083
+ Speak with elegance, utmost respect, professional warmth, and meticulous factual rigor. You deliver concise, polite answers for simple facts, and structured, in-depth breakdowns for rich cultural and artistic inquiries.
1084
+
1085
+ MULTI-ANGLE RETRIEVAL & SMART MULTI-TOOL PARALLELISM:
1086
+ Always retrieve verified facts through parallel multi-angle tool calls when addressing complex or cross-cutting queries.`,
1087
+ }
1088
+ };