Compare commits

..

6 Commits

Author SHA1 Message Date
diegosouzapw d8bf4b1db8 chore(release): bump version to v1.6.3 2026-02-28 00:55:59 -03:00
diegosouzapw fb2351ffe7 fix: sanitize hardcoded build-machine paths in standalone output (#147)
Next.js standalone bakes absolute build-time paths (outputFileTracingRoot,
appDir, turbopack root) into server.js and required-server-files.json.
When installed via npm on a different machine, these paths don't exist,
causing ENOENT errors. The prepublish script now replaces build-machine
absolute paths with '.' (relative) so they resolve correctly wherever
the package is installed.
2026-02-28 00:54:23 -03:00
diegosouzapw 12f7d2b484 fix: preserve database data on upgrade when old schema_migrations exists (#146)
Previously, the upgrade detection logic renamed the entire DB file when it
found a schema_migrations table (from older versions), causing data loss.
Now checks if the DB actually contains data (provider_connections) before
deciding to rename. If data exists, drops only the old migration tracking
table and lets the new CREATE TABLE IF NOT EXISTS schema take over.
2026-02-28 00:54:07 -03:00
diegosouzapw 2c40ef0964 chore(release): bump version to v1.6.2 2026-02-27 22:22:58 -03:00
diegosouzapw ceb778a040 Merge branch 'feat/issue-121-provider-labels' into main (#121) 2026-02-27 22:21:46 -03:00
diegosouzapw d55b6e0b7a feat: show user-defined provider labels and full API key names (#121)
- Combo cards now resolve UUID-based provider IDs to user-defined names using providerNodes
- API key names shown in full instead of masked (Op***w → OpenAI-Prod-Key); key IDs remain masked
- Logs/Analytics: OAI-compatible and ANT-compatible providers now show user-defined names from providerNodes instead of generic OAI-COMPAT labels
- All views (combo cards, combos form modal, logs table, quick filters, dropdowns, analytics) updated consistently
2026-02-24 14:28:39 -03:00
8 changed files with 169 additions and 42 deletions
+21
View File
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.6.3] — 2026-02-28
### 🐛 Bug Fixes
- **Database data preservation on upgrade** — Previously, upgrading from older versions (e.g. v1.2.0 → v1.6.x) could cause data loss by renaming the existing database when a legacy `schema_migrations` table was detected. Now checks for actual data before deciding to reset ([#146](https://github.com/diegosouzapw/OmniRoute/issues/146))
- **Hardcoded build-machine paths in npm package** — Next.js standalone output baked absolute paths from the build machine into `server.js` and `required-server-files.json`. On other machines these paths don't exist, causing `ENOENT` errors. The prepublish script now sanitizes all build paths to relative ([#147](https://github.com/diegosouzapw/OmniRoute/issues/147))
---
## [1.6.2] — 2026-02-27
### ✨ New Features
- **Provider labels in Combos** — Combo cards now show user-defined provider names instead of long UUID identifiers, making complex multi-provider combos easier to read ([#121](https://github.com/diegosouzapw/OmniRoute/issues/121))
- **Improved request log labels** — RequestLoggerV2 resolves OpenAI-compatible provider IDs to user-defined names via provider nodes lookup
- **Smarter API key display** — `formatApiKey()` now shows the full key name for named keys instead of truncating them
---
## [1.6.1] — 2026-02-27
### 🐛 Bug Fixes
@@ -759,6 +778,8 @@ New environment variables:
---
[1.6.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.3
[1.6.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.2
[1.6.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.1
[1.6.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.0
[1.5.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.5.0
+15 -2
View File
@@ -1,12 +1,13 @@
{
"name": "omniroute",
"version": "1.5.0",
"version": "1.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "1.5.0",
"version": "1.6.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"open-sse"
@@ -5886,6 +5887,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7951,6 +7953,17 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.6.1",
"version": "1.6.3",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
+32
View File
@@ -51,6 +51,38 @@ console.log(" 📋 Copying standalone build to app/...");
mkdirSync(APP_DIR, { recursive: true });
cpSync(standaloneDir, APP_DIR, { recursive: true });
// ── Step 5.5: Sanitize hardcoded build-machine paths ───────
// Next.js standalone bakes absolute build-time paths into server.js and
// required-server-files.json (outputFileTracingRoot, appDir, turbopack root).
// Replace the build machine's absolute path with "." (current directory)
// so paths resolve relative to wherever the standalone app/ is installed.
console.log(" 🧹 Sanitizing build-machine paths...");
const buildRoot = ROOT.replace(/\\/g, "/"); // normalise for regex safety
const sanitizeTargets = [
join(APP_DIR, "server.js"),
join(APP_DIR, ".next", "required-server-files.json"),
];
let sanitisedCount = 0;
for (const filePath of sanitizeTargets) {
if (!existsSync(filePath)) continue;
let content = readFileSync(filePath, "utf8");
// Escape special regex characters in the path
const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(escaped, "g");
const matches = content.match(re);
if (matches) {
// Replace with "." so Next.js resolves paths relative to the standalone dir
content = content.replace(re, ".");
writeFileSync(filePath, content);
sanitisedCount += matches.length;
}
}
if (sanitisedCount > 0) {
console.log(` ✅ Sanitised ${sanitisedCount} hardcoded path references`);
} else {
console.log(" ️ No hardcoded paths found to sanitise");
}
// ── Step 6: Copy static assets ─────────────────────────────
const staticSrc = join(ROOT, ".next", "static");
const staticDest = join(APP_DIR, ".next", "static");
+19 -2
View File
@@ -49,6 +49,7 @@ export default function CombosPage() {
const notify = useNotificationStore();
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
const [providerNodes, setProviderNodes] = useState([]);
useEffect(() => {
fetchData();
@@ -60,14 +61,16 @@ export default function CombosPage() {
const fetchData = async () => {
try {
const [combosRes, providersRes, metricsRes] = await Promise.all([
const [combosRes, providersRes, metricsRes, nodesRes] = await Promise.all([
fetch("/api/combos"),
fetch("/api/providers"),
fetch("/api/combos/metrics"),
fetch("/api/provider-nodes"),
]);
const combosData = await combosRes.json();
const providersData = await providersRes.json();
const metricsData = await metricsRes.json();
const nodesData = nodesRes.ok ? await nodesRes.json() : { nodes: [] };
if (combosRes.ok) setCombos(combosData.combos || []);
if (providersRes.ok) {
@@ -77,6 +80,7 @@ export default function CombosPage() {
setActiveProviders(active);
}
if (metricsRes.ok) setMetrics(metricsData.metrics || {});
setProviderNodes(nodesData.nodes || []);
} catch (error) {
console.log("Error fetching data:", error);
} finally {
@@ -231,6 +235,7 @@ export default function CombosPage() {
key={combo.id}
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
@@ -310,6 +315,7 @@ function ComboCard({
onProxy,
hasProxy,
onToggle,
providerNodes,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
@@ -317,6 +323,17 @@ function ComboCard({
const t = useTranslations("combos");
const tc = useTranslations("common");
// Resolve provider UUID to user-defined name
const formatModelDisplay = (modelValue) => {
const parts = modelValue.split("/");
if (parts.length !== 2) return modelValue;
const [providerIdentifier, modelId] = parts;
const matchedNode = (providerNodes || []).find(
(node) => node.id === providerIdentifier || node.prefix === providerIdentifier
);
return matchedNode ? `${matchedNode.name}/${modelId}` : modelValue;
};
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<div className="flex items-center justify-between">
@@ -381,7 +398,7 @@ function ComboCard({
key={index}
className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1.5 py-0.5 rounded text-text-muted"
>
{model}
{formatModelDisplay(model)}
{strategy === "weighted" && weight > 0 ? ` (${weight}%)` : ""}
</code>
);
+43 -11
View File
@@ -322,28 +322,60 @@ export function getDbInstance() {
return _db;
}
// Detect and replace old incompatible schema
// Detect and handle old schema format — preserve data when possible (#146)
if (fs.existsSync(SQLITE_FILE)) {
try {
const probe = new Database(SQLITE_FILE, { readonly: true });
const hasOldSchema = probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get();
probe.close();
if (hasOldSchema) {
const oldPath = SQLITE_FILE + ".old-schema";
console.log(
`[DB] Old incompatible schema detected — renaming to ${path.basename(oldPath)}`
);
fs.renameSync(SQLITE_FILE, oldPath);
for (const ext of ["-wal", "-shm"]) {
// Check if the DB has actual data we should preserve
let hasData = false;
try {
const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get() as
| { c: number }
| undefined;
hasData = count && count.c > 0;
} catch {
// Table might not exist at all — truly incompatible
}
probe.close();
if (hasData) {
// Data exists — preserve it! Just drop the old migration tracking table
// and let our new migration system (CREATE TABLE IF NOT EXISTS) take over
console.log(
`[DB] Old schema_migrations table found but data exists — preserving data (#146)`
);
const fixDb = new Database(SQLITE_FILE);
try {
if (fs.existsSync(SQLITE_FILE + ext)) fs.unlinkSync(SQLITE_FILE + ext);
} catch {
/* ok */
fixDb.exec("DROP TABLE IF EXISTS schema_migrations");
// Clean up WAL/SHM files that might be stale
fixDb.pragma("wal_checkpoint(TRUNCATE)");
} catch (e) {
console.warn("[DB] Could not clean up old schema table:", e.message);
} finally {
fixDb.close();
}
} else {
// No data — safe to rename and start fresh
const oldPath = SQLITE_FILE + ".old-schema";
console.log(
`[DB] Old incompatible schema detected (empty) — renaming to ${path.basename(oldPath)}`
);
fs.renameSync(SQLITE_FILE, oldPath);
for (const ext of ["-wal", "-shm"]) {
try {
if (fs.existsSync(SQLITE_FILE + ext)) fs.unlinkSync(SQLITE_FILE + ext);
} catch {
/* ok */
}
}
}
} else {
probe.close();
}
} catch (e) {
console.warn("[DB] Could not probe existing DB, will create fresh:", e.message);
+34 -22
View File
@@ -43,29 +43,32 @@ const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
/**
* Get a friendly display label for compatible providers.
* Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
* to readable labels like "OAI-Compat".
* to readable labels. If providerNodes are available, uses user-defined name;
* otherwise falls back to "OAI-Compat".
*/
function getProviderDisplayLabel(provider: string): string {
function getProviderDisplayLabel(provider: string, providerNodes?: any[]): string {
if (!provider) return "-";
if (provider.startsWith("openai-compatible-")) {
// Extract the "chat" or custom-name part after the prefix
const suffix = provider.replace("openai-compatible-", "");
// If it's just "chat-<uuid>", show "OAI-Compat"
// If it has a meaningful name, include it
const parts = suffix.split("-");
if (parts.length > 1 && parts[1]?.length >= 8) {
// Looks like chat-<uuid>, just show category
return `OAI-COMPAT`;
if (provider.startsWith("openai-compatible-") || provider.startsWith("anthropic-compatible-")) {
// Try to find user-defined name from provider nodes
if (providerNodes?.length) {
const matchedNode = providerNodes.find(
(node) => node.id === provider || node.prefix === provider
);
if (matchedNode?.name) return matchedNode.name;
}
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
}
if (provider.startsWith("anthropic-compatible-")) {
const suffix = provider.replace("anthropic-compatible-", "");
const parts = suffix.split("-");
if (parts.length > 1 && parts[1]?.length >= 8) {
return `ANT-COMPAT`;
// Fallback to generic labels
if (provider.startsWith("openai-compatible-")) {
const suffix = provider.replace("openai-compatible-", "");
const parts = suffix.split("-");
if (parts.length > 1 && parts[1]?.length >= 8) return `OAI-COMPAT`;
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
}
if (provider.startsWith("anthropic-compatible-")) {
const suffix = provider.replace("anthropic-compatible-", "");
const parts = suffix.split("-");
if (parts.length > 1 && parts[1]?.length >= 8) return `ANT-COMPAT`;
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
}
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
}
return null; // Not a compatible provider, use default PROVIDER_COLORS
}
@@ -90,6 +93,7 @@ export default function RequestLoggerV2() {
const [detailData, setDetailData] = useState(null);
const intervalRef = useRef(null);
const hasLoadedRef = useRef(false);
const [providerNodes, setProviderNodes] = useState([]);
// Column visibility with localStorage persistence
const [visibleColumns, setVisibleColumns] = useState(() => {
@@ -147,6 +151,14 @@ export default function RequestLoggerV2() {
fetchLogs(showLoading);
}, [fetchLogs]);
// Fetch provider nodes for display labels
useEffect(() => {
fetch("/api/provider-nodes")
.then((r) => (r.ok ? r.json() : { nodes: [] }))
.then((d) => setProviderNodes(d.nodes || []))
.catch(() => {});
}, []);
// Auto-refresh
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
@@ -299,7 +311,7 @@ export default function RequestLoggerV2() {
>
<option value="">All Providers</option>
{uniqueProviders.map((p) => {
const compatLabel = getProviderDisplayLabel(p);
const compatLabel = getProviderDisplayLabel(p, providerNodes);
const pc = PROVIDER_COLORS[p];
return (
<option key={p} value={p}>
@@ -441,7 +453,7 @@ export default function RequestLoggerV2() {
{/* Dynamic Provider Quick Filters (from data) */}
{uniqueProviders.map((p) => {
const compatLabel = getProviderDisplayLabel(p);
const compatLabel = getProviderDisplayLabel(p, providerNodes);
const pc = PROVIDER_COLORS[p] || {
bg: "#374151",
text: "#fff",
@@ -575,7 +587,7 @@ export default function RequestLoggerV2() {
text: "#fff",
label: (protocolKey || log.provider || "-").toUpperCase(),
};
const compatLabel = getProviderDisplayLabel(log.provider);
const compatLabel = getProviderDisplayLabel(log.provider, providerNodes);
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
text: "#fff",
+4 -4
View File
@@ -80,16 +80,16 @@ export function maskAccount(account) {
}
/**
* Format an API key label, masking both name and ID.
* Format an API key label, showing full name but masking the ID.
* @param {string} apiKeyName - Human-readable name of the key
* @param {string} apiKeyId - Unique ID of the key
* @returns {string}
*/
export function formatApiKeyLabel(apiKeyName, apiKeyId) {
if (!apiKeyName && !apiKeyId) return "—";
const maskedName = apiKeyName ? maskSegment(apiKeyName, 2, 1) : "key";
if (!apiKeyId) return maskedName;
return `${maskedName} (${maskSegment(apiKeyId, 4, 4)})`;
const displayName = apiKeyName || "key";
if (!apiKeyId) return displayName;
return `${displayName} (${maskSegment(apiKeyId, 4, 4)})`;
}
/**