-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
482 lines (429 loc) · 21.8 KB
/
Copy pathindex.js
File metadata and controls
482 lines (429 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#!/usr/bin/env node
/**
* RouterX - A lightweight CLI for interacting with OpenRouter models
*
* This CLI provides chat capabilities, model listing, and code assistance
* using various AI models through the OpenRouter API.
*
* Features:
* - Chat interface with streaming responses
* - Code assistance (generate, explain, fix, review, diff)
* - Model discovery and filtering
* - File output functionality
* - Configurable settings via config file
*/
import "dotenv/config";
import axios from "axios";
import { Command } from "commander";
import chalk from "chalk";
import dayjs from "dayjs";
import fs from "fs";
import path from "path";
import os from "os";
// Initialize the CLI program using Commander.js
const program = new Command();
// Configuration loading functions
function loadConfig() {
// Try to load config from multiple locations in order of preference:
// 1. Current working directory: ./config.json
// 2. User's home directory: ~/routerx-config.json
// 3. Default values
const configPaths = [
path.join(process.cwd(), 'config.json'),
path.join(os.homedir(), 'routerx-config.json')
];
for (const configPath of configPaths) {
if (fs.existsSync(configPath)) {
try {
const configFile = fs.readFileSync(configPath, 'utf8');
return { ...getDefaultConfig(), ...JSON.parse(configFile) };
} catch (error) {
console.warn(`⚠️ Warning: Could not parse config file ${configPath}:`, error.message);
}
}
}
// Return default config if no config file is found
return getDefaultConfig();
}
function getDefaultConfig() {
return {
defaultModel: "openai/gpt-4o-mini",
defaultBaseUrl: "https://openrouter.ai/api/v1",
defaultSavePath: "./outputs",
maxRetries: 3,
timeout: 30000
};
}
// Load the configuration
const config = loadConfig();
program
.name("routerx")
.description("A lightweight CLI for OpenRouter models");
// -----------------------------------------------------
// 🔹 Command: Run a chat completion
// Handles general chat requests with AI models
// -----------------------------------------------------
program
.command("chat")
.argument("<prompt>", "Prompt to send to the model")
.option("--model <model>", `Specify model name (default: ${config.defaultModel})`)
.option("--base-url <url>", `Override API base URL (default: ${config.defaultBaseUrl})`)
.option("--save <file>", "Save the streamed reply to a text file")
.action(async (prompt, options) => {
// Validate API key exists
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY;
if (!apiKey) {
console.error("❌ Missing API key. Please set OPENAI_API_KEY or OPENROUTER_API_KEY.");
process.exit(1);
}
// Set default values for model and base URL from config
const model = options.model || config.defaultModel;
const baseUrl = options.baseUrl || config.defaultBaseUrl;
// Helper function for timestamp formatting
const now = () => chalk.dim(`[${dayjs().format("HH:mm:ss")}]`);
// Log the request details
console.log(`${now()} 🧠 Sending to model: ${chalk.yellow(model)}`);
console.log(`${now()} 🔗 ${chalk.dim(baseUrl)}`);
console.log(`${now()} 📝 Prompt: "${prompt}"`);
console.log(`${now()} 💬 Reply (streaming):\n`);
// Setup file output if requested
let outputFile = null;
if (options.save) {
const dir = path.dirname(options.save);
fs.mkdirSync(dir, { recursive: true }); // Create directory if it doesn't exist
outputFile = fs.createWriteStream(options.save, { flags: "a" }); // Append to file
outputFile.write(`\n[${new Date().toISOString()}] Prompt: ${prompt}\n\n`);
}
try {
// Make the API request with streaming response
const response = await axios({
method: "post",
url: `${baseUrl}/chat/completions`,
data: {
model,
stream: true, // Enable streaming for real-time responses
messages: [{ role: "user", content: prompt }],
},
responseType: "stream", // Get response as a stream
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: config.timeout, // Use timeout from config
});
// Handle streaming response data
response.data.on("data", (chunk) => {
// Split chunk into individual lines (SSE format)
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
if (line.trim() === "data: [DONE]") return; // Stop if stream is done
if (line.startsWith("data:")) {
try {
// Parse the JSON response from Server-Sent Events
const json = JSON.parse(line.replace("data: ", ""));
const token = json.choices?.[0]?.delta?.content;
if (token) {
// Output the token to console and file if requested
process.stdout.write(token);
if (outputFile) outputFile.write(token);
}
} catch { }
}
}
});
// Handle stream completion
response.data.on("end", () => {
if (outputFile) {
// Close the output file and log completion
outputFile.write(`\n\n[${new Date().toISOString()}] ✅ Stream complete.\n`);
outputFile.end();
console.log(chalk.dim(`\n\n${now()} ✅ Stream complete. Saved to ${options.save}\n`));
} else {
console.log(chalk.dim(`\n\n${now()} ✅ Stream complete.\n`));
}
});
} catch (err) {
// Handle any errors from the API request
console.error("❌ Error:", err.response?.data || err.message);
}
});
// -----------------------------------------------------
// 🔹 Command: List models (free + paid)
// Provides a way to discover available AI models
// -----------------------------------------------------
program
.command("models")
.description("List available models (free, paid, or filtered by search keyword)")
.option("--free", "Show only free models")
.option("--search <keyword>", "Filter models by keyword (e.g. mistral, vision, llama)")
.action(async (options) => {
const baseUrl = config.defaultBaseUrl; // Use base URL from config
try {
console.log("📡 Fetching model list...");
const res = await axios.get(`${baseUrl}/models`, {
timeout: config.timeout, // Use timeout from config
});
const models = res.data.data || [];
// Apply filters based on options
let filtered = models.filter((m) => m.id);
// Filter by free models if requested
if (options.free) {
filtered = filtered.filter(
(m) =>
/(:free|-free|\/free)/i.test(m.id) || // Check for free suffix/prefix
m.pricing?.prompt === 0 || // Check for free pricing
m.pricing?.completion === 0
);
}
// Filter by search keyword if provided
if (options.search) {
const q = options.search.toLowerCase();
filtered = filtered.filter((m) => m.id.toLowerCase().includes(q));
}
// Handle case where no models match the filters
if (filtered.length === 0) {
console.log("⚠️ No models found matching your filters.");
return;
}
// Display the available models
console.log(
`\n🧠 Available ${options.free ? "Free " : ""}Models${options.search ? ` matching '${options.search}'` : ""}:\n`
);
for (const model of filtered) {
// Determine if the model is free or paid
const status =
/(:free|-free|\/free)/i.test(model.id) ||
model.pricing?.prompt === 0 ||
model.pricing?.completion === 0
? "Free"
: "Paid";
console.log(`• ${model.id.padEnd(45)} | ${status}`);
}
} catch (err) {
console.error("❌ Failed to fetch model list:", err.message);
}
});
// -----------------------------------------------------
// 🔹 Enhanced Command: Code Assistant (Claude / Codex style)
// Provides specialized code-related assistance
// -----------------------------------------------------
program
.command("code")
.description("AI code assistant (generate, explain, fix, review, diff)")
.argument("[mode]", `Mode: generate | explain | fix | review | diff (default: generate)`)
.argument("[target...]", "Code file(s) or prompt")
.option("--model <model>", `Force specific model ID (default: ${config.defaultModel})`)
.option("--save <path>", "Save output to file")
.option("--context <dir>", "Add folder context (default current dir)")
.option("--free", "Force only free model fallback")
.option("--prefer <keyword>", "Bias fallback model selection (e.g. coder, mistral, llama, qwen)")
.action(async (mode, target, options) => {
// Validate API key exists
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY;
if (!apiKey) {
console.error("❌ Missing API key.");
process.exit(1);
}
const baseUrl = config.defaultBaseUrl; // Use base URL from config
// Define preferred model fallback chain - using config default model as primary
const preferredModels = [
options.model,
"anthropic/claude-3.5-sonnet", // High-quality model preference
config.defaultModel, // Use configured default model
"mistralai/mixtral-8x7b", // Alternative strong model
].filter(Boolean); // Remove any undefined values
// Override preference if --free flag is specified
if (options.free) {
preferredModels.splice(0, preferredModels.length, config.defaultModel, "mistralai/mixtral-8x7b");
}
// Helper function to select a valid model from the preferred list
async function getValidModel(preferred, baseUrl) {
try {
const res = await axios.get(`${baseUrl}/models`, {
timeout: config.timeout, // Use timeout from config
});
const available = res.data?.data?.map((m) => m.id) || [];
// Find the first preferred model that's available
for (const m of preferred) if (available.includes(m)) return m;
// Fallback to configured default if none found
return config.defaultModel;
} catch {
return config.defaultModel; // Fallback on error
}
}
// Normalize the mode to lowercase for consistent handling
const modeLower = (mode || "generate").toLowerCase();
// Build the appropriate prompt based on mode and target
let prompt = "";
if (["explain", "fix", "review", "diff"].includes(modeLower) && target.length) {
// Read content from specified files if they exist
const files = target.map((f) => ({
name: f,
content: fs.existsSync(f) ? fs.readFileSync(f, "utf-8") : "",
}));
// Construct prompt based on the mode
if (modeLower === "diff" && files.length === 2) {
// Compare two files
prompt = `Compare and summarise key differences:\n\n--- ${files[0].name} ---\n${files[0].content}\n\n--- ${files[1].name} ---\n${files[1].content}`;
} else if (modeLower === "fix") {
// Fix the provided code
prompt = `Fix and improve the following code. Return the corrected version only:\n\n${files.map(f => f.content).join("\n\n")}`;
} else if (modeLower === "review") {
// Review the provided code
prompt = `Perform a detailed code review:\n\n${files.map(f => f.content).join("\n\n")}`;
} else {
// Explain the provided code
prompt = `Explain what this code does:\n\n${files.map(f => f.content).join("\n\n")}`;
}
} else {
// If no target files provided, treat the target as the prompt
prompt = target.join(" ") || "Write example code in Python";
}
// For the model variable, use the first model from preferredModels that was requested
// This is a small adjustment since options.model should be used if explicitly set
const model = options.model || config.defaultModel;
// Log the action details
console.log(chalk.cyan(`🧠 Using model:`), chalk.yellow(model));
console.log(chalk.blue(`📝 Mode:`), modeLower, "\n");
try {
// Make the API request
const res = await axios.post(
`${baseUrl}/chat/completions`,
{ model, messages: [{ role: "user", content: prompt }] },
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: config.timeout, // Use timeout from config
}
);
// Extract and display the response
const reply = res.data?.choices?.[0]?.message?.content || "(no reply)";
console.log(chalk.green("\n💬 Reply:\n") + reply);
// Save to file if requested
if (options.save) {
// If save path doesn't include a directory, prepend the default save path
let savePath = options.save;
if (!savePath.includes('/') && !savePath.includes('\\')) {
savePath = path.join(config.defaultSavePath, savePath);
}
const dir = path.dirname(savePath);
fs.mkdirSync(dir, { recursive: true }); // Create directory if needed
fs.writeFileSync(savePath, reply);
console.log(chalk.dim(`\n💾 Saved to ${savePath}`));
}
} catch (err) {
// Handle payment required error (402) and try fallback models
if (err.response?.status === 402 && !options.free) {
console.log(chalk.yellow("💰 Model requires more credits. Searching for best free model...\n"));
try {
// Fetch available models
const resList = await axios.get(`${baseUrl}/models`, {
timeout: config.timeout, // Use timeout from config
});
const freeModels = resList.data.data
.map((m) => m.id)
.filter((id) => /(:free|-free|\/free)/i.test(id));
let fallback = config.defaultModel; // Use configured default
// If user specified a preferred model type, try to find a match
if (options.prefer) {
const prefer = options.prefer.toLowerCase();
const match = freeModels.find((id) => id.toLowerCase().includes(prefer));
if (match) {
fallback = match;
console.log(chalk.cyan(`🧠 Using preferred free model:`), chalk.yellow(fallback));
} else {
console.log(chalk.yellow(`⚠️ No free models matched preference '${options.prefer}', using default fallback.`));
}
} else {
// Otherwise, try to find coding-specific models
const codingPreference = freeModels.find((id) =>
/(coder|code|mistral|qwen|llama)/i.test(id)
);
if (codingPreference) fallback = codingPreference;
console.log(chalk.cyan(`🧠 Selected free model:`), chalk.yellow(fallback));
}
// Try with the fallback model
const res2 = await axios.post(
`${baseUrl}/chat/completions`,
{ model: fallback, messages: [{ role: "user", content: prompt }] },
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: config.timeout, // Use timeout from config
}
);
const reply2 = res2.data?.choices?.[0]?.message?.content || "(no reply)";
console.log(chalk.green("\n💬 Reply:\n") + reply2);
if (options.save) {
// If save path doesn't include a directory, prepend the default save path
let savePath = options.save;
if (!savePath.includes('/') && !savePath.includes('\\')) {
savePath = path.join(config.defaultSavePath, savePath);
}
const dir = path.dirname(savePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(savePath, reply2);
console.log(chalk.dim(`\n💾 Saved to ${savePath}`));
}
} catch (fallbackErr) {
// Handle rate limiting errors (429) with another fallback
if (fallbackErr.response?.status === 429) {
console.log(chalk.yellow("⚠️ Preferred free model is rate-limited. Trying next available free model...\n"));
try {
// Fetch models again for second fallback attempt
const resList2 = await axios.get(`${baseUrl}/models`, {
timeout: config.timeout, // Use timeout from config
});
const freeModels2 = resList2.data.data
.map((m) => m.id)
.filter((id) => /(:free|-free|\/free)/i.test(id));
// Find a different coding-capable model that wasn't tried before
const nextFree = freeModels2.find((id) =>
id !== fallback && /(coder|code|mistral|qwen|llama|gemma)/i.test(id)
) || config.defaultModel; // Use configured default as fallback
console.log(chalk.cyan(`🧠 Retrying with alternate model:`), chalk.yellow(nextFree));
// Try with the second fallback model
const res3 = await axios.post(
`${baseUrl}/chat/completions`,
{ model: nextFree, messages: [{ role: "user", content: prompt }] },
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: config.timeout, // Use timeout from config
}
);
const reply3 = res3.data?.choices?.[0]?.message?.content || "(no reply)";
console.log(chalk.green("\n💬 Reply:\n") + reply3);
if (options.save) {
// If save path doesn't include a directory, prepend the default save path
let savePath = options.save;
if (!savePath.includes('/') && !savePath.includes('\\')) {
savePath = path.join(config.defaultSavePath, savePath);
}
const dir = path.dirname(savePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(savePath, reply3);
console.log(chalk.dim(`\n💾 Saved to ${savePath}`));
}
} catch (nextErr) {
console.error("❌ Alternate fallback also failed:", nextErr.message);
}
} else {
console.error("❌ Fallback model also failed:", fallbackErr.message);
}
return;
}
return;
}
// If it wasn't a payment error, re-throw the original error
console.error("❌ Error:", err.response?.data || err.message);
}
});
// Parse and execute the command
program.parse();