Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c40ef0964 | |||
| ceb778a040 | |||
| 2c5a546759 | |||
| 3286f05b3b | |||
| 6a6a868845 | |||
| 56de009756 | |||
| deb855f9d4 | |||
| 7da0675907 | |||
| f8a22e71f7 | |||
| 5229290ac4 | |||
| 01c1bbfe29 | |||
| 234e14a30b | |||
| 0b0e503615 | |||
| 344e602b26 | |||
| b941515c5a | |||
| fb597c677e | |||
| d0138a5037 | |||
| d55b6e0b7a |
@@ -23,7 +23,15 @@ SQLITE_MAX_SIZE_MB=2048
|
||||
SQLITE_CLEAN_LEGACY_FILES=true
|
||||
|
||||
# Recommended runtime variables
|
||||
# Canonical/base port (keeps backward compatibility)
|
||||
PORT=20128
|
||||
# Optional split ports:
|
||||
# API_PORT=20129
|
||||
# API_HOST=0.0.0.0
|
||||
# DASHBOARD_PORT=20128
|
||||
# Optional Docker production host publish ports:
|
||||
# PROD_DASHBOARD_PORT=20130
|
||||
# PROD_API_PORT=20131
|
||||
NODE_ENV=production
|
||||
INSTANCE_NAME=omniroute
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform_pair }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
echo "Publishing Docker image version: $VERSION"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
||||
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
- **Cross-platform npm install** — Added `postinstall` script to auto-rebuild `better-sqlite3` for the user's OS/architecture. Previously, the npm package shipped Linux x64 binaries that failed on Windows and macOS ([#129](https://github.com/diegosouzapw/OmniRoute/issues/129))
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] — 2026-02-27
|
||||
|
||||
> ### 🔀 Feature Release — Split-Port Mode
|
||||
>
|
||||
> API and Dashboard can now run on separate ports for advanced deployment scenarios (reverse proxies, container networking, network isolation). Community contribution by [@npmSteven](https://github.com/npmSteven) — PR [#140](https://github.com/diegosouzapw/OmniRoute/pull/140).
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Split-Port Runtime** — Serve dashboard and OpenAI-compatible API on different ports via `API_PORT` and `DASHBOARD_PORT` env vars. Opt-in; single-port mode unchanged ([#140](https://github.com/diegosouzapw/OmniRoute/pull/140))
|
||||
- **API Bridge Server** — Lightweight HTTP proxy routes only OpenAI-compatible paths (`/v1`, `/chat/completions`, `/responses`, `/models`, `/codex`) on the API port, returns 404 for everything else
|
||||
- **Centralized Port Resolution** — New `src/lib/runtime/ports.ts` module ensures consistent port config across server, CLI, OAuth, and cloud sync
|
||||
- **Runtime Wrapper Scripts** — `scripts/run-next.mjs` and `scripts/run-standalone.mjs` for proper env propagation in dev and Docker modes
|
||||
|
||||
### 🐛 Bug Fixes & Polish
|
||||
|
||||
- Added 30s timeout to API bridge proxy requests to prevent resource exhaustion
|
||||
- Extracted healthcheck into `scripts/healthcheck.mjs` (replaces duplicated inline code)
|
||||
- CLI tools page and onboarding derive endpoints from runtime API port
|
||||
- OAuth server fallback resolves to effective dashboard port
|
||||
- Cloud sync internal URL follows dashboard port
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- API bridge defaults to `127.0.0.1` (not `0.0.0.0`) — network-safe by default
|
||||
- `API_HOST` env var available for explicit override when needed
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- Bump `actions/upload-artifact` from 4 to 7 ([#143](https://github.com/diegosouzapw/OmniRoute/pull/143))
|
||||
- Bump `actions/download-artifact` from 4 to 8 ([#144](https://github.com/diegosouzapw/OmniRoute/pull/144))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added 14 unit tests for `parsePort` and `resolveRuntimePorts`
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] — 2026-02-26
|
||||
|
||||
> ### 🌍 Massive i18n Expansion — 30 Languages
|
||||
@@ -714,6 +769,9 @@ New environment variables:
|
||||
|
||||
---
|
||||
|
||||
[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
|
||||
[1.4.11]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.11
|
||||
[1.4.10]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.10
|
||||
|
||||
+5
-3
@@ -27,13 +27,16 @@ RUN mkdir -p /app/data
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"
|
||||
CMD ["node", "healthcheck.mjs"]
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "run-standalone.mjs"]
|
||||
|
||||
FROM runner-base AS runner-cli
|
||||
|
||||
@@ -45,4 +48,3 @@ RUN apt-get update \
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
|
||||
@@ -170,12 +170,22 @@ omniroute
|
||||
|
||||
🎉 Dashboard opens at `http://localhost:20128`
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `omniroute` | Start server (default port 20128) |
|
||||
| `omniroute --port 3000` | Use custom port |
|
||||
| `omniroute --no-open` | Don't auto-open browser |
|
||||
| `omniroute --help` | Show help |
|
||||
| Command | Description |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
|
||||
| `omniroute --port 3000` | Set canonical/API port to 3000 |
|
||||
| `omniroute --no-open` | Don't auto-open browser |
|
||||
| `omniroute --help` | Show help |
|
||||
|
||||
Optional split-port mode:
|
||||
|
||||
```bash
|
||||
PORT=20128 DASHBOARD_PORT=20129 omniroute
|
||||
# API: http://localhost:20128/v1
|
||||
# Dashboard: http://localhost:20129
|
||||
```
|
||||
|
||||
When ports are split, the API port serves only OpenAI-compatible routes (`/v1`, `/chat/completions`, `/responses`, `/models`, `/codex/*`).
|
||||
|
||||
**2. Connect a FREE provider:**
|
||||
|
||||
@@ -197,7 +207,7 @@ Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Settings:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
@@ -985,9 +995,12 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
|
||||
- Switch primary model to GLM/MiniMax
|
||||
- Use free tier (Gemini CLI, iFlow) for non-critical tasks
|
||||
|
||||
**Dashboard opens on wrong port**
|
||||
**Dashboard/API ports are wrong**
|
||||
|
||||
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
|
||||
- `PORT` is the canonical base port (and API port by default)
|
||||
- `API_PORT` overrides only OpenAI-compatible API listener
|
||||
- `DASHBOARD_PORT` overrides only dashboard/Next.js listener
|
||||
- Set `NEXT_PUBLIC_BASE_URL` to your dashboard/public URL (for OAuth callbacks)
|
||||
|
||||
**Cloud sync errors**
|
||||
|
||||
|
||||
+27
-14
@@ -30,18 +30,18 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
|
||||
\x1b[1mUsage:\x1b[0m
|
||||
omniroute Start the server
|
||||
omniroute --port <port> Use custom port (default: 20128)
|
||||
omniroute --port <port> Use custom API port (default: 20128)
|
||||
omniroute --no-open Don't open browser automatically
|
||||
omniroute --help Show this help
|
||||
omniroute --version Show version
|
||||
|
||||
\x1b[1mAfter starting:\x1b[0m
|
||||
Dashboard: http://localhost:<port>
|
||||
API: http://localhost:<port>/v1
|
||||
Dashboard: http://localhost:<dashboard-port>
|
||||
API: http://localhost:<api-port>/v1
|
||||
|
||||
\x1b[1mConnect your tools:\x1b[0m
|
||||
Set your CLI tool (Cursor, Cline, Codex, etc.) to use:
|
||||
\x1b[33mhttp://localhost:20128/v1\x1b[0m
|
||||
\x1b[33mhttp://localhost:<api-port>/v1\x1b[0m
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -58,17 +58,26 @@ if (args.includes("--version") || args.includes("-v")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Parse --port
|
||||
let port = 20128;
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// Parse --port (canonical/base port)
|
||||
let port = parsePort(process.env.PORT || "20128", 20128);
|
||||
const portIdx = args.indexOf("--port");
|
||||
if (portIdx !== -1 && args[portIdx + 1]) {
|
||||
port = parseInt(args[portIdx + 1], 10);
|
||||
if (isNaN(port)) {
|
||||
const cliPort = parsePort(args[portIdx + 1], null);
|
||||
if (cliPort === null) {
|
||||
console.error("\x1b[31m✖ Invalid port number\x1b[0m");
|
||||
process.exit(1);
|
||||
}
|
||||
port = cliPort;
|
||||
}
|
||||
|
||||
const apiPort = parsePort(process.env.API_PORT || String(port), port);
|
||||
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
||||
|
||||
const noOpen = args.includes("--no-open");
|
||||
|
||||
// ── Banner ─────────────────────────────────────────────────
|
||||
@@ -110,7 +119,10 @@ console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
OMNIROUTE_PORT: String(port),
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
HOSTNAME: "0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
@@ -168,16 +180,17 @@ process.on("SIGTERM", shutdown);
|
||||
|
||||
// ── On ready ───────────────────────────────────────────────
|
||||
async function onReady() {
|
||||
const url = `http://localhost:${port}`;
|
||||
const dashboardUrl = `http://localhost:${dashboardPort}`;
|
||||
const apiUrl = `http://localhost:${apiPort}`;
|
||||
|
||||
console.log(`
|
||||
\x1b[32m✔ OmniRoute is running!\x1b[0m
|
||||
|
||||
\x1b[1m Dashboard:\x1b[0m ${url}
|
||||
\x1b[1m API Base:\x1b[0m ${url}/v1
|
||||
\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
|
||||
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1
|
||||
|
||||
\x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m
|
||||
\x1b[33m ${url}/v1\x1b[0m
|
||||
\x1b[33m ${apiUrl}/v1\x1b[0m
|
||||
|
||||
\x1b[2m Press Ctrl+C to stop\x1b[0m
|
||||
`);
|
||||
@@ -185,7 +198,7 @@ async function onReady() {
|
||||
if (!noOpen) {
|
||||
try {
|
||||
const open = await import("open");
|
||||
await open.default(url);
|
||||
await open.default(dashboardUrl);
|
||||
} catch {
|
||||
// open is optional — if not available, just skip
|
||||
}
|
||||
|
||||
@@ -22,21 +22,19 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=20128
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- HOSTNAME=0.0.0.0
|
||||
- DATA_DIR=/app/data
|
||||
ports:
|
||||
- "20130:20128"
|
||||
- "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${PROD_API_PORT:-20131}:${API_PORT:-20129}"
|
||||
volumes:
|
||||
- omniroute-prod-data:/app/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
|
||||
]
|
||||
test: ["CMD", "node", "healthcheck.mjs"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
+16
-10
@@ -20,16 +20,14 @@ x-common: &common
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data # Must match the volume mount below
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
volumes:
|
||||
- omniroute-data:/app/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
|
||||
]
|
||||
test: ["CMD", "node", "healthcheck.mjs"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -45,7 +43,8 @@ services:
|
||||
target: runner-base
|
||||
image: omniroute:base
|
||||
ports:
|
||||
- "${PORT:-20128}:20128"
|
||||
- "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
profiles:
|
||||
- base
|
||||
|
||||
@@ -58,7 +57,8 @@ services:
|
||||
target: runner-cli
|
||||
image: omniroute:cli
|
||||
ports:
|
||||
- "${PORT:-20128}:20128"
|
||||
- "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
profiles:
|
||||
- cli
|
||||
|
||||
@@ -71,8 +71,14 @@ services:
|
||||
target: runner-base
|
||||
image: omniroute:base
|
||||
ports:
|
||||
- "${PORT:-20128}:20128"
|
||||
- "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- CLI_MODE=host
|
||||
- CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin
|
||||
- CLI_CONFIG_HOME=/host-home
|
||||
|
||||
Generated
+8
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.4.11",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "1.4.11",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
@@ -3251,9 +3251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -5886,7 +5886,6 @@
|
||||
"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,
|
||||
@@ -7711,9 +7710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -7952,17 +7951,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.2",
|
||||
"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": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"files": [
|
||||
"bin/",
|
||||
"app/",
|
||||
"scripts/postinstall.mjs",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -42,10 +43,10 @@
|
||||
},
|
||||
"homepage": "https://omniroute.online",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 20128",
|
||||
"dev": "node scripts/run-next.mjs dev",
|
||||
"build": "next build --webpack",
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "next start --port 20128",
|
||||
"start": "node scripts/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
@@ -57,6 +58,7 @@
|
||||
"test:all": "npm run test:unit && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
@@ -8,7 +11,7 @@ export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:20128",
|
||||
baseURL: dashboardBaseUrl,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
@@ -20,7 +23,7 @@ export default defineConfig({
|
||||
],
|
||||
webServer: {
|
||||
command: process.env.CI ? "npm start" : "npm run dev",
|
||||
url: "http://localhost:20128",
|
||||
url: dashboardBaseUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Docker healthcheck script for OmniRoute.
|
||||
* Checks the /api/settings endpoint on the dashboard port.
|
||||
* Used by Dockerfile and docker-compose files.
|
||||
*/
|
||||
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/settings`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
})
|
||||
.catch(() => process.exit(1));
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Postinstall Native Module Rebuild
|
||||
*
|
||||
* The npm package ships with a Next.js standalone build that includes
|
||||
* better-sqlite3 compiled for the build platform (Linux x64).
|
||||
* This script detects platform mismatches and rebuilds the native
|
||||
* module for the user's actual OS/architecture.
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
// The standalone build bundles better-sqlite3 inside app/node_modules
|
||||
const appNodeModules = join(ROOT, "app", "node_modules", "better-sqlite3");
|
||||
|
||||
if (!existsSync(appNodeModules)) {
|
||||
// No bundled better-sqlite3 — nothing to do (dev install, not npm global)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const buildInfoPath = join(appNodeModules, "build", "Release", "better_sqlite3.node");
|
||||
|
||||
// Quick check: try to load the native module
|
||||
try {
|
||||
// Use a dynamic import-like approach — try to dlopen the .node file
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
// If it loaded, the binary is compatible — nothing to do
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Binary is incompatible — rebuild
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
|
||||
|
||||
try {
|
||||
execSync("npm rebuild better-sqlite3", {
|
||||
cwd: join(ROOT, "app"),
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
});
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
} catch (error) {
|
||||
console.warn(" ⚠️ Failed to rebuild better-sqlite3 automatically.");
|
||||
console.warn(" You can fix this manually by running:");
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3\n`);
|
||||
// Don't fail the install — the user can fix manually
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
resolveRuntimePorts,
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
|
||||
const mode = process.argv[2] === "start" ? "start" : "dev";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
const { dashboardPort } = runtimePorts;
|
||||
|
||||
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
|
||||
if (mode === "dev") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
}
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
resolveRuntimePorts,
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
|
||||
spawnWithForwardedSignals("node", ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function parsePort(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolveRuntimePorts() {
|
||||
const basePort = parsePort(process.env.PORT || "20128", 20128);
|
||||
const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort);
|
||||
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort);
|
||||
|
||||
return { basePort, apiPort, dashboardPort };
|
||||
}
|
||||
|
||||
export function withRuntimePortEnv(env, runtimePorts) {
|
||||
const { basePort, apiPort, dashboardPort } = runtimePorts;
|
||||
|
||||
return {
|
||||
...env,
|
||||
OMNIROUTE_PORT: String(basePort),
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnWithForwardedSignals(command, args, options = {}) {
|
||||
const child = spawn(command, args, options);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => child.kill("SIGINT"));
|
||||
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
||||
|
||||
return child;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [toolStatuses, setToolStatuses] = useState({});
|
||||
const [statusesLoaded, setStatusesLoaded] = useState(false);
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
@@ -46,6 +47,12 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
if (typeof window !== "undefined") {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const apiPort = data?.apiPort || 20128;
|
||||
setApiBaseUrl(`${protocol}//${hostname}:${apiPort}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
@@ -147,6 +154,9 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
if (cloudEnabled && CLOUD_URL) {
|
||||
return CLOUD_URL;
|
||||
}
|
||||
if (apiBaseUrl) {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function OnboardingWizard() {
|
||||
const tc = useTranslations("common");
|
||||
const [step, setStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
|
||||
|
||||
// Security step state
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -40,11 +41,20 @@ export default function OnboardingWizard() {
|
||||
|
||||
// Check if setup is already complete
|
||||
useEffect(() => {
|
||||
const resolveApiEndpoint = (apiPort) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const effectiveApiPort = apiPort || 20128;
|
||||
setApiEndpoint(`${protocol}//${hostname}:${effectiveApiPort}/api/v1`);
|
||||
};
|
||||
|
||||
const checkSetup = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const settings = await res.json();
|
||||
resolveApiEndpoint(settings?.apiPort);
|
||||
if (settings.setupComplete) {
|
||||
router.replace("/dashboard");
|
||||
return;
|
||||
@@ -387,7 +397,7 @@ export default function OnboardingWizard() {
|
||||
<p className="text-text-muted">{t("doneDesc")}</p>
|
||||
<div className="bg-white/[0.03] rounded-xl p-4 border border-white/[0.06] text-left">
|
||||
<p className="text-xs text-text-muted mb-2 font-medium">{t("yourEndpoint")}</p>
|
||||
<code className="text-sm text-primary">http://localhost:20128/api/v1</code>
|
||||
<code className="text-sm text-primary">{apiEndpoint}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
/**
|
||||
* POST /api/cli-tools/guide-settings/:toolId
|
||||
@@ -37,6 +38,7 @@ export async function POST(request, { params }) {
|
||||
* Merges with existing config if present.
|
||||
*/
|
||||
async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
const configPath = path.join(os.homedir(), ".continue", "config.json");
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
@@ -53,24 +55,40 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
}
|
||||
|
||||
// Build the OmniRoute model entry
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
const routerModel = {
|
||||
apiBase: baseUrl,
|
||||
apiBase: normalizedBaseUrl,
|
||||
title: model,
|
||||
model: model,
|
||||
provider: "openai",
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
omnirouteManaged: true,
|
||||
};
|
||||
|
||||
// Merge into existing models array
|
||||
const models = existingConfig.models || [];
|
||||
|
||||
function normalizeApiBase(value: unknown): string {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Check if OmniRoute entry already exists and update it, or add new
|
||||
const existingIdx = models.findIndex(
|
||||
(m) =>
|
||||
m.apiBase &&
|
||||
(m.apiBase.includes("localhost:20128") ||
|
||||
m.apiBase.includes("omniroute") ||
|
||||
m.title === model)
|
||||
m &&
|
||||
(m.omnirouteManaged === true ||
|
||||
normalizeApiBase(m.apiBase) === normalizedBaseUrl.toLowerCase() ||
|
||||
normalizeApiBase(m.apiBase).includes("omniroute") ||
|
||||
normalizeApiBase(m.apiBase).includes(`localhost:${apiPort}`) ||
|
||||
normalizeApiBase(m.apiBase).includes(`127.0.0.1:${apiPort}`) ||
|
||||
String(m.apiKey || "")
|
||||
.toLowerCase()
|
||||
.includes("sk_omniroute"))
|
||||
);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
getCliPrimaryConfigPath,
|
||||
} from "@/shared/services/cliRuntime";
|
||||
import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
const { apiPort } = getRuntimePorts();
|
||||
|
||||
// Check if a tool has OmniRoute configured by reading its config file directly
|
||||
// This replaces the expensive self-referential HTTP calls to /api/cli-tools/*-settings
|
||||
@@ -31,9 +34,12 @@ async function checkToolConfigStatus(toolId: string): Promise<string> {
|
||||
case "openclaw":
|
||||
case "cline":
|
||||
case "kilo":
|
||||
// Generic check: look for any OmniRoute-related URL in the config
|
||||
// Generic check: look for OmniRoute-specific markers in the config
|
||||
const configStr = JSON.stringify(config).toLowerCase();
|
||||
return configStr.includes("omniroute") || configStr.includes("20128")
|
||||
return configStr.includes("omniroute") ||
|
||||
configStr.includes("sk_omniroute") ||
|
||||
configStr.includes(`localhost:${apiPort}`) ||
|
||||
configStr.includes(`127.0.0.1:${apiPort}`)
|
||||
? "configured"
|
||||
: "not_configured";
|
||||
default:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -10,11 +11,15 @@ export async function GET() {
|
||||
const { password, ...safeSettings } = settings;
|
||||
|
||||
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
|
||||
const runtimePorts = getRuntimePorts();
|
||||
|
||||
return NextResponse.json({
|
||||
...safeSettings,
|
||||
enableRequestLogs,
|
||||
hasPassword: !!password || !!process.env.INITIAL_PASSWORD,
|
||||
runtimePorts,
|
||||
apiPort: runtimePorts.apiPort,
|
||||
dashboardPort: runtimePorts.dashboardPort,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error getting settings:", error);
|
||||
|
||||
@@ -31,6 +31,9 @@ export async function register() {
|
||||
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
|
||||
initGracefulShutdown();
|
||||
|
||||
const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
|
||||
initApiBridgeServer();
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import http from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
const PROXY_TIMEOUT_MS = 30_000; // 30s timeout to prevent resource exhaustion
|
||||
|
||||
const OPENAI_COMPAT_PATHS = [
|
||||
/^\/v1(?:\/|$)/,
|
||||
/^\/chat\/completions(?:\?|$)/,
|
||||
/^\/responses(?:\?|$)/,
|
||||
/^\/models(?:\?|$)/,
|
||||
/^\/codex(?:\/|\?|$)/,
|
||||
];
|
||||
|
||||
function isOpenAiCompatiblePath(pathname: string): boolean {
|
||||
return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
|
||||
const targetReq = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: dashboardPort,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${dashboardPort}`,
|
||||
},
|
||||
timeout: PROXY_TIMEOUT_MS,
|
||||
},
|
||||
(targetRes) => {
|
||||
res.writeHead(targetRes.statusCode || 502, targetRes.headers);
|
||||
targetRes.pipe(res);
|
||||
}
|
||||
);
|
||||
|
||||
targetReq.on("timeout", () => {
|
||||
targetReq.destroy();
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(504, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "api_bridge_timeout",
|
||||
detail: `Proxy request timed out after ${PROXY_TIMEOUT_MS}ms`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
targetReq.on("error", (error) => {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(502, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "api_bridge_unavailable",
|
||||
detail: String(error.message || error),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
req.on("aborted", () => {
|
||||
targetReq.destroy();
|
||||
});
|
||||
|
||||
req.pipe(targetReq);
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __omnirouteApiBridgeStarted: boolean | undefined;
|
||||
}
|
||||
|
||||
export function initApiBridgeServer(): void {
|
||||
if (globalThis.__omnirouteApiBridgeStarted) return;
|
||||
|
||||
const { apiPort, dashboardPort } = getRuntimePorts();
|
||||
if (apiPort === dashboardPort) return;
|
||||
|
||||
const host = process.env.API_HOST || "127.0.0.1";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const rawUrl = req.url || "/";
|
||||
const pathname = rawUrl.split("?")[0] || "/";
|
||||
|
||||
if (!isOpenAiCompatiblePath(pathname)) {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "not_found",
|
||||
message: "API port only serves OpenAI-compatible routes.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
proxyRequest(req, res, dashboardPort);
|
||||
});
|
||||
|
||||
server.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error?.code === "EADDRINUSE") {
|
||||
console.warn(
|
||||
`[API Bridge] Port ${apiPort} is already in use. API bridge disabled. (dashboard: ${dashboardPort})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn("[API Bridge] Failed to start:", error?.message || error);
|
||||
});
|
||||
|
||||
server.listen(apiPort, host, () => {
|
||||
globalThis.__omnirouteApiBridgeStarted = true;
|
||||
console.log(`[API Bridge] Listening on ${host}:${apiPort} -> dashboard:${dashboardPort}`);
|
||||
});
|
||||
}
|
||||
@@ -5,19 +5,26 @@
|
||||
* with the running OmniRoute server when saving tokens.
|
||||
*/
|
||||
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
interface ServerCredentials {
|
||||
server: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function getDefaultApiServer() {
|
||||
const { dashboardPort } = getRuntimePorts();
|
||||
return `http://localhost:${dashboardPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server credentials from environment variables.
|
||||
* Used by OAuth CLI services to save tokens to the running server.
|
||||
*/
|
||||
export function getServerCredentials(): ServerCredentials {
|
||||
return {
|
||||
server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || "http://localhost:20128",
|
||||
server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || getDefaultApiServer(),
|
||||
token: process.env.OMNIROUTE_TOKEN || process.env.CLI_TOKEN || "",
|
||||
userId: process.env.OMNIROUTE_USER_ID || process.env.CLI_USER_ID || "cli",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const DEFAULT_PORT = 20128;
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type RuntimePorts = {
|
||||
port: number;
|
||||
apiPort: number;
|
||||
dashboardPort: number;
|
||||
apiPortExplicit: boolean;
|
||||
dashboardPortExplicit: boolean;
|
||||
};
|
||||
|
||||
export function getRuntimePorts(): RuntimePorts {
|
||||
// OMNIROUTE_PORT preserves the user's canonical PORT in wrapped runtimes
|
||||
// where Next.js requires process.env.PORT to be the dashboard listener port.
|
||||
const basePort = parsePort(process.env.OMNIROUTE_PORT || process.env.PORT, DEFAULT_PORT);
|
||||
const apiPortExplicit = !!process.env.API_PORT;
|
||||
const dashboardPortExplicit = !!process.env.DASHBOARD_PORT;
|
||||
|
||||
return {
|
||||
port: basePort,
|
||||
apiPort: parsePort(process.env.API_PORT, basePort),
|
||||
dashboardPort: parsePort(process.env.DASHBOARD_PORT, basePort),
|
||||
apiPortExplicit,
|
||||
dashboardPortExplicit,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isCloudEnabled } from "@/lib/localDb";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
const { dashboardPort } = getRuntimePorts();
|
||||
|
||||
const INTERNAL_BASE_URL =
|
||||
process.env.BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
"http://localhost:20128";
|
||||
`http://localhost:${dashboardPort}`;
|
||||
|
||||
/**
|
||||
* Cloud sync scheduler
|
||||
|
||||
@@ -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)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+4
@@ -13,6 +13,10 @@ declare namespace NodeJS {
|
||||
PROMPT_CACHE_MAX_SIZE?: string;
|
||||
PROMPT_CACHE_TTL_MS?: string;
|
||||
NEXT_PUBLIC_CLOUD_URL?: string;
|
||||
API_PORT?: string;
|
||||
API_HOST?: string;
|
||||
DASHBOARD_PORT?: string;
|
||||
OMNIROUTE_PORT?: string;
|
||||
NODE_ENV?: "development" | "production" | "test";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// We test the standalone (scripts/) version of port resolution since
|
||||
// the src/ version uses @/ alias that requires the full Next.js build.
|
||||
import { parsePort, resolveRuntimePorts } from "../../scripts/runtime-env.mjs";
|
||||
|
||||
describe("parsePort", () => {
|
||||
it("parses a valid port number", () => {
|
||||
assert.equal(parsePort("3000", 20128), 3000);
|
||||
});
|
||||
|
||||
it("returns fallback for undefined", () => {
|
||||
assert.equal(parsePort(undefined, 20128), 20128);
|
||||
});
|
||||
|
||||
it("returns fallback for non-numeric string", () => {
|
||||
assert.equal(parsePort("abc", 20128), 20128);
|
||||
});
|
||||
|
||||
it("returns fallback for port 0", () => {
|
||||
assert.equal(parsePort("0", 20128), 20128);
|
||||
});
|
||||
|
||||
it("returns fallback for port > 65535", () => {
|
||||
assert.equal(parsePort("70000", 20128), 20128);
|
||||
});
|
||||
|
||||
it("returns fallback for negative port", () => {
|
||||
assert.equal(parsePort("-1", 20128), 20128);
|
||||
});
|
||||
|
||||
it("accepts port 1", () => {
|
||||
assert.equal(parsePort("1", 20128), 1);
|
||||
});
|
||||
|
||||
it("accepts port 65535", () => {
|
||||
assert.equal(parsePort("65535", 20128), 65535);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimePorts", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.PORT;
|
||||
delete process.env.API_PORT;
|
||||
delete process.env.DASHBOARD_PORT;
|
||||
delete process.env.OMNIROUTE_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (!(key in originalEnv)) delete process.env[key];
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it("returns default ports when no env vars set", () => {
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 20128);
|
||||
assert.equal(ports.apiPort, 20128);
|
||||
assert.equal(ports.dashboardPort, 20128);
|
||||
});
|
||||
|
||||
it("uses PORT as base for all ports", () => {
|
||||
process.env.PORT = "3000";
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 3000);
|
||||
assert.equal(ports.apiPort, 3000);
|
||||
assert.equal(ports.dashboardPort, 3000);
|
||||
});
|
||||
|
||||
it("splits ports when API_PORT is set", () => {
|
||||
process.env.PORT = "3000";
|
||||
process.env.API_PORT = "3001";
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 3000);
|
||||
assert.equal(ports.apiPort, 3001);
|
||||
assert.equal(ports.dashboardPort, 3000);
|
||||
});
|
||||
|
||||
it("splits ports when DASHBOARD_PORT is set", () => {
|
||||
process.env.PORT = "3000";
|
||||
process.env.DASHBOARD_PORT = "3002";
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 3000);
|
||||
assert.equal(ports.apiPort, 3000);
|
||||
assert.equal(ports.dashboardPort, 3002);
|
||||
});
|
||||
|
||||
it("supports full split (API + DASHBOARD)", () => {
|
||||
process.env.PORT = "3000";
|
||||
process.env.API_PORT = "3001";
|
||||
process.env.DASHBOARD_PORT = "3002";
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 3000);
|
||||
assert.equal(ports.apiPort, 3001);
|
||||
assert.equal(ports.dashboardPort, 3002);
|
||||
});
|
||||
|
||||
it("ignores invalid port values and falls back", () => {
|
||||
process.env.PORT = "abc";
|
||||
const ports = resolveRuntimePorts();
|
||||
assert.equal(ports.basePort, 20128);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user