@appkit/llamacpp-cli 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +171 -42
  3. package/dist/cli.js +75 -10
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/completion.d.ts +9 -0
  6. package/dist/commands/completion.d.ts.map +1 -0
  7. package/dist/commands/completion.js +83 -0
  8. package/dist/commands/completion.js.map +1 -0
  9. package/dist/commands/monitor.js +1 -1
  10. package/dist/commands/monitor.js.map +1 -1
  11. package/dist/commands/ps.d.ts +1 -3
  12. package/dist/commands/ps.d.ts.map +1 -1
  13. package/dist/commands/ps.js +36 -115
  14. package/dist/commands/ps.js.map +1 -1
  15. package/dist/commands/router/config.d.ts +1 -0
  16. package/dist/commands/router/config.d.ts.map +1 -1
  17. package/dist/commands/router/config.js +7 -2
  18. package/dist/commands/router/config.js.map +1 -1
  19. package/dist/commands/router/logs.d.ts +12 -0
  20. package/dist/commands/router/logs.d.ts.map +1 -0
  21. package/dist/commands/router/logs.js +238 -0
  22. package/dist/commands/router/logs.js.map +1 -0
  23. package/dist/commands/tui.d.ts +2 -0
  24. package/dist/commands/tui.d.ts.map +1 -0
  25. package/dist/commands/tui.js +27 -0
  26. package/dist/commands/tui.js.map +1 -0
  27. package/dist/lib/completion.d.ts +5 -0
  28. package/dist/lib/completion.d.ts.map +1 -0
  29. package/dist/lib/completion.js +195 -0
  30. package/dist/lib/completion.js.map +1 -0
  31. package/dist/lib/model-downloader.d.ts +5 -1
  32. package/dist/lib/model-downloader.d.ts.map +1 -1
  33. package/dist/lib/model-downloader.js +53 -20
  34. package/dist/lib/model-downloader.js.map +1 -1
  35. package/dist/lib/router-logger.d.ts +61 -0
  36. package/dist/lib/router-logger.d.ts.map +1 -0
  37. package/dist/lib/router-logger.js +200 -0
  38. package/dist/lib/router-logger.js.map +1 -0
  39. package/dist/lib/router-manager.d.ts.map +1 -1
  40. package/dist/lib/router-manager.js +1 -0
  41. package/dist/lib/router-manager.js.map +1 -1
  42. package/dist/lib/router-server.d.ts +9 -0
  43. package/dist/lib/router-server.d.ts.map +1 -1
  44. package/dist/lib/router-server.js +169 -57
  45. package/dist/lib/router-server.js.map +1 -1
  46. package/dist/tui/ConfigApp.d.ts +7 -0
  47. package/dist/tui/ConfigApp.d.ts.map +1 -0
  48. package/dist/tui/ConfigApp.js +1002 -0
  49. package/dist/tui/ConfigApp.js.map +1 -0
  50. package/dist/tui/HistoricalMonitorApp.d.ts.map +1 -1
  51. package/dist/tui/HistoricalMonitorApp.js +85 -49
  52. package/dist/tui/HistoricalMonitorApp.js.map +1 -1
  53. package/dist/tui/ModelsApp.d.ts +7 -0
  54. package/dist/tui/ModelsApp.d.ts.map +1 -0
  55. package/dist/tui/ModelsApp.js +362 -0
  56. package/dist/tui/ModelsApp.js.map +1 -0
  57. package/dist/tui/MultiServerMonitorApp.d.ts +6 -1
  58. package/dist/tui/MultiServerMonitorApp.d.ts.map +1 -1
  59. package/dist/tui/MultiServerMonitorApp.js +1038 -122
  60. package/dist/tui/MultiServerMonitorApp.js.map +1 -1
  61. package/dist/tui/RootNavigator.d.ts +7 -0
  62. package/dist/tui/RootNavigator.d.ts.map +1 -0
  63. package/dist/tui/RootNavigator.js +55 -0
  64. package/dist/tui/RootNavigator.js.map +1 -0
  65. package/dist/tui/SearchApp.d.ts +6 -0
  66. package/dist/tui/SearchApp.d.ts.map +1 -0
  67. package/dist/tui/SearchApp.js +451 -0
  68. package/dist/tui/SearchApp.js.map +1 -0
  69. package/dist/tui/SplashScreen.d.ts +16 -0
  70. package/dist/tui/SplashScreen.d.ts.map +1 -0
  71. package/dist/tui/SplashScreen.js +129 -0
  72. package/dist/tui/SplashScreen.js.map +1 -0
  73. package/dist/types/router-config.d.ts +1 -0
  74. package/dist/types/router-config.d.ts.map +1 -1
  75. package/dist/utils/log-parser.d.ts +14 -1
  76. package/dist/utils/log-parser.d.ts.map +1 -1
  77. package/dist/utils/log-parser.js +57 -26
  78. package/dist/utils/log-parser.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/cli.ts +41 -10
  81. package/src/commands/monitor.ts +1 -1
  82. package/src/commands/ps.ts +44 -133
  83. package/src/commands/router/config.ts +9 -2
  84. package/src/commands/router/logs.ts +256 -0
  85. package/src/commands/tui.ts +25 -0
  86. package/src/lib/model-downloader.ts +57 -20
  87. package/src/lib/router-logger.ts +201 -0
  88. package/src/lib/router-manager.ts +1 -0
  89. package/src/lib/router-server.ts +193 -62
  90. package/src/tui/ConfigApp.ts +1085 -0
  91. package/src/tui/HistoricalMonitorApp.ts +88 -49
  92. package/src/tui/ModelsApp.ts +368 -0
  93. package/src/tui/MultiServerMonitorApp.ts +1163 -122
  94. package/src/tui/RootNavigator.ts +74 -0
  95. package/src/tui/SearchApp.ts +511 -0
  96. package/src/tui/SplashScreen.ts +149 -0
  97. package/src/types/router-config.ts +1 -0
  98. package/src/utils/log-parser.ts +61 -25
