mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-23 04:28:06 +00:00
fix(playground): guard ChatPlayground filteredModels for non-string ids
Same root cause as commit 49fe356b9: ChatPlayground filtered models
with m.id.startsWith(...) which crashed on null/undefined ids returned
by /v1/models (synthetic combo entries). Apply the same defensive guard
and dedupe used in the parent page.
This commit is contained in:
parent
49fe356b91
commit
3ff3e3dd15
22 changed files with 328 additions and 274 deletions
|
|
@ -4,9 +4,9 @@ rules:
|
|||
- pattern: new Database(...)
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
|
||||
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
|
||||
|
|
@ -21,9 +21,9 @@ rules:
|
|||
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
|
||||
hard rule #5 and bin/cli/CONVENTIONS.md.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
|
@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path";
|
|||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { backupSqliteFile } from "../sqlite.mjs";
|
||||
|
||||
function getBackupDir() {
|
||||
return join(resolveDataDir(), "backups");
|
||||
|
|
@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) {
|
|||
try {
|
||||
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
Database = null;
|
||||
}
|
||||
|
||||
let backedUp = 0;
|
||||
let skipped = 0;
|
||||
|
||||
|
|
@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) {
|
|||
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
|
||||
const destPath = join(backupPath, destName);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
if (file.name.endsWith(".sqlite") && Database) {
|
||||
const db = new Database(sourcePath, { readonly: true });
|
||||
if (file.name.endsWith(".sqlite")) {
|
||||
const tmpPath = destPath.replace(/\.enc$/, "");
|
||||
await db.backup(tmpPath);
|
||||
db.close();
|
||||
await backupSqliteFile(sourcePath, tmpPath);
|
||||
if (opts.encrypt) {
|
||||
encryptFile(tmpPath, destPath, passphrase);
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(tmpPath);
|
||||
}
|
||||
} else if (opts.encrypt) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
|
|||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
|
||||
const STATIC_SALT = "omniroute-field-encryption-v1";
|
||||
const KEY_LENGTH = 32;
|
||||
|
|
@ -78,14 +79,6 @@ function checkConfig(dataDir) {
|
|||
return ok("Config", `.env found at ${envFile}`, { envFile });
|
||||
}
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigrationsDir(rootDir) {
|
||||
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
|
||||
const candidates = [
|
||||
|
|
@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) {
|
|||
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Database", "better-sqlite3 could not be loaded", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
|
||||
await readDatabaseHealth(dbPath);
|
||||
if (quickCheckValue !== "ok") {
|
||||
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
|
||||
}
|
||||
|
|
@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) {
|
|||
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
|
||||
}
|
||||
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
if (!table) {
|
||||
if (!hasMigrationTable) {
|
||||
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
|
||||
}
|
||||
|
||||
const appliedRows = db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version);
|
||||
const applied = new Set(appliedRows);
|
||||
const applied = new Set(appliedMigrationVersions);
|
||||
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
|
||||
|
||||
if (pending.length > 0) {
|
||||
|
|
@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) {
|
|||
dbPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) {
|
|||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const hasProviderTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
|
||||
if (!hasProviderTable) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
|
||||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
if (encryptedValues.length === 0) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
|
||||
|
|
@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) {
|
|||
return fail("Storage/encryption", "Encrypted credential check failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,42 @@
|
|||
import fs from "node:fs";
|
||||
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
|
||||
import { ensureProviderSchema } from "./provider-store.mjs";
|
||||
import { ensureSettingsSchema } from "./settings-store.mjs";
|
||||
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
}
|
||||
|
||||
function createSqliteNativeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
return new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const Database = await loadBetterSqlite();
|
||||
try {
|
||||
return new Database(dbPath, options);
|
||||
} catch (error) {
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openOmniRouteDb() {
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
throw new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
|
|
@ -35,3 +44,113 @@ export async function openOmniRouteDb() {
|
|||
|
||||
return { db, dataDir, dbPath };
|
||||
}
|
||||
|
||||
export async function withReadonlySqlite(dbPath, callback) {
|
||||
const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
return await callback(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupSqliteFile(sourcePath, destPath) {
|
||||
const db = await openSqliteDatabase(sourcePath, { readonly: true });
|
||||
try {
|
||||
await db.backup(destPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDatabaseHealth(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const hasMigrationTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
const appliedMigrationVersions = hasMigrationTable
|
||||
? db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version)
|
||||
: [];
|
||||
|
||||
return { quickCheckValue, hasMigrationTable, appliedMigrationVersions };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readEncryptedCredentialSamples(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasProviderTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
if (!hasProviderTable) {
|
||||
return { hasProviderTable: false, encryptedValues: [] };
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
return { hasProviderTable: true, encryptedValues };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return { exists: false, hasPassword: false };
|
||||
}
|
||||
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasSettingsTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("key_value");
|
||||
if (!hasSettingsTable) {
|
||||
return { exists: true, hasPassword: false };
|
||||
}
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
|
||||
.get("password");
|
||||
let password = row?.value;
|
||||
if (typeof password === "string") {
|
||||
try {
|
||||
password = JSON.parse(password);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
hasPassword: typeof password === "string" && password.length > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetManagementPassword(
|
||||
password,
|
||||
dbPath = resolveStoragePath(resolveDataDir())
|
||||
) {
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
try {
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
const hashedPassword = await hashManagementPassword(password);
|
||||
updateSettings(db, { password: hashedPassword, requireLogin: true });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,12 @@
|
|||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
|
||||
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
|
||||
|
||||
// Resolve data directory — same logic as the server
|
||||
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
|
||||
const DB_PATH = resolve(DATA_DIR, "settings.db");
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const DB_PATH = resolveStoragePath(DATA_DIR);
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
|
|
@ -34,37 +30,19 @@ function ask(question) {
|
|||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function generateSecretDigest(input) {
|
||||
// Use bcrypt with a salt round of 10 to match login/route.ts expectations
|
||||
// and resolve CodeQL js/insufficient-password-hash warning.
|
||||
return bcrypt.hashSync(input, 10);
|
||||
}
|
||||
|
||||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||||
|
||||
async function main() {
|
||||
// Check if database exists
|
||||
if (!existsSync(DB_PATH)) {
|
||||
const passwordState = await readManagementPasswordState(DB_PATH);
|
||||
if (!passwordState.exists) {
|
||||
console.error(`❌ Database not found at: ${DB_PATH}`);
|
||||
console.error(` Make sure OmniRoute has been started at least once.`);
|
||||
console.error(` Or set DATA_DIR env var to your data directory.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
console.error("❌ better-sqlite3 not installed. Run: npm install");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Check current settings
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
|
||||
|
||||
if (row) {
|
||||
if (passwordState.hasPassword) {
|
||||
console.log("ℹ️ A password is currently set.");
|
||||
} else {
|
||||
console.log("ℹ️ No password is currently set.");
|
||||
|
|
@ -74,7 +52,6 @@ async function main() {
|
|||
|
||||
if (!password || password.length < 8) {
|
||||
console.error("\n❌ Password must be at least 8 characters.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -83,28 +60,11 @@ async function main() {
|
|||
|
||||
if (password !== confirm) {
|
||||
console.error("\n❌ Passwords do not match.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashed = generateSecretDigest(password);
|
||||
|
||||
// Upsert the password
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
stmt.run(hashed);
|
||||
|
||||
// Also ensure requireLogin is true
|
||||
const loginStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
loginStmt.run();
|
||||
|
||||
db.close();
|
||||
await resetManagementPassword(password, DB_PATH);
|
||||
rl.close();
|
||||
|
||||
console.log("\n✅ Password reset successfully!");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
services:
|
||||
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
|
||||
redis:
|
||||
image: redis:8.6.2
|
||||
image: redis:8.6.2-alpine
|
||||
container_name: omniroute-redis-prod
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -6,14 +6,11 @@
|
|||
# base → minimal image, no CLI tools
|
||||
# cli → CLIs installed inside the container (portable)
|
||||
# host → runner-base + host-mounted CLI binaries (Linux-first)
|
||||
# cliproxyapi → CLIProxyAPI sidecar on port 8317
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile base up -d
|
||||
# docker compose --profile cli up -d
|
||||
# docker compose --profile host up -d
|
||||
# docker compose --profile cliproxyapi up -d
|
||||
# docker compose --profile cli --profile cliproxyapi up -d
|
||||
#
|
||||
# Before first run, copy .env.example → .env and edit your secrets.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -130,30 +127,6 @@ services:
|
|||
profiles:
|
||||
- host
|
||||
|
||||
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
|
||||
cliproxyapi:
|
||||
container_name: cliproxyapi
|
||||
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
volumes:
|
||||
- cliproxyapi-data:/root/.cli-proxy-api
|
||||
environment:
|
||||
- PORT=${CLIPROXYAPI_PORT:-8317}
|
||||
- HOST=0.0.0.0
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
profiles:
|
||||
- cliproxyapi
|
||||
|
||||
volumes:
|
||||
cliproxyapi-data:
|
||||
name: cliproxyapi-data
|
||||
redis-data:
|
||||
name: omniroute-redis-data
|
||||
|
|
|
|||
|
|
@ -64,23 +64,17 @@ docker compose --profile cli up -d
|
|||
|
||||
# Host profile (Linux-first; mounts host CLI binaries read-only)
|
||||
docker compose --profile host up -d
|
||||
|
||||
# Combine CLI + CLIProxyAPI sidecar
|
||||
docker compose --profile cli --profile cliproxyapi up -d
|
||||
```
|
||||
|
||||
## Available Profiles
|
||||
|
||||
OmniRoute ships four Compose profiles. Pick the one that matches your environment.
|
||||
OmniRoute ships three Compose profiles. Pick the one that matches your environment.
|
||||
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` |
|
||||
|
||||
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
|
||||
## Redis Sidecar
|
||||
|
||||
|
|
@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
|
|||
| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) |
|
||||
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
|
||||
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
|
||||
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
|
||||
|
||||
## Docker Compose with Caddy (HTTPS Auto-TLS)
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const eslintConfig = [
|
|||
"bin/**",
|
||||
// Dependencies
|
||||
"node_modules/**",
|
||||
".worktrees/**",
|
||||
".omnivscodeagent/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
"_references/**",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename);
|
|||
const ROOT: string = join(__dirname, "..", "..");
|
||||
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function runPackDryRun(): any {
|
||||
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
const command = npmExecPath ? process.execPath : npmCommand;
|
||||
const args = [
|
||||
...(npmExecPath ? [npmExecPath] : []),
|
||||
"pack",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--ignore-scripts",
|
||||
];
|
||||
|
||||
const output = execFileSync(command, args, {
|
||||
return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAppStagingReady(): void {
|
||||
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
|
||||
requiredPath.startsWith("app/")
|
||||
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
|
||||
|
||||
if (missingAppRequiredPaths.length === 0) return;
|
||||
|
||||
console.log("📦 app/ staging is missing required runtime files; running npm run build:cli...");
|
||||
runNpm(["run", "build:cli"], "inherit");
|
||||
}
|
||||
|
||||
function runPackDryRun(): any {
|
||||
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
||||
|
||||
const jsonStart = output.indexOf("[");
|
||||
const jsonEnd = output.lastIndexOf("]");
|
||||
|
|
@ -66,6 +74,7 @@ function formatBytes(bytes: number): string {
|
|||
}
|
||||
|
||||
try {
|
||||
ensureAppStagingReady();
|
||||
const packReport = runPackDryRun();
|
||||
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
|
||||
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,18 @@ export default function ChatPlayground({
|
|||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
const filteredModels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ value: string; label: string }> = [];
|
||||
for (const m of models) {
|
||||
if (typeof m?.id !== "string") continue;
|
||||
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push({ value: m.id, label: m.id });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
|
@ -25,7 +26,7 @@ export async function GET(request) {
|
|||
hasCachedPassword: !!getCachedPassword(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error getting MITM status:", error.message);
|
||||
console.log("Error getting MITM status:", sanitizeErrorMessage(error));
|
||||
return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -81,9 +82,9 @@ export async function POST(request) {
|
|||
pid: result.pid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error starting MITM:", error.message);
|
||||
console.log("Error starting MITM:", sanitizeErrorMessage(error));
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to start MITM proxy" },
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to start MITM proxy" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
@ -130,9 +131,9 @@ export async function DELETE(request) {
|
|||
|
||||
return NextResponse.json({ success: true, running: false });
|
||||
} catch (error) {
|
||||
console.log("Error stopping MITM:", error.message);
|
||||
console.log("Error stopping MITM:", sanitizeErrorMessage(error));
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to stop MITM proxy" },
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to stop MITM proxy" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { z } from "zod";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ export async function POST(request: NextRequest) {
|
|||
status: 0,
|
||||
statusText: "Network Error",
|
||||
headers: {},
|
||||
body: { error: error.message || "Request failed" },
|
||||
body: { error: sanitizeErrorMessage(error) || "Request failed" },
|
||||
latencyMs: 0,
|
||||
contentType: "application/json",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -15,33 +16,6 @@ const MAX_FAVICON_SIZE = 50 * 1024; // 50KB
|
|||
const FETCH_TIMEOUT = 5000; // 5 seconds
|
||||
const CACHE_DURATION = 300; // 5 minutes
|
||||
|
||||
function isAllowedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// Only allow https (or http for local development)
|
||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
// Block private/internal IPs
|
||||
const hostname = parsedUrl.hostname;
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("172.") ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname === "localhost"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateImageData(base64Data: string, contentType: string): boolean {
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(contentType)) {
|
||||
console.error("Invalid content type:", contentType);
|
||||
|
|
@ -76,42 +50,35 @@ export async function GET() {
|
|||
faviconData = customFaviconBase64;
|
||||
}
|
||||
} else if (customFaviconUrl) {
|
||||
// Validate URL before fetching (SSRF protection)
|
||||
if (!isAllowedUrl(customFaviconUrl)) {
|
||||
console.error("Blocked invalid favicon URL:", customFaviconUrl);
|
||||
} else {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
try {
|
||||
const response = await safeOutboundFetch(customFaviconUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.validationRead,
|
||||
guard: "public-only",
|
||||
timeoutMs: FETCH_TIMEOUT,
|
||||
headers: {
|
||||
"User-Agent": "OmniRoute/2.0",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(customFaviconUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "OmniRoute/2.0",
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
// Validate size before processing
|
||||
if (uint8Array.length > MAX_FAVICON_SIZE) {
|
||||
console.error("Favicon exceeds max size:", uint8Array.length);
|
||||
} else {
|
||||
const base64 = Buffer.from(uint8Array).toString("base64");
|
||||
const fullData = `data:${contentType};base64,${base64}`;
|
||||
|
||||
// Validate size before processing
|
||||
if (uint8Array.length > MAX_FAVICON_SIZE) {
|
||||
console.error("Favicon exceeds max size:", uint8Array.length);
|
||||
} else {
|
||||
const base64 = Buffer.from(uint8Array).toString("base64");
|
||||
const fullData = `data:${contentType};base64,${base64}`;
|
||||
|
||||
if (validateImageData(fullData, contentType)) {
|
||||
faviconData = fullData;
|
||||
}
|
||||
if (validateImageData(fullData, contentType)) {
|
||||
faviconData = fullData;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch custom favicon:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch custom favicon:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
|
|
@ -88,7 +89,10 @@ export async function POST(request, { params }) {
|
|||
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
|
||||
} catch (error) {
|
||||
console.log("Error handling Gemini request:", error);
|
||||
return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: { message: sanitizeErrorMessage(error), code: 500 } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getAllCustomModels,
|
||||
getAllSyncedAvailableModels,
|
||||
|
|
@ -156,6 +157,6 @@ export async function GET() {
|
|||
return Response.json({ models });
|
||||
} catch (error: any) {
|
||||
console.log("Error fetching models:", error);
|
||||
return Response.json({ error: { message: error.message } }, { status: 500 });
|
||||
return Response.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
|
@ -33,7 +34,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
|
|||
}
|
||||
return NextResponse.json({ webhook });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
|||
}
|
||||
return NextResponse.json({ webhook });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +72,6 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getWebhook, recordWebhookDelivery } from "@/lib/localDb";
|
||||
import { deliverWebhook } from "@/lib/webhookDispatcher";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
|
@ -38,9 +39,9 @@ export async function POST(_: Request, { params }: { params: Promise<{ id: strin
|
|||
return NextResponse.json({
|
||||
delivered: result.success,
|
||||
status: result.status,
|
||||
error: result.error || null,
|
||||
error: result.error ? sanitizeErrorMessage(result.error) : null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getWebhooks, createWebhook } from "@/lib/localDb";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
|
@ -31,7 +32,7 @@ export async function GET(request: Request) {
|
|||
return NextResponse.json({ webhooks: masked });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to list webhooks" },
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to list webhooks" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
@ -59,7 +60,7 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ webhook }, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to create webhook" },
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to create webhook" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,6 +457,24 @@ export function handleNoCredentials(
|
|||
credentials.retryAfterHuman
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials?.allExpired) {
|
||||
// Every connection for this provider is in a terminal state (expired,
|
||||
// banned, or credits_exhausted). Surface as 401 with a re-auth hint
|
||||
// instead of the generic 400 "No credentials", so dashboards/CLIs can
|
||||
// distinguish "never configured" from "needs to reconnect".
|
||||
const status = credentials.expiredStatus || "expired";
|
||||
const count = credentials.expiredCount || 1;
|
||||
const reason =
|
||||
status === "credits_exhausted"
|
||||
? "credits exhausted"
|
||||
: status === "banned"
|
||||
? "banned by upstream"
|
||||
: "authentication expired";
|
||||
const message = `[${provider}] All ${count} connection(s) ${reason} — please reconnect in the dashboard`;
|
||||
log.warn("CHAT", message);
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, message);
|
||||
}
|
||||
if (lastError && lastStatus) {
|
||||
log.warn("CHAT", "Preserving last upstream error after credential exhaustion", {
|
||||
provider,
|
||||
|
|
|
|||
|
|
@ -885,6 +885,28 @@ export async function getProviderCredentials(
|
|||
` → ${c.id?.slice(0, 8)} | isActive=${c.isActive} | rateLimitedUntil=${c.rateLimitedUntil || "none"} | testStatus=${c.testStatus}`
|
||||
);
|
||||
});
|
||||
|
||||
// If every existing connection is in a terminal state (expired/banned/
|
||||
// credits_exhausted), surface that as a re-auth signal instead of the
|
||||
// generic "No credentials" 400. The classic case is AWS SSO/Kiro
|
||||
// refresh tokens hitting their 90-day TTL: all connections flip to
|
||||
// is_active=0 with testStatus=banned|expired, and without this branch
|
||||
// the dashboard sees a misleading "bad_request" code.
|
||||
const terminalConnections = allConnections.filter(isTerminalConnectionStatus);
|
||||
if (terminalConnections.length === allConnections.length) {
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const c of terminalConnections) {
|
||||
const key = normalizeStatus(c.testStatus) || "expired";
|
||||
statusCounts.set(key, (statusCounts.get(key) || 0) + 1);
|
||||
}
|
||||
const dominantStatus =
|
||||
[...statusCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "expired";
|
||||
return {
|
||||
allExpired: true,
|
||||
expiredCount: terminalConnections.length,
|
||||
expiredStatus: dominantStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
log.warn("AUTH", `No credentials for ${provider}`);
|
||||
return null;
|
||||
|
|
@ -1390,7 +1412,7 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (credentials.allRateLimited) {
|
||||
if (credentials.allRateLimited || credentials.allExpired) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,6 +257,41 @@ test("handleNoCredentials returns structured model_cooldown when every credentia
|
|||
assert.match(json.error.message, /cooling down/i);
|
||||
});
|
||||
|
||||
test("handleNoCredentials returns 401 with re-auth hint when every connection is in a terminal state", async () => {
|
||||
// Classic scenario: AWS SSO refresh tokens hit their 90-day TTL, every Kiro
|
||||
// connection flips to is_active=0 + testStatus=banned/expired. Surface as
|
||||
// 401 with a reconnect hint instead of the misleading 400 "No credentials".
|
||||
const response = handleNoCredentials(
|
||||
{ allExpired: true, expiredCount: 1, expiredStatus: "banned" },
|
||||
null,
|
||||
"kiro",
|
||||
"claude-sonnet-4.6",
|
||||
null,
|
||||
null
|
||||
);
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.match(json.error.message, /\[kiro\]/);
|
||||
assert.match(json.error.message, /banned by upstream/);
|
||||
assert.match(json.error.message, /please reconnect/i);
|
||||
});
|
||||
|
||||
test("handleNoCredentials maps allExpired status='expired' to the 'authentication expired' reason", async () => {
|
||||
const response = handleNoCredentials(
|
||||
{ allExpired: true, expiredCount: 3, expiredStatus: "expired" },
|
||||
null,
|
||||
"cline",
|
||||
"claude-sonnet-4.6",
|
||||
null,
|
||||
null
|
||||
);
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.match(json.error.message, /3 connection\(s\) authentication expired/);
|
||||
});
|
||||
|
||||
test("safeResolveProxy returns the direct route when no proxy config is present", async () => {
|
||||
const connection = await seedConnection("openai", { apiKey: "sk-openai-direct" });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue