Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36856b18db | |||
| 66f0a8f994 | |||
| 455231170f | |||
| 5faeb58ab0 | |||
| 056e4a88ff | |||
| 8fd944ccf7 | |||
| 86105a547c | |||
| 9806648c07 | |||
| 6186babdb3 | |||
| f2ecefb54a | |||
| 43bd529b78 | |||
| 9c82b3d4ca | |||
| b19e6a8e87 | |||
| e3a2bd75f3 | |||
| da39e1485f | |||
| 88cc53a4b0 |
+16
-1
@@ -2,7 +2,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.5.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.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.5.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",
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.5.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;
|
||||
|
||||
@@ -2540,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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -35,10 +35,22 @@ 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 {
|
||||
@@ -76,6 +88,8 @@ 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),
|
||||
@@ -469,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 {
|
||||
@@ -569,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
|
||||
|
||||
@@ -582,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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user