tiger-agent 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/.env.secrets.example +14 -0
- package/LICENSE +22 -0
- package/README.md +284 -0
- package/bin/tiger.js +96 -0
- package/package.json +58 -0
- package/scripts/audit.sh +54 -0
- package/scripts/backup.sh +42 -0
- package/scripts/cryptoEnv.js +57 -0
- package/scripts/decrypt-env.js +34 -0
- package/scripts/encrypt-env.js +34 -0
- package/scripts/migrate-vector-db.js +44 -0
- package/scripts/onboard.js +319 -0
- package/scripts/scan-secrets.sh +87 -0
- package/scripts/setup.js +302 -0
- package/scripts/sqlite_memory.py +297 -0
- package/scripts/sqlite_vec_setup.py +112 -0
- package/src/agent/contextFiles.js +30 -0
- package/src/agent/db.js +349 -0
- package/src/agent/mainAgent.js +406 -0
- package/src/agent/reflectionAgent.js +193 -0
- package/src/agent/reflectionScheduler.js +21 -0
- package/src/agent/skills.js +169 -0
- package/src/agent/subAgent.js +39 -0
- package/src/agent/toolbox.js +291 -0
- package/src/apiProviders.js +217 -0
- package/src/cli.js +187 -0
- package/src/config.js +141 -0
- package/src/kimiClient.js +88 -0
- package/src/llmClient.js +147 -0
- package/src/telegram/bot.js +182 -0
- package/src/telegram/supervisor.js +84 -0
- package/src/tokenManager.js +223 -0
- package/src/utils.js +30 -0
package/src/llmClient.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* llmClient.js
|
|
5
|
+
*
|
|
6
|
+
* Multi-provider LLM client. Drop-in replacement for kimiClient.js.
|
|
7
|
+
*
|
|
8
|
+
* Exported API (identical to kimiClient):
|
|
9
|
+
* chatCompletion(messages, options) → message object
|
|
10
|
+
* embedText(input, model?) → number[]
|
|
11
|
+
*
|
|
12
|
+
* Auto-switch behaviour:
|
|
13
|
+
* - Before each request: skip providers that are over their token limit.
|
|
14
|
+
* - On 429 rate-limit: immediately switch to next provider and retry.
|
|
15
|
+
* - After each response: record token usage; if limit now exceeded, queue
|
|
16
|
+
* an auto-switch so the next request uses a fresh provider.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { getProvider } = require('./apiProviders');
|
|
20
|
+
const tokenManager = require('./tokenManager');
|
|
21
|
+
|
|
22
|
+
// ─── Low-level fetch wrapper ─────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
async function fetchProvider(provider, endpoint, body) {
|
|
25
|
+
const key = provider.apiKey;
|
|
26
|
+
const headers = { 'Content-Type': 'application/json', ...provider.authHeaders(key) };
|
|
27
|
+
if (provider.userAgent) headers['User-Agent'] = provider.userAgent;
|
|
28
|
+
|
|
29
|
+
const timeout = provider.timeout || 30000;
|
|
30
|
+
const ctrl = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
32
|
+
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch(`${provider.baseUrl}${endpoint}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
signal: ctrl.signal
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
if (err && err.name === 'AbortError') {
|
|
44
|
+
throw Object.assign(new Error(`Timeout after ${timeout}ms (${provider.name})`), { status: 0 });
|
|
45
|
+
}
|
|
46
|
+
throw Object.assign(new Error(`Network error (${provider.name}): ${err.message}`), { status: 0 });
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const text = await res.text().catch(() => '');
|
|
53
|
+
throw Object.assign(new Error(`HTTP ${res.status} from ${provider.name}: ${text}`), { status: res.status });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return res.json();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── chatCompletion ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function chatCompletion(messages, options = {}) {
|
|
62
|
+
// Build candidate list: active provider first, then fallbacks
|
|
63
|
+
const activeId = tokenManager.getCurrentProvider();
|
|
64
|
+
const candidates = [activeId, ...tokenManager.getNextCandidates(activeId)];
|
|
65
|
+
|
|
66
|
+
let lastError = null;
|
|
67
|
+
|
|
68
|
+
for (const providerId of candidates) {
|
|
69
|
+
if (!providerId) continue;
|
|
70
|
+
|
|
71
|
+
// Skip if over daily limit
|
|
72
|
+
if (tokenManager.isOverLimit(providerId)) continue;
|
|
73
|
+
|
|
74
|
+
const provider = getProvider(providerId);
|
|
75
|
+
if (!provider || !provider.apiKey) continue;
|
|
76
|
+
|
|
77
|
+
const reqOptions = { ...options, model: options.model || provider.chatModel };
|
|
78
|
+
const body = provider.formatRequest(messages, reqOptions);
|
|
79
|
+
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = await fetchProvider(provider, provider.chatPath, body);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
lastError = err;
|
|
85
|
+
|
|
86
|
+
// 429 = rate limit, 403 = quota exhausted — both warrant a fallback
|
|
87
|
+
if (err.status === 429 || err.status === 403) {
|
|
88
|
+
const reason = err.status === 429 ? 'rate_limit' : 'quota_exceeded';
|
|
89
|
+
const switched = tokenManager.autoSwitch(reason);
|
|
90
|
+
if (switched.switched) {
|
|
91
|
+
process.stderr.write(`[llm] ${reason} on ${providerId} → switched to ${switched.to}\n`);
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Any other error: try next candidate silently; surface only if all fail
|
|
97
|
+
lastError = err;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { message, tokens } = provider.parseResponse(data);
|
|
102
|
+
|
|
103
|
+
// Record usage
|
|
104
|
+
tokenManager.recordTokens(providerId, tokens || 0);
|
|
105
|
+
|
|
106
|
+
// Queue auto-switch if this request pushed us over the limit
|
|
107
|
+
if (tokenManager.isOverLimit(providerId)) {
|
|
108
|
+
const switched = tokenManager.autoSwitch('token_limit');
|
|
109
|
+
if (switched.switched) {
|
|
110
|
+
process.stderr.write(`[llm] Token limit reached for ${providerId} → next request will use ${switched.to}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return message;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw lastError || new Error('All providers failed or exhausted.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── embedText ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async function embedText(input, model) {
|
|
123
|
+
// Find first provider in order that supports embeddings and has a key
|
|
124
|
+
const candidates = [tokenManager.getCurrentProvider(), ...tokenManager.getNextCandidates(tokenManager.getCurrentProvider())];
|
|
125
|
+
let provider = null;
|
|
126
|
+
let providerId = null;
|
|
127
|
+
for (const id of candidates) {
|
|
128
|
+
const p = getProvider(id);
|
|
129
|
+
if (p && p.apiKey && p.embedPath && (model || p.embedModel)) { provider = p; providerId = id; break; }
|
|
130
|
+
}
|
|
131
|
+
if (!provider) throw new Error('No provider with embeddings support and a configured API key.');
|
|
132
|
+
|
|
133
|
+
const embedModel = model || provider.embedModel;
|
|
134
|
+
if (!embedModel) throw new Error(`No embedding model configured for "${provider.name}".`);
|
|
135
|
+
|
|
136
|
+
const data = await fetchProvider(provider, provider.embedPath, { model: embedModel, input });
|
|
137
|
+
|
|
138
|
+
const vector = data.data?.[0]?.embedding;
|
|
139
|
+
if (!vector || !Array.isArray(vector)) {
|
|
140
|
+
throw new Error(`Invalid embedding response from ${provider.name}.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tokenManager.recordTokens(providerId, data.usage?.total_tokens || 0);
|
|
144
|
+
return vector;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { chatCompletion, embedText };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TelegramBot = require('node-telegram-bot-api');
|
|
4
|
+
const { telegramBotToken } = require('../config');
|
|
5
|
+
const { handleMessage } = require('../agent/mainAgent');
|
|
6
|
+
const tokenManager = require('../tokenManager');
|
|
7
|
+
const { getProvider } = require('../apiProviders');
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
async function safeSend(bot, chatId, text, opts = {}) {
|
|
12
|
+
try {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let i = 0; i < text.length; i += 4000) chunks.push(text.slice(i, i + 4000));
|
|
15
|
+
for (const chunk of chunks) {
|
|
16
|
+
await bot.sendMessage(chatId, chunk, opts).catch(async () => {
|
|
17
|
+
// If parse_mode causes an error, retry as plain text
|
|
18
|
+
await bot.sendMessage(chatId, chunk);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
} catch (err) {
|
|
22
|
+
process.stderr.write(`[telegram] sendMessage failed: ${err.message}\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function safeSendTyping(bot, chatId) {
|
|
27
|
+
try {
|
|
28
|
+
await bot.sendChatAction(chatId, 'typing');
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stderr.write(`[telegram] sendChatAction failed: ${err.message}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── /api command ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const KNOWN_PROVIDERS = ['kimi', 'moonshot', 'zai', 'minimax', 'claude'];
|
|
37
|
+
|
|
38
|
+
function buildApiStatus() {
|
|
39
|
+
const status = tokenManager.getStatus();
|
|
40
|
+
const lines = ['📊 *API Provider Status* (today)'];
|
|
41
|
+
lines.push('');
|
|
42
|
+
for (const s of status) {
|
|
43
|
+
const active = s.active ? '✅ *active*' : ' ';
|
|
44
|
+
const limitStr = s.limit > 0 ? `/ ${s.limit.toLocaleString()}` : '/ ∞';
|
|
45
|
+
const over = s.over ? ' ⚠️ LIMIT' : '';
|
|
46
|
+
const p = getProvider(s.id);
|
|
47
|
+
const modelStr = p ? ` (${p.chatModel})` : '';
|
|
48
|
+
lines.push(`${active} \`${s.id}\`${modelStr}`);
|
|
49
|
+
lines.push(` tokens: ${s.tokens.toLocaleString()} ${limitStr}${over} | requests: ${s.requests}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push('Use `/api <name>` to switch provider.');
|
|
53
|
+
lines.push('Names: ' + KNOWN_PROVIDERS.map((n) => `\`${n}\``).join(', '));
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleApiCommand(arg) {
|
|
58
|
+
if (!arg) {
|
|
59
|
+
return buildApiStatus();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const target = arg.trim().toLowerCase();
|
|
63
|
+
if (!KNOWN_PROVIDERS.includes(target)) {
|
|
64
|
+
return `❌ Unknown provider: \`${target}\`\nAvailable: ${KNOWN_PROVIDERS.map((n) => `\`${n}\``).join(', ')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const provider = getProvider(target);
|
|
68
|
+
if (!provider || !provider.apiKey) {
|
|
69
|
+
return `⚠️ Provider \`${target}\` has no API key configured.\nSet ${target.toUpperCase()}_API_KEY in .env and restart.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = tokenManager.setProvider(target);
|
|
73
|
+
if (!result.ok) return `❌ Switch failed: ${result.error}`;
|
|
74
|
+
|
|
75
|
+
const p = getProvider(target);
|
|
76
|
+
return `✅ Switched to *${p.name}* (\`${target}\`)\nModel: \`${p.chatModel}\``;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── /tokens command ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function handleTokensCommand() {
|
|
82
|
+
const status = tokenManager.getStatus();
|
|
83
|
+
const lines = ['📈 *Token Usage Today*', ''];
|
|
84
|
+
for (const s of status) {
|
|
85
|
+
const limitStr = s.limit > 0 ? `${s.tokens.toLocaleString()} / ${s.limit.toLocaleString()} (${Math.round((s.tokens / s.limit) * 100)}%)` : `${s.tokens.toLocaleString()} / unlimited`;
|
|
86
|
+
const flag = s.over ? ' 🔴 OVER LIMIT' : s.active ? ' 🟢 active' : '';
|
|
87
|
+
lines.push(`\`${s.id}\`: ${limitStr}${flag}`);
|
|
88
|
+
}
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Bot startup ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function startTelegramBot() {
|
|
95
|
+
if (!telegramBotToken) {
|
|
96
|
+
throw new Error('TELEGRAM_BOT_TOKEN is empty.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const bot = new TelegramBot(telegramBotToken, { polling: true });
|
|
100
|
+
|
|
101
|
+
// Register commands so Telegram shows the list when user types /
|
|
102
|
+
bot.setMyCommands([
|
|
103
|
+
{ command: 'api', description: 'Show or switch active API provider' },
|
|
104
|
+
{ command: 'tokens', description: 'Show token usage for today' },
|
|
105
|
+
{ command: 'help', description: 'Show all available commands' }
|
|
106
|
+
]).catch((err) => {
|
|
107
|
+
process.stderr.write(`[telegram] setMyCommands failed: ${err.message}\n`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
bot.on('polling_error', (err) => {
|
|
111
|
+
process.stderr.write(`[telegram] polling_error: ${err.message}\n`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
bot.on('error', (err) => {
|
|
115
|
+
process.stderr.write(`[telegram] error: ${err.message}\n`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
bot.on('message', async (msg) => {
|
|
119
|
+
const chatId = String(msg.chat.id);
|
|
120
|
+
const userId = String(msg.from?.id || msg.chat.id);
|
|
121
|
+
// Strip @botname suffix that Telegram appends in group chats (e.g. /api@MyBot → /api)
|
|
122
|
+
const rawText = String(msg.text || '').trim();
|
|
123
|
+
const text = rawText.replace(/^(\/\w+)@\S+/, '$1');
|
|
124
|
+
|
|
125
|
+
if (!text) return;
|
|
126
|
+
|
|
127
|
+
const MD = { parse_mode: 'Markdown' };
|
|
128
|
+
|
|
129
|
+
// ── Slash commands ────────────────────────────────────────────────────
|
|
130
|
+
if (text.startsWith('/api')) {
|
|
131
|
+
const arg = text.slice(4).trim() || null;
|
|
132
|
+
await safeSend(bot, chatId, handleApiCommand(arg), MD);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (text === '/tokens' || text === '/token') {
|
|
137
|
+
await safeSend(bot, chatId, handleTokensCommand(), MD);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (text === '/help' || text === '/start') {
|
|
142
|
+
const helpText = [
|
|
143
|
+
'🤖 *Tiger Bot Commands*',
|
|
144
|
+
'',
|
|
145
|
+
'/api \\- Show current provider & token stats',
|
|
146
|
+
'/api `<name>` \\- Switch active API provider',
|
|
147
|
+
'/tokens \\- Show token usage for today',
|
|
148
|
+
'/help \\- Show this message',
|
|
149
|
+
'',
|
|
150
|
+
'*Available providers:* ' + KNOWN_PROVIDERS.join(', '),
|
|
151
|
+
'',
|
|
152
|
+
'_Token limits & auto\\-switch configured in .env_'
|
|
153
|
+
].join('\n');
|
|
154
|
+
await safeSend(bot, chatId, helpText, { parse_mode: 'MarkdownV2' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Unknown slash command — show hint instead of sending to agent
|
|
159
|
+
if (text.startsWith('/')) {
|
|
160
|
+
await safeSend(bot, chatId, `Unknown command. Type /help to see available commands.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Regular messages → agent ──────────────────────────────────────────
|
|
165
|
+
let typingTimer = null;
|
|
166
|
+
try {
|
|
167
|
+
await safeSendTyping(bot, chatId);
|
|
168
|
+
typingTimer = setInterval(() => safeSendTyping(bot, chatId), 4500);
|
|
169
|
+
|
|
170
|
+
const reply = await handleMessage({ platform: 'telegram', userId, text });
|
|
171
|
+
clearInterval(typingTimer);
|
|
172
|
+
await safeSend(bot, chatId, reply);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (typingTimer) clearInterval(typingTimer);
|
|
175
|
+
await safeSend(bot, chatId, `⚠️ Error: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return bot;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { startTelegramBot };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
|
|
6
|
+
// Source root (where cli.js lives) — always inside the npm package
|
|
7
|
+
const srcRoot = path.resolve(__dirname, '..', '..');
|
|
8
|
+
// Runtime root (where logs/pids go) — inside TIGER_HOME when installed globally
|
|
9
|
+
const runtimeDir = process.env.TIGER_HOME || process.cwd();
|
|
10
|
+
const logsDir = path.resolve(runtimeDir, 'logs');
|
|
11
|
+
const botLogPath = path.resolve(logsDir, 'telegram.out.log');
|
|
12
|
+
const supervisorPidPath = path.resolve(runtimeDir, 'tiger-telegram.pid');
|
|
13
|
+
const workerPidPath = path.resolve(runtimeDir, 'tiger-telegram-worker.pid');
|
|
14
|
+
const restartDelayMs = 5000;
|
|
15
|
+
|
|
16
|
+
let worker = null;
|
|
17
|
+
let stopping = false;
|
|
18
|
+
|
|
19
|
+
function appendLog(line) {
|
|
20
|
+
fs.appendFileSync(botLogPath, `[${new Date().toISOString()}] ${line}\n`, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeBufferToLog(buffer) {
|
|
24
|
+
fs.appendFileSync(botLogPath, buffer);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function startWorker() {
|
|
28
|
+
if (stopping) return;
|
|
29
|
+
|
|
30
|
+
const cliPath = path.resolve(srcRoot, 'src', 'cli.js');
|
|
31
|
+
worker = spawn(process.execPath, [cliPath, '--telegram', '--worker'], {
|
|
32
|
+
cwd: runtimeDir,
|
|
33
|
+
env: process.env,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
fs.writeFileSync(workerPidPath, `${worker.pid}\n`, 'utf8');
|
|
38
|
+
appendLog(`worker started (PID ${worker.pid})`);
|
|
39
|
+
|
|
40
|
+
if (worker.stdout) {
|
|
41
|
+
worker.stdout.on('data', writeBufferToLog);
|
|
42
|
+
}
|
|
43
|
+
if (worker.stderr) {
|
|
44
|
+
worker.stderr.on('data', writeBufferToLog);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
worker.on('exit', (code, signal) => {
|
|
48
|
+
if (fs.existsSync(workerPidPath)) fs.unlinkSync(workerPidPath);
|
|
49
|
+
if (stopping) return;
|
|
50
|
+
appendLog(`worker exited (code=${code}, signal=${signal || 'none'}), restarting in 5s`);
|
|
51
|
+
setTimeout(startWorker, restartDelayMs);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function shutdown(signal) {
|
|
56
|
+
if (stopping) return;
|
|
57
|
+
stopping = true;
|
|
58
|
+
appendLog(`supervisor stopping (${signal})`);
|
|
59
|
+
|
|
60
|
+
if (worker && worker.pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(worker.pid, 'SIGTERM');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Worker may already be dead.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fs.existsSync(workerPidPath)) fs.unlinkSync(workerPidPath);
|
|
69
|
+
if (fs.existsSync(supervisorPidPath)) fs.unlinkSync(supervisorPidPath);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main() {
|
|
74
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(supervisorPidPath, `${process.pid}\n`, 'utf8');
|
|
76
|
+
appendLog(`supervisor started (PID ${process.pid})`);
|
|
77
|
+
|
|
78
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
79
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
80
|
+
|
|
81
|
+
startWorker();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main();
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tokenManager.js
|
|
5
|
+
*
|
|
6
|
+
* Tracks daily token usage per provider, enforces per-provider limits, and
|
|
7
|
+
* handles auto-switching when a limit or rate-limit is hit.
|
|
8
|
+
*
|
|
9
|
+
* Config (read from .env via config.js on init):
|
|
10
|
+
* ACTIVE_PROVIDER – starting provider id (default: kimi)
|
|
11
|
+
* PROVIDER_ORDER – comma-separated priority list (default: kimi,zai,minimax,claude,moonshot)
|
|
12
|
+
* KIMI_TOKEN_LIMIT – daily token cap for kimi (0 = unlimited)
|
|
13
|
+
* MOONSHOT_TOKEN_LIMIT – daily token cap for moonshot (0 = unlimited)
|
|
14
|
+
* ZAI_TOKEN_LIMIT – daily token cap for zai (0 = unlimited)
|
|
15
|
+
* MINIMAX_TOKEN_LIMIT – daily token cap for minimax (0 = unlimited)
|
|
16
|
+
* CLAUDE_TOKEN_LIMIT – daily token cap for claude (0 = unlimited)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const USAGE_FILE = path.resolve('./db/token_usage.json');
|
|
23
|
+
|
|
24
|
+
// ─── In-memory state ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const state = {
|
|
27
|
+
activeProvider: '',
|
|
28
|
+
providerOrder: [],
|
|
29
|
+
limits: {}, // { providerId: number }
|
|
30
|
+
usage: {} // { providerId: { tokens, requests, date } }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let _initialized = false;
|
|
34
|
+
|
|
35
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function todayStr() {
|
|
38
|
+
return new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanEnv(v) {
|
|
42
|
+
return String(v || '').trim().replace(/^['"]|['"]$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Persistence ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function loadUsageFile() {
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(USAGE_FILE)) return {};
|
|
50
|
+
return JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8')) || {};
|
|
51
|
+
} catch (_) {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveUsageFile() {
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(path.dirname(USAGE_FILE), { recursive: true });
|
|
59
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(state.usage, null, 2), 'utf8');
|
|
60
|
+
} catch (_) {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function init() {
|
|
66
|
+
if (_initialized) return;
|
|
67
|
+
_initialized = true;
|
|
68
|
+
|
|
69
|
+
const env = process.env;
|
|
70
|
+
const today = todayStr();
|
|
71
|
+
|
|
72
|
+
// Provider order
|
|
73
|
+
const orderRaw = cleanEnv(env.PROVIDER_ORDER) || 'kimi,zai,minimax,claude,moonshot';
|
|
74
|
+
state.providerOrder = orderRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
75
|
+
|
|
76
|
+
// Active provider
|
|
77
|
+
const active = cleanEnv(env.ACTIVE_PROVIDER) || state.providerOrder[0] || 'kimi';
|
|
78
|
+
state.activeProvider = active;
|
|
79
|
+
|
|
80
|
+
// Token limits per provider (0 = unlimited)
|
|
81
|
+
state.limits = {
|
|
82
|
+
kimi: Number(env.KIMI_TOKEN_LIMIT || 0),
|
|
83
|
+
moonshot: Number(env.MOONSHOT_TOKEN_LIMIT || 0),
|
|
84
|
+
zai: Number(env.ZAI_TOKEN_LIMIT || 0),
|
|
85
|
+
minimax: Number(env.MINIMAX_TOKEN_LIMIT || 0),
|
|
86
|
+
claude: Number(env.CLAUDE_TOKEN_LIMIT || 0)
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Load persisted daily usage; discard stale days
|
|
90
|
+
const persisted = loadUsageFile();
|
|
91
|
+
state.usage = {};
|
|
92
|
+
for (const [id, record] of Object.entries(persisted)) {
|
|
93
|
+
if (record && record.date === today) {
|
|
94
|
+
state.usage[id] = { tokens: Number(record.tokens || 0), requests: Number(record.requests || 0), date: today };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ensureInit() {
|
|
100
|
+
if (!_initialized) init();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function getCurrentProvider() {
|
|
106
|
+
ensureInit();
|
|
107
|
+
return state.activeProvider;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Manually switch to a named provider.
|
|
112
|
+
* Returns { ok, provider } or { ok: false, error }
|
|
113
|
+
*/
|
|
114
|
+
function setProvider(id) {
|
|
115
|
+
ensureInit();
|
|
116
|
+
if (!state.providerOrder.includes(id) && !['kimi', 'moonshot', 'zai', 'minimax', 'claude'].includes(id)) {
|
|
117
|
+
return { ok: false, error: `Unknown provider: ${id}` };
|
|
118
|
+
}
|
|
119
|
+
const prev = state.activeProvider;
|
|
120
|
+
state.activeProvider = id;
|
|
121
|
+
saveUsageFile();
|
|
122
|
+
return { ok: true, from: prev, to: id };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Record token usage for a provider after a successful response.
|
|
127
|
+
*/
|
|
128
|
+
function recordTokens(providerId, tokens) {
|
|
129
|
+
ensureInit();
|
|
130
|
+
const today = todayStr();
|
|
131
|
+
if (!state.usage[providerId] || state.usage[providerId].date !== today) {
|
|
132
|
+
state.usage[providerId] = { tokens: 0, requests: 0, date: today };
|
|
133
|
+
}
|
|
134
|
+
state.usage[providerId].tokens += Math.max(0, tokens || 0);
|
|
135
|
+
state.usage[providerId].requests += 1;
|
|
136
|
+
saveUsageFile();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True if the provider has exceeded its daily token limit.
|
|
141
|
+
*/
|
|
142
|
+
function isOverLimit(providerId) {
|
|
143
|
+
ensureInit();
|
|
144
|
+
const limit = state.limits[providerId] || 0;
|
|
145
|
+
if (limit === 0) return false;
|
|
146
|
+
const rec = state.usage[providerId];
|
|
147
|
+
if (!rec || rec.date !== todayStr()) return false;
|
|
148
|
+
return rec.tokens >= limit;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns all providers in priority order that are not over-limit,
|
|
153
|
+
* excluding the specified provider.
|
|
154
|
+
*/
|
|
155
|
+
function getNextCandidates(excludeId) {
|
|
156
|
+
ensureInit();
|
|
157
|
+
return state.providerOrder.filter((p) => p !== excludeId && !isOverLimit(p));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Auto-switch to the next available provider.
|
|
162
|
+
* Returns { switched, from, to, reason }
|
|
163
|
+
*/
|
|
164
|
+
function autoSwitch(reason) {
|
|
165
|
+
ensureInit();
|
|
166
|
+
const current = state.activeProvider;
|
|
167
|
+
const candidates = getNextCandidates(current);
|
|
168
|
+
if (!candidates.length) {
|
|
169
|
+
return { switched: false, from: current, reason };
|
|
170
|
+
}
|
|
171
|
+
const next = candidates[0];
|
|
172
|
+
state.activeProvider = next;
|
|
173
|
+
saveUsageFile();
|
|
174
|
+
return { switched: true, from: current, to: next, reason };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Full status for all known providers.
|
|
179
|
+
*/
|
|
180
|
+
function getStatus() {
|
|
181
|
+
ensureInit();
|
|
182
|
+
const today = todayStr();
|
|
183
|
+
const all = ['kimi', 'moonshot', 'zai', 'minimax', 'claude'];
|
|
184
|
+
return all.map((id) => {
|
|
185
|
+
const rec = state.usage[id];
|
|
186
|
+
const tokens = rec && rec.date === today ? rec.tokens : 0;
|
|
187
|
+
const requests = rec && rec.date === today ? rec.requests : 0;
|
|
188
|
+
const limit = state.limits[id] || 0;
|
|
189
|
+
return {
|
|
190
|
+
id,
|
|
191
|
+
active: id === state.activeProvider,
|
|
192
|
+
tokens,
|
|
193
|
+
requests,
|
|
194
|
+
limit,
|
|
195
|
+
over: isOverLimit(id)
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reset daily usage for a provider (or all if no id given).
|
|
202
|
+
*/
|
|
203
|
+
function resetUsage(providerId) {
|
|
204
|
+
ensureInit();
|
|
205
|
+
if (providerId) {
|
|
206
|
+
delete state.usage[providerId];
|
|
207
|
+
} else {
|
|
208
|
+
state.usage = {};
|
|
209
|
+
}
|
|
210
|
+
saveUsageFile();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
init,
|
|
215
|
+
getCurrentProvider,
|
|
216
|
+
setProvider,
|
|
217
|
+
recordTokens,
|
|
218
|
+
isOverLimit,
|
|
219
|
+
getNextCandidates,
|
|
220
|
+
autoSwitch,
|
|
221
|
+
getStatus,
|
|
222
|
+
resetUsage
|
|
223
|
+
};
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
function ensureDir(dir) {
|
|
4
|
+
if (!fs.existsSync(dir)) {
|
|
5
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function cosineSimilarity(a, b) {
|
|
10
|
+
if (!a || !b || a.length !== b.length || a.length === 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
let dot = 0;
|
|
14
|
+
let na = 0;
|
|
15
|
+
let nb = 0;
|
|
16
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
17
|
+
dot += a[i] * b[i];
|
|
18
|
+
na += a[i] * a[i];
|
|
19
|
+
nb += b[i] * b[i];
|
|
20
|
+
}
|
|
21
|
+
if (!na || !nb) {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
ensureDir,
|
|
29
|
+
cosineSimilarity
|
|
30
|
+
};
|