mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-28 10:52:10 +03:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d3374d5c | ||
|
|
3ae00bebe4 | ||
|
|
f9df72c4d7 | ||
|
|
d0fb4576a8 | ||
|
|
0e4b0b3540 | ||
|
|
df1105d0c6 | ||
|
|
44478c36a3 | ||
|
|
fa267274b0 | ||
|
|
0db272946a | ||
|
|
91015b6499 | ||
|
|
8fbbe8b82b | ||
|
|
7c992ffd21 | ||
|
|
0f13965391 |
30
.github/workflows/docker-publish.yml
vendored
30
.github/workflows/docker-publish.yml
vendored
@@ -3,6 +3,12 @@ name: Publish to Docker Hub
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to build (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,30 +21,36 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
||||
- name: Set up QEMU (for multi-arch builds)
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from release tag
|
||||
- name: Extract version from release tag or input
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
@@ -58,7 +70,7 @@ jobs:
|
||||
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
17
.github/workflows/npm-publish.yml
vendored
17
.github/workflows/npm-publish.yml
vendored
@@ -3,6 +3,12 @@ name: Publish to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to publish (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,11 +31,14 @@ jobs:
|
||||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Sync version from release tag
|
||||
- name: Sync version from release tag or input
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
# Remove 'v' prefix if present (v2.1.0 -> 2.1.0)
|
||||
VERSION="${VERSION#v}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -4,6 +4,42 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.6.1] — 2026-03-15
|
||||
|
||||
> Critical startup fix: v2.6.0 global npm installs crashed with a 500 error due to a Turbopack/webpack module-name hashing bug in the Next.js 16 instrumentation hook.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: Force `better-sqlite3` to always be required by its exact package name in the webpack server bundle. Next.js 16 compiled the instrumentation hook into a separate chunk and emitted `require('better-sqlite3-<hash>')` — a hashed module name that doesn't exist in `node_modules` — even though the package was listed in `serverExternalPackages`. Added an explicit `externals` function to the server webpack config so the bundler always emits `require('better-sqlite3')`, resolving the startup `500 Internal Server Error` on clean global installs. (#394, PR #395)
|
||||
|
||||
### 🔧 CI
|
||||
|
||||
- **ci**: Added `workflow_dispatch` to `npm-publish.yml` with version sync safeguard for manual triggers (#392)
|
||||
- **ci**: Added `workflow_dispatch` to `docker-publish.yml`, updated GitHub Actions to latest versions (#392)
|
||||
|
||||
---
|
||||
|
||||
## [2.6.0] - 2026-03-15
|
||||
|
||||
> Issue resolution sprint: 4 bugs fixed, logs UX improved, Kiro credit tracking added.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(media)**: ComfyUI and SD WebUI no longer appear in the Media page provider list when unconfigured — fetches `/api/providers` on mount and hides local providers with no connections (#390)
|
||||
- **fix(auth)**: Round-robin no longer re-selects rate-limited accounts immediately after cooldown — `backoffLevel` is now used as primary sort key in the LRU rotation (#340)
|
||||
- **fix(oauth)**: iFlow (and other providers that redirect to their own UI) no longer leave the OAuth modal stuck at "Waiting for Authorization" — popup-closed detector auto-transitions to manual URL input mode (#344)
|
||||
- **fix(logs)**: Request log table is now readable in light mode — status badges, token counts, and combo tags use adaptive `dark:` color classes (#378)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(kiro)**: Kiro credit tracking added to usage fetcher — queries `getUserCredits` from AWS CodeWhisperer endpoint (#337)
|
||||
|
||||
### 🛠 Chores
|
||||
|
||||
- **chore(tests)**: Aligned `test:plan3`, `test:fixes`, `test:security` to use same `tsx/esm` loader as `npm test` — eliminates module resolution false negatives in targeted runs (PR #386)
|
||||
|
||||
---
|
||||
|
||||
## [2.5.9] - 2026-03-15
|
||||
|
||||
> Codex native passthrough fix + route body validation hardening.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.5.9
|
||||
version: 2.6.1
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -38,8 +38,31 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore native Node.js modules in browser bundle
|
||||
if (!isServer) {
|
||||
if (isServer) {
|
||||
// Force better-sqlite3 to always be required by its exact package name.
|
||||
//
|
||||
// Next.js 16 webpack compiles the instrumentation hook into a separate
|
||||
// chunk ([root-of-the-server]__<hash>.js) and can emit a hashed require
|
||||
// such as `require('better-sqlite3-90e2652d1716b047')` even when the
|
||||
// package is listed in `serverExternalPackages`. That hashed name doesn't
|
||||
// exist in node_modules, causing the 500 error reported in issue #394.
|
||||
//
|
||||
// Adding an explicit `externals` function overrides the bundler's default
|
||||
// handling and always emits `require('better-sqlite3')`, which resolves
|
||||
// correctly in the published standalone build.
|
||||
const prev = config.externals ?? [];
|
||||
const prevArr = Array.isArray(prev) ? prev : [prev];
|
||||
config.externals = [
|
||||
...prevArr,
|
||||
({ request }, callback) => {
|
||||
if (request === "better-sqlite3") {
|
||||
return callback(null, `commonjs ${request}`);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Ignore native Node.js modules in browser bundle
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.5.9",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.5.9",
|
||||
"version": "2.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.5.9",
|
||||
"version": "2.6.1",
|
||||
"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": {
|
||||
@@ -58,9 +58,9 @@
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --test tests/unit/security-fase01.test.mjs",
|
||||
"test:plan3": "node --import tsx/esm --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --import tsx/esm --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --import tsx/esm --test tests/unit/security-fase01.test.mjs",
|
||||
"check:cycles": "node scripts/check-cycles.mjs",
|
||||
"check:route-validation:t06": "node scripts/check-route-validation.mjs",
|
||||
"check:any-budget:t11": "node scripts/check-t11-any-budget.mjs",
|
||||
|
||||
@@ -419,7 +419,46 @@ export default function MediaPageClient() {
|
||||
// Transcription-specific
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
|
||||
const currentProviders = PROVIDER_MODELS[activeTab] ?? [];
|
||||
// Fix #390: Track which local providers (sdwebui, comfyui) are actually configured
|
||||
// so we can hide them when they haven't been set up in the providers page
|
||||
const LOCAL_PROVIDERS = ["sdwebui", "comfyui"];
|
||||
const [configuredLocalProviders, setConfiguredLocalProviders] = useState<Set<string>>(
|
||||
new Set(LOCAL_PROVIDERS) // Optimistic: show all until we know otherwise
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch configured provider connections to determine which local providers are set up
|
||||
fetch("/api/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const connections: { provider?: string; testStatus?: string }[] = Array.isArray(data)
|
||||
? data
|
||||
: (data?.connections ?? data?.providers ?? []);
|
||||
const configured = new Set<string>();
|
||||
for (const conn of connections) {
|
||||
const pId = conn?.provider;
|
||||
if (pId && LOCAL_PROVIDERS.includes(pId)) {
|
||||
configured.add(pId);
|
||||
}
|
||||
}
|
||||
// Only update if at least one local provider was found, otherwise keep optimistic
|
||||
if (configured.size > 0) {
|
||||
setConfiguredLocalProviders(configured);
|
||||
} else {
|
||||
// No local providers configured — hide sdwebui/comfyui
|
||||
setConfiguredLocalProviders(new Set());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// On error, keep showing all (fail-open)
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Filter out unconfigured local providers from the provider list
|
||||
const currentProviders = (PROVIDER_MODELS[activeTab] ?? []).filter(
|
||||
(p) => !LOCAL_PROVIDERS.includes(p.id) || configuredLocalProviders.has(p.id)
|
||||
);
|
||||
const currentModels = currentProviders.find((p) => p.id === selectedProvider)?.models ?? [];
|
||||
|
||||
const switchTab = (tab: Modality) => {
|
||||
|
||||
@@ -433,6 +433,51 @@ export default function OAuthModal({
|
||||
};
|
||||
}, [authData, exchangeTokens]);
|
||||
|
||||
// Fix #344: Detect when OAuth popup is closed without completing authorization
|
||||
// Some providers (like iFlow) redirect to their own chat UI instead of sending a callback,
|
||||
// leaving the modal stuck at "Waiting for Authorization" forever.
|
||||
useEffect(() => {
|
||||
if (step !== "waiting" || isDeviceCode || !popupRef.current) return;
|
||||
|
||||
let closed = false;
|
||||
const popupClosedInterval = setInterval(() => {
|
||||
if (callbackProcessedRef.current) {
|
||||
clearInterval(popupClosedInterval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (popupRef.current?.closed) {
|
||||
closed = true;
|
||||
clearInterval(popupClosedInterval);
|
||||
// Popup was closed without completing OAuth — switch to manual input mode
|
||||
// so user can paste the callback URL from their browser address bar
|
||||
if (step === "waiting") {
|
||||
setStep("input");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin access may throw — ignore
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Safety timeout: 5 minutes
|
||||
const safetyTimeout = setTimeout(
|
||||
() => {
|
||||
if (!callbackProcessedRef.current && step === "waiting") {
|
||||
clearInterval(popupClosedInterval);
|
||||
setStep("input");
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(popupClosedInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
};
|
||||
|
||||
}, [step, isDeviceCode]);
|
||||
|
||||
// Handle manual URL input
|
||||
const handleManualSubmit = async () => {
|
||||
try {
|
||||
@@ -471,9 +516,13 @@ export default function OAuthModal({
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Waiting for Authorization</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
<p className="text-sm text-text-muted mb-2">
|
||||
Complete the authorization in the popup window.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-4 opacity-70">
|
||||
If the popup closes without redirecting back (e.g. iFlow), this dialog will
|
||||
automatically switch to manual URL input mode.
|
||||
</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
Popup blocked? Enter URL manually
|
||||
</Button>
|
||||
|
||||
@@ -351,16 +351,16 @@ export default function RequestLoggerV2() {
|
||||
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
|
||||
{totalCount} total
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-400 font-mono">
|
||||
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 font-mono">
|
||||
{okCount} OK
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400 font-mono">
|
||||
<span className="px-2 py-1 rounded bg-red-500/10 text-red-700 dark:text-red-400 font-mono">
|
||||
{errorCount} ERR
|
||||
</span>
|
||||
)}
|
||||
{comboCount > 0 && (
|
||||
<span className="px-2 py-1 rounded bg-violet-500/10 text-violet-300 font-mono">
|
||||
<span className="px-2 py-1 rounded bg-violet-500/10 text-violet-700 dark:text-violet-400 font-mono">
|
||||
{comboCount} combo
|
||||
</span>
|
||||
)}
|
||||
@@ -498,11 +498,11 @@ export default function RequestLoggerV2() {
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<thead
|
||||
className="sticky top-0 z-10"
|
||||
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
|
||||
style={{ backgroundColor: "var(--color-bg, #fff)" }}
|
||||
>
|
||||
<tr
|
||||
className="border-b border-border"
|
||||
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
|
||||
style={{ backgroundColor: "var(--color-bg, #fff)" }}
|
||||
>
|
||||
{visibleColumns.status && (
|
||||
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
||||
@@ -635,7 +635,7 @@ export default function RequestLoggerV2() {
|
||||
{visibleColumns.combo && (
|
||||
<td className="px-3 py-2">
|
||||
{log.comboName ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
{log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
@@ -651,7 +651,7 @@ export default function RequestLoggerV2() {
|
||||
</span>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">O:</span>{" "}
|
||||
<span className="text-emerald-400">
|
||||
<span className="text-emerald-700 dark:text-emerald-400">
|
||||
{log.tokens?.out?.toLocaleString() || 0}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -382,7 +382,13 @@ export async function getProviderCredentials(
|
||||
});
|
||||
} else {
|
||||
// Pick the least recently used (excluding current if possible)
|
||||
// Also penalize accounts with high backoffLevel (previously rate-limited)
|
||||
// so they don't get immediately re-selected after cooldown (#340)
|
||||
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
|
||||
// Penalize previously rate-limited accounts (backoffLevel > 0)
|
||||
const aBackoff = a.backoffLevel || 0;
|
||||
const bBackoff = b.backoffLevel || 0;
|
||||
if (aBackoff !== bBackoff) return aBackoff - bBackoff; // lower backoff first
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
if (!b.lastUsedAt) return 1;
|
||||
@@ -404,7 +410,11 @@ export async function getProviderCredentials(
|
||||
} else {
|
||||
// Fallback scenario: excluded an account due to failure
|
||||
// Always pick the least recently used to ensure proper cycling
|
||||
// Also penalize accounts with high backoffLevel (#340)
|
||||
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
|
||||
const aBackoff = a.backoffLevel || 0;
|
||||
const bBackoff = b.backoffLevel || 0;
|
||||
if (aBackoff !== bBackoff) return aBackoff - bBackoff;
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
if (!b.lastUsedAt) return 1;
|
||||
|
||||
Reference in New Issue
Block a user