Compare commits

..

33 Commits

Author SHA1 Message Date
diegosouzapw 5d7772ecb0 chore(release): v2.6.2 — fix all module hashing, Anthropic tools filter, custom endpoint paths, Alibaba Cloud provider
Build Electron Desktop App / Validate version (push) Failing after 33s
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
2026-03-16 09:53:32 -03:00
Diego Rodrigues de Sa e Souza 56ce618eca Merge pull request #400 from Regis-RCR/feat/custom-endpoint-paths
feat(api): custom endpoint paths for compatible provider nodes
2026-03-16 09:46:22 -03:00
Diego Rodrigues de Sa e Souza b0381c7542 Merge pull request #397 from xandr0s/fix/tools-filter-claude-format
fix(chat): handle Anthropic-format tools in empty-name filter (#346)
2026-03-16 09:40:39 -03:00
Diego Rodrigues de Sa e Souza b328ed5fa9 Merge pull request #403 from diegosouzapw/fix/issue-396-398-hashed-externals-all-packages
fix(build): extend externals hash-strip to cover ALL Turbopack-hashed packages (#396, #398)
2026-03-16 09:37:05 -03:00
diegosouzapw 7d72f1711f fix(build): extend externals hash-strip to cover ALL packages, not just better-sqlite3 (#396, #398)
Turbopack in Next.js 16 hashes ALL serverExternalPackages (not just better-sqlite3),
emitting require() calls like 'zod-dcb22c6336e0bc69', 'pino-28069d5257187539' etc.
that don't exist in node_modules.

Changes:
- next.config.mjs: Replace single-package check with a HASH_PATTERN regex
  that strips '<name>-<16hexchars>' suffix for any externalized package.
  Also adds KNOWN_EXTERNALS set for exact-name matching.
- scripts/prepublish.mjs: Add NEXT_PRIVATE_BUILD_WORKER=0 env to reinforce
  webpack mode. Add post-build scan that reports hashed refs so CI is visible.

Closes #396, addresses #398
2026-03-16 09:34:34 -03:00
Regis cd05e03d63 fix(review): simplify cascade logic and add ARIA attributes
Address review feedback:
- Simplify providerSpecificData cascade for chatPath/modelsPath
  using `|| undefined` instead of conditional spreads (Gemini)
- Add aria-expanded, aria-controls, aria-hidden to Advanced
  Settings toggle buttons for accessibility (Copilot)
2026-03-16 11:29:06 +01:00
Regis e25029939d feat(api): add custom endpoint paths for compatible provider nodes
Allow provider_nodes to configure custom chat and models endpoint
paths via chatPath/modelsPath fields. This enables providers with
non-standard versioned APIs (e.g. /v4/chat/completions) to work
without embedding the version prefix in base_url.

- Add migration 003: chat_path and models_path columns
- Update Zod schemas (create, update, validate)
- Update CRUD in providers.ts (INSERT/UPDATE)
- Wire chatPath/modelsPath through API routes and providerSpecificData cascade
- Read chatPath in DefaultExecutor and BaseExecutor buildUrl()
- Use modelsPath in validate endpoint
- Add Advanced Settings UI section (collapsible) in create/edit modals
- Update base URL hint to reference Advanced Settings
- Add i18n keys across all 30 locales
- Add unit tests for buildUrl with custom paths

Backward compatible: NULL chatPath/modelsPath = default behavior.
2026-03-16 10:23:44 +01:00
Oleg Saprykin 53de27417d fix(chat): handle Anthropic-format tools in empty-name filter (#346)
The filter introduced in #346 only checked OpenAI-format tool names
(tool.function.name), silently dropping all tools when the request
arrives in Anthropic Messages API format (tool.name without .function).

This happens when LiteLLM proxies requests with anthropic/ model prefix —
it translates to Anthropic format before forwarding, so OmniRoute receives
Claude-format tools. The filter drops them all, causing Anthropic API to
return 400: 'tool_choice.any may only be specified while providing tools'.

Fix: check both formats with fn?.name ?? tool.name.
2026-03-16 11:37:40 +03:00
diegosouzapw 74d3374d5c chore(release): v2.6.1 — fix better-sqlite3 startup crash on npm global installs (#394)
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
2026-03-15 21:51:35 -03:00
Diego Rodrigues de Sa e Souza 3ae00bebe4 Merge pull request #395 from diegosouzapw/fix/issue-394-better-sqlite3-module-resolution
fix(build): force better-sqlite3 webpack external to prevent hash-based module name in instrumentation hook (#394)
2026-03-15 21:47:46 -03:00
diegosouzapw f9df72c4d7 fix(build): force better-sqlite3 webpack external to prevent hash-based module name in instrumentation hook (#394) 2026-03-15 21:45:19 -03:00
diegosouzapw d0fb4576a8 ci: add workflow_dispatch to npm-publish, fix version sync for manual triggers 2026-03-15 20:20:44 -03:00
Diego Rodrigues de Sa e Souza 0e4b0b3540 Merge pull request #393 from diegosouzapw/fix/issue-392-docker-workflow
fix: add workflow_dispatch to docker-publish, update action versions (#392)
2026-03-15 20:11:54 -03:00
diegosouzapw df1105d0c6 fix: add workflow_dispatch to docker-publish, update action versions (#392) 2026-03-15 20:06:49 -03:00
diegosouzapw 44478c36a3 chore(release): v2.6.0 — issue resolution sprint (#390 #340 #344 #377 #378 #337)
Build Electron Desktop App / Validate version (push) Failing after 35s
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
2026-03-15 19:15:38 -03:00
Diego Rodrigues de Sa e Souza fa267274b0 Merge pull request #386 from kfiramar/chore-test-script-loader-consistency
chore(tests): align targeted test runners
2026-03-15 19:08:47 -03:00
Diego Rodrigues de Sa e Souza 0db272946a Merge pull request #391 from diegosouzapw/fix/multi-issues-390-340-378
fix(media,auth,oauth): hide unconfigured local providers, round-robin improvement, OAuth popup fix
2026-03-15 19:08:45 -03:00
diegosouzapw 91015b6499 fix(media,auth,oauth): hide unconfigured local providers, improve round-robin, fix OAuth popup (#390 #340 #344) 2026-03-15 18:48:40 -03:00
diegosouzapw 2979a36a7c chore(release): v2.5.9 — codex passthrough + route validation + JWT persist
Build Electron Desktop App / Validate version (push) Failing after 33s
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
2026-03-15 15:46:06 -03:00
Diego Rodrigues de Sa e Souza 72f6d6b7b9 Merge pull request #388 from kfiramar/fix-route-validation-t06
fix(build,api): restore production build and validate route bodies
2026-03-15 15:43:32 -03:00
Diego Rodrigues de Sa e Souza d81a7bcedf Merge pull request #387 from kfiramar/feat-codex-native-responses-parity
All tests pass except pre-existing clearAccountError module resolution (dataPaths) which is unrelated to this PR. Merging codex native passthrough fix.
2026-03-15 15:43:11 -03:00
Kfir Amar 8fbbe8b82b Revert "fix(api): validate pricing sync and task routing routes"
This reverts commit 7c992ffd21.
2026-03-15 20:37:18 +02:00
Kfir Amar 271f5f9c64 Revert "fix(api): validate pricing sync and task routing routes"
This reverts commit fc2af8ba87.
2026-03-15 20:37:18 +02:00
Kfir Amar 7c992ffd21 fix(api): validate pricing sync and task routing routes 2026-03-15 20:30:00 +02:00
Kfir Amar fc2af8ba87 fix(api): validate pricing sync and task routing routes 2026-03-15 20:30:00 +02:00
Kfir Amar c8a539a6cb fix(review): surface secret fallback and tighten error typing 2026-03-15 20:25:12 +02:00
Kfir Amar b7cdaa662a fix(api): validate pricing sync and task routing routes 2026-03-15 20:25:12 +02:00
Kfir Amar 0a25930020 fix(mitm): use standalone-safe server entrypoint 2026-03-15 20:25:12 +02:00
Kfir Amar 8643f4015f fix(build): restore webpack production build 2026-03-15 20:25:11 +02:00
diegosouzapw 1854711aff fix(build): fix Next.js 16 Turbopack standalone build for npm publish
- instrumentation.ts: eval(require) → createRequire (banned in Turbopack edge runtime)
- mitm/manager.ts: static imports → lazy require getters to prevent Turbopack trace
- mitm/manager.stub.ts: build-time stub for turbopack.resolveAlias
- antigravity-mitm/route.ts: dynamic imports + nodejs runtime + remove use server
- next.config.mjs: turbopack.resolveAlias for mitm stub + expanded serverExternalPackages
- prepublish.mjs: remove --webpack flag (removed in Next.js 15+)
2026-03-15 15:18:00 -03:00
Kfir Amar b1c713de60 fix(codex): avoid mutating request body 2026-03-15 18:35:43 +02:00
Kfir Amar 0f13965391 chore(tests): align targeted test runners 2026-03-15 18:25:22 +02:00
Kfir Amar 8642e2b721 fix(codex): preserve native responses payloads 2026-03-15 18:25:22 +02:00
69 changed files with 1361 additions and 254 deletions
+21 -9
View File
@@ -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 }}
+13 -4
View File
@@ -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"
+70
View File
@@ -4,6 +4,76 @@
---
## [2.6.2] — 2026-03-16
> Sprint: module hashing fully fixed, 2 PRs merged (Anthropic tools filter + custom endpoint paths), Alibaba Cloud DashScope provider added, 3 stale issues closed.
### 🐛 Bug Fixes
- **fix(build)**: Extended webpack `externals` hash-strip to cover ALL `serverExternalPackages`, not just `better-sqlite3`. Next.js 16 Turbopack hashes `zod`, `pino`, and every other server-external package into names like `zod-dcb22c6336e0bc69` that don't exist in `node_modules` at runtime. A HASH_PATTERN regex catch-all now strips the 16-char suffix and falls back to the base package name. Also added `NEXT_PRIVATE_BUILD_WORKER=0` in `prepublish.mjs` to reinforce webpack mode, plus a post-build scan that reports any remaining hashed refs. (#396, #398, PR #403)
- **fix(chat)**: Anthropic-format tool names (`tool.name` without `.function` wrapper) were silently dropped by the empty-name filter introduced in #346. LiteLLM proxies requests with `anthropic/` prefix in Anthropic Messages API format, causing all tools to be filtered and Anthropic to return `400: tool_choice.any may only be specified while providing tools`. Fixed by falling back to `tool.name` when `tool.function.name` is absent. Added 8 regression unit tests. (PR #397)
### ✨ Features
- **feat(api)**: Custom endpoint paths for OpenAI-compatible provider nodes — configure `chatPath` and `modelsPath` per node (e.g. `/v4/chat/completions`) in the provider connection UI. Includes a DB migration (`003_provider_node_custom_paths.sql`) and URL path sanitization (no `..` traversal, must start with `/`). (PR #400)
- **feat(provider)**: Alibaba Cloud DashScope added as OpenAI-compatible provider. International endpoint: `dashscope-intl.aliyuncs.com/compatible-mode/v1`. 12 models: `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwen3-coder-plus/flash`, `qwq-plus`, `qwq-32b`, `qwen3-32b`, `qwen3-235b-a22b`. Auth: Bearer API key.
### 📋 Issues Closed
- **close #323**: Cline connection error `[object Object]` — fixed in v2.3.7; instructed user to upgrade from v2.2.9
- **close #337**: Kiro credit tracking — implemented in v2.5.5 (#381); pointed user to Dashboard → Usage
- **triage #402**: ARM64 macOS DMG damaged — requested macOS version, exact error, and advised `xattr -d com.apple.quarantine` workaround
---
## [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.
### 🐛 Bug Fixes
- **fix(codex)**: Preserve native Responses API passthrough for Codex clients — avoids unnecessary translation mutations (PR #387)
- **fix(api)**: Validate request bodies on pricing/sync and task-routing routes — prevents crashes from malformed inputs (PR #388)
- **fix(auth)**: JWT secrets persist across restarts via `src/lib/db/secrets.ts` — eliminates 401 errors after pm2 restart (PR #388)
---
## [2.5.8] - 2026-03-15
> Build fix: restore VPS connectivity broken by v2.5.7 incomplete publish.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.5.8
version: 2.6.2
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,
+88 -6
View File
@@ -4,9 +4,30 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
turbopack: {},
// Turbopack config: redirect native modules to stubs at build time
turbopack: {
resolveAlias: {
// Point mitm/manager to a stub during build (native child_process/fs can't be bundled)
"@/mitm/manager": "./src/mitm/manager.stub.ts",
},
},
output: "standalone",
serverExternalPackages: ["better-sqlite3", "zod"],
serverExternalPackages: [
"better-sqlite3",
"zod",
"child_process",
"fs",
"path",
"os",
"crypto",
"net",
"tls",
"http",
"https",
"stream",
"buffer",
"util",
],
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"],
typescript: {
@@ -16,19 +37,80 @@ const nextConfig = {
images: {
unoptimized: true,
},
// NEXT_PUBLIC_CLOUD_URL is set in .env — do NOT hardcode here (it overrides .env)
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
if (!isServer) {
if (isServer) {
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
//
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
// into a separate chunk and emits hashed require() calls such as:
// require('better-sqlite3-90e2652d1716b047')
// require('zod-dcb22c6336e0bc69')
// require('pino-28069d5257187539')
//
// These hashed names don't exist in node_modules and cause a 500 at
// startup on all npm global installs (issues #394, #396, #398).
//
// We use two strategies:
// 1. Exact-name externals for all known server-side packages.
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
// suffix and falls through to the real package name.
//
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
"zod",
"pino",
"pino-pretty",
"child_process",
"fs",
"path",
"os",
"crypto",
"net",
"tls",
"http",
"https",
"stream",
"buffer",
"util",
]);
const prev = config.externals ?? [];
const prevArr = Array.isArray(prev) ? prev : [prev];
config.externals = [
...prevArr,
({ request }, callback) => {
// Case 1: Exact known package — treat as external
if (KNOWN_EXTERNALS.has(request)) {
return callback(null, `commonjs ${request}`);
}
// Case 2: Hash-suffixed name — strip hash, use base name
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
// "zod-dcb22c6336e0bc69" → "zod"
const hashMatch = request?.match?.(HASH_PATTERN);
if (hashMatch) {
const baseName = hashMatch[1];
return callback(null, `commonjs ${baseName}`);
}
callback();
},
];
} else {
// Ignore native Node.js modules in browser bundle
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
child_process: false,
net: false,
tls: false,
crypto: false,
};
}
return config;
},
async rewrites() {
return [
{
+4 -4
View File
@@ -99,11 +99,11 @@ export class BaseExecutor {
void model;
void stream;
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl =
typeof credentials?.providerSpecificData?.baseUrl === "string"
? credentials.providerSpecificData.baseUrl
: "https://api.openai.com/v1";
const psd = credentials?.providerSpecificData;
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
if (customPath) return `${normalized}${customPath}`;
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
+14 -7
View File
@@ -106,8 +106,22 @@ export class CodexExecutor extends BaseExecutor {
* Transform request before sending - inject default instructions if missing
*/
transformRequest(model, body, stream, credentials) {
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
body.stream = true;
delete body._nativeCodexPassthrough;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
if (requestServiceTier) {
body.service_tier = requestServiceTier;
} else if (defaultFastServiceTierEnabled) {
body.service_tier = CODEX_FAST_WIRE_VALUE;
}
if (nativeCodexPassthrough) {
return body;
}
// If no instructions provided, inject default Codex instructions
if (!body.instructions || body.instructions.trim() === "") {
@@ -117,13 +131,6 @@ export class CodexExecutor extends BaseExecutor {
// Ensure store is false (Codex requirement)
body.store = false;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
if (requestServiceTier) {
body.service_tier = requestServiceTier;
} else if (defaultFastServiceTierEnabled) {
body.service_tier = CODEX_FAST_WIRE_VALUE;
}
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
+8 -3
View File
@@ -9,15 +9,20 @@ export class DefaultExecutor extends BaseExecutor {
buildUrl(model, stream, urlIndex = 0, credentials = null) {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
if (customPath) return `${normalized}${customPath}`;
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1";
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`;
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
return `${normalized}${customPath || "/messages"}`;
}
switch (this.provider) {
case "claude":
+75 -47
View File
@@ -42,6 +42,20 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
export function shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
endpointPath,
}: {
provider?: string | null;
sourceFormat?: string | null;
endpointPath?: string | null;
}): boolean {
if (provider !== "codex") return false;
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
return String(endpointPath || "").toLowerCase().endsWith("/responses");
}
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
@@ -103,6 +117,11 @@ export async function handleChatCore({
const sourceFormat = detectFormat(body);
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
const isResponsesEndpoint = endpointPath.endsWith("/responses");
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
endpointPath,
});
// Check for bypass patterns (warmup, skip) - return fake response
const bypassResponse = handleBypassRequest(body, model, userAgent);
@@ -164,55 +183,64 @@ export async function handleChatCore({
// Translate request (pass reqLogger for intermediate logging)
let translatedBody = body;
try {
// Issue #199: Disable tool name prefix when routing Claude-format requests
// to non-Claude backends (prefix causes tool name mismatches)
const claudeProviders = ["claude", "anthropic"];
if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) {
translatedBody = { ...translatedBody, _disableToolPrefix: true };
}
if (nativeCodexPassthrough) {
translatedBody = { ...body, _nativeCodexPassthrough: true };
log?.debug?.("FORMAT", "native codex passthrough enabled");
} else {
translatedBody = { ...body };
// ── #291: Strip empty name fields from messages/input items ──
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
// Clients like PocketPaw may forward empty name fields from assistant turns.
if (Array.isArray(body.messages)) {
body.messages = body.messages.map((msg: Record<string, unknown>) => {
if (msg.name === "") {
const { name: _n, ...rest } = msg;
return rest;
}
return msg;
});
}
if (Array.isArray(body.input)) {
body.input = body.input.map((item: Record<string, unknown>) => {
if (item.name === "") {
const { name: _n, ...rest } = item;
return rest;
}
return item;
});
}
// ── #346: Strip tools with empty function.name ──
// Claude Code sometimes forwards tool definitions with empty names, causing
// OpenAI-compatible upstream providers to reject with:
// "Invalid 'input[N].name': empty string. Expected minimum length 1."
if (Array.isArray(body.tools)) {
body.tools = body.tools.filter((tool: Record<string, unknown>) => {
const fn = tool.function as Record<string, unknown> | undefined;
return fn?.name && String(fn.name).trim().length > 0;
});
}
// Issue #199: Disable tool name prefix when routing Claude-format requests
// to non-Claude backends (prefix causes tool name mismatches)
const claudeProviders = ["claude", "anthropic"];
if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) {
translatedBody._disableToolPrefix = true;
}
translatedBody = translateRequest(
sourceFormat,
targetFormat,
model,
translatedBody,
stream,
credentials,
provider,
reqLogger
);
// ── #291: Strip empty name fields from messages/input items ──
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
// Clients like PocketPaw may forward empty name fields from assistant turns.
if (Array.isArray(translatedBody.messages)) {
translatedBody.messages = translatedBody.messages.map((msg: Record<string, unknown>) => {
if (msg.name === "") {
const { name: _n, ...rest } = msg;
return rest;
}
return msg;
});
}
if (Array.isArray(translatedBody.input)) {
translatedBody.input = translatedBody.input.map((item: Record<string, unknown>) => {
if (item.name === "") {
const { name: _n, ...rest } = item;
return rest;
}
return item;
});
}
// ── #346: Strip tools with empty name ──
// Claude Code sometimes forwards tool definitions with empty names, causing
// OpenAI-compatible upstream providers to reject with:
// "Invalid 'input[N].name': empty string. Expected minimum length 1."
// Handles both OpenAI format ({ function: { name } }) and Anthropic format ({ name }).
if (Array.isArray(translatedBody.tools)) {
translatedBody.tools = translatedBody.tools.filter((tool: Record<string, unknown>) => {
const fn = tool.function as Record<string, unknown> | undefined;
const name = fn?.name ?? tool.name;
return name && String(name).trim().length > 0;
});
}
translatedBody = translateRequest(
sourceFormat,
targetFormat,
model,
translatedBody,
stream,
credentials,
provider,
reqLogger
);
}
} catch (error) {
const parsedStatus = Number(error?.statusCode);
const statusCode =
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.5.8",
"version": "2.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.5.8",
"version": "2.6.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.5.8",
"version": "2.6.2",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -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",
+61 -2
View File
@@ -10,7 +10,16 @@
*/
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
cpSync,
rmSync,
writeFileSync,
readFileSync,
readdirSync,
statSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
@@ -34,7 +43,17 @@ execSync("npm install", { cwd: ROOT, stdio: "inherit" });
// ── Step 3: Build Next.js ──────────────────────────────────
console.log(" 🏗️ Building Next.js (standalone)...");
execSync("npx next build", { cwd: ROOT, stdio: "inherit" });
execSync("npx next build", {
cwd: ROOT,
stdio: "inherit",
env: {
...process.env,
// Force webpack codegen — Turbopack emits hashed require() calls for
// server external packages that break npm global installs (#394, #396, #398).
EXPERIMENTAL_TURBOPACK: "0",
NEXT_PRIVATE_BUILD_WORKER: "0",
},
});
// ── Step 4: Verify standalone output ───────────────────────
const standaloneDir = join(ROOT, ".next", "standalone");
@@ -47,6 +66,46 @@ if (!existsSync(serverJs)) {
}
// ── Step 5: Copy standalone output to app/ ─────────────────
// ── Step 4.5: Check build for hashed external references ──────────────────────
// Warn if Turbopack-style hash suffixes are found — they will be resolved at
// runtime by the externals patch in next.config.mjs, but log for visibility.
{
const HASH_RE = /require\(["']([\w@./-]+-[0-9a-f]{16})["']\)/;
const scanDir = (dir, hits = []) => {
let entries = [];
try {
entries = readdirSync(dir);
} catch {
return hits;
}
for (const e of entries) {
const f = join(dir, e);
try {
if (statSync(f).isDirectory()) {
scanDir(f, hits);
continue;
}
if (!f.endsWith(".js")) continue;
const m = readFileSync(f, "utf8").match(HASH_RE);
if (m) hits.push({ file: f.replace(standaloneDir, "app"), mod: m[1] });
} catch {
continue;
}
}
return hits;
};
const hits = scanDir(join(standaloneDir, ".next", "server"));
if (hits.length > 0) {
console.warn(
" ⚠️ Hashed externals in build (will be auto-fixed at runtime by externals patch):"
);
hits.slice(0, 5).forEach((h) => console.warn());
if (hits.length > 5) console.warn();
} else {
console.log(" ✅ Build clean — no hashed externals found.");
}
}
console.log(" 📋 Copying standalone build to app/...");
mkdirSync(APP_DIR, { recursive: true });
cpSync(standaloneDir, APP_DIR, { recursive: true });
@@ -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) => {
@@ -3075,11 +3075,14 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
chatPath: "",
modelsPath: "",
});
const [saving, setSaving] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
if (node) {
@@ -3090,7 +3093,10 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
baseUrl:
node.baseUrl ||
(isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
chatPath: node.chatPath || "",
modelsPath: node.modelsPath || "",
});
setShowAdvanced(!!(node.chatPath || node.modelsPath));
}
}, [node, isAnthropic]);
@@ -3107,6 +3113,8 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
chatPath: formData.chatPath || "",
modelsPath: formData.modelsPath || "",
};
if (!isAnthropic) {
payload.apiType = formData.apiType;
@@ -3127,6 +3135,7 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: isAnthropic ? "anthropic-compatible" : "openai-compatible",
modelsPath: formData.modelsPath || "",
}),
});
const data = await res.json();
@@ -3182,6 +3191,39 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
type: isAnthropic ? t("anthropic") : t("openai"),
})}
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="advanced-settings"
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={isAnthropic ? "/messages" : t("chatPathPlaceholder")}
hint={t("chatPathHint")}
/>
<Input
label={t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
/>
</div>
)}
<div className="flex gap-2">
<Input
label={t("apiKeyForCheck")}
@@ -3232,6 +3274,8 @@ EditCompatibleNodeModal.propTypes = {
prefix: PropTypes.string,
apiType: PropTypes.string,
baseUrl: PropTypes.string,
chatPath: PropTypes.string,
modelsPath: PropTypes.string,
}),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
@@ -772,11 +772,14 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
chatPath: "",
modelsPath: "",
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiTypeOptions = [
{ value: "chat", label: t("chatCompletions") },
@@ -804,6 +807,8 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
apiType: formData.apiType,
baseUrl: formData.baseUrl,
type: "openai-compatible",
chatPath: formData.chatPath || "",
modelsPath: formData.modelsPath || "",
}),
});
const data = await res.json();
@@ -814,9 +819,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
chatPath: "",
modelsPath: "",
});
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
}
} catch (error) {
console.log("Error creating OpenAI Compatible node:", error);
@@ -835,6 +843,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "openai-compatible",
modelsPath: formData.modelsPath || "",
}),
});
const data = await res.json();
@@ -876,6 +885,39 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
placeholder={t("openaiBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("openai") })}
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="advanced-settings"
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={t("chatPathPlaceholder")}
hint={t("chatPathHint")}
/>
<Input
label={t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
/>
</div>
)}
<div className="flex gap-2">
<Input
label={t("apiKeyForCheck")}
@@ -933,11 +975,14 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
chatPath: "",
modelsPath: "",
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
// Reset validation when modal opens
@@ -959,6 +1004,8 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
prefix: formData.prefix,
baseUrl: formData.baseUrl,
type: "anthropic-compatible",
chatPath: formData.chatPath || "",
modelsPath: formData.modelsPath || "",
}),
});
const data = await res.json();
@@ -968,9 +1015,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
chatPath: "",
modelsPath: "",
});
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
}
} catch (error) {
console.log("Error creating Anthropic Compatible node:", error);
@@ -989,6 +1039,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "anthropic-compatible",
modelsPath: formData.modelsPath || "",
}),
});
const data = await res.json();
@@ -1024,6 +1075,39 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
placeholder={t("anthropicBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("anthropic") })}
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="advanced-settings"
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder="/messages"
hint={t("chatPathHint")}
/>
<Input
label={t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
/>
</div>
)}
<div className="flex gap-2">
<Input
label={t("apiKeyForCheck")}
@@ -1,19 +1,15 @@
"use server";
// Node.js-only route: uses child_process, fs, path via mitm/manager
// Dynamic imports prevent Turbopack from statically resolving native modules
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import {
getMitmStatus,
startMitm,
stopMitm,
getCachedPassword,
setCachedPassword,
} from "@/mitm/manager";
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET - Check MITM status
export async function GET() {
try {
const { getMitmStatus, getCachedPassword } = await import("@/mitm/manager");
const status = await getMitmStatus();
return NextResponse.json({
running: status.running,
@@ -51,6 +47,7 @@ export async function POST(request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { apiKey, sudoPassword } = validation.data;
const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";
@@ -101,6 +98,7 @@ export async function DELETE(request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { sudoPassword } = validation.data;
const { stopMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";
+2 -3
View File
@@ -10,8 +10,7 @@ import { APP_CONFIG } from "@/shared/constants/config";
*/
export async function GET() {
try {
const { getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker");
const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
@@ -66,7 +65,7 @@ export async function GET() {
export async function DELETE() {
try {
const { resetAllCircuitBreakers, getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker");
await import("@/shared/utils/circuitBreaker");
const before = getAllCircuitBreakerStatuses();
const resetCount = before.length;
+22 -5
View File
@@ -7,14 +7,31 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { pricingSyncRequestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function POST(request: NextRequest) {
let rawBody: unknown;
try {
const body = await request.json().catch(() => ({}));
const sources = Array.isArray(body.sources)
? body.sources.filter((s: unknown): s is string => typeof s === "string")
: undefined;
const dryRun = body.dryRun === true;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(pricingSyncRequestSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { sources, dryRun = false } = validation.data;
const { syncPricingFromSources } = await import("@/lib/pricingSync");
const result = await syncPricingFromSources({ sources, dryRun });
+5 -1
View File
@@ -39,7 +39,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, prefix, apiType, baseUrl } = validation.data;
const { name, prefix, apiType, baseUrl, chatPath, modelsPath } = validation.data;
const node: any = await getProviderNodeById(id);
if (!node) {
@@ -68,6 +68,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
name: name.trim(),
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
chatPath: chatPath || null,
modelsPath: modelsPath || null,
};
if (node.type === "openai-compatible") {
@@ -88,6 +90,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
nodeName: updated.name,
chatPath: updated.chatPath || undefined,
modelsPath: updated.modelsPath || undefined,
} as JsonRecord;
if (node.type === "openai-compatible") {
providerSpecificData.apiType = apiType;
+5 -1
View File
@@ -49,7 +49,7 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, prefix, apiType, baseUrl, type } = validation.data;
const { name, prefix, apiType, baseUrl, type, chatPath, modelsPath } = validation.data;
// Determine type
const nodeType = type || "openai-compatible";
@@ -62,6 +62,8 @@ export async function POST(request) {
apiType,
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
name: name.trim(),
chatPath: chatPath || null,
modelsPath: modelsPath || null,
});
return NextResponse.json({ node }, { status: 201 });
}
@@ -82,6 +84,8 @@ export async function POST(request) {
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
name: name.trim(),
chatPath: chatPath || null,
modelsPath: modelsPath || null,
});
return NextResponse.json({ node }, { status: 201 });
}
+3 -3
View File
@@ -24,7 +24,7 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, type } = validation.data;
const { baseUrl, apiKey, type, modelsPath } = validation.data;
// Anthropic Compatible Validation
if (type === "anthropic-compatible") {
@@ -35,7 +35,7 @@ export async function POST(request) {
}
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
const modelsUrl = `${normalizedBase}/models`;
const modelsUrl = `${normalizedBase}${modelsPath || "/models"}`;
const res = await fetch(modelsUrl, {
method: "GET",
@@ -50,7 +50,7 @@ export async function POST(request) {
}
// OpenAI Compatible Validation (Default)
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
const modelsUrl = `${baseUrl.replace(/\/$/, "")}${modelsPath || "/models"}`;
const res = await fetch(modelsUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
+4
View File
@@ -82,6 +82,8 @@ export async function POST(request: Request) {
apiType: node.apiType,
baseUrl: node.baseUrl,
nodeName: node.name,
...(node.chatPath ? { chatPath: node.chatPath } : {}),
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
};
} else if (isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
@@ -101,6 +103,8 @@ export async function POST(request: Request) {
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
...(node.chatPath ? { chatPath: node.chatPath } : {}),
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
};
}
+4 -6
View File
@@ -7,7 +7,7 @@ export async function POST() {
try {
// Reset all circuit breakers
const { getAllCircuitBreakerStatuses, getCircuitBreaker } =
await import("@/../../src/shared/utils/circuitBreaker");
await import("@/shared/utils/circuitBreaker");
const statuses = getAllCircuitBreakerStatuses();
let resetCount = 0;
@@ -23,11 +23,9 @@ export async function POST() {
resetCount,
message: `Reset ${resetCount} circuit breaker(s)`,
});
} catch (err) {
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to reset resilience state";
console.error("[API] POST /api/resilience/reset error:", err);
return NextResponse.json(
{ error: err.message || "Failed to reset resilience state" },
{ status: 500 }
);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+1 -2
View File
@@ -19,8 +19,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
export async function GET() {
try {
// Dynamic imports for open-sse modules
const { getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker");
const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
await import("@omniroute/open-sse/config/constants");
+38 -8
View File
@@ -6,6 +6,8 @@ import {
getDefaultTaskModelMap,
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
import { updateSettings } from "@/lib/db/settings";
import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/settings/task-routing
@@ -29,15 +31,29 @@ export async function GET() {
* Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean }
*/
export async function PUT(request: Request) {
let rawBody: Record<string, unknown>;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
setTaskRoutingConfig(rawBody as any);
const validation = validateBody(updateTaskRoutingSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const config = validation.data;
setTaskRoutingConfig(config);
// Persist to database (excluding stats)
const { stats, ...persistable } = getTaskRoutingConfig();
@@ -56,15 +72,29 @@ export async function PUT(request: Request) {
* For "detect": pass { action: "detect", body: <request-body> } to test detection
*/
export async function POST(request: Request) {
let rawBody: any;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
if (rawBody.action === "reset-stats") {
const validation = validateBody(taskRoutingActionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const actionRequest = validation.data;
if (actionRequest.action === "reset-stats") {
resetTaskRoutingStats();
return NextResponse.json({
success: true,
@@ -72,9 +102,9 @@ export async function POST(request: Request) {
});
}
if (rawBody.action === "detect") {
if (actionRequest.action === "detect") {
const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts");
const taskType = detectTaskType(rawBody.body || {});
const taskType = detectTaskType(actionRequest.body || {});
const config = getTaskRoutingConfig();
return NextResponse.json({
taskType,
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "فشل",
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
"editCompatibleTitle": "تحرير {type} متوافق",
"compatibleBaseUrlHint": "استخدم عنوان URL الأساسي (الذي ينتهي بـ /v1) لواجهة برمجة التطبيقات المتوافقة مع {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "مفتاح API (للفحص)",
"compatibleProdPlaceholder": "{type} متوافق (المنتج)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "الإعدادات",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Неуспешно",
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
"editCompatibleTitle": "Редактиране {type} Съвместим",
"compatibleBaseUrlHint": "Използвайте основния URL (завършващ на /v1) за вашия {type}-съвместим API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API ключ (за проверка)",
"compatibleProdPlaceholder": "{type} Съвместим (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Настройки",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Mislykkedes",
"leaveBlankKeepCurrentApiKey": "Lad stå tomt for at beholde den aktuelle API-nøgle.",
"editCompatibleTitle": "Rediger {type} Kompatibel",
"compatibleBaseUrlHint": "Brug basis-URL'en (der slutter på /v1) til din {type}-kompatible API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-nøgle (til check)",
"compatibleProdPlaceholder": "{type} Kompatibel (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Indstillinger",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Fehlgeschlagen",
"leaveBlankKeepCurrentApiKey": "Lassen Sie das Feld leer, um den aktuellen API-Schlüssel beizubehalten.",
"editCompatibleTitle": "Bearbeiten Sie {type} kompatibel",
"compatibleBaseUrlHint": "Verwenden Sie die Basis-URL (die auf /v1 endet) für Ihre {type}-kompatible API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-Schlüssel (zur Überprüfung)",
"compatibleProdPlaceholder": "{type} Kompatibel (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Einstellungen",
+9 -2
View File
@@ -1420,11 +1420,18 @@
"failed": "Failed",
"leaveBlankKeepCurrentApiKey": "Leave blank to keep the current API key.",
"editCompatibleTitle": "Edit {type} Compatible",
"compatibleBaseUrlHint": "Use the base URL (ending in /v1) for your {type}-compatible API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API Key (for Check)",
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
"tokenRefreshed": "Token refreshed successfully",
"tokenRefreshFailed": "Token refresh failed"
"tokenRefreshFailed": "Token refresh failed",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Settings",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Fallido",
"leaveBlankKeepCurrentApiKey": "Déjelo en blanco para conservar la clave API actual.",
"editCompatibleTitle": "Editar {type} Compatible",
"compatibleBaseUrlHint": "Utilice la URL base (que termina en /v1) para su API compatible con {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Clave API (para verificación)",
"compatibleProdPlaceholder": "{type} Compatible (Prod.)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Configuración",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Epäonnistui",
"leaveBlankKeepCurrentApiKey": "Jätä tyhjäksi, jos haluat säilyttää nykyisen API-avaimen.",
"editCompatibleTitle": "Muokkaa {type} Yhteensopiva",
"compatibleBaseUrlHint": "Käytä perus-URL-osoitetta (päättyy /v1) {type}-yhteensopivalle API:lle.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-avain (tarkistusta varten)",
"compatibleProdPlaceholder": "{type} Yhteensopiva (tuote)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Asetukset",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Échec",
"leaveBlankKeepCurrentApiKey": "Laissez vide pour conserver la clé API actuelle.",
"editCompatibleTitle": "Modifier {type} Compatible",
"compatibleBaseUrlHint": "Utilisez l'URL de base (se terminant par /v1) pour votre API compatible {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Clé API (pour vérification)",
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Paramètres",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "נכשל",
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
"editCompatibleTitle": "ערוך {type} תואם",
"compatibleBaseUrlHint": "השתמש בכתובת ה-URL הבסיסית (המסתיימת ב-/v1) עבור ה-API התואם {type} שלך.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "מפתח API (לבדיקה)",
"compatibleProdPlaceholder": "{type} תואם (פרוד)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "הגדרות",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Sikertelen",
"leaveBlankKeepCurrentApiKey": "Hagyja üresen az aktuális API-kulcs megtartásához.",
"editCompatibleTitle": "Szerkesztés {type} Kompatibilis",
"compatibleBaseUrlHint": "Használja a {type}-kompatibilis API alap URL-jét (a /v1 végződésű).",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-kulcs (ellenőrzéshez)",
"compatibleProdPlaceholder": "{type} Kompatibilis (termék)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Beállítások elemre",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mempertahankan kunci API saat ini.",
"editCompatibleTitle": "Sunting {type} Kompatibel",
"compatibleBaseUrlHint": "Gunakan URL dasar (berakhiran /v1) untuk API Anda yang kompatibel dengan {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Kunci API (untuk Pemeriksaan)",
"compatibleProdPlaceholder": "{type} Kompatibel (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Pengaturan",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "असफल",
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
"editCompatibleTitle": "संपादित करें {type} संगत",
"compatibleBaseUrlHint": "अपने {type}-संगत API के लिए आधार URL (/v1 पर समाप्त) का उपयोग करें।",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "एपीआई कुंजी (चेक के लिए)",
"compatibleProdPlaceholder": "{type} संगत (उत्पाद)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "सेटिंग्स",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Fallito",
"leaveBlankKeepCurrentApiKey": "Lascia vuoto per mantenere la chiave API corrente.",
"editCompatibleTitle": "Modifica {type} Compatibile",
"compatibleBaseUrlHint": "Utilizza l'URL di base (che termina con /v1) per la tua API compatibile con {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Chiave API (per controllo)",
"compatibleProdPlaceholder": "{type} Compatibile (prodotto)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Impostazioni",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "失敗しました",
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
"editCompatibleTitle": "編集 {type} 互換",
"compatibleBaseUrlHint": "{type} 互換 API のベース URL (/v1 で終わる) を使用します。",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "APIキー(チェック用)",
"compatibleProdPlaceholder": "{type} 互換性あり (製品)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "設定",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "실패",
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
"editCompatibleTitle": "{type} 호환 가능 편집",
"compatibleBaseUrlHint": "{type} 호환 API에는 기본 URL(/v1로 끝남)을 사용하세요.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API Key(확인용)",
"compatibleProdPlaceholder": "{type} 호환 가능(프로덕션)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "설정",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mengekalkan kunci API semasa.",
"editCompatibleTitle": "Edit {type} Serasi",
"compatibleBaseUrlHint": "Gunakan URL asas (berakhir dengan /v1) untuk API serasi {type} anda.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Kunci API (untuk Semakan)",
"compatibleProdPlaceholder": "{type} Serasi (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "tetapan",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Mislukt",
"leaveBlankKeepCurrentApiKey": "Laat dit leeg om de huidige API-sleutel te behouden.",
"editCompatibleTitle": "Bewerk {type} Compatibel",
"compatibleBaseUrlHint": "Gebruik de basis-URL (eindigend op /v1) voor uw {type}-compatibele API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-sleutel (ter controle)",
"compatibleProdPlaceholder": "{type} Compatibel (product)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Instellingen",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Mislyktes",
"leaveBlankKeepCurrentApiKey": "La stå tomt for å beholde gjeldende API-nøkkel.",
"editCompatibleTitle": "Rediger {type} Kompatibel",
"compatibleBaseUrlHint": "Bruk basis-URLen (som slutter på /v1) for din {type}-kompatible API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-nøkkel (for sjekk)",
"compatibleProdPlaceholder": "{type} Kompatibel (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Innstillinger",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Nabigo",
"leaveBlankKeepCurrentApiKey": "Iwanang blangko upang mapanatili ang kasalukuyang API key.",
"editCompatibleTitle": "I-edit ang {type} Compatible",
"compatibleBaseUrlHint": "Gamitin ang base URL (nagtatapos sa /v1) para sa iyong {type}-compatible na API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API Key (para sa Pagsusuri)",
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Mga setting",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Nie udało się",
"leaveBlankKeepCurrentApiKey": "Pozostaw puste, aby zachować bieżący klucz API.",
"editCompatibleTitle": "Edytuj {type} Kompatybilny",
"compatibleBaseUrlHint": "Użyj podstawowego adresu URL (kończącego się na /v1) dla interfejsu API zgodnego z {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Klucz API (do sprawdzenia)",
"compatibleProdPlaceholder": "{type} Kompatybilny (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Ustawienia",
+9 -2
View File
@@ -1419,10 +1419,17 @@
"failed": "Falhou",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
"editCompatibleTitle": "Editar Compatível {type}",
"compatibleBaseUrlHint": "Use a URL base (terminando em /v1) para sua API compatível com {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Chave de API (para verificação)",
"compatibleProdPlaceholder": "{type} Compatível (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Configurações",
+9 -2
View File
@@ -1398,10 +1398,17 @@
"failed": "Falha",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave API atual.",
"editCompatibleTitle": "Editar {type} Compatível",
"compatibleBaseUrlHint": "Use o URL base (terminando em /v1) para sua API compatível com {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Chave API (para verificação)",
"compatibleProdPlaceholder": "{type} Compatível (Produção)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Configurações",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "A eșuat",
"leaveBlankKeepCurrentApiKey": "Lăsați necompletat pentru a păstra cheia API curentă.",
"editCompatibleTitle": "Editați {type} Compatibil",
"compatibleBaseUrlHint": "Utilizați adresa URL de bază (se termină în /v1) pentru API-ul dvs. compatibil {type}.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Cheie API (pentru verificare)",
"compatibleProdPlaceholder": "{type} Compatibil (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Setări",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Не удалось",
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
"editCompatibleTitle": "Изменить совместимость {type}",
"compatibleBaseUrlHint": "Используйте базовый URL-адрес (оканчивающийся на /v1) для вашего {type}-совместимого API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-ключ (для проверки)",
"compatibleProdPlaceholder": "{type} Совместимость (Прод.)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Настройки",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Nepodarilo sa",
"leaveBlankKeepCurrentApiKey": "Ak chcete zachovať aktuálny kľúč API, nechajte pole prázdne.",
"editCompatibleTitle": "Upraviť {type} kompatibilné",
"compatibleBaseUrlHint": "Pre svoje {type}-kompatibilné API použite základnú webovú adresu (končiacu na /v1).",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API kľúč (na kontrolu)",
"compatibleProdPlaceholder": "{type} Kompatibilné (produkt)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Nastavenia",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Misslyckades",
"leaveBlankKeepCurrentApiKey": "Lämna tomt för att behålla den aktuella API-nyckeln.",
"editCompatibleTitle": "Redigera {type} Kompatibel",
"compatibleBaseUrlHint": "Använd basadressen (som slutar på /v1) för ditt {type}-kompatibla API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API-nyckel (för kontroll)",
"compatibleProdPlaceholder": "{type} Kompatibel (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Inställningar",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "ล้มเหลว",
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
"editCompatibleTitle": "แก้ไข {type} เข้ากันได้",
"compatibleBaseUrlHint": "ใช้ URL พื้นฐาน (ลงท้ายด้วย /v1) สำหรับ {type}- API ที่เข้ากันได้กับของคุณ",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "คีย์ API (สำหรับการตรวจสอบ)",
"compatibleProdPlaceholder": "{type} เข้ากันได้ (ผลิตภัณฑ์)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "การตั้งค่า",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "Не вдалося",
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
"editCompatibleTitle": "Редагувати {type} Сумісний",
"compatibleBaseUrlHint": "Використовуйте базову URL-адресу (закінчується на /v1) для свого {type}-сумісного API.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Ключ API (для перевірки)",
"compatibleProdPlaceholder": "{type} Сумісність (Prod)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Налаштування",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "thất bại",
"leaveBlankKeepCurrentApiKey": "Để trống để giữ khóa API hiện tại.",
"editCompatibleTitle": "Chỉnh sửa {type} Tương thích",
"compatibleBaseUrlHint": "Sử dụng URL cơ sở (kết thúc bằng /v1) cho API tương thích {type} của bạn.",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "Khóa API (để kiểm tra)",
"compatibleProdPlaceholder": "{type} Tương thích (Sản phẩm)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "Cài đặt",
+9 -2
View File
@@ -1386,10 +1386,17 @@
"failed": "失败",
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
"editCompatibleTitle": "编辑 {type} 兼容",
"compatibleBaseUrlHint": "使用 {type} 兼容 API 的基本 URL(以 /v1 结尾)。",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"apiKeyForCheck": "API 密钥(用于检查)",
"compatibleProdPlaceholder": "{type} 兼容(产品)",
"providerTestTimeout": "Provider test timed out — too many connections to test at once"
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)"
},
"settings": {
"title": "设置",
+28 -52
View File
@@ -8,65 +8,41 @@
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
function ensureSecrets(): void {
// eslint-disable-next-line no-eval
const crypto = eval("require")("crypto");
// eslint-disable-next-line no-eval
const Database = eval("require")("better-sqlite3");
// eslint-disable-next-line no-eval
const path = eval("require")("path");
const os = eval("require")("os"); // eslint-disable-line no-eval
function getRandomBytes(byteLength: number): Uint8Array {
const bytes = new Uint8Array(byteLength);
globalThis.crypto.getRandomValues(bytes);
return bytes;
}
function getSecretsDb() {
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
const dbPath = path.join(dataDir, "storage.sqlite");
try {
const db = new Database(dbPath);
db.exec(
"CREATE TABLE IF NOT EXISTS key_value (namespace TEXT, key TEXT, value TEXT, PRIMARY KEY (namespace, key))"
);
return db;
} catch {
return null;
}
}
function toBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function loadPersistedSecret(key: string): string | null {
try {
const db = getSecretsDb();
if (!db) return null;
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'secrets' AND key = ?")
.get(key) as { value: string } | undefined;
db.close();
return row ? JSON.parse(row.value) : null;
} catch {
return null;
}
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function persistSecret(key: string, value: string): void {
try {
const db = getSecretsDb();
if (!db) return;
db.prepare(
"INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('secrets', ?, ?)"
).run(key, JSON.stringify(value));
db.close();
} catch {
// Non-fatal — secrets can still work in-memory if persist fails
}
async function ensureSecrets(): Promise<void> {
let getPersistedSecret = (_key: string): string | null => null;
let persistSecret = (_key: string, _value: string): void => {};
try {
({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
"[STARTUP] Secret persistence unavailable; falling back to process-local secrets:",
msg
);
}
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
// Try to load previously generated secret from DB (survives restarts)
const persisted = loadPersistedSecret("jwtSecret");
const persisted = getPersistedSecret("jwtSecret");
if (persisted) {
process.env.JWT_SECRET = persisted;
console.log("[STARTUP] JWT_SECRET restored from persistent store");
} else {
// First run — generate and persist
const generated = crypto.randomBytes(48).toString("base64");
const generated = toBase64(getRandomBytes(48));
process.env.JWT_SECRET = generated;
persistSecret("jwtSecret", generated);
console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
@@ -74,11 +50,11 @@ function ensureSecrets(): void {
}
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
const persisted = loadPersistedSecret("apiKeySecret");
const persisted = getPersistedSecret("apiKeySecret");
if (persisted) {
process.env.API_KEY_SECRET = persisted;
} else {
const generated = crypto.randomBytes(32).toString("hex");
const generated = toHex(getRandomBytes(32));
process.env.API_KEY_SECRET = generated;
persistSecret("apiKeySecret", generated);
console.log(
@@ -91,7 +67,7 @@ function ensureSecrets(): void {
export async function register() {
// Only run on the server (not during build or in Edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
ensureSecrets();
await ensureSecrets();
// Console log file capture (must be first — before any logging occurs)
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();
@@ -0,0 +1,5 @@
-- Add custom endpoint path columns to provider_nodes
-- Allows compatible providers to override default chat/models paths
-- NULL = use default path (backward compatible)
ALTER TABLE provider_nodes ADD COLUMN chat_path TEXT;
ALTER TABLE provider_nodes ADD COLUMN models_path TEXT;
+8 -3
View File
@@ -453,14 +453,16 @@ export async function createProviderNode(data: JsonRecord) {
prefix: data.prefix || null,
apiType: data.apiType || null,
baseUrl: data.baseUrl || null,
chatPath: data.chatPath || null,
modelsPath: data.modelsPath || null,
createdAt: now,
updatedAt: now,
};
db.prepare(
`
INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at)
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt)
INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, created_at, updated_at)
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @createdAt, @updatedAt)
`
).run(node);
@@ -482,7 +484,8 @@ export async function updateProviderNode(id: string, data: JsonRecord) {
db.prepare(
`
UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix,
api_type = @apiType, base_url = @baseUrl, updated_at = @updatedAt
api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath,
models_path = @modelsPath, updated_at = @updatedAt
WHERE id = @id
`
).run({
@@ -492,6 +495,8 @@ export async function updateProviderNode(id: string, data: JsonRecord) {
prefix: merged["prefix"] || null,
apiType: merged["apiType"] || null,
baseUrl: merged["baseUrl"] || null,
chatPath: merged["chatPath"] || null,
modelsPath: merged["modelsPath"] || null,
updatedAt: merged["updatedAt"],
});
+27
View File
@@ -0,0 +1,27 @@
import { getDbInstance } from "./core";
interface SecretRow {
value?: unknown;
}
export function getPersistedSecret(key: string): string | null {
try {
const db = getDbInstance();
const row = db
.prepare<SecretRow>("SELECT value FROM key_value WHERE namespace = 'secrets' AND key = ?")
.get(key);
return typeof row?.value === "string" ? JSON.parse(row.value) : null;
} catch {
return null;
}
}
export function persistSecret(key: string, value: string): void {
try {
const db = getDbInstance();
db.prepare("INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('secrets', ?, ?)")
.run(key, JSON.stringify(value));
} catch {
// Non-fatal: secrets still work for the current process if persistence fails.
}
}
+18
View File
@@ -0,0 +1,18 @@
// Build-time stub for @/mitm/manager
// Used by Turbopack during next build to avoid native module resolution errors.
// The real module is used at runtime via dynamic import in route handlers.
export const getCachedPassword = () => null;
export const setCachedPassword = (_pwd: string) => {};
export const clearCachedPassword = () => {};
export const getMitmStatus = async () => ({
running: false,
pid: null,
dnsConfigured: false,
certExists: false,
});
export const startMitm = async (_apiKey: string, _sudoPassword: string) => ({
running: false,
pid: null,
});
export const stopMitm = async (_sudoPassword: string) => ({ running: false, pid: null });
+6 -3
View File
@@ -23,8 +23,12 @@ export function clearCachedPassword() {
_cachedPassword = null;
}
// server.js is in same directory as this file
const PID_FILE = path.join(resolveDataDir(), "mitm", ".mitm.pid");
const MITM_SERVER_URL = new URL("./server.cjs", import.meta.url);
const MITM_SERVER_PATH =
process.platform === "win32" && MITM_SERVER_URL.pathname.startsWith("/")
? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1))
: decodeURIComponent(MITM_SERVER_URL.pathname);
// Check if a PID is alive
function isProcessAlive(pid) {
@@ -104,8 +108,7 @@ export async function startMitm(apiKey, sudoPassword) {
// 4. Start MITM server
console.log("Starting MITM server...");
const serverPath = path.join(process.cwd(), "src/mitm/server.js");
serverProcess = spawn("node", [serverPath], {
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
env: {
...process.env,
ROUTER_API_KEY: apiKey,
+1 -1
View File
@@ -244,7 +244,7 @@ const server = https.createServer(sslOptions, async (req, res) => {
const bodyBuffer = await collectBodyRaw(req);
// Save request log if enabled
if ((bodyBuffer as any).length > 0) saveRequestLog(req.url, bodyBuffer);
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
// Anti-loop: requests from OmniRoute bypass interception
if (req.headers["x-omniroute-source"] === "omniroute") {
+50 -1
View File
@@ -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>
+7 -7
View File
@@ -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>
+57
View File
@@ -378,6 +378,58 @@ export const resetStatsActionSchema = z.object({
action: z.literal("reset-stats"),
});
const pricingSyncSourceSchema = z.enum(["litellm"]);
export const pricingSyncRequestSchema = z
.object({
sources: z.array(pricingSyncSourceSchema).min(1).optional(),
dryRun: z.boolean().optional(),
})
.strict();
const taskRoutingModelMapSchema = z
.object({
coding: z.string().max(200).optional(),
creative: z.string().max(200).optional(),
analysis: z.string().max(200).optional(),
vision: z.string().max(200).optional(),
summarization: z.string().max(200).optional(),
background: z.string().max(200).optional(),
chat: z.string().max(200).optional(),
})
.strict();
export const updateTaskRoutingSchema = z
.object({
enabled: z.boolean().optional(),
taskModelMap: taskRoutingModelMapSchema.optional(),
detectionEnabled: z.boolean().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.enabled === undefined &&
value.taskModelMap === undefined &&
value.detectionEnabled === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "No valid fields to update",
path: [],
});
}
});
export const taskRoutingActionSchema = z.discriminatedUnion("action", [
resetStatsActionSchema,
z
.object({
action: z.literal("detect"),
body: jsonObjectSchema.optional(),
})
.strict(),
]);
export const updateComboDefaultsSchema = z
.object({
comboDefaults: comboRuntimeConfigSchema.optional(),
@@ -767,6 +819,8 @@ export const createProviderNodeSchema = z
apiType: z.enum(["chat", "responses"]).optional(),
baseUrl: z.string().trim().min(1).optional(),
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
})
.superRefine((value, ctx) => {
const nodeType = value.type || "openai-compatible";
@@ -784,12 +838,15 @@ export const updateProviderNodeSchema = z.object({
prefix: z.string().trim().min(1, "Prefix is required"),
apiType: z.enum(["chat", "responses"]).optional(),
baseUrl: z.string().trim().min(1, "Base URL is required"),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
});
export const providerNodeValidateSchema = z.object({
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
apiKey: z.string().trim().min(1, "Base URL and API key required"),
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
});
export const updateProviderConnectionSchema = z
+10
View File
@@ -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;
+140
View File
@@ -0,0 +1,140 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// Inline buildUrl logic from DefaultExecutor for unit testing
// (avoids importing ESM modules with complex dependency chains)
function buildUrlOpenAI(provider, credentials) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
if (customPath) return `${normalized}${customPath}`;
const path = provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
function buildUrlAnthropic(credentials) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
return `${normalized}${customPath || "/messages"}`;
}
function buildModelsUrl(baseUrl, modelsPath) {
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}${modelsPath || "/models"}`;
}
describe("Custom Endpoint Paths", () => {
describe("OpenAI Compatible buildUrl", () => {
it("returns custom chatPath when provided", () => {
const url = buildUrlOpenAI("openai-compatible-chat-abc123", {
providerSpecificData: {
baseUrl: "https://api.epsiloncode.pl",
chatPath: "/v4/chat/completions",
},
});
assert.equal(url, "https://api.epsiloncode.pl/v4/chat/completions");
});
it("returns default /chat/completions without chatPath", () => {
const url = buildUrlOpenAI("openai-compatible-chat-abc123", {
providerSpecificData: {
baseUrl: "https://api.openai.com/v1",
},
});
assert.equal(url, "https://api.openai.com/v1/chat/completions");
});
it("returns /responses for responses provider without chatPath", () => {
const url = buildUrlOpenAI("openai-compatible-responses-abc123", {
providerSpecificData: {
baseUrl: "https://api.openai.com/v1",
},
});
assert.equal(url, "https://api.openai.com/v1/responses");
});
it("treats empty string chatPath as default", () => {
const url = buildUrlOpenAI("openai-compatible-chat-abc123", {
providerSpecificData: {
baseUrl: "https://api.example.com/v1",
chatPath: "",
},
});
assert.equal(url, "https://api.example.com/v1/chat/completions");
});
it("strips trailing slash from baseUrl", () => {
const url = buildUrlOpenAI("openai-compatible-chat-abc123", {
providerSpecificData: {
baseUrl: "https://api.example.com/v1/",
chatPath: "/v4/chat/completions",
},
});
assert.equal(url, "https://api.example.com/v1/v4/chat/completions");
});
});
describe("Anthropic Compatible buildUrl", () => {
it("returns custom chatPath when provided", () => {
const url = buildUrlAnthropic({
providerSpecificData: {
baseUrl: "https://proxy.example.com/v2",
chatPath: "/v4/messages",
},
});
assert.equal(url, "https://proxy.example.com/v2/v4/messages");
});
it("returns default /messages without chatPath", () => {
const url = buildUrlAnthropic({
providerSpecificData: {
baseUrl: "https://api.anthropic.com/v1",
},
});
assert.equal(url, "https://api.anthropic.com/v1/messages");
});
it("treats empty string chatPath as default", () => {
const url = buildUrlAnthropic({
providerSpecificData: {
baseUrl: "https://api.anthropic.com/v1",
chatPath: "",
},
});
assert.equal(url, "https://api.anthropic.com/v1/messages");
});
});
describe("Validate endpoint modelsPath", () => {
it("uses modelsPath when provided", () => {
const url = buildModelsUrl("https://api.example.com/v1", "/v4/models");
assert.equal(url, "https://api.example.com/v1/v4/models");
});
it("falls back to /models when modelsPath is empty", () => {
const url = buildModelsUrl("https://api.example.com/v1", "");
assert.equal(url, "https://api.example.com/v1/models");
});
it("falls back to /models when modelsPath is undefined", () => {
const url = buildModelsUrl("https://api.example.com/v1", undefined);
assert.equal(url, "https://api.example.com/v1/models");
});
});
describe("No credentials fallback", () => {
it("works with null credentials for openai-compatible", () => {
const url = buildUrlOpenAI("openai-compatible-chat-abc123", null);
assert.equal(url, "https://api.openai.com/v1/chat/completions");
});
it("works with null credentials for anthropic-compatible", () => {
const url = buildUrlAnthropic(null);
assert.equal(url, "https://api.anthropic.com/v1/messages");
});
});
});
+66
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { FORMATS } from "../../open-sse/translator/formats.ts";
import { getModelInfoCore } from "../../open-sse/services/model.ts";
import { detectFormat } from "../../open-sse/services/provider.ts";
import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts";
import { translateRequest } from "../../open-sse/translator/index.ts";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import {
@@ -79,6 +80,44 @@ test("CodexExecutor maps fast service tier to priority", () => {
assert.equal(transformed.service_tier, "priority");
});
test("shouldUseNativeCodexPassthrough only enables responses-native Codex requests", () => {
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses",
}),
true
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI,
endpointPath: "/v1/responses",
}),
false
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "openai",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses",
}),
false
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/chat/completions",
}),
false
);
});
test("CodexExecutor can force fast service tier from settings", () => {
setDefaultFastServiceTierEnabled(true);
@@ -101,6 +140,33 @@ test("CodexExecutor always requests SSE accept header", () => {
assert.equal(headers.Accept, "text/event-stream");
});
test("CodexExecutor preserves native responses payloads for Codex passthrough", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{
model: "gpt-5.1-codex",
input: "ship it",
instructions: "custom system prompt",
store: true,
metadata: { source: "codex-client" },
reasoning_effort: "high",
service_tier: "fast",
_nativeCodexPassthrough: true,
stream: false,
},
false
);
assert.equal(transformed.stream, true);
assert.equal(transformed.service_tier, "priority");
assert.equal(transformed.instructions, "custom system prompt");
assert.equal(transformed.store, true);
assert.deepEqual(transformed.metadata, { source: "codex-client" });
assert.equal(transformed.reasoning_effort, "high");
assert.ok(!("_nativeCodexPassthrough" in transformed));
});
test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => {
const responseBody = {
id: "resp_123",
+49
View File
@@ -10,6 +10,9 @@ import {
v1EmbeddingsSchema,
providerChatCompletionSchema,
v1CountTokensSchema,
pricingSyncRequestSchema,
updateTaskRoutingSchema,
taskRoutingActionSchema,
} from "../../src/shared/validation/schemas.ts";
test("translatorDetectSchema rejects empty body object", () => {
@@ -130,3 +133,49 @@ test("v1CountTokensSchema rejects empty messages", () => {
});
assert.equal(validation.success, false);
});
test("pricingSyncRequestSchema rejects unsupported sources", () => {
const validation = validateBody(pricingSyncRequestSchema, {
sources: ["unknown-source"],
});
assert.equal(validation.success, false);
});
test("pricingSyncRequestSchema accepts dryRun-only requests", () => {
const validation = validateBody(pricingSyncRequestSchema, {
dryRun: true,
});
assert.equal(validation.success, true);
});
test("updateTaskRoutingSchema rejects empty payloads", () => {
const validation = validateBody(updateTaskRoutingSchema, {});
assert.equal(validation.success, false);
});
test("updateTaskRoutingSchema accepts partial task routing updates", () => {
const validation = validateBody(updateTaskRoutingSchema, {
enabled: true,
taskModelMap: {
coding: "codex/gpt-5.1-codex",
},
});
assert.equal(validation.success, true);
});
test("taskRoutingActionSchema rejects unknown actions", () => {
const validation = validateBody(taskRoutingActionSchema, {
action: "noop",
});
assert.equal(validation.success, false);
});
test("taskRoutingActionSchema accepts detect action with object body", () => {
const validation = validateBody(taskRoutingActionSchema, {
action: "detect",
body: {
messages: [{ role: "user", content: "write code" }],
},
});
assert.equal(validation.success, true);
});