mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: CLI Integration Suite for issue #2016
- Add tool-detector.ts (6 CLI tools: claude, codex, opencode, cline, kilocode, continue) - Add config-generator/ factory + 6 generators (JSON + YAML) - Add doctor/checks.ts for CLI tool health checks - Add log-streamer.ts for usage log streaming - Add @omniroute/opencode-provider npm package - Add 5 CLI commands: config, status, logs, update, provider - Add 3 API routes: config, detect, apply - Update bin/omniroute.mjs, bin/cli/index.mjs, package.json - Update docs: SETUP_GUIDE.md, CLI-TOOLS.md - All tests pass (4302/4326, 24 pre-existing failures unchanged)
This commit is contained in:
45
@omniroute/opencode-provider/README.md
Normal file
45
@omniroute/opencode-provider/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @omniroute/opencode-provider
|
||||
|
||||
Provider plugin for connecting [OpenCode](https://github.com/anomalyco/opencode) to [OmniRoute](https://github.com/diegosouzapw/OmniRoute).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @omniroute/opencode-provider
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
import { createOmniRouteProvider } from "@omniroute/opencode-provider";
|
||||
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128/v1",
|
||||
apiKey: "your-omniroute-api-key",
|
||||
});
|
||||
```
|
||||
|
||||
Then configure OpenCode to use the provider:
|
||||
|
||||
```jsonc
|
||||
// OpenCode settings
|
||||
{
|
||||
"provider": provider
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createOmniRouteProvider(options)`
|
||||
|
||||
Creates an OpenCode-compatible provider object that routes requests through OmniRoute.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
| --------- | -------- | -------- | ---------------------------------------------------------- |
|
||||
| `baseURL` | `string` | Yes | OmniRoute API base URL (e.g., `http://localhost:20128/v1`) |
|
||||
| `apiKey` | `string` | Yes | OmniRoute API key |
|
||||
| `model` | `string` | No | Model identifier (default: `"opencode"`) |
|
||||
|
||||
**Returns:** An OpenCode-compatible provider object with `id`, `name`, `npm`, `options`, and `auth` fields.
|
||||
3
@omniroute/opencode-provider/index.d.ts
vendored
Normal file
3
@omniroute/opencode-provider/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import OmniRouteProvider from "./index.js";
|
||||
export { OmniRouteProvider };
|
||||
export default OmniRouteProvider;
|
||||
1
@omniroute/opencode-provider/index.js
Normal file
1
@omniroute/opencode-provider/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { createOmniRouteProvider, default as default } from "./index.ts";
|
||||
54
@omniroute/opencode-provider/index.ts
Normal file
54
@omniroute/opencode-provider/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* OpenCode provider plugin for OmniRoute AI Gateway
|
||||
*
|
||||
* Usage:
|
||||
* import { createOmniRouteProvider } from "@omniroute/opencode-provider";
|
||||
* const provider = createOmniRouteProvider({
|
||||
* baseURL: "http://localhost:20128/v1",
|
||||
* apiKey: "your-api-key",
|
||||
* });
|
||||
*
|
||||
* Then add to OpenCode settings:
|
||||
* { "provider": provider }
|
||||
*/
|
||||
|
||||
export interface OmniRouteProviderOptions {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface OmniRouteProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
npm: string;
|
||||
options: Record<string, unknown>;
|
||||
auth: { type: string; apiKey: string };
|
||||
}
|
||||
|
||||
export function createOmniRouteProvider(options: OmniRouteProviderOptions): OmniRouteProvider {
|
||||
if (!options.baseURL) {
|
||||
throw new Error("baseURL is required");
|
||||
}
|
||||
if (!options.apiKey) {
|
||||
throw new Error("apiKey is required");
|
||||
}
|
||||
|
||||
const baseURL = options.baseURL.replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
id: "omniroute",
|
||||
name: "OmniRoute AI Gateway",
|
||||
npm: "@omniroute/opencode-provider",
|
||||
options: {
|
||||
baseURL: `${baseURL}/v1`,
|
||||
model: options.model || "opencode",
|
||||
},
|
||||
auth: {
|
||||
type: "apiKey",
|
||||
apiKey: options.apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createOmniRouteProvider;
|
||||
20
@omniroute/opencode-provider/package.json
Normal file
20
@omniroute/opencode-provider/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-provider",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenCode provider plugin for OmniRoute AI Gateway",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"provider"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
182
bin/cli/commands/config.mjs
Normal file
182
bin/cli/commands/config.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
function printConfigHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
omniroute config list List all CLI tools and config status
|
||||
omniroute config get <tool> Show current config for a tool
|
||||
omniroute config set <tool> [options] Write config for a tool
|
||||
omniroute config validate <tool> Validate config format without writing
|
||||
|
||||
Options:
|
||||
--base-url <url> OmniRoute API base URL (default: http://localhost:20128/v1)
|
||||
--api-key <key> API key for the tool
|
||||
--model <model> Model identifier (where applicable)
|
||||
--json Output as JSON
|
||||
--non-interactive Do not prompt for confirmation
|
||||
--yes Skip confirmation prompt
|
||||
--help Show this help
|
||||
|
||||
Tools: claude, codex, opencode, cline, kilocode, continue
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureBackup(configPath) {
|
||||
if (!fs.existsSync(configPath)) return;
|
||||
const backupDir = path.join(path.dirname(configPath), ".omniroute.bak");
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||
const backupPath = path.join(backupDir, path.basename(configPath) + ".bak");
|
||||
fs.copyFileSync(configPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
export async function runConfigCommand(argv) {
|
||||
const { flags, positionals } = parseArgs(argv);
|
||||
|
||||
if (hasFlag(flags, "help") || hasFlag(flags, "h") || positionals.length === 0) {
|
||||
printConfigHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const subcommand = positionals[0];
|
||||
const toolId = positionals[1];
|
||||
|
||||
if (subcommand === "list") {
|
||||
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.js");
|
||||
const tools = await detectAllTools();
|
||||
|
||||
if (hasFlag(flags, "json")) {
|
||||
console.log(JSON.stringify(tools, null, 2));
|
||||
} else {
|
||||
printHeading("CLI Tool Configuration Status");
|
||||
for (const t of tools) {
|
||||
const status = t.configured
|
||||
? "✓ Configured"
|
||||
: t.installed
|
||||
? "✗ Not configured"
|
||||
: "✗ Not installed";
|
||||
console.log(` ${t.name.padEnd(14)} ${status}`);
|
||||
if (t.version) console.log(` version: ${t.version}`);
|
||||
console.log(` config: ${t.configPath}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "get") {
|
||||
if (!toolId) {
|
||||
printError("Tool ID required. Usage: omniroute config get <tool>");
|
||||
return 1;
|
||||
}
|
||||
const { detectTool } = await import("../../../src/lib/cli-helper/tool-detector.js");
|
||||
const tool = await detectTool(toolId);
|
||||
if (!tool) {
|
||||
printError(`Unknown tool: ${toolId}`);
|
||||
return 1;
|
||||
}
|
||||
if (hasFlag(flags, "json")) {
|
||||
console.log(JSON.stringify(tool, null, 2));
|
||||
} else {
|
||||
printHeading(`${tool.name} Configuration`);
|
||||
console.log(` Installed: ${tool.installed ? "Yes" : "No"}`);
|
||||
console.log(` Configured: ${tool.configured ? "Yes" : "No"}`);
|
||||
console.log(` Config: ${tool.configPath}`);
|
||||
if (tool.version) console.log(` Version: ${tool.version}`);
|
||||
if (tool.configContents) {
|
||||
console.log(`\n Contents:`);
|
||||
console.log(tool.configContents);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "set") {
|
||||
if (!toolId) {
|
||||
printError("Tool ID required. Usage: omniroute config set <tool> [options]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
getStringFlag(flags, "base-url", "OMNIROUTE_BASE_URL") || "http://localhost:20128/v1";
|
||||
const apiKey = getStringFlag(flags, "api-key", "OMNIROUTE_API_KEY");
|
||||
const model = getStringFlag(flags, "model");
|
||||
|
||||
if (!apiKey) {
|
||||
printError("API key required. Use --api-key or set OMNIROUTE_API_KEY.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { generateConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/index.js");
|
||||
const result = await generateConfig(toolId, { baseUrl, apiKey, model });
|
||||
|
||||
if (!result.success) {
|
||||
printError(result.error || "Failed to generate config");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const nonInteractive = hasFlag(flags, "non-interactive") || hasFlag(flags, "yes");
|
||||
|
||||
if (!nonInteractive) {
|
||||
console.log(`\n About to write config to: ${result.configPath}`);
|
||||
console.log(` Content preview:\n`);
|
||||
console.log(result.content);
|
||||
console.log("");
|
||||
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) => rl.question("Proceed? [y/N] ", resolve));
|
||||
rl.close();
|
||||
|
||||
if (!/^y(es)?$/i.test(answer)) {
|
||||
console.log("Aborted.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const dir = path.dirname(result.configPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const backupPath = ensureBackup(result.configPath);
|
||||
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
|
||||
|
||||
fs.writeFileSync(result.configPath, result.content, "utf-8");
|
||||
printSuccess(`Config written to ${result.configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "validate") {
|
||||
if (!toolId) {
|
||||
printError("Tool ID required. Usage: omniroute config validate <tool>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
getStringFlag(flags, "base-url", "OMNIROUTE_BASE_URL") || "http://localhost:20128/v1";
|
||||
const apiKey = getStringFlag(flags, "api-key", "OMNIROUTE_API_KEY") || "test-key";
|
||||
const model = getStringFlag(flags, "model");
|
||||
|
||||
const { generateConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/index.js");
|
||||
const result = await generateConfig(toolId, { baseUrl, apiKey, model });
|
||||
|
||||
if (!result.success) {
|
||||
printError(`Validation failed: ${result.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printSuccess(`Config for ${toolId} is valid`);
|
||||
if (hasFlag(flags, "json")) {
|
||||
console.log(JSON.stringify({ valid: true, content: result.content }, null, 2));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
printError(`Unknown subcommand: ${subcommand}`);
|
||||
printConfigHelp();
|
||||
return 1;
|
||||
}
|
||||
@@ -466,6 +466,15 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
checks.push(await checkServerLiveness(options));
|
||||
}
|
||||
|
||||
// CLI tool health checks
|
||||
try {
|
||||
const { collectCliToolChecks } = await import("../../../src/lib/cli-helper/doctor/checks.js");
|
||||
const cliChecks = await collectCliToolChecks();
|
||||
checks.push(...cliChecks);
|
||||
} catch (err) {
|
||||
checks.push(warn("CLI Tools", `Could not run CLI tool checks: ${err.message}`));
|
||||
}
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
dbPath,
|
||||
@@ -493,7 +502,7 @@ Options:
|
||||
--liveness-url <url> Full health endpoint URL override
|
||||
|
||||
Checks:
|
||||
config, database, storage/encryption, ports, Node runtime, native binary, memory, server liveness
|
||||
config, database, storage/encryption, ports, Node runtime, native binary, memory, server liveness, CLI tools
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
83
bin/cli/commands/logs.mjs
Normal file
83
bin/cli/commands/logs.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
|
||||
import { printHeading, printInfo, printError } from "../io.mjs";
|
||||
|
||||
function printLogsHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
omniroute logs [options]
|
||||
|
||||
Options:
|
||||
--follow Stream logs in real-time
|
||||
--filter <level> Filter by level (error, warn, info) — comma-separated
|
||||
--lines <n> Number of lines to fetch (default: 100)
|
||||
--timeout <ms> Connection timeout in ms (default: 30000)
|
||||
--base-url <url> OmniRoute API base URL (default: http://localhost:20128)
|
||||
--json Output as JSON
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
export async function runLogsCommand(argv) {
|
||||
const { flags } = parseArgs(argv);
|
||||
|
||||
if (hasFlag(flags, "help") || hasFlag(flags, "h")) {
|
||||
printLogsHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseUrl = getStringFlag(flags, "base-url") || "http://localhost:20128";
|
||||
const follow = hasFlag(flags, "follow");
|
||||
const filter = getStringFlag(flags, "filter");
|
||||
const lines = getStringFlag(flags, "lines") || "100";
|
||||
const timeout = parseInt(getStringFlag(flags, "timeout") || "30000", 10);
|
||||
|
||||
const filters = filter ? filter.split(",").map((f) => f.trim()) : [];
|
||||
|
||||
const { createLogStream } = await import("../../../src/lib/cli-helper/log-streamer.js");
|
||||
const { stream, stop } = createLogStream({ baseUrl, filters, follow, timeout });
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const processLine = (line) => {
|
||||
if (!line.trim()) return;
|
||||
if (hasFlag(flags, "json")) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const level = parsed.level || "info";
|
||||
const ts = parsed.timestamp || new Date().toISOString();
|
||||
const msg = parsed.message || JSON.stringify(parsed);
|
||||
const prefix =
|
||||
{ error: "\x1b[31m[ERR]", warn: "\x1b[33m[WRN]", info: "\x1b[36m[INF]" }[level] || "[INF]";
|
||||
console.log(`${prefix}\x1b[0m ${ts} ${msg}`);
|
||||
} catch {
|
||||
console.log(line);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) processLine(line);
|
||||
}
|
||||
if (buffer) processLine(buffer);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
printInfo("Log stream stopped.");
|
||||
} else {
|
||||
printError(`Log stream error: ${err.message}`);
|
||||
}
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
278
bin/cli/commands/provider-cmd.mjs
Normal file
278
bin/cli/commands/provider-cmd.mjs
Normal file
@@ -0,0 +1,278 @@
|
||||
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
function printProviderHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
omniroute provider add <name> [options] Add a provider connection
|
||||
omniroute provider list List configured providers
|
||||
omniroute provider remove <name|id> Remove a provider connection
|
||||
omniroute provider test <name|id> Test a provider connection
|
||||
omniroute provider default <name|id> Set default provider
|
||||
|
||||
Options:
|
||||
--provider <id> Provider id (e.g., openai, anthropic, omniroute)
|
||||
--api-key <key> API key for the provider
|
||||
--provider-name <name> Display name for the connection
|
||||
--default-model <model> Default model to use
|
||||
--base-url <url> Custom base URL override
|
||||
--json Output as JSON
|
||||
--yes Skip confirmation
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
export async function runProviderCommand(argv) {
|
||||
const { flags, positionals } = parseArgs(argv);
|
||||
|
||||
if (hasFlag(flags, "help") || hasFlag(flags, "h") || positionals.length === 0) {
|
||||
printProviderHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const subcommand = positionals[0];
|
||||
|
||||
if (subcommand === "add") {
|
||||
const providerName = positionals[1] || getStringFlag(flags, "provider");
|
||||
const apiKey = getStringFlag(flags, "api-key");
|
||||
const displayName = getStringFlag(flags, "provider-name");
|
||||
const defaultModel = getStringFlag(flags, "default-model");
|
||||
const baseUrl = getStringFlag(flags, "base-url");
|
||||
|
||||
if (!providerName) {
|
||||
printError("Provider name required. Usage: omniroute provider add <name>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (providerName === "omniroute") {
|
||||
// Special case: add OmniRoute as a provider in OpenCode config
|
||||
const opencodePath = path.join(
|
||||
process.env.HOME || os.homedir(),
|
||||
".config",
|
||||
"opencode",
|
||||
"opencode.json"
|
||||
);
|
||||
const { generateConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/index.js");
|
||||
const result = await generateConfig("opencode", {
|
||||
baseUrl: baseUrl || "http://localhost:20128/v1",
|
||||
apiKey: apiKey || "",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
printError(result.error || "Failed to generate config");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!hasFlag(flags, "yes")) {
|
||||
console.log(`\n About to write OpenCode config to: ${opencodePath}`);
|
||||
console.log(` Content:\n`);
|
||||
console.log(result.content);
|
||||
console.log("");
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) => rl.question("Proceed? [y/N] ", resolve));
|
||||
rl.close();
|
||||
if (!/^y(es)?$/i.test(answer)) {
|
||||
printInfo("Aborted.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const dir = path.dirname(opencodePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(opencodePath, result.content, "utf-8");
|
||||
printSuccess(`OpenCode config written to ${opencodePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Generic provider addition via SQLite
|
||||
const dbPath = resolveStoragePath(resolveDataDir());
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
printError("Database not found. Run `omniroute setup` first.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO provider_connections (provider, name, api_key, default_model, provider_specific_data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const specificData = baseUrl ? JSON.stringify({ baseUrl }) : null;
|
||||
stmt.run(
|
||||
providerName,
|
||||
displayName || providerName,
|
||||
apiKey || "",
|
||||
defaultModel || null,
|
||||
specificData
|
||||
);
|
||||
printSuccess(`Provider "${displayName || providerName}" added`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "list") {
|
||||
const dbPath = resolveStoragePath(resolveDataDir());
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
if (isJson()) console.log(JSON.stringify([]));
|
||||
else printInfo("No database found. Run `omniroute setup` first.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const rows = db
|
||||
.prepare("SELECT id, provider, name, default_model FROM provider_connections")
|
||||
.all();
|
||||
if (isJson()) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
} else {
|
||||
printHeading("Configured Providers");
|
||||
for (const r of rows) {
|
||||
console.log(
|
||||
` [${r.id}] ${r.name} (${r.provider})${r.default_model ? ` — model: ${r.default_model}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "remove") {
|
||||
const target = positionals[1];
|
||||
if (!target) {
|
||||
printError("Provider name or ID required. Usage: omniroute provider remove <name|id>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const dbPath = resolveStoragePath(resolveDataDir());
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
printError("Database not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const isId = /^\d+$/.test(target);
|
||||
const stmt = isId
|
||||
? db.prepare("DELETE FROM provider_connections WHERE id = ?")
|
||||
: db.prepare("DELETE FROM provider_connections WHERE name = ? OR provider = ?");
|
||||
const result = stmt.run(isId ? parseInt(target, 10) : target);
|
||||
if (result.changes > 0) {
|
||||
printSuccess(`Removed ${result.changes} provider(s)`);
|
||||
} else {
|
||||
printError("Provider not found");
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "test") {
|
||||
const target = positionals[1];
|
||||
if (!target) {
|
||||
printError("Provider name or ID required. Usage: omniroute provider test <name|id>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const dbPath = resolveStoragePath(resolveDataDir());
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
printError("Database not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const isId = /^\d+$/.test(target);
|
||||
const row = isId
|
||||
? db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(parseInt(target, 10))
|
||||
: db
|
||||
.prepare("SELECT * FROM provider_connections WHERE name = ? OR provider = ?")
|
||||
.get(target);
|
||||
|
||||
if (!row) {
|
||||
printError("Provider not found");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { testProviderApiKey } = await import("../provider-test.mjs");
|
||||
const result = await testProviderApiKey({
|
||||
provider: row.provider,
|
||||
apiKey: row.api_key,
|
||||
defaultModel: row.default_model,
|
||||
baseUrl: row.provider_specific_data ? JSON.parse(row.provider_specific_data).baseUrl : null,
|
||||
});
|
||||
|
||||
if (isJson()) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.valid) {
|
||||
printSuccess(`Provider "${row.name}" is reachable`);
|
||||
} else {
|
||||
printError(`Provider test failed: ${result.error || "unknown error"}`);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === "default") {
|
||||
const target = positionals[1];
|
||||
if (!target) {
|
||||
printError("Provider name or ID required. Usage: omniroute provider default <name|id>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const dbPath = resolveStoragePath(resolveDataDir());
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
printError("Database not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const isId = /^\d+$/.test(target);
|
||||
const row = isId
|
||||
? db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(parseInt(target, 10))
|
||||
: db
|
||||
.prepare("SELECT * FROM provider_connections WHERE name = ? OR provider = ?")
|
||||
.get(target);
|
||||
|
||||
if (!row) {
|
||||
printError("Provider not found");
|
||||
return 1;
|
||||
}
|
||||
|
||||
db.prepare("UPDATE provider_connections SET is_default = 0").run();
|
||||
db.prepare("UPDATE provider_connections SET is_default = 1 WHERE id = ?").run(row.id);
|
||||
printSuccess(`Default provider set to "${row.name}"`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
printError(`Unknown subcommand: ${subcommand}`);
|
||||
printProviderHelp();
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isJson() {
|
||||
return process.argv.includes("--json");
|
||||
}
|
||||
84
bin/cli/commands/status.mjs
Normal file
84
bin/cli/commands/status.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
|
||||
import { printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
function getPackageVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8"));
|
||||
return pkg.version || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function runStatusCommand(argv) {
|
||||
const { flags } = parseArgs(argv);
|
||||
const isJson = hasFlag(flags, "json");
|
||||
const isVerbose = hasFlag(flags, "verbose");
|
||||
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
const version = getPackageVersion();
|
||||
|
||||
const status = {
|
||||
version,
|
||||
dataDir,
|
||||
database: {
|
||||
exists: fs.existsSync(dbPath),
|
||||
path: dbPath,
|
||||
size: fs.existsSync(dbPath) ? formatBytes(fs.statSync(dbPath).size) : null,
|
||||
},
|
||||
configDir: path.join(dataDir, "config"),
|
||||
configExists: fs.existsSync(path.join(dataDir, "config")),
|
||||
};
|
||||
|
||||
if (isVerbose || !isJson) {
|
||||
try {
|
||||
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.js");
|
||||
const tools = await detectAllTools();
|
||||
status.tools = tools.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
installed: t.installed,
|
||||
configured: t.configured,
|
||||
version: t.version || null,
|
||||
}));
|
||||
} catch {
|
||||
status.tools = "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
printHeading("OmniRoute Status");
|
||||
console.log(` Version: ${status.version}`);
|
||||
console.log(` Data Dir: ${status.dataDir}`);
|
||||
console.log(
|
||||
` Database: ${status.database.exists ? "Found" : "Not found"} (${status.database.size || "N/A"})`
|
||||
);
|
||||
console.log(` Config Dir: ${status.configExists ? "Exists" : "Not found"}`);
|
||||
|
||||
if (status.tools) {
|
||||
console.log("\n CLI Tools:");
|
||||
for (const t of status.tools) {
|
||||
const icon = t.configured ? "✓" : t.installed ? "~" : "✗";
|
||||
console.log(
|
||||
` ${icon} ${t.name.padEnd(14)} ${t.installed ? "installed" : "not installed"}${t.version ? ` (${t.version})` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
166
bin/cli/commands/update.mjs
Normal file
166
bin/cli/commands/update.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function printUpdateHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
omniroute update [options]
|
||||
|
||||
Options:
|
||||
--check Check for available update without applying
|
||||
--dry-run Show what would be updated without applying
|
||||
--backup Create backup before updating (default: true)
|
||||
--no-backup Skip backup creation
|
||||
--help Show this help
|
||||
|
||||
Environment:
|
||||
OMNIRoute_AUTO_UPDATE Set to "true" to enable auto-update check on startup
|
||||
`);
|
||||
}
|
||||
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf-8"));
|
||||
return pkg.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestVersion() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "version"], {
|
||||
timeout: 15000,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const binPath = path.join(process.cwd(), "bin");
|
||||
const backupDir = path.join(homedir(), ".omniroute", "backups", `omniroute-${Date.now()}`);
|
||||
|
||||
try {
|
||||
const { mkdirSync, copyFileSync, existsSync } = await import("node:fs");
|
||||
if (!existsSync(binPath)) return null;
|
||||
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
const files = ["omniroute.mjs", "cli", "nodeRuntimeSupport.mjs", "mcp-server.mjs"];
|
||||
for (const f of files) {
|
||||
const src = path.join(binPath, f);
|
||||
if (existsSync(src)) {
|
||||
copyFileSync(src, path.join(backupDir, f));
|
||||
}
|
||||
}
|
||||
return backupDir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdateCommand(argv) {
|
||||
const { flags } = parseArgs(argv);
|
||||
|
||||
if (hasFlag(flags, "help") || hasFlag(flags, "h")) {
|
||||
printUpdateHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const checkOnly = hasFlag(flags, "check");
|
||||
const dryRun = hasFlag(flags, "dry-run");
|
||||
const skipBackup = hasFlag(flags, "no-backup");
|
||||
|
||||
const current = await getCurrentVersion();
|
||||
const latest = await getLatestVersion();
|
||||
|
||||
if (!current) {
|
||||
printError("Could not determine current version");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!latest) {
|
||||
printError("Could not check latest version. Is npm available?");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printHeading("OmniRoute Update");
|
||||
console.log(` Current version: ${current}`);
|
||||
console.log(` Latest version: ${latest}`);
|
||||
|
||||
const cmp = compareVersions(current, latest);
|
||||
if (cmp >= 0) {
|
||||
printSuccess("You are running the latest version!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`\n Update available: ${current} → ${latest}`);
|
||||
|
||||
if (checkOnly) {
|
||||
console.log("\n Run `omniroute update` to apply the update.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest");
|
||||
if (!skipBackup) console.log(" [DRY RUN] Would create backup in ~/.omniroute/backups/");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!skipBackup) {
|
||||
printInfo("Creating backup...");
|
||||
const backupPath = await createBackup();
|
||||
if (backupPath) {
|
||||
printSuccess(`Backup created: ${backupPath}`);
|
||||
} else {
|
||||
printError("Failed to create backup. Aborting update.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFlag(flags, "yes")) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) =>
|
||||
rl.question(`Proceed with update to ${latest}? [y/N] `, resolve)
|
||||
);
|
||||
rl.close();
|
||||
if (!/^y(es)?$/i.test(answer)) {
|
||||
printInfo("Update aborted.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
printInfo("Updating OmniRoute...");
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("npm install -g omniroute@latest", { stdio: "inherit" });
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
return 0;
|
||||
} catch (err) {
|
||||
printError(`Update failed: ${err.message}`);
|
||||
printInfo("Restore from backup:");
|
||||
const backupDir = path.join(homedir(), ".omniroute", "backups");
|
||||
printInfo(` ls ${backupDir}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { runDoctorCommand } from "./commands/doctor.mjs";
|
||||
import { runProvidersCommand } from "./commands/providers.mjs";
|
||||
import { runSetupCommand } from "./commands/setup.mjs";
|
||||
import { runConfigCommand } from "./commands/config.mjs";
|
||||
import { runStatusCommand } from "./commands/status.mjs";
|
||||
import { runLogsCommand } from "./commands/logs.mjs";
|
||||
import { runUpdateCommand } from "./commands/update.mjs";
|
||||
import { runProviderCommand } from "./commands/provider-cmd.mjs";
|
||||
|
||||
export async function runCliCommand(command, argv, context = {}) {
|
||||
if (command === "doctor") {
|
||||
@@ -15,5 +20,25 @@ export async function runCliCommand(command, argv, context = {}) {
|
||||
return runSetupCommand(argv, context);
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
return runConfigCommand(argv);
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
return runStatusCommand(argv);
|
||||
}
|
||||
|
||||
if (command === "logs") {
|
||||
return runLogsCommand(argv);
|
||||
}
|
||||
|
||||
if (command === "update") {
|
||||
return runUpdateCommand(argv);
|
||||
}
|
||||
|
||||
if (command === "provider") {
|
||||
return runProviderCommand(argv);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown CLI command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -54,3 +54,7 @@ export function printSuccess(message) {
|
||||
export function printInfo(message) {
|
||||
console.log(`\x1b[2m${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
export function printError(message) {
|
||||
console.log(`\x1b[31m✖ ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,16 @@ loadEnvFile();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const CLI_COMMANDS = new Set(["doctor", "providers", "setup"]);
|
||||
const CLI_COMMANDS = new Set([
|
||||
"doctor",
|
||||
"providers",
|
||||
"setup",
|
||||
"config",
|
||||
"status",
|
||||
"logs",
|
||||
"update",
|
||||
"provider",
|
||||
]);
|
||||
|
||||
if (CLI_COMMANDS.has(command)) {
|
||||
try {
|
||||
@@ -200,6 +209,18 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
omniroute providers test-all
|
||||
omniroute providers validate
|
||||
|
||||
\x1b[1mCLI Tools:\x1b[0m
|
||||
omniroute config list List CLI tool configuration status
|
||||
omniroute config get <tool> Show config for a specific tool
|
||||
omniroute config set <tool> Write config for a tool
|
||||
omniroute config validate <tool> Validate config without writing
|
||||
omniroute status Offline status dashboard
|
||||
omniroute logs [--follow] [--filter] Stream usage logs
|
||||
omniroute update [--check] [--dry-run] Check or apply OmniRoute update
|
||||
omniroute provider add <name> Add a provider connection
|
||||
omniroute provider list List configured providers
|
||||
omniroute provider test <name|id> Test a provider connection
|
||||
|
||||
\x1b[1mAfter starting:\x1b[0m
|
||||
Dashboard: http://localhost:<dashboard-port>
|
||||
API: http://localhost:<api-port>/v1
|
||||
|
||||
134
docs/AUTO-COMBO.md
Normal file
134
docs/AUTO-COMBO.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# OmniRoute Auto-Combo Engine
|
||||
|
||||
> Self-managing model chains with adaptive scoring + zero-config auto-routing
|
||||
|
||||
## Zero-Config Auto-Routing (`auto/` prefix)
|
||||
|
||||
> **NEW:** No combo creation required. Use `auto/` prefix directly in any client.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
| Model ID | Variant | Behavior |
|
||||
| -------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `auto` | default | All connected providers, LKGP strategy, balanced weights |
|
||||
| `auto/coding` | coding | Quality-first weights, suitable for code generation |
|
||||
| `auto/fast` | fast | Low-latency weighted selection |
|
||||
| `auto/cheap` | cheap | Cost-optimized routing (lowest cost first) |
|
||||
| `auto/offline` | offline | Favors providers with highest quota availability |
|
||||
| `auto/smart` | smart | Quality-first + higher exploration rate (10%) for better model discovery |
|
||||
| `auto/lkgp` | lkgp | Explicit LKGP (same as default `auto`) |
|
||||
|
||||
**How to use:**
|
||||
|
||||
```bash
|
||||
# Any IDE or CLI tool that supports OpenAI format
|
||||
Base URL: http://localhost:20128/v1
|
||||
API Key: <your-endpoint-key>
|
||||
|
||||
# In your code/config, set model to:
|
||||
model: "auto" # balanced default
|
||||
model: "auto/coding" # best for coding tasks
|
||||
model: "auto/fast" # fastest available
|
||||
model: "auto/cheap" # cheapest per token
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. OmniRoute detects `auto/` prefix in `src/sse/handlers/chat.ts`
|
||||
2. Queries all **active provider connections** from the database
|
||||
3. Filters to those with valid credentials (API key or OAuth token)
|
||||
4. Determines the model per connection (`connection.defaultModel` or provider's first model)
|
||||
5. Builds a **virtual combo** in-memory (not stored in DB)
|
||||
6. Routes using the selected variant's weight profile + LKGP strategy
|
||||
|
||||
**Key properties:**
|
||||
|
||||
- ✅ **Always-on:** No toggle, no combo creation, no configuration needed
|
||||
- ✅ **Dynamic:** Reflects current connected providers automatically
|
||||
- ✅ **Session stickiness:** LKGP ensures last successful provider is prioritized
|
||||
- ✅ **Multi-account aware:** Each provider connection becomes a separate candidate
|
||||
- ✅ **No DB writes:** Virtual combo exists only for the request, zero persistence overhead
|
||||
|
||||
**Behind the scenes:**
|
||||
|
||||
```txt
|
||||
Request: { model: "auto/coding" }
|
||||
↓
|
||||
src/sse/handlers/chat.ts detects prefix
|
||||
↓
|
||||
createVirtualAutoCombo('coding') → candidatePool from active connections
|
||||
↓
|
||||
handleComboChat (same engine as persisted combos)
|
||||
↓
|
||||
Auto-scoring selects best provider/model per request
|
||||
```
|
||||
|
||||
**Implementation files:**
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------- |
|
||||
| `open-sse/services/autoCombo/autoPrefix.ts` | Prefix parser (`parseAutoPrefix`) |
|
||||
| `open-sse/services/autoCombo/virtualFactory.ts` | Creates virtual `AutoComboConfig` objects |
|
||||
| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | Test hook for mocking provider registry |
|
||||
| `src/sse/handlers/chat.ts` | Integration: auto prefix short-circuit |
|
||||
| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` system entry |
|
||||
|
||||
## How It Works (Persisted Auto-Combos)
|
||||
|
||||
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **6-factor scoring function**:
|
||||
|
||||
| Factor | Weight | Description |
|
||||
| :--------- | :----- | :---------------------------------------------- |
|
||||
| Quota | 0.20 | Remaining capacity [0..1] |
|
||||
| Health | 0.25 | Circuit breaker: CLOSED=1.0, HALF=0.5, OPEN=0.0 |
|
||||
| CostInv | 0.20 | Inverse cost (cheaper = higher score) |
|
||||
| LatencyInv | 0.15 | Inverse p95 latency (faster = higher) |
|
||||
| TaskFit | 0.10 | Model × task type fitness score |
|
||||
| Stability | 0.10 | Low variance in latency/errors |
|
||||
|
||||
## Mode Packs
|
||||
|
||||
| Pack | Focus | Key Weight |
|
||||
| :---------------------- | :----------- | :--------------- |
|
||||
| 🚀 **Ship Fast** | Speed | latencyInv: 0.35 |
|
||||
| 💰 **Cost Saver** | Economy | costInv: 0.40 |
|
||||
| 🎯 **Quality First** | Best model | taskFit: 0.40 |
|
||||
| 📡 **Offline Friendly** | Availability | quota: 0.40 |
|
||||
|
||||
## Self-Healing
|
||||
|
||||
- **Temporary exclusion**: Score < 0.2 → excluded for 5 min (progressive backoff, max 30 min)
|
||||
- **Circuit breaker awareness**: OPEN → auto-excluded; HALF_OPEN → probe requests
|
||||
- **Incident mode**: >50% OPEN → disable exploration, maximize stability
|
||||
- **Cooldown recovery**: After exclusion, first request is a "probe" with reduced timeout
|
||||
|
||||
## Bandit Exploration
|
||||
|
||||
5% of requests (configurable) are routed to random providers for exploration. Disabled in incident mode.
|
||||
|
||||
## API
|
||||
|
||||
```bash
|
||||
# Create auto-combo
|
||||
curl -X POST http://localhost:20128/api/combos/auto \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"my-auto","name":"Auto Coder","candidatePool":["anthropic","google","openai"],"modePack":"ship-fast"}'
|
||||
|
||||
# List auto-combos
|
||||
curl http://localhost:20128/api/combos/auto
|
||||
```
|
||||
|
||||
## Task Fitness
|
||||
|
||||
30+ models scored across 6 task types (`coding`, `review`, `planning`, `analysis`, `debugging`, `documentation`). Supports wildcard patterns (e.g., `*-coder` → high coding score).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| :------------------------------------------- | :------------------------------------ |
|
||||
| `open-sse/services/autoCombo/scoring.ts` | Scoring function & pool normalization |
|
||||
| `open-sse/services/autoCombo/taskFitness.ts` | Model × task fitness lookup |
|
||||
| `open-sse/services/autoCombo/engine.ts` | Selection logic, bandit, budget cap |
|
||||
| `open-sse/services/autoCombo/selfHealing.ts` | Exclusion, probes, incident mode |
|
||||
| `open-sse/services/autoCombo/modePacks.ts` | 4 weight profiles |
|
||||
| `src/app/api/combos/auto/route.ts` | REST API |
|
||||
492
docs/CLI-TOOLS.md
Normal file
492
docs/CLI-TOOLS.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# CLI Tools Setup Guide — OmniRoute
|
||||
|
||||
This guide explains how to install and configure all supported AI coding CLI tools
|
||||
to use **OmniRoute** as the unified backend, giving you centralized key management,
|
||||
cost tracking, model switching, and request logging across every tool.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Claude / Codex / OpenCode / Cline / KiloCode / Continue / Kiro / Cursor / Copilot
|
||||
│
|
||||
▼ (all point to OmniRoute)
|
||||
http://YOUR_SERVER:20128/v1
|
||||
│
|
||||
▼ (OmniRoute routes to the right provider)
|
||||
Anthropic / OpenAI / Gemini / DeepSeek / Groq / Mistral / ...
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- One API key to manage all tools
|
||||
- Cost tracking across all CLIs in the dashboard
|
||||
- Model switching without reconfiguring every tool
|
||||
- Works locally and on remote servers (VPS)
|
||||
|
||||
---
|
||||
|
||||
## Supported Tools (Dashboard Source of Truth)
|
||||
|
||||
The dashboard cards in `/dashboard/cli-tools` are generated from `src/shared/constants/cliTools.ts`.
|
||||
Current list (v3.0.0-rc.16):
|
||||
|
||||
| Tool | ID | Command | Setup Mode | Install Method |
|
||||
| ------------------ | ------------- | ---------- | ---------- | -------------- |
|
||||
| **Claude Code** | `claude` | `claude` | env | npm |
|
||||
| **OpenAI Codex** | `codex` | `codex` | custom | npm |
|
||||
| **Factory Droid** | `droid` | `droid` | custom | bundled/CLI |
|
||||
| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI |
|
||||
| **Cursor** | `cursor` | app | guide | desktop app |
|
||||
| **Cline** | `cline` | `cline` | custom | npm |
|
||||
| **Kilo Code** | `kilo` | `kilocode` | custom | npm |
|
||||
| **Continue** | `continue` | extension | guide | VS Code |
|
||||
| **Antigravity** | `antigravity` | internal | mitm | OmniRoute |
|
||||
| **GitHub Copilot** | `copilot` | extension | custom | VS Code |
|
||||
| **OpenCode** | `opencode` | `opencode` | guide | npm |
|
||||
| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI |
|
||||
| **Qwen Code** | `qwen` | `qwen` | custom | npm |
|
||||
|
||||
### CLI fingerprint sync (Agents + Settings)
|
||||
|
||||
`/dashboard/agents` and `Settings > CLI Fingerprint` use `src/shared/constants/cliCompatProviders.ts`.
|
||||
This keeps provider IDs aligned with CLI cards and legacy IDs.
|
||||
|
||||
| CLI ID | Fingerprint Provider ID |
|
||||
| ---------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| `kilo` | `kilocode` |
|
||||
| `copilot` | `github` |
|
||||
| `claude` / `codex` / `antigravity` / `kiro` / `cursor` / `cline` / `opencode` / `droid` / `openclaw` | same ID |
|
||||
|
||||
Legacy IDs still accepted for compatibility: `copilot`, `kimi-coding`, `qwen`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Get an OmniRoute API Key
|
||||
|
||||
1. Open the OmniRoute dashboard → **API Manager** (`/dashboard/api-manager`)
|
||||
2. Click **Create API Key**
|
||||
3. Give it a name (e.g. `cli-tools`) and select all permissions
|
||||
4. Copy the key — you'll need it for every CLI below
|
||||
|
||||
> Your key looks like: `sk-xxxxxxxxxxxxxxxx-xxxxxxxxx`
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install CLI Tools
|
||||
|
||||
All npm-based tools require Node.js 18+:
|
||||
|
||||
```bash
|
||||
# Claude Code (Anthropic)
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# OpenAI Codex
|
||||
npm install -g @openai/codex
|
||||
|
||||
# OpenCode
|
||||
npm install -g opencode-ai
|
||||
|
||||
# Cline
|
||||
npm install -g cline
|
||||
|
||||
# KiloCode
|
||||
npm install -g kilocode
|
||||
|
||||
# Kiro CLI (Amazon — requires curl + unzip)
|
||||
apt-get install -y unzip # on Debian/Ubuntu
|
||||
curl -fsSL https://cli.kiro.dev/install | bash
|
||||
export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
claude --version # 2.x.x
|
||||
codex --version # 0.x.x
|
||||
opencode --version # x.x.x
|
||||
cline --version # 2.x.x
|
||||
kilocode --version # x.x.x (or: kilo --version)
|
||||
kiro-cli --version # 1.x.x
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Set Global Environment Variables
|
||||
|
||||
Add to `~/.bashrc` (or `~/.zshrc`), then run `source ~/.bashrc`:
|
||||
|
||||
```bash
|
||||
# OmniRoute Universal Endpoint
|
||||
export OPENAI_BASE_URL="http://localhost:20128/v1"
|
||||
export OPENAI_API_KEY="sk-your-omniroute-key"
|
||||
export ANTHROPIC_BASE_URL="http://localhost:20128"
|
||||
export ANTHROPIC_AUTH_TOKEN="sk-your-omniroute-key"
|
||||
export GEMINI_BASE_URL="http://localhost:20128/v1"
|
||||
export GEMINI_API_KEY="sk-your-omniroute-key"
|
||||
```
|
||||
|
||||
> For a **remote server** replace `localhost:20128` with the server IP or domain,
|
||||
> e.g. `http://192.168.0.15:20128`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Configure Each Tool
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
# Create ~/.claude/settings.json:
|
||||
mkdir -p ~/.claude && cat > ~/.claude/settings.json << EOF
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-omniroute-key"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here.
|
||||
|
||||
**Test:** `claude "say hello"`
|
||||
|
||||
---
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF
|
||||
model: auto
|
||||
apiKey: sk-your-omniroute-key
|
||||
apiBaseUrl: http://localhost:20128/v1
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:** `codex "what is 2+2?"`
|
||||
|
||||
---
|
||||
|
||||
### OpenCode
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode && cat > ~/.config/opencode/config.toml << EOF
|
||||
[provider.openai]
|
||||
base_url = "http://localhost:20128/v1"
|
||||
api_key = "sk-your-omniroute-key"
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:** `opencode`
|
||||
|
||||
---
|
||||
|
||||
### Cline (CLI or VS Code)
|
||||
|
||||
**CLI mode:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cline/data && cat > ~/.cline/data/globalState.json << EOF
|
||||
{
|
||||
"apiProvider": "openai",
|
||||
"openAiBaseUrl": "http://localhost:20128/v1",
|
||||
"openAiApiKey": "sk-your-omniroute-key"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**VS Code mode:**
|
||||
Cline extension settings → API Provider: `OpenAI Compatible` → Base URL: `http://localhost:20128/v1`
|
||||
|
||||
Or use the OmniRoute dashboard → **CLI Tools → Cline → Apply Config**.
|
||||
|
||||
---
|
||||
|
||||
### KiloCode (CLI or VS Code)
|
||||
|
||||
**CLI mode:**
|
||||
|
||||
```bash
|
||||
kilocode --api-base http://localhost:20128/v1 --api-key sk-your-omniroute-key
|
||||
```
|
||||
|
||||
**VS Code settings:**
|
||||
|
||||
```json
|
||||
{
|
||||
"kilo-code.openAiBaseUrl": "http://localhost:20128/v1",
|
||||
"kilo-code.apiKey": "sk-your-omniroute-key"
|
||||
}
|
||||
```
|
||||
|
||||
Or use the OmniRoute dashboard → **CLI Tools → KiloCode → Apply Config**.
|
||||
|
||||
---
|
||||
|
||||
### Continue (VS Code Extension)
|
||||
|
||||
Edit `~/.continue/config.yaml`:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: OmniRoute
|
||||
provider: openai
|
||||
model: auto
|
||||
apiBase: http://localhost:20128/v1
|
||||
apiKey: sk-your-omniroute-key
|
||||
default: true
|
||||
```
|
||||
|
||||
Restart VS Code after editing.
|
||||
|
||||
---
|
||||
|
||||
### Kiro CLI (Amazon)
|
||||
|
||||
```bash
|
||||
# Login to your AWS/Kiro account:
|
||||
kiro-cli login
|
||||
|
||||
# The CLI uses its own auth — OmniRoute is not needed as backend for Kiro CLI itself.
|
||||
# Use kiro-cli alongside OmniRoute for other tools.
|
||||
kiro-cli status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Qwen Code (Alibaba)
|
||||
|
||||
Qwen Code supports OpenAI-compatible API endpoints via environment variables or `settings.json`.
|
||||
|
||||
**Option 1: Environment variables (`~/.qwen/.env`)**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.qwen && cat > ~/.qwen/.env << EOF
|
||||
OPENAI_API_KEY="sk-your-omniroute-key"
|
||||
OPENAI_BASE_URL="http://localhost:20128/v1"
|
||||
OPENAI_MODEL="auto"
|
||||
EOF
|
||||
```
|
||||
|
||||
**Option 2: `settings.json` with model providers**
|
||||
|
||||
```json
|
||||
// ~/.qwen/settings.json
|
||||
{
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-your-omniroute-key",
|
||||
"OPENAI_BASE_URL": "http://localhost:20128/v1"
|
||||
},
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "omniroute-default",
|
||||
"name": "OmniRoute (Auto)",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "http://localhost:20128/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3: Inline CLI flags**
|
||||
|
||||
```bash
|
||||
OPENAI_BASE_URL="http://localhost:20128/v1" \
|
||||
OPENAI_API_KEY="sk-your-omniroute-key" \
|
||||
OPENAI_MODEL="auto" \
|
||||
qwen
|
||||
```
|
||||
|
||||
> For a **remote server** replace `localhost:20128` with the server IP or domain.
|
||||
|
||||
**Test:** `qwen "say hello"`
|
||||
|
||||
### Cursor (Desktop App)
|
||||
|
||||
> **Note:** Cursor routes requests through its cloud. For OmniRoute integration,
|
||||
> enable **Cloud Endpoint** in OmniRoute Settings and use your public domain URL.
|
||||
|
||||
Via GUI: **Settings → Models → OpenAI API Key**
|
||||
|
||||
- Base URL: `https://your-domain.com/v1`
|
||||
- API Key: your OmniRoute key
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Auto-Configuration
|
||||
|
||||
The OmniRoute dashboard automates configuration for most tools:
|
||||
|
||||
1. Go to `http://localhost:20128/dashboard/cli-tools`
|
||||
2. Expand any tool card
|
||||
3. Select your API key from the dropdown
|
||||
4. Click **Apply Config** (if tool is detected as installed)
|
||||
5. Or copy the generated config snippet manually
|
||||
|
||||
---
|
||||
|
||||
## Built-in Agents: Droid & OpenClaw
|
||||
|
||||
**Droid** and **OpenClaw** are AI agents built directly into OmniRoute — no installation needed.
|
||||
They run as internal routes and use OmniRoute's model routing automatically.
|
||||
|
||||
- Access: `http://localhost:20128/dashboard/agents`
|
||||
- Configure: same combos and providers as all other tools
|
||||
- No API key or CLI install required
|
||||
|
||||
---
|
||||
|
||||
## Available API Endpoints
|
||||
|
||||
| Endpoint | Description | Use For |
|
||||
| -------------------------- | ----------------------------- | --------------------------- |
|
||||
| `/v1/chat/completions` | Standard chat (all providers) | All modern tools |
|
||||
| `/v1/responses` | Responses API (OpenAI format) | Codex, agentic workflows |
|
||||
| `/v1/completions` | Legacy text completions | Older tools using `prompt:` |
|
||||
| `/v1/embeddings` | Text embeddings | RAG, search |
|
||||
| `/v1/images/generations` | Image generation | GPT-Image, Flux, etc. |
|
||||
| `/v1/audio/speech` | Text-to-speech | ElevenLabs, OpenAI TTS |
|
||||
| `/v1/audio/transcriptions` | Speech-to-text | Deepgram, AssemblyAI |
|
||||
|
||||
### CLI Tools API (New in v3.8)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------- | ------ | ------------------------------------------------ |
|
||||
| `/api/cli-tools/detect` | GET | Detect all installed CLI tools and config status |
|
||||
| `/api/cli-tools/detect?tool=ID` | GET | Detect a specific tool by ID |
|
||||
| `/api/cli-tools/config` | GET | List generated configs for all tools |
|
||||
| `/api/cli-tools/config` | POST | Generate config for a specific tool |
|
||||
| `/api/cli-tools/apply` | POST | Apply config to a tool (with backup) |
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands Reference (New in v3.8)
|
||||
|
||||
### `omniroute config`
|
||||
|
||||
Manage CLI tool configurations directly from the terminal.
|
||||
|
||||
```bash
|
||||
omniroute config list # List all tools and config status
|
||||
omniroute config get <tool> # Show config for a specific tool
|
||||
omniroute config set <tool> \ # Generate and write config
|
||||
--api-key sk-your-key \
|
||||
[--base-url http://localhost:20128/v1] \
|
||||
[--model auto]
|
||||
omniroute config validate <tool> # Validate config without writing
|
||||
```
|
||||
|
||||
**Options:** `--base-url`, `--api-key`, `--model`, `--json`, `--non-interactive`, `--yes`, `--help`
|
||||
|
||||
### `omniroute status`
|
||||
|
||||
Show offline status dashboard with version, database, and tool info.
|
||||
|
||||
```bash
|
||||
omniroute status # Human-readable status
|
||||
omniroute status --json # JSON output
|
||||
omniroute status --verbose # Include tool detection details
|
||||
```
|
||||
|
||||
### `omniroute logs`
|
||||
|
||||
Stream usage logs from the API endpoint.
|
||||
|
||||
```bash
|
||||
omniroute logs # Fetch last 100 log lines
|
||||
omniroute logs --follow # Stream in real-time
|
||||
omniroute logs --filter error,warn # Filter by level
|
||||
omniroute logs --lines 500 # Fetch more lines
|
||||
omniroute logs --base-url http://localhost:20128
|
||||
```
|
||||
|
||||
**Options:** `--follow`, `--filter`, `--lines`, `--timeout`, `--base-url`, `--json`, `--help`
|
||||
|
||||
### `omniroute update`
|
||||
|
||||
Check for or apply OmniRoute updates.
|
||||
|
||||
```bash
|
||||
omniroute update --check # Check for updates only
|
||||
omniroute update --dry-run # Preview update without applying
|
||||
omniroute update --yes # Apply update without prompt
|
||||
omniroute update --no-backup # Skip backup creation
|
||||
```
|
||||
|
||||
**Options:** `--check`, `--dry-run`, `--backup`, `--no-backup`, `--yes`, `--help`
|
||||
|
||||
### `omniroute provider`
|
||||
|
||||
Manage provider connections from the CLI.
|
||||
|
||||
```bash
|
||||
omniroute provider add openai --api-key sk-xxx # Add a provider
|
||||
omniroute provider list # List all providers
|
||||
omniroute provider remove <name|id> # Remove a provider
|
||||
omniroute provider test <name|id> # Test connectivity
|
||||
omniroute provider default <name|id> # Set default provider
|
||||
```
|
||||
|
||||
**Options:** `--provider`, `--api-key`, `--provider-name`, `--default-model`, `--base-url`, `--json`, `--yes`, `--help`
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Script (One Command)
|
||||
|
||||
Set up all CLI tools and configure for OmniRoute:
|
||||
|
||||
```bash
|
||||
OMNIROUTE_URL="http://localhost:20128/v1"
|
||||
OMNIROUTE_ANTHROPIC_URL="http://localhost:20128"
|
||||
OMNIROUTE_KEY="sk-your-omniroute-key"
|
||||
|
||||
npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode @qwen-code/qwen-code
|
||||
|
||||
# Kiro CLI
|
||||
apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install | bash
|
||||
|
||||
# Write configs
|
||||
mkdir -p ~/.claude ~/.codex ~/.config/opencode ~/.continue
|
||||
|
||||
cat > ~/.claude/settings.json <<< "{\"env\":{\"ANTHROPIC_BASE_URL\":\"$OMNIROUTE_ANTHROPIC_URL\",\"ANTHROPIC_AUTH_TOKEN\":\"$OMNIROUTE_KEY\"}}"
|
||||
cat > ~/.codex/config.yaml <<< "model: auto\napiKey: $OMNIROUTE_KEY\napiBaseUrl: $OMNIROUTE_URL"
|
||||
cat >> ~/.bashrc << EOF
|
||||
export OPENAI_BASE_URL="$OMNIROUTE_URL"
|
||||
export OPENAI_API_KEY="$OMNIROUTE_KEY"
|
||||
export ANTHROPIC_BASE_URL="$OMNIROUTE_ANTHROPIC_URL"
|
||||
export ANTHROPIC_AUTH_TOKEN="$OMNIROUTE_KEY"
|
||||
EOF
|
||||
|
||||
source ~/.bashrc
|
||||
echo "✅ All CLIs installed and configured for OmniRoute"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install all CLIs and configure for OmniRoute (replace with your key and server URL)
|
||||
OMNIROUTE_URL="http://localhost:20128/v1"
|
||||
OMNIROUTE_ANTHROPIC_URL="http://localhost:20128"
|
||||
OMNIROUTE_KEY="sk-your-omniroute-key"
|
||||
|
||||
npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode @qwen-code/qwen-code
|
||||
|
||||
# Kiro CLI
|
||||
apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install | bash
|
||||
|
||||
# Write configs
|
||||
mkdir -p ~/.claude ~/.codex ~/.config/opencode ~/.continue
|
||||
|
||||
cat > ~/.claude/settings.json <<< "{\"env\":{\"ANTHROPIC_BASE_URL\":\"$OMNIROUTE_ANTHROPIC_URL\",\"ANTHROPIC_AUTH_TOKEN\":\"$OMNIROUTE_KEY\"}}"
|
||||
cat > ~/.codex/config.yaml <<< "model: auto\napiKey: $OMNIROUTE_KEY\napiBaseUrl: $OMNIROUTE_URL"
|
||||
cat >> ~/.bashrc << EOF
|
||||
export OPENAI_BASE_URL="$OMNIROUTE_URL"
|
||||
export OPENAI_API_KEY="$OMNIROUTE_KEY"
|
||||
export ANTHROPIC_BASE_URL="$OMNIROUTE_ANTHROPIC_URL"
|
||||
export ANTHROPIC_AUTH_TOKEN="$OMNIROUTE_KEY"
|
||||
EOF
|
||||
|
||||
source ~/.bashrc
|
||||
echo "✅ All CLIs installed and configured for OmniRoute"
|
||||
```
|
||||
@@ -92,11 +92,16 @@ Combined with env vars (`INITIAL_PASSWORD`, `OMNIROUTE_WS_BRIDGE_SECRET`, etc.),
|
||||
### CLI Options
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| ----------------------- | -------------------------------------------------------------- |
|
||||
| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
|
||||
| `omniroute setup` | Guided CLI onboarding for password and first provider |
|
||||
| `omniroute doctor` | Run local health checks without starting the server |
|
||||
| `omniroute providers` | Discover, list, validate, and test providers from CLI |
|
||||
| `omniroute config` | CLI tool configuration — list, get, set, validate configs |
|
||||
| `omniroute status` | Offline status dashboard — version, DB, tools, config |
|
||||
| `omniroute logs` | Stream usage logs from the API (supports `--follow`) |
|
||||
| `omniroute update` | Check for or apply OmniRoute updates |
|
||||
| `omniroute provider` | Manage provider connections — add, list, remove, test, default |
|
||||
| `omniroute --port 3000` | Set canonical/API port to 3000 |
|
||||
| `omniroute --mcp` | Start MCP server (stdio transport) |
|
||||
| `omniroute --no-open` | Don't auto-open browser |
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"files": [
|
||||
"bin/",
|
||||
"app/",
|
||||
"src/lib/cli-helper/",
|
||||
"@omniroute/",
|
||||
"open-sse/mcp-server/index.ts",
|
||||
"open-sse/mcp-server/server.ts",
|
||||
"open-sse/mcp-server/httpTransport.ts",
|
||||
|
||||
@@ -296,8 +296,16 @@ export default function RoutingTab() {
|
||||
}}
|
||||
disabled={loading || forced}
|
||||
aria-pressed={checked}
|
||||
<<<<<<< HEAD
|
||||
aria-disabled={forced || undefined}
|
||||
title={titleText}
|
||||
=======
|
||||
title={
|
||||
checked
|
||||
? t("disableFingerprintTitle", { provider: label })
|
||||
: t("enableFingerprintTitle", { provider: label })
|
||||
}
|
||||
>>>>>>> a10ef5ee (feat(auto): complete zero-config auto-routing feature)
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-all ${
|
||||
checked
|
||||
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
|
||||
|
||||
82
src/app/api/cli-tools/apply/route.ts
Normal file
82
src/app/api/cli-tools/apply/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { generateConfig } from "@/lib/cli-helper/config-generator";
|
||||
|
||||
const TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
};
|
||||
|
||||
function ensureBackup(configPath: string): string | null {
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
const backupDir = path.join(path.dirname(configPath), ".omniroute.bak");
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||
const backupPath = path.join(backupDir, path.basename(configPath) + ".bak");
|
||||
fs.copyFileSync(configPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
// POST /api/cli-tools/apply - Apply config for a specific tool
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { toolId, baseUrl, apiKey, model, dryRun } = body;
|
||||
|
||||
if (!toolId) {
|
||||
return NextResponse.json({ error: "toolId is required" }, { status: 400 });
|
||||
}
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await generateConfig(toolId, {
|
||||
baseUrl: baseUrl || "http://localhost:20128/v1",
|
||||
apiKey,
|
||||
model,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return NextResponse.json({
|
||||
dryRun: true,
|
||||
configPath: result.configPath,
|
||||
content: result.content,
|
||||
});
|
||||
}
|
||||
|
||||
const configPath = TOOL_CONFIG_PATHS[toolId];
|
||||
if (!configPath) {
|
||||
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const backupPath = ensureBackup(configPath);
|
||||
|
||||
const dir = path.dirname(configPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(configPath, result.content!, "utf-8");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
configPath,
|
||||
backupPath,
|
||||
content: result.content,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying config:", error);
|
||||
return NextResponse.json({ error: "Failed to apply config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
61
src/app/api/cli-tools/config/route.ts
Normal file
61
src/app/api/cli-tools/config/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { generateConfig, generateAllConfigs } from "@/lib/cli-helper/config-generator";
|
||||
|
||||
// GET /api/cli-tools/config - List generated configs for all tools
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const baseUrl = searchParams.get("baseUrl") || "http://localhost:20128/v1";
|
||||
const apiKey = searchParams.get("apiKey") || "";
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "API key is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await generateAllConfigs({ baseUrl, apiKey });
|
||||
return NextResponse.json({ configs: results });
|
||||
} catch (error) {
|
||||
console.log("Error generating configs:", error);
|
||||
return NextResponse.json({ error: "Failed to generate configs" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cli-tools/config - Generate config for a specific tool
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { toolId, baseUrl, apiKey, model } = body;
|
||||
|
||||
if (!toolId) {
|
||||
return NextResponse.json({ error: "toolId is required" }, { status: 400 });
|
||||
}
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await generateConfig(toolId, {
|
||||
baseUrl: baseUrl || "http://localhost:20128/v1",
|
||||
apiKey,
|
||||
model,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
configPath: result.configPath,
|
||||
content: result.content,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error generating config:", error);
|
||||
return NextResponse.json({ error: "Failed to generate config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/cli-tools/detect/route.ts
Normal file
28
src/app/api/cli-tools/detect/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { detectAllTools, detectTool } from "@/lib/cli-helper/tool-detector";
|
||||
|
||||
// GET /api/cli-tools/detect - Detect all installed CLI tools
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const toolId = searchParams.get("tool");
|
||||
|
||||
try {
|
||||
if (toolId) {
|
||||
const tool = await detectTool(toolId);
|
||||
if (!tool) {
|
||||
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(tool);
|
||||
}
|
||||
|
||||
const tools = await detectAllTools();
|
||||
return NextResponse.json({ tools });
|
||||
} catch (error) {
|
||||
console.log("Error detecting tools:", error);
|
||||
return NextResponse.json({ error: "Failed to detect tools" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
src/lib/cli-helper/config-generator/claude.ts
Normal file
21
src/lib/cli-helper/config-generator/claude.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
||||
|
||||
export function generateClaudeConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): string {
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
const model = options.model || "claude-3-5-sonnet-20241022";
|
||||
|
||||
const config = {
|
||||
baseUrl: `${base}/v1`,
|
||||
authToken: options.apiKey,
|
||||
models: [{ id: model }],
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
19
src/lib/cli-helper/config-generator/cline.ts
Normal file
19
src/lib/cli-helper/config-generator/cline.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".cline", "data", "globalState.json");
|
||||
|
||||
export function generateClineConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): string {
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
|
||||
const config = {
|
||||
openAiBaseUrl: `${base}/v1`,
|
||||
openAiApiKey: options.apiKey,
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
30
src/lib/cli-helper/config-generator/codex.ts
Normal file
30
src/lib/cli-helper/config-generator/codex.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
let yaml: typeof import("js-yaml") | null = null;
|
||||
async function loadYaml() {
|
||||
if (!yaml) {
|
||||
yaml = await import("js-yaml");
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.yaml");
|
||||
|
||||
export async function generateCodexConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): Promise<string> {
|
||||
const y = await loadYaml();
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
|
||||
const config = {
|
||||
openai: {
|
||||
api_key: options.apiKey,
|
||||
base_url: `${base}/v1`,
|
||||
},
|
||||
};
|
||||
|
||||
return y.dump(config, { lineWidth: -1 });
|
||||
}
|
||||
33
src/lib/cli-helper/config-generator/continue.ts
Normal file
33
src/lib/cli-helper/config-generator/continue.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
let yaml: typeof import("js-yaml") | null = null;
|
||||
async function loadYaml() {
|
||||
if (!yaml) {
|
||||
yaml = await import("js-yaml");
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".continue", "config.yaml");
|
||||
|
||||
export async function generateContinueConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): Promise<string> {
|
||||
const y = await loadYaml();
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
|
||||
const config = {
|
||||
models: [
|
||||
{
|
||||
title: "OmniRoute",
|
||||
apiKey: options.apiKey,
|
||||
apiBase: `${base}/v1`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return y.dump(config, { lineWidth: -1 });
|
||||
}
|
||||
95
src/lib/cli-helper/config-generator/index.ts
Normal file
95
src/lib/cli-helper/config-generator/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export interface GenerateOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
success: boolean;
|
||||
configPath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function validateBaseUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function expandHome(p: string): string {
|
||||
const home = os.homedir();
|
||||
return p.replace(/^~\//, home + "/");
|
||||
}
|
||||
|
||||
const TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
};
|
||||
|
||||
async function importGenerator(toolId: string) {
|
||||
const generators: Record<string, { module: string; export: string }> = {
|
||||
claude: { module: "./claude.js", export: "generateClaudeConfig" },
|
||||
codex: { module: "./codex.js", export: "generateCodexConfig" },
|
||||
opencode: { module: "./opencode.js", export: "generateOpencodeConfig" },
|
||||
cline: { module: "./cline.js", export: "generateClineConfig" },
|
||||
kilocode: { module: "./kilocode.js", export: "generateKilocodeConfig" },
|
||||
continue: { module: "./continue.js", export: "generateContinueConfig" },
|
||||
};
|
||||
|
||||
const gen = generators[toolId];
|
||||
if (!gen) return null;
|
||||
const mod = await import(gen.module);
|
||||
return { generate: mod[gen.export] };
|
||||
}
|
||||
|
||||
export async function generateConfig(
|
||||
toolId: string,
|
||||
options: GenerateOptions
|
||||
): Promise<GenerateResult> {
|
||||
if (!validateBaseUrl(options.baseUrl)) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: "",
|
||||
error: "Invalid baseUrl: must be an absolute HTTP(S) URL",
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.apiKey || options.apiKey.trim().length === 0) {
|
||||
return { success: false, configPath: "", error: "API key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await importGenerator(toolId);
|
||||
if (!mod) {
|
||||
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
|
||||
}
|
||||
const content = await mod.generate(options);
|
||||
const configPath = TOOL_CONFIG_PATHS[toolId] || "";
|
||||
return { success: true, configPath, content };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, configPath: "", error: `Generation failed: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> {
|
||||
const toolIds = ["claude", "codex", "opencode", "cline", "kilocode", "continue"] as const;
|
||||
const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options)));
|
||||
|
||||
return results.map((r) =>
|
||||
r.status === "fulfilled"
|
||||
? r.value
|
||||
: { success: false, configPath: "", error: r.reason?.message || "Unknown error" }
|
||||
);
|
||||
}
|
||||
19
src/lib/cli-helper/config-generator/kilocode.ts
Normal file
19
src/lib/cli-helper/config-generator/kilocode.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".config", "kilocode", "settings.json");
|
||||
|
||||
export function generateKilocodeConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): string {
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
|
||||
const config = {
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: `${base}/v1`,
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
21
src/lib/cli-helper/config-generator/opencode.ts
Normal file
21
src/lib/cli-helper/config-generator/opencode.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
export function generateOpencodeConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}): string {
|
||||
const base = options.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
|
||||
const config = {
|
||||
provider: "omniroute",
|
||||
baseURL: `${base}/v1`,
|
||||
apiKey: options.apiKey,
|
||||
model: options.model || "opencode",
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
41
src/lib/cli-helper/doctor/checks.ts
Normal file
41
src/lib/cli-helper/doctor/checks.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export interface DoctorCheckResult {
|
||||
name: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function collectCliToolChecks(): Promise<DoctorCheckResult[]> {
|
||||
const { detectAllTools } = await import("../tool-detector.js");
|
||||
const tools = await detectAllTools();
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!tool.installed) {
|
||||
return {
|
||||
name: `CLI: ${tool.name}`,
|
||||
status: "warn" as const,
|
||||
message: `${tool.name} not installed`,
|
||||
details: { id: tool.id, installed: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (!tool.configured) {
|
||||
return {
|
||||
name: `CLI: ${tool.name}`,
|
||||
status: "warn" as const,
|
||||
message: `${tool.name} not configured for OmniRoute`,
|
||||
details: { id: tool.id, configured: false },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `CLI: ${tool.name}`,
|
||||
status: "ok" as const,
|
||||
message: `${tool.name} configured`,
|
||||
details: { id: tool.id, configured: true },
|
||||
};
|
||||
});
|
||||
}
|
||||
79
src/lib/cli-helper/log-streamer.ts
Normal file
79
src/lib/cli-helper/log-streamer.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface LogStreamOptions {
|
||||
baseUrl?: string;
|
||||
filters?: string[];
|
||||
follow?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface LogStream {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function createLogStream(options: LogStreamOptions = {}): LogStream {
|
||||
const baseUrl = options.baseUrl || "http://localhost:20128";
|
||||
const filters = options.filters || [];
|
||||
const follow = options.follow ?? false;
|
||||
const timeout = options.timeout || 30000;
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let url = `${baseUrl}/api/cli-tools/logs?follow=${follow}`;
|
||||
if (filters.length > 0) {
|
||||
url += `&filter=${encodeURIComponent(filters.join(","))}`;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (follow) return; // Don't timeout follow mode
|
||||
controller.error(new Error(`Log stream timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal });
|
||||
|
||||
if (!response.ok) {
|
||||
controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
controller.error(new Error("Response body is null"));
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (signal.aborted) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
|
||||
controller.close();
|
||||
clearTimeout(timeoutId);
|
||||
} catch (err) {
|
||||
if (signal.aborted) return; // Expected stop
|
||||
controller.error(err instanceof Error ? err : new Error(String(err)));
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stream,
|
||||
stop: () => controller.abort(),
|
||||
};
|
||||
}
|
||||
105
src/lib/cli-helper/tool-detector.ts
Normal file
105
src/lib/cli-helper/tool-detector.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface DetectedTool {
|
||||
id: string;
|
||||
name: string;
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
configPath: string;
|
||||
configured: boolean;
|
||||
configContents?: string;
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
|
||||
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
|
||||
{ id: "opencode", name: "OpenCode", configPath: "~/.config/opencode/opencode.json" },
|
||||
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
|
||||
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
|
||||
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
|
||||
] as const;
|
||||
|
||||
const BINARY_NAMES: Record<string, string> = {
|
||||
claude: "claude",
|
||||
codex: "codex",
|
||||
opencode: "opencode",
|
||||
cline: "cline",
|
||||
kilocode: "kilocode",
|
||||
continue: "continue",
|
||||
};
|
||||
|
||||
function expandHome(p: string): string {
|
||||
const home = os.homedir();
|
||||
return p.replace(/^~\//, home + "/");
|
||||
}
|
||||
|
||||
function isConfigured(content: string, baseUrl: string): boolean {
|
||||
const normalized = baseUrl.replace(/\/+$/, "");
|
||||
return (
|
||||
content.includes(normalized) ||
|
||||
content.includes("localhost:20128") ||
|
||||
content.includes("OMNIROUTE_BASE_URL")
|
||||
);
|
||||
}
|
||||
|
||||
async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> {
|
||||
const binary = BINARY_NAMES[name] || name;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binary, ["--version"], { timeout: 5000 });
|
||||
const version = stdout.trim().replace(/^v/, "");
|
||||
return { installed: true, version };
|
||||
} catch {
|
||||
try {
|
||||
// Try `which` as fallback
|
||||
const { stdout } = await execFileAsync("which", [binary], { timeout: 5000 });
|
||||
if (stdout.trim()) {
|
||||
return { installed: true };
|
||||
}
|
||||
} catch {}
|
||||
return { installed: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigFile(configPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const expanded = expandHome(configPath);
|
||||
if (!expanded) return null;
|
||||
return readFileSync(expanded, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectTool(id: string): Promise<DetectedTool | null> {
|
||||
const tool = TOOLS.find((t) => t.id === id);
|
||||
if (!tool) return null;
|
||||
|
||||
const { installed, version } = await detectBinary(tool.id);
|
||||
const configPath = expandHome(tool.configPath);
|
||||
const configContents = await readConfigFile(tool.configPath);
|
||||
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
installed,
|
||||
version,
|
||||
configPath,
|
||||
configured,
|
||||
configContents: configContents ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectAllTools(): Promise<DetectedTool[]> {
|
||||
const results = await Promise.allSettled(TOOLS.map((t) => detectTool(t.id)));
|
||||
|
||||
return results
|
||||
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
||||
.map((r) => (r as PromiseFulfilledResult<DetectedTool>).value);
|
||||
}
|
||||
96
src/shared/components/AutoRoutingBanner.test.tsx
Normal file
96
src/shared/components/AutoRoutingBanner.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => {
|
||||
container.remove();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AutoRoutingBanner", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders banner on first mount", async () => {
|
||||
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoRoutingBanner />);
|
||||
});
|
||||
expect(container.querySelector('[role="banner"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain("Auto-Routing Active");
|
||||
});
|
||||
|
||||
it("includes link to Combos page", async () => {
|
||||
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoRoutingBanner />);
|
||||
});
|
||||
const link = container.querySelector('a[href="/dashboard/combos"]');
|
||||
expect(link).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can be dismissed by clicking close button", async () => {
|
||||
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoRoutingBanner />);
|
||||
});
|
||||
expect(container.querySelector('[role="banner"]')).toBeTruthy();
|
||||
const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]');
|
||||
expect(closeButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
closeButton?.click();
|
||||
});
|
||||
expect(container.querySelector('[role="banner"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it("persists dismissal to localStorage", async () => {
|
||||
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoRoutingBanner />);
|
||||
});
|
||||
const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]');
|
||||
await act(async () => {
|
||||
closeButton?.click();
|
||||
});
|
||||
expect(localStorage.getItem("auto-routing-banner-dismissed")).toBe("true");
|
||||
});
|
||||
|
||||
it("remains hidden after dismissal on remount", async () => {
|
||||
localStorage.setItem("auto-routing-banner-dismissed", "true");
|
||||
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoRoutingBanner />);
|
||||
});
|
||||
expect(container.querySelector('[role="banner"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -661,7 +661,7 @@ async function handleSingleModelChat(
|
||||
});
|
||||
}
|
||||
|
||||
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
|
||||
const { provider, model, sourceFormat, targetFormat, extendedContext, apiFormat } = resolved;
|
||||
const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true;
|
||||
const hasForcedConnection =
|
||||
typeof runtimeOptions.forcedConnectionId === "string" &&
|
||||
@@ -901,6 +901,7 @@ async function handleSingleModelChat(
|
||||
comboStepId: runtimeOptions.comboStepId ?? null,
|
||||
comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null,
|
||||
extendedContext,
|
||||
modelApiFormat: apiFormat,
|
||||
providerProfile,
|
||||
cachedSettings: runtimeOptions.cachedSettings,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user