Compare commits
27 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 |
+28
-1
@@ -2,7 +2,34 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.5.0] - 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.
|
||||
|
||||
|
||||
@@ -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.5.0
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.4",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.4.4",
|
||||
"version": "2.5.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.5.0",
|
||||
"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;
|
||||
|
||||
@@ -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 =
|
||||
@@ -2425,7 +2540,7 @@ function ConnectionRow({
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500"
|
||||
title={t("deleteConnection")}
|
||||
title={t("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
@@ -2451,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,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -449,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),
|
||||
|
||||
+156
-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,
|
||||
@@ -35,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) : {};
|
||||
}
|
||||
@@ -74,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();
|
||||
|
||||
@@ -101,7 +151,8 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
|
||||
*/
|
||||
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;
|
||||
@@ -114,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"}`
|
||||
@@ -204,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(
|
||||
@@ -355,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 {
|
||||
@@ -455,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
|
||||
|
||||
@@ -468,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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user