Compare commits

...

6 Commits

Author SHA1 Message Date
diegosouzapw aa93a3f2e2 feat(3.0.0-rc.3): provider icons, model auto-sync, Gemini OAuth fix
Build Electron Desktop App / Validate version (push) Failing after 40s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
feat(ui): ProviderIcon component with @lobehub/icons + PNG fallback (#529)
  - 130+ providers covered by Lobehub SVG components via LobehubErrorBoundary
  - Falls back to existing /providers/{id}.png, then generic icon
  - Replaces manual img state machine in ProviderCard + ApiKeyProviderCard

feat(scheduler): modelSyncScheduler — 24h model list auto-update (#488)
  - Syncs 16 major providers every 24h (MODEL_SYNC_INTERVAL_HOURS configurable)
  - Wired into POST /api/sync/initialize startup hook

fix(oauth): Gemini CLI — clear error when client_secret missing in Docker (#537)
2026-03-22 15:01:38 -03:00
diegosouzapw 8b9abcb6cc fix(3.0.0-rc.2): resolve issues #536, #535, #524
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
fix(providers): LongCat AI key validation — correct base URL and auth header (#536)
  - baseUrl: longcat.chat/api/v1/chat/completions -> api.longcat.chat/openai
  - authHeader: 'bearer' -> 'Authorization' + authPrefix: 'Bearer'

fix(combo): implement pinnedModel override in comboAgentMiddleware (#535)
  - Previously: pinnedModel was detected but body.model was never updated
  - Now: body = { ...body, model: pinnedModel } when context_cache_protection fires

fix(cli-tools): add OpenCode config save to guide-settings endpoint (#524)
  - Added 'opencode' case to switch in guide-settings/[toolId]/route.ts
  - saveOpenCodeConfig(): XDG_CONFIG_HOME aware, writes [provider.omniroute] TOML block
2026-03-22 13:31:56 -03:00
diegosouzapw 1ecc1908c7 chore(3.0.0-rc.1): bump version to 3.0.0-rc.1, close resolved issues, update CHANGELOG
Build Electron Desktop App / Validate version (push) Failing after 30s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- package.json: 2.9.5 → 3.0.0-rc.1
- docs/openapi.yaml: version → 3.0.0-rc.1
- CHANGELOG.md: add [3.0.0-rc.1] section with all batch1-3 fixes
- scripts/check-docs-sync.mjs: isSemver now accepts pre-release versions (X.Y.Z-prerelease.N)

Closed issues: #489, #492, #510, #513, #520, #521, #522, #525, #527, #532
RC versioning: rc.1 → rc.2 → rc.N on each VPS deploy until v3.0.0 is approved
2026-03-22 12:25:30 -03:00
diegosouzapw 6a2c7b467d fix(3.0.0-rc/batch3): convert tool_result blocks to text to stop Codex loop (#527)
fix(chat): convert tool_result content blocks to [Tool Result: id] text (#527)
  - Previously, tool_result blocks in user messages were silently dropped
  - This caused an infinite loop when Claude Code + superpowers routed to Codex:
    Codex never received the tool response and kept re-requesting the tool
  - Now: tool_result → text block '[Tool Result: {id}]\n{content}'
  - Handles string, array-of-text, and JSON-serialized content types

docs(issues): add Turbopack postinstall workaround on #509 and #508
docs(issues): note that #464 (API key provisioning) is on the v3.0 roadmap
2026-03-22 11:47:39 -03:00
diegosouzapw 0acef57865 fix(3.0.0-rc/batch2): resolve issues #510, #492, and improve #520, #529
fix(cli): normalize MSYS2/Git-Bash paths in cliRuntime.ts (#510)
  - Add normalizeMsys2Path() helper: /c/Program Files/... → C:\Program Files\...
  - Apply to both Windows 'where' and Unix 'command -v' path resolution
  - Fixes 'CLI not detected' on Windows when running Git Bash / MSYS2

fix(cli-launcher): detect mise/nvm on server.js not found error (#492)
  - Show targeted fix instructions based on which Node manager is in use
  - mise users: told to use npx or mise exec
  - nvm users: reminded to nvm use --lts before reinstalling

docs(issues): add pnpm bindings workaround comment (#520)
docs(issues): note OpenCode/Lobehub icons coming in v3.0.0 (#529)
2026-03-22 11:41:04 -03:00
diegosouzapw 43046ee649 fix(3.0.0-rc/batch1): resolve issues #521, #522, #525, #532, #489
fix(login): redirect to /dashboard/onboarding when API returns needsSetup:true (#521)
  - Handle the case where user skips password setup and lands on login
  - Instead of showing a cryptic error, redirect to onboarding flow

fix(api-manager): replace useless 'copy masked key' button with lock tooltip (#522)
  - Copying a masked key (sk-proj123****abcd) is misleading and useless
  - Show a lock icon on hover explaining key is only available at creation time
  - Add i18n key 'keyOnlyAvailableAtCreation'

fix(opencode-go): use zen/v1 for API key validation, not zen/go/v1 (#532)
  - Added testKeyBaseUrl field to RegistryEntry interface
  - opencode-go: testKeyBaseUrl → zen/v1 (same key authenticates both tiers)
  - validation.ts: resolveBaseUrl for key testing now prefers testKeyBaseUrl

fix(antigravity): return structured 422 error when projectId is missing (#489)
  - Instead of throwing (crash), executor returns an OpenAI-format error JSON
  - Client receives message with instruction to reconnect OAuth
  - Prevents opaque 500 errors in the proxy logs

chore: close #525 (OmniRoute = 9router — same project, different name)
docs: add Docker password reset comment on #513 with INITIAL_PASSWORD workaround
2026-03-22 11:31:34 -03:00
21 changed files with 8099 additions and 115 deletions
+46
View File
@@ -4,6 +4,52 @@
---
## [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
- **#536** — LongCat AI key validation: fixed baseUrl (`api.longcat.chat/openai`) and authHeader (`Authorization: Bearer`)
- **#535** — Pinned model override: `body.model` is now set to `pinnedModel` when context-cache protection detects a pinned model
- **#524** — OpenCode config now saved correctly: added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML)
---
## [3.0.0-rc.1] - 2026-03-22
### 🔧 Bug Fixes
- **#521** — Login no longer gets stuck after skipping password setup (redirects to onboarding)
- **#522** — API Manager: Removed misleading "Copy masked key" button (replaced with lock icon tooltip)
- **#527** — Claude Code + Codex superpowers loop: `tool_result` blocks now converted to text instead of dropped
- **#532** — OpenCode GO API key validation now uses the correct `zen/v1` endpoint (`testKeyBaseUrl`)
- **#489** — Antigravity: missing `googleProjectId` returns structured 422 error with reconnect guidance
- **#510** — Windows: MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...`
- **#492** — `omniroute` CLI now detects `mise`/`nvm` when `app/server.js` is missing and shows targeted fix
### 📖 Documentation
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` documented
### ✅ Closed Issues
#489, #492, #510, #513, #520, #521, #522, #525, #527, #532
---
## [2.9.5] — 2026-03-22
> Sprint: New OpenCode providers, embedding credentials fix, CLI masked key bug, CACHE_TAG_PATTERN fix.
+21 -2
View File
@@ -189,8 +189,27 @@ const serverJs = join(APP_DIR, "server.js");
if (!existsSync(serverJs)) {
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
console.error(" This usually means the package was not built correctly.");
console.error(" Try reinstalling: npm install -g omniroute");
console.error(" The package may not have been built correctly.");
console.error("");
// (#492) Detect common non-standard Node managers that cause this issue
const nodeExec = process.execPath || "";
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
if (isMise) {
console.error(
" \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`,"
);
console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)");
console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m");
} else if (isNvm) {
console.error(
" \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:"
);
console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m");
} else {
console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)");
console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m");
}
process.exit(1);
}
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.9.5
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,
+10 -2
View File
@@ -47,6 +47,8 @@ export interface RegistryEntry {
executor: string;
baseUrl?: string;
baseUrls?: string[];
/** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */
testKeyBaseUrl?: string;
responsesBaseUrl?: string;
urlSuffix?: string;
urlBuilder?: (base: string, model: string, stream: boolean) => string;
@@ -501,6 +503,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
format: "openai",
executor: "opencode",
baseUrl: "https://opencode.ai/zen/go/v1",
// (#532) Key validation must hit the main zen endpoint (same key works for both tiers)
testKeyBaseUrl: "https://opencode.ai/zen/v1",
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer",
@@ -1201,9 +1205,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "lc",
format: "openai",
executor: "default",
baseUrl: "https://longcat.chat/api/v1/chat/completions",
// (#536) Correct OpenAI-compatible base URL — was longcat.chat/api/v1/chat/completions
// which is the chat endpoint directly, not the base. Key validation and routing must
// use https://api.longcat.chat/openai which resolves /v1/models and /v1/chat/completions
baseUrl: "https://api.longcat.chat/openai",
authType: "apikey",
authHeader: "bearer",
authHeader: "Authorization",
authPrefix: "Bearer",
// Free tier: 50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — 100% free while public beta
models: [
{ id: "LongCat-Flash-Lite", name: "LongCat Flash-Lite (50M tok/day 🆓)" },
+20 -4
View File
@@ -44,12 +44,28 @@ export class AntigravityExecutor extends BaseExecutor {
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
const projectId =
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
allowBodyProjectOverride && bodyProjectId
? bodyProjectId
: credentialsProjectId || bodyProjectId;
if (!projectId) {
throw new Error(
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
);
// (#489) Return a structured error instead of throwing — gives the client a clear signal
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
const errorMsg =
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project.";
const errorBody = {
error: {
message: errorMsg,
type: "oauth_missing_project_id",
code: "missing_project_id",
},
};
const resp = new Response(JSON.stringify(errorBody), {
status: 422,
headers: { "Content-Type": "application/json" },
});
// Returning a Response object signals the executor to stop and forward it
return resp as unknown as never;
}
// Fix contents for Claude models via Antigravity
+21
View File
@@ -308,6 +308,27 @@ export async function handleChatCore({
}
return [];
}
// (#527) tool_result → convert to text instead of dropping.
// When Claude Code + superpowers routes through Codex, it sends tool_result
// blocks in user messages. Silently dropping them causes Codex to loop
// because it never receives the tool response and keeps re-requesting it.
if (block.type === "tool_result") {
const toolId = block.tool_use_id ?? block.id ?? "unknown";
const resultContent = block.content ?? block.text ?? block.output ?? "";
const resultText =
typeof resultContent === "string"
? resultContent
: Array.isArray(resultContent)
? resultContent
.filter((c: Record<string, unknown>) => c.type === "text")
.map((c: Record<string, unknown>) => c.text)
.join("\n")
: JSON.stringify(resultContent);
if (resultText.length > 0) {
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
}
return [];
}
// Unknown types: drop silently
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
return [];
+5 -1
View File
@@ -169,7 +169,11 @@ export function applyComboAgentMiddleware(
if (comboConfig.context_cache_protection) {
pinnedModel = extractPinnedModel(messages);
if (pinnedModel) {
// Model is pinned — caller should override model selection
// (#535) Model is pinned via <omniModel> tag — override body.model so the combo
// router uses exactly this model instead of picking a different one. Without this,
// the extracted pinnedModel is returned but body.model is unchanged, breaking
// context cache sessions by sending subsequent turns to a different model.
body = { ...body, model: pinnedModel };
}
}
+7525 -40
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.9.5",
"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 -1
View File
@@ -48,7 +48,8 @@ function extractChangelogSections(content) {
}
function isSemver(value) {
return /^\d+\.\d+\.\d+$/.test(value);
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
}
let hasFailure = false;
@@ -523,15 +523,12 @@ export default function ApiManagerPageClient() {
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all shrink-0"
title={t("copyMaskedKey")}
<span
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
title={t("keyOnlyAvailableAtCreation")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
<span className="material-symbols-outlined text-[14px]">lock</span>
</span>
</div>
<div className="col-span-2 flex items-center">
<div className="flex flex-col items-start gap-1">
@@ -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>
@@ -39,6 +39,10 @@ export async function POST(request, { params }) {
switch (toolId) {
case "continue":
return await saveContinueConfig({ baseUrl, apiKey, model });
case "opencode":
// (#524) OpenCode config was never saved because only 'continue' was handled here.
// opencode reads ~/.config/opencode/config.toml — write the OmniRoute settings there.
return await saveOpenCodeConfig({ baseUrl, apiKey, model });
default:
return NextResponse.json(
{ error: `Direct config save not supported for: ${toolId}` },
@@ -125,3 +129,56 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
configPath,
});
}
/**
* Save OpenCode config to ~/.config/opencode/config.toml (XDG_CONFIG_HOME aware).
* (#524) OpenCode was silently failing because this handler was missing.
*/
async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
const { apiPort } = getRuntimePorts();
// Honour $XDG_CONFIG_HOME if set, otherwise use ~/.config per the XDG Base Directory spec
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const configPath = path.join(xdgConfigHome, "opencode", "config.toml");
const configDir = path.dirname(configPath);
// Ensure ~/.config/opencode/ exists
await fs.mkdir(configDir, { recursive: true });
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
// Read existing TOML to preserve any user settings outside our block
let existingContent = "";
try {
existingContent = await fs.readFile(configPath, "utf-8");
} catch {
// File doesn't exist yet — start fresh
}
// Build the OmniRoute TOML block.
// opencode config.toml uses the [provider.X] table format.
void apiPort; // available for future port-based detection
const omniBlock = `
# OmniRoute managed updated automatically by OmniRoute CLI Tools
[provider.omniroute]
api_key = "${apiKey || "sk_omniroute"}"
base_url = "${normalizedBaseUrl}"
model = "${model}"
`;
// Remove old OmniRoute-managed block (if any) then append fresh one
const cleanedContent = existingContent
.replace(/\n?# OmniRoute managed[\s\S]*?(?=\n\[|$)/, "")
.trimEnd();
const newContent = (cleanedContent ? cleanedContent + "\n" : "") + omniBlock;
await fs.writeFile(configPath, newContent, "utf-8");
return NextResponse.json({
success: true,
message: `OpenCode config saved to ${configPath}`,
configPath,
});
}
+11
View File
@@ -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",
});
}
+5
View File
@@ -69,6 +69,11 @@ export default function LoginPage() {
router.refresh();
} else {
const data = await res.json();
// (#521) If no password is set, redirect to onboarding instead of showing an error
if (data.needsSetup) {
router.push("/dashboard/onboarding");
return;
}
setError(data.error || t("invalidPassword"));
}
} catch (err) {
+1
View File
@@ -281,6 +281,7 @@
"failedUpdatePermissionsRetry": "Failed to update permissions. Please try again.",
"unknownProvider": "unknown",
"copyMaskedKey": "Copy masked key",
"keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key",
"modelsCount": "{count, plural, one {# model} other {# models}}",
"lastUsedOn": "Last: {date}",
"editPermissions": "Edit permissions",
+12
View File
@@ -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, {
+6 -1
View File
@@ -610,7 +610,12 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
}
const modelId = entry.models?.[0]?.id || null;
const baseUrl = resolveBaseUrl(entry, providerSpecificData);
// (#532) Use testKeyBaseUrl if defined — some providers validate keys on a different endpoint
// than where requests are sent (e.g. opencode-go validates on zen/v1, not zen/go/v1)
const validationEntry = entry.testKeyBaseUrl
? { ...entry, baseUrl: entry.testKeyBaseUrl }
: entry;
const baseUrl = resolveBaseUrl(validationEntry, providerSpecificData);
try {
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
+167
View 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 };
+20 -2
View File
@@ -105,6 +105,24 @@ const CLI_TOOLS: Record<string, any> = {
const isWindows = () => process.platform === "win32";
/**
* (#510) Normalize MSYS2/Git-Bash style paths to Windows-native paths.
* On Windows with Git Bash, 'where claude' may return '/c/Program Files/...'
* instead of 'C:\\Program Files\\...'. Convert these so the path is usable
* by Node's fs and child_process modules.
*/
const normalizeMsys2Path = (p: string): string => {
if (!p || !isWindows()) return p;
// Match /letter/rest-of-path — MSYS2 POSIX-style drive mount
const msys2Match = p.match(/^\/([a-zA-Z])\/(.+)$/);
if (msys2Match) {
const drive = msys2Match[1].toUpperCase();
const rest = msys2Match[2].replace(/\//g, "\\");
return `${drive}:\\${rest}`;
}
return p;
};
const parseBoolean = (value: unknown, defaultValue = true) => {
if (value == null || value === "") return defaultValue;
return !FALSE_VALUES.has(String(value).trim().toLowerCase());
@@ -256,7 +274,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
const first =
located.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.map((line) => normalizeMsys2Path(line.trim()))
.find(Boolean) || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
}
@@ -271,7 +289,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
const first =
located.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.map((line) => normalizeMsys2Path(line.trim()))
.find(Boolean) || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
};
+145
View 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;
}
}