@@ -0,0 +1,256 @@
1
+ import chalk from 'chalk';
2
+ import { spawn } from 'child_process';
3
+ import * as readline from 'readline';
4
+ import * as fs from 'fs';
5
+ import { routerManager } from '../../lib/router-manager';
6
+ import { fileExists } from '../../utils/file-utils';
7
+ import {
8
+ getFileSize,
9
+ formatFileSize,
10
+ rotateLogFile,
11
+ clearLogFile,
12
+ } from '../../utils/log-utils';
13
+
14
+ interface RouterLogsOptions {
15
+ follow?: boolean;
16
+ lines?: number;
17
+ stderr?: boolean; // View system logs (stderr) instead of activity logs (stdout)
18
+ verbose?: boolean;
19
+ clear?: boolean;
20
+ rotate?: boolean;
21
+ clearAll?: boolean;
22
+ }
23
+
24
+ export async function routerLogsCommand(options: RouterLogsOptions): Promise<void> {
25
+ // Load router config
26
+ const config = await routerManager.loadConfig();
27
+ if (!config) {
28
+ throw new Error('Router configuration not found. Use "llamacpp router start" to create it.');
29
+ }
30
+
31
+ // Determine log file (default to stdout for activity logs, stderr for system logs)
32
+ const logPath = options.stderr ? config.stderrPath : config.stdoutPath;
33
+ const logType = options.stderr ? 'system' : 'activity';
34
+
35
+ // Also check for verbose JSON log file if --verbose flag is used
36
+ const verboseLogPath = '/Users/dweaver/.llamacpp/logs/router.log';
37
+ const useVerboseLog = options.verbose && (await fileExists(verboseLogPath));
38
+
39
+ // Handle --clear-all option (clears both stderr and stdout)
40
+ if (options.clearAll) {
41
+ let totalFreed = 0;
42
+
43
+ // Clear stderr
44
+ if (await fileExists(config.stderrPath)) {
45
+ totalFreed += await getFileSize(config.stderrPath);
46
+ await clearLogFile(config.stderrPath);
47
+ }
48
+
49
+ // Clear stdout
50
+ if (await fileExists(config.stdoutPath)) {
51
+ totalFreed += await getFileSize(config.stdoutPath);
52
+ await clearLogFile(config.stdoutPath);
53
+ }
54
+
55
+ // Clear verbose log file
56
+ if (await fileExists(verboseLogPath)) {
57
+ totalFreed += await getFileSize(verboseLogPath);
58
+ await clearLogFile(verboseLogPath);
59
+ }
60
+
61
+ console.log(chalk.green('✅ Cleared all router logs'));
62
+ console.log(chalk.dim(` Total freed: ${formatFileSize(totalFreed)}`));
63
+ return;
64
+ }
65
+
66
+ // Handle --clear option
67
+ if (options.clear) {
68
+ const targetPath = useVerboseLog ? verboseLogPath : logPath;
69
+
70
+ if (!(await fileExists(targetPath))) {
71
+ console.log(chalk.yellow(`⚠️ No ${useVerboseLog ? 'verbose log' : logType} found for router`));
72
+ console.log(chalk.dim(` Log file does not exist: ${targetPath}`));
73
+ return;
74
+ }
75
+
76
+ const sizeBefore = await getFileSize(targetPath);
77
+ await clearLogFile(targetPath);
78
+
79
+ console.log(chalk.green(`✅ Cleared router ${useVerboseLog ? 'verbose log' : logType}`));
80
+ console.log(chalk.dim(` Freed: ${formatFileSize(sizeBefore)}`));
81
+ console.log(chalk.dim(` ${targetPath}`));
82
+ return;
83
+ }
84
+
85
+ // Handle --rotate option
86
+ if (options.rotate) {
87
+ const targetPath = useVerboseLog ? verboseLogPath : logPath;
88
+
89
+ if (!(await fileExists(targetPath))) {
90
+ console.log(chalk.yellow(`⚠️ No ${useVerboseLog ? 'verbose log' : logType} found for router`));
91
+ console.log(chalk.dim(` Log file does not exist: ${targetPath}`));
92
+ return;
93
+ }
94
+
95
+ try {
96
+ const archivedPath = await rotateLogFile(targetPath);
97
+ const size = await getFileSize(archivedPath);
98
+
99
+ console.log(chalk.green(`✅ Rotated router ${useVerboseLog ? 'verbose log' : logType}`));
100
+ console.log(chalk.dim(` Archived: ${formatFileSize(size)}`));
101
+ console.log(chalk.dim(` → ${archivedPath}`));
102
+ } catch (error) {
103
+ throw new Error(`Failed to rotate log: ${(error as Error).message}`);
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Determine which log to display
109
+ const displayPath = useVerboseLog ? verboseLogPath : logPath;
110
+ const displayType = useVerboseLog ? 'verbose JSON log' : logType;
111
+
112
+ // Check if log file exists
113
+ if (!(await fileExists(displayPath))) {
114
+ console.log(chalk.yellow(`⚠️ No ${displayType} found for router`));
115
+ console.log(chalk.dim(` Log file does not exist: ${displayPath}`));
116
+
117
+ if (useVerboseLog) {
118
+ console.log();
119
+ console.log(chalk.dim(' Verbose logging is disabled. Enable with:'));
120
+ console.log(chalk.dim(' llamacpp router config --verbose true --restart'));
121
+ }
122
+ return;
123
+ }
124
+
125
+ console.log(chalk.blue(`📋 Router logs (${displayType})`));
126
+ console.log(chalk.dim(` ${displayPath}`));
127
+
128
+ // Show log size information
129
+ const currentSize = await getFileSize(displayPath);
130
+ console.log(chalk.dim(` Size: ${formatFileSize(currentSize)}`));
131
+
132
+ if (!useVerboseLog && config.verbose) {
133
+ console.log(chalk.dim(` Verbose logging is enabled (use --verbose to view JSON log)`));
134
+ } else if (!useVerboseLog && !config.verbose) {
135
+ console.log(chalk.dim(` Verbose logging is disabled`));
136
+ }
137
+
138
+ console.log();
139
+
140
+ if (options.follow) {
141
+ // Follow logs in real-time
142
+ if (useVerboseLog) {
143
+ // Pretty-print JSON logs
144
+ const tailProcess = spawn('tail', ['-f', displayPath]);
145
+ const rl = readline.createInterface({
146
+ input: tailProcess.stdout,
147
+ crlfDelay: Infinity,
148
+ });
149
+
150
+ rl.on('line', (line) => {
151
+ try {
152
+ const entry = JSON.parse(line);
153
+ // Format timestamp
154
+ const timestamp = new Date(entry.timestamp).toLocaleTimeString();
155
+ // Color code status
156
+ const statusColor = entry.status === 'success' ? chalk.green : chalk.red;
157
+
158
+ console.log(
159
+ chalk.dim(`[${timestamp}]`),
160
+ statusColor(entry.statusCode),
161
+ entry.method,
162
+ entry.endpoint,
163
+ '→',
164
+ chalk.cyan(entry.model),
165
+ chalk.dim(`(${entry.backend || 'N/A'})`),
166
+ chalk.yellow(`${entry.durationMs}ms`)
167
+ );
168
+ if (entry.prompt) {
169
+ console.log(chalk.dim(` Prompt: "${entry.prompt}"`));
170
+ }
171
+ if (entry.error) {
172
+ console.log(chalk.red(` Error: ${entry.error}`));
173
+ }
174
+ } catch {
175
+ // Not JSON, just print raw line
176
+ console.log(line);
177
+ }
178
+ });
179
+
180
+ tailProcess.on('close', () => {
181
+ process.exit(0);
182
+ });
183
+
184
+ // Handle Ctrl+C gracefully
185
+ process.on('SIGINT', () => {
186
+ tailProcess.kill();
187
+ process.exit(0);
188
+ });
189
+ } else {
190
+ // Standard tail for stderr/stdout
191
+ const tailProcess = spawn('tail', ['-f', displayPath]);
192
+ tailProcess.stdout.pipe(process.stdout);
193
+ tailProcess.stderr.pipe(process.stderr);
194
+
195
+ tailProcess.on('close', () => {
196
+ process.exit(0);
197
+ });
198
+
199
+ // Handle Ctrl+C gracefully
200
+ process.on('SIGINT', () => {
201
+ tailProcess.kill();
202
+ process.exit(0);
203
+ });
204
+ }
205
+ } else {
206
+ // Show last N lines (default 50)
207
+ const linesToShow = options.lines || 50;
208
+
209
+ if (useVerboseLog) {
210
+ // Pretty-print JSON logs
211
+ const lines = fs.readFileSync(displayPath, 'utf-8')
212
+ .split('\n')
213
+ .filter(line => line.trim())
214
+ .slice(-linesToShow);
215
+
216
+ for (const line of lines) {
217
+ try {
218
+ const entry = JSON.parse(line);
219
+ // Format timestamp
220
+ const timestamp = new Date(entry.timestamp).toLocaleTimeString();
221
+ // Color code status
222
+ const statusColor = entry.status === 'success' ? chalk.green : chalk.red;
223
+
224
+ console.log(
225
+ chalk.dim(`[${timestamp}]`),
226
+ statusColor(entry.statusCode),
227
+ entry.method,
228
+ entry.endpoint,
229
+ '→',
230
+ chalk.cyan(entry.model),
231
+ chalk.dim(`(${entry.backend || 'N/A'})`),
232
+ chalk.yellow(`${entry.durationMs}ms`)
233
+ );
234
+ if (entry.prompt) {
235
+ console.log(chalk.dim(` Prompt: "${entry.prompt}"`));
236
+ }
237
+ if (entry.error) {
238
+ console.log(chalk.red(` Error: ${entry.error}`));
239
+ }
240
+ } catch {
241
+ // Not JSON, just print raw line
242
+ console.log(line);
243
+ }
244
+ }
245
+ } else {
246
+ // Standard tail for stderr/stdout
247
+ const { execSync } = require('child_process');
248
+ try {
249
+ const output = execSync(`tail -n ${linesToShow} "${displayPath}"`, { encoding: 'utf-8' });
250
+ process.stdout.write(output);
251
+ } catch (error) {
252
+ throw new Error(`Failed to read log file: ${(error as Error).message}`);
253
+ }
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,25 @@
1
+ import chalk from 'chalk';
2
+ import blessed from 'blessed';
3
+ import { stateManager } from '../lib/state-manager.js';
4
+ import { statusChecker } from '../lib/status-checker.js';
5
+ import { createRootNavigator } from '../tui/RootNavigator.js';
6
+
7
+ export async function tuiCommand(): Promise<void> {
8
+ const servers = await stateManager.getAllServers();
9
+
10
+ if (servers.length === 0) {
11
+ console.log(chalk.yellow('No servers configured.'));
12
+ console.log(chalk.dim('\nCreate a server: llamacpp server create <model-filename>'));
13
+ return;
14
+ }
15
+
16
+ const serversWithStatus = await statusChecker.updateAllServerStatuses();
17
+
18
+ const screen = blessed.screen({
19
+ smartCSR: true,
20
+ title: 'llama.cpp Server Monitor',
21
+ fullUnicode: true,
22
+ });
23
+
24
+ await createRootNavigator(screen, serversWithStatus);
25
+ }
@@ -13,6 +13,11 @@ export interface DownloadProgress {
13
13
  speed: string;
14
14
  }
15
15
 
16
+ export interface DownloadOptions {
17
+ silent?: boolean; // Suppress console output (for TUI)
18
+ signal?: AbortSignal; // Abort signal for cancellation
19
+ }
20
+
16
21
  export class ModelDownloader {
17
22
  private modelsDir?: string;
18
23
  private getModelsDirFn?: () => Promise<string>;
@@ -68,7 +73,8 @@ export class ModelDownloader {
68
73
  private downloadFile(
69
74
  url: string,
70
75
  destPath: string,
71
- onProgress?: (downloaded: number, total: number) => void
76
+ onProgress?: (downloaded: number, total: number) => void,
77
+ signal?: AbortSignal
72
78
  ): Promise<void> {
73
79
  return new Promise((resolve, reject) => {
74
80
  const file = fs.createWriteStream(destPath);
@@ -77,6 +83,7 @@ export class ModelDownloader {
77
83
  let lastUpdateTime = Date.now();
78
84
  let lastDownloadedBytes = 0;
79
85
  let completed = false;
86
+ let request: ReturnType<typeof https.get> | null = null;
80
87
 
81
88
  const cleanup = (sigintHandler?: () => void) => {
82
89
  if (sigintHandler) {
@@ -95,22 +102,37 @@ export class ModelDownloader {
95
102
  };
96
103
 
97
104
  const sigintHandler = () => {
98
- request.destroy();
105
+ if (request) request.destroy();
99
106
  handleError(new Error('Download interrupted by user'), sigintHandler);
100
107
  };
101
108
 
102
- const request = https.get(url, { agent: new https.Agent({ keepAlive: false }) }, (response) => {
109
+ // Handle abort signal
110
+ const abortHandler = () => {
111
+ if (request) request.destroy();
112
+ handleError(new Error('Download cancelled'), sigintHandler);
113
+ };
114
+
115
+ if (signal) {
116
+ if (signal.aborted) {
117
+ handleError(new Error('Download cancelled'), sigintHandler);
118
+ return;
119
+ }
120
+ signal.addEventListener('abort', abortHandler, { once: true });
121
+ }
122
+
123
+ request = https.get(url, { agent: new https.Agent({ keepAlive: false }) }, (response) => {
103
124
  // Handle redirects (301, 302, 307, 308)
104
125
  if (response.statusCode === 301 || response.statusCode === 302 ||
105
126
  response.statusCode === 307 || response.statusCode === 308) {
106
127
  const redirectUrl = response.headers.location;
107
128
  if (redirectUrl) {
108
129
  cleanup(sigintHandler);
130
+ if (signal) signal.removeEventListener('abort', abortHandler);
109
131
  // Wait for file to close before starting new download
110
132
  file.close(() => {
111
133
  fs.unlink(destPath, () => {
112
134
  // Start recursive download only after cleanup is complete
113
- this.downloadFile(redirectUrl, destPath, onProgress)
135
+ this.downloadFile(redirectUrl, destPath, onProgress, signal)
114
136
  .then(resolve)
115
137
  .catch(reject);
116
138
  });
@@ -154,6 +176,7 @@ export class ModelDownloader {
154
176
  // Use callback to ensure close completes before resolving
155
177
  file.close((err) => {
156
178
  cleanup(sigintHandler);
179
+ if (signal) signal.removeEventListener('abort', abortHandler);
157
180
  if (err) reject(err);
158
181
  else resolve();
159
182
  });
@@ -161,10 +184,12 @@ export class ModelDownloader {
161
184
  });
162
185
 
163
186
  request.on('error', (err) => {
187
+ if (signal) signal.removeEventListener('abort', abortHandler);
164
188
  handleError(err, sigintHandler);
165
189
  });
166
190
 
167
191
  file.on('error', (err) => {
192
+ if (signal) signal.removeEventListener('abort', abortHandler);
168
193
  handleError(err, sigintHandler);
169
194
  });
170
195
 
@@ -200,15 +225,21 @@ export class ModelDownloader {
200
225
  repoId: string,
201
226
  filename: string,
202
227
  onProgress?: (progress: DownloadProgress) => void,
203
- modelsDir?: string
228
+ modelsDir?: string,
229
+ options?: DownloadOptions
204
230
  ): Promise<string> {
231
+ const silent = options?.silent ?? false;
232
+ const signal = options?.signal;
233
+
205
234
  // Use provided models directory or get from config
206
235
  const targetDir = modelsDir || await this.getModelsDirectory();
207
236
 
208
- console.log(chalk.blue(`📥 Downloading ${filename} from Hugging Face...`));
209
- console.log(chalk.dim(`Repository: ${repoId}`));
210
- console.log(chalk.dim(`Destination: ${targetDir}`));
211
- console.log();
237
+ if (!silent) {
238
+ console.log(chalk.blue(`📥 Downloading ${filename} from Hugging Face...`));
239
+ console.log(chalk.dim(`Repository: ${repoId}`));
240
+ console.log(chalk.dim(`Destination: ${targetDir}`));
241
+ console.log();
242
+ }
212
243
 
213
244
  // Build download URL
214
245
  const url = this.buildDownloadUrl(repoId, filename);
@@ -216,8 +247,10 @@ export class ModelDownloader {
216
247
 
217
248
  // Check if file already exists
218
249
  if (fs.existsSync(destPath)) {
219
- console.log(chalk.yellow(`⚠️ File already exists: ${filename}`));
220
- console.log(chalk.dim(' Remove it first or choose a different filename'));
250
+ if (!silent) {
251
+ console.log(chalk.yellow(`⚠️ File already exists: ${filename}`));
252
+ console.log(chalk.dim(' Remove it first or choose a different filename'));
253
+ }
221
254
  throw new Error('File already exists');
222
255
  }
223
256
 
@@ -237,8 +270,10 @@ export class ModelDownloader {
237
270
  lastTime = now;
238
271
  lastDownloaded = downloaded;
239
272
 
240
- // Display progress bar
241
- this.displayProgress(downloaded, total, filename);
273
+ // Display progress bar (only if not silent)
274
+ if (!silent) {
275
+ this.displayProgress(downloaded, total, filename);
276
+ }
242
277
 
243
278
  // Call user progress callback if provided
244
279
  if (onProgress) {
@@ -250,15 +285,17 @@ export class ModelDownloader {
250
285
  speed: `${formatBytes(speed)}/s`,
251
286
  });
252
287
  }
253
- });
288
+ }, signal);
254
289
 
255
- // Clear progress line and show completion
256
- process.stdout.write('\r\x1b[K');
257
- console.log(chalk.green('✅ Download complete!'));
290
+ if (!silent) {
291
+ // Clear progress line and show completion
292
+ process.stdout.write('\r\x1b[K');
293
+ console.log(chalk.green('✅ Download complete!'));
258
294
 
259
- const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
260
- console.log(chalk.dim(` Time: ${totalTime}s`));
261
- console.log(chalk.dim(` Location: ${destPath}`));
295
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
296
+ console.log(chalk.dim(` Time: ${totalTime}s`));
297
+ console.log(chalk.dim(` Location: ${destPath}`));
298
+ }
262
299
 
263
300
  return destPath;
264
301
  }
@@ -0,0 +1,201 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { getLogsDir } from '../utils/file-utils';
4
+
5
+ export interface RouterLogEntry {
6
+ timestamp: string;
7
+ model: string;
8
+ endpoint: string;
9
+ method: string;
10
+ status: 'success' | 'error';
11
+ statusCode: number;
12
+ durationMs: number;
13
+ error?: string;
14
+ backend?: string; // e.g., "localhost:9001"
15
+ prompt?: string; // First part of the prompt/message
16
+ }
17
+
18
+ export class RouterLogger {
19
+ private logFilePath: string;
20
+ private verbose: boolean;
21
+
22
+ constructor(verbose: boolean = false) {
23
+ this.verbose = verbose;
24
+ this.logFilePath = path.join(getLogsDir(), 'router.log');
25
+ }
26
+
27
+ /**
28
+ * Log a request with timing and outcome
29
+ */
30
+ async logRequest(entry: RouterLogEntry): Promise<void> {
31
+ // Human-readable format for console
32
+ const humanLog = this.formatHumanReadable(entry);
33
+
34
+ // Output request activity to stdout (separate from system messages on stderr)
35
+ console.log(humanLog);
36
+
37
+ // Verbose mode: append detailed JSON to log file
38
+ if (this.verbose) {
39
+ const jsonLog = JSON.stringify(entry) + '\n';
40
+ try {
41
+ await fs.appendFile(this.logFilePath, jsonLog, 'utf-8');
42
+ } catch (error) {
43
+ console.error('[Router Logger] Failed to write to log file:', error);
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Format log entry for human reading (console output)
50
+ */
51
+ private formatHumanReadable(entry: RouterLogEntry): string {
52
+ const { timestamp, model, endpoint, method, status, statusCode, durationMs, error, backend, prompt } = entry;
53
+
54
+ // Color coding based on status (using ANSI codes)
55
+ const statusColor = status === 'success' ? '\x1b[32m' : '\x1b[31m'; // Green or Red
56
+ const resetColor = '\x1b[0m';
57
+
58
+ // Base log format (no [Router] prefix, no icons)
59
+ let log = `${statusColor}${statusCode}${resetColor} ${method} ${endpoint} → ${model}`;
60
+
61
+ // Add backend if available
62
+ if (backend) {
63
+ log += ` (${backend})`;
64
+ }
65
+
66
+ // Add duration
67
+ log += ` ${durationMs}ms`;
68
+
69
+ // Add prompt preview if available
70
+ if (prompt) {
71
+ log += ` | "${prompt}"`;
72
+ }
73
+
74
+ // Add error if present
75
+ if (error) {
76
+ log += ` | Error: ${error}`;
77
+ }
78
+
79
+ return log;
80
+ }
81
+
82
+ /**
83
+ * Format log entry for LLM parsing (verbose JSON format)
84
+ */
85
+ static formatForLLM(entry: RouterLogEntry): string {
86
+ return JSON.stringify(entry, null, 2);
87
+ }
88
+
89
+ /**
90
+ * Read log file and return all entries (for verbose mode)
91
+ */
92
+ async readLogs(limit?: number): Promise<RouterLogEntry[]> {
93
+ try {
94
+ const content = await fs.readFile(this.logFilePath, 'utf-8');
95
+ const lines = content.trim().split('\n').filter(line => line);
96
+
97
+ // Parse JSON entries
98
+ const entries = lines
99
+ .map(line => {
100
+ try {
101
+ return JSON.parse(line) as RouterLogEntry;
102
+ } catch {
103
+ return null;
104
+ }
105
+ })
106
+ .filter((entry): entry is RouterLogEntry => entry !== null);
107
+
108
+ // Apply limit if specified
109
+ if (limit && limit > 0) {
110
+ return entries.slice(-limit);
111
+ }
112
+
113
+ return entries;
114
+ } catch (error) {
115
+ // Log file doesn't exist or can't be read
116
+ return [];
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Clear the log file
122
+ */
123
+ async clearLogs(): Promise<void> {
124
+ try {
125
+ await fs.writeFile(this.logFilePath, '', 'utf-8');
126
+ console.error('[Router Logger] Log file cleared');
127
+ } catch (error) {
128
+ console.error('[Router Logger] Failed to clear log file:', error);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get log file size
134
+ */
135
+ async getLogFileSize(): Promise<number> {
136
+ try {
137
+ const stats = await fs.stat(this.logFilePath);
138
+ return stats.size;
139
+ } catch {
140
+ return 0;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Rotate log file if it exceeds threshold
146
+ */
147
+ async rotateIfNeeded(thresholdMB: number = 100): Promise<boolean> {
148
+ const size = await this.getLogFileSize();
149
+ const thresholdBytes = thresholdMB * 1024 * 1024;
150
+
151
+ if (size > thresholdBytes) {
152
+ try {
153
+ // Generate timestamp
154
+ const timestamp = new Date()
155
+ .toISOString()
156
+ .replace(/T/, '-')
157
+ .replace(/:/g, '-')
158
+ .replace(/\..+/, '');
159
+
160
+ const logsDir = getLogsDir();
161
+ const archivedPath = path.join(logsDir, `router.${timestamp}.log`);
162
+
163
+ // Rename current log to archived version
164
+ await fs.rename(this.logFilePath, archivedPath);
165
+
166
+ console.error(`[Router Logger] Rotated log file to ${archivedPath}`);
167
+ return true;
168
+ } catch (error) {
169
+ console.error('[Router Logger] Failed to rotate log file:', error);
170
+ return false;
171
+ }
172
+ }
173
+
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Utility class for tracking request timing
180
+ */
181
+ export class RequestTimer {
182
+ private startTime: number;
183
+
184
+ constructor() {
185
+ this.startTime = Date.now();
186
+ }
187
+
188
+ /**
189
+ * Get elapsed time in milliseconds
190
+ */
191
+ elapsed(): number {
192
+ return Date.now() - this.startTime;
193
+ }
194
+
195
+ /**
196
+ * Get current ISO timestamp
197
+ */
198
+ static now(): string {
199
+ return new Date().toISOString();
200
+ }
201
+ }
@@ -56,6 +56,7 @@ export class RouterManager {
56
56
  stderrPath: path.join(this.logsDir, 'router.stderr'),
57
57
  healthCheckInterval: 5000,
58
58
  requestTimeout: 120000,
59
+ verbose: false,
59
60
  status: 'stopped',
60
61
  createdAt: new Date().toISOString(),
61
62
  };