Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36856b18db | |||
| 66f0a8f994 | |||
| 455231170f | |||
| 5faeb58ab0 | |||
| 056e4a88ff | |||
| 8fd944ccf7 | |||
| 86105a547c | |||
| 9806648c07 | |||
| 6186babdb3 | |||
| f2ecefb54a | |||
| 43bd529b78 | |||
| 9c82b3d4ca | |||
| b19e6a8e87 | |||
| e3a2bd75f3 | |||
| da39e1485f | |||
| 88cc53a4b0 | |||
| 245243c7e7 | |||
| 759ac0df3d | |||
| db8d97b6de | |||
| 27d66e4b3e | |||
| ca7854210d | |||
| c009c993c3 | |||
| 00188f75ae | |||
| 4d086542aa | |||
| 1555883633 | |||
| 8f2c0acc7e | |||
| 0e30d15c01 | |||
| da14390fe0 | |||
| 11c0cff4ef | |||
| e322376996 | |||
| 4fbe45f30a | |||
| 2cd0f60c3c | |||
| 1b354be827 | |||
| 7db280ee64 | |||
| 192c06cadf | |||
| ad7e7abda0 | |||
| 02ccb35e80 | |||
| a8a29e17c5 | |||
| 75a6d850fc |
+73
-1
@@ -2,7 +2,79 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.4.2] - 2026-03-14
|
||||
## [2.5.3] - 2026-03-14
|
||||
|
||||
> Critical bugfixes: DB schema migration, startup env loading, provider error state clearing, and i18n tooltip fix. Code quality improvements on top of each PR.
|
||||
|
||||
### 🐛 Bug Fixes (PRs #369, #371, #372, #373 by @kfiramar)
|
||||
|
||||
- **fix(db) #373**: Add `provider_connections.group` column to base schema + backfill migration for existing databases — column was used in all queries but missing from schema definition
|
||||
- **fix(i18n) #371**: Replace non-existent `t("deleteConnection")` key with existing `providers.delete` key — fixes `MISSING_MESSAGE: providers.deleteConnection` runtime error on provider detail page
|
||||
- **fix(auth) #372**: Clear stale error metadata (`errorCode`, `lastErrorType`, `lastErrorSource`) from provider accounts after genuine recovery — previously, recovered accounts kept appearing as failed
|
||||
- **fix(startup) #369**: Unify env loading across `npm run start`, `run-standalone.mjs`, and Electron to respect `DATA_DIR/.env → ~/.omniroute/.env → ./.env` priority — prevents generating a new `STORAGE_ENCRYPTION_KEY` over an existing encrypted database
|
||||
|
||||
### 🔧 Code Quality
|
||||
|
||||
- Documented `result.success` vs `response?.ok` patterns in `auth.ts` (both intentional, now explained)
|
||||
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs`
|
||||
- Added `preferredEnv` merge order comment in Electron startup
|
||||
|
||||
> Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix.
|
||||
|
||||
### ✨ New Features (PRs #366, #367, #368)
|
||||
|
||||
- **Codex Quota Policy (PR #366)**: Per-account 5h/weekly quota window toggles in Provider dashboard. Accounts are automatically skipped when enabled windows reach 90% threshold and re-admitted after `resetAt`. Includes `quotaCache.ts` with side-effect free status getter.
|
||||
- **Codex Fast Tier Toggle (PR #367)**: Dashboard → Settings → Codex Service Tier. Default-off toggle injects `service_tier: "flex"` only for Codex requests, reducing cost ~80%. Full stack: UI tab + API endpoint + executor + translator + startup restore.
|
||||
- **gpt-5.4 Model (PR #368)**: Adds `cx/gpt-5.4` and `codex/gpt-5.4` to the Codex model registry. Regression test included.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #356**: Analytics charts (Top Provider, By Account, Provider Breakdown) now display human-readable provider names/labels instead of raw internal IDs for OpenAI-compatible providers.
|
||||
|
||||
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
|
||||
|
||||
### ✨ New Features (PRs #363 & #365)
|
||||
|
||||
- **Strict-Random Routing Strategy**: Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent requests. Independent decks per combo and per provider.
|
||||
- **API Key Access Controls**: `allowedConnections` (restrict which connections a key can use), `is_active` (enable/disable key with 403), `accessSchedule` (time-based access control), `autoResolve` toggle, rename keys via PATCH.
|
||||
- **Connection Groups**: Group provider connections by environment. Accordion view in Limits page with localStorage persistence and smart auto-switch.
|
||||
- **External Pricing Sync (LiteLLM)**: 3-tier pricing resolution (user overrides → synced → defaults). Opt-in via `PRICING_SYNC_ENABLED=true`. MCP tool `omniroute_sync_pricing`. 23 new tests.
|
||||
- **i18n**: 30 languages updated with strict-random strategy, API key management strings. pt-BR fully translated.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #355**: Stream idle timeout increased from 60s to 300s — prevents aborting extended-thinking models (claude-opus-4-6, o3, etc.) during long reasoning phases. Configurable via `STREAM_IDLE_TIMEOUT_MS`.
|
||||
- **fix #350**: Combo test now bypasses `REQUIRE_API_KEY=true` using internal header, and uses OpenAI-compatible format universally. Timeout extended from 15s to 20s.
|
||||
- **fix #346**: Tools with empty `function.name` (forwarded by Claude Code) are now filtered before upstream providers receive them, preventing "Invalid input[N].name: empty string" errors.
|
||||
|
||||
### 🗑️ Closed Issues
|
||||
|
||||
- **#341**: Debug section removed — replacement is `/dashboard/logs` and `/dashboard/health`.
|
||||
|
||||
> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required.
|
||||
|
||||
### 📝 Already Implemented (confirmed in audit)
|
||||
|
||||
- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking.
|
||||
- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking).
|
||||
|
||||
> UI polish, routing strategy additions, and graceful error handling for usage limits.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Fill-First & P2C Routing Strategies**: Added `fill-first` (drain quota before moving on) and `p2c` (Power-of-Two-Choices low-latency selection) to combo strategy picker, with full guidance panels and color-coded badges.
|
||||
- **Free Stack Preset Models**: Creating a combo with the Free Stack template now auto-fills 7 best-in-class free provider models (Gemini CLI, Kiro, iFlow×2, Qwen, NVIDIA NIM, Groq). Users just activate the providers and get a $0/month combo out-of-the-box.
|
||||
- **Wider Combo Modal**: Create/Edit combo modal now uses `max-w-4xl` for comfortable editing of large combos.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Limits page HTTP 500 for Codex & GitHub**: `getCodexUsage()` and `getGitHubUsage()` now return a user-friendly message when the provider returns 401/403 (expired token), instead of throwing and causing a 500 error on the Limits page.
|
||||
- **MaintenanceBanner false-positive**: Banner no longer shows "Server is unreachable" spuriously on page load. Fixed by calling `checkHealth()` immediately on mount and removing stale `show`-state closure.
|
||||
- **Provider icon tooltips**: Edit (pencil) and delete icon buttons in the provider connection row now have native HTML tooltips — all 6 action icons are now self-documented.
|
||||
|
||||
> Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability.
|
||||
|
||||
|
||||
@@ -1292,6 +1292,23 @@ Models:
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
#### Codex Account Limit Management (5h + Weekly)
|
||||
|
||||
Each Codex account now has policy toggles in `Dashboard -> Providers`:
|
||||
|
||||
- `5h` (ON/OFF): enforce the 5-hour window threshold policy.
|
||||
- `Weekly` (ON/OFF): enforce the weekly window threshold policy.
|
||||
- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped.
|
||||
- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically.
|
||||
- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically.
|
||||
|
||||
Scenarios:
|
||||
|
||||
- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold.
|
||||
- `5h OFF` + `Weekly ON`: only weekly usage can block the account.
|
||||
- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account.
|
||||
- `resetAt` passed: account re-enters rotation automatically (no manual re-enable).
|
||||
|
||||
### Gemini CLI (FREE 180K/month!)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs.
|
||||
- **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md)
|
||||
- **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md)
|
||||
|
||||
## Recent note: Codex account limit policy
|
||||
|
||||
Documentation now includes Codex account-level quota policy behavior:
|
||||
|
||||
- Per-account toggles: `5h` and `Weekly` (ON/OFF).
|
||||
- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection.
|
||||
- Auto-rotation: traffic moves to the next eligible Codex account.
|
||||
- Auto-reuse: account becomes eligible again after provider `resetAt` passes.
|
||||
|
||||
Generated on 2026-02-26.
|
||||
|
||||
@@ -1059,6 +1059,23 @@ Models:
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
#### Manajemen Limit Akun Codex (5h + Mingguan)
|
||||
|
||||
Setiap akun Codex sekarang punya toggle kebijakan di `Dashboard -> Providers`:
|
||||
|
||||
- `5h` (ON/OFF): menerapkan kebijakan ambang untuk jendela 5 jam.
|
||||
- `Weekly` (ON/OFF): menerapkan kebijakan ambang untuk jendela mingguan.
|
||||
- Perilaku ambang: saat jendela yang aktif mencapai >=90% penggunaan, akun tersebut di-skip.
|
||||
- Perilaku rotasi: OmniRoute otomatis merutekan ke akun Codex berikutnya yang masih eligible.
|
||||
- Perilaku reset: saat waktu `resetAt` provider sudah lewat, akun otomatis bisa dipakai lagi.
|
||||
|
||||
Skenario:
|
||||
|
||||
- `5h ON` + `Weekly ON`: akun di-skip jika salah satu jendela mencapai ambang.
|
||||
- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang bisa memblokir akun.
|
||||
- `5h ON` + `Weekly OFF`: hanya penggunaan 5 jam yang bisa memblokir akun.
|
||||
- `resetAt` sudah lewat: akun otomatis masuk rotasi lagi (tanpa enable manual).
|
||||
|
||||
### Gemini CLI (GRATIS 180K/bulan!)
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.4.2
|
||||
version: 2.5.3
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
+77
-6
@@ -64,6 +64,64 @@ let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
|
||||
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return path.join(path.resolve(xdg), "omniroute");
|
||||
|
||||
return path.join(require("os").homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(path.join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(path.join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dbPath) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-Updater Configuration ──────────────────────────────
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
@@ -386,12 +444,10 @@ function startNextServer() {
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
// 3. Persist back to DATA_DIR/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
@@ -407,8 +463,12 @@ function startNextServer() {
|
||||
return env;
|
||||
}
|
||||
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = path.join(dataDir, "server.env");
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
@@ -417,6 +477,16 @@ function startNextServer() {
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
|
||||
console.error(
|
||||
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
return;
|
||||
}
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
@@ -432,7 +502,7 @@ function startNextServer() {
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
@@ -454,6 +524,7 @@ function startNextServer() {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...serverEnv,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
|
||||
|
||||
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
|
||||
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
|
||||
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
|
||||
|
||||
// Provider configurations
|
||||
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
|
||||
|
||||
@@ -186,6 +186,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -23,6 +24,7 @@ export type ProviderCredentials = {
|
||||
refreshToken?: string;
|
||||
apiKey?: string;
|
||||
expiresAt?: string;
|
||||
connectionId?: string; // T07: used for API key rotation index
|
||||
providerSpecificData?: JsonRecord;
|
||||
};
|
||||
|
||||
@@ -131,7 +133,14 @@ export class BaseExecutor {
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
} else if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
// T07: rotate between primary + extra API keys when extraApiKeys is configured
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const effectiveKey =
|
||||
extraKeys.length > 0 && credentials.connectionId
|
||||
? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys)
|
||||
: credentials.apiKey;
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
|
||||
@@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
const CODEX_FAST_WIRE_VALUE = "priority";
|
||||
let defaultFastServiceTierEnabled = false;
|
||||
|
||||
function normalizeServiceTierValue(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
|
||||
defaultFastServiceTierEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasoning effort allowed per Codex model.
|
||||
@@ -103,6 +117,13 @@ 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"];
|
||||
|
||||
@@ -94,6 +94,12 @@ export async function handleChatCore({
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
|
||||
// T07: Inject connectionId into credentials so executors can rotate API keys
|
||||
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
||||
if (connectionId && credentials && !credentials.connectionId) {
|
||||
credentials.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const sourceFormat = detectFormat(body);
|
||||
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
|
||||
const isResponsesEndpoint = endpointPath.endsWith("/responses");
|
||||
@@ -186,6 +192,16 @@ export async function handleChatCore({
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { syncPricingInput, syncPricingTool, MCP_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_sync_pricing MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_sync_pricing");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_sync_pricing"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require pricing:write scope", () => {
|
||||
expect(syncPricingTool.scopes).toContain("pricing:write");
|
||||
});
|
||||
|
||||
it("should have full audit level", () => {
|
||||
expect(syncPricingTool.auditLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("should validate empty input (all fields optional)", () => {
|
||||
const result = syncPricingInput.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with sources array", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: ["litellm"] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with dryRun", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: true });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate full input", () => {
|
||||
const result = syncPricingInput.safeParse({
|
||||
sources: ["litellm"],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid sources type", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: "litellm" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid dryRun type", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: "yes" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should point to correct source endpoint", () => {
|
||||
expect(syncPricingTool.sourceEndpoints).toContain("/api/pricing/sync");
|
||||
});
|
||||
});
|
||||
@@ -723,6 +723,42 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 17: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("External pricing sources to sync from (default: ['litellm'])"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("If true, preview sync results without saving to database"),
|
||||
});
|
||||
|
||||
export const syncPricingOutput = z.object({
|
||||
success: z.boolean(),
|
||||
modelCount: z.number(),
|
||||
providerCount: z.number(),
|
||||
source: z.string(),
|
||||
dryRun: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof syncPricingOutput> =
|
||||
{
|
||||
name: "omniroute_sync_pricing",
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.",
|
||||
inputSchema: syncPricingInput,
|
||||
outputSchema: syncPricingOutput,
|
||||
scopes: ["pricing:write"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/pricing/sync"],
|
||||
};
|
||||
|
||||
// ============ Tool Registry ============
|
||||
|
||||
/** All MCP tool definitions, ordered by phase then name */
|
||||
@@ -745,6 +781,7 @@ export const MCP_TOOLS = [
|
||||
bestComboForTaskTool,
|
||||
explainRouteTool,
|
||||
getSessionSnapshotTool,
|
||||
syncPricingTool,
|
||||
] as const;
|
||||
|
||||
/** Essential tools only (Phase 1) */
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
bestComboForTaskInput,
|
||||
explainRouteInput,
|
||||
getSessionSnapshotInput,
|
||||
syncPricingInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
handleBestComboForTask,
|
||||
handleExplainRoute,
|
||||
handleGetSessionSnapshot,
|
||||
handleSyncPricing,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
|
||||
@@ -664,6 +666,18 @@ export function createMcpServer(): McpServer {
|
||||
})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_sync_pricing",
|
||||
{
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
|
||||
inputSchema: syncPricingInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_sync_pricing", (args) =>
|
||||
handleSyncPricing(syncPricingInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,6 +678,28 @@ export async function handleExplainRoute(args: { requestId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSyncPricing(args: { sources?: string[]; dryRun?: boolean }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/pricing/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sources: args.sources,
|
||||
dryRun: args.dryRun ?? false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetSessionSnapshot() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* apiKeyRotator.ts — T07: API Key Round-Robin
|
||||
*
|
||||
* Rotates between a primary API key and extra API keys stored in
|
||||
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
|
||||
*
|
||||
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
|
||||
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
|
||||
*
|
||||
* The in-memory rotation index resets on process restart, which is intentional —
|
||||
* it ensures even distribution across restarts without persistence overhead.
|
||||
*/
|
||||
|
||||
// In-memory round-robin index per connection
|
||||
const _keyIndexes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Get the next API key in round-robin rotation for a given connection.
|
||||
* If no extra keys are configured, returns the primary key unchanged.
|
||||
*
|
||||
* @param connectionId - Unique connection identifier (for index isolation)
|
||||
* @param primaryKey - The main api_key from the connection
|
||||
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
|
||||
* @returns The selected API key (may be primary or one of the extras)
|
||||
*/
|
||||
export function getRotatingApiKey(
|
||||
connectionId: string,
|
||||
primaryKey: string,
|
||||
extraKeys: string[] = []
|
||||
): string {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
|
||||
// Only 1 key available → no rotation needed
|
||||
if (validExtras.length === 0) return primaryKey;
|
||||
|
||||
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
|
||||
if (allKeys.length <= 1) return primaryKey;
|
||||
|
||||
const current = _keyIndexes.get(connectionId) ?? 0;
|
||||
const idx = current % allKeys.length;
|
||||
_keyIndexes.set(connectionId, current + 1);
|
||||
|
||||
return allKeys[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the rotation index for a connection.
|
||||
* Call this when a key fails (401/403) to skip the bad key next time.
|
||||
*
|
||||
* @param connectionId - Connection to reset
|
||||
*/
|
||||
export function resetRotationIndex(connectionId: string): void {
|
||||
_keyIndexes.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of API keys available for a connection.
|
||||
* Used for logging/observability.
|
||||
*/
|
||||
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
return (primaryKey ? 1 : 0) + validExtras.length;
|
||||
}
|
||||
+14
-14
@@ -9,6 +9,7 @@ import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
||||
import { parseModel } from "./model.ts";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
@@ -150,18 +151,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
|
||||
return [selected, ...rest].filter(Boolean).map((e) => e.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle (in-place)
|
||||
* @param {Array} arr
|
||||
* @returns {Array} The shuffled array
|
||||
*/
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
|
||||
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
|
||||
|
||||
/**
|
||||
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
||||
@@ -287,8 +278,17 @@ export async function handleComboChat({
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "random") {
|
||||
orderedModels = shuffleArray([...orderedModels]);
|
||||
if (strategy === "strict-random") {
|
||||
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
|
||||
// Put selected model first so the fallback loop tries it first
|
||||
const rest = orderedModels.filter((m) => m !== selectedId);
|
||||
orderedModels = [selectedId, ...rest];
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedModels = fisherYatesShuffle([...orderedModels]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedModels = sortModelsByUsage(orderedModels, combo.name);
|
||||
|
||||
@@ -161,6 +161,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`GitHub API error: ${error}`);
|
||||
}
|
||||
|
||||
@@ -620,6 +625,11 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ export function openaiToOpenAIResponsesRequest(
|
||||
}
|
||||
|
||||
// Pass through relevant fields
|
||||
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
|
||||
if (root.temperature !== undefined) result.temperature = root.temperature;
|
||||
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
|
||||
if (root.top_p !== undefined) result.top_p = root.top_p;
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -6866,6 +6866,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.3",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+68
-16
@@ -7,22 +7,25 @@
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
* - Electron: called from main.js startup, persisted in DATA_DIR
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath?.trim()) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => existsSync(filePath)) ?? null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dataDir) {
|
||||
const dbPath = join(dataDir, "storage.sqlite");
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
|
||||
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
|
||||
const merged = { ...persisted, ...preferredEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
if (hasEncryptedCredentials(dataDir)) {
|
||||
throw new Error(
|
||||
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
}
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
|
||||
@@ -52,15 +52,34 @@ function validateKeyName(
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface KeyUsageStats {
|
||||
totalRequests: number;
|
||||
lastUsed: string | null;
|
||||
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
|
||||
const tc = useTranslations("common");
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [allModels, setAllModels] = useState<Model[]>([]);
|
||||
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
|
||||
const handleUpdatePermissions = async (
|
||||
allowedModels: string[],
|
||||
noLog: boolean,
|
||||
allowedConnections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
|
||||
// Validate models array
|
||||
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
|
||||
(id) => typeof id === "string" && id.length > 0 && id.length < 200
|
||||
);
|
||||
|
||||
// Validate connections (must be UUIDs)
|
||||
const validConnections = allowedConnections.filter(
|
||||
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowedModels: validModels, noLog }),
|
||||
body: JSON.stringify({
|
||||
allowedModels: validModels,
|
||||
allowedConnections: validConnections,
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
accessSchedule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
|
||||
{keys.map((key) => {
|
||||
const stats = usageStats[key.id];
|
||||
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
|
||||
const hasConnectionRestrictions =
|
||||
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
|
||||
const noLogEnabled = key.noLog === true;
|
||||
const keyIsActive = key.isActive !== false; // default true
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
|
||||
{t("allModels")}
|
||||
</button>
|
||||
)}
|
||||
{hasConnectionRestrictions && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">cable</span>
|
||||
{key.allowedConnections.length} conn
|
||||
</button>
|
||||
)}
|
||||
{noLogEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
|
||||
No-Log
|
||||
</span>
|
||||
)}
|
||||
{key.autoResolve && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
auto_fix_high
|
||||
</span>
|
||||
Auto-Resolve
|
||||
</span>
|
||||
)}
|
||||
{!keyIsActive && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{hasSchedule && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
{t("scheduleActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col justify-center">
|
||||
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
|
||||
apiKey={editingKey}
|
||||
modelsByProvider={filteredModelsByProvider}
|
||||
allModels={allModels}
|
||||
allConnections={allConnections}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={setSearchModel}
|
||||
onSave={handleUpdatePermissions}
|
||||
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey,
|
||||
modelsByProvider,
|
||||
allModels,
|
||||
allConnections,
|
||||
searchModel,
|
||||
onSearchChange,
|
||||
onSave,
|
||||
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey: ApiKey;
|
||||
modelsByProvider: ProviderGroup[];
|
||||
allModels: Model[];
|
||||
allConnections: ProviderConnection[];
|
||||
searchModel: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSave: (models: string[], noLog: boolean) => void;
|
||||
onSave: (
|
||||
models: string[],
|
||||
noLog: boolean,
|
||||
connections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => void;
|
||||
}) {
|
||||
const t = useTranslations("apiManager");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
const initialConnections = Array.isArray(apiKey?.allowedConnections)
|
||||
? apiKey.allowedConnections
|
||||
: [];
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
|
||||
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
|
||||
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
|
||||
const [scheduleDays, setScheduleDays] = useState<number[]>(
|
||||
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
|
||||
);
|
||||
const [scheduleTz, setScheduleTz] = useState(
|
||||
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
|
||||
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
|
||||
// Expand all providers by default when in restrict mode with existing selections
|
||||
if (initialModels.length > 0) {
|
||||
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleToggleConnection = useCallback(
|
||||
(connectionId: string) => {
|
||||
if (allowAllConnections) return;
|
||||
setSelectedConnections((prev) =>
|
||||
prev.includes(connectionId)
|
||||
? prev.filter((c) => c !== connectionId)
|
||||
: [...prev, connectionId]
|
||||
);
|
||||
},
|
||||
[allowAllConnections]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(allowAll ? [] : selectedModels, noLogEnabled);
|
||||
}, [onSave, allowAll, selectedModels, noLogEnabled]);
|
||||
const schedule: AccessSchedule | null = scheduleEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
from: scheduleFrom,
|
||||
until: scheduleUntil,
|
||||
days: scheduleDays,
|
||||
tz: scheduleTz,
|
||||
}
|
||||
: null;
|
||||
onSave(
|
||||
allowAll ? [] : selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
schedule
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
allowAll,
|
||||
selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections,
|
||||
selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
scheduleDays,
|
||||
scheduleTz,
|
||||
]);
|
||||
|
||||
const selectedCount = selectedModels.length;
|
||||
const totalModels = allModels.length;
|
||||
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Active Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
|
||||
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={keyIsActive}
|
||||
onClick={() => setKeyIsActive((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
keyIsActive
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsActive ? "check_circle" : "block"}
|
||||
</span>
|
||||
{keyIsActive ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Access Schedule */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
|
||||
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={scheduleEnabled}
|
||||
onClick={() => setScheduleEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
scheduleEnabled
|
||||
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">schedule</span>
|
||||
{scheduleEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{scheduleEnabled && (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleFrom}
|
||||
onChange={(e) => setScheduleFrom(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleUntil}
|
||||
onChange={(e) => setScheduleUntil(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(
|
||||
[
|
||||
[0, t("daySun")],
|
||||
[1, t("dayMon")],
|
||||
[2, t("dayTue")],
|
||||
[3, t("dayWed")],
|
||||
[4, t("dayThu")],
|
||||
[5, t("dayFri")],
|
||||
[6, t("daySat")],
|
||||
] as [number, string][]
|
||||
).map(([dayIdx, label]) => {
|
||||
const selected = scheduleDays.includes(dayIdx);
|
||||
return (
|
||||
<button
|
||||
key={dayIdx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setScheduleDays((prev) =>
|
||||
prev.includes(dayIdx)
|
||||
? prev.filter((d) => d !== dayIdx)
|
||||
: [...prev, dayIdx].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
|
||||
selected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("scheduleTimezone")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Resolve Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
|
||||
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoResolveEnabled}
|
||||
onClick={() => setAutoResolveEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
autoResolveEnabled
|
||||
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
|
||||
</span>
|
||||
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed Connections Section */}
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAllowAllConnections(true);
|
||||
setSelectedConnections([]);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAllowAllConnections(false)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
!allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Restrict
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{allowAllConnections
|
||||
? "This key can use any active connection."
|
||||
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{!allowAllConnections && (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{Object.entries(
|
||||
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
|
||||
const p = conn.provider || "Other";
|
||||
if (!acc[p]) acc[p] = [];
|
||||
acc[p].push(conn);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, conns]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
|
||||
{provider}
|
||||
</p>
|
||||
{conns.map((conn) => {
|
||||
const isSelected = selectedConnections.includes(conn.id);
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => handleToggleConnection(conn.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
isSelected ? "bg-primary border-primary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[10px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate flex-1">
|
||||
{conn.name || conn.id.slice(0, 8)}
|
||||
</span>
|
||||
{!conn.isActive && (
|
||||
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth>
|
||||
|
||||
@@ -27,6 +27,14 @@ const STRATEGY_OPTIONS = [
|
||||
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
|
||||
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
|
||||
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
|
||||
{
|
||||
value: "fill-first",
|
||||
labelKey: "fillFirst",
|
||||
descKey: "fillFirstDesc",
|
||||
icon: "stacked_bar_chart",
|
||||
},
|
||||
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
|
||||
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
|
||||
];
|
||||
|
||||
const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
@@ -60,6 +68,21 @@ const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
avoid: "Avoid when pricing data is missing or outdated.",
|
||||
example: "Example: Batch or background jobs where lower cost matters most.",
|
||||
},
|
||||
"fill-first": {
|
||||
when: "Use when you want to drain one provider's quota fully before moving to the next.",
|
||||
avoid: "Avoid when you need request-level load balancing across providers.",
|
||||
example: "Example: Use all $200 Deepgram credits before falling to Groq.",
|
||||
},
|
||||
p2c: {
|
||||
when: "Use when you want low-latency selection using Power-of-Two-Choices algorithm.",
|
||||
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
|
||||
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
|
||||
},
|
||||
"strict-random": {
|
||||
when: "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
avoid: "Avoid when models have different quality or latency and order matters.",
|
||||
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
@@ -126,6 +149,34 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
"Use for batch/background jobs where cost is the main KPI.",
|
||||
],
|
||||
},
|
||||
"fill-first": {
|
||||
title: "Quota drain strategy",
|
||||
description: "Exhausts one provider's quota before moving to the next in chain.",
|
||||
tips: [
|
||||
"Order models by free quota size — biggest first.",
|
||||
"Enable health checks to skip drained providers.",
|
||||
"Ideal for free-tier stacking (Deepgram → Groq → NIM).",
|
||||
],
|
||||
},
|
||||
p2c: {
|
||||
title: "Power-of-Two-Choices",
|
||||
description:
|
||||
"Picks the less-loaded of two random candidates per request — low latency at scale.",
|
||||
tips: [
|
||||
"Use with 4+ models for best effect.",
|
||||
"Requires latency telemetry enabled in Settings.",
|
||||
"Great replacement for round-robin in high-throughput combos.",
|
||||
],
|
||||
},
|
||||
"strict-random": {
|
||||
title: "Shuffle deck distribution",
|
||||
description: "Each model is used exactly once per cycle before reshuffling.",
|
||||
tips: [
|
||||
"Use at least 2 models for meaningful distribution.",
|
||||
"Ideal for same-model accounts to evenly spread quota.",
|
||||
"Guarantees no model is skipped or repeated within a cycle.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
|
||||
@@ -227,6 +278,8 @@ function getStrategyBadgeClass(strategy) {
|
||||
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
|
||||
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
|
||||
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
|
||||
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
|
||||
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -1365,10 +1418,24 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
);
|
||||
};
|
||||
|
||||
const FREE_STACK_PRESET_MODELS = [
|
||||
{ model: "gc/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "kr/claude-sonnet-4.5", weight: 0 },
|
||||
{ model: "if/kimi-k2-thinking", weight: 0 },
|
||||
{ model: "if/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "qw/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
|
||||
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
|
||||
];
|
||||
|
||||
const applyTemplate = (template) => {
|
||||
setStrategy(template.strategy);
|
||||
setConfig((prev) => ({ ...prev, ...template.config }));
|
||||
if (!name.trim()) setName(template.suggestedName);
|
||||
// Pre-fill Free Stack with 7 real free provider models
|
||||
if (template.id === "free-stack") {
|
||||
setModels(FREE_STACK_PRESET_MODELS);
|
||||
}
|
||||
};
|
||||
|
||||
// Format model display name with readable provider name
|
||||
@@ -1473,7 +1540,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("editCombo") : t("createCombo")}
|
||||
size="full"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function LimitsPage() {
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
<RateLimitStatus />
|
||||
<SessionsTab />
|
||||
<RateLimitStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,17 @@ import {
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
|
||||
const record =
|
||||
policy && typeof policy === "object" && !Array.isArray(policy)
|
||||
? (policy as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
use5h: typeof record.use5h === "boolean" ? record.use5h : true,
|
||||
useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProviderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -49,6 +60,7 @@ export default function ProviderDetailPage() {
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const hasAutoOpened = useRef(false);
|
||||
const userDismissed = useRef(false);
|
||||
const [proxyTarget, setProxyTarget] = useState(null);
|
||||
@@ -311,6 +323,63 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
|
||||
try {
|
||||
const target = connections.find((connection) => connection.id === connectionId);
|
||||
if (!target) return;
|
||||
|
||||
const providerSpecificData =
|
||||
target.providerSpecificData && typeof target.providerSpecificData === "object"
|
||||
? target.providerSpecificData
|
||||
: {};
|
||||
const existingPolicy =
|
||||
providerSpecificData.codexLimitPolicy &&
|
||||
typeof providerSpecificData.codexLimitPolicy === "object"
|
||||
? providerSpecificData.codexLimitPolicy
|
||||
: {};
|
||||
|
||||
const nextPolicy = {
|
||||
...normalizeCodexLimitPolicy(existingPolicy),
|
||||
[field]: enabled,
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: {
|
||||
...providerSpecificData,
|
||||
codexLimitPolicy: nextPolicy,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Codex limit policy");
|
||||
return;
|
||||
}
|
||||
|
||||
setConnections((prev) =>
|
||||
prev.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
providerSpecificData: {
|
||||
...(connection.providerSpecificData || {}),
|
||||
codexLimitPolicy: nextPolicy,
|
||||
},
|
||||
}
|
||||
: connection
|
||||
)
|
||||
);
|
||||
notify.success("Codex limit policy updated");
|
||||
} catch (error) {
|
||||
console.error("Error toggling Codex quota policy:", error);
|
||||
notify.error("Failed to update Codex limit policy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetestConnection = async (connectionId) => {
|
||||
if (!connectionId || retestingId) return;
|
||||
setRetestingId(connectionId);
|
||||
@@ -331,7 +400,6 @@ export default function ProviderDetailPage() {
|
||||
|
||||
// T12: Manual token refresh
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const notify = useNotificationStore();
|
||||
const handleRefreshToken = async (connectionId: string) => {
|
||||
if (refreshingId) return;
|
||||
setRefreshingId(connectionId);
|
||||
@@ -941,6 +1009,11 @@ export default function ProviderDetailPage() {
|
||||
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
@@ -2175,12 +2248,15 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
isOAuth,
|
||||
isCodex,
|
||||
isFirst,
|
||||
isLast,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onToggleActive,
|
||||
onToggleRateLimit,
|
||||
onToggleCodex5h,
|
||||
onToggleCodexWeekly,
|
||||
onRetest,
|
||||
isRetesting,
|
||||
onEdit,
|
||||
@@ -2242,6 +2318,16 @@ function ConnectionRow({
|
||||
|
||||
const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t);
|
||||
const rateLimitEnabled = !!connection.rateLimitProtection;
|
||||
const codexPolicy =
|
||||
connection.providerSpecificData &&
|
||||
typeof connection.providerSpecificData === "object" &&
|
||||
connection.providerSpecificData.codexLimitPolicy &&
|
||||
typeof connection.providerSpecificData.codexLimitPolicy === "object"
|
||||
? connection.providerSpecificData.codexLimitPolicy
|
||||
: {};
|
||||
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
|
||||
const codex5hEnabled = normalizedCodexPolicy.use5h;
|
||||
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2331,6 +2417,35 @@ function ConnectionRow({
|
||||
<span className="material-symbols-outlined text-[13px]">shield</span>
|
||||
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
|
||||
</button>
|
||||
{isCodex && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleCodex5h?.(!codex5hEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
codex5hEnabled
|
||||
? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle Codex 5h limit policy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">timer</span>
|
||||
5h {codex5hEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleCodexWeekly?.(!codexWeeklyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
codexWeeklyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle Codex weekly limit policy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">date_range</span>
|
||||
Weekly {codexWeeklyEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasProxy &&
|
||||
(() => {
|
||||
const colorClass =
|
||||
@@ -2411,6 +2526,7 @@ function ConnectionRow({
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
@@ -2421,7 +2537,11 @@ function ConnectionRow({
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="p-2 hover:bg-red-500/10 rounded text-red-500">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500"
|
||||
title={t("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2446,14 +2566,18 @@ ConnectionRow.propTypes = {
|
||||
lastErrorSource: PropTypes.string,
|
||||
errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
globalPriority: PropTypes.number,
|
||||
providerSpecificData: PropTypes.object,
|
||||
}).isRequired,
|
||||
isOAuth: PropTypes.bool.isRequired,
|
||||
isCodex: PropTypes.bool,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onToggleActive: PropTypes.func.isRequired,
|
||||
onToggleRateLimit: PropTypes.func.isRequired,
|
||||
onToggleCodex5h: PropTypes.func,
|
||||
onToggleCodexWeekly: PropTypes.func,
|
||||
onRetest: PropTypes.func.isRequired,
|
||||
isRetesting: PropTypes.bool,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
@@ -2644,6 +2768,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
|
||||
const [newExtraKey, setNewExtraKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
@@ -2653,6 +2779,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
setExtraApiKeys(Array.isArray(existing) ? existing : []);
|
||||
setNewExtraKey("");
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -2739,6 +2869,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
updates.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
// Persist extra API keys in providerSpecificData
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
};
|
||||
}
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -2823,6 +2960,68 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* T07: Extra API Keys for round-robin rotation */}
|
||||
{!isOAuth && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
Extra API Keys
|
||||
<span className="ml-2 text-[11px] font-normal text-text-muted">
|
||||
(round-robin rotation — optional)
|
||||
</span>
|
||||
</label>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{extraApiKeys.map((key, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate">
|
||||
{`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExtraApiKeys(extraApiKeys.filter((_, i) => i !== idx))}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-red-400 hover:text-red-500"
|
||||
title="Remove this key"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={newExtraKey}
|
||||
onChange={(e) => setNewExtraKey(e.target.value)}
|
||||
placeholder="Add another API key..."
|
||||
className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
disabled={!newExtraKey.trim()}
|
||||
className="px-3 py-2 rounded bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 text-sm font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{extraApiKeys.length + 1} keys total — rotating round-robin on each request.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function CodexServiceTierTab() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<"" | "saved" | "error">("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/codex-service-tier")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setEnabled(Boolean(data.enabled));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const save = async (nextEnabled: boolean) => {
|
||||
setEnabled(nextEnabled);
|
||||
setSaving(true);
|
||||
setStatus("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/codex-service-tier", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
} else {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
bolt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Inject `service_tier=fast` into Codex requests when the client leaves it unset.
|
||||
</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Force fast tier for Codex</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Off by default. Applies only to Codex requests and does not override an explicit tier.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save(!enabled)}
|
||||
disabled={loading || saving}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-sky-500" : "bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
|
||||
import ProxyTab from "./components/ProxyTab";
|
||||
import AppearanceTab from "./components/AppearanceTab";
|
||||
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
|
||||
import CodexServiceTierTab from "./components/CodexServiceTierTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import ModelAliasesTab from "./components/ModelAliasesTab";
|
||||
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
|
||||
@@ -85,6 +86,7 @@ export default function SettingsPage() {
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ThinkingBudgetTab />
|
||||
<CodexServiceTierTab />
|
||||
<SystemPromptTab />
|
||||
<CacheStatsCard />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ import Badge from "@/shared/components/Badge";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const LS_GROUP_BY = "omniroute:limits:groupBy";
|
||||
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
|
||||
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 120000;
|
||||
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
|
||||
|
||||
@@ -20,6 +24,7 @@ const PROVIDER_CONFIG = {
|
||||
kiro: { label: "Kiro AI", color: "#FF6B35" },
|
||||
codex: { label: "OpenAI Codex", color: "#10A37F" },
|
||||
claude: { label: "Claude Code", color: "#D97757" },
|
||||
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
|
||||
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
|
||||
};
|
||||
|
||||
@@ -89,12 +94,30 @@ export default function ProviderLimits() {
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
const [loading, setLoading] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
|
||||
});
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [countdown, setCountdown] = useState(120);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [tierFilter, setTierFilter] = useState("all");
|
||||
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
|
||||
if (typeof window === "undefined") return "none";
|
||||
const saved = localStorage.getItem(LS_GROUP_BY);
|
||||
if (saved === "environment" || saved === "none") return saved;
|
||||
return "none";
|
||||
});
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_EXPANDED_GROUPS);
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
@@ -175,10 +198,12 @@ export default function ProviderLimits() {
|
||||
setCountdown(120);
|
||||
try {
|
||||
const conns = await fetchConnections();
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
const usageConnections = conns.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
);
|
||||
await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
await Promise.all(usageConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error("Error refreshing all:", error);
|
||||
@@ -231,13 +256,23 @@ export default function ProviderLimits() {
|
||||
const filteredConnections = useMemo(
|
||||
() =>
|
||||
connections.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
),
|
||||
[connections]
|
||||
);
|
||||
|
||||
const sortedConnections = useMemo(() => {
|
||||
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
|
||||
const priority = {
|
||||
antigravity: 1,
|
||||
github: 2,
|
||||
codex: 3,
|
||||
claude: 4,
|
||||
kiro: 5,
|
||||
glm: 6,
|
||||
"kimi-coding": 7,
|
||||
};
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
|
||||
);
|
||||
@@ -276,6 +311,50 @@ export default function ProviderLimits() {
|
||||
);
|
||||
}, [sortedConnections, tierByConnection, tierFilter]);
|
||||
|
||||
const groupedConnections = useMemo(() => {
|
||||
if (groupBy !== "environment") return null;
|
||||
const groups = new Map();
|
||||
for (const conn of visibleConnections) {
|
||||
const key = conn.group || t("ungrouped");
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(conn);
|
||||
}
|
||||
return groups;
|
||||
}, [groupBy, visibleConnections, t]);
|
||||
|
||||
const handleSetGroupBy = (value: "none" | "environment") => {
|
||||
setGroupBy(value);
|
||||
localStorage.setItem(LS_GROUP_BY, value);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(groupName) ? next.delete(groupName) : next.add(groupName);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null;
|
||||
if (!hasSaved && connections.some((c) => c.group)) {
|
||||
setGroupBy("environment");
|
||||
}
|
||||
}, [connections]);
|
||||
|
||||
// Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos
|
||||
useEffect(() => {
|
||||
if (groupBy !== "environment" || !groupedConnections) return;
|
||||
if (expandedGroups.size === 0) {
|
||||
const allGroups = new Set([...groupedConnections.keys()]);
|
||||
setExpandedGroups(allGroups);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups]));
|
||||
}
|
||||
}, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Group by toggle */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("none")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
|
||||
style={{
|
||||
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{t("viewFlat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("environment")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
|
||||
style={{
|
||||
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
{t("viewByEnvironment")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
onClick={() => {
|
||||
const next = !autoRefresh;
|
||||
setAutoRefresh(next);
|
||||
localStorage.setItem(LS_AUTO_REFRESH, String(next));
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
|
||||
>
|
||||
<span
|
||||
@@ -382,157 +490,196 @@ export default function ProviderLimits() {
|
||||
<div className="text-center">{t("actions")}</div>
|
||||
</div>
|
||||
|
||||
{visibleConnections.map((conn, idx) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" };
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
{(() => {
|
||||
const renderRow = (conn, isLast) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || {
|
||||
label: conn.provider,
|
||||
color: "#666",
|
||||
};
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom:
|
||||
idx < visibleConnections.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
refresh
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (groupedConnections) {
|
||||
const entries = [...groupedConnections.entries()];
|
||||
return entries.map(([groupName, conns]) => (
|
||||
<div
|
||||
key={groupName}
|
||||
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
folder
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
|
||||
{groupName}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
|
||||
{conns.length}
|
||||
</span>
|
||||
</button>
|
||||
{expandedGroups.has(groupName) && (
|
||||
<div>{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return visibleConnections.map((conn, idx) =>
|
||||
renderRow(conn, idx === visibleConnections.length - 1)
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
|
||||
{visibleConnections.length === 0 && (
|
||||
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function POST(request) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// Send a minimal chat request to the internal SSE handler
|
||||
// Use OpenAI-compatible format — universally accepted by all providers via the translator
|
||||
const testBody = {
|
||||
model: modelStr,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
@@ -58,11 +59,15 @@ export async function POST(request) {
|
||||
|
||||
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 20000); // 20s timeout (was 15s, slow providers need more)
|
||||
|
||||
const res = await fetch(internalUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Fix #350: bypass REQUIRE_API_KEY for internal admin combo tests
|
||||
"X-Internal-Test": "combo-health-check",
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* API Route: /api/pricing/sync
|
||||
*
|
||||
* POST — Trigger a manual pricing sync from external sources.
|
||||
* GET — Get current sync status.
|
||||
* DELETE — Clear all synced pricing data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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;
|
||||
|
||||
const { syncPricingFromSources } = await import("@/lib/pricingSync");
|
||||
const result = await syncPricingFromSources({ sources, dryRun });
|
||||
|
||||
return NextResponse.json(result, { status: result.success ? 200 : 502 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getSyncStatus } = await import("@/lib/pricingSync");
|
||||
return NextResponse.json(getSyncStatus());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const { clearSyncedPricing } = await import("@/lib/pricingSync");
|
||||
clearSyncedPricing();
|
||||
return NextResponse.json({ success: true, message: "Synced pricing data cleared" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,30 @@ import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
function normalizeCodexLimitPolicy(
|
||||
incoming: unknown,
|
||||
existing: unknown
|
||||
): { use5h: boolean; useWeekly: boolean } {
|
||||
const incomingRecord =
|
||||
incoming && typeof incoming === "object" && !Array.isArray(incoming)
|
||||
? (incoming as Record<string, unknown>)
|
||||
: {};
|
||||
const existingRecord =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const existingUse5h = typeof existingRecord.use5h === "boolean" ? existingRecord.use5h : true;
|
||||
const existingUseWeekly =
|
||||
typeof existingRecord.useWeekly === "boolean" ? existingRecord.useWeekly : true;
|
||||
|
||||
return {
|
||||
use5h: typeof incomingRecord.use5h === "boolean" ? incomingRecord.use5h : existingUse5h,
|
||||
useWeekly:
|
||||
typeof incomingRecord.useWeekly === "boolean" ? incomingRecord.useWeekly : existingUseWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/providers/[id] - Get single connection
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
@@ -105,7 +129,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
existing.providerSpecificData && typeof existing.providerSpecificData === "object"
|
||||
? existing.providerSpecificData
|
||||
: {};
|
||||
updateData.providerSpecificData = { ...existingPsd, ...incomingPsd };
|
||||
const mergedPsd = { ...existingPsd, ...incomingPsd };
|
||||
|
||||
// Deep-merge and normalize Codex limit policy defaults.
|
||||
if (existing.provider === "codex") {
|
||||
const incomingRecord = incomingPsd as Record<string, unknown>;
|
||||
if ("codexLimitPolicy" in incomingRecord || "codexLimitPolicy" in existingPsd) {
|
||||
mergedPsd.codexLimitPolicy = normalizeCodexLimitPolicy(
|
||||
incomingRecord.codexLimitPolicy,
|
||||
(existingPsd as Record<string, unknown>).codexLimitPolicy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateData.providerSpecificData = mergedPsd;
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse, type Request } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts";
|
||||
import { updateCodexServiceTierSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: typeof persisted?.enabled === "boolean" ? persisted.enabled : false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/codex-service-tier GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
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(updateCodexServiceTierSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = validation.data;
|
||||
await updateSettings({ codexServiceTier: config });
|
||||
setDefaultFastServiceTierEnabled(config.enabled);
|
||||
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/codex-service-tier PUT:", error);
|
||||
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -70,5 +75,9 @@ export async function POST(request) {
|
||||
}
|
||||
}
|
||||
|
||||
return handleAudioSpeech({ body, credentials });
|
||||
const response = await handleAudioSpeech({ body, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseTranscriptionModel, getTranscriptionProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -68,5 +73,9 @@ export async function POST(request) {
|
||||
}
|
||||
}
|
||||
|
||||
return handleAudioTranscription({ formData, credentials });
|
||||
const response = await handleAudioTranscription({ formData, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import {
|
||||
parseEmbeddingModel,
|
||||
getAllEmbeddingModels,
|
||||
@@ -126,6 +131,7 @@ export async function POST(request) {
|
||||
const result = await handleEmbedding({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import {
|
||||
parseImageModel,
|
||||
getAllImageModels,
|
||||
@@ -170,6 +175,7 @@ export async function POST(request) {
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -64,5 +69,9 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
return handleModeration({ body: { ...body, model }, credentials });
|
||||
const response = await handleModeration({ body: { ...body, model }, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import {
|
||||
parseMusicModel,
|
||||
getAllMusicModels,
|
||||
@@ -110,6 +115,7 @@ export async function POST(request) {
|
||||
const result = await handleMusicGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
|
||||
const result = await handleEmbedding({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -66,7 +71,7 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
|
||||
return handleRerank({
|
||||
const response = await handleRerank({
|
||||
model: body.model,
|
||||
query: body.query,
|
||||
documents: body.documents,
|
||||
@@ -74,4 +79,8 @@ export async function POST(request) {
|
||||
return_documents: body.return_documents,
|
||||
credentials,
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import {
|
||||
parseVideoModel,
|
||||
getAllVideoModels,
|
||||
@@ -110,6 +115,7 @@ export async function POST(request) {
|
||||
const result = await handleVideoGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -35,6 +35,13 @@ interface QuotaCacheEntry {
|
||||
windowDurationMs?: number | null; // T08: optional rolling window duration
|
||||
}
|
||||
|
||||
interface QuotaWindowStatus {
|
||||
remainingPercentage: number;
|
||||
usedPercentage: number;
|
||||
resetAt: string | null;
|
||||
reachedThreshold: boolean;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts
|
||||
@@ -89,6 +96,11 @@ function parseDate(value: string): number | null {
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
|
||||
let earliest: string | null = null;
|
||||
let earliestMs = Infinity;
|
||||
@@ -175,6 +187,45 @@ export function isAccountQuotaExhausted(connectionId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return quota window status for a connection (e.g., session/weekly).
|
||||
* Returns null when no cache or no window data is available.
|
||||
*/
|
||||
export function getQuotaWindowStatus(
|
||||
connectionId: string,
|
||||
windowName: string,
|
||||
thresholdPercent = 90
|
||||
): QuotaWindowStatus | null {
|
||||
const entry = cache.get(connectionId);
|
||||
if (!entry) return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const window = entry.quotas[windowName];
|
||||
if (!window) return null;
|
||||
|
||||
const remainingPercentage = clampPercent(window.remainingPercentage);
|
||||
const usedPercentage = clampPercent(100 - remainingPercentage);
|
||||
|
||||
let resetAt = window.resetAt || null;
|
||||
let windowExpired = false;
|
||||
if (resetAt) {
|
||||
const resetMs = parseDate(resetAt);
|
||||
if (resetMs !== null && resetMs <= now) {
|
||||
resetAt = null;
|
||||
windowExpired = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
remainingPercentage,
|
||||
usedPercentage,
|
||||
resetAt,
|
||||
// If reset time has already passed, avoid stale cached percentages blocking selection.
|
||||
reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an account as quota-exhausted from a 429 response (no quota data available).
|
||||
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "اختيار عشوائي موحد، ثم الرجوع إلى النماذج المتبقية",
|
||||
"leastUsedDesc": "يختار النموذج الذي يحتوي على أقل عدد من الطلبات، مع موازنة الحمل مع مرور الوقت",
|
||||
"costOptimizedDesc": "الطرق إلى النموذج الأرخص تعتمد أولاً على التسعير",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "نماذج",
|
||||
"autoBalance": "التوازن التلقائي",
|
||||
"advancedSettings": "الإعدادات المتقدمة",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "البريد الإلكتروني",
|
||||
"healthCheckMinutes": "فحص الصحة (دقيقة)",
|
||||
"healthCheckHint": "الفاصل الزمني لتحديث الرمز المميز. 0 = معطل.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "فشل في اختبار الاتصال",
|
||||
"failed": "فشل",
|
||||
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "اختر الحساب الأقل استخدامًا مؤخرًا",
|
||||
"costOpt": "خيار التكلفة",
|
||||
"costOptDesc": "تفضل أرخص حساب متاح",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "الحد اللزج",
|
||||
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
|
||||
"modelAliases": "الأسماء المستعارة النموذجية",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "الخطة الأولية: {plan}",
|
||||
"noPlanFromProvider": "لا توجد خطة من المزود",
|
||||
"noQuotaData": "لا توجد بيانات الحصص",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "لا توجد بيانات الحصص المتاحة",
|
||||
"noAccountsForTierFilter": "لم يتم العثور على حسابات لمرشح الطبقة",
|
||||
"tierAll": "الكل",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Единен случаен избор, след което се връща към останалите модели",
|
||||
"leastUsedDesc": "Избира модела с най-малко заявки, като балансира натоварването във времето",
|
||||
"costOptimizedDesc": "Първо маршрути към най-евтиния модел въз основа на ценообразуването",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Модели",
|
||||
"autoBalance": "Автоматичен баланс",
|
||||
"advancedSettings": "Разширени настройки",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Имейл",
|
||||
"healthCheckMinutes": "Проверка на здравето (мин.)",
|
||||
"healthCheckHint": "Интервал за опресняване на проактивен токен. 0 = забранено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Неуспешно тестване на връзката",
|
||||
"failed": "Неуспешно",
|
||||
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Изберете най-малко използван акаунт",
|
||||
"costOpt": "Цена Опт",
|
||||
"costOptDesc": "Предпочитайте най-евтиния наличен акаунт",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Лепкава граница",
|
||||
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
|
||||
"modelAliases": "Псевдоними на модела",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необработен план: {plan}",
|
||||
"noPlanFromProvider": "Няма план от доставчика",
|
||||
"noQuotaData": "Няма данни за квоти",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Няма налични данни за квота",
|
||||
"noAccountsForTierFilter": "Няма намерени акаунти за филтър за ниво",
|
||||
"tierAll": "Всички",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Ensartet tilfældig udvælgelse, derefter tilbagevenden til de resterende modeller",
|
||||
"leastUsedDesc": "Vælger modellen med færrest anmodninger, balancerer belastningen over tid",
|
||||
"costOptimizedDesc": "Ruter til den billigste model først baseret på priser",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Auto-balance",
|
||||
"advancedSettings": "Avancerede indstillinger",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Sundhedstjek (min)",
|
||||
"healthCheckHint": "Proaktivt token-opdateringsinterval. 0 = deaktiveret.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Forbindelsen kunne ikke testes",
|
||||
"failed": "Mislykkedes",
|
||||
"leaveBlankKeepCurrentApiKey": "Lad stå tomt for at beholde den aktuelle API-nøgle.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Vælg den mindst brugte konto",
|
||||
"costOpt": "Omkostningsopt",
|
||||
"costOptDesc": "Foretrækker den billigste tilgængelige konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Opkald pr. konto før skift",
|
||||
"modelAliases": "Model aliaser",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan fra udbyderen",
|
||||
"noQuotaData": "Ingen kvotedata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Ingen tilgængelige kvotedata",
|
||||
"noAccountsForTierFilter": "Der blev ikke fundet nogen konti til niveaufilter",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle",
|
||||
"leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus",
|
||||
"costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelle",
|
||||
"autoBalance": "Automatischer Ausgleich",
|
||||
"advancedSettings": "Erweiterte Einstellungen",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-Mail",
|
||||
"healthCheckMinutes": "Gesundheitscheck (Min.)",
|
||||
"healthCheckHint": "Proaktives Token-Aktualisierungsintervall. 0 = deaktiviert.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Die Verbindung konnte nicht getestet werden",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"leaveBlankKeepCurrentApiKey": "Lassen Sie das Feld leer, um den aktuellen API-Schlüssel beizubehalten.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus",
|
||||
"costOpt": "Kosten Opt",
|
||||
"costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky-Limit",
|
||||
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
|
||||
"modelAliases": "Modell-Aliase",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rohplan: {plan}",
|
||||
"noPlanFromProvider": "Kein Plan vom Anbieter",
|
||||
"noQuotaData": "Keine Quotendaten",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Keine Quotendaten verfügbar",
|
||||
"noAccountsForTierFilter": "Für den Stufenfilter wurden keine Konten gefunden",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Usage",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Costs",
|
||||
@@ -235,6 +236,26 @@
|
||||
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
|
||||
"done": "Done",
|
||||
"savePermissions": "Save Permissions",
|
||||
"autoResolve": "Auto-Resolve",
|
||||
"autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.",
|
||||
"keyActive": "Key Active",
|
||||
"keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.",
|
||||
"accessSchedule": "Access Schedule",
|
||||
"accessScheduleDesc": "Restrict access to specific hours and days of the week.",
|
||||
"scheduleFrom": "From",
|
||||
"scheduleUntil": "Until",
|
||||
"scheduleDays": "Days",
|
||||
"scheduleTimezone": "Timezone",
|
||||
"scheduleTimezoneHint": "Use IANA timezone names, e.g. America/New_York, Europe/Berlin",
|
||||
"scheduleActive": "Schedule",
|
||||
"disabled": "Disabled",
|
||||
"daySun": "Sun",
|
||||
"dayMon": "Mon",
|
||||
"dayTue": "Tue",
|
||||
"dayWed": "Wed",
|
||||
"dayThu": "Thu",
|
||||
"dayFri": "Fri",
|
||||
"daySat": "Sat",
|
||||
"allowAll": "Allow All",
|
||||
"restrict": "Restrict",
|
||||
"allowAllInfo": "This key can access all available models.",
|
||||
@@ -604,6 +625,8 @@
|
||||
"randomDesc": "Uniform random selection, then fallback to remaining models",
|
||||
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
|
||||
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Models",
|
||||
"autoBalance": "Auto-balance",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
@@ -1391,6 +1414,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Failed to test connection",
|
||||
"failed": "Failed",
|
||||
"leaveBlankKeepCurrentApiKey": "Leave blank to keep the current API key.",
|
||||
@@ -1562,6 +1587,8 @@
|
||||
"leastUsedDesc": "Pick least recently used account",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Prefer cheapest available account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Calls per account before switching",
|
||||
"modelAliases": "Model Aliases",
|
||||
@@ -2086,6 +2113,9 @@
|
||||
"rawPlanWithValue": "Raw plan: {plan}",
|
||||
"noPlanFromProvider": "No plan from provider",
|
||||
"noQuotaData": "No quota data",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "No quota data available",
|
||||
"noAccountsForTierFilter": "No accounts found for tier filter",
|
||||
"tierAll": "All",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selección aleatoria uniforme y luego recurrir a los modelos restantes.",
|
||||
"leastUsedDesc": "Elige el modelo con menos solicitudes y equilibra la carga a lo largo del tiempo.",
|
||||
"costOptimizedDesc": "Rutas al modelo más barato primero según el precio",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Equilibrio automático",
|
||||
"advancedSettings": "Configuración avanzada",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Correo electrónico",
|
||||
"healthCheckMinutes": "Control de salud (min)",
|
||||
"healthCheckHint": "Intervalo de actualización de token proactivo. 0 = deshabilitado.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "No se pudo probar la conexión",
|
||||
"failed": "Fallido",
|
||||
"leaveBlankKeepCurrentApiKey": "Déjelo en blanco para conservar la clave API actual.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Elija la cuenta utilizada menos recientemente",
|
||||
"costOpt": "Opción de costo",
|
||||
"costOptDesc": "Prefiere la cuenta más barata disponible",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Límite fijo",
|
||||
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
|
||||
"modelAliases": "Alias de modelo",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan sin formato: {plan}",
|
||||
"noPlanFromProvider": "Sin plan del proveedor",
|
||||
"noQuotaData": "Sin datos de cuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "No hay datos de cuota disponibles",
|
||||
"noAccountsForTierFilter": "No se encontraron cuentas para el filtro de niveles",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Yhtenäinen satunnainen valinta, sitten takaisin muihin malleihin",
|
||||
"leastUsedDesc": "Valitsee mallin, jolla on vähiten pyyntöjä ja tasapainottaa kuormitusta ajan myötä",
|
||||
"costOptimizedDesc": "Reitit edullisimpaan malliin ensin hinnoittelun perusteella",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Mallit",
|
||||
"autoBalance": "Automaattinen tasapainotus",
|
||||
"advancedSettings": "Lisäasetukset",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Sähköposti",
|
||||
"healthCheckMinutes": "Terveystarkastus (min)",
|
||||
"healthCheckHint": "Ennakoiva tunnuksen päivitysväli. 0 = pois käytöstä.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Yhteyden testaus epäonnistui",
|
||||
"failed": "Epäonnistui",
|
||||
"leaveBlankKeepCurrentApiKey": "Jätä tyhjäksi, jos haluat säilyttää nykyisen API-avaimen.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Valitse vähiten käytetty tili",
|
||||
"costOpt": "Kustannusopt",
|
||||
"costOptDesc": "Valitse halvin saatavilla oleva tili",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
|
||||
"modelAliases": "Mallin aliakset",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Raakasuunnitelma: {plan}",
|
||||
"noPlanFromProvider": "Ei suunnitelmaa palveluntarjoajalta",
|
||||
"noQuotaData": "Ei kiintiötietoja",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Kiintiötietoja ei ole saatavilla",
|
||||
"noAccountsForTierFilter": "Tasosuodattimelle ei löytynyt tilejä",
|
||||
"tierAll": "Kaikki",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Sélection aléatoire uniforme, puis retour aux modèles restants",
|
||||
"leastUsedDesc": "Sélectionne le modèle avec le moins de demandes, en équilibrant la charge au fil du temps",
|
||||
"costOptimizedDesc": "Itinéraires vers le modèle le moins cher en premier en fonction du prix",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modèles",
|
||||
"autoBalance": "Équilibre automatique",
|
||||
"advancedSettings": "Paramètres avancés",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Courriel",
|
||||
"healthCheckMinutes": "Bilan de santé (min)",
|
||||
"healthCheckHint": "Intervalle d’actualisation proactif des jetons. 0 = désactivé.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Échec du test de connexion",
|
||||
"failed": "Échec",
|
||||
"leaveBlankKeepCurrentApiKey": "Laissez vide pour conserver la clé API actuelle.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Choisissez le compte le moins récemment utilisé",
|
||||
"costOpt": "Option de coût",
|
||||
"costOptDesc": "Préférer le compte disponible le moins cher",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite collante",
|
||||
"stickyLimitDesc": "Appels par compte avant de changer",
|
||||
"modelAliases": "Alias de modèle",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan brut : {plan}",
|
||||
"noPlanFromProvider": "Aucun plan du fournisseur",
|
||||
"noQuotaData": "Aucune donnée de quota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Aucune donnée de quota disponible",
|
||||
"noAccountsForTierFilter": "Aucun compte trouvé pour le filtre de niveau",
|
||||
"tierAll": "Tout",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "בחירה אקראית אחידה, ואז חזרה לדגמים שנותרו",
|
||||
"leastUsedDesc": "בוחר את הדגם עם הכי פחות בקשות, מאזן עומס לאורך זמן",
|
||||
"costOptimizedDesc": "מסלולים לדגם הזול ביותר לפי תמחור",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "דגמים",
|
||||
"autoBalance": "איזון אוטומטי",
|
||||
"advancedSettings": "הגדרות מתקדמות",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "דוא\"ל",
|
||||
"healthCheckMinutes": "בדיקת בריאות (דקה)",
|
||||
"healthCheckHint": "מרווח רענון אסימון פרואקטיבי. 0 = מושבת.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "בדיקת החיבור נכשלה",
|
||||
"failed": "נכשל",
|
||||
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "בחר חשבון שנעשה בו שימוש לפחות לאחרונה",
|
||||
"costOpt": "אופטימיזציית עלות",
|
||||
"costOptDesc": "העדיפו את החשבון הזול ביותר הזמין",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "גבול דביק",
|
||||
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
|
||||
"modelAliases": "כינויי דגם",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "תוכנית גולמית: {plan}",
|
||||
"noPlanFromProvider": "אין תוכנית מספק",
|
||||
"noQuotaData": "אין נתוני מכסה",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "אין נתוני מכסה זמינים",
|
||||
"noAccountsForTierFilter": "לא נמצאו חשבונות עבור מסנן שכבות",
|
||||
"tierAll": "הכל",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Egységes véletlenszerű kiválasztás, majd visszaállás a többi modellhez",
|
||||
"leastUsedDesc": "A legkevesebb kéréssel rendelkező modellt választja, idővel kiegyensúlyozva a terhelést",
|
||||
"costOptimizedDesc": "Először a legolcsóbb modellhez vezet az árak alapján",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modellek",
|
||||
"autoBalance": "Automatikus egyensúly",
|
||||
"advancedSettings": "Speciális beállítások",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "állapotfelmérés (perc)",
|
||||
"healthCheckHint": "Proaktív token frissítési időköz. 0 = letiltva.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nem sikerült tesztelni a kapcsolatot",
|
||||
"failed": "Sikertelen",
|
||||
"leaveBlankKeepCurrentApiKey": "Hagyja üresen az aktuális API-kulcs megtartásához.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Válassza ki a legutóbb használt fiókot",
|
||||
"costOpt": "Költségopt",
|
||||
"costOptDesc": "A legolcsóbb elérhető fiók előnyben részesítése",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Ragadós határ",
|
||||
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
|
||||
"modelAliases": "Modell álnevek",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Nyers terv: {plan}",
|
||||
"noPlanFromProvider": "Nincs terv a szolgáltatótól",
|
||||
"noQuotaData": "Nincsenek kvótaadatok",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nem állnak rendelkezésre kvótaadatok",
|
||||
"noAccountsForTierFilter": "Nem található fiók a rétegszűrőhöz",
|
||||
"tierAll": "Mind",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Pemilihan acak seragam, lalu kembali ke model lainnya",
|
||||
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, menyeimbangkan beban dari waktu ke waktu",
|
||||
"costOptimizedDesc": "Rutekan ke model termurah terlebih dahulu berdasarkan harga",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Model",
|
||||
"autoBalance": "Keseimbangan otomatis",
|
||||
"advancedSettings": "Pengaturan Lanjutan",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Surel",
|
||||
"healthCheckMinutes": "Pemeriksaan Kesehatan (menit)",
|
||||
"healthCheckHint": "Interval penyegaran token proaktif. 0 = dinonaktifkan.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Gagal menguji koneksi",
|
||||
"failed": "Gagal",
|
||||
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mempertahankan kunci API saat ini.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pilih akun yang paling jarang digunakan",
|
||||
"costOpt": "Pilihan Biaya",
|
||||
"costOptDesc": "Lebih suka akun termurah yang tersedia",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Batas Lengket",
|
||||
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
|
||||
"modelAliases": "Alias Model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Paket mentah: {plan}",
|
||||
"noPlanFromProvider": "Tidak ada rencana dari penyedia",
|
||||
"noQuotaData": "Tidak ada data kuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Tidak ada data kuota yang tersedia",
|
||||
"noAccountsForTierFilter": "Tidak ditemukan akun untuk filter tingkat",
|
||||
"tierAll": "Semua",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "समान यादृच्छिक चयन, फिर शेष मॉडलों पर वापस लौटना",
|
||||
"leastUsedDesc": "समय के साथ लोड को संतुलित करते हुए, सबसे कम अनुरोधों वाला मॉडल चुनता है",
|
||||
"costOptimizedDesc": "मूल्य निर्धारण के आधार पर सबसे पहले सबसे सस्ते मॉडल पर रूट करें",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "मॉडल",
|
||||
"autoBalance": "स्वत: संतुलन",
|
||||
"advancedSettings": "उन्नत सेटिंग्स",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "ईमेल",
|
||||
"healthCheckMinutes": "स्वास्थ्य जांच (न्यूनतम)",
|
||||
"healthCheckHint": "प्रोएक्टिव टोकन ताज़ा अंतराल। 0 = अक्षम.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "कनेक्शन का परीक्षण करने में विफल",
|
||||
"failed": "असफल",
|
||||
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "कम से कम हाल ही में उपयोग किया गया खाता चुनें",
|
||||
"costOpt": "लागत विकल्प",
|
||||
"costOptDesc": "सबसे सस्ते उपलब्ध खाते को प्राथमिकता दें",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "चिपचिपी सीमा",
|
||||
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
|
||||
"modelAliases": "मॉडल उपनाम",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "कच्ची योजना: {plan}",
|
||||
"noPlanFromProvider": "प्रदाता की ओर से कोई योजना नहीं",
|
||||
"noQuotaData": "कोई कोटा डेटा नहीं",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "कोई कोटा डेटा उपलब्ध नहीं है",
|
||||
"noAccountsForTierFilter": "टियर फ़िल्टर के लिए कोई खाता नहीं मिला",
|
||||
"tierAll": "सब",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selezione casuale uniforme, quindi fallback sui modelli rimanenti",
|
||||
"leastUsedDesc": "Sceglie il modello con meno richieste, bilanciando il carico nel tempo",
|
||||
"costOptimizedDesc": "Percorsi prima verso il modello più economico in base al prezzo",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelli",
|
||||
"autoBalance": "Bilanciamento automatico",
|
||||
"advancedSettings": "Impostazioni avanzate",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Controllo dello stato (min)",
|
||||
"healthCheckHint": "Intervallo di aggiornamento del token proattivo. 0 = disabilitato.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Impossibile testare la connessione",
|
||||
"failed": "Fallito",
|
||||
"leaveBlankKeepCurrentApiKey": "Lascia vuoto per mantenere la chiave API corrente.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Scegli l'account utilizzato meno di recente",
|
||||
"costOpt": "Opzione costo",
|
||||
"costOptDesc": "Preferisci il conto più economico disponibile",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite appiccicoso",
|
||||
"stickyLimitDesc": "Chiamate per account prima del cambio",
|
||||
"modelAliases": "Alias del modello",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Piano grezzo: {plan}",
|
||||
"noPlanFromProvider": "Nessun piano dal fornitore",
|
||||
"noQuotaData": "Nessun dato sulle quote",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nessun dato sulle quote disponibile",
|
||||
"noAccountsForTierFilter": "Nessun account trovato per il filtro del livello",
|
||||
"tierAll": "Tutto",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "均一なランダム選択、その後残りのモデルへのフォールバック",
|
||||
"leastUsedDesc": "リクエストが最も少ないモデルを選択し、時間の経過とともに負荷のバランスをとります",
|
||||
"costOptimizedDesc": "価格に基づいて最初に最も安価なモデルにルーティングします",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "モデル",
|
||||
"autoBalance": "オートバランス",
|
||||
"advancedSettings": "詳細設定",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "電子メール",
|
||||
"healthCheckMinutes": "ヘルスチェック (分)",
|
||||
"healthCheckHint": "プロアクティブなトークンの更新間隔。 0 = 無効。",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "接続のテストに失敗しました",
|
||||
"failed": "失敗しました",
|
||||
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "最も最近使用されていないアカウントを選択する",
|
||||
"costOpt": "コストオプション",
|
||||
"costOptDesc": "利用可能な最も安いアカウントを優先する",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "スティッキー制限",
|
||||
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
|
||||
"modelAliases": "モデルのエイリアス",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "未加工のプラン: {plan}",
|
||||
"noPlanFromProvider": "プロバイダーからのプランなし",
|
||||
"noQuotaData": "クォータ データがありません",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "利用可能なクォータ データがありません",
|
||||
"noAccountsForTierFilter": "層フィルターのアカウントが見つかりませんでした",
|
||||
"tierAll": "すべて",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "균일한 무작위 선택 후 나머지 모델로 대체",
|
||||
"leastUsedDesc": "시간이 지남에 따라 로드 밸런싱을 통해 요청이 가장 적은 모델을 선택합니다.",
|
||||
"costOptimizedDesc": "가격을 기준으로 가장 저렴한 모델로 먼저 라우팅",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "모델",
|
||||
"autoBalance": "자동 균형",
|
||||
"advancedSettings": "고급 설정",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "이메일",
|
||||
"healthCheckMinutes": "상태 점검(분)",
|
||||
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "연결을 테스트하지 못했습니다.",
|
||||
"failed": "실패",
|
||||
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "최근에 가장 적게 사용한 계정 선택",
|
||||
"costOpt": "비용 선택",
|
||||
"costOptDesc": "가장 저렴한 계정을 선호합니다",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "고정 한도",
|
||||
"stickyLimitDesc": "전환 전 계정당 통화",
|
||||
"modelAliases": "모델 별칭",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "기본 계획: {plan}",
|
||||
"noPlanFromProvider": "공급자의 계획 없음",
|
||||
"noQuotaData": "할당량 데이터 없음",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "사용 가능한 할당량 데이터가 없습니다.",
|
||||
"noAccountsForTierFilter": "등급 필터에 대한 계정을 찾을 수 없습니다.",
|
||||
"tierAll": "모두",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Pemilihan rawak seragam, kemudian sandarkan kepada model yang tinggal",
|
||||
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, mengimbangi beban dari semasa ke semasa",
|
||||
"costOptimizedDesc": "Laluan ke model termurah dahulu berdasarkan harga",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "model",
|
||||
"autoBalance": "Imbangan automatik",
|
||||
"advancedSettings": "Tetapan Lanjutan",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mel",
|
||||
"healthCheckMinutes": "Pemeriksaan Kesihatan (min)",
|
||||
"healthCheckHint": "Selang penyegaran token proaktif. 0 = kurang upaya.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Gagal menguji sambungan",
|
||||
"failed": "gagal",
|
||||
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mengekalkan kunci API semasa.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pilih akaun yang paling kurang digunakan baru-baru ini",
|
||||
"costOpt": "Pilihan Kos",
|
||||
"costOptDesc": "Pilih akaun termurah yang tersedia",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Had Melekit",
|
||||
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
|
||||
"modelAliases": "Alias Model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Pelan mentah: {plan}",
|
||||
"noPlanFromProvider": "Tiada pelan daripada pembekal",
|
||||
"noQuotaData": "Tiada data kuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Tiada data kuota tersedia",
|
||||
"noAccountsForTierFilter": "Tiada akaun ditemui untuk penapis peringkat",
|
||||
"tierAll": "Semua",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Uniforme willekeurige selectie en vervolgens terugvallen op de resterende modellen",
|
||||
"leastUsedDesc": "Kiest het model met de minste verzoeken, waarbij de belasting in de loop van de tijd wordt verdeeld",
|
||||
"costOptimizedDesc": "Routes eerst naar het goedkoopste model op basis van prijzen",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modellen",
|
||||
"autoBalance": "Automatische balans",
|
||||
"advancedSettings": "Geavanceerde instellingen",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Gezondheidscontrole (min)",
|
||||
"healthCheckHint": "Proactief tokenvernieuwingsinterval. 0 = uitgeschakeld.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Kan de verbinding niet testen",
|
||||
"failed": "Mislukt",
|
||||
"leaveBlankKeepCurrentApiKey": "Laat dit leeg om de huidige API-sleutel te behouden.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Kies het minst recent gebruikte account",
|
||||
"costOpt": "Kosten opt",
|
||||
"costOptDesc": "Geef de voorkeur aan het goedkoopste beschikbare account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Kleverige limiet",
|
||||
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
|
||||
"modelAliases": "Modelaliassen",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Ruw plan: {plan}",
|
||||
"noPlanFromProvider": "Geen abonnement van aanbieder",
|
||||
"noQuotaData": "Geen quotagegevens",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Geen quotagegevens beschikbaar",
|
||||
"noAccountsForTierFilter": "Er zijn geen accounts gevonden voor niveaufilter",
|
||||
"tierAll": "Allemaal",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Ensartet tilfeldig valg, deretter fallback til gjenværende modeller",
|
||||
"leastUsedDesc": "Velger modellen med færrest forespørsler, og balanserer belastningen over tid",
|
||||
"costOptimizedDesc": "Ruter til den billigste modellen først basert på priser",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Autobalanse",
|
||||
"advancedSettings": "Avanserte innstillinger",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-post",
|
||||
"healthCheckMinutes": "Helsesjekk (min)",
|
||||
"healthCheckHint": "Proaktivt token-oppdateringsintervall. 0 = deaktivert.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Kunne ikke teste tilkoblingen",
|
||||
"failed": "Mislyktes",
|
||||
"leaveBlankKeepCurrentApiKey": "La stå tomt for å beholde gjeldende API-nøkkel.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Velg minst nylig brukte konto",
|
||||
"costOpt": "Kostnad Opt",
|
||||
"costOptDesc": "Foretrekker den billigste tilgjengelige kontoen",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Anrop per konto før bytte",
|
||||
"modelAliases": "Modellaliaser",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan fra leverandøren",
|
||||
"noQuotaData": "Ingen kvotedata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Ingen kvotedata tilgjengelig",
|
||||
"noAccountsForTierFilter": "Fant ingen kontoer for nivåfilter",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Uniform random selection, pagkatapos ay fallback sa natitirang mga modelo",
|
||||
"leastUsedDesc": "Pinipili ang modelo na may kaunting mga kahilingan, binabalanse ang pagkarga sa paglipas ng panahon",
|
||||
"costOptimizedDesc": "Mga ruta muna sa pinakamurang modelo batay sa pagpepresyo",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Mga modelo",
|
||||
"autoBalance": "Awtomatikong balanse",
|
||||
"advancedSettings": "Mga Advanced na Setting",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = hindi pinagana.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nabigong subukan ang koneksyon",
|
||||
"failed": "Nabigo",
|
||||
"leaveBlankKeepCurrentApiKey": "Iwanang blangko upang mapanatili ang kasalukuyang API key.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pumili ng hindi bababa sa kamakailang ginamit na account",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Mas gusto ang pinakamurang available na account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Malagkit na Limitasyon",
|
||||
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
|
||||
"modelAliases": "Mga Alyas ng Modelo",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Raw plan: {plan}",
|
||||
"noPlanFromProvider": "Walang plano mula sa provider",
|
||||
"noQuotaData": "Walang data ng quota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Walang available na data ng quota",
|
||||
"noAccountsForTierFilter": "Walang nahanap na account para sa tier filter",
|
||||
"tierAll": "Lahat",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Jednolity wybór losowy, a następnie powrót do pozostałych modeli",
|
||||
"leastUsedDesc": "Wybiera model z najmniejszą liczbą żądań, równoważąc obciążenie w czasie",
|
||||
"costOptimizedDesc": "Najpierw wybiera najtańszy model na podstawie ceny",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modele",
|
||||
"autoBalance": "Automatyczne balansowanie",
|
||||
"advancedSettings": "Ustawienia zaawansowane",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Kontrola stanu (min)",
|
||||
"healthCheckHint": "Proaktywny interwał odświeżania tokenu. 0 = wyłączone.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nie udało się przetestować połączenia",
|
||||
"failed": "Nie udało się",
|
||||
"leaveBlankKeepCurrentApiKey": "Pozostaw puste, aby zachować bieżący klucz API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Wybierz ostatnio używane konto",
|
||||
"costOpt": "Opcja kosztowa",
|
||||
"costOptDesc": "Preferuj najtańsze dostępne konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Lepki limit",
|
||||
"stickyLimitDesc": "Połączenia na konto przed zmianą",
|
||||
"modelAliases": "Aliasy modeli",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Surowy plan: {plan}",
|
||||
"noPlanFromProvider": "Brak planu od dostawcy",
|
||||
"noQuotaData": "Brak danych dotyczących kwot",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Brak dostępnych danych dotyczących kwot",
|
||||
"noAccountsForTierFilter": "Nie znaleziono kont dla filtra poziomów",
|
||||
"tierAll": "Wszystko",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"dashboard": "Painel",
|
||||
"providers": "Provedores",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Uso",
|
||||
"analytics": "Análises",
|
||||
"costs": "Custos",
|
||||
@@ -235,6 +236,26 @@
|
||||
"keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.",
|
||||
"done": "Pronto",
|
||||
"savePermissions": "Salvar Permissões",
|
||||
"autoResolve": "Auto-Resolve",
|
||||
"autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.",
|
||||
"keyActive": "Chave Ativa",
|
||||
"keyActiveDesc": "Ativa ou desativa esta API key. Chaves desativadas são bloqueadas com 403.",
|
||||
"accessSchedule": "Horário de Acesso",
|
||||
"accessScheduleDesc": "Restrinja o acesso a horários e dias da semana específicos.",
|
||||
"scheduleFrom": "Das",
|
||||
"scheduleUntil": "Até",
|
||||
"scheduleDays": "Dias",
|
||||
"scheduleTimezone": "Fuso Horário",
|
||||
"scheduleTimezoneHint": "Use nomes IANA, ex: America/Sao_Paulo",
|
||||
"scheduleActive": "Agenda",
|
||||
"disabled": "Desativada",
|
||||
"daySun": "Dom",
|
||||
"dayMon": "Seg",
|
||||
"dayTue": "Ter",
|
||||
"dayWed": "Qua",
|
||||
"dayThu": "Qui",
|
||||
"dayFri": "Sex",
|
||||
"daySat": "Sáb",
|
||||
"allowAll": "Permitir Tudo",
|
||||
"restrict": "Restringir",
|
||||
"allowAllInfo": "Esta chave pode acessar todos os modelos disponíveis.",
|
||||
@@ -604,6 +625,8 @@
|
||||
"randomDesc": "Seleção aleatória uniforme, depois fallback para modelos restantes",
|
||||
"leastUsedDesc": "Escolhe o modelo com menos requisições, equilibrando carga ao longo do tempo",
|
||||
"costOptimizedDesc": "Roteia para o modelo mais barato primeiro baseado em preços",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Auto-balancear",
|
||||
"advancedSettings": "Configurações Avançadas",
|
||||
@@ -652,6 +675,11 @@
|
||||
"when": "Redução de custo é a prioridade principal.",
|
||||
"avoid": "A base de preços está ausente ou desatualizada.",
|
||||
"example": "Jobs em lote ou segundo plano focados em menor custo."
|
||||
},
|
||||
"strict-random": {
|
||||
"when": "Você quer distribuição perfeitamente uniforme — cada modelo é usado uma vez antes de repetir.",
|
||||
"avoid": "Os modelos têm qualidade ou latência muito diferentes e a ordem importa.",
|
||||
"example": "Múltiplas contas do mesmo modelo para distribuir uso de forma equilibrada."
|
||||
}
|
||||
},
|
||||
"advancedHelp": {
|
||||
@@ -705,46 +733,53 @@
|
||||
"recommendationsApplied": "Recommendations applied to this combo.",
|
||||
"strategyRecommendations": {
|
||||
"priority": {
|
||||
"title": "Fail-safe baseline",
|
||||
"description": "Use one primary model and keep fallback chain short and reliable.",
|
||||
"tip1": "Put your most reliable model first.",
|
||||
"tip2": "Keep 1-2 backup models with similar quality.",
|
||||
"tip3": "Use safe retries to absorb transient provider failures."
|
||||
"title": "Fail-safe básico",
|
||||
"description": "Use um modelo principal e mantenha a cadeia de fallback curta e confiável.",
|
||||
"tip1": "Coloque o modelo mais confiável em primeiro.",
|
||||
"tip2": "Mantenha 1-2 modelos de backup com qualidade similar.",
|
||||
"tip3": "Use retries seguros para absorver falhas transitórias do provedor."
|
||||
},
|
||||
"weighted": {
|
||||
"title": "Controlled traffic split",
|
||||
"description": "Great for canary rollouts and gradual migration between models.",
|
||||
"tip1": "Start with conservative split like 90/10.",
|
||||
"tip2": "Keep the total at 100% and auto-balance after changes.",
|
||||
"tip3": "Monitor success and latency before increasing canary weight."
|
||||
"title": "Divisão controlada de tráfego",
|
||||
"description": "Ótimo para rollouts canário e migração gradual entre modelos.",
|
||||
"tip1": "Comece com divisão conservadora tipo 90/10.",
|
||||
"tip2": "Mantenha o total em 100% e rebalanceie após mudanças.",
|
||||
"tip3": "Monitore sucesso e latência antes de aumentar o peso canário."
|
||||
},
|
||||
"round-robin": {
|
||||
"title": "Predictable load sharing",
|
||||
"description": "Best when models are equivalent and you need smooth distribution.",
|
||||
"tip1": "Use at least 2 models.",
|
||||
"tip2": "Set concurrency limits to avoid burst overload.",
|
||||
"tip3": "Use queue timeout to fail fast under saturation."
|
||||
"title": "Distribuição previsível de carga",
|
||||
"description": "Melhor quando os modelos são equivalentes e você precisa de distribuição uniforme.",
|
||||
"tip1": "Use pelo menos 2 modelos.",
|
||||
"tip2": "Configure limites de concorrência para evitar sobrecarga.",
|
||||
"tip3": "Use timeout de fila para falhar rápido sob saturação."
|
||||
},
|
||||
"random": {
|
||||
"title": "Quick spread with low setup",
|
||||
"description": "Use when you need simple distribution without strict guarantees.",
|
||||
"tip1": "Use models with similar latency profiles.",
|
||||
"tip2": "Keep retries enabled to absorb random misses.",
|
||||
"tip3": "Prefer this for experimentation, not strict SLAs."
|
||||
"title": "Distribuição rápida com baixa configuração",
|
||||
"description": "Use quando precisar de distribuição simples sem garantias rígidas.",
|
||||
"tip1": "Use modelos com perfis de latência semelhantes.",
|
||||
"tip2": "Mantenha retries habilitados para absorver falhas aleatórias.",
|
||||
"tip3": "Prefira para experimentação, não para SLAs rígidos."
|
||||
},
|
||||
"least-used": {
|
||||
"title": "Adaptive balancing",
|
||||
"description": "Routes to less-used models to reduce hotspots over time.",
|
||||
"tip1": "Works better under continuous traffic.",
|
||||
"tip2": "Combine with health checks for safer balancing.",
|
||||
"tip3": "Track per-model usage to validate distribution gains."
|
||||
"title": "Balanceamento adaptativo",
|
||||
"description": "Roteia para modelos menos usados para reduzir hotspots ao longo do tempo.",
|
||||
"tip1": "Funciona melhor sob tráfego contínuo.",
|
||||
"tip2": "Combine com health checks para balanceamento mais seguro.",
|
||||
"tip3": "Acompanhe uso por modelo para validar ganhos na distribuição."
|
||||
},
|
||||
"cost-optimized": {
|
||||
"title": "Budget-first routing",
|
||||
"description": "Routes to lower-cost models when pricing metadata is available.",
|
||||
"tip1": "Ensure pricing coverage for all selected models.",
|
||||
"tip2": "Keep a quality fallback for hard prompts.",
|
||||
"tip3": "Use for batch/background jobs where cost is the main KPI."
|
||||
"title": "Roteamento por orçamento",
|
||||
"description": "Roteia para modelos mais baratos quando metadados de preço estão disponíveis.",
|
||||
"tip1": "Garanta cobertura de preços para todos os modelos selecionados.",
|
||||
"tip2": "Mantenha um fallback de qualidade para prompts difíceis.",
|
||||
"tip3": "Use para jobs em lote/background onde custo é o KPI principal."
|
||||
},
|
||||
"strict-random": {
|
||||
"title": "Distribuição estritamente uniforme",
|
||||
"description": "Cada modelo é usado exatamente uma vez antes de reembaralhar o baralho.",
|
||||
"tip1": "Ideal para múltiplas contas do mesmo modelo.",
|
||||
"tip2": "Garante que nenhuma conta é repetida antes de todas serem usadas.",
|
||||
"tip3": "Combine com health checks para pular contas indisponíveis sem quebrar o ciclo."
|
||||
}
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
@@ -1378,6 +1413,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
|
||||
"groupLabel": "Ambiente",
|
||||
"groupPlaceholder": "ex: eKaizen, Pessoal",
|
||||
"failedTestConnection": "Falha ao testar conexão",
|
||||
"failed": "Falhou",
|
||||
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
|
||||
@@ -1548,6 +1585,8 @@
|
||||
"leastUsedDesc": "Escolher a conta usada menos recentemente",
|
||||
"costOpt": "Custo Otimizado",
|
||||
"costOptDesc": "Preferir conta mais barata disponível",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada conta uma vez antes de reembaralhar",
|
||||
"stickyLimit": "Limite Fixo",
|
||||
"stickyLimitDesc": "Chamadas por conta antes de trocar",
|
||||
"modelAliases": "Aliases de Modelo",
|
||||
@@ -2072,6 +2111,9 @@
|
||||
"rawPlanWithValue": "Plano bruto: {plan}",
|
||||
"noPlanFromProvider": "Sem plano do provedor",
|
||||
"noQuotaData": "Sem dados de cota",
|
||||
"ungrouped": "Sem grupo",
|
||||
"viewFlat": "Lista",
|
||||
"viewByEnvironment": "Por Ambiente",
|
||||
"noQuotaDataAvailable": "Nenhum dado de cota disponível",
|
||||
"noAccountsForTierFilter": "Nenhuma conta encontrada para o filtro de plano",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Seleção aleatória uniforme e, em seguida, retorno aos modelos restantes",
|
||||
"leastUsedDesc": "Escolhe o modelo com menos solicitações, equilibrando a carga ao longo do tempo",
|
||||
"costOptimizedDesc": "Rotas para o modelo mais barato primeiro com base no preço",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Equilíbrio automático",
|
||||
"advancedSettings": "Configurações avançadas",
|
||||
@@ -1390,6 +1392,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Verificação de integridade (min)",
|
||||
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Falha ao testar a conexão",
|
||||
"failed": "Falha",
|
||||
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave API atual.",
|
||||
@@ -1555,6 +1559,8 @@
|
||||
"leastUsedDesc": "Escolha a conta usada menos recentemente",
|
||||
"costOpt": "Opção de custo",
|
||||
"costOptDesc": "Prefira a conta mais barata disponível",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite pegajoso",
|
||||
"stickyLimitDesc": "Chamadas por conta antes de mudar",
|
||||
"modelAliases": "Aliases de modelo",
|
||||
@@ -2084,6 +2090,9 @@
|
||||
"rawPlanWithValue": "Plano bruto: {plan}",
|
||||
"noPlanFromProvider": "Nenhum plano do provedor",
|
||||
"noQuotaData": "Sem dados de cota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Não há dados de cota disponíveis",
|
||||
"noAccountsForTierFilter": "Nenhuma conta encontrada para filtro de nível",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selectare aleatorie uniformă, apoi revenire la modelele rămase",
|
||||
"leastUsedDesc": "Alege modelul cu cele mai puține solicitări, echilibrând sarcina în timp",
|
||||
"costOptimizedDesc": "Rute către cel mai ieftin model mai întâi pe baza prețului",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modele",
|
||||
"autoBalance": "Auto-echilibrare",
|
||||
"advancedSettings": "Setări avansate",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Verificare de sănătate (min)",
|
||||
"healthCheckHint": "Interval proactiv de reîmprospătare a simbolului. 0 = dezactivat.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nu s-a testat conexiunea",
|
||||
"failed": "A eșuat",
|
||||
"leaveBlankKeepCurrentApiKey": "Lăsați necompletat pentru a păstra cheia API curentă.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Alegeți contul cel mai puțin utilizat recent",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Prefer cel mai ieftin cont disponibil",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limită lipicioasă",
|
||||
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
|
||||
"modelAliases": "Aliasuri de model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan brut: {plan}",
|
||||
"noPlanFromProvider": "Niciun plan de la furnizor",
|
||||
"noQuotaData": "Fără date de cotă",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nu sunt disponibile date privind cotele",
|
||||
"noAccountsForTierFilter": "Nu s-au găsit conturi pentru filtrul de nivel",
|
||||
"tierAll": "Toate",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Равномерный случайный выбор, затем возврат к оставшимся моделям",
|
||||
"leastUsedDesc": "Выбирает модель с наименьшим количеством запросов, балансируя нагрузку с течением времени.",
|
||||
"costOptimizedDesc": "Маршруты к самой дешевой модели в первую очередь на основе цены",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Модели",
|
||||
"autoBalance": "Автобаланс",
|
||||
"advancedSettings": "Расширенные настройки",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "электронная почта",
|
||||
"healthCheckMinutes": "Проверка здоровья (мин)",
|
||||
"healthCheckHint": "Интервал обновления упреждающего токена. 0 = отключено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Не удалось проверить соединение",
|
||||
"failed": "Не удалось",
|
||||
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Выберите наименее использованную учетную запись",
|
||||
"costOpt": "Опция стоимости",
|
||||
"costOptDesc": "Предпочитаю самый дешевый доступный аккаунт",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Липкий лимит",
|
||||
"stickyLimitDesc": "Звонки на аккаунт до переключения",
|
||||
"modelAliases": "Псевдонимы моделей",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необработанный план: {plan}",
|
||||
"noPlanFromProvider": "Нет плана от провайдера",
|
||||
"noQuotaData": "Нет данных о квотах",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Нет данных о квотах",
|
||||
"noAccountsForTierFilter": "Аккаунты для фильтра уровня не найдены",
|
||||
"tierAll": "Все",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Jednotný náhodný výber, potom návrat k zostávajúcim modelom",
|
||||
"leastUsedDesc": "Vyberie model s najmenším počtom požiadaviek, čím vyrovná zaťaženie v priebehu času",
|
||||
"costOptimizedDesc": "Najprv sa presmeruje na najlacnejší model na základe ceny",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelky",
|
||||
"autoBalance": "Automatické vyváženie",
|
||||
"advancedSettings": "Rozšírené nastavenia",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Kontrola stavu (min)",
|
||||
"healthCheckHint": "Interval proaktívneho obnovenia tokenu. 0 = vypnuté.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nepodarilo sa otestovať pripojenie",
|
||||
"failed": "Nepodarilo sa",
|
||||
"leaveBlankKeepCurrentApiKey": "Ak chcete zachovať aktuálny kľúč API, nechajte pole prázdne.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Vyberte najmenej nedávno používaný účet",
|
||||
"costOpt": "Opt",
|
||||
"costOptDesc": "Uprednostnite najlacnejší dostupný účet",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Hovory na účet pred prepnutím",
|
||||
"modelAliases": "Aliasy modelov",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Nespracovaný plán: {plan}",
|
||||
"noPlanFromProvider": "Žiadny plán od poskytovateľa",
|
||||
"noQuotaData": "Žiadne údaje o kvóte",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nie sú k dispozícii žiadne údaje o kvóte",
|
||||
"noAccountsForTierFilter": "Pre filter úrovne sa nenašli žiadne účty",
|
||||
"tierAll": "Všetky",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Enhetligt slumpmässigt urval, sedan fallback till återstående modeller",
|
||||
"leastUsedDesc": "Väljer modellen med minst förfrågningar, balanserar belastningen över tiden",
|
||||
"costOptimizedDesc": "Rutter till den billigaste modellen först baserat på prissättning",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Automatisk balansering",
|
||||
"advancedSettings": "Avancerade inställningar",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-post",
|
||||
"healthCheckMinutes": "Hälsokontroll (min)",
|
||||
"healthCheckHint": "Proaktivt uppdateringsintervall för token. 0 = inaktiverad.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Det gick inte att testa anslutningen",
|
||||
"failed": "Misslyckades",
|
||||
"leaveBlankKeepCurrentApiKey": "Lämna tomt för att behålla den aktuella API-nyckeln.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Välj minst senast använda konto",
|
||||
"costOpt": "Kostnad Opt",
|
||||
"costOptDesc": "Föredrar billigaste tillgängliga konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Samtal per konto innan byte",
|
||||
"modelAliases": "Modellalias",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan från leverantören",
|
||||
"noQuotaData": "Inga kvotdata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Inga kvotdata tillgängliga",
|
||||
"noAccountsForTierFilter": "Inga konton hittades för nivåfilter",
|
||||
"tierAll": "Alla",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "การเลือกแบบสุ่มแบบสม่ำเสมอ จากนั้นจึงย้อนกลับไปยังโมเดลที่เหลือ",
|
||||
"leastUsedDesc": "เลือกโมเดลที่มีคำขอน้อยที่สุด โดยจะปรับสมดุลการโหลดเมื่อเวลาผ่านไป",
|
||||
"costOptimizedDesc": "กำหนดเส้นทางไปยังรุ่นที่ถูกที่สุดก่อนตามราคา",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "โมเดล",
|
||||
"autoBalance": "ปรับสมดุลอัตโนมัติ",
|
||||
"advancedSettings": "การตั้งค่าขั้นสูง",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "อีเมล",
|
||||
"healthCheckMinutes": "ตรวจสุขภาพ (ขั้นต่ำ)",
|
||||
"healthCheckHint": "ช่วงเวลาการรีเฟรชโทเค็นเชิงรุก 0 = ปิดการใช้งาน",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "ทดสอบการเชื่อมต่อไม่สำเร็จ",
|
||||
"failed": "ล้มเหลว",
|
||||
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "เลือกบัญชีที่ใช้ล่าสุดน้อยที่สุด",
|
||||
"costOpt": "การเลือกใช้ต้นทุน",
|
||||
"costOptDesc": "ต้องการบัญชีที่ถูกที่สุด",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "ขีด จำกัด เหนียว",
|
||||
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
|
||||
"modelAliases": "นามแฝงของโมเดล",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "แผนดิบ: {plan}",
|
||||
"noPlanFromProvider": "ไม่มีแผนจากผู้ให้บริการ",
|
||||
"noQuotaData": "ไม่มีข้อมูลโควต้า",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "ไม่มีข้อมูลโควต้า",
|
||||
"noAccountsForTierFilter": "ไม่พบบัญชีสำหรับตัวกรองระดับ",
|
||||
"tierAll": "ทั้งหมด",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Рівномірний випадковий вибір, а потім повернення до інших моделей",
|
||||
"leastUsedDesc": "Вибирає модель із найменшою кількістю запитів, балансуючи навантаження за часом",
|
||||
"costOptimizedDesc": "Маршрути до найдешевшої моделі на основі ціни",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Моделі",
|
||||
"autoBalance": "Автобаланс",
|
||||
"advancedSettings": "Розширені налаштування",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Електронна пошта",
|
||||
"healthCheckMinutes": "Перевірка стану (хв.)",
|
||||
"healthCheckHint": "Проактивний інтервал оновлення маркера. 0 = вимкнено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Не вдалося перевірити з’єднання",
|
||||
"failed": "Не вдалося",
|
||||
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Виберіть нещодавно використовуваний обліковий запис",
|
||||
"costOpt": "Вартість Opt",
|
||||
"costOptDesc": "Віддайте перевагу найдешевшому доступному обліковому запису",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
|
||||
"modelAliases": "Псевдоніми моделі",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необроблений план: {plan}",
|
||||
"noPlanFromProvider": "Без плану від провайдера",
|
||||
"noQuotaData": "Немає даних про квоти",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Немає даних про квоти",
|
||||
"noAccountsForTierFilter": "Не знайдено облікових записів для фільтра рівня",
|
||||
"tierAll": "всі",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Lựa chọn ngẫu nhiên thống nhất, sau đó dự phòng cho các mô hình còn lại",
|
||||
"leastUsedDesc": "Chọn mô hình có ít yêu cầu nhất, cân bằng tải theo thời gian",
|
||||
"costOptimizedDesc": "Hướng tới mô hình rẻ nhất trước tiên dựa trên giá cả",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Người mẫu",
|
||||
"autoBalance": "Tự động cân bằng",
|
||||
"advancedSettings": "Cài đặt nâng cao",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Kiểm tra sức khỏe (phút)",
|
||||
"healthCheckHint": "Khoảng thời gian làm mới mã thông báo chủ động. 0 = bị vô hiệu hóa.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Không thể kiểm tra kết nối",
|
||||
"failed": "thất bại",
|
||||
"leaveBlankKeepCurrentApiKey": "Để trống để giữ khóa API hiện tại.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Chọn tài khoản ít được sử dụng gần đây nhất",
|
||||
"costOpt": "Lựa chọn chi phí",
|
||||
"costOptDesc": "Ưu tiên tài khoản có sẵn rẻ nhất",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Giới hạn dính",
|
||||
"stickyLimitDesc": "Cuộc gọi trên mỗi tài khoản trước khi chuyển đổi",
|
||||
"modelAliases": "Bí danh mẫu",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Gói thô: {plan}",
|
||||
"noPlanFromProvider": "Không có kế hoạch từ nhà cung cấp",
|
||||
"noQuotaData": "Không có dữ liệu hạn ngạch",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Không có sẵn dữ liệu hạn ngạch",
|
||||
"noAccountsForTierFilter": "Không tìm thấy tài khoản nào cho bộ lọc cấp độ",
|
||||
"tierAll": "Tất cả",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "统一随机选择,然后回退到剩余模型",
|
||||
"leastUsedDesc": "选择请求最少的模型,随着时间的推移平衡负载",
|
||||
"costOptimizedDesc": "首先根据定价路由至最便宜的型号",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "型号",
|
||||
"autoBalance": "自动平衡",
|
||||
"advancedSettings": "高级设置",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "电子邮件",
|
||||
"healthCheckMinutes": "健康检查(分钟)",
|
||||
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "测试连接失败",
|
||||
"failed": "失败",
|
||||
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "选择最近最少使用的帐户",
|
||||
"costOpt": "成本选择",
|
||||
"costOptDesc": "更喜欢最便宜的可用帐户",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "粘性限制",
|
||||
"stickyLimitDesc": "切换前每个账户的通话次数",
|
||||
"modelAliases": "模型别名",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "原始计划:{plan}",
|
||||
"noPlanFromProvider": "提供商没有计划",
|
||||
"noQuotaData": "无配额数据",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "无可用配额数据",
|
||||
"noAccountsForTierFilter": "未找到适用于层过滤器的帐户",
|
||||
"tierAll": "全部",
|
||||
|
||||
+13
-1
@@ -52,7 +52,9 @@ export async function register() {
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
|
||||
const { setDefaultFastServiceTierEnabled } = await import("@omniroute/open-sse/executors/codex.ts");
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
@@ -65,9 +67,19 @@ export async function register() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
if (typeof persisted?.enabled === "boolean") {
|
||||
setDefaultFastServiceTierEnabled(persisted.enabled);
|
||||
console.log(`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not restore model aliases:", msg);
|
||||
console.warn("[STARTUP] Could not restore runtime settings:", msg);
|
||||
}
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
|
||||
+175
-4
@@ -20,12 +20,24 @@ interface CacheEntry<TValue> {
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
machineId: string | null;
|
||||
allowedModels: string[];
|
||||
allowedConnections: string[];
|
||||
noLog: boolean;
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
}
|
||||
|
||||
interface ApiKeyRow extends JsonRecord {
|
||||
@@ -36,8 +48,16 @@ interface ApiKeyRow extends JsonRecord {
|
||||
machineId?: unknown;
|
||||
allowed_models?: unknown;
|
||||
allowedModels?: unknown;
|
||||
allowed_connections?: unknown;
|
||||
allowedConnections?: unknown;
|
||||
no_log?: unknown;
|
||||
noLog?: unknown;
|
||||
auto_resolve?: unknown;
|
||||
autoResolve?: unknown;
|
||||
is_active?: unknown;
|
||||
isActive?: unknown;
|
||||
access_schedule?: unknown;
|
||||
accessSchedule?: unknown;
|
||||
}
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
@@ -63,7 +83,11 @@ interface ApiKeysStatements {
|
||||
interface ApiKeyView extends JsonRecord {
|
||||
id?: string;
|
||||
allowedModels: string[];
|
||||
allowedConnections: string[];
|
||||
noLog: boolean;
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
}
|
||||
|
||||
// LRU cache for API key validation (valid keys only)
|
||||
@@ -147,6 +171,22 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN no_log INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.no_log column");
|
||||
}
|
||||
if (!columnNames.has("allowed_connections")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_connections TEXT");
|
||||
console.log("[DB] Added api_keys.allowed_connections column");
|
||||
}
|
||||
if (!columnNames.has("auto_resolve")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN auto_resolve INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.auto_resolve column");
|
||||
}
|
||||
if (!columnNames.has("is_active")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1");
|
||||
console.log("[DB] Added api_keys.is_active column");
|
||||
}
|
||||
if (!columnNames.has("access_schedule")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
|
||||
console.log("[DB] Added api_keys.access_schedule column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -172,7 +212,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
|
||||
"SELECT id, name, machine_id, allowed_models, no_log FROM api_keys WHERE key = ?"
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -208,7 +248,11 @@ export async function getApiKeys() {
|
||||
return rows.map((row) => {
|
||||
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
|
||||
camelRow.noLog = parseNoLog(camelRow.noLog);
|
||||
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
|
||||
camelRow.isActive = parseIsActive(camelRow.isActive);
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -223,7 +267,11 @@ export async function getApiKeyById(id: string) {
|
||||
if (!row) return null;
|
||||
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
|
||||
camelRow.noLog = parseNoLog(camelRow.noLog);
|
||||
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
|
||||
camelRow.isActive = parseIsActive(camelRow.isActive);
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -251,6 +299,63 @@ function parseNoLog(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function parseAutoResolve(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function parseIsActive(value: unknown): boolean {
|
||||
// DEFAULT 1 — active unless explicitly set to 0
|
||||
if (value === 0 || value === "0" || value === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseAccessSchedule(value: unknown): AccessSchedule | null {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj["enabled"] !== "boolean" ||
|
||||
typeof obj["from"] !== "string" ||
|
||||
typeof obj["until"] !== "string" ||
|
||||
!Array.isArray(obj["days"]) ||
|
||||
typeof obj["tz"] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const days = (obj["days"] as unknown[]).filter(
|
||||
(d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
|
||||
);
|
||||
return {
|
||||
enabled: obj["enabled"],
|
||||
from: obj["from"],
|
||||
until: obj["until"],
|
||||
days,
|
||||
tz: obj["tz"],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse allowed_connections JSON
|
||||
*/
|
||||
function parseAllowedConnections(value: unknown): string[] {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiKey(name: string, machineId: string) {
|
||||
if (!machineId) {
|
||||
throw new Error("machineId is required");
|
||||
@@ -268,6 +373,7 @@ export async function createApiKey(name: string, machineId: string) {
|
||||
key: result.key,
|
||||
machineId: machineId,
|
||||
allowedModels: [], // Empty array means all models allowed
|
||||
allowedConnections: [], // Empty array means all connections allowed
|
||||
noLog: false,
|
||||
createdAt: now,
|
||||
};
|
||||
@@ -290,7 +396,17 @@ export async function createApiKey(name: string, machineId: string) {
|
||||
|
||||
export async function updateApiKeyPermissions(
|
||||
id: string,
|
||||
update: string[] | { allowedModels?: string[]; noLog?: boolean }
|
||||
update:
|
||||
| string[]
|
||||
| {
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
allowedConnections?: string[];
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
getPreparedStatements(db);
|
||||
@@ -299,16 +415,43 @@ export async function updateApiKeyPermissions(
|
||||
Array.isArray(update) || update === undefined
|
||||
? { allowedModels: update || [] }
|
||||
: {
|
||||
name: update.name,
|
||||
allowedModels: update.allowedModels,
|
||||
allowedConnections: update.allowedConnections,
|
||||
noLog: update.noLog,
|
||||
autoResolve: update.autoResolve,
|
||||
isActive: update.isActive,
|
||||
accessSchedule: update.accessSchedule,
|
||||
};
|
||||
|
||||
if (normalized.allowedModels === undefined && normalized.noLog === undefined) {
|
||||
if (
|
||||
normalized.name === undefined &&
|
||||
normalized.allowedModels === undefined &&
|
||||
normalized.allowedConnections === undefined &&
|
||||
normalized.noLog === undefined &&
|
||||
normalized.autoResolve === undefined &&
|
||||
normalized.isActive === undefined &&
|
||||
normalized.accessSchedule === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: { id: string; allowedModels?: string; noLog?: number } = { id };
|
||||
const params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string;
|
||||
allowedConnections?: string;
|
||||
noLog?: number;
|
||||
autoResolve?: number;
|
||||
isActive?: number;
|
||||
accessSchedule?: string | null;
|
||||
} = { id };
|
||||
|
||||
if (normalized.name !== undefined) {
|
||||
updates.push("name = @name");
|
||||
params.name = normalized.name;
|
||||
}
|
||||
|
||||
if (normalized.allowedModels !== undefined) {
|
||||
// Empty array means all models are allowed
|
||||
@@ -316,11 +459,33 @@ export async function updateApiKeyPermissions(
|
||||
params.allowedModels = JSON.stringify(normalized.allowedModels || []);
|
||||
}
|
||||
|
||||
if (normalized.allowedConnections !== undefined) {
|
||||
// Empty array means all connections are allowed
|
||||
updates.push("allowed_connections = @allowedConnections");
|
||||
params.allowedConnections = JSON.stringify(normalized.allowedConnections || []);
|
||||
}
|
||||
|
||||
if (normalized.noLog !== undefined) {
|
||||
updates.push("no_log = @noLog");
|
||||
params.noLog = normalized.noLog ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.autoResolve !== undefined) {
|
||||
updates.push("auto_resolve = @autoResolve");
|
||||
params.autoResolve = normalized.autoResolve ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.isActive !== undefined) {
|
||||
updates.push("is_active = @isActive");
|
||||
params.isActive = normalized.isActive ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.accessSchedule !== undefined) {
|
||||
updates.push("access_schedule = @accessSchedule");
|
||||
params.accessSchedule =
|
||||
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -414,7 +579,13 @@ export async function getApiKeyMetadata(
|
||||
name: metadataName,
|
||||
machineId: metadataMachineId,
|
||||
allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
|
||||
allowedConnections: parseAllowedConnections(
|
||||
record.allowed_connections ?? record.allowedConnections
|
||||
),
|
||||
noLog: parseNoLog(record.no_log ?? record.noLog),
|
||||
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
|
||||
isActive: parseIsActive(record.is_active ?? record.isActive),
|
||||
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
|
||||
};
|
||||
|
||||
if (!metadata.id) {
|
||||
|
||||
@@ -80,6 +80,7 @@ const SCHEMA_SQL = `
|
||||
consecutive_use_count INTEGER DEFAULT 0,
|
||||
rate_limit_protection INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
"group" TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -316,6 +317,10 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) {
|
||||
db.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT");
|
||||
console.log("[DB] Added provider_connections.last_used_at column");
|
||||
}
|
||||
if (!columnNames.has("group")) {
|
||||
db.exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
|
||||
console.log('[DB] Added provider_connections."group" column');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Failed to verify provider_connections schema:", message);
|
||||
|
||||
+16
-2
@@ -185,6 +185,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
"errorCode",
|
||||
"consecutiveUseCount",
|
||||
"rateLimitProtection",
|
||||
"group",
|
||||
];
|
||||
for (const field of optionalFields) {
|
||||
if (data[field] !== undefined && data[field] !== null) {
|
||||
@@ -217,7 +218,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
rate_limited_until, health_check_interval, last_health_check_at,
|
||||
last_tested, api_key, id_token, provider_specific_data,
|
||||
expires_in, display_name, global_priority, default_model,
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @provider, @authType, @name, @email, @priority, @isActive,
|
||||
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
|
||||
@@ -226,7 +227,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
|
||||
@lastTested, @apiKey, @idToken, @providerSpecificData,
|
||||
@expiresIn, @displayName, @globalPriority, @defaultModel,
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @createdAt, @updatedAt
|
||||
)
|
||||
`
|
||||
).run({
|
||||
@@ -268,6 +269,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
rateLimitProtection:
|
||||
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: conn.lastUsedAt || null,
|
||||
group: conn.group || null,
|
||||
createdAt: conn.createdAt,
|
||||
updatedAt: conn.updatedAt,
|
||||
});
|
||||
@@ -292,6 +294,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
consecutive_use_count = @consecutiveUseCount,
|
||||
rate_limit_protection = @rateLimitProtection,
|
||||
last_used_at = @lastUsedAt,
|
||||
"group" = @group,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
`
|
||||
@@ -334,6 +337,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
rateLimitProtection:
|
||||
data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: data.lastUsedAt || null,
|
||||
group: data.group || null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -407,6 +411,16 @@ export async function cleanupProviderConnections() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getDistinctGroups(): Promise<string[]> {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT DISTINCT "group" FROM provider_connections WHERE "group" IS NOT NULL ORDER BY "group"'
|
||||
)
|
||||
.all() as Array<{ group?: string }>;
|
||||
return rows.map((r) => String(r.group ?? "")).filter(Boolean);
|
||||
}
|
||||
|
||||
// ──────────────── Provider Nodes ────────────────
|
||||
|
||||
export async function getProviderNodes(filter: JsonRecord = {}) {
|
||||
|
||||
+32
-17
@@ -94,6 +94,25 @@ export async function isCloudEnabled() {
|
||||
|
||||
export async function getPricing() {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Layer 1: Hardcoded defaults (lowest priority)
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
// Layer 2: Synced external pricing (middle priority)
|
||||
const syncedRows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
|
||||
.all();
|
||||
const syncedPricing: PricingByProvider = {};
|
||||
for (const row of syncedRows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
syncedPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
// Layer 3: User overrides (highest priority)
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
const userPricing: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
@@ -104,28 +123,24 @@ export async function getPricing() {
|
||||
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
// Merge: defaults → synced → user (each layer overrides the previous)
|
||||
const mergedPricing: PricingByProvider = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
|
||||
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
|
||||
if (userPricing[provider]) {
|
||||
for (const [model, pricing] of Object.entries(userPricing[provider])) {
|
||||
mergedPricing[provider][model] = mergedPricing[provider][model]
|
||||
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
||||
: pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [provider, models] of Object.entries(userPricing)) {
|
||||
if (!mergedPricing[provider]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
} else {
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
if (!mergedPricing[provider][model]) {
|
||||
mergedPricing[provider][model] = pricing;
|
||||
// Layer synced then user on top (each higher-priority layer overrides)
|
||||
for (const layer of [syncedPricing, userPricing]) {
|
||||
for (const [provider, models] of Object.entries(layer)) {
|
||||
if (!mergedPricing[provider]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
} else {
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
mergedPricing[provider][model] = mergedPricing[provider][model]
|
||||
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
||||
: pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,18 @@ export {
|
||||
setProxyConfig,
|
||||
} from "./db/settings";
|
||||
|
||||
export {
|
||||
// Pricing Sync
|
||||
getSyncedPricing,
|
||||
saveSyncedPricing,
|
||||
clearSyncedPricing,
|
||||
syncPricingFromSources,
|
||||
getSyncStatus,
|
||||
initPricingSync,
|
||||
startPeriodicSync,
|
||||
stopPeriodicSync,
|
||||
} from "./pricingSync";
|
||||
|
||||
export {
|
||||
// Backup Management
|
||||
backupDbFile,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* pricingSync.ts — External pricing sync engine.
|
||||
*
|
||||
* Fetches pricing data from external sources (LiteLLM) and stores it
|
||||
* in a separate namespace (`pricing_synced`) so user overrides are
|
||||
* never touched.
|
||||
*
|
||||
* Resolution order: user overrides > synced external > hardcoded defaults
|
||||
*
|
||||
* Opt-in via PRICING_SYNC_ENABLED=true (default: false).
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./db/core";
|
||||
import { invalidateDbCache } from "./db/readCache";
|
||||
import { backupDbFile } from "./db/backup";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
|
||||
type PricingEntry = {
|
||||
input: number;
|
||||
output: number;
|
||||
cached?: number;
|
||||
cache_creation?: number;
|
||||
};
|
||||
|
||||
type PricingModels = Record<string, PricingEntry>;
|
||||
type PricingByProvider = Record<string, PricingModels>;
|
||||
|
||||
interface LiteLLMModelInfo {
|
||||
input_cost_per_token?: number;
|
||||
output_cost_per_token?: number;
|
||||
cache_read_input_token_cost?: number;
|
||||
cache_creation_input_token_cost?: number;
|
||||
litellm_provider?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
enabled: boolean;
|
||||
lastSync: string | null;
|
||||
lastSyncModelCount: number;
|
||||
nextSync: string | null;
|
||||
intervalMs: number;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
modelCount: number;
|
||||
providerCount: number;
|
||||
source: string;
|
||||
dryRun: boolean;
|
||||
data?: PricingByProvider;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────
|
||||
|
||||
const SUPPORTED_SOURCES = ["litellm"] as const;
|
||||
type SupportedSource = (typeof SUPPORTED_SOURCES)[number];
|
||||
|
||||
const parsedInterval = parseInt(process.env.PRICING_SYNC_INTERVAL || "86400", 10);
|
||||
const SYNC_INTERVAL_MS =
|
||||
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000;
|
||||
const SYNC_SOURCES = (process.env.PRICING_SYNC_SOURCES || "litellm")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is SupportedSource => SUPPORTED_SOURCES.includes(s as SupportedSource));
|
||||
|
||||
const LITELLM_PRICING_URL =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
|
||||
// ─── Provider mapping: LiteLLM provider → OmniRoute aliases ─────
|
||||
|
||||
const LITELLM_PROVIDER_MAP: Record<string, string[]> = {
|
||||
openai: ["openai", "cx"],
|
||||
anthropic: ["anthropic", "cc"],
|
||||
vertex_ai: ["gemini", "gc"],
|
||||
"vertex_ai-anthropic_models": ["anthropic"],
|
||||
google: ["gemini", "gc"],
|
||||
deepseek: ["if"],
|
||||
groq: ["groq"],
|
||||
together_ai: ["openrouter"],
|
||||
bedrock: ["kiro"],
|
||||
fireworks_ai: ["fireworks"],
|
||||
cerebras: ["cerebras"],
|
||||
nvidia_nim: ["nvidia"],
|
||||
siliconflow: ["siliconflow"],
|
||||
};
|
||||
|
||||
// ─── Periodic sync state ─────────────────────────────────
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSyncTime: string | null = null;
|
||||
let lastSyncModelCount = 0;
|
||||
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
|
||||
|
||||
// ─── Core: Fetch + Transform ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch raw pricing data from LiteLLM GitHub.
|
||||
*/
|
||||
export async function fetchLiteLLMPricing(): Promise<Record<string, LiteLLMModelInfo>> {
|
||||
const response = await fetch(LITELLM_PRICING_URL, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`LiteLLM fetch failed [${response.status}]: ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, LiteLLMModelInfo>;
|
||||
} catch {
|
||||
throw new Error(`LiteLLM returned invalid JSON (${text.slice(0, 100)}...)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform LiteLLM raw data → OmniRoute PricingByProvider format.
|
||||
*
|
||||
* Conversion: cost_per_token × 1_000_000 → $/1M tokens (OmniRoute format)
|
||||
* Filters: only chat/completion modes (skip image/audio/embedding)
|
||||
*/
|
||||
export function transformToOmniRoute(raw: Record<string, LiteLLMModelInfo>): PricingByProvider {
|
||||
const result: PricingByProvider = {};
|
||||
|
||||
for (const [modelKey, info] of Object.entries(raw)) {
|
||||
// Skip non-chat models
|
||||
if (info.mode && !["chat", "completion"].includes(info.mode)) continue;
|
||||
|
||||
// Must have at least input pricing
|
||||
if (!info.input_cost_per_token && info.input_cost_per_token !== 0) continue;
|
||||
|
||||
const inputCost = (info.input_cost_per_token || 0) * 1_000_000;
|
||||
const outputCost = (info.output_cost_per_token || 0) * 1_000_000;
|
||||
|
||||
const entry: PricingEntry = {
|
||||
input: Math.round(inputCost * 1000) / 1000,
|
||||
output: Math.round(outputCost * 1000) / 1000,
|
||||
};
|
||||
|
||||
if (info.cache_read_input_token_cost != null) {
|
||||
entry.cached = Math.round(info.cache_read_input_token_cost * 1_000_000 * 1000) / 1000;
|
||||
}
|
||||
if (info.cache_creation_input_token_cost != null) {
|
||||
entry.cache_creation =
|
||||
Math.round(info.cache_creation_input_token_cost * 1_000_000 * 1000) / 1000;
|
||||
}
|
||||
|
||||
// Extract model name (strip provider prefix from key)
|
||||
// LiteLLM keys look like: "openai/gpt-4o", "anthropic/claude-3-opus"
|
||||
const slashIdx = modelKey.indexOf("/");
|
||||
const modelName = slashIdx >= 0 ? modelKey.slice(slashIdx + 1) : modelKey;
|
||||
|
||||
// Map to OmniRoute providers
|
||||
const litellmProvider = info.litellm_provider || "";
|
||||
const omniRouteProviders = LITELLM_PROVIDER_MAP[litellmProvider];
|
||||
|
||||
if (omniRouteProviders) {
|
||||
for (const provider of omniRouteProviders) {
|
||||
if (!result[provider]) result[provider] = {};
|
||||
result[provider][modelName] = entry;
|
||||
}
|
||||
} else if (litellmProvider) {
|
||||
// Use litellm_provider as-is for unknown providers
|
||||
if (!result[litellmProvider]) result[litellmProvider] = {};
|
||||
result[litellmProvider][modelName] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── DB: Synced pricing namespace ────────────────────────
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read synced pricing from `pricing_synced` namespace.
|
||||
*/
|
||||
export function getSyncedPricing(): PricingByProvider {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
|
||||
.all();
|
||||
const synced: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
try {
|
||||
synced[key] = JSON.parse(rawValue) as PricingModels;
|
||||
} catch {
|
||||
console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`);
|
||||
}
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save synced pricing to `pricing_synced` namespace (full replace).
|
||||
*/
|
||||
export function saveSyncedPricing(data: PricingByProvider): void {
|
||||
const db = getDbInstance();
|
||||
const del = db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'");
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO key_value (namespace, key, value) VALUES ('pricing_synced', ?, ?)"
|
||||
);
|
||||
const tx = db.transaction(() => {
|
||||
del.run();
|
||||
for (const [provider, models] of Object.entries(data)) {
|
||||
insert.run(provider, JSON.stringify(models));
|
||||
}
|
||||
});
|
||||
tx();
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("pricing");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all synced pricing data.
|
||||
*/
|
||||
export function clearSyncedPricing(): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'").run();
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("pricing");
|
||||
}
|
||||
|
||||
// ─── Main sync function ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch, transform, and save pricing from external sources.
|
||||
*/
|
||||
export async function syncPricingFromSources(opts?: {
|
||||
sources?: string[];
|
||||
dryRun?: boolean;
|
||||
}): Promise<SyncResult> {
|
||||
const requestedSources = opts?.sources || SYNC_SOURCES;
|
||||
const dryRun = opts?.dryRun ?? false;
|
||||
|
||||
// Validate sources
|
||||
const validSources = requestedSources.filter((s): s is SupportedSource =>
|
||||
SUPPORTED_SOURCES.includes(s as SupportedSource)
|
||||
);
|
||||
const invalidSources = requestedSources.filter(
|
||||
(s) => !SUPPORTED_SOURCES.includes(s as SupportedSource)
|
||||
);
|
||||
|
||||
if (validSources.length === 0) {
|
||||
const supported = SUPPORTED_SOURCES.join(", ");
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
providerCount: 0,
|
||||
source: requestedSources.join(","),
|
||||
dryRun,
|
||||
error: `No valid sources provided. Supported: ${supported}. Invalid: ${invalidSources.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const aggregated: PricingByProvider = {};
|
||||
|
||||
for (const source of validSources) {
|
||||
if (source === "litellm") {
|
||||
const raw = await fetchLiteLLMPricing();
|
||||
const transformed = transformToOmniRoute(raw);
|
||||
for (const [provider, models] of Object.entries(transformed)) {
|
||||
if (!aggregated[provider]) aggregated[provider] = {};
|
||||
Object.assign(aggregated[provider], models);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelCount = Object.values(aggregated).reduce(
|
||||
(sum, models) => sum + Object.keys(models).length,
|
||||
0
|
||||
);
|
||||
const providerCount = Object.keys(aggregated).length;
|
||||
|
||||
if (!dryRun) {
|
||||
saveSyncedPricing(aggregated);
|
||||
lastSyncTime = new Date().toISOString();
|
||||
lastSyncModelCount = modelCount;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modelCount,
|
||||
providerCount,
|
||||
source: validSources.join(","),
|
||||
dryRun,
|
||||
...(invalidSources.length > 0
|
||||
? { warnings: [`Unknown sources ignored: ${invalidSources.join(", ")}`] }
|
||||
: {}),
|
||||
...(dryRun ? { data: aggregated } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[PRICING_SYNC] Sync failed:", message);
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
providerCount: 0,
|
||||
source: requestedSources.join(","),
|
||||
dryRun,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Periodic sync ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start periodic pricing sync (non-blocking).
|
||||
*/
|
||||
export function startPeriodicSync(intervalMs?: number): void {
|
||||
if (syncTimer) return; // Already running
|
||||
|
||||
const interval = intervalMs ?? SYNC_INTERVAL_MS;
|
||||
activeSyncIntervalMs = interval;
|
||||
console.log(`[PRICING_SYNC] Starting periodic sync every ${interval / 1000}s`);
|
||||
|
||||
// Initial sync (non-blocking)
|
||||
syncPricingFromSources()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[PRICING_SYNC] Initial sync complete: ${result.modelCount} models from ${result.providerCount} providers`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[PRICING_SYNC] Initial sync error:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
syncTimer = setInterval(() => {
|
||||
syncPricingFromSources()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(`[PRICING_SYNC] Periodic sync complete: ${result.modelCount} models`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"[PRICING_SYNC] Periodic sync error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic sync and cleanup timer.
|
||||
*/
|
||||
export function stopPeriodicSync(): void {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
console.log("[PRICING_SYNC] Periodic sync stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status.
|
||||
*/
|
||||
export function getSyncStatus(): SyncStatus {
|
||||
const enabled = process.env.PRICING_SYNC_ENABLED === "true";
|
||||
return {
|
||||
enabled,
|
||||
lastSync: lastSyncTime,
|
||||
lastSyncModelCount,
|
||||
nextSync:
|
||||
syncTimer && lastSyncTime
|
||||
? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString()
|
||||
: null,
|
||||
intervalMs: activeSyncIntervalMs,
|
||||
sources: SYNC_SOURCES,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Init (called from server-init.ts) ───────────────────
|
||||
|
||||
/**
|
||||
* Initialize pricing sync if enabled.
|
||||
*/
|
||||
export async function initPricingSync(): Promise<void> {
|
||||
if (process.env.PRICING_SYNC_ENABLED !== "true") {
|
||||
console.log("[PRICING_SYNC] Disabled (set PRICING_SYNC_ENABLED=true to enable)");
|
||||
return;
|
||||
}
|
||||
startPeriodicSync();
|
||||
}
|
||||
@@ -42,6 +42,19 @@ async function startServer() {
|
||||
console.error("[FATAL] Error initializing cloud sync:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pricing sync: opt-in external pricing data (non-blocking, never fatal)
|
||||
if (process.env.PRICING_SYNC_ENABLED === "true") {
|
||||
try {
|
||||
const { initPricingSync } = await import("./lib/pricingSync");
|
||||
await initPricingSync();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[PRICING_SYNC] Could not initialize:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server initialization
|
||||
|
||||
@@ -8,38 +8,36 @@
|
||||
* comes back online.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function MaintenanceBanner() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Server is healthy — hide banner if shown
|
||||
if (show) {
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShow(false);
|
||||
setMessage("");
|
||||
} else {
|
||||
setShow(true);
|
||||
setMessage("Server is experiencing issues. Some features may be unavailable.");
|
||||
}
|
||||
} else {
|
||||
} catch {
|
||||
setShow(true);
|
||||
setMessage("Server is experiencing issues. Some features may be unavailable.");
|
||||
setMessage("Server is unreachable. Reconnecting...");
|
||||
}
|
||||
} catch {
|
||||
setShow(true);
|
||||
setMessage("Server is unreachable. Reconnecting...");
|
||||
}
|
||||
}, [show]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check health every 10 seconds
|
||||
// Run immediately on mount, then every 10 seconds
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
}, []); // empty deps — checkHealth is defined inside effect, no stale closure
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
|
||||
@@ -15,14 +15,92 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
/** Metadata stored for an API key in the local database. */
|
||||
export interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
allowedConnections?: string[];
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current time (in the schedule's timezone) is within
|
||||
* the configured window.
|
||||
* Supports overnight ranges (e.g. 22:00 until 06:00).
|
||||
*/
|
||||
function isWithinSchedule(schedule: AccessSchedule): boolean {
|
||||
if (!schedule.enabled) return true;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Convert current UTC time to the configured timezone
|
||||
let localTimeStr: string;
|
||||
try {
|
||||
localTimeStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch {
|
||||
// Invalid timezone — fail open (don't block)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Intl may return "24:xx" instead of "00:xx" — normalize
|
||||
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
|
||||
const [localHour, localMin] = normalizedTime.split(":").map(Number);
|
||||
const localMinutes = localHour * 60 + localMin;
|
||||
|
||||
// Determine current weekday in the configured timezone
|
||||
let localDayStr: string;
|
||||
try {
|
||||
localDayStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
weekday: "short",
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dayMap: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
const localDay = dayMap[localDayStr] ?? now.getDay();
|
||||
|
||||
if (!schedule.days.includes(localDay)) return false;
|
||||
|
||||
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
|
||||
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
|
||||
const fromMinutes = fromHour * 60 + fromMin;
|
||||
const untilMinutes = untilHour * 60 + untilMin;
|
||||
|
||||
// Overnight window (e.g. 22:00 → 06:00)
|
||||
if (untilMinutes < fromMinutes) {
|
||||
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
@@ -82,7 +160,31 @@ export async function enforceApiKeyPolicy(
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: Model restriction ──
|
||||
// ── Check 1: is_active — hard block regardless of schedule ──
|
||||
if (apiKeyInfo.isActive === false) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 2: access_schedule — time-based access window ──
|
||||
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
|
||||
if (!isWithinSchedule(apiKeyInfo.accessSchedule)) {
|
||||
const { from, until, tz } = apiKeyInfo.accessSchedule;
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Access denied outside allowed hours (${from}–${until} ${tz})`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 3: Model restriction ──
|
||||
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
|
||||
const allowed = await isModelAllowedForKey(apiKey, modelStr);
|
||||
if (!allowed) {
|
||||
@@ -97,7 +199,7 @@ export async function enforceApiKeyPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Budget limit ──
|
||||
// ── Check 4: Budget limit ──
|
||||
if (apiKeyInfo.id) {
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Shared shuffle deck utility — Fisher-Yates shuffle with anti-repeat guarantee.
|
||||
* Used by both combo model rotation and credential connection selection.
|
||||
*
|
||||
* Thread-safe: each deck namespace gets its own promise-based mutex to prevent
|
||||
* race conditions when concurrent requests hit the same deck simultaneously.
|
||||
*/
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ShuffleDeck {
|
||||
order: readonly string[];
|
||||
index: number;
|
||||
idsKey: string;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const decks = new Map<string, ShuffleDeck>();
|
||||
const mutexes = new Map<string, Promise<void>>();
|
||||
|
||||
// ─── Fisher-Yates Shuffle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle — returns a new shuffled copy of the array.
|
||||
* Does NOT mutate the original.
|
||||
*/
|
||||
export function fisherYatesShuffle<T>(arr: readonly T[]): T[] {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = result[i];
|
||||
result[i] = result[j];
|
||||
result[j] = tmp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Deck Operations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get next item from a namespaced shuffle deck.
|
||||
*
|
||||
* - Namespace isolates decks (e.g. "combo:myCombo" vs "conn:openai").
|
||||
* - Uses each item exactly once per cycle before reshuffling.
|
||||
* - Guarantees the last item of a cycle is not the first of the next.
|
||||
* - Resets deck when the item set changes (detected via sorted key).
|
||||
* - Serialized per namespace via promise-based mutex (no race conditions).
|
||||
*/
|
||||
export async function getNextFromDeck(
|
||||
namespace: string,
|
||||
itemIds: readonly string[]
|
||||
): Promise<string> {
|
||||
if (itemIds.length === 0) return "";
|
||||
if (itemIds.length === 1) return itemIds[0];
|
||||
|
||||
// Acquire per-namespace mutex
|
||||
const currentMutex = mutexes.get(namespace) ?? Promise.resolve();
|
||||
let resolveMutex: (() => void) | undefined;
|
||||
mutexes.set(
|
||||
namespace,
|
||||
new Promise<void>((resolve) => {
|
||||
resolveMutex = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await currentMutex;
|
||||
|
||||
const idsKey = [...itemIds].sort().join(",");
|
||||
const existing = decks.get(namespace);
|
||||
|
||||
// If deck exists, same item set, and not exhausted — advance
|
||||
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
|
||||
const id = existing.order[existing.index];
|
||||
decks.set(namespace, { ...existing, index: existing.index + 1 });
|
||||
return id;
|
||||
}
|
||||
|
||||
// Reshuffle — ensure last of previous cycle is not first of new cycle
|
||||
const lastUsedId =
|
||||
existing && existing.idsKey === idsKey && existing.order.length > 0
|
||||
? existing.order[existing.order.length - 1]
|
||||
: undefined;
|
||||
|
||||
const newOrder = fisherYatesShuffle(itemIds);
|
||||
|
||||
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
|
||||
const tmp = newOrder[0];
|
||||
newOrder[0] = newOrder[swapIdx];
|
||||
newOrder[swapIdx] = tmp;
|
||||
}
|
||||
|
||||
decks.set(namespace, { order: newOrder, index: 1, idsKey });
|
||||
return newOrder[0];
|
||||
} finally {
|
||||
resolveMutex?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync version (backwards compat for non-concurrent callers) ─────────────
|
||||
|
||||
/**
|
||||
* Synchronous version of getNextFromDeck — NO mutex protection.
|
||||
* Only safe when the caller already holds a mutex (e.g. auth.ts getProviderCredentials).
|
||||
*/
|
||||
export function getNextFromDeckSync(namespace: string, itemIds: readonly string[]): string {
|
||||
if (itemIds.length === 0) return "";
|
||||
if (itemIds.length === 1) return itemIds[0];
|
||||
|
||||
const idsKey = [...itemIds].sort().join(",");
|
||||
const existing = decks.get(namespace);
|
||||
|
||||
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
|
||||
const id = existing.order[existing.index];
|
||||
decks.set(namespace, { ...existing, index: existing.index + 1 });
|
||||
return id;
|
||||
}
|
||||
|
||||
const lastUsedId =
|
||||
existing && existing.idsKey === idsKey && existing.order.length > 0
|
||||
? existing.order[existing.order.length - 1]
|
||||
: undefined;
|
||||
|
||||
const newOrder = fisherYatesShuffle(itemIds);
|
||||
|
||||
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
|
||||
const tmp = newOrder[0];
|
||||
newOrder[0] = newOrder[swapIdx];
|
||||
newOrder[swapIdx] = tmp;
|
||||
}
|
||||
|
||||
decks.set(namespace, { order: newOrder, index: 1, idsKey });
|
||||
return newOrder[0];
|
||||
}
|
||||
|
||||
// ─── Test helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Reset all decks — for testing only. */
|
||||
export function _resetAllDecks(): void {
|
||||
decks.clear();
|
||||
mutexes.clear();
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const comboStrategySchema = z.enum([
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
]);
|
||||
|
||||
const comboRuntimeConfigSchema = z
|
||||
@@ -77,6 +78,7 @@ export const createComboSchema = z.object({
|
||||
models: z.array(comboModelEntry).optional().default([]),
|
||||
strategy: comboStrategySchema.optional().default("priority"),
|
||||
config: comboConfigSchema,
|
||||
allowedProviders: z.array(z.string().max(200)).optional(),
|
||||
});
|
||||
|
||||
// ──── Auto-Combo Schemas ────
|
||||
@@ -125,7 +127,15 @@ export const updateSettingsSchema = z.object({
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z
|
||||
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
|
||||
.enum([
|
||||
"fill-first",
|
||||
"round-robin",
|
||||
"p2c",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
])
|
||||
.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
@@ -439,6 +449,12 @@ export const updateThinkingBudgetSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const updateCodexServiceTierSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
|
||||
const tempBanSchema = z.object({
|
||||
ip: z.string().trim().min(1),
|
||||
@@ -676,6 +692,7 @@ export const updateComboSchema = z
|
||||
strategy: comboStrategySchema.optional(),
|
||||
config: comboRuntimeConfigSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
allowedProviders: z.array(z.string().max(200)).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
@@ -683,7 +700,8 @@ export const updateComboSchema = z
|
||||
value.models === undefined &&
|
||||
value.strategy === undefined &&
|
||||
value.config === undefined &&
|
||||
value.isActive === undefined
|
||||
value.isActive === undefined &&
|
||||
value.allowedProviders === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -706,13 +724,34 @@ export const evalRunSuiteSchema = z.object({
|
||||
outputs: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const accessScheduleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
from: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
|
||||
until: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
|
||||
days: z.array(z.number().int().min(0).max(6)).min(1, "At least one day is required").max(7),
|
||||
tz: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const updateKeyPermissionsSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
|
||||
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
|
||||
noLog: z.boolean().optional(),
|
||||
autoResolve: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.allowedModels === undefined && value.noLog === undefined) {
|
||||
if (
|
||||
value.name === undefined &&
|
||||
value.allowedModels === undefined &&
|
||||
value.allowedConnections === undefined &&
|
||||
value.noLog === undefined &&
|
||||
value.autoResolve === undefined &&
|
||||
value.isActive === undefined &&
|
||||
value.accessSchedule === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
@@ -770,6 +809,7 @@ export const updateProviderConnectionSchema = z
|
||||
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
|
||||
lastTested: z.union([z.string(), z.null()]).optional(),
|
||||
healthCheckInterval: z.coerce.number().int().min(0).optional(),
|
||||
group: z.union([z.string().max(100), z.null()]).optional(),
|
||||
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
||||
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
@@ -133,7 +133,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
|
||||
// Optional strict API key mode for /v1 endpoints.
|
||||
// Keep disabled by default to preserve local-mode compatibility.
|
||||
if (process.env.REQUIRE_API_KEY === "true") {
|
||||
// Exception: X-Internal-Test header bypasses auth for admin-side combo health checks (#350)
|
||||
const isInternalTest = request.headers?.get?.("x-internal-test") === "combo-health-check";
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !isInternalTest) {
|
||||
if (!apiKey) {
|
||||
log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
@@ -207,7 +209,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const creds = await getProviderCredentials(provider);
|
||||
const creds = await getProviderCredentials(
|
||||
provider,
|
||||
null,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -291,7 +297,11 @@ async function handleSingleModelChat(
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionId);
|
||||
const credentials = await getProviderCredentials(
|
||||
provider,
|
||||
excludeConnectionId,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (lastStatus === 429 || lastStatus === 503) {
|
||||
|
||||
+167
-9
@@ -4,7 +4,7 @@ import {
|
||||
updateProviderConnection,
|
||||
getSettings,
|
||||
} from "@/lib/localDb";
|
||||
import { isAccountQuotaExhausted } from "@/domain/quotaCache";
|
||||
import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache";
|
||||
import {
|
||||
isAccountUnavailable,
|
||||
getUnavailableUntil,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
lockModel,
|
||||
} from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -34,10 +35,24 @@ interface ProviderConnectionView {
|
||||
consecutiveUseCount: number;
|
||||
priority: number;
|
||||
lastError: string | null;
|
||||
lastErrorType: string | null;
|
||||
lastErrorSource: string | null;
|
||||
errorCode: string | number | null;
|
||||
backoffLevel: number;
|
||||
}
|
||||
|
||||
interface RecoverableConnectionState {
|
||||
connectionId: string;
|
||||
testStatus?: string | null;
|
||||
lastError?: string | null;
|
||||
rateLimitedUntil?: string | null;
|
||||
errorCode?: string | number | null;
|
||||
lastErrorType?: string | null;
|
||||
lastErrorSource?: string | null;
|
||||
}
|
||||
|
||||
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
@@ -73,12 +88,48 @@ function toProviderConnection(value: unknown): ProviderConnectionView {
|
||||
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
|
||||
priority: toNumber(row.priority, 999),
|
||||
lastError: toStringOrNull(row.lastError),
|
||||
lastErrorType: toStringOrNull(row.lastErrorType),
|
||||
lastErrorSource: toStringOrNull(row.lastErrorSource),
|
||||
errorCode:
|
||||
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
|
||||
backoffLevel: toNumber(row.backoffLevel, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
|
||||
use5h: boolean;
|
||||
useWeekly: boolean;
|
||||
} {
|
||||
const policy = asRecord(providerSpecificData.codexLimitPolicy);
|
||||
return {
|
||||
use5h: toBooleanOrDefault(policy.use5h, true),
|
||||
useWeekly: toBooleanOrDefault(policy.useWeekly, true),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFutureDateMs(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const ms = new Date(value).getTime();
|
||||
if (!Number.isFinite(ms) || ms <= Date.now()) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
function getEarliestFutureDate(candidates: Array<string | null>): string | null {
|
||||
return (
|
||||
candidates
|
||||
.map((candidate) => ({
|
||||
raw: candidate,
|
||||
ms: parseFutureDateMs(candidate),
|
||||
}))
|
||||
.filter((entry) => entry.ms !== null)
|
||||
.sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null
|
||||
);
|
||||
}
|
||||
|
||||
// Mutex to prevent race conditions during account selection
|
||||
let selectionMutex = Promise.resolve();
|
||||
|
||||
@@ -87,6 +138,11 @@ let selectionMutex = Promise.resolve();
|
||||
// unavailable in parallel, which was the root cause of cascading 502 lockouts.
|
||||
const markMutexes = new Map<string, Promise<void>>();
|
||||
|
||||
// Strict-Random shuffle deck moved to src/shared/utils/shuffleDeck.ts
|
||||
// auth.ts uses getNextFromDeckSync (already inside selectionMutex).
|
||||
// Re-export for backwards compat with existing test imports.
|
||||
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
|
||||
|
||||
/**
|
||||
* Get provider credentials from localDb
|
||||
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||
@@ -95,7 +151,8 @@ const markMutexes = new Map<string, Promise<void>>();
|
||||
*/
|
||||
export async function getProviderCredentials(
|
||||
provider: string,
|
||||
excludeConnectionId: string | null = null
|
||||
excludeConnectionId: string | null = null,
|
||||
allowedConnections: string[] | null = null
|
||||
) {
|
||||
// Acquire mutex to prevent race conditions
|
||||
const currentMutex = selectionMutex;
|
||||
@@ -108,9 +165,13 @@ export async function getProviderCredentials(
|
||||
await currentMutex;
|
||||
|
||||
const connectionsRaw = await getProviderConnections({ provider, isActive: true });
|
||||
const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
|
||||
let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
|
||||
.map(toProviderConnection)
|
||||
.filter((conn) => conn.id.length > 0);
|
||||
// allowedConnections: restrict to specific connection IDs (from API key policy, #363)
|
||||
if (allowedConnections && allowedConnections.length > 0) {
|
||||
connections = connections.filter((conn) => allowedConnections.includes(conn.id));
|
||||
}
|
||||
log.debug(
|
||||
"AUTH",
|
||||
`${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}`
|
||||
@@ -198,11 +259,84 @@ export async function getProviderCredentials(
|
||||
return null;
|
||||
}
|
||||
|
||||
let policyEligibleConnections = availableConnections;
|
||||
if (provider === "codex") {
|
||||
const blockedByPolicy: Array<{
|
||||
id: string;
|
||||
reasons: string[];
|
||||
resetAt: string | null;
|
||||
}> = [];
|
||||
|
||||
policyEligibleConnections = availableConnections.filter((connection) => {
|
||||
const policy = getCodexLimitPolicy(connection.providerSpecificData);
|
||||
const sessionStatus = policy.use5h
|
||||
? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT)
|
||||
: null;
|
||||
const weeklyStatus = policy.useWeekly
|
||||
? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT)
|
||||
: null;
|
||||
|
||||
const reasons: string[] = [];
|
||||
const resetCandidates: Array<string | null> = [];
|
||||
|
||||
if (policy.use5h && sessionStatus?.reachedThreshold) {
|
||||
reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`);
|
||||
resetCandidates.push(sessionStatus.resetAt);
|
||||
}
|
||||
|
||||
if (policy.useWeekly && weeklyStatus?.reachedThreshold) {
|
||||
reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`);
|
||||
resetCandidates.push(weeklyStatus.resetAt);
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
const nextResetAt = getEarliestFutureDate(resetCandidates);
|
||||
|
||||
blockedByPolicy.push({
|
||||
id: connection.id,
|
||||
reasons,
|
||||
resetAt: nextResetAt,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (blockedByPolicy.length > 0) {
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
|
||||
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
|
||||
.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
|
||||
const earliestResetAt = getEarliestFutureDate(
|
||||
blockedByPolicy.map((entry) => entry.resetAt)
|
||||
);
|
||||
const earliestResetMs = parseFutureDateMs(earliestResetAt);
|
||||
|
||||
const retryAfter = earliestResetMs
|
||||
? new Date(earliestResetMs).toISOString()
|
||||
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
|
||||
return {
|
||||
allRateLimited: true,
|
||||
retryAfter,
|
||||
retryAfterHuman: formatRetryAfter(retryAfter),
|
||||
lastError: "All Codex accounts reached configured quota threshold",
|
||||
lastErrorCode: 429,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Quota-aware: prioritize accounts with available quota
|
||||
const withQuota = availableConnections.filter((c) => !isAccountQuotaExhausted(c.id));
|
||||
const exhaustedQuota = availableConnections.filter((c) => isAccountQuotaExhausted(c.id));
|
||||
const withQuota = policyEligibleConnections.filter((c) => !isAccountQuotaExhausted(c.id));
|
||||
const exhaustedQuota = policyEligibleConnections.filter((c) => isAccountQuotaExhausted(c.id));
|
||||
const orderedConnections =
|
||||
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : availableConnections;
|
||||
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : policyEligibleConnections;
|
||||
|
||||
if (exhaustedQuota.length > 0) {
|
||||
log.debug(
|
||||
@@ -324,6 +458,11 @@ export async function getProviderCredentials(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
connection = sorted[0];
|
||||
} else if (strategy === "strict-random") {
|
||||
// Strict Random: shuffle deck — uses each account once before reshuffling
|
||||
const ids = orderedConnections.map((c) => c.id);
|
||||
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
|
||||
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
|
||||
} else {
|
||||
// Default: fill-first (already sorted by priority in getProviderConnections)
|
||||
connection = orderedConnections[0];
|
||||
@@ -344,6 +483,9 @@ export async function getProviderCredentials(
|
||||
// Include current status for optimization check
|
||||
testStatus: connection.testStatus,
|
||||
lastError: connection.lastError,
|
||||
lastErrorType: connection.lastErrorType,
|
||||
lastErrorSource: connection.lastErrorSource,
|
||||
errorCode: connection.errorCode,
|
||||
rateLimitedUntil: connection.rateLimitedUntil,
|
||||
};
|
||||
} finally {
|
||||
@@ -444,12 +586,18 @@ export async function markAccountUnavailable(
|
||||
* Clear account error status (only if currently has error)
|
||||
* Optimized to avoid unnecessary DB updates
|
||||
*/
|
||||
export async function clearAccountError(connectionId: string, currentConnection: any) {
|
||||
export async function clearAccountError(
|
||||
connectionId: string,
|
||||
currentConnection: Partial<RecoverableConnectionState>
|
||||
) {
|
||||
// Only update if currently has error status
|
||||
const hasError =
|
||||
currentConnection.testStatus === "unavailable" ||
|
||||
(currentConnection.testStatus && currentConnection.testStatus !== "active") ||
|
||||
currentConnection.lastError ||
|
||||
currentConnection.rateLimitedUntil;
|
||||
currentConnection.rateLimitedUntil ||
|
||||
currentConnection.errorCode ||
|
||||
currentConnection.lastErrorType ||
|
||||
currentConnection.lastErrorSource;
|
||||
|
||||
if (!hasError) return; // Skip if already clean
|
||||
|
||||
@@ -457,12 +605,22 @@ export async function clearAccountError(connectionId: string, currentConnection:
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 0,
|
||||
});
|
||||
log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`);
|
||||
}
|
||||
|
||||
export async function clearRecoveredProviderState(
|
||||
credentials: Partial<RecoverableConnectionState> | null
|
||||
) {
|
||||
if (!credentials?.connectionId) return;
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API key from request headers
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface Settings {
|
||||
| "p2c"
|
||||
| "random"
|
||||
| "least-used"
|
||||
| "cost-optimized";
|
||||
| "cost-optimized"
|
||||
| "strict-random";
|
||||
stickyRoundRobinLimit: number;
|
||||
jwtSecret?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for API key policy helpers:
|
||||
* - parseIsActive (via parseAccessSchedule indirect coverage)
|
||||
* - isWithinSchedule logic (tested directly via a re-export or by mocking Date)
|
||||
*
|
||||
* Because isWithinSchedule is module-private, we test it through observable
|
||||
* behavior: feeding real Date overrides via globalThis.Date stubbing.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Extract the schedule-check logic into a standalone helper exported only
|
||||
* for tests — OR test it end-to-end through enforceApiKeyPolicy.
|
||||
* 2. Since enforceApiKeyPolicy needs a full DB + HTTP Request, we isolate
|
||||
* isWithinSchedule by copying its logic into this test file and verifying
|
||||
* the exact same algorithm.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Replicate the isWithinSchedule logic for pure unit testing ───────────────
|
||||
//
|
||||
// This mirrors the implementation in apiKeyPolicy.ts exactly.
|
||||
// If the production code changes, update this copy too.
|
||||
|
||||
/**
|
||||
* @param {{ enabled: boolean; from: string; until: string; days: number[]; tz: string }} schedule
|
||||
* @param {Date} now — injectable "current time"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithinSchedule(schedule, now = new Date()) {
|
||||
if (!schedule.enabled) return true;
|
||||
|
||||
let localTimeStr;
|
||||
try {
|
||||
localTimeStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
|
||||
const [localHour, localMin] = normalizedTime.split(":").map(Number);
|
||||
const localMinutes = localHour * 60 + localMin;
|
||||
|
||||
let localDayStr;
|
||||
try {
|
||||
localDayStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
weekday: "short",
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
const localDay = dayMap[localDayStr] ?? now.getDay();
|
||||
|
||||
if (!schedule.days.includes(localDay)) return false;
|
||||
|
||||
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
|
||||
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
|
||||
const fromMinutes = fromHour * 60 + fromMin;
|
||||
const untilMinutes = untilHour * 60 + untilMin;
|
||||
|
||||
if (untilMinutes < fromMinutes) {
|
||||
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ─── parseIsActive helper (mirrors production code) ──────────────────────────
|
||||
function parseIsActive(value) {
|
||||
if (value === 0 || value === "0" || value === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── parseAccessSchedule helper (mirrors production code) ────────────────────
|
||||
function parseAccessSchedule(value) {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (
|
||||
typeof parsed.enabled !== "boolean" ||
|
||||
typeof parsed.from !== "string" ||
|
||||
typeof parsed.until !== "string" ||
|
||||
!Array.isArray(parsed.days) ||
|
||||
typeof parsed.tz !== "string"
|
||||
)
|
||||
return null;
|
||||
const days = parsed.days.filter(
|
||||
(d) => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
|
||||
);
|
||||
return { enabled: parsed.enabled, from: parsed.from, until: parsed.until, days, tz: parsed.tz };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── parseIsActive ────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseIsActive: undefined → true (default active)", () => {
|
||||
assert.equal(parseIsActive(undefined), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: null → true", () => {
|
||||
assert.equal(parseIsActive(null), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 1 → true", () => {
|
||||
assert.equal(parseIsActive(1), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: true → true", () => {
|
||||
assert.equal(parseIsActive(true), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 0 → false", () => {
|
||||
assert.equal(parseIsActive(0), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: false → false", () => {
|
||||
assert.equal(parseIsActive(false), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: '0' → false", () => {
|
||||
assert.equal(parseIsActive("0"), false);
|
||||
});
|
||||
|
||||
// ─── parseAccessSchedule ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseAccessSchedule: null/empty → null", () => {
|
||||
assert.equal(parseAccessSchedule(null), null);
|
||||
assert.equal(parseAccessSchedule(""), null);
|
||||
assert.equal(parseAccessSchedule(" "), null);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: valid JSON → object", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result, {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid day values are filtered out", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 7, -1, 5],
|
||||
tz: "UTC",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result.days, [1, 5]);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: missing required field → null", () => {
|
||||
const input = JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [1] });
|
||||
assert.equal(parseAccessSchedule(input), null); // tz missing
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid JSON → null", () => {
|
||||
assert.equal(parseAccessSchedule("{broken json}"), null);
|
||||
});
|
||||
|
||||
// ─── isWithinSchedule ────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: create a Date at a specific UTC datetime
|
||||
function utc(y, m, d, h, min) {
|
||||
return new Date(Date.UTC(y, m - 1, d, h, min));
|
||||
}
|
||||
|
||||
test("isWithinSchedule: enabled=false → always true", () => {
|
||||
const schedule = { enabled: false, from: "00:00", until: "00:01", days: [1], tz: "UTC" };
|
||||
// Even a time that would be blocked
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time within window → true", () => {
|
||||
// Monday 2024-03-11, 09:00 UTC (UTC timezone)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 9, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time before window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
// 07:59
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 7, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'from' → true (inclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 8, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'until' → false (exclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 18, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time after window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 20, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: wrong weekday → false", () => {
|
||||
// Monday (day 1) schedule, but 2024-03-12 is Tuesday (day 2)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 12, 10, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — matching day → true", () => {
|
||||
// Mon-Fri schedule, Wednesday (3)
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-13 is Wednesday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 13, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — Saturday blocked", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-09 is Saturday (day 6)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 9, 12, 0)), false);
|
||||
});
|
||||
|
||||
// ─── Overnight schedule tests ─────────────────────────────────────────────────
|
||||
|
||||
test("isWithinSchedule: overnight window — time after midnight → true", () => {
|
||||
// 22:00 → 06:00, Monday
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 02:30 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 2, 30)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time before start → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 21:59 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 21, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time after end → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 06:01 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 6, 1)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time exactly at start → true", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: invalid timezone → fail-open (true)", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "Invalid/Zone",
|
||||
};
|
||||
// Should not throw, should return true (fail-open)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo timezone conversion", () => {
|
||||
// UTC 2024-03-11 15:00 = BRT (UTC-3) 12:00, Monday
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 15, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo — outside window", () => {
|
||||
// UTC 2024-03-11 22:00 = BRT 19:00, Monday — after 18:00
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-clear-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("clearAccountError clears stale provider error metadata after recovery", async () => {
|
||||
await resetStorage();
|
||||
core.getDbInstance().exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
|
||||
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "recover@example.com",
|
||||
accessToken: "access",
|
||||
refreshToken: "refresh",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
|
||||
const credentials = await auth.getProviderCredentials("codex");
|
||||
assert.equal(credentials.connectionId, created.id);
|
||||
assert.equal(credentials.errorCode, "refresh_failed");
|
||||
assert.equal(credentials.lastErrorType, "token_refresh_failed");
|
||||
assert.equal(credentials.lastErrorSource, "oauth");
|
||||
await auth.clearAccountError(created.id, credentials);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(created.id);
|
||||
assert.equal(updated.testStatus, "active");
|
||||
assert.equal(updated.lastError, undefined);
|
||||
assert.equal(updated.lastErrorType, undefined);
|
||||
assert.equal(updated.lastErrorSource, undefined);
|
||||
assert.equal(updated.errorCode, undefined);
|
||||
assert.equal(updated.rateLimitedUntil, undefined);
|
||||
assert.equal(updated.backoffLevel, 0);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-routes-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const moderationRoute = await import("../../src/app/api/v1/moderations/route.ts");
|
||||
const embeddingsRoute = await import("../../src/app/api/v1/embeddings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
core.getDbInstance().exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
|
||||
}
|
||||
|
||||
async function seedOpenAIConnection(email) {
|
||||
return await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
email,
|
||||
name: email,
|
||||
apiKey: "sk-test",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
}
|
||||
|
||||
async function readConnection(id) {
|
||||
return await providersDb.getProviderConnectionById(id);
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("moderations route clears stale provider error metadata on success", async () => {
|
||||
await resetStorage();
|
||||
const created = await seedOpenAIConnection("moderation@example.com");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({
|
||||
id: "modr-1",
|
||||
model: "omni-moderation-latest",
|
||||
results: [{ flagged: false }],
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request("http://localhost/v1/moderations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: "hello" }),
|
||||
});
|
||||
|
||||
const response = await moderationRoute.POST(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const updated = await readConnection(created.id);
|
||||
assert.equal(updated.testStatus, "active");
|
||||
assert.equal(updated.errorCode, undefined);
|
||||
assert.equal(updated.lastErrorType, undefined);
|
||||
assert.equal(updated.lastErrorSource, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("embeddings route clears stale provider error metadata on success", async () => {
|
||||
await resetStorage();
|
||||
const created = await seedOpenAIConnection("embeddings@example.com");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({
|
||||
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
|
||||
});
|
||||
|
||||
const response = await embeddingsRoute.POST(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const updated = await readConnection(created.id);
|
||||
assert.equal(updated.testStatus, "active");
|
||||
assert.equal(updated.errorCode, undefined);
|
||||
assert.equal(updated.lastErrorType, undefined);
|
||||
assert.equal(updated.lastErrorSource, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { bootstrapEnv } from "../../scripts/bootstrap-env.mjs";
|
||||
|
||||
function withTempEnv(fn) {
|
||||
const originalCwd = process.cwd();
|
||||
const originalEnv = { ...process.env };
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bootstrap-test-"));
|
||||
const tempCwd = path.join(tempRoot, "cwd");
|
||||
const tempHome = path.join(tempRoot, "home");
|
||||
|
||||
fs.mkdirSync(tempCwd, { recursive: true });
|
||||
fs.mkdirSync(tempHome, { recursive: true });
|
||||
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
delete process.env.APPDATA;
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY_VERSION;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
process.env.HOME = tempHome;
|
||||
process.chdir(tempCwd);
|
||||
|
||||
try {
|
||||
fn({ tempRoot, tempCwd, tempHome, dataDir: path.join(tempHome, ".omniroute") });
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, ".env"),
|
||||
"STORAGE_ENCRYPTION_KEY=from-dot-env\nJWT_SECRET=jwt-from-dot-env\n",
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "server.env"),
|
||||
"STORAGE_ENCRYPTION_KEY=from-server-env\nJWT_SECRET=jwt-from-server-env\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = bootstrapEnv({ quiet: true });
|
||||
|
||||
assert.equal(env.STORAGE_ENCRYPTION_KEY, "from-dot-env");
|
||||
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new Database(path.join(dataDir, "storage.sqlite"));
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE provider_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
api_key TEXT,
|
||||
id_token TEXT
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
|
||||
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => bootstrapEnv({ quiet: true }),
|
||||
/Refusing to auto-generate STORAGE_ENCRYPTION_KEY/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => bootstrapEnv({ quiet: true }),
|
||||
/Unable to inspect existing database/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv ignores blank dataDirOverride values", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");
|
||||
|
||||
const env = bootstrapEnv({ dataDirOverride: " ", quiet: true });
|
||||
|
||||
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,63 @@ test("provider connection persists rateLimitProtection across reopen", async ()
|
||||
assert.equal(secondRead.rateLimitProtection, true);
|
||||
});
|
||||
|
||||
test('provider connection migration adds "group" column for existing databases', async () => {
|
||||
await resetStorage();
|
||||
|
||||
const sqlitePath = core.SQLITE_FILE;
|
||||
core.resetDbInstance();
|
||||
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
const db = new Database(sqlitePath);
|
||||
db.exec(`
|
||||
CREATE TABLE provider_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
auth_type TEXT,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT,
|
||||
token_expires_at TEXT,
|
||||
scope TEXT,
|
||||
project_id TEXT,
|
||||
test_status TEXT,
|
||||
error_code TEXT,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_type TEXT,
|
||||
last_error_source TEXT,
|
||||
backoff_level INTEGER DEFAULT 0,
|
||||
rate_limited_until TEXT,
|
||||
health_check_interval INTEGER,
|
||||
last_health_check_at TEXT,
|
||||
last_tested TEXT,
|
||||
api_key TEXT,
|
||||
id_token TEXT,
|
||||
provider_specific_data TEXT,
|
||||
expires_in INTEGER,
|
||||
display_name TEXT,
|
||||
global_priority INTEGER,
|
||||
default_model TEXT,
|
||||
token_type TEXT,
|
||||
consecutive_use_count INTEGER DEFAULT 0,
|
||||
rate_limit_protection INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.close();
|
||||
|
||||
const reopened = core.getDbInstance();
|
||||
const columns = reopened.prepare("PRAGMA table_info(provider_connections)").all();
|
||||
const names = new Set(columns.map((column) => column.name));
|
||||
assert.equal(names.has("group"), true);
|
||||
});
|
||||
|
||||
test("resolveProxyForConnection applies combo proxy for object/string model entries", async () => {
|
||||
await resetStorage();
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts";
|
||||
import { detectFormat } from "../../open-sse/services/provider.ts";
|
||||
import { translateRequest } from "../../open-sse/translator/index.ts";
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.ts";
|
||||
import {
|
||||
CodexExecutor,
|
||||
setDefaultFastServiceTierEnabled,
|
||||
} from "../../open-sse/executors/codex.ts";
|
||||
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
|
||||
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
|
||||
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
|
||||
@@ -23,6 +26,12 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => {
|
||||
assert.equal(info.model, "gpt-4o");
|
||||
});
|
||||
|
||||
test("getModelInfoCore resolves gpt-5.4 to codex", async () => {
|
||||
const info = await getModelInfoCore("gpt-5.4", {});
|
||||
assert.equal(info.provider, "codex");
|
||||
assert.equal(info.model, "gpt-5.4");
|
||||
});
|
||||
|
||||
test("getModelInfoCore returns explicit ambiguity metadata for ambiguous unprefixed model", async () => {
|
||||
const info = await getModelInfoCore("claude-haiku-4.5", {});
|
||||
assert.equal(info.provider, null);
|
||||
@@ -60,6 +69,32 @@ test("CodexExecutor forces stream=true for upstream compatibility", () => {
|
||||
assert.equal(transformed.stream, true);
|
||||
});
|
||||
|
||||
test("CodexExecutor maps fast service tier to priority", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"gpt-5.1-codex",
|
||||
{ model: "gpt-5.1-codex", input: [], service_tier: "fast" },
|
||||
true
|
||||
);
|
||||
assert.equal(transformed.service_tier, "priority");
|
||||
});
|
||||
|
||||
test("CodexExecutor can force fast service tier from settings", () => {
|
||||
setDefaultFastServiceTierEnabled(true);
|
||||
|
||||
try {
|
||||
const executor = new CodexExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"gpt-5.1-codex",
|
||||
{ model: "gpt-5.1-codex", input: [] },
|
||||
true
|
||||
);
|
||||
assert.equal(transformed.service_tier, "priority");
|
||||
} finally {
|
||||
setDefaultFastServiceTierEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("CodexExecutor always requests SSE accept header", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "test-token" }, false);
|
||||
@@ -166,6 +201,24 @@ test("translateRequest normalizes openai-responses input string into list payloa
|
||||
assert.equal(translated.input[0].content[0].text, "hello from responses");
|
||||
});
|
||||
|
||||
test("translateRequest preserves service_tier when converting openai to openai-responses", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
{
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hello from chat completions" }],
|
||||
service_tier: "fast",
|
||||
stream: false,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(translated.service_tier, "fast");
|
||||
assert.ok(Array.isArray(translated.input));
|
||||
});
|
||||
|
||||
test("parseSSEToResponsesOutput parses completed response from SSE payload", () => {
|
||||
const rawSSE = [
|
||||
"event: response.created",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { transformToOmniRoute } from "../../src/lib/pricingSync.ts";
|
||||
|
||||
// ─── transformToOmniRoute ────────────────────────────────
|
||||
|
||||
describe("transformToOmniRoute", () => {
|
||||
test("converts LiteLLM per-token pricing to OmniRoute per-million format", () => {
|
||||
const raw = {
|
||||
"openai/gpt-4o": {
|
||||
input_cost_per_token: 0.0000025,
|
||||
output_cost_per_token: 0.00001,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.openai, "Should have openai provider");
|
||||
assert.ok(result.openai["gpt-4o"], "Should have gpt-4o model");
|
||||
assert.strictEqual(result.openai["gpt-4o"].input, 2.5);
|
||||
assert.strictEqual(result.openai["gpt-4o"].output, 10);
|
||||
});
|
||||
|
||||
test("maps anthropic provider to cc alias", () => {
|
||||
const raw = {
|
||||
"anthropic/claude-sonnet-4-20250514": {
|
||||
input_cost_per_token: 0.000003,
|
||||
output_cost_per_token: 0.000015,
|
||||
litellm_provider: "anthropic",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.cc, "Should map to cc alias");
|
||||
assert.ok(result.cc["claude-sonnet-4-20250514"]);
|
||||
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].input, 3);
|
||||
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].output, 15);
|
||||
});
|
||||
|
||||
test("maps vertex_ai provider to gemini and gc aliases", () => {
|
||||
const raw = {
|
||||
"vertex_ai/gemini-2.5-flash": {
|
||||
input_cost_per_token: 0.0000003,
|
||||
output_cost_per_token: 0.0000025,
|
||||
litellm_provider: "vertex_ai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.gemini, "Should map to gemini alias");
|
||||
assert.ok(result.gc, "Should map to gc alias");
|
||||
assert.strictEqual(result.gemini["gemini-2.5-flash"].input, 0.3);
|
||||
assert.strictEqual(result.gc["gemini-2.5-flash"].input, 0.3);
|
||||
});
|
||||
|
||||
test("skips non-chat models (embedding, image, audio)", () => {
|
||||
const raw = {
|
||||
"openai/text-embedding-3-small": {
|
||||
input_cost_per_token: 0.00000002,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "openai",
|
||||
mode: "embedding",
|
||||
},
|
||||
"openai/dall-e-3": {
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "openai",
|
||||
mode: "image_generation",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// openai key should not exist since all models were filtered
|
||||
const openaiModels = result.openai || {};
|
||||
assert.strictEqual(Object.keys(openaiModels).length, 0, "Should skip non-chat models");
|
||||
});
|
||||
|
||||
test("includes cache pricing when available", () => {
|
||||
const raw = {
|
||||
"anthropic/claude-sonnet-4-20250514": {
|
||||
input_cost_per_token: 0.000003,
|
||||
output_cost_per_token: 0.000015,
|
||||
cache_read_input_token_cost: 0.0000003,
|
||||
cache_creation_input_token_cost: 0.00000375,
|
||||
litellm_provider: "anthropic",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
const model = result.anthropic["claude-sonnet-4-20250514"];
|
||||
assert.ok(model, "Should have model");
|
||||
assert.strictEqual(model.cached, 0.3);
|
||||
assert.strictEqual(model.cache_creation, 3.75);
|
||||
});
|
||||
|
||||
test("handles models without explicit mode (treated as chat)", () => {
|
||||
const raw = {
|
||||
"deepseek/deepseek-chat": {
|
||||
input_cost_per_token: 0.00000014,
|
||||
output_cost_per_token: 0.00000028,
|
||||
litellm_provider: "deepseek",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// deepseek maps to "if" alias
|
||||
assert.ok(result.if, "Should map deepseek to if alias");
|
||||
assert.ok(result.if["deepseek-chat"]);
|
||||
});
|
||||
|
||||
test("skips entries without input cost", () => {
|
||||
const raw = {
|
||||
"unknown/model": {
|
||||
litellm_provider: "unknown",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
const unknownModels = result.unknown || {};
|
||||
assert.strictEqual(Object.keys(unknownModels).length, 0);
|
||||
});
|
||||
|
||||
test("handles zero-cost (free) models", () => {
|
||||
const raw = {
|
||||
"groq/llama-3.3-70b-versatile": {
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "groq",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.groq, "Should have groq provider");
|
||||
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].input, 0);
|
||||
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].output, 0);
|
||||
});
|
||||
|
||||
test("uses litellm_provider as-is for unmapped providers", () => {
|
||||
const raw = {
|
||||
"newprovider/some-model": {
|
||||
input_cost_per_token: 0.000001,
|
||||
output_cost_per_token: 0.000002,
|
||||
litellm_provider: "newprovider",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.newprovider, "Should use litellm_provider as-is");
|
||||
assert.ok(result.newprovider["some-model"]);
|
||||
});
|
||||
|
||||
test("strips provider prefix from model key", () => {
|
||||
const raw = {
|
||||
"openai/gpt-4o-mini": {
|
||||
input_cost_per_token: 0.00000015,
|
||||
output_cost_per_token: 0.0000006,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.openai["gpt-4o-mini"], "Should strip openai/ prefix");
|
||||
assert.strictEqual(result.openai["gpt-4o-mini"].input, 0.15);
|
||||
});
|
||||
|
||||
test("rounds pricing to 3 decimal places", () => {
|
||||
const raw = {
|
||||
"test/model": {
|
||||
input_cost_per_token: 0.00000033333,
|
||||
output_cost_per_token: 0.00000066666,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// 0.00000033333 * 1e6 = 0.33333 → rounded to 0.333
|
||||
assert.strictEqual(result.openai.model.input, 0.333);
|
||||
assert.strictEqual(result.openai.model.output, 0.667);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Merge precedence ────────────────────────────────────
|
||||
|
||||
describe("pricing merge precedence", () => {
|
||||
test("user overrides > synced > defaults conceptual order", () => {
|
||||
// This test validates the conceptual model.
|
||||
// The actual merge is tested via integration with settings.ts.
|
||||
// Here we verify transform doesn't lose data needed for merge.
|
||||
const raw = {
|
||||
"openai/gpt-4o": {
|
||||
input_cost_per_token: 0.0000025,
|
||||
output_cost_per_token: 0.00001,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const synced = transformToOmniRoute(raw);
|
||||
const userOverride = { openai: { "gpt-4o": { input: 999 } } };
|
||||
|
||||
// Simulate merge: synced then user
|
||||
const merged = { ...synced.openai["gpt-4o"], ...userOverride.openai["gpt-4o"] };
|
||||
|
||||
assert.strictEqual(merged.input, 999, "User override should win");
|
||||
assert.strictEqual(merged.output, 10, "Non-overridden fields from synced should remain");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ── Env vars BEFORE dynamic imports ──────────────────────────────────────────
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-strict-random-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "strict-random-test-secret";
|
||||
|
||||
const { fisherYatesShuffle, getNextFromDeck } = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── fisherYatesShuffle ──────────────────────────────────────────────────────
|
||||
|
||||
test("fisherYatesShuffle: returns array with same elements", () => {
|
||||
const input = ["a", "b", "c", "d", "e"];
|
||||
const result = fisherYatesShuffle(input);
|
||||
assert.equal(result.length, input.length);
|
||||
for (const item of input) {
|
||||
assert.ok(result.includes(item), `Missing item: ${item}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: does not mutate original array", () => {
|
||||
const input = Object.freeze(["a", "b", "c"]);
|
||||
const result = fisherYatesShuffle(input);
|
||||
assert.deepStrictEqual([...input], ["a", "b", "c"]);
|
||||
assert.equal(result.length, 3);
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: single element returns same element", () => {
|
||||
const result = fisherYatesShuffle(["only"]);
|
||||
assert.deepStrictEqual(result, ["only"]);
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: empty array returns empty array", () => {
|
||||
const result = fisherYatesShuffle([]);
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
// ─── getNextFromDeck ─────────────────────────────────────────────────────────
|
||||
|
||||
test("getNextFromDeck: uses all connections before repeating", () => {
|
||||
const provider = "test-full-cycle";
|
||||
const ids = ["c1", "c2", "c3", "c4"];
|
||||
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.ok(!seen.has(id), `Duplicate before full cycle: ${id} at step ${i}`);
|
||||
seen.add(id);
|
||||
}
|
||||
assert.equal(seen.size, ids.length, "Should have used every connection exactly once");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: reshuffles after exhausting deck", () => {
|
||||
const provider = "test-reshuffle";
|
||||
const ids = ["c1", "c2", "c3"];
|
||||
|
||||
// Exhaust first cycle
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
getNextFromDeck(provider, ids);
|
||||
}
|
||||
|
||||
// Next call should start a new cycle (reshuffle)
|
||||
const firstOfNewCycle = getNextFromDeck(provider, ids);
|
||||
assert.ok(ids.includes(firstOfNewCycle), "New cycle should return a valid connection");
|
||||
|
||||
// Complete the new cycle
|
||||
const newCycleSeen = new Set([firstOfNewCycle]);
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.ok(!newCycleSeen.has(id), `Duplicate in new cycle: ${id}`);
|
||||
newCycleSeen.add(id);
|
||||
}
|
||||
assert.equal(newCycleSeen.size, ids.length, "New cycle should use all connections");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: last of previous cycle is not first of next cycle", () => {
|
||||
const provider = "test-no-repeat-boundary";
|
||||
const ids = ["c1", "c2", "c3", "c4", "c5"];
|
||||
|
||||
// Run multiple full cycles and check the boundary condition
|
||||
let violations = 0;
|
||||
const totalCycles = 50;
|
||||
|
||||
for (let cycle = 0; cycle < totalCycles; cycle++) {
|
||||
let lastId = "";
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
lastId = getNextFromDeck(provider, ids);
|
||||
}
|
||||
// First of next cycle
|
||||
const firstOfNext = getNextFromDeck(provider, ids);
|
||||
if (firstOfNext === lastId) violations++;
|
||||
|
||||
// Consume rest of cycle
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
getNextFromDeck(provider, ids);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations,
|
||||
0,
|
||||
`Last of cycle matched first of next cycle ${violations}/${totalCycles} times`
|
||||
);
|
||||
});
|
||||
|
||||
test("getNextFromDeck: connection list change resets deck", () => {
|
||||
const provider = "test-reset-on-change";
|
||||
const originalIds = ["c1", "c2", "c3", "c4"];
|
||||
|
||||
// Use 2 from original deck
|
||||
getNextFromDeck(provider, originalIds);
|
||||
getNextFromDeck(provider, originalIds);
|
||||
|
||||
// Now change the connection list (simulates quota exhaustion removing a connection)
|
||||
const newIds = ["c1", "c2", "c3"]; // c4 removed
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < newIds.length; i++) {
|
||||
const id = getNextFromDeck(provider, newIds);
|
||||
assert.ok(newIds.includes(id), `Got invalid id ${id} after reset`);
|
||||
assert.ok(!seen.has(id), `Duplicate after reset: ${id}`);
|
||||
seen.add(id);
|
||||
}
|
||||
assert.equal(seen.size, newIds.length, "Should use all new connections after reset");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: single connection always returns that connection", () => {
|
||||
const provider = "test-single";
|
||||
const ids = ["only-one"];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.equal(id, "only-one");
|
||||
}
|
||||
});
|
||||
|
||||
test("getNextFromDeck: empty array returns empty string", () => {
|
||||
const provider = "test-empty";
|
||||
const id = getNextFromDeck(provider, []);
|
||||
assert.equal(id, "");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: different providers have independent decks", () => {
|
||||
const idsA = ["a1", "a2", "a3"];
|
||||
const idsB = ["b1", "b2"];
|
||||
|
||||
const firstA = getNextFromDeck("providerA", idsA);
|
||||
const firstB = getNextFromDeck("providerB", idsB);
|
||||
|
||||
assert.ok(idsA.includes(firstA));
|
||||
assert.ok(idsB.includes(firstB));
|
||||
|
||||
// Exhaust providerB deck
|
||||
getNextFromDeck("providerB", idsB);
|
||||
|
||||
// providerA should still have remaining items from its deck
|
||||
const secondA = getNextFromDeck("providerA", idsA);
|
||||
assert.ok(idsA.includes(secondA));
|
||||
assert.notEqual(firstA, secondA, "providerA deck should advance independently");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ── Env vars ANTES dos imports dinâmicos ─────────────────────────
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t08-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "t08-test-secret-key";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const schemas = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "../..");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Bloco 1 — Schema Zod (updateKeyPermissionsSchema)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
test("1.1 — allowedConnections com UUID válido é aceito", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("1.2 — allowedConnections com string não-UUID é rejeitado", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["nao-e-uuid"],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("1.3 — allowedConnections + noLog combinados são aceitos", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
noLog: true,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("1.4 — payload vazio é rejeitado pelo superRefine", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("1.5 — allowedConnections array vazio é aceito (não é undefined, superRefine não dispara)", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: [],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Bloco 2 — DB (apiKeys.ts)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
test("2.1 — key criada tem allowedConnections: [] por padrão", async () => {
|
||||
const created = await apiKeysDb.createApiKey("test-key", "machine-t08");
|
||||
assert.deepEqual(created.allowedConnections, []);
|
||||
});
|
||||
|
||||
test("2.2 — updateApiKeyPermissions persiste array de UUIDs", async () => {
|
||||
const created = await apiKeysDb.createApiKey("conn-key", "machine-t08");
|
||||
const uuid1 = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const uuid2 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
|
||||
|
||||
const updated = await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
allowedConnections: [uuid1, uuid2],
|
||||
});
|
||||
assert.equal(updated, true);
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.deepEqual(row?.allowedConnections, [uuid1, uuid2]);
|
||||
});
|
||||
|
||||
test("2.3 — getApiKeyById retorna allowedConnections corretamente", async () => {
|
||||
const created = await apiKeysDb.createApiKey("by-id-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.ok(Array.isArray(row?.allowedConnections));
|
||||
assert.equal(row?.allowedConnections[0], uuid);
|
||||
});
|
||||
|
||||
test("2.4 — getApiKeyMetadata retorna allowedConnections corretamente", async () => {
|
||||
const created = await apiKeysDb.createApiKey("meta-key", "machine-t08");
|
||||
const uuid = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(Array.isArray(meta?.allowedConnections));
|
||||
assert.equal(meta?.allowedConnections[0], uuid);
|
||||
});
|
||||
|
||||
test("2.5 — allowedConnections: [] persiste como array vazio (não null)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("empty-conn-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Primeiro adiciona um UUID
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
// Reseta para array vazio
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [] });
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.ok(Array.isArray(row?.allowedConnections));
|
||||
assert.deepEqual(row?.allowedConnections, []);
|
||||
});
|
||||
|
||||
test("2.6 — cache é invalidado após update (2ª chamada ao metadata reflete novo valor)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("cache-test-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Primeira chamada popula cache (allowedConnections vazio)
|
||||
const before = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.deepEqual(before?.allowedConnections, []);
|
||||
|
||||
// Atualiza — deve invalidar cache
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
// Segunda chamada deve retornar novo valor (não o cache antigo)
|
||||
const after = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.deepEqual(after?.allowedConnections, [uuid]);
|
||||
});
|
||||
Reference in New Issue
Block a user