Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ad7c6c2e | |||
| afc9362ca5 | |||
| f6b125e8c2 | |||
| 5df3c22be8 | |||
| 11a0df5443 | |||
| e27a2a0d55 | |||
| dc8abe60ee | |||
| afe2ab37e4 | |||
| f7bd99f965 | |||
| f5238944b4 | |||
| c7ae9c30c2 | |||
| 82f7a12a46 | |||
| f494a8531b | |||
| 36ed0499db | |||
| 46cff2200d | |||
| 5ea6ad4a9e | |||
| 6cad4fae8e | |||
| 8df24c855b | |||
| f25882c0e9 | |||
| be6c769192 | |||
| a4276444b5 | |||
| 0af27b8d8a |
@@ -11,6 +11,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [2.3.1] — 2026-03-11
|
||||
|
||||
> ### TypeScript Fixes & UI Polish
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **OAuth Modal displayed Portuguese text regardless of language setting (#314)** — Two hardcoded PT-BR strings in `OAuthModal.tsx` (remote-access info banner and `redirect_uri_mismatch` error message) are now in English for all users (PR #325).
|
||||
- **TypeScript errors in Kimi usage parser (`usage.ts`)** — `dataObj.five_hour`, `dataObj.seven_day`, and `dataObj.user` were typed as `unknown`. Wrapped with `toRecord()` before passing to typed functions — fixes 6 compiler errors on lines 921–948.
|
||||
- **`await` missing on `getSettings()` in `instrumentation.ts` (#316 follow-up)** — `getSettings()` is declared `async`; calling it without `await` made `settings` a `Promise` causing 4 TS errors when accessing `settings.modelAliases`.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] — 2026-03-11
|
||||
|
||||
> ### Bug Fixes
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Custom Model Alias (Pattern→Target) ignored during routing (#315)** — `chatCore.ts` now calls `resolveModelAlias()` before the routing format lookup so aliases configured in Settings → Model Aliases → Pattern→Target are applied correctly (PR #317).
|
||||
- **Custom Model Aliases lost after server restart (#316)** — Next.js startup hook (`src/instrumentation.ts`) now restores custom aliases from `settings.modelAliases` in the DB at boot, preventing the in-memory state from resetting to empty on restart (PR #317).
|
||||
- **`better-sqlite3` postinstall rebuild fails silently on macOS ARM (#312)** — Replace unreliable `process.dlopen()` detection with explicit `process.platform`/`process.arch` comparison. Rebuild now fail-fasts with a clear error on non-linux-x64 platforms (PR #313 by @ardaaltinors).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.9] — 2026-03-11
|
||||
|
||||
> ### Features, Bug Fixes & Dependency Updates
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Edit custom model endpoints (#307)** — Provider detail page now shows per-row **Edit / Save / Cancel** controls for custom models. Changes to `apiFormat` and `supportedEndpoints` are now persisted via the new `PUT /api/provider-models` endpoint instead of resetting on navigation (PR #307 by @hijak).
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **`@swc/helpers` MODULE_NOT_FOUND on startup (#306)** — Added `@swc/helpers@0.5.19` as an explicit `dependency` and `override` in `package.json`. Global npm install (`npm install -g omniroute`) now reliably includes this transitive dependency on all platforms including Windows (PR #308).
|
||||
- **Claude quota display inverted (#299)** — Claude Code's OAuth API returns `utilization` as _percent used_, not percent remaining. The quota bar was backwards: 87% used on Claude.ai = 87% "remaining" (green) in OmniRoute. Fixed `open-sse/services/usage.ts`: `remaining = 100 - utilization` (PR #309).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.8] — 2026-03-11
|
||||
|
||||
> ### Bug Fixes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Docker healthcheck wrong endpoint (#296)** — `scripts/healthcheck.mjs` now queries `/api/monitoring/health` instead of `/api/settings`. Aligns the healthcheck with all other health monitoring components (PR #301).
|
||||
- **429 causes endless queue / requests hang forever (#297)** — Added `maxWait=120000` (2 min) to all Bottleneck instances. When all provider quotas are exhausted, requests now fail-fast with a clean error instead of queueing indefinitely. Configurable via `RATE_LIMIT_MAX_WAIT_MS` env var (PR #302).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.7] — 2026-03-10
|
||||
|
||||
> ### Bug Fixes & Dependency Updates
|
||||
|
||||
@@ -193,6 +193,39 @@ if (!existsSync(serverJs)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Pre-flight: verify better-sqlite3 native binary ───────
|
||||
// The published binary targets linux-x64. Check both platform match AND
|
||||
// dlopen — on macOS, dlopen alone may succeed on an incompatible binary
|
||||
// (false positive), so we check platform first as the primary signal.
|
||||
const sqliteBinary = join(
|
||||
APP_DIR,
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (existsSync(sqliteBinary)) {
|
||||
let compatible = false;
|
||||
try {
|
||||
process.dlopen({ exports: {} }, sqliteBinary);
|
||||
compatible = true;
|
||||
} catch {
|
||||
// dlopen failed — definitely incompatible
|
||||
}
|
||||
|
||||
if (!compatible) {
|
||||
console.error(
|
||||
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
|
||||
);
|
||||
console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`);
|
||||
if (platform() === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start server ───────────────────────────────────────────
|
||||
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.2.7
|
||||
version: 2.3.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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.ts";
|
||||
import { createRequestLogger } from "../utils/requestLogger.ts";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||
import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.ts";
|
||||
@@ -105,8 +106,13 @@ export async function handleChatCore({
|
||||
// Detect source format and get target format
|
||||
// Model-specific targetFormat takes priority over provider default
|
||||
|
||||
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315)
|
||||
// Custom aliases take priority over built-in and must be resolved here so the
|
||||
// downstream getModelTargetFormat() lookup uses the correct, aliased model ID.
|
||||
const resolvedModel = resolveModelAlias(model);
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
|
||||
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
|
||||
|
||||
@@ -59,6 +59,11 @@ const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
|
||||
// Track initialization
|
||||
let initialized = false;
|
||||
|
||||
// Max time (ms) a job can wait in queue before failing with a timeout error.
|
||||
// Prevents infinite queuing when all providers are exhausted after a 429.
|
||||
// Configurable via RATE_LIMIT_MAX_WAIT_MS env var (default: 2 minutes).
|
||||
const MAX_WAIT_MS = parseInt(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000", 10);
|
||||
|
||||
// Default conservative settings (before we learn from headers)
|
||||
const DEFAULT_SETTINGS = {
|
||||
maxConcurrent: 10,
|
||||
@@ -66,6 +71,7 @@ const DEFAULT_SETTINGS = {
|
||||
reservoir: null, // No initial reservoir — unlimited until we learn
|
||||
reservoirRefreshAmount: null,
|
||||
reservoirRefreshInterval: null,
|
||||
maxWait: MAX_WAIT_MS, // Fail-fast: don't queue forever on 429 exhaustion
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,6 +117,7 @@ export async function initializeRateLimits() {
|
||||
reservoir: rpm,
|
||||
reservoirRefreshAmount: rpm,
|
||||
reservoirRefreshInterval: 60 * 1000,
|
||||
maxWait: MAX_WAIT_MS,
|
||||
id: key,
|
||||
})
|
||||
);
|
||||
@@ -135,6 +142,7 @@ export async function initializeRateLimits() {
|
||||
reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshInterval: 60 * 1000, // Refresh every minute
|
||||
maxWait: MAX_WAIT_MS,
|
||||
id: key,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -488,13 +488,14 @@ async function getClaudeUsage(accessToken) {
|
||||
const data = await oauthResponse.json();
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// utilization = percentage REMAINING (e.g., 90 means 90% remaining, 10% used)
|
||||
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
|
||||
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const remaining = safePercentage(window.utilization) as number;
|
||||
const used = 100 - remaining;
|
||||
const used = safePercentage(window.utilization) as number; // utilization = % used
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
@@ -917,12 +918,12 @@ async function getKimiUsage(accessToken) {
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(dataObj.five_hour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(dataObj.five_hour);
|
||||
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
||||
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
||||
}
|
||||
|
||||
if (hasUtilization(dataObj.seven_day)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(dataObj.seven_day);
|
||||
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
||||
}
|
||||
|
||||
// Check for model-specific quotas
|
||||
@@ -935,7 +936,8 @@ async function getKimiUsage(accessToken) {
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
const membershipLevel = dataObj.user?.membership?.level;
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
@@ -944,7 +946,8 @@ async function getKimiUsage(accessToken) {
|
||||
}
|
||||
|
||||
// No quota data in response
|
||||
const membershipLevel = dataObj.user?.membership?.level;
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.7",
|
||||
"version": "2.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.2.7",
|
||||
"version": "2.3.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@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",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.7",
|
||||
"version": "2.3.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": {
|
||||
@@ -109,7 +109,8 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.10",
|
||||
"@swc/helpers": "0.5.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -142,6 +143,6 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@swc/helpers": "^0.5.19"
|
||||
"@swc/helpers": "0.5.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
/**
|
||||
* Docker healthcheck script for OmniRoute.
|
||||
* Checks the /api/settings endpoint on the dashboard port.
|
||||
* Checks the /api/monitoring/health 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`)
|
||||
fetch(`http://127.0.0.1:${port}/api/monitoring/health`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
})
|
||||
|
||||
+37
-13
@@ -30,14 +30,20 @@ if (!existsSync(appNodeModules)) {
|
||||
|
||||
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
|
||||
// The published binary is compiled for linux-x64.
|
||||
// On any other platform/arch, we must rebuild — dlopen alone is unreliable
|
||||
// because macOS may load an incompatible binary without throwing.
|
||||
const BUILD_PLATFORM = "linux";
|
||||
const BUILD_ARCH = "x64";
|
||||
const needsRebuild = process.platform !== BUILD_PLATFORM || process.arch !== BUILD_ARCH;
|
||||
|
||||
if (!needsRebuild) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Same platform but binary still incompatible (e.g. Node.js ABI mismatch) — rebuild
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
|
||||
@@ -48,10 +54,28 @@ try {
|
||||
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
|
||||
console.error(" ❌ Failed to rebuild better-sqlite3 automatically.");
|
||||
console.error(" You can fix this manually by running:");
|
||||
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify the rebuilt binary actually loads
|
||||
try {
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
} catch {
|
||||
console.error(" ❌ Rebuild completed but binary is still incompatible.");
|
||||
console.error(" Try manually:");
|
||||
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1341,6 +1341,7 @@ PassthroughModelRow.propTypes = {
|
||||
|
||||
function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [newModelId, setNewModelId] = useState("");
|
||||
const [newModelName, setNewModelName] = useState("");
|
||||
@@ -1348,6 +1349,10 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const [newEndpoints, setNewEndpoints] = useState(["chat"]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
||||
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
|
||||
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
|
||||
const [savingModelId, setSavingModelId] = useState<string | null>(null);
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
try {
|
||||
@@ -1410,6 +1415,61 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (model) => {
|
||||
setEditingModelId(model.id);
|
||||
setEditingApiFormat(model.apiFormat || "chat-completions");
|
||||
setEditingEndpoints(
|
||||
Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length
|
||||
? model.supportedEndpoints
|
||||
: ["chat"]
|
||||
);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingModelId(null);
|
||||
setEditingApiFormat("chat-completions");
|
||||
setEditingEndpoints(["chat"]);
|
||||
setSavingModelId(null);
|
||||
};
|
||||
|
||||
const saveEdit = async (modelId) => {
|
||||
if (!editingModelId || editingModelId !== modelId) return;
|
||||
if (!editingEndpoints.length) {
|
||||
notify.error("Select at least one supported endpoint");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingModelId(modelId);
|
||||
try {
|
||||
const model = customModels.find((m) => m.id === modelId);
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: model?.name || modelId,
|
||||
source: model?.source || "manual",
|
||||
apiFormat: editingApiFormat,
|
||||
supportedEndpoints: editingEndpoints,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save model endpoint settings");
|
||||
}
|
||||
|
||||
await fetchCustomModels();
|
||||
notify.success("Saved model endpoint settings");
|
||||
cancelEdit();
|
||||
} catch (e) {
|
||||
console.error("Failed to save custom model:", e);
|
||||
notify.error("Failed to save model endpoint settings");
|
||||
} finally {
|
||||
setSavingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
@@ -1554,14 +1614,82 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingModelId === model.id && (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-sidebar/40">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div className="w-44">
|
||||
<label className="text-xs text-text-muted mb-1 block">API Format</label>
|
||||
<select
|
||||
value={editingApiFormat}
|
||||
onChange={(e) => setEditingApiFormat(e.target.value)}
|
||||
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="chat-completions">Chat Completions</option>
|
||||
<option value="responses">Responses API</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{["chat", "embeddings", "images", "audio"].map((ep) => (
|
||||
<label key={ep} className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingEndpoints.includes(ep)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setEditingEndpoints((prev) => (prev.includes(ep) ? prev : [...prev, ep]));
|
||||
} else {
|
||||
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
|
||||
}
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ep === "chat"
|
||||
? "💬 Chat"
|
||||
: ep === "embeddings"
|
||||
? "📐 Embeddings"
|
||||
: ep === "images"
|
||||
? "🖼️ Images"
|
||||
: "🔊 Audio"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEdit(model.id)}
|
||||
disabled={savingModelId === model.id}
|
||||
>
|
||||
{savingModelId === model.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => beginEdit(model)}
|
||||
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(model.id)}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title={t("removeCustomModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(model.id)}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title={t("removeCustomModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { providerModelMutationSchema } from "@/shared/validation/schemas";
|
||||
@@ -84,6 +85,59 @@ export async function POST(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/provider-models
|
||||
* Body: { provider, modelId, modelName?, apiFormat?, supportedEndpoints? }
|
||||
*/
|
||||
export async function PUT(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validateBody(providerModelMutationSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, modelId, modelName, apiFormat, supportedEndpoints } = validation.data;
|
||||
|
||||
const model = await updateCustomModel(provider, modelId, {
|
||||
modelName,
|
||||
apiFormat,
|
||||
supportedEndpoints,
|
||||
});
|
||||
|
||||
if (!model) {
|
||||
return Response.json(
|
||||
{ error: { message: "Model not found", type: "not_found" } },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
console.error("Error updating provider model:", error);
|
||||
return Response.json(
|
||||
{ error: { message: "Failed to update provider model", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/provider-models?provider=<id>&model=<modelId>
|
||||
*/
|
||||
|
||||
@@ -46,6 +46,30 @@ export async function register() {
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
|
||||
// Model aliases: restore persisted custom aliases into in-memory state (#316)
|
||||
// Custom aliases are saved to settings.modelAliases on PUT /api/settings/model-aliases
|
||||
// but the in-memory _customAliases resets to {} on every restart — load them here.
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
|
||||
const settings = await getSettings();
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
? JSON.parse(settings.modelAliases)
|
||||
: settings.modelAliases;
|
||||
if (aliases && typeof aliases === "object") {
|
||||
setCustomAliases(aliases);
|
||||
console.log(
|
||||
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not restore model aliases:", msg);
|
||||
}
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
|
||||
@@ -177,3 +177,38 @@ export async function removeCustomModel(providerId, modelId) {
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateCustomModel(providerId, modelId, updates = {}) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
|
||||
.get(providerId);
|
||||
if (!row) return null;
|
||||
|
||||
const value = getKeyValue(row).value;
|
||||
if (!value) return null;
|
||||
|
||||
const models = JSON.parse(value);
|
||||
const index = models.findIndex((m) => m.id === modelId);
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
const next = {
|
||||
...current,
|
||||
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
|
||||
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
|
||||
...(updates.supportedEndpoints !== undefined
|
||||
? { supportedEndpoints: updates.supportedEndpoints }
|
||||
: {}),
|
||||
};
|
||||
|
||||
models[index] = next;
|
||||
|
||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
||||
JSON.stringify(models),
|
||||
providerId
|
||||
);
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
} from "./db/models";
|
||||
|
||||
export {
|
||||
|
||||
@@ -98,12 +98,12 @@ export default function OAuthModal({
|
||||
GOOGLE_OAUTH_PROVIDERS.has(provider)
|
||||
) {
|
||||
setError(
|
||||
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
|
||||
"Para uso remoto, configure suas próprias credenciais OAuth nas variáveis de ambiente: " +
|
||||
"redirect_uri_mismatch: The default Google OAuth credentials only work on localhost. " +
|
||||
"For remote use, configure your own OAuth credentials via environment variables: " +
|
||||
(provider === "antigravity"
|
||||
? "ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
: "GEMINI_OAUTH_CLIENT_ID e GEMINI_OAUTH_CLIENT_SECRET") +
|
||||
". Veja o README, seção 'OAuth em Servidor Remoto'."
|
||||
? "ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
: "GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET") +
|
||||
". See the README section 'OAuth on a Remote Server'."
|
||||
);
|
||||
} else {
|
||||
setError(err.message);
|
||||
@@ -512,17 +512,17 @@ export default function OAuthModal({
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>Acesso remoto + Google OAuth:</strong> As credenciais padrão só aceitam
|
||||
redirect para <code>localhost</code>. Após autorizar, o browser tentará abrir
|
||||
<code>localhost</code> — copie essa URL completa e cole abaixo. Para uso
|
||||
totalmente remoto sem esse passo manual,{" "}
|
||||
<strong>Remote access + Google OAuth:</strong> The default credentials only accept
|
||||
redirects to <code>localhost</code>. After authorizing, your browser will try to
|
||||
open <code>localhost</code> — copy that full URL and paste it below. For fully
|
||||
remote use without this manual step,{" "}
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-em-servidor-remoto"
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
configure suas próprias credenciais OAuth
|
||||
configure your own OAuth credentials
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user