mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
1 Commits
v3.0.0-rc.
...
v3.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa93a3f2e2 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -4,6 +4,19 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.3] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#529** — Provider icons now use [@lobehub/icons](https://github.com/lobehub/lobe-icons) with graceful PNG fallback and a `ProviderIcon` component (130+ providers supported)
|
||||
- **#488** — Auto-update model lists every 24h via `modelSyncScheduler` (configurable via `MODEL_SYNC_INTERVAL_HOURS`)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#537** — Gemini CLI OAuth: now shows clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.2] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.0.0-rc.2
|
||||
version: 3.0.0-rc.3
|
||||
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,
|
||||
|
||||
7565
package-lock.json
generated
7565
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.2",
|
||||
"version": "3.0.0-rc.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": {
|
||||
@@ -81,8 +81,10 @@
|
||||
"system-info": "node scripts/system-info.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^5.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
@@ -110,8 +112,7 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10",
|
||||
"@swc/helpers": "0.5.19"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Card,
|
||||
@@ -490,16 +491,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgSrc, setImgSrc] = useState(`/providers/${provider.id}.png`);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgSrc.endsWith(".png")) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
// (#529) Icon state replaced by ProviderIcon component (Lobehub + PNG + generic fallback)
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -526,21 +519,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
||||
sizes="32px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
{/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */}
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
@@ -633,28 +613,15 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
compatible: t("compatibleLabel"),
|
||||
};
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const getIconPath = () => {
|
||||
// (#529) Icon state replaced by ProviderIcon component
|
||||
// For compatible/anthropic providers, continue using static PNGs via the icon path
|
||||
const staticIconPath = (() => {
|
||||
if (isCompatible) {
|
||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) {
|
||||
return "/providers/anthropic-m.png"; // Use Anthropic icon as base
|
||||
}
|
||||
return `/providers/${provider.id}.png`;
|
||||
};
|
||||
|
||||
const [imgSrc, setImgSrc] = useState<string>(() => getIconPath());
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
const basePath = getIconPath();
|
||||
if (imgSrc.endsWith(".png") && !isCompatible && !isAnthropicCompatible) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
return null; // ProviderIcon will handle it
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
@@ -668,20 +635,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
{/* (#529) ProviderIcon with static override for compatible providers */}
|
||||
{staticIconPath ? (
|
||||
<Image
|
||||
src={imgSrc || getIconPath()}
|
||||
src={staticIconPath}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
sizes="30px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
) : (
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler";
|
||||
|
||||
let syncInitialized = false;
|
||||
let modelSyncInitialized = false;
|
||||
|
||||
// POST /api/sync/initialize - Initialize cloud sync scheduler
|
||||
export async function POST(request) {
|
||||
@@ -15,9 +17,17 @@ export async function POST(request) {
|
||||
await initializeCloudSync();
|
||||
syncInitialized = true;
|
||||
|
||||
// (#488) Start model auto-sync scheduler (24h, configurable via MODEL_SYNC_INTERVAL_HOURS)
|
||||
if (!modelSyncInitialized) {
|
||||
const origin = request.headers.get("origin") || "http://localhost:20128";
|
||||
startModelSyncScheduler(origin);
|
||||
modelSyncInitialized = true;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud sync initialized successfully",
|
||||
modelSyncEnabled: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
@@ -34,6 +44,7 @@ export async function POST(request) {
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({
|
||||
initialized: syncInitialized,
|
||||
modelSyncInitialized,
|
||||
message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ export const gemini = {
|
||||
|
||||
if (config.clientSecret) {
|
||||
bodyParams.client_secret = config.clientSecret;
|
||||
} else {
|
||||
// (#537) Google's OAuth2 token endpoint always requires client_secret for
|
||||
// non-PKCE flows. Without it we get a cryptic "client_secret is missing" error.
|
||||
// This typically happens in self-hosted / Docker deployments where
|
||||
// GEMINI_OAUTH_CLIENT_SECRET is not set in the container environment.
|
||||
throw new Error(
|
||||
"Gemini CLI OAuth requires GEMINI_OAUTH_CLIENT_SECRET to be set.\n" +
|
||||
"In Docker: add 'GEMINI_OAUTH_CLIENT_SECRET=<your-secret>' to your docker-compose.yml env.\n" +
|
||||
"In npm: add it to ~/.omniroute/.env\n" +
|
||||
"Obtain the client secret from https://console.cloud.google.com/apis/credentials\n" +
|
||||
"for the same OAuth 2.0 Client ID configured as GEMINI_OAUTH_CLIENT_ID."
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
|
||||
167
src/shared/components/ProviderIcon.tsx
Normal file
167
src/shared/components/ProviderIcon.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderIcon — Renders a provider logo using @lobehub/icons with PNG fallback.
|
||||
*
|
||||
* Strategy (#529):
|
||||
* 1. Try @lobehub/icons ProviderIcon (130+ providers, React components)
|
||||
* 2. Fall back to /providers/{id}.png (existing static assets)
|
||||
* 3. Fall back to a generic AI icon
|
||||
*
|
||||
* Usage:
|
||||
* <ProviderIcon providerId="openai" size={24} />
|
||||
* <ProviderIcon providerId="anthropic" size={28} type="color" />
|
||||
*/
|
||||
|
||||
import { memo, useState, Component, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { ProviderIcon as LobehubProviderIcon } from "@lobehub/icons";
|
||||
|
||||
// Mapping from OmniRoute provider IDs → Lobehub icon IDs
|
||||
// Lobehub uses lowercase IDs matching ModelProvider enum values
|
||||
const LOBEHUB_PROVIDER_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
claude: "anthropic",
|
||||
gemini: "google",
|
||||
google: "google",
|
||||
deepseek: "deepseek",
|
||||
groq: "groq",
|
||||
mistral: "mistral",
|
||||
cohere: "cohere",
|
||||
perplexity: "perplexity",
|
||||
xai: "xai",
|
||||
grok: "xai",
|
||||
together: "togetherai",
|
||||
fireworks: "fireworks",
|
||||
"fireworks-ai": "fireworks",
|
||||
cerebras: "cerebras",
|
||||
huggingface: "huggingface",
|
||||
"hugging-face": "huggingface",
|
||||
openrouter: "openrouter",
|
||||
"open-router": "openrouter",
|
||||
ollama: "ollama",
|
||||
minimax: "minimax",
|
||||
qwen: "qwen",
|
||||
alibaba: "qwen",
|
||||
moonshot: "moonshot",
|
||||
kimi: "moonshot",
|
||||
baidu: "baidu",
|
||||
ernie: "baidu",
|
||||
spark: "iflytek",
|
||||
"zhipu-ai": "zhipu",
|
||||
zhipu: "zhipu",
|
||||
lmsys: "lmsys",
|
||||
"stability-ai": "stability",
|
||||
stability: "stability",
|
||||
replicate: "replicate",
|
||||
ai21: "ai21",
|
||||
nvidia: "nvidia",
|
||||
cloudflare: "cloudflare",
|
||||
"cloudflare-ai": "cloudflare",
|
||||
"aws-bedrock": "bedrock",
|
||||
bedrock: "bedrock",
|
||||
azure: "azure",
|
||||
"azure-openai": "azure",
|
||||
copilot: "githubcopilot",
|
||||
"github-copilot": "githubcopilot",
|
||||
mistralai: "mistral",
|
||||
codex: "openai",
|
||||
blackbox: "blackboxai",
|
||||
blackboxai: "blackboxai",
|
||||
pollinations: "pollinations",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerId: string;
|
||||
size?: number;
|
||||
type?: "mono" | "color";
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Error boundary to catch Lobehub component render errors gracefully. */
|
||||
class LobehubErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError: () => void },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
this.props.onError();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return null;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function GenericProviderIcon({ size }: { size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flex: "none" }}>
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
|
||||
<path d="M8 12h8M12 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ProviderIcon = memo(function ProviderIcon({
|
||||
providerId,
|
||||
size = 24,
|
||||
type = "color",
|
||||
className,
|
||||
style,
|
||||
}: ProviderIconProps) {
|
||||
const lobehubId = LOBEHUB_PROVIDER_MAP[providerId.toLowerCase()] ?? null;
|
||||
const [useLobehub, setUseLobehub] = useState(lobehubId !== null);
|
||||
const [usePng, setUsePng] = useState(true);
|
||||
|
||||
if (useLobehub && lobehubId) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
<LobehubErrorBoundary onError={() => setUseLobehub(false)}>
|
||||
<LobehubProviderIcon provider={lobehubId} size={size} type={type} />
|
||||
</LobehubErrorBoundary>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (usePng) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
<Image
|
||||
src={`/providers/${providerId}.png`}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: "contain" }}
|
||||
onError={() => setUsePng(false)}
|
||||
unoptimized
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
|
||||
<GenericProviderIcon size={size} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProviderIcon;
|
||||
export type { ProviderIconProps };
|
||||
145
src/shared/services/modelSyncScheduler.ts
Normal file
145
src/shared/services/modelSyncScheduler.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Model Auto-Sync Scheduler (#488)
|
||||
*
|
||||
* Automatically refreshes model lists for all providers with autoSync enabled
|
||||
* at a configurable interval (default: 24h).
|
||||
*
|
||||
* Pattern mirrors cloudSyncScheduler.ts for consistency.
|
||||
*/
|
||||
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
|
||||
|
||||
/** Providers that support live model list fetching via /v1/models */
|
||||
const AUTO_SYNC_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"groq",
|
||||
"mistral",
|
||||
"cohere",
|
||||
"openrouter",
|
||||
"together",
|
||||
"fireworks",
|
||||
"perplexity",
|
||||
"xai",
|
||||
"cerebras",
|
||||
"ollama",
|
||||
"nvidia",
|
||||
];
|
||||
|
||||
let schedulerTimer: NodeJS.Timeout | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Fetch and cache models for a single provider.
|
||||
* Calls the internal /api/providers/{id}/sync-models endpoint (if it exists)
|
||||
* or falls back to /v1/models from the provider registry.
|
||||
*/
|
||||
async function syncProviderModels(providerId: string, baseUrl: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/provider-nodes/sync-models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: sync returned ${res.status}`);
|
||||
} else {
|
||||
console.log(`[ModelSync] Provider ${providerId}: ✓ updated`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: fetch failed —`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full model-sync cycle across all auto-sync providers.
|
||||
*/
|
||||
async function runSyncCycle(apiBaseUrl: string): Promise<void> {
|
||||
if (isRunning) {
|
||||
console.log("[ModelSync] Skipping cycle — previous run still in progress");
|
||||
return;
|
||||
}
|
||||
isRunning = true;
|
||||
const start = Date.now();
|
||||
console.log(
|
||||
`[ModelSync] Starting 24h model sync cycle — ${AUTO_SYNC_PROVIDERS.length} providers`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
AUTO_SYNC_PROVIDERS.map((id) => syncProviderModels(id, apiBaseUrl))
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
||||
console.log(
|
||||
`[ModelSync] Cycle complete: ${succeeded}/${AUTO_SYNC_PROVIDERS.length} providers synced in ${Date.now() - start}ms`
|
||||
);
|
||||
|
||||
// Record last sync time
|
||||
try {
|
||||
await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the model sync scheduler.
|
||||
* @param apiBaseUrl — internal base URL to call OmniRoute's own API
|
||||
* @param intervalMs — sync interval in milliseconds (default: 24h)
|
||||
*/
|
||||
export function startModelSyncScheduler(
|
||||
apiBaseUrl = "http://localhost:20128",
|
||||
intervalMs = DEFAULT_INTERVAL_MS
|
||||
): void {
|
||||
if (schedulerTimer) {
|
||||
console.log("[ModelSync] Scheduler already running — skipping start");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read MODEL_SYNC_INTERVAL_HOURS env override
|
||||
const envHours = parseInt(process.env.MODEL_SYNC_INTERVAL_HOURS ?? "", 10);
|
||||
const effectiveIntervalMs =
|
||||
!isNaN(envHours) && envHours > 0 ? envHours * 60 * 60 * 1000 : intervalMs;
|
||||
|
||||
console.log(
|
||||
`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h, providers: ${AUTO_SYNC_PROVIDERS.length}`
|
||||
);
|
||||
|
||||
// Run immediately on startup (staggered by 5s to avoid startup congestion)
|
||||
const startupDelay = setTimeout(() => runSyncCycle(apiBaseUrl), 5_000);
|
||||
startupDelay.unref?.();
|
||||
|
||||
// Then run on the regular interval
|
||||
schedulerTimer = setInterval(() => runSyncCycle(apiBaseUrl), effectiveIntervalMs);
|
||||
schedulerTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the model sync scheduler.
|
||||
*/
|
||||
export function stopModelSyncScheduler(): void {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer);
|
||||
schedulerTimer = null;
|
||||
console.log("[ModelSync] Scheduler stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last sync timestamp from settings DB.
|
||||
*/
|
||||
export async function getLastModelSyncTime(): Promise<string | null> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return (settings as Record<string, string>)[MODEL_SYNC_SETTING_KEY] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user