Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce6d7dc6bf | |||
| 19eeebae95 | |||
| 1dd05bffe8 | |||
| ac3d251a1a | |||
| 238e080928 | |||
| 7ed40c2139 | |||
| d2bee37e76 | |||
| 343e6c50e3 | |||
| 90d2dcac97 | |||
| 631ed4d97f | |||
| 9485985608 | |||
| b32db28a3d | |||
| 1516429b87 | |||
| 5668e16fbf | |||
| bd4a076942 | |||
| f0a0c97b5e | |||
| 7ffe21e23d | |||
| 4b137d8e72 | |||
| 6ba48241fe | |||
| a17583d3fc | |||
| 62c634ae78 | |||
| 02dc8ea0f3 | |||
| a40c463a87 |
@@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] — 2026-02-23
|
||||
|
||||
> ### ✨ Feature Release — Dedicated API Key Manager with Model Permissions
|
||||
>
|
||||
> Community-contributed API Key Manager page with model-level access control, enhanced with usage statistics, key status indicators, and improved UX.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Dedicated API Key Manager** — New `/dashboard/api-manager` page for managing API keys, extracted from the Endpoint page. Includes create, delete, and permissions management with a clean table UI ([PR #118](https://github.com/diegosouzapw/OmniRoute/pull/118) by [@nyatoru](https://github.com/nyatoru))
|
||||
- **Model-Level API Key Permissions** — Restrict API keys to specific models using `allowed_models` with wildcard pattern support (e.g., `openai/*`). Toggle between "Allow All" and "Restrict" modes with an intuitive provider-grouped model selector
|
||||
- **API Key Validation Cache** — 3-tier caching layer (validation, metadata, permission) reduces database hits on every request, with automatic cache invalidation on key changes
|
||||
- **Usage Statistics Per Key** — Each API key shows total request count and last used timestamp, with a stats summary dashboard (total keys, restricted keys, total requests, models available)
|
||||
- **Key Status Indicators** — Color-coded lock/unlock icons and copy buttons on each key row for quick identification of restricted vs unrestricted keys
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **Endpoint Page Simplified** — API key management removed from Endpoint page and replaced with a prominent link to the API Manager
|
||||
- **Sidebar Navigation** — New "API Manager" entry with `vpn_key` icon in the sidebar
|
||||
- **Prepared Statements** — API key database operations now use cached prepared statements for better performance
|
||||
- **Input Validation** — XSS-safe sanitization and regex validation for key names; ID format validation for API calls
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] — 2026-02-23
|
||||
|
||||
> ### 🐛 Bugfix Release — Proxy Connection Tests & Compatible Provider Display
|
||||
>
|
||||
> Fixes provider connection tests bypassing configured proxy and improves compatible provider display in the request logger.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Connection Tests Now Use Proxy** — Provider connection tests (`Test Connection` button) now route through the configured proxy (key → combo → provider → global → direct), matching the behavior of real API calls. Previously, `fetch()` was called directly, bypassing the proxy entirely ([#119](https://github.com/diegosouzapw/OmniRoute/issues/119))
|
||||
- **Compatible Provider Display in Logs** — OpenAI/Anthropic compatible providers now show friendly labels (`OAI-COMPAT`, `ANT-COMPAT`) instead of raw UUID-based IDs in the request logger's provider column, dropdown, and quick filters ([#113](https://github.com/diegosouzapw/OmniRoute/issues/113))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **Connection Test Unit Tests** — 26 new test cases covering error classification logic, token expiry detection, and provider display label resolution
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] — 2026-02-23
|
||||
|
||||
> ### ✨ Feature Release — iFlow Fix, Health Check Logs Toggle, Kilocode Models & Model Deduplication
|
||||
>
|
||||
> Community-driven release with iFlow HMAC-SHA256 signature support, health check log management, expanded Kilocode model list, and model deduplication on the dashboard.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Hide Health Check Logs** — New toggle in Settings → Appearance to suppress verbose `[HealthCheck]` messages from the server console. Uses a 30-second cache to minimize database reads with request coalescing for concurrent calls ([PR #111](https://github.com/diegosouzapw/OmniRoute/pull/111) by [@nyatoru](https://github.com/nyatoru))
|
||||
- **Kilocode Custom Models Endpoint** — Added `modelsUrl` support in `RegistryEntry` for providers with non-standard model endpoints. Expanded Kilocode model list from 8 to 26 models including Qwen3, GPT-5, Claude 3 Haiku, Gemini 2.5, DeepSeek V3, Llama 4, and more ([PR #115](https://github.com/diegosouzapw/OmniRoute/pull/115) by [@benzntech](https://github.com/benzntech))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **iFlow 406 Error** — Created dedicated `IFlowExecutor` with HMAC-SHA256 signature support (`session-id`, `x-iflow-timestamp`, `x-iflow-signature` headers). The iFlow provider was previously using the default executor which lacked the required signature headers, causing 406 errors ([#114](https://github.com/diegosouzapw/OmniRoute/issues/114))
|
||||
- **Duplicate Models in Endpoint Lists** — Filtered out parent models (`!m.parent`) from all model categorization and count logic on the Endpoint page. Provider modal lists also exclude duplicates ([PR #112](https://github.com/diegosouzapw/OmniRoute/pull/112) by [@nyatoru](https://github.com/nyatoru))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **IFlowExecutor Unit Tests** — 11 new test cases covering HMAC-SHA256 signature generation, header building, URL construction, body passthrough, and executor registry integration
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] — 2026-02-22
|
||||
|
||||
> ### ✨ Feature Release — Dashboard Session Auth for Models Endpoint
|
||||
>
|
||||
> Dashboard users can now access `/v1/models` via their existing session when API key auth is required.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **JWT Session Auth Fallback** — When `requireAuthForModels` is enabled, the `/v1/models` endpoint now accepts both API key (Bearer token) for external clients **and** the dashboard JWT session cookie (`auth_token`), allowing logged-in dashboard users to view models without needing an explicit API key ([PR #110](https://github.com/diegosouzapw/OmniRoute/pull/110) by [@nyatoru](https://github.com/nyatoru))
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **401 instead of 404** — Authentication failures on `/v1/models` now return `401 Unauthorized` with a structured JSON error body (OpenAI-compatible format) instead of a generic `404 Not Found`, improving debuggability for API clients
|
||||
- **Simplified auth logic** — Refactored the JWT cookie verification to reuse the same pattern as `apiAuth.ts`, removing redundant same-origin detection (~60 lines) since the `sameSite:lax` + `httpOnly` cookie flags already provide equivalent CSRF protection
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] — 2026-02-22
|
||||
|
||||
> ### 🐛 Bugfix Release — API Key Creation & Codex Team Plan Quotas
|
||||
@@ -450,6 +529,8 @@ New environment variables:
|
||||
|
||||
---
|
||||
|
||||
[1.3.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.3.0
|
||||
[1.2.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.2.0
|
||||
[1.1.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.1
|
||||
[1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7
|
||||
[1.0.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.6
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface RegistryEntry {
|
||||
extraHeaders?: Record<string, string>;
|
||||
oauth?: RegistryOAuth;
|
||||
models: RegistryModel[];
|
||||
modelsUrl?: string;
|
||||
chatPath?: string;
|
||||
clientVersion?: string;
|
||||
passthroughModels?: boolean;
|
||||
@@ -211,7 +212,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
id: "iflow",
|
||||
alias: "if",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
executor: "iflow",
|
||||
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
@@ -503,6 +504,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
format: "openrouter",
|
||||
executor: "openrouter",
|
||||
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
|
||||
modelsUrl: "https://api.kilo.ai/api/openrouter/models",
|
||||
authType: "oauth",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
@@ -511,14 +513,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
pollUrlBase: "https://api.kilo.ai/api/device-auth/codes",
|
||||
},
|
||||
models: [
|
||||
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
||||
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
|
||||
{ id: "openai/o3", name: "o3" },
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
|
||||
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
|
||||
{ id: "openrouter/free", name: "Free Models Router" },
|
||||
{ id: "qwen/qwen3-vl-235b-a22b-thinking", name: "Qwen3 VL 235B A22B Thinking" },
|
||||
{ id: "qwen/qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
|
||||
{ id: "qwen/qwen3-vl-30b-a3b-thinking", name: "Qwen3 VL 30B A3B Thinking" },
|
||||
{ id: "stepfun/step-3.5-flash:free", name: "StepFun Step 3.5 Flash" },
|
||||
{ id: "arcee-ai/trinity-large-preview:free", name: "Arcee AI Trinity Large Preview" },
|
||||
{ id: "openai/gpt-4o-mini", name: "GPT-4o Mini" },
|
||||
{ id: "openai/gpt-4.1-nano", name: "GPT-4.1 Nano" },
|
||||
{ id: "openai/gpt-5-nano", name: "GPT-5 Nano" },
|
||||
{ id: "openai/gpt-5-mini", name: "GPT-5 Mini" },
|
||||
{ id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku" },
|
||||
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "google/gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "deepseek/deepseek-chat-v3.1", name: "DeepSeek V3.1" },
|
||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta-llama/llama-4-scout", name: "Llama 4 Scout" },
|
||||
{ id: "meta-llama/llama-4-maverick", name: "Llama 4 Maverick" },
|
||||
{ id: "qwen/qwen3-8b", name: "Qwen3 8B" },
|
||||
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
|
||||
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B" },
|
||||
{ id: "qwen/qwq-32b", name: "QwQ 32B" },
|
||||
{ id: "mistralai/mistral-small-24b-instruct-2501", name: "Mistral Small 3" },
|
||||
{ id: "mistralai/mistral-7b-instruct", name: "Mistral 7B" },
|
||||
{ id: "x-ai/grok-code-fast-1", name: "Grok Code Fast 1" },
|
||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import crypto from "crypto";
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
* IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature.
|
||||
*
|
||||
* iFlow requires custom headers including a session ID, timestamp,
|
||||
* and an HMAC-SHA256 signature for request authentication.
|
||||
* Without these headers, the API returns a 406 error.
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114
|
||||
*/
|
||||
export class IFlowExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("iflow", PROVIDERS.iflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create iFlow signature using HMAC-SHA256
|
||||
* @param userAgent - User agent string
|
||||
* @param sessionID - Session ID
|
||||
* @param timestamp - Unix timestamp in milliseconds
|
||||
* @param apiKey - API key for signing
|
||||
* @returns Hex-encoded signature
|
||||
*/
|
||||
createIFlowSignature(
|
||||
userAgent: string,
|
||||
sessionID: string,
|
||||
timestamp: number,
|
||||
apiKey: string
|
||||
): string {
|
||||
if (!apiKey) return "";
|
||||
const payload = `${userAgent}:${sessionID}:${timestamp}`;
|
||||
const hmac = crypto.createHmac("sha256", apiKey);
|
||||
hmac.update(payload);
|
||||
return hmac.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers with iFlow-specific HMAC-SHA256 signature.
|
||||
* Includes session-id, x-iflow-timestamp, and x-iflow-signature.
|
||||
*/
|
||||
buildHeaders(credentials: any, stream = true) {
|
||||
// Generate session ID and timestamp
|
||||
const sessionID = `session-${crypto.randomUUID()}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Get user agent from config
|
||||
const userAgent = this.config.headers?.["User-Agent"] || "iFlow-Cli";
|
||||
|
||||
// Get API key (prefer apiKey, fallback to accessToken)
|
||||
const apiKey = credentials.apiKey || credentials.accessToken || "";
|
||||
|
||||
// Create HMAC-SHA256 signature
|
||||
const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey);
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers,
|
||||
"session-id": sessionID,
|
||||
"x-iflow-timestamp": timestamp.toString(),
|
||||
"x-iflow-signature": signature,
|
||||
};
|
||||
|
||||
// Add authorization
|
||||
if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
|
||||
// Add streaming header
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for iFlow API — uses baseUrl directly.
|
||||
*/
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform request body (passthrough for iFlow).
|
||||
*/
|
||||
transformRequest(model: string, body: any, stream: boolean, credentials: any) {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default IFlowExecutor;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AntigravityExecutor } from "./antigravity.ts";
|
||||
import { GeminiCLIExecutor } from "./gemini-cli.ts";
|
||||
import { GithubExecutor } from "./github.ts";
|
||||
import { IFlowExecutor } from "./iflow.ts";
|
||||
import { KiroExecutor } from "./kiro.ts";
|
||||
import { CodexExecutor } from "./codex.ts";
|
||||
import { CursorExecutor } from "./cursor.ts";
|
||||
@@ -10,6 +11,7 @@ const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
"gemini-cli": new GeminiCLIExecutor(),
|
||||
github: new GithubExecutor(),
|
||||
iflow: new IFlowExecutor(),
|
||||
kiro: new KiroExecutor(),
|
||||
codex: new CodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
@@ -32,6 +34,7 @@ export { BaseExecutor } from "./base.ts";
|
||||
export { AntigravityExecutor } from "./antigravity.ts";
|
||||
export { GeminiCLIExecutor } from "./gemini-cli.ts";
|
||||
export { GithubExecutor } from "./github.ts";
|
||||
export { IFlowExecutor } from "./iflow.ts";
|
||||
export { KiroExecutor } from "./kiro.ts";
|
||||
export { CodexExecutor } from "./codex.ts";
|
||||
export { CursorExecutor } from "./cursor.ts";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.1.1",
|
||||
"version": "1.4.0",
|
||||
"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": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "next start --port 20128",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test tests/unit/*.test.mjs",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
import ApiManagerPageClient from "./ApiManagerPageClient";
|
||||
|
||||
export default function ApiManagerPage() {
|
||||
return <ApiManagerPageClient />;
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import Link from "next/link";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
|
||||
// Endpoints / models state
|
||||
const [allModels, setAllModels] = useState([]);
|
||||
@@ -53,16 +49,19 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
// Categorize models by endpoint type
|
||||
// Filter out parent models (models with parent field set) to avoid showing duplicates
|
||||
const endpointData = useMemo(() => {
|
||||
const chat = allModels.filter((m) => !m.type);
|
||||
const embeddings = allModels.filter((m) => m.type === "embedding");
|
||||
const images = allModels.filter((m) => m.type === "image");
|
||||
const rerank = allModels.filter((m) => m.type === "rerank");
|
||||
const chat = allModels.filter((m) => !m.type && !m.parent);
|
||||
const embeddings = allModels.filter((m) => m.type === "embedding" && !m.parent);
|
||||
const images = allModels.filter((m) => m.type === "image" && !m.parent);
|
||||
const rerank = allModels.filter((m) => m.type === "rerank" && !m.parent);
|
||||
const audioTranscription = allModels.filter(
|
||||
(m) => m.type === "audio" && m.subtype === "transcription"
|
||||
(m) => m.type === "audio" && m.subtype === "transcription" && !m.parent
|
||||
);
|
||||
const audioSpeech = allModels.filter((m) => m.type === "audio" && m.subtype === "speech");
|
||||
const moderation = allModels.filter((m) => m.type === "moderation");
|
||||
const audioSpeech = allModels.filter(
|
||||
(m) => m.type === "audio" && m.subtype === "speech" && !m.parent
|
||||
);
|
||||
const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent);
|
||||
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
|
||||
}, [allModels]);
|
||||
|
||||
@@ -130,16 +129,9 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [keysRes, providersRes] = await Promise.all([
|
||||
fetch("/api/keys"),
|
||||
fetch("/api/providers"),
|
||||
]);
|
||||
const providersRes = await fetch("/api/providers");
|
||||
|
||||
const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]);
|
||||
|
||||
if (keysRes.ok) {
|
||||
setKeys(keysData.keys || []);
|
||||
}
|
||||
const providersData = await providersRes.json();
|
||||
|
||||
if (providersRes.ok) {
|
||||
setProviderConnections(providersData.connections || []);
|
||||
@@ -280,41 +272,6 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setCreatedKey(data.key);
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setShowAddModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id) => {
|
||||
if (!confirm("Delete this API key?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setKeys(keys.filter((k) => k.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const cloudEndpointNew = `${CLOUD_URL}/v1`;
|
||||
|
||||
@@ -439,93 +396,22 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Registered Keys — collapsible section inside API Endpoint card */}
|
||||
<div className="border border-border rounded-lg overflow-hidden mt-4">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">Registered Keys</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
|
||||
{keys.length} {keys.length === 1 ? "key" : "keys"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Manage API keys used to authenticate requests to this endpoint
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedEndpoint === "keys" && (
|
||||
<div className="border-t border-border px-4 pb-4">
|
||||
<div className="flex items-center justify-between mt-3 mb-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
Each key isolates usage tracking and can be revoked independently.
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
|
||||
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
Create your first API key to get started
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(key.id)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Link to API Manager */}
|
||||
<div className="flex items-center gap-3 p-4 border border-border rounded-lg mt-4 bg-surface/30">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm">API Key Management</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Create and manage API keys for authenticating requests
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/api-manager">
|
||||
<Button size="sm" variant="secondary" icon="arrow_forward">
|
||||
Manage Keys
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -535,7 +421,8 @@ export default function APIPageClient({ machineId }) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Available Endpoints</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{allModels.length} models across{" "}
|
||||
{Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)} models
|
||||
across{" "}
|
||||
{
|
||||
[
|
||||
endpointData.chat,
|
||||
@@ -833,67 +720,6 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Key Modal */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
title="Create API Key"
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Key Name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Production Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal isOpen={!!createdKey} title="API Key Created" onClose={() => setCreatedKey(null)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
||||
Save this key now!
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
This is the only time you will see this key. Store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "created_key" ? "check" : "content_copy"}
|
||||
onClick={() => copy(createdKey, "created_key")}
|
||||
>
|
||||
{copied === "created_key" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setCreatedKey(null)} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Disable Cloud Modal */}
|
||||
<Modal
|
||||
isOpen={showDisableModal}
|
||||
@@ -979,83 +805,16 @@ APIPageClient.propTypes = {
|
||||
machineId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function ProviderOverviewCard({ item, onClick }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const statusVariant =
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-[10px] font-bold"
|
||||
style={{ color: item.provider.color || "#888" }}
|
||||
>
|
||||
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${item.provider.id}.png`}
|
||||
alt={item.provider.name}
|
||||
width={26}
|
||||
height={26}
|
||||
className="object-contain rounded-lg"
|
||||
sizes="26px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
|
||||
<p className={`text-xs ${statusVariant}`}>
|
||||
{item.total === 0
|
||||
? "Not configured"
|
||||
: `${item.connected} active · ${item.errors} error`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-text-muted">#{item.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderOverviewCard.propTypes = {
|
||||
item: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string,
|
||||
textIcon: PropTypes.string,
|
||||
}).isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
connected: PropTypes.number.isRequired,
|
||||
errors: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
// -- Sub-component: Provider Models Modal ------------------------------------------
|
||||
|
||||
function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
|
||||
// Get provider alias for matching models
|
||||
// Filter out parent models (models with parent field set) to avoid showing duplicates
|
||||
const providerAlias = provider.provider.alias || provider.id;
|
||||
const providerModels = useMemo(() => {
|
||||
return models.filter((m) => m.owned_by === providerAlias || m.owned_by === provider.id);
|
||||
return models.filter(
|
||||
(m) => !m.parent && (m.owned_by === providerAlias || m.owned_by === provider.id)
|
||||
);
|
||||
}, [models, providerAlias, provider.id]);
|
||||
|
||||
const chatModels = providerModels.filter((m) => !m.type);
|
||||
@@ -1151,7 +910,9 @@ function EndpointSection({
|
||||
if (!map[owner]) map[owner] = [];
|
||||
map[owner].push(m);
|
||||
}
|
||||
return Object.entries(map).sort((a: any, b: any) => (b[1] as any).length - (a[1] as any).length);
|
||||
return Object.entries(map).sort(
|
||||
(a: any, b: any) => (b[1] as any).length - (a[1] as any).length
|
||||
);
|
||||
}, [models]);
|
||||
|
||||
const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id);
|
||||
@@ -1216,7 +977,9 @@ function EndpointSection({
|
||||
<span className="text-xs font-semibold text-text-main">
|
||||
{providerName(providerId)}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">({(providerModels as any).length})</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
({(providerModels as any).length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-5 flex flex-wrap gap-1.5">
|
||||
{(providerModels as any).map((m) => (
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export default function AppearanceTab() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSettings(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to update ${key}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -53,6 +86,22 @@ export default function AppearanceTab() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Hide Health Check Logs</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
When ON, suppress [HealthCheck] messages in server console
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.hideHealthCheckLogs === true}
|
||||
onChange={() => updateSetting("hideHealthCheckLogs", !settings.hideHealthCheckLogs)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import {
|
||||
deleteApiKey,
|
||||
getApiKeyById,
|
||||
updateApiKeyPermissions,
|
||||
isCloudEnabled,
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
|
||||
// GET /api/keys/[id] - Get single API key
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const key = await getApiKeyById(id);
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Mask the key value
|
||||
return NextResponse.json({
|
||||
...key,
|
||||
key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/keys/[id] - Update API key permissions
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { allowedModels } = body;
|
||||
|
||||
// Validate allowedModels is an array
|
||||
if (!Array.isArray(allowedModels)) {
|
||||
return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate each model ID is a string
|
||||
for (const model of allowedModels) {
|
||||
if (typeof model !== "string") {
|
||||
return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateApiKeyPermissions(id, allowedModels);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Permissions updated successfully",
|
||||
allowedModels,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating key permissions:", error);
|
||||
return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/keys/[id] - Delete API key
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
|
||||
@@ -198,6 +198,14 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
kilocode: {
|
||||
url: "https://api.kilo.ai/api/openrouter/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,8 +228,17 @@ export async function GET(request, { params }) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
const response = await fetch(url, {
|
||||
|
||||
let modelsUrl = baseUrl.replace(/\/$/, "");
|
||||
if (modelsUrl.endsWith("/chat/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -17) + "/models";
|
||||
} else if (modelsUrl.endsWith("/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -12) + "/models";
|
||||
} else {
|
||||
modelsUrl = `${modelsUrl}/models`;
|
||||
}
|
||||
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
||||
import {
|
||||
getProviderConnectionById,
|
||||
updateProviderConnection,
|
||||
isCloudEnabled,
|
||||
resolveProxyForConnection,
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
@@ -8,6 +13,7 @@ import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
|
||||
import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { logProxyEvent } from "@/lib/proxyLogger";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
// OAuth provider test endpoints
|
||||
const OAUTH_TEST_CONFIG = {
|
||||
@@ -91,7 +97,12 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function makeDiagnosis(type: string, source: string, message: string | null, code: string | null = null) {
|
||||
function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
@@ -100,7 +111,17 @@ function makeDiagnosis(type: string, source: string, message: string | null, cod
|
||||
};
|
||||
}
|
||||
|
||||
function classifyFailure({ error, statusCode = null, refreshFailed = false, unsupported = false }: { error: string; statusCode?: number | null; refreshFailed?: boolean; unsupported?: boolean }) {
|
||||
function classifyFailure({
|
||||
error,
|
||||
statusCode = null,
|
||||
refreshFailed = false,
|
||||
unsupported = false,
|
||||
}: {
|
||||
error: string;
|
||||
statusCode?: number | null;
|
||||
refreshFailed?: boolean;
|
||||
unsupported?: boolean;
|
||||
}) {
|
||||
const message = toSafeMessage(error, "Connection test failed");
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
@@ -510,6 +531,14 @@ export async function testSingleConnection(connectionId: string) {
|
||||
return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 };
|
||||
}
|
||||
|
||||
// Resolve proxy for this connection (key → combo → provider → global → direct)
|
||||
let proxyInfo: any = null;
|
||||
try {
|
||||
proxyInfo = await resolveProxyForConnection(connectionId);
|
||||
} catch (proxyErr: any) {
|
||||
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
|
||||
}
|
||||
|
||||
let result;
|
||||
const startTime = Date.now();
|
||||
const runtime = await getProviderRuntimeStatus(connection.provider);
|
||||
@@ -522,9 +551,13 @@ export async function testSingleConnection(connectionId: string) {
|
||||
diagnosis: (runtime as any).diagnosis,
|
||||
};
|
||||
} else if (connection.authType === "apikey") {
|
||||
result = await testApiKeyConnection(connection);
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
testApiKeyConnection(connection)
|
||||
);
|
||||
} else {
|
||||
result = await testOAuthConnection(connection);
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
testOAuthConnection(connection)
|
||||
);
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
@@ -591,9 +624,9 @@ export async function testSingleConnection(connectionId: string) {
|
||||
try {
|
||||
logProxyEvent({
|
||||
status: result.valid ? "success" : "error",
|
||||
proxy: null,
|
||||
level: "provider-test",
|
||||
levelId: null,
|
||||
proxy: proxyInfo?.proxy || null,
|
||||
level: proxyInfo?.level || "provider-test",
|
||||
levelId: proxyInfo?.levelId || null,
|
||||
provider: connection.provider,
|
||||
targetUrl: `${connection.provider}/connection-test`,
|
||||
latencyMs,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
|
||||
|
||||
@@ -61,6 +62,12 @@ export async function PATCH(request) {
|
||||
}
|
||||
|
||||
const settings = await updateSettings(body);
|
||||
|
||||
// Clear health check log cache if that setting was updated
|
||||
if ("hideHealthCheckLogs" in body) {
|
||||
clearHealthCheckLogCache();
|
||||
}
|
||||
|
||||
const { password, ...safeSettings } = settings;
|
||||
return NextResponse.json(safeSettings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
@@ -97,16 +99,53 @@ export async function OPTIONS() {
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Issue #100: Optionally require API key for /models (security hardening)
|
||||
// When enabled, unauthenticated requests get 404 to hide endpoint existence
|
||||
// Issue #100: Optionally require authentication for /models (security hardening)
|
||||
// When enabled, unauthenticated requests get 401 with proper error response.
|
||||
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
|
||||
let settings: Record<string, any> = {};
|
||||
try {
|
||||
settings = await getSettings();
|
||||
} catch {}
|
||||
if (settings.requireAuthForModels === true) {
|
||||
// Check authentication: API key OR dashboard session (JWT cookie)
|
||||
// Supports dual auth: Bearer token for external clients, cookie for dashboard.
|
||||
let isAuthenticated = false;
|
||||
|
||||
// 1. Check API key (for external clients)
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey || !(await isValidApiKey(apiKey))) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
if (apiKey && (await isValidApiKey(apiKey))) {
|
||||
isAuthenticated = true;
|
||||
}
|
||||
|
||||
// 2. Check JWT cookie (for dashboard session)
|
||||
// The auth_token cookie has sameSite:lax + httpOnly, which already
|
||||
// prevents cross-origin abuse — no additional origin check needed.
|
||||
// Same pattern as shared/utils/apiAuth.ts verifyAuth().
|
||||
if (!isAuthenticated && process.env.JWT_SECRET) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
if (token) {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
await jwtVerify(token, secret);
|
||||
isAuthenticated = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid/expired token or cookies not available — not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Authentication required",
|
||||
type: "invalid_request_error",
|
||||
code: "invalid_api_key",
|
||||
},
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+338
-14
@@ -6,9 +6,156 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
|
||||
// ──────────────── Performance Optimizations ────────────────
|
||||
|
||||
// Schema check memoization - only run once
|
||||
let _schemaChecked = false;
|
||||
|
||||
// LRU cache for API key validation (valid keys only)
|
||||
const _keyValidationCache = new Map<string, { valid: boolean; timestamp: number }>();
|
||||
const _keyMetadataCache = new Map<string, { metadata: any; timestamp: number }>();
|
||||
const CACHE_TTL = 60 * 1000; // 1 minute TTL
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
// Compiled regex cache for wildcard patterns
|
||||
const _regexCache = new Map<string, RegExp>();
|
||||
|
||||
// Cache for model permission checks
|
||||
const _modelPermissionCache = new Map<string, { allowed: boolean; timestamp: number }>();
|
||||
|
||||
// Prepared statements cache
|
||||
let _stmtGetAllKeys: any = null;
|
||||
let _stmtGetKeyById: any = null;
|
||||
let _stmtValidateKey: any = null;
|
||||
let _stmtGetKeyMetadata: any = null;
|
||||
let _stmtInsertKey: any = null;
|
||||
let _stmtUpdatePermissions: any = null;
|
||||
let _stmtDeleteKey: any = null;
|
||||
|
||||
/**
|
||||
* Clear all caches (called on key create/update/delete)
|
||||
*/
|
||||
function invalidateCaches() {
|
||||
_keyValidationCache.clear();
|
||||
_keyMetadataCache.clear();
|
||||
_modelPermissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU eviction for cache
|
||||
*/
|
||||
function evictIfNeeded(cache: Map<any, any>) {
|
||||
if (cache.size > MAX_CACHE_SIZE) {
|
||||
// Remove oldest 20% of entries
|
||||
const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
|
||||
let i = 0;
|
||||
for (const key of cache.keys()) {
|
||||
if (i++ >= entriesToRemove) break;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compile regex for wildcard pattern
|
||||
*/
|
||||
function getWildcardRegex(pattern: string): RegExp {
|
||||
let regex = _regexCache.get(pattern);
|
||||
if (!regex) {
|
||||
const regexStr = pattern.replace(/\*/g, ".*");
|
||||
regex = new RegExp(`^${regexStr}$`);
|
||||
_regexCache.set(pattern, regex);
|
||||
// Prevent unbounded growth
|
||||
if (_regexCache.size > 100) {
|
||||
const firstKey = _regexCache.keys().next().value;
|
||||
if (firstKey) _regexCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
// Ensure the allowed_models column exists (memoized)
|
||||
function ensureAllowedModelsColumn(db) {
|
||||
if (_schemaChecked) return;
|
||||
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(api_keys)").all();
|
||||
const columnNames = new Set(columns.map((column) => column.name));
|
||||
if (!columnNames.has("allowed_models")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_models TEXT");
|
||||
console.log("[DB] Added api_keys.allowed_models column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
console.warn("[DB] Failed to verify api_keys schema:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize prepared statements (lazy initialization)
|
||||
*/
|
||||
function getPreparedStatements(db: any) {
|
||||
if (!_stmtGetAllKeys) {
|
||||
_stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at");
|
||||
_stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare(
|
||||
"SELECT id, name, machine_id, allowed_models FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
_stmtUpdatePermissions = db.prepare("UPDATE api_keys SET allowed_models = ? WHERE id = ?");
|
||||
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||
}
|
||||
return {
|
||||
getAllKeys: _stmtGetAllKeys,
|
||||
getKeyById: _stmtGetKeyById,
|
||||
validateKey: _stmtValidateKey,
|
||||
getKeyMetadata: _stmtGetKeyMetadata,
|
||||
insertKey: _stmtInsertKey,
|
||||
updatePermissions: _stmtUpdatePermissions,
|
||||
deleteKey: _stmtDeleteKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const db = getDbInstance();
|
||||
return db.prepare("SELECT * FROM api_keys ORDER BY created_at").all().map(rowToCamel);
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const rows = stmt.getAllKeys.all() as Record<string, any>[];
|
||||
return rows.map((row) => {
|
||||
const camelRow = rowToCamel(row) as Record<string, any>;
|
||||
// Parse allowed_models from JSON string to array
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
return camelRow;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiKeyById(id: string) {
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.getKeyById.get(id) as Record<string, any> | undefined;
|
||||
if (!row) return null;
|
||||
const camelRow = rowToCamel(row) as Record<string, any>;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
return camelRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse allowed_models JSON
|
||||
*/
|
||||
function parseAllowedModels(value: any): string[] {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiKey(name, machineId) {
|
||||
@@ -17,6 +164,7 @@ export async function createApiKey(name, machineId) {
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
|
||||
@@ -27,35 +175,211 @@ export async function createApiKey(name, machineId) {
|
||||
name: name,
|
||||
key: result.key,
|
||||
machineId: machineId,
|
||||
allowedModels: [], // Empty array means all models allowed
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, apiKey.createdAt);
|
||||
const stmt = getPreparedStatements(db);
|
||||
stmt.insertKey.run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, "[]", apiKey.createdAt);
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id) {
|
||||
export async function updateApiKeyPermissions(id, allowedModels) {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM api_keys WHERE id = ?").run(id);
|
||||
ensureAllowedModelsColumn(db);
|
||||
|
||||
// allowedModels should be an array of model IDs (strings)
|
||||
// Empty array means all models are allowed
|
||||
const modelsJson = JSON.stringify(allowedModels || []);
|
||||
|
||||
const stmt = getPreparedStatements(db);
|
||||
const result = stmt.updatePermissions.run(modelsJson, id);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
|
||||
// Invalidate caches since permissions changed
|
||||
invalidateCaches();
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function validateApiKey(key) {
|
||||
export async function deleteApiKey(id) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT 1 FROM api_keys WHERE key = ?").get(key);
|
||||
return !!row;
|
||||
const stmt = getPreparedStatements(db);
|
||||
const result = stmt.deleteKey.run(id);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
|
||||
// Invalidate caches since a key was removed
|
||||
invalidateCaches();
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getApiKeyMetadata(key) {
|
||||
if (!key) return null;
|
||||
/**
|
||||
* Validate API key with caching for performance
|
||||
* Cached valid keys reduce DB hits on every request
|
||||
*/
|
||||
export async function validateApiKey(key) {
|
||||
if (!key || typeof key !== "string") return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = _keyValidationCache.get(key);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT id, name, machine_id FROM api_keys WHERE key = ?").get(key);
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, machineId: row.machine_id };
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.validateKey.get(key);
|
||||
const valid = !!row;
|
||||
|
||||
// Only cache valid keys to prevent cache pollution
|
||||
if (valid) {
|
||||
evictIfNeeded(_keyValidationCache);
|
||||
_keyValidationCache.set(key, { valid: true, timestamp: now });
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key metadata with caching for performance
|
||||
*/
|
||||
export async function getApiKeyMetadata(key) {
|
||||
if (!key || typeof key !== "string") return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = _keyMetadataCache.get(key);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.metadata;
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.getKeyMetadata.get(key) as Record<string, any> | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const metadata = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
machineId: row.machine_id,
|
||||
allowedModels: parseAllowedModels(row.allowed_models),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
evictIfNeeded(_keyMetadataCache);
|
||||
_keyMetadataCache.set(key, { metadata, timestamp: now });
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is allowed for a given API key
|
||||
* @param {string} key - The API key
|
||||
* @param {string} modelId - The model ID to check
|
||||
* @returns {boolean} - true if allowed, false if not
|
||||
*/
|
||||
export async function isModelAllowedForKey(key, modelId) {
|
||||
// If no key provided, allow (request may be using different auth method like JWT)
|
||||
// If no modelId provided, deny (invalid request)
|
||||
if (!key) return true;
|
||||
if (!modelId) return false;
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = `${key}:${modelId}`;
|
||||
const now = Date.now();
|
||||
|
||||
// Check permission cache
|
||||
const cached = _modelPermissionCache.get(cacheKey);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.allowed;
|
||||
}
|
||||
|
||||
const metadata = await getApiKeyMetadata(key);
|
||||
// SECURITY: Key not found in database = deny access (invalid/non-existent key)
|
||||
if (!metadata) return false;
|
||||
|
||||
const { allowedModels } = metadata;
|
||||
|
||||
// Empty array means all models allowed
|
||||
if (!allowedModels || allowedModels.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let allowed = false;
|
||||
|
||||
// Check if model matches any allowed pattern
|
||||
// Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models)
|
||||
for (const pattern of allowedModels) {
|
||||
if (pattern === modelId) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -2); // Remove "/*"
|
||||
if (modelId.startsWith(prefix + "/") || modelId.startsWith(prefix)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Support wildcard patterns using cached regex
|
||||
if (pattern.includes("*")) {
|
||||
const regex = getWildcardRegex(pattern);
|
||||
if (regex.test(modelId)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
evictIfNeeded(_modelPermissionCache);
|
||||
_modelPermissionCache.set(cacheKey, { allowed, timestamp: now });
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear prepared statements cache (called on database reset/restore)
|
||||
* Prepared statements are bound to a specific database connection,
|
||||
* so they must be cleared when the connection is reset.
|
||||
*/
|
||||
function clearPreparedStatementCache() {
|
||||
_stmtGetAllKeys = null;
|
||||
_stmtGetKeyById = null;
|
||||
_stmtValidateKey = null;
|
||||
_stmtGetKeyMetadata = null;
|
||||
_stmtInsertKey = null;
|
||||
_stmtUpdatePermissions = null;
|
||||
_stmtDeleteKey = null;
|
||||
_schemaChecked = false; // Also reset schema check for new connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches (exported for testing/debugging)
|
||||
*/
|
||||
export function clearApiKeyCaches() {
|
||||
invalidateCaches();
|
||||
_modelPermissionCache.clear();
|
||||
_regexCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached state for database connection reset/restore.
|
||||
* Called by backup.ts when the database is restored.
|
||||
*/
|
||||
export function resetApiKeyState() {
|
||||
clearPreparedStatementCache();
|
||||
clearApiKeyCaches();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DB_BACKUPS_DIR,
|
||||
DATA_DIR,
|
||||
} from "./core";
|
||||
import { resetApiKeyState } from "./apiKeys";
|
||||
|
||||
// ──────────────── Backup Config ────────────────
|
||||
|
||||
@@ -181,6 +182,9 @@ export async function restoreDbBackup(backupId) {
|
||||
// Close and reset current connection
|
||||
resetDbInstance();
|
||||
|
||||
// Clear all cached prepared statements and other state bound to the old connection
|
||||
resetApiKeyState();
|
||||
|
||||
// Remove main file and WAL sidecars to avoid stale frame replay after restore.
|
||||
const sqliteFilesToReplace = [
|
||||
SQLITE_FILE,
|
||||
|
||||
@@ -55,10 +55,15 @@ export {
|
||||
export {
|
||||
// API Keys
|
||||
getApiKeys,
|
||||
getApiKeyById,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
validateApiKey,
|
||||
getApiKeyMetadata,
|
||||
updateApiKeyPermissions,
|
||||
isModelAllowedForKey,
|
||||
clearApiKeyCaches,
|
||||
resetApiKeyState,
|
||||
} from "./db/apiKeys";
|
||||
|
||||
export {
|
||||
|
||||
@@ -70,12 +70,13 @@ async function validateOpenAILikeProvider({
|
||||
baseUrl,
|
||||
providerSpecificData = {},
|
||||
modelId = "gpt-4o-mini",
|
||||
modelsUrl: customModelsUrl,
|
||||
}) {
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
const modelsUrl = addModelsSuffix(baseUrl);
|
||||
const modelsUrl = customModelsUrl || addModelsSuffix(baseUrl);
|
||||
if (!modelsUrl) {
|
||||
return { valid: false, error: "Invalid models endpoint" };
|
||||
}
|
||||
@@ -423,6 +424,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
baseUrl,
|
||||
providerSpecificData,
|
||||
modelId,
|
||||
modelsUrl: entry.modelsUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+81
-14
@@ -10,7 +10,7 @@
|
||||
* updates the DB, and logs the result.
|
||||
*/
|
||||
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||
import { getProviderConnections, updateProviderConnection, getSettings } from "@/lib/localDb";
|
||||
import {
|
||||
getAccessToken,
|
||||
supportsTokenRefresh,
|
||||
@@ -22,6 +22,68 @@ const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds
|
||||
const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
|
||||
const LOG_PREFIX = "[HealthCheck]";
|
||||
|
||||
// ── Logging helper ───────────────────────────────────────────────────────────
|
||||
let cachedHideLogs: boolean | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
let pendingHideLogs: Promise<boolean> | null = null;
|
||||
const CACHE_TTL = 30_000; // Cache settings for 30 seconds
|
||||
|
||||
async function shouldHideLogs(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached value if valid
|
||||
if (cachedHideLogs !== null && now - cacheTimestamp < CACHE_TTL) {
|
||||
return cachedHideLogs;
|
||||
}
|
||||
|
||||
// Return pending promise if a query is already in progress (request coalescing)
|
||||
if (pendingHideLogs !== null) {
|
||||
return pendingHideLogs;
|
||||
}
|
||||
|
||||
// Create new promise for DB query
|
||||
pendingHideLogs = (async () => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
cachedHideLogs = settings.hideHealthCheckLogs === true;
|
||||
cacheTimestamp = now;
|
||||
return cachedHideLogs;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
pendingHideLogs = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pendingHideLogs;
|
||||
}
|
||||
|
||||
function log(message: string, ...args: any[]) {
|
||||
shouldHideLogs().then((hide) => {
|
||||
if (!hide) console.log(message, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
function logWarn(message: string, ...args: any[]) {
|
||||
shouldHideLogs().then((hide) => {
|
||||
if (!hide) console.warn(message, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
function logError(message: string, ...args: any[]) {
|
||||
shouldHideLogs().then((hide) => {
|
||||
if (!hide) console.error(message, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached hideLogs setting (call when settings are updated).
|
||||
*/
|
||||
export function clearHealthCheckLogCache() {
|
||||
cachedHideLogs = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
// ── Singleton guard ──────────────────────────────────────────────────────────
|
||||
let initialized = false;
|
||||
let intervalHandle = null;
|
||||
@@ -33,9 +95,7 @@ export function initTokenHealthCheck() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
console.log(
|
||||
`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`
|
||||
);
|
||||
log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`);
|
||||
|
||||
// Run first sweep after a short delay so the server finishes booting
|
||||
setTimeout(() => {
|
||||
@@ -67,11 +127,11 @@ async function sweep() {
|
||||
await checkConnection(conn);
|
||||
} catch (err) {
|
||||
// Per-connection isolation: one failure never blocks others
|
||||
console.error(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message);
|
||||
logError(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`${LOG_PREFIX} Sweep error:`, err.message);
|
||||
logError(`${LOG_PREFIX} Sweep error:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +151,7 @@ async function checkConnection(conn) {
|
||||
if (!supportsTokenRefresh(conn.provider)) {
|
||||
const now = new Date().toISOString();
|
||||
await updateProviderConnection(conn.id, { lastHealthCheckAt: now });
|
||||
console.log(
|
||||
log(
|
||||
`${LOG_PREFIX} Skipping ${conn.provider}/${conn.name || conn.email || conn.id} (refresh unsupported)`
|
||||
);
|
||||
return;
|
||||
@@ -103,7 +163,7 @@ async function checkConnection(conn) {
|
||||
// Not yet due
|
||||
if (Date.now() - lastCheck < intervalMs) return;
|
||||
|
||||
console.log(
|
||||
log(
|
||||
`${LOG_PREFIX} Refreshing ${conn.provider}/${conn.name || conn.email || conn.id} (interval: ${intervalMin}min)`
|
||||
);
|
||||
|
||||
@@ -114,10 +174,17 @@ async function checkConnection(conn) {
|
||||
providerSpecificData: conn.providerSpecificData,
|
||||
};
|
||||
|
||||
const hideLogs = await shouldHideLogs();
|
||||
const result = await getAccessToken(conn.provider, credentials, {
|
||||
info: (tag, msg) => console.log(`${LOG_PREFIX} [${tag}] ${msg}`),
|
||||
warn: (tag, msg) => console.warn(`${LOG_PREFIX} [${tag}] ${msg}`),
|
||||
error: (tag, msg, extra) => console.error(`${LOG_PREFIX} [${tag}] ${msg}`, extra || ""),
|
||||
info: (tag, msg) => {
|
||||
if (!hideLogs) console.log(`${LOG_PREFIX} [${tag}] ${msg}`);
|
||||
},
|
||||
warn: (tag, msg) => {
|
||||
if (!hideLogs) console.warn(`${LOG_PREFIX} [${tag}] ${msg}`);
|
||||
},
|
||||
error: (tag, msg, extra) => {
|
||||
if (!hideLogs) console.error(`${LOG_PREFIX} [${tag}] ${msg}`, extra || "");
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -138,7 +205,7 @@ async function checkConnection(conn) {
|
||||
isActive: false,
|
||||
refreshToken: null,
|
||||
});
|
||||
console.error(
|
||||
logError(
|
||||
`${LOG_PREFIX} ✗ ${conn.provider}/${conn.name || conn.email || conn.id} — ` +
|
||||
`Refresh token is permanently invalid (${result.error}). ` +
|
||||
`Connection deactivated. Re-authenticate to restore.`
|
||||
@@ -168,7 +235,7 @@ async function checkConnection(conn) {
|
||||
}
|
||||
|
||||
await updateProviderConnection(conn.id, updateData);
|
||||
console.log(`${LOG_PREFIX} ✓ ${conn.provider}/${conn.name || conn.email || conn.id} refreshed`);
|
||||
log(`${LOG_PREFIX} ✓ ${conn.provider}/${conn.name || conn.email || conn.id} refreshed`);
|
||||
} else {
|
||||
// Refresh failed — record but don't disable the connection
|
||||
await updateProviderConnection(conn.id, {
|
||||
@@ -180,7 +247,7 @@ async function checkConnection(conn) {
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
});
|
||||
console.warn(
|
||||
logWarn(
|
||||
`${LOG_PREFIX} ✗ ${conn.provider}/${conn.name || conn.email || conn.id} refresh failed`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,36 @@ const COLUMNS = [
|
||||
|
||||
const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
|
||||
|
||||
/**
|
||||
* Get a friendly display label for compatible providers.
|
||||
* Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
|
||||
* to readable labels like "OAI-Compat".
|
||||
*/
|
||||
function getProviderDisplayLabel(provider: string): string {
|
||||
if (!provider) return "-";
|
||||
if (provider.startsWith("openai-compatible-")) {
|
||||
// Extract the "chat" or custom-name part after the prefix
|
||||
const suffix = provider.replace("openai-compatible-", "");
|
||||
// If it's just "chat-<uuid>", show "OAI-Compat"
|
||||
// If it has a meaningful name, include it
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
// Looks like chat-<uuid>, just show category
|
||||
return `OAI-COMPAT`;
|
||||
}
|
||||
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
if (provider.startsWith("anthropic-compatible-")) {
|
||||
const suffix = provider.replace("anthropic-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
return `ANT-COMPAT`;
|
||||
}
|
||||
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
return null; // Not a compatible provider, use default PROVIDER_COLORS
|
||||
}
|
||||
|
||||
function getLogTotalTokens(log) {
|
||||
return (log?.tokens?.in || 0) + (log?.tokens?.out || 0);
|
||||
}
|
||||
@@ -269,10 +299,11 @@ export default function RequestLoggerV2() {
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{uniqueProviders.map((p) => {
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const pc = PROVIDER_COLORS[p];
|
||||
return (
|
||||
<option key={p} value={p}>
|
||||
{pc?.label || p.toUpperCase()}
|
||||
{compatLabel || pc?.label || p.toUpperCase()}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@@ -410,7 +441,13 @@ export default function RequestLoggerV2() {
|
||||
|
||||
{/* Dynamic Provider Quick Filters (from data) */}
|
||||
{uniqueProviders.map((p) => {
|
||||
const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: p.toUpperCase() };
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const pc = PROVIDER_COLORS[p] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
label: compatLabel || p.toUpperCase(),
|
||||
};
|
||||
const displayLabel = compatLabel || pc.label;
|
||||
const isActive = selectedProvider === p;
|
||||
return (
|
||||
<button
|
||||
@@ -426,7 +463,7 @@ export default function RequestLoggerV2() {
|
||||
color: isActive ? pc.text : pc.bg,
|
||||
}}
|
||||
>
|
||||
{pc.label}
|
||||
{displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -538,11 +575,13 @@ export default function RequestLoggerV2() {
|
||||
text: "#fff",
|
||||
label: (protocolKey || log.provider || "-").toUpperCase(),
|
||||
};
|
||||
const compatLabel = getProviderDisplayLabel(log.provider);
|
||||
const providerColor = PROVIDER_COLORS[log.provider] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
label: (log.provider || "-").toUpperCase(),
|
||||
label: compatLabel || (log.provider || "-").toUpperCase(),
|
||||
};
|
||||
const providerLabel = compatLabel || providerColor.label;
|
||||
const isError = log.status >= 400;
|
||||
|
||||
return (
|
||||
@@ -572,7 +611,7 @@ export default function RequestLoggerV2() {
|
||||
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
||||
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
|
||||
>
|
||||
{providerColor.label}
|
||||
{providerLabel}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import CloudSyncStatus from "./CloudSyncStatus";
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Home", icon: "home", exact: true },
|
||||
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
||||
{ href: "/dashboard/api-manager", label: "API Manager", icon: "vpn_key" },
|
||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
|
||||
{ href: "/dashboard/logs", label: "Logs", icon: "description" },
|
||||
|
||||
@@ -72,6 +72,7 @@ export const updateSettingsSchema = z.object({
|
||||
setupComplete: z.boolean().optional(),
|
||||
requireAuthForModels: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Auth Schemas ────
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// IFlowExecutor Unit Tests
|
||||
// Tests for HMAC-SHA256 signature, headers, URL building
|
||||
// Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { IFlowExecutor } = await import("../../open-sse/executors/iflow.ts");
|
||||
|
||||
// ─── Constructor ──────────────────────────────────────────────
|
||||
|
||||
test("IFlowExecutor: constructor sets provider to 'iflow'", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
assert.equal(executor.getProvider(), "iflow");
|
||||
});
|
||||
|
||||
// ─── createIFlowSignature ─────────────────────────────────────
|
||||
|
||||
test("IFlowExecutor: createIFlowSignature returns valid HMAC-SHA256 hex", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const userAgent = "iFlow-Cli";
|
||||
const sessionID = "session-test-123";
|
||||
const timestamp = 1700000000000;
|
||||
const apiKey = "test-api-key-secret";
|
||||
|
||||
const signature = executor.createIFlowSignature(userAgent, sessionID, timestamp, apiKey);
|
||||
|
||||
// Verify it's a valid hex string (64 chars for SHA256)
|
||||
assert.match(signature, /^[0-9a-f]{64}$/);
|
||||
|
||||
// Verify reproducibility — same inputs produce same signature
|
||||
const signature2 = executor.createIFlowSignature(userAgent, sessionID, timestamp, apiKey);
|
||||
assert.equal(signature, signature2);
|
||||
|
||||
// Verify against manual HMAC computation
|
||||
const payload = `${userAgent}:${sessionID}:${timestamp}`;
|
||||
const expected = crypto.createHmac("sha256", apiKey).update(payload).digest("hex");
|
||||
assert.equal(signature, expected);
|
||||
});
|
||||
|
||||
test("IFlowExecutor: createIFlowSignature returns empty string when apiKey is empty", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const result = executor.createIFlowSignature("agent", "session", 123, "");
|
||||
assert.equal(result, "");
|
||||
});
|
||||
|
||||
test("IFlowExecutor: createIFlowSignature returns empty string when apiKey is null", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const result = executor.createIFlowSignature("agent", "session", 123, null);
|
||||
assert.equal(result, "");
|
||||
});
|
||||
|
||||
// ─── buildHeaders ─────────────────────────────────────────────
|
||||
|
||||
test("IFlowExecutor: buildHeaders includes iflow-specific headers", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const credentials = { apiKey: "test-key-123" };
|
||||
|
||||
const headers = executor.buildHeaders(credentials, true);
|
||||
|
||||
// Must include required iflow headers
|
||||
assert.ok(headers["session-id"], "Missing session-id header");
|
||||
assert.ok(headers["x-iflow-timestamp"], "Missing x-iflow-timestamp header");
|
||||
assert.ok(headers["x-iflow-signature"], "Missing x-iflow-signature header");
|
||||
|
||||
// session-id format
|
||||
assert.ok(
|
||||
headers["session-id"].startsWith("session-"),
|
||||
"session-id should start with 'session-'"
|
||||
);
|
||||
|
||||
// timestamp is a number string
|
||||
assert.match(headers["x-iflow-timestamp"], /^\d+$/);
|
||||
|
||||
// signature is hex
|
||||
assert.match(headers["x-iflow-signature"], /^[0-9a-f]{64}$/);
|
||||
|
||||
// Authorization
|
||||
assert.equal(headers["Authorization"], "Bearer test-key-123");
|
||||
|
||||
// Content-Type
|
||||
assert.equal(headers["Content-Type"], "application/json");
|
||||
|
||||
// Streaming Accept
|
||||
assert.equal(headers["Accept"], "text/event-stream");
|
||||
});
|
||||
|
||||
test("IFlowExecutor: buildHeaders omits Accept header when stream is false", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const credentials = { apiKey: "test-key" };
|
||||
|
||||
const headers = executor.buildHeaders(credentials, false);
|
||||
|
||||
assert.equal(headers["Accept"], undefined);
|
||||
});
|
||||
|
||||
test("IFlowExecutor: buildHeaders uses accessToken when apiKey is missing", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const credentials = { accessToken: "oauth-token-123" };
|
||||
|
||||
const headers = executor.buildHeaders(credentials);
|
||||
|
||||
assert.equal(headers["Authorization"], "Bearer oauth-token-123");
|
||||
// Signature should still be generated using the accessToken
|
||||
assert.ok(headers["x-iflow-signature"].length > 0);
|
||||
});
|
||||
|
||||
test("IFlowExecutor: buildHeaders generates unique session IDs per call", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const credentials = { apiKey: "key" };
|
||||
|
||||
const headers1 = executor.buildHeaders(credentials);
|
||||
const headers2 = executor.buildHeaders(credentials);
|
||||
|
||||
assert.notEqual(headers1["session-id"], headers2["session-id"]);
|
||||
});
|
||||
|
||||
// ─── buildUrl ─────────────────────────────────────────────────
|
||||
|
||||
test("IFlowExecutor: buildUrl returns config baseUrl", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const url = executor.buildUrl("qwen3-coder-plus", true);
|
||||
|
||||
assert.equal(url, "https://apis.iflow.cn/v1/chat/completions");
|
||||
});
|
||||
|
||||
// ─── transformRequest ─────────────────────────────────────────
|
||||
|
||||
test("IFlowExecutor: transformRequest passes body through unchanged", () => {
|
||||
const executor = new IFlowExecutor();
|
||||
const body = {
|
||||
model: "deepseek-r1",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("deepseek-r1", body, true, {});
|
||||
assert.deepEqual(result, body);
|
||||
});
|
||||
|
||||
// ─── Integration: executor registry ───────────────────────────
|
||||
|
||||
test("IFlowExecutor: getExecutor('iflow') returns IFlowExecutor instance", async () => {
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const executor = getExecutor("iflow");
|
||||
assert.ok(executor instanceof IFlowExecutor, "Should return IFlowExecutor instance");
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ── Import test targets from connection test route ──────────────────────────
|
||||
|
||||
// We can't import the full route (needs DB), but we can test the pure functions
|
||||
// by importing them from the module. The classifyFailure, toSafeMessage, isTokenExpired,
|
||||
// and makeDiagnosis are not exported, so we test them through testSingleConnection's
|
||||
// behavior patterns using inline reimplementations that verify the same logic.
|
||||
|
||||
// ─── classifyFailure Logic Tests ────────────────────────────────────────────
|
||||
|
||||
// Reimplementation of classifyFailure for testing (mirrors route.ts logic)
|
||||
function classifyFailure({ error, statusCode = null, refreshFailed = false, unsupported = false }) {
|
||||
const message =
|
||||
typeof error !== "string" ? "Connection test failed" : error.trim() || "Connection test failed";
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
|
||||
if (unsupported)
|
||||
return { type: "unsupported", source: "validation", message, code: "unsupported" };
|
||||
if (refreshFailed || normalized.includes("refresh failed"))
|
||||
return { type: "token_refresh_failed", source: "oauth", message, code: "refresh_failed" };
|
||||
if (numericStatus === 401 || numericStatus === 403)
|
||||
return {
|
||||
type: "upstream_auth_error",
|
||||
source: "upstream",
|
||||
message,
|
||||
code: String(numericStatus),
|
||||
};
|
||||
if (numericStatus === 429)
|
||||
return { type: "upstream_rate_limited", source: "upstream", message, code: "429" };
|
||||
if (numericStatus && numericStatus >= 500)
|
||||
return {
|
||||
type: "upstream_unavailable",
|
||||
source: "upstream",
|
||||
message,
|
||||
code: String(numericStatus),
|
||||
};
|
||||
if (normalized.includes("token expired") || normalized.includes("expired"))
|
||||
return { type: "token_expired", source: "oauth", message, code: "token_expired" };
|
||||
if (
|
||||
normalized.includes("invalid api key") ||
|
||||
normalized.includes("token invalid") ||
|
||||
normalized.includes("revoked") ||
|
||||
normalized.includes("access denied") ||
|
||||
normalized.includes("unauthorized") ||
|
||||
normalized.includes("forbidden")
|
||||
) {
|
||||
return {
|
||||
type: "upstream_auth_error",
|
||||
source: "upstream",
|
||||
message,
|
||||
code: numericStatus ? String(numericStatus) : "auth_failed",
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes("rate limit") ||
|
||||
normalized.includes("quota") ||
|
||||
normalized.includes("too many requests")
|
||||
) {
|
||||
return {
|
||||
type: "upstream_rate_limited",
|
||||
source: "upstream",
|
||||
message,
|
||||
code: numericStatus ? String(numericStatus) : "rate_limited",
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes("fetch failed") ||
|
||||
normalized.includes("network") ||
|
||||
normalized.includes("timeout") ||
|
||||
normalized.includes("econn") ||
|
||||
normalized.includes("enotfound") ||
|
||||
normalized.includes("socket")
|
||||
) {
|
||||
return { type: "network_error", source: "upstream", message, code: "network_error" };
|
||||
}
|
||||
return {
|
||||
type: "upstream_error",
|
||||
source: "upstream",
|
||||
message,
|
||||
code: numericStatus ? String(numericStatus) : "upstream_error",
|
||||
};
|
||||
}
|
||||
|
||||
// ── classifyFailure ─────────────────────────────────────────────────────────
|
||||
|
||||
test("classifyFailure: unsupported provider", () => {
|
||||
const result = classifyFailure({ error: "Not supported", unsupported: true });
|
||||
assert.equal(result.type, "unsupported");
|
||||
assert.equal(result.source, "validation");
|
||||
assert.equal(result.code, "unsupported");
|
||||
});
|
||||
|
||||
test("classifyFailure: refresh failed", () => {
|
||||
const result = classifyFailure({
|
||||
error: "Token expired and refresh failed",
|
||||
refreshFailed: true,
|
||||
});
|
||||
assert.equal(result.type, "token_refresh_failed");
|
||||
assert.equal(result.source, "oauth");
|
||||
assert.equal(result.code, "refresh_failed");
|
||||
});
|
||||
|
||||
test("classifyFailure: refresh failed via message", () => {
|
||||
const result = classifyFailure({ error: "Something refresh failed here" });
|
||||
assert.equal(result.type, "token_refresh_failed");
|
||||
assert.equal(result.code, "refresh_failed");
|
||||
});
|
||||
|
||||
test("classifyFailure: 401 status", () => {
|
||||
const result = classifyFailure({ error: "Auth error", statusCode: 401 });
|
||||
assert.equal(result.type, "upstream_auth_error");
|
||||
assert.equal(result.code, "401");
|
||||
});
|
||||
|
||||
test("classifyFailure: 403 status", () => {
|
||||
const result = classifyFailure({ error: "Forbidden", statusCode: 403 });
|
||||
assert.equal(result.type, "upstream_auth_error");
|
||||
assert.equal(result.code, "403");
|
||||
});
|
||||
|
||||
test("classifyFailure: 429 rate limit", () => {
|
||||
const result = classifyFailure({ error: "Rate limited", statusCode: 429 });
|
||||
assert.equal(result.type, "upstream_rate_limited");
|
||||
assert.equal(result.code, "429");
|
||||
});
|
||||
|
||||
test("classifyFailure: 500+ server error", () => {
|
||||
const result = classifyFailure({ error: "Server error", statusCode: 502 });
|
||||
assert.equal(result.type, "upstream_unavailable");
|
||||
assert.equal(result.code, "502");
|
||||
});
|
||||
|
||||
test("classifyFailure: token expired message", () => {
|
||||
const result = classifyFailure({ error: "Token expired" });
|
||||
assert.equal(result.type, "token_expired");
|
||||
assert.equal(result.source, "oauth");
|
||||
});
|
||||
|
||||
test("classifyFailure: invalid API key message", () => {
|
||||
const result = classifyFailure({ error: "Invalid API key provided" });
|
||||
assert.equal(result.type, "upstream_auth_error");
|
||||
assert.equal(result.code, "auth_failed");
|
||||
});
|
||||
|
||||
test("classifyFailure: network error messages", () => {
|
||||
for (const msg of [
|
||||
"fetch failed",
|
||||
"network timeout",
|
||||
"ECONNREFUSED",
|
||||
"ENOTFOUND",
|
||||
"socket hang up",
|
||||
]) {
|
||||
const result = classifyFailure({ error: msg });
|
||||
assert.equal(result.type, "network_error", `Expected network_error for "${msg}"`);
|
||||
assert.equal(result.code, "network_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyFailure: rate limit via message", () => {
|
||||
for (const msg of ["rate limit exceeded", "quota reached", "too many requests"]) {
|
||||
const result = classifyFailure({ error: msg });
|
||||
assert.equal(
|
||||
result.type,
|
||||
"upstream_rate_limited",
|
||||
`Expected upstream_rate_limited for "${msg}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyFailure: generic upstream error", () => {
|
||||
const result = classifyFailure({ error: "Something went wrong" });
|
||||
assert.equal(result.type, "upstream_error");
|
||||
assert.equal(result.source, "upstream");
|
||||
});
|
||||
|
||||
test("classifyFailure: non-string error defaults to fallback message", () => {
|
||||
const result = classifyFailure({ error: null });
|
||||
assert.equal(result.message, "Connection test failed");
|
||||
});
|
||||
|
||||
test("classifyFailure: empty string error defaults to fallback", () => {
|
||||
const result = classifyFailure({ error: " " });
|
||||
assert.equal(result.message, "Connection test failed");
|
||||
});
|
||||
|
||||
// ── isTokenExpired ──────────────────────────────────────────────────────────
|
||||
|
||||
function isTokenExpired(connection) {
|
||||
const expiresAtValue = connection.expiresAt || connection.tokenExpiresAt;
|
||||
if (!expiresAtValue) return false;
|
||||
const expiresAt = new Date(expiresAtValue).getTime();
|
||||
const buffer = 5 * 60 * 1000;
|
||||
return expiresAt <= Date.now() + buffer;
|
||||
}
|
||||
|
||||
test("isTokenExpired: returns false when no expiry set", () => {
|
||||
assert.equal(isTokenExpired({}), false);
|
||||
assert.equal(isTokenExpired({ expiresAt: null }), false);
|
||||
});
|
||||
|
||||
test("isTokenExpired: returns true when token is expired", () => {
|
||||
const pastDate = new Date(Date.now() - 60000).toISOString();
|
||||
assert.equal(isTokenExpired({ expiresAt: pastDate }), true);
|
||||
});
|
||||
|
||||
test("isTokenExpired: returns true when token expires within 5 minutes", () => {
|
||||
const nearFuture = new Date(Date.now() + 2 * 60 * 1000).toISOString(); // 2 min
|
||||
assert.equal(isTokenExpired({ expiresAt: nearFuture }), true);
|
||||
});
|
||||
|
||||
test("isTokenExpired: returns false when token is far in the future", () => {
|
||||
const farFuture = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
assert.equal(isTokenExpired({ expiresAt: farFuture }), false);
|
||||
});
|
||||
|
||||
test("isTokenExpired: uses tokenExpiresAt as fallback", () => {
|
||||
const pastDate = new Date(Date.now() - 60000).toISOString();
|
||||
assert.equal(isTokenExpired({ tokenExpiresAt: pastDate }), true);
|
||||
});
|
||||
|
||||
// ── Provider Display Label ──────────────────────────────────────────────────
|
||||
|
||||
function getProviderDisplayLabel(provider) {
|
||||
if (!provider) return "-";
|
||||
if (provider.startsWith("openai-compatible-")) {
|
||||
const suffix = provider.replace("openai-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
return "OAI-COMPAT";
|
||||
}
|
||||
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
if (provider.startsWith("anthropic-compatible-")) {
|
||||
const suffix = provider.replace("anthropic-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
return "ANT-COMPAT";
|
||||
}
|
||||
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("getProviderDisplayLabel: openai-compatible with UUID shows OAI-COMPAT", () => {
|
||||
const result = getProviderDisplayLabel(
|
||||
"openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
|
||||
);
|
||||
assert.equal(result, "OAI-COMPAT");
|
||||
});
|
||||
|
||||
test("getProviderDisplayLabel: anthropic-compatible with UUID shows ANT-COMPAT", () => {
|
||||
const result = getProviderDisplayLabel(
|
||||
"anthropic-compatible-chat-abcdef12-3456-7890-abcd-ef1234567890"
|
||||
);
|
||||
assert.equal(result, "ANT-COMPAT");
|
||||
});
|
||||
|
||||
test("getProviderDisplayLabel: openai-compatible with short name shows name", () => {
|
||||
const result = getProviderDisplayLabel("openai-compatible-myapi");
|
||||
assert.equal(result, "OAI: MYAPI");
|
||||
});
|
||||
|
||||
test("getProviderDisplayLabel: non-compatible provider returns null", () => {
|
||||
assert.equal(getProviderDisplayLabel("groq"), null);
|
||||
assert.equal(getProviderDisplayLabel("openai"), null);
|
||||
assert.equal(getProviderDisplayLabel("claude"), null);
|
||||
});
|
||||
|
||||
test("getProviderDisplayLabel: empty/null provider returns dash", () => {
|
||||
assert.equal(getProviderDisplayLabel(""), "-");
|
||||
assert.equal(getProviderDisplayLabel(null), "-");
|
||||
});
|
||||
|
||||
// ── OAUTH_TEST_CONFIG Validation ────────────────────────────────────────────
|
||||
|
||||
test("OAuth test config covers all expected providers", () => {
|
||||
// List of providers that should have test config
|
||||
const expected = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"github",
|
||||
"iflow",
|
||||
"qwen",
|
||||
"cursor",
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"cline",
|
||||
"kiro",
|
||||
];
|
||||
|
||||
// Reimport of OAUTH_TEST_CONFIG keys (verify by name)
|
||||
// These are the providers defined in the test route
|
||||
const configuredProviders = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"github",
|
||||
"iflow",
|
||||
"qwen",
|
||||
"cursor",
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"cline",
|
||||
"kiro",
|
||||
];
|
||||
|
||||
for (const provider of expected) {
|
||||
assert.ok(
|
||||
configuredProviders.includes(provider),
|
||||
`Missing OAUTH_TEST_CONFIG for provider: ${provider}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Refreshable OAuth providers are correctly identified", () => {
|
||||
const refreshable = [
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"iflow",
|
||||
"qwen",
|
||||
"kimi-coding",
|
||||
"cline",
|
||||
"kiro",
|
||||
];
|
||||
const nonRefreshable = ["claude", "github", "cursor", "kilocode"];
|
||||
|
||||
// Verify these two sets are mutually exclusive and cover all providers
|
||||
const allProviders = [...refreshable, ...nonRefreshable];
|
||||
assert.equal(allProviders.length, 12);
|
||||
assert.equal(new Set(allProviders).size, 12);
|
||||
});
|
||||
Reference in New Issue
Block a user