- {shResults.length > 0 && (
+ {skillsProvider === "skillssh" && shResults.length > 0 && (
)}
- {!shLoading && shResults.length === 0 && !shError && (
+
+ {skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
+
+ )}
+ {skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
Search the skills.sh open directory to discover and install agent skills.
diff --git a/src/app/api/acp/agents/route.ts b/src/app/api/acp/agents/route.ts
index 765c397c..9f8fc6bf 100644
--- a/src/app/api/acp/agents/route.ts
+++ b/src/app/api/acp/agents/route.ts
@@ -1,16 +1,33 @@
import { NextResponse } from "next/server";
+import { z } from "zod";
import {
+ type CliAgentInfo,
detectInstalledAgents,
refreshAgentCache,
+ resolveVersionProbe,
setCustomAgents,
- getCustomAgentDefs,
type CustomAgentDef,
} from "@/lib/acp/registry";
-import { getSettings, updateSettings } from "@/lib/localDb";
-import { jsonObjectSchema } from "@/shared/validation/schemas";
+import { getSettings, updateSettings } from "@/lib/db/settings";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+import { isAuthenticated } from "@/shared/utils/apiAuth";
+
+const customAgentBodySchema = z.object({
+ action: z.string().optional(),
+ id: z.string().optional(),
+ name: z.string().optional(),
+ binary: z.string().optional(),
+ versionCommand: z.string().optional(),
+ providerAlias: z.string().optional(),
+ spawnArgs: z.array(z.string()).optional(),
+ protocol: z.enum(["stdio", "http"]).optional(),
+});
+
+export async function GET(request: Request) {
+ if (!(await isAuthenticated(request))) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
-export async function GET() {
try {
// Load custom agents from settings on each GET to stay in sync
const settings = await getSettings();
@@ -19,7 +36,7 @@ export async function GET() {
}
const agents = detectInstalledAgents();
- const installed = agents.filter((a) => a.installed).length;
+ const installed = agents.filter((a: CliAgentInfo) => a.installed).length;
const total = agents.length;
return NextResponse.json({
@@ -28,8 +45,8 @@ export async function GET() {
total,
installed,
notFound: total - installed,
- builtIn: agents.filter((a) => !a.isCustom).length,
- custom: agents.filter((a) => a.isCustom).length,
+ builtIn: agents.filter((a: CliAgentInfo) => !a.isCustom).length,
+ custom: agents.filter((a: CliAgentInfo) => a.isCustom).length,
},
});
} catch (error) {
@@ -39,6 +56,10 @@ export async function GET() {
}
export async function POST(request: Request) {
+ if (!(await isAuthenticated(request))) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
let rawBody: unknown;
try {
rawBody = await request.json();
@@ -46,7 +67,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
- const validation = validateBody(jsonObjectSchema, rawBody);
+ const validation = validateBody(customAgentBodySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
@@ -69,15 +90,22 @@ export async function POST(request: Request) {
}
const newAgent: CustomAgentDef = {
- id: (id as string).toLowerCase().replace(/[^a-z0-9-]/g, "-"),
- name: name as string,
- binary: binary as string,
- versionCommand: versionCommand as string,
- providerAlias: (providerAlias as string) || (id as string),
- spawnArgs: Array.isArray(spawnArgs) ? (spawnArgs as string[]) : [],
- protocol: (protocol as "stdio" | "http") || "stdio",
+ id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
+ name,
+ binary,
+ versionCommand,
+ providerAlias: providerAlias || id,
+ spawnArgs: spawnArgs || [],
+ protocol: protocol || "stdio",
};
+ if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {
+ return NextResponse.json(
+ { error: "Invalid versionCommand: use the configured binary with plain arguments only" },
+ { status: 400 }
+ );
+ }
+
// Load current, append, save
const settings = await getSettings();
const current: CustomAgentDef[] = (settings.customAgents as CustomAgentDef[]) || [];
@@ -104,6 +132,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
+ if (!(await isAuthenticated(request))) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
try {
const { searchParams } = new URL(request.url);
const agentId = searchParams.get("id");
diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts
index c2b67867..48a523ff 100644
--- a/src/app/api/cli-tools/antigravity-mitm/route.ts
+++ b/src/app/api/cli-tools/antigravity-mitm/route.ts
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
// GET - Check MITM status
export async function GET() {
@@ -46,7 +47,10 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- const { apiKey, sudoPassword } = validation.data;
+ const { apiKey: rawApiKey, sudoPassword } = validation.data;
+ // (#523) Extract keyId BEFORE validation β Zod strips unknown fields!
+ const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
+ const apiKey = await resolveApiKey(apiKeyId, rawApiKey);
const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";
diff --git a/src/app/api/cli-tools/backups/route.ts b/src/app/api/cli-tools/backups/route.ts
index 968297fa..beb3a7d6 100644
--- a/src/app/api/cli-tools/backups/route.ts
+++ b/src/app/api/cli-tools/backups/route.ts
@@ -6,7 +6,7 @@ import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { cliBackupMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
+const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
// GET /api/cli-tools/backups?tool=claude β list backups
export async function GET(request) {
diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts
index b032babb..dbe83280 100644
--- a/src/app/api/cli-tools/cline-settings/route.ts
+++ b/src/app/api/cli-tools/cline-settings/route.ts
@@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { getApiKeyById } from "@/lib/localDb";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
@@ -129,17 +129,8 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- let { baseUrl, apiKey, model } = validation.data;
-
- // Resolve real key from DB by ID
- if (keyId) {
- try {
- const keyRecord = await getApiKeyById(keyId);
- if (keyRecord?.key) apiKey = keyRecord.key as string;
- } catch {
- /* non-critical */
- }
- }
+ const { baseUrl, model } = validation.data;
+ const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
// Ensure directory exists
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });
diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts
index 25cd6d52..46a7d642 100644
--- a/src/app/api/cli-tools/droid-settings/route.ts
+++ b/src/app/api/cli-tools/droid-settings/route.ts
@@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { getApiKeyById } from "@/lib/localDb";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -106,19 +106,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, model } = validation.data;
- let { apiKey } = validation.data;
-
- // Resolve real key from DB by ID
- if (keyId) {
- try {
- const keyRecord = await getApiKeyById(keyId);
- if (keyRecord?.key) {
- apiKey = keyRecord.key as string;
- }
- } catch {
- // Non-critical: fall back to whatever value was in apiKey
- }
- }
+ const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
const droidDir = getDroidDir();
const settingsPath = getDroidSettingsPath();
diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
index 49f1adaa..1dd970bb 100644
--- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
+++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
@@ -7,6 +7,7 @@ import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
/**
* POST /api/cli-tools/guide-settings/:toolId
@@ -35,7 +36,10 @@ export async function POST(request, { params }) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- const { baseUrl, apiKey, model } = validation.data;
+ const { baseUrl, model } = validation.data;
+ // (#523) Extract keyId BEFORE validation β Zod strips unknown fields!
+ const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
+ const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
try {
switch (toolId) {
@@ -177,20 +181,50 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
}
/**
- * Save Qwen Code config to ~/.qwen/settings.json
- * Writes the modelProviders.openai entry with OmniRoute as the provider.
- * Merges with existing config to preserve other providers.
+ * Save Qwen Code config to ~/.qwen/settings.json + ~/.qwen/.env
+ *
+ * Per official docs, credentials go in .env via envKey references,
+ * not hardcoded in settings.json modelProviders entries.
+ * Writes openai, anthropic, and gemini providers pointing to OmniRoute.
*/
async function saveQwenConfig({ baseUrl, apiKey, model }) {
const home = os.homedir();
const configPath = path.join(home, ".qwen", "settings.json");
+ const envPath = path.join(home, ".qwen", ".env");
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
- const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
+ const normalizedBaseUrl = String(baseUrl || "")
+ .trim()
+ .replace(/\/+$/, "");
+ const resolvedApiKey = apiKey || "sk_omniroute";
+ const resolvedModel = model || "coder-model";
- // Read existing config to preserve other provider entries
+ // --- Write API keys to .env ---
+ let envContent = "";
+ try {
+ envContent = await fs.readFile(envPath, "utf-8");
+ } catch {
+ // File doesn't exist
+ }
+
+ const envLines = envContent.split("\n").filter((line) => {
+ // Remove old OmniRoute-related keys we're about to write
+ return (
+ !line.startsWith("OPENAI_API_KEY=") &&
+ !line.startsWith("ANTHROPIC_API_KEY=") &&
+ !line.startsWith("GEMINI_API_KEY=")
+ );
+ });
+
+ envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
+ envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
+ envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
+
+ await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
+
+ // --- Write modelProviders to settings.json ---
let existingConfig: Record = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
@@ -199,39 +233,75 @@ async function saveQwenConfig({ baseUrl, apiKey, model }) {
// File doesn't exist or invalid JSON
}
- // Build OmniRoute openai provider entry
- const omnirouteEntry = {
- id: "omniroute",
- name: "OmniRoute",
+ if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
+
+ // openai provider β primary, supports all models via OmniRoute
+ const openaiEntry = {
+ id: resolvedModel,
+ name: `${resolvedModel} (OmniRoute)`,
envKey: "OPENAI_API_KEY",
baseUrl: normalizedBaseUrl,
- apiKey: apiKey || "sk_omniroute",
generationConfig: {
- defaultModel: model || "auto",
+ contextWindowSize: 200000,
},
};
- // Ensure modelProviders.openai array exists
- if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
-
- const providers = existingConfig.modelProviders.openai;
-
- // Replace OmniRoute entry if already present, otherwise add it
- const existingIdx = providers.findIndex(
+ const openaiProviders = existingConfig.modelProviders.openai;
+ const openaiIdx = openaiProviders.findIndex(
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
);
- if (existingIdx >= 0) {
- providers[existingIdx] = omnirouteEntry;
+ if (openaiIdx >= 0) {
+ openaiProviders[openaiIdx] = openaiEntry;
} else {
- providers.push(omnirouteEntry);
+ openaiProviders.push(openaiEntry);
+ }
+
+ // anthropic provider β for Claude models via OmniRoute
+ const anthropicEntry = {
+ id: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6 (OmniRoute)",
+ envKey: "ANTHROPIC_API_KEY",
+ baseUrl: normalizedBaseUrl,
+ generationConfig: {
+ contextWindowSize: 200000,
+ },
+ };
+
+ if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
+ const anthropicProviders = existingConfig.modelProviders.anthropic;
+ const anthropicIdx = anthropicProviders.findIndex(
+ (p: any) => p && p.baseUrl === normalizedBaseUrl
+ );
+ if (anthropicIdx >= 0) {
+ anthropicProviders[anthropicIdx] = anthropicEntry;
+ } else {
+ anthropicProviders.push(anthropicEntry);
+ }
+
+ // gemini provider β for Gemini models via OmniRoute
+ const geminiEntry = {
+ id: "gemini-3-flash",
+ name: "Gemini 3 Flash (OmniRoute)",
+ envKey: "GEMINI_API_KEY",
+ baseUrl: normalizedBaseUrl,
+ };
+
+ if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
+ const geminiProviders = existingConfig.modelProviders.gemini;
+ const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
+ if (geminiIdx >= 0) {
+ geminiProviders[geminiIdx] = geminiEntry;
+ } else {
+ geminiProviders.push(geminiEntry);
}
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8");
return NextResponse.json({
success: true,
- message: `Qwen Code config saved to ${configPath}`,
+ message: `Qwen Code config saved to ${configPath} + ${envPath}`,
configPath,
+ envPath,
});
}
diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts
index 95248e31..d14f4b17 100644
--- a/src/app/api/cli-tools/kilo-settings/route.ts
+++ b/src/app/api/cli-tools/kilo-settings/route.ts
@@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { getApiKeyById } from "@/lib/localDb";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
@@ -138,19 +138,7 @@ export async function POST(request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, model } = validation.data;
- let { apiKey } = validation.data;
-
- // Resolve real key from DB by ID
- if (keyId) {
- try {
- const keyRecord = await getApiKeyById(keyId);
- if (keyRecord?.key) {
- apiKey = keyRecord.key as string;
- }
- } catch {
- // Non-critical: fall back to whatever value was in apiKey
- }
- }
+ const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
// Ensure directories exist
await fs.mkdir(KILO_DATA_DIR, { recursive: true });
diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts
index 88958bb5..e007951b 100644
--- a/src/app/api/cli-tools/openclaw-settings/route.ts
+++ b/src/app/api/cli-tools/openclaw-settings/route.ts
@@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { getApiKeyById } from "@/lib/localDb";
+import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
@@ -105,17 +105,8 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- let { baseUrl, apiKey, model } = validation.data;
-
- // Resolve real key from DB by ID
- if (keyId) {
- try {
- const keyRecord = await getApiKeyById(keyId);
- if (keyRecord?.key) apiKey = keyRecord.key as string;
- } catch {
- /* non-critical */
- }
- }
+ let { baseUrl, model } = validation.data;
+ let apiKey = await resolveApiKey(keyId, validation.data.apiKey);
const openclawDir = getOpenClawDir();
const settingsPath = getOpenClawSettingsPath();
diff --git a/src/app/api/cli-tools/qwen-settings/route.ts b/src/app/api/cli-tools/qwen-settings/route.ts
new file mode 100644
index 00000000..1e079755
--- /dev/null
+++ b/src/app/api/cli-tools/qwen-settings/route.ts
@@ -0,0 +1,353 @@
+"use server";
+
+import { NextResponse } from "next/server";
+import fs from "fs/promises";
+import path from "path";
+import os from "os";
+import {
+ ensureCliConfigWriteAllowed,
+ getCliPrimaryConfigPath,
+ getCliRuntimeStatus,
+} from "@/shared/services/cliRuntime";
+import { createBackup } from "@/shared/services/backupService";
+import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
+import { cliModelConfigSchema } from "@/shared/validation/schemas";
+import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+import { getApiKeyById } from "@/lib/localDb";
+
+const getQwenSettingsPath = () => getCliPrimaryConfigPath("qwen");
+const getQwenDir = () => path.dirname(getQwenSettingsPath());
+const getQwenEnvPath = () => path.join(getQwenDir(), ".env");
+
+// Read current settings.json
+const readSettings = async () => {
+ try {
+ const settingsPath = getQwenSettingsPath();
+ const content = await fs.readFile(settingsPath, "utf-8");
+ return JSON.parse(content);
+ } catch (error: any) {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ }
+};
+
+// Read current .env file
+const readEnv = async () => {
+ try {
+ const envPath = getQwenEnvPath();
+ return await fs.readFile(envPath, "utf-8");
+ } catch (error: any) {
+ if (error.code === "ENOENT") return "";
+ throw error;
+ }
+};
+
+// Check if settings has OmniRoute config
+const hasOmniRouteConfig = (settings: any) => {
+ if (!settings || !settings.modelProviders) return false;
+ const openai = settings.modelProviders.openai;
+ if (!Array.isArray(openai)) return false;
+ return openai.some((p: any) => {
+ if (p.name?.includes("OmniRoute") || p.id === "omniroute") return true;
+ if (!p.baseUrl) return false;
+ try {
+ const urlObj = new URL(p.baseUrl);
+ const host = urlObj.hostname;
+ const isDashScope =
+ host === "dashscope.aliyuncs.com" || host.endsWith(".dashscope.aliyuncs.com");
+ const isOpenAI = host === "api.openai.com" || host.endsWith(".openai.com");
+ return !isDashScope && !isOpenAI;
+ } catch {
+ return true; // invalid URLs are treated as custom endpoints
+ }
+ });
+};
+
+// GET - Check Qwen CLI and read current settings
+export async function GET() {
+ try {
+ const runtime = await getCliRuntimeStatus("qwen");
+
+ if (!runtime.installed || !runtime.runnable) {
+ return NextResponse.json({
+ installed: runtime.installed,
+ runnable: runtime.runnable,
+ command: runtime.command,
+ commandPath: runtime.commandPath,
+ runtimeMode: runtime.runtimeMode,
+ reason: runtime.reason,
+ settings: null,
+ message:
+ runtime.installed && !runtime.runnable
+ ? "Qwen Code CLI is installed but not runnable"
+ : "Qwen Code CLI is not installed",
+ });
+ }
+
+ const settings = await readSettings();
+
+ return NextResponse.json({
+ installed: runtime.installed,
+ runnable: runtime.runnable,
+ command: runtime.command,
+ commandPath: runtime.commandPath,
+ runtimeMode: runtime.runtimeMode,
+ reason: runtime.reason,
+ settings,
+ hasOmniRoute: hasOmniRouteConfig(settings),
+ settingsPath: getQwenSettingsPath(),
+ envPath: getQwenEnvPath(),
+ });
+ } catch (error) {
+ console.log("Error checking qwen settings:", error);
+ return NextResponse.json({ error: "Failed to check qwen settings" }, { status: 500 });
+ }
+}
+
+// POST - Write OmniRoute config to settings.json + .env
+export async function POST(request: Request) {
+ let rawBody;
+ try {
+ rawBody = await request.json();
+ } catch {
+ return NextResponse.json(
+ {
+ error: {
+ message: "Invalid request",
+ details: [{ field: "body", message: "Invalid JSON body" }],
+ },
+ },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const writeGuard = ensureCliConfigWriteAllowed();
+ if (writeGuard) {
+ return NextResponse.json({ error: writeGuard }, { status: 403 });
+ }
+
+ // Extract keyId BEFORE validation β Zod strips unknown fields
+ const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
+
+ const validation = validateBody(cliModelConfigSchema, rawBody);
+ if (isValidationFailure(validation)) {
+ return NextResponse.json({ error: validation.error }, { status: 400 });
+ }
+ let { baseUrl, apiKey, model } = validation.data;
+
+ // Resolve real key from DB by ID
+ if (keyId) {
+ try {
+ const keyRecord = await getApiKeyById(keyId);
+ if (keyRecord?.key) apiKey = keyRecord.key as string;
+ } catch {
+ /* non-critical */
+ }
+ }
+
+ const resolvedApiKey = apiKey || "sk_omniroute";
+ const resolvedModel = model || "coder-model";
+ const normalizedBaseUrl = String(baseUrl || "")
+ .trim()
+ .replace(/\/+$/, "");
+ const qwenDir = getQwenDir();
+ const settingsPath = getQwenSettingsPath();
+ const envPath = getQwenEnvPath();
+
+ // Ensure directory exists
+ await fs.mkdir(qwenDir, { recursive: true });
+
+ // Backup current settings before modifying
+ await createBackup("qwen", settingsPath);
+
+ // --- Write API keys to ~/.qwen/.env ---
+ let envContent = await readEnv();
+ const envLines = envContent.split("\n").filter((line) => {
+ // Remove old OmniRoute-related keys we're about to write
+ return (
+ !line.startsWith("OPENAI_API_KEY=") &&
+ !line.startsWith("ANTHROPIC_API_KEY=") &&
+ !line.startsWith("GEMINI_API_KEY=")
+ );
+ });
+
+ envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
+ envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
+ envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
+
+ await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
+
+ // --- Write modelProviders to settings.json ---
+ let existingConfig: Record = {};
+ try {
+ const raw = await fs.readFile(settingsPath, "utf-8");
+ existingConfig = JSON.parse(raw);
+ } catch {
+ // File doesn't exist or invalid JSON
+ }
+
+ if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
+
+ // openai provider β primary, supports all models via OmniRoute
+ const openaiEntry = {
+ id: resolvedModel,
+ name: `${resolvedModel} (OmniRoute)`,
+ envKey: "OPENAI_API_KEY",
+ baseUrl: normalizedBaseUrl,
+ generationConfig: {
+ contextWindowSize: 200000,
+ },
+ };
+
+ if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
+ const openaiProviders = existingConfig.modelProviders.openai;
+ const openaiIdx = openaiProviders.findIndex(
+ (p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
+ );
+ if (openaiIdx >= 0) {
+ openaiProviders[openaiIdx] = openaiEntry;
+ } else {
+ openaiProviders.push(openaiEntry);
+ }
+
+ // anthropic provider β for Claude models via OmniRoute
+ const anthropicEntry = {
+ id: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6 (OmniRoute)",
+ envKey: "ANTHROPIC_API_KEY",
+ baseUrl: normalizedBaseUrl,
+ generationConfig: {
+ contextWindowSize: 200000,
+ },
+ };
+
+ if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
+ const anthropicProviders = existingConfig.modelProviders.anthropic;
+ const anthropicIdx = anthropicProviders.findIndex(
+ (p: any) => p && p.baseUrl === normalizedBaseUrl
+ );
+ if (anthropicIdx >= 0) {
+ anthropicProviders[anthropicIdx] = anthropicEntry;
+ } else {
+ anthropicProviders.push(anthropicEntry);
+ }
+
+ // gemini provider β for Gemini models via OmniRoute
+ const geminiEntry = {
+ id: "gemini-3-flash",
+ name: "Gemini 3 Flash (OmniRoute)",
+ envKey: "GEMINI_API_KEY",
+ baseUrl: normalizedBaseUrl,
+ };
+
+ if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
+ const geminiProviders = existingConfig.modelProviders.gemini;
+ const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
+ if (geminiIdx >= 0) {
+ geminiProviders[geminiIdx] = geminiEntry;
+ } else {
+ geminiProviders.push(geminiEntry);
+ }
+
+ await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
+
+ // Persist last-configured timestamp
+ try {
+ saveCliToolLastConfigured("qwen");
+ } catch {
+ /* non-critical */
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: "Qwen Code config saved successfully!",
+ settingsPath,
+ envPath,
+ });
+ } catch (error) {
+ console.log("Error updating qwen settings:", error);
+ return NextResponse.json({ error: "Failed to update qwen settings" }, { status: 500 });
+ }
+}
+
+// DELETE - Remove OmniRoute config from settings.json and .env
+export async function DELETE() {
+ try {
+ const writeGuard = ensureCliConfigWriteAllowed();
+ if (writeGuard) {
+ return NextResponse.json({ error: writeGuard }, { status: 403 });
+ }
+
+ const settingsPath = getQwenSettingsPath();
+ const envPath = getQwenEnvPath();
+
+ // Backup current settings before resetting
+ await createBackup("qwen", settingsPath);
+
+ // --- Clean settings.json ---
+ let existingConfig: Record = {};
+ try {
+ const raw = await fs.readFile(settingsPath, "utf-8");
+ existingConfig = JSON.parse(raw);
+ } catch (error: any) {
+ if (error.code === "ENOENT") {
+ return NextResponse.json({
+ success: true,
+ message: "No settings file to reset",
+ });
+ }
+ throw error;
+ }
+
+ // Remove OmniRoute entries from each provider type
+ const providerTypes = ["openai", "anthropic", "gemini"];
+ for (const type of providerTypes) {
+ if (Array.isArray(existingConfig.modelProviders?.[type])) {
+ existingConfig.modelProviders[type] = existingConfig.modelProviders[type].filter(
+ (p: any) => !p.name?.includes("OmniRoute") && p.id !== "omniroute"
+ );
+ // Remove empty provider arrays
+ if (existingConfig.modelProviders[type].length === 0) {
+ delete existingConfig.modelProviders[type];
+ }
+ }
+ }
+
+ // Clean up empty modelProviders
+ if (existingConfig.modelProviders && Object.keys(existingConfig.modelProviders).length === 0) {
+ delete existingConfig.modelProviders;
+ }
+
+ await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
+
+ // --- Clean .env ---
+ const RESET_ENV_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"];
+
+ try {
+ let envContent = await fs.readFile(envPath, "utf-8");
+ const envLines = envContent
+ .split("\n")
+ .filter((line) => !RESET_ENV_KEYS.some((key) => line.startsWith(`${key}=`)));
+
+ await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
+ } catch {
+ // .env doesn't exist β nothing to clean
+ }
+
+ // Clear last-configured timestamp
+ try {
+ deleteCliToolLastConfigured("qwen");
+ } catch {
+ /* non-critical */
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: "OmniRoute settings removed from Qwen Code",
+ });
+ } catch (error) {
+ console.log("Error resetting qwen settings:", error);
+ return NextResponse.json({ error: "Failed to reset qwen settings" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts
index ebcb7067..cc7c5aff 100644
--- a/src/app/api/cli-tools/status/route.ts
+++ b/src/app/api/cli-tools/status/route.ts
@@ -52,6 +52,16 @@ async function checkToolConfigStatus(toolId: string): Promise {
switch (toolId) {
case "claude":
return config?.env?.ANTHROPIC_BASE_URL ? "configured" : "not_configured";
+ case "qwen":
+ // Check modelProviders for OmniRoute entries
+ const mp = config?.modelProviders;
+ if (!mp) return "not_configured";
+ const qwenConfigStr = JSON.stringify(mp).toLowerCase();
+ return qwenConfigStr.includes("omniroute") ||
+ qwenConfigStr.includes(`localhost:${apiPort}`) ||
+ qwenConfigStr.includes(`127.0.0.1:${apiPort}`)
+ ? "configured"
+ : "not_configured";
case "droid":
case "openclaw":
case "cline":
@@ -128,7 +138,7 @@ export async function GET() {
);
// Check config status for installed+runnable tools via direct file reads
- const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
+ const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
await Promise.all(
settingsTools.map(async (toolId) => {
diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts
index 6d902228..ddc2c3cb 100644
--- a/src/app/api/providers/[id]/refresh/route.ts
+++ b/src/app/api/providers/[id]/refresh/route.ts
@@ -1,7 +1,13 @@
import { NextResponse } from "next/server";
-import { getProviderConnectionById } from "@/models";
+import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers";
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
+type RefreshResult = {
+ accessToken?: string;
+ expiresIn?: number;
+ error?: string;
+};
+
/**
* POST /api/providers/[id]/refresh
* Manually trigger an OAuth token refresh for a provider connection.
@@ -33,7 +39,11 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
);
}
- const provider = connection.provider as string;
+ if (typeof connection.provider !== "string" || connection.provider.length === 0) {
+ return NextResponse.json({ error: "Connection provider is invalid" }, { status: 422 });
+ }
+
+ const provider = connection.provider;
const credentials = {
connectionId: id,
accessToken: connection.accessToken,
@@ -46,7 +56,24 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
// Use the existing getAccessToken helper which knows how to refresh
// tokens for each provider type (Claude, GitHub, Gemini, etc.)
- const newCredentials = await getAccessToken(provider, credentials);
+ const newCredentials = (await getAccessToken(provider, credentials)) as RefreshResult | null;
+
+ if (newCredentials && typeof newCredentials === "object" && newCredentials.error) {
+ if (
+ newCredentials.error === "unrecoverable_refresh_error" ||
+ newCredentials.error === "refresh_token_reused" ||
+ newCredentials.error === "invalid_grant"
+ ) {
+ await updateProviderConnection(id, {
+ testStatus: "invalid",
+ lastError: "Refresh token expired. Please re-authenticate this account.",
+ });
+ return NextResponse.json(
+ { error: "Token refresh failed β provider returned no new token", requiresReauth: true },
+ { status: 401 }
+ );
+ }
+ }
if (!newCredentials?.accessToken) {
return NextResponse.json(
diff --git a/src/app/api/skills/[id]/route.ts b/src/app/api/skills/[id]/route.ts
index 159eae0b..80d5fc9b 100644
--- a/src/app/api/skills/[id]/route.ts
+++ b/src/app/api/skills/[id]/route.ts
@@ -5,7 +5,8 @@ import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
const updateSkillSchema = z.object({
- enabled: z.boolean(),
+ enabled: z.boolean().optional(),
+ mode: z.enum(["on", "off", "auto"]).optional(),
});
export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) {
@@ -32,14 +33,44 @@ export async function PUT(request: Request, props: { params: Promise<{ id: strin
}
const db = getDbInstance();
- db.prepare("UPDATE skills SET enabled = ? WHERE id = ?").run(
- validation.data.enabled ? 1 : 0,
- id
- );
+ const updates: string[] = [];
+ const params: unknown[] = [];
+
+ if (validation.data.enabled !== undefined) {
+ updates.push("enabled = ?");
+ params.push(validation.data.enabled ? 1 : 0);
+
+ // Legacy enabled toggle should also keep mode in sync.
+ // Without this, skills created as mode="off" remain excluded even after enabled=true.
+ if (validation.data.mode === undefined) {
+ updates.push("mode = ?");
+ params.push(validation.data.enabled ? "on" : "off");
+ }
+ }
+
+ if (validation.data.mode !== undefined) {
+ updates.push("mode = ?");
+ params.push(validation.data.mode);
+ // keep enabled column consistent for older codepaths
+ updates.push("enabled = ?");
+ params.push(validation.data.mode === "off" ? 0 : 1);
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: "No update payload provided" }, { status: 400 });
+ }
+
+ updates.push("updated_at = datetime('now')");
+ params.push(id);
+ db.prepare(`UPDATE skills SET ${updates.join(", ")} WHERE id = ?`).run(...params);
await skillRegistry.loadFromDatabase();
- return NextResponse.json({ success: true, enabled: validation.data.enabled });
+ return NextResponse.json({
+ success: true,
+ enabled: validation.data.enabled,
+ mode: validation.data.mode,
+ });
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error }, { status: 500 });
diff --git a/src/app/api/skills/marketplace/install/route.ts b/src/app/api/skills/marketplace/install/route.ts
index 4bce9825..f05ec580 100644
--- a/src/app/api/skills/marketplace/install/route.ts
+++ b/src/app/api/skills/marketplace/install/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { skillRegistry } from "@/lib/skills/registry";
+import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
import { isAuthenticated } from "@/shared/utils/apiAuth";
@@ -18,6 +19,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
+ const provider = await getSkillsProviderSetting();
+ if (provider !== "skillsmp") {
+ return NextResponse.json(
+ {
+ error:
+ "Active skills provider is not SkillsMP. Switch provider in Settings β Memory & Skills.",
+ },
+ { status: 409 }
+ );
+ }
+
const rawBody = await request.json();
const validation = validateBody(marketplaceInstallSchema, rawBody);
if (isValidationFailure(validation)) {
@@ -31,8 +43,12 @@ export async function POST(request: Request) {
description,
schema: { input: { content: "string" }, output: { result: "string" } },
handler: `// Installed from SkillsMP\n// SKILL.md content:\n${skillMdContent}`,
- apiKeyId: "skillsmp",
+ apiKeyId: provider,
enabled: true,
+ mode: "auto",
+ sourceProvider: "skillsmp",
+ tags: ["popular", "marketplace"],
+ installCount: 1,
});
return NextResponse.json({ success: true, id: skill.id });
diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts
index 3d62075e..7e4c10fa 100644
--- a/src/app/api/skills/route.ts
+++ b/src/app/api/skills/route.ts
@@ -1,18 +1,54 @@
import { NextResponse } from "next/server";
import { skillRegistry } from "@/lib/skills/registry";
import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination";
+import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
+
+const POPULAR_BY_PROVIDER = {
+ skillsmp: ["web-search", "file-reader", "sql-assistant", "devops-helper", "docs-assistant"],
+ skillssh: ["git", "terminal", "postgres", "kubernetes", "playwright"],
+} as const;
export async function GET(request?: Request) {
try {
await skillRegistry.loadFromDatabase();
- const allSkills = skillRegistry.list();
+ const provider = await getSkillsProviderSetting();
const url = request?.url || "http://localhost/api/skills";
- const params = parsePaginationParams(new URL(url).searchParams);
+ const parsedUrl = new URL(url);
+ const query = parsedUrl.searchParams.get("q")?.trim().toLowerCase() || "";
+ const modeFilter = parsedUrl.searchParams.get("mode");
+ const sourceFilter = parsedUrl.searchParams.get("source");
+
+ let allSkills = skillRegistry.list();
+
+ if (query) {
+ allSkills = allSkills.filter((skill) => {
+ const tags = Array.isArray(skill.tags) ? skill.tags.join(" ").toLowerCase() : "";
+ return (
+ skill.name.toLowerCase().includes(query) ||
+ skill.description.toLowerCase().includes(query) ||
+ tags.includes(query)
+ );
+ });
+ }
+
+ if (modeFilter === "on" || modeFilter === "off" || modeFilter === "auto") {
+ allSkills = allSkills.filter(
+ (skill) => (skill.mode || (skill.enabled ? "on" : "off")) === modeFilter
+ );
+ }
+
+ if (sourceFilter === "skillsmp" || sourceFilter === "skillssh" || sourceFilter === "local") {
+ allSkills = allSkills.filter((skill) => (skill.sourceProvider || "local") === sourceFilter);
+ }
+
+ const params = parsePaginationParams(parsedUrl.searchParams);
const paged = allSkills.slice((params.page - 1) * params.limit, params.page * params.limit);
const response = buildPaginatedResponse(paged, allSkills.length, params);
return NextResponse.json({
...response,
skills: response.data,
+ provider,
+ popularDefaults: POPULAR_BY_PROVIDER[provider],
});
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
diff --git a/src/app/api/skills/skillssh/install/route.ts b/src/app/api/skills/skillssh/install/route.ts
index 3e6d24cf..cb3288e2 100644
--- a/src/app/api/skills/skillssh/install/route.ts
+++ b/src/app/api/skills/skillssh/install/route.ts
@@ -4,6 +4,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { skillRegistry } from "@/lib/skills/registry";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { fetchSkillMd } from "@/lib/skills/skillssh";
+import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
const skillsshInstallSchema = z.object({
name: z.string().min(1).max(64),
@@ -18,6 +19,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
+ const provider = await getSkillsProviderSetting();
+ if (provider !== "skillssh") {
+ return NextResponse.json(
+ {
+ error:
+ "Active skills provider is not skills.sh. Switch provider in Settings β Memory & Skills.",
+ },
+ { status: 409 }
+ );
+ }
+
const rawBody = await request.json();
const validation = validateBody(skillsshInstallSchema, rawBody);
if (isValidationFailure(validation)) {
@@ -33,8 +45,12 @@ export async function POST(request: Request) {
description,
schema: { input: { content: "string" }, output: { result: "string" } },
handler: `// Installed from skills.sh\n// Source: ${source}/${skillId}\n// SKILL.md content:\n${skillMdContent}`,
- apiKeyId: "skillssh",
+ apiKeyId: provider,
enabled: true,
+ mode: "auto",
+ sourceProvider: "skillssh",
+ tags: ["popular", "community"],
+ installCount: 1,
});
return NextResponse.json({ success: true, id: skill.id });
diff --git a/src/lib/acp/registry.ts b/src/lib/acp/registry.ts
index 9721f3d0..a752f394 100644
--- a/src/lib/acp/registry.ts
+++ b/src/lib/acp/registry.ts
@@ -10,7 +10,8 @@
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
*/
-import { execSync } from "child_process";
+import { execFileSync } from "child_process";
+import path from "path";
export interface CliAgentInfo {
/** Agent identifier (e.g., "codex", "claude", "goose") */
@@ -188,6 +189,8 @@ const CACHE_TTL_MS = 60_000;
/** Custom agents loaded from settings */
let _customAgentDefs: CustomAgentDef[] = [];
+const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
+
/**
* Set custom agent definitions from settings.
*/
@@ -203,6 +206,110 @@ export function getCustomAgentDefs(): CustomAgentDef[] {
return _customAgentDefs;
}
+function tokenizeVersionCommand(command: string): string[] | null {
+ if (!command || DISALLOWED_VERSION_COMMAND_CHARS.test(command)) {
+ return null;
+ }
+
+ const tokens: string[] = [];
+ let current = "";
+ let quote: '"' | "'" | null = null;
+
+ for (let index = 0; index < command.length; index += 1) {
+ const char = command[index];
+
+ if (quote) {
+ if (char === quote) {
+ quote = null;
+ } else {
+ current += char;
+ }
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ quote = char;
+ continue;
+ }
+
+ if (/\s/.test(char)) {
+ if (current) {
+ tokens.push(current);
+ current = "";
+ }
+ continue;
+ }
+
+ if (char === "\\") {
+ const next = command[index + 1];
+ if (next) {
+ current += next;
+ index += 1;
+ continue;
+ }
+ }
+
+ current += char;
+ }
+
+ if (quote) {
+ return null;
+ }
+
+ if (current) {
+ tokens.push(current);
+ }
+
+ return tokens.length > 0 ? tokens : null;
+}
+
+function normalizeCommandToken(command: string): string {
+ return path.normalize(command).replace(/\\/g, "/").toLowerCase();
+}
+
+export function resolveVersionProbe(
+ binary: string,
+ versionCommand: string,
+ requireBinaryMatch = false
+): { command: string; args: string[] } | null {
+ const tokens = tokenizeVersionCommand(versionCommand);
+ if (!tokens) {
+ return null;
+ }
+
+ const [command, ...args] = tokens;
+ if (!command) {
+ return null;
+ }
+
+ if (requireBinaryMatch) {
+ const normalizedCommand = normalizeCommandToken(command);
+ const allowed = new Set([
+ normalizeCommandToken(binary),
+ normalizeCommandToken(path.basename(binary)),
+ ]);
+ if (!allowed.has(normalizedCommand)) {
+ return null;
+ }
+ }
+
+ return { command, args };
+}
+
+export function shouldUseShellForVersionProbe(
+ command: string,
+ platform = process.platform
+): boolean {
+ if (platform !== "win32") return false;
+
+ const normalized = command.trim().toLowerCase();
+ if (!normalized) return false;
+
+ return (
+ normalized.endsWith(".cmd") || normalized.endsWith(".bat") || path.extname(normalized) === ""
+ );
+}
+
/**
* Detect a single agent by running its version command.
*/
@@ -214,10 +321,16 @@ function detectAgent(
let installed = false;
try {
- const output = execSync(def.versionCommand, {
+ const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
+ if (!probe) {
+ return { ...def, version, installed, isCustom };
+ }
+
+ const output = execFileSync(probe.command, probe.args, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
+ ...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();
// Extract version number from output
diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts
index ef5e2ccc..7239790b 100644
--- a/src/lib/config/runtimeSettings.ts
+++ b/src/lib/config/runtimeSettings.ts
@@ -46,6 +46,11 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
+function isTruthyEnvFlag(value: string | undefined): boolean {
+ if (typeof value !== "string") return false;
+ return new Set(["1", "true", "yes", "on"]).has(value.trim().toLowerCase());
+}
+
function isAutomatedTestProcess(): boolean {
return (
typeof process !== "undefined" &&
@@ -250,7 +255,8 @@ async function applyModelsDevSyncSection(
) {
const { startPeriodicSync, stopPeriodicSync } = await import("@/lib/modelsDevSync");
const skipBackgroundSyncInTests =
- isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1";
+ (isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1") ||
+ isTruthyEnvFlag(process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES);
if (skipBackgroundSyncInTests) {
stopPeriodicSync();
diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js
index 5078bbe9..35954299 100644
--- a/src/lib/dataPaths.js
+++ b/src/lib/dataPaths.js
@@ -1,9 +1,7 @@
"use strict";
-var __importDefault =
- (this && this.__importDefault) ||
- function (mod) {
- return mod && mod.__esModule ? mod : { default: mod };
- };
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
Object.defineProperty(exports, "__esModule", { value: true });
exports.APP_NAME = void 0;
exports.getLegacyDotDataDir = getLegacyDotDataDir;
@@ -14,53 +12,59 @@ const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
exports.APP_NAME = "omniroute";
function fallbackHomeDir() {
- const envHome = process.env.HOME || process.env.USERPROFILE;
- if (typeof envHome === "string" && envHome.trim().length > 0) {
- return path_1.default.resolve(envHome);
- }
- return os_1.default.tmpdir();
+ const envHome = process.env.HOME || process.env.USERPROFILE;
+ if (typeof envHome === "string" && envHome.trim().length > 0) {
+ return path_1.default.resolve(envHome);
+ }
+ return os_1.default.tmpdir();
}
function safeHomeDir() {
- try {
- return os_1.default.homedir();
- } catch {
- return fallbackHomeDir();
- }
+ try {
+ return os_1.default.homedir();
+ }
+ catch {
+ return fallbackHomeDir();
+ }
}
function normalizeConfiguredPath(dir) {
- if (typeof dir !== "string") return null;
- const trimmed = dir.trim();
- if (!trimmed) return null;
- return path_1.default.resolve(trimmed);
+ if (typeof dir !== "string")
+ return null;
+ const trimmed = dir.trim();
+ if (!trimmed)
+ return null;
+ return path_1.default.resolve(trimmed);
}
function getLegacyDotDataDir() {
- return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
+ return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
}
function getDefaultDataDir() {
- const homeDir = safeHomeDir();
- if (process.platform === "win32") {
- const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
- return path_1.default.join(appData, exports.APP_NAME);
- }
- // Support XDG on Linux/macOS when explicitly configured.
- const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
- if (xdgConfigHome) {
- return path_1.default.join(xdgConfigHome, exports.APP_NAME);
- }
- return getLegacyDotDataDir();
+ const homeDir = safeHomeDir();
+ if (process.platform === "win32") {
+ const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
+ return path_1.default.join(appData, exports.APP_NAME);
+ }
+ // Support XDG on Linux/macOS when explicitly configured.
+ const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
+ if (xdgConfigHome) {
+ return path_1.default.join(xdgConfigHome, exports.APP_NAME);
+ }
+ return getLegacyDotDataDir();
}
function resolveDataDir({ isCloud = false } = {}) {
- if (isCloud) return "/tmp";
- const configured = normalizeConfiguredPath(process.env.DATA_DIR);
- if (configured) return configured;
- return getDefaultDataDir();
+ if (isCloud)
+ return "/tmp";
+ const configured = normalizeConfiguredPath(process.env.DATA_DIR);
+ if (configured)
+ return configured;
+ return getDefaultDataDir();
}
function isSamePath(a, b) {
- if (!a || !b) return false;
- const normalizedA = path_1.default.resolve(a);
- const normalizedB = path_1.default.resolve(b);
- if (process.platform === "win32") {
- return normalizedA.toLowerCase() === normalizedB.toLowerCase();
- }
- return normalizedA === normalizedB;
+ if (!a || !b)
+ return false;
+ const normalizedA = path_1.default.resolve(a);
+ const normalizedB = path_1.default.resolve(b);
+ if (process.platform === "win32") {
+ return normalizedA.toLowerCase() === normalizedB.toLowerCase();
+ }
+ return normalizedA === normalizedB;
}
diff --git a/src/lib/db/migrations/027_skill_mode_and_metadata.sql b/src/lib/db/migrations/027_skill_mode_and_metadata.sql
new file mode 100644
index 00000000..523acdea
--- /dev/null
+++ b/src/lib/db/migrations/027_skill_mode_and_metadata.sql
@@ -0,0 +1,10 @@
+-- 027_skill_mode_and_metadata.sql
+-- Adds per-skill mode metadata and indexing for provider/filter UX.
+
+ALTER TABLE skills ADD COLUMN mode TEXT NOT NULL DEFAULT 'auto';
+ALTER TABLE skills ADD COLUMN source_provider TEXT;
+ALTER TABLE skills ADD COLUMN tags TEXT;
+ALTER TABLE skills ADD COLUMN install_count INTEGER NOT NULL DEFAULT 0;
+
+CREATE INDEX IF NOT EXISTS idx_skills_mode ON skills(mode);
+CREATE INDEX IF NOT EXISTS idx_skills_source_provider ON skills(source_provider);
diff --git a/src/lib/skills/injection.ts b/src/lib/skills/injection.ts
index 809c65f0..c5a63e20 100644
--- a/src/lib/skills/injection.ts
+++ b/src/lib/skills/injection.ts
@@ -56,10 +56,185 @@ export interface InjectionOptions {
provider: "openai" | "anthropic" | "google" | "other";
existingTools?: unknown[];
apiKeyId: string;
+ model?: string;
+ sourceFormat?: string;
+ targetFormat?: string;
+ backgroundReason?: string | null;
+ messages?: unknown[];
+}
+
+const AUTO_MIN_SCORE = 3;
+const AUTO_MAX_SKILLS = 5;
+const TOKEN_MIN_LEN = 3;
+
+function toLowerText(value: unknown): string {
+ if (typeof value === "string") return value.toLowerCase();
+ return "";
+}
+
+function extractTokens(value: string): Set {
+ const matches = value.toLowerCase().match(/[a-z0-9]+/g) || [];
+ return new Set(matches.filter((t) => t.length >= TOKEN_MIN_LEN));
+}
+
+function splitNameTokens(name: string): Set {
+ const expandedCamel = name
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/[._@\-/]+/g, " ")
+ .toLowerCase();
+ return extractTokens(expandedCamel);
+}
+
+function extractMessageText(messages: unknown[]): string {
+ const chunks: string[] = [];
+ for (const message of messages) {
+ if (!message || typeof message !== "object") continue;
+ const record = message as Record;
+ const content = record.content;
+
+ if (typeof content === "string") {
+ chunks.push(content);
+ continue;
+ }
+
+ if (Array.isArray(content)) {
+ for (const item of content) {
+ if (typeof item === "string") {
+ chunks.push(item);
+ continue;
+ }
+ if (item && typeof item === "object") {
+ const itemRecord = item as Record;
+ if (typeof itemRecord.text === "string") {
+ chunks.push(itemRecord.text);
+ }
+ }
+ }
+ }
+ }
+
+ return chunks.join(" ").toLowerCase();
+}
+
+function buildContextText(options: InjectionOptions): string {
+ const parts = [
+ JSON.stringify(options.existingTools || []).toLowerCase(),
+ toLowerText(options.model),
+ toLowerText(options.sourceFormat),
+ toLowerText(options.targetFormat),
+ toLowerText(options.backgroundReason),
+ ];
+
+ if (Array.isArray(options.messages) && options.messages.length > 0) {
+ parts.push(extractMessageText(options.messages));
+ }
+
+ return parts.filter(Boolean).join(" ");
+}
+
+function scoreAutoSkill(
+ skill: Skill,
+ options: InjectionOptions,
+ contextText: string,
+ contextTokens: Set,
+ backgroundTokens: Set
+): number {
+ const name = skill.name.toLowerCase();
+ const tags = (Array.isArray(skill.tags) ? skill.tags : []).map((tag) =>
+ String(tag).toLowerCase()
+ );
+ const description = toLowerText(skill.description);
+
+ const nameTokens = splitNameTokens(skill.name);
+ const descriptionTokens = extractTokens(description);
+
+ let score = 0;
+
+ if (name && contextText.includes(name)) {
+ score += 6;
+ }
+
+ for (const token of nameTokens) {
+ if (contextTokens.has(token)) score += 2;
+ }
+
+ for (const tag of tags) {
+ if (!tag) continue;
+ if (contextText.includes(tag)) {
+ score += 3;
+ }
+ }
+
+ for (const token of descriptionTokens) {
+ if (contextTokens.has(token)) score += 1;
+ }
+
+ if (backgroundTokens.size > 0) {
+ for (const token of backgroundTokens) {
+ if (nameTokens.has(token)) score += 2;
+ if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 2;
+ }
+ }
+
+ const providerAliases: Record = {
+ openai: ["openai", "gpt"],
+ anthropic: ["anthropic", "claude"],
+ google: ["google", "gemini"],
+ other: [],
+ };
+ const knownProviderHints = new Set(["openai", "gpt", "anthropic", "claude", "google", "gemini"]);
+
+ const skillProviderHints = tags.filter((tag) => knownProviderHints.has(tag));
+ if (skillProviderHints.length > 0) {
+ const aliases = providerAliases[options.provider];
+ const hasProviderMatch = skillProviderHints.some((hint) => aliases.includes(hint));
+ if (hasProviderMatch) {
+ score += 2;
+ } else {
+ score -= 2;
+ }
+ }
+
+ return score;
}
export function injectSkills(options: InjectionOptions): unknown[] {
- const skills = skillRegistry.list(options.apiKeyId).filter((s) => s.enabled);
+ const contextText = buildContextText(options);
+ const contextTokens = extractTokens(contextText);
+ const backgroundTokens = extractTokens(toLowerText(options.backgroundReason));
+ const selectedSkills = skillRegistry.list(options.apiKeyId).filter((s) => {
+ const mode = s.mode || (s.enabled ? "on" : "off");
+ if (mode === "off") return false;
+ return s.enabled;
+ });
+
+ const alwaysOnSkills = selectedSkills.filter((s) => {
+ const mode = s.mode || (s.enabled ? "on" : "off");
+ return mode === "on";
+ });
+
+ const autoCandidates = selectedSkills.filter((s) => {
+ const mode = s.mode || (s.enabled ? "on" : "off");
+ return mode === "auto";
+ });
+
+ const autoSkills = autoCandidates
+ .map((skill) => ({
+ skill,
+ score: scoreAutoSkill(skill, options, contextText, contextTokens, backgroundTokens),
+ }))
+ .filter((entry) => entry.score >= AUTO_MIN_SCORE)
+ .sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ const installA = typeof a.skill.installCount === "number" ? a.skill.installCount : 0;
+ const installB = typeof b.skill.installCount === "number" ? b.skill.installCount : 0;
+ if (installB !== installA) return installB - installA;
+ return a.skill.name.localeCompare(b.skill.name);
+ })
+ .slice(0, AUTO_MAX_SKILLS)
+ .map((entry) => entry.skill);
+
+ const skills = [...alwaysOnSkills, ...autoSkills];
if (skills.length === 0) {
log.info("skills.injection.skipped", {
diff --git a/src/lib/skills/providerSettings.ts b/src/lib/skills/providerSettings.ts
new file mode 100644
index 00000000..0fa978ca
--- /dev/null
+++ b/src/lib/skills/providerSettings.ts
@@ -0,0 +1,14 @@
+import { getSettings } from "@/lib/db/settings";
+
+export type SkillsProvider = "skillsmp" | "skillssh";
+
+export const DEFAULT_SKILLS_PROVIDER: SkillsProvider = "skillsmp";
+
+export function normalizeSkillsProvider(value: unknown): SkillsProvider {
+ return value === "skillssh" || value === "skillsmp" ? value : DEFAULT_SKILLS_PROVIDER;
+}
+
+export async function getSkillsProviderSetting(): Promise {
+ const settings = (await getSettings()) as Record;
+ return normalizeSkillsProvider(settings.skillsProvider);
+}
diff --git a/src/lib/skills/registry.ts b/src/lib/skills/registry.ts
index 17f0b4b9..2ced908a 100644
--- a/src/lib/skills/registry.ts
+++ b/src/lib/skills/registry.ts
@@ -39,16 +39,27 @@ class SkillRegistry {
handler: string;
enabled?: boolean;
apiKeyId: string;
+ mode?: "on" | "off" | "auto";
+ sourceProvider?: "skillsmp" | "skillssh" | "local";
+ tags?: string[];
+ installCount?: number;
}): Promise {
- const { apiKeyId: _apiKeyId, ...parseableData } = skillData;
+ const {
+ apiKeyId: _apiKeyId,
+ mode: _mode,
+ sourceProvider: _sourceProvider,
+ tags: _tags,
+ installCount: _installCount,
+ ...parseableData
+ } = skillData;
const parsed = SkillCreateInputSchema.parse(parseableData);
const db = getDbInstance();
const id = randomUUID();
const now = new Date();
db.prepare(
- `INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ `INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, mode, source_provider, tags, install_count, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
skillData.apiKeyId,
@@ -58,6 +69,10 @@ class SkillRegistry {
JSON.stringify(parsed.schema),
parsed.handler,
parsed.enabled ? 1 : 0,
+ skillData.mode || (parsed.enabled ? "on" : "off"),
+ skillData.sourceProvider || null,
+ JSON.stringify(skillData.tags || []),
+ typeof skillData.installCount === "number" ? Math.max(0, skillData.installCount) : 0,
now.toISOString(),
now.toISOString()
);
@@ -71,6 +86,11 @@ class SkillRegistry {
schema: parsed.schema,
handler: parsed.handler,
enabled: parsed.enabled,
+ mode: skillData.mode || (parsed.enabled ? "on" : "off"),
+ sourceProvider: skillData.sourceProvider,
+ tags: skillData.tags || [],
+ installCount:
+ typeof skillData.installCount === "number" ? Math.max(0, skillData.installCount) : 0,
createdAt: now,
updatedAt: now,
};
@@ -246,6 +266,16 @@ class SkillRegistry {
: db.prepare("SELECT * FROM skills").all();
for (const row of rows as any[]) {
+ const tags = (() => {
+ try {
+ if (typeof row.tags !== "string") return [];
+ const parsed = JSON.parse(row.tags);
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
+ } catch {
+ return [];
+ }
+ })();
+
const skill: Skill = {
id: row.id,
apiKeyId: row.api_key_id,
@@ -255,6 +285,15 @@ class SkillRegistry {
schema: JSON.parse(row.schema),
handler: row.handler,
enabled: row.enabled === 1,
+ mode: row.mode === "off" || row.mode === "auto" ? row.mode : "on",
+ sourceProvider:
+ row.source_provider === "skillsmp" || row.source_provider === "skillssh"
+ ? row.source_provider
+ : row.source_provider
+ ? "local"
+ : undefined,
+ tags,
+ installCount: typeof row.install_count === "number" ? row.install_count : 0,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts
index 9bc6e622..45977b05 100644
--- a/src/lib/skills/types.ts
+++ b/src/lib/skills/types.ts
@@ -26,6 +26,10 @@ export interface Skill {
schema: SkillSchema;
handler: string;
enabled: boolean;
+ mode?: "on" | "off" | "auto";
+ sourceProvider?: "skillsmp" | "skillssh" | "local";
+ tags?: string[];
+ installCount?: number;
createdAt: Date;
updatedAt: Date;
}
diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts
index dbf3c104..49b05d73 100644
--- a/src/lib/tokenHealthCheck.ts
+++ b/src/lib/tokenHealthCheck.ts
@@ -13,6 +13,7 @@
import {
getProviderConnections,
+ getProviderConnectionById,
updateProviderConnection,
getSettings,
resolveProxyForConnection,
@@ -62,6 +63,18 @@ export function extractResolvedProxyConfig(resolvedProxy: unknown) {
return resolvedProxy ?? null;
}
+function getEffectiveTokenExpiryIso(conn: any): string | null {
+ if (!conn || typeof conn !== "object") return null;
+ return conn.tokenExpiresAt || conn.expiresAt || null;
+}
+
+function getEffectiveTokenExpiryMs(conn: any): number {
+ const effectiveExpiry = getEffectiveTokenExpiryIso(conn);
+ if (!effectiveExpiry) return 0;
+ const expiryMs = new Date(effectiveExpiry).getTime();
+ return Number.isFinite(expiryMs) ? expiryMs : 0;
+}
+
export function buildRefreshFailureUpdate(conn: any, now: string) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
@@ -246,6 +259,11 @@ async function sweep() {
* Check a single connection and refresh if due.
*/
export async function checkConnection(conn) {
+ if (!conn?.id) return;
+
+ const latestConnection = (await getProviderConnectionById(conn.id)) || conn;
+ conn = latestConnection;
+
// Determine interval (0 = disabled)
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
if (intervalMin <= 0) return;
@@ -278,22 +296,26 @@ export async function checkConnection(conn) {
const intervalMs = intervalMin * 60 * 1000;
const lastCheck = conn.lastHealthCheckAt ? new Date(conn.lastHealthCheckAt).getTime() : 0;
- // Proactive pre-expiry check (#631): if token is about to expire, refresh immediately
- // regardless of the health check interval β prevents request failures between checks
+ // Prefer expiry-driven refresh when the provider returns a concrete expiry timestamp.
+ // Rotating-token providers such as Codex should not be refreshed on a fixed hourly
+ // cadence while the access token is still valid for days.
const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
- const tokenExpiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
- const isAboutToExpire = tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
+ const tokenExpiresAt = getEffectiveTokenExpiryMs(conn);
+ const hasKnownExpiry = tokenExpiresAt > 0;
+ const isAboutToExpire = hasKnownExpiry && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
+ const shouldRefreshByInterval = !hasKnownExpiry && Date.now() - lastCheck >= intervalMs;
- // Not yet due: skip if (a) interval hasn't elapsed AND (b) token is not about to expire
- if (Date.now() - lastCheck < intervalMs && !isAboutToExpire) return;
+ if (!isAboutToExpire && !shouldRefreshByInterval) return;
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
+ const attemptedRefreshToken = conn.refreshToken;
+ const attemptedAccessToken = conn.accessToken || null;
const credentials = {
- refreshToken: conn.refreshToken,
- accessToken: conn.accessToken,
- expiresAt: conn.tokenExpiresAt,
+ refreshToken: attemptedRefreshToken,
+ accessToken: attemptedAccessToken,
+ expiresAt: getEffectiveTokenExpiryIso(conn),
providerSpecificData: conn.providerSpecificData,
};
@@ -324,6 +346,41 @@ export async function checkConnection(conn) {
// Once used, the old token is permanently invalidated.
// Retrying will never succeed β deactivate and stop the loop.
if (isUnrecoverableRefreshError(result)) {
+ const currentConnection = await getProviderConnectionById(conn.id);
+ const credentialsChangedSinceSweep =
+ !!currentConnection &&
+ (currentConnection.refreshToken !== attemptedRefreshToken ||
+ (currentConnection.accessToken || null) !== attemptedAccessToken);
+
+ if (credentialsChangedSinceSweep) {
+ await updateProviderConnection(conn.id, {
+ lastHealthCheckAt: now,
+ });
+ logWarn(
+ `${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} changed during refresh; skipping stale deactivation`
+ );
+ return;
+ }
+
+ const accessTokenStillValid =
+ getEffectiveTokenExpiryMs(currentConnection || conn) > Date.now() + TOKEN_EXPIRY_BUFFER;
+
+ if (accessTokenStillValid) {
+ await updateProviderConnection(conn.id, {
+ lastHealthCheckAt: now,
+ testStatus: "active",
+ lastError: `Health check refresh failed (${result.error}). Re-authenticate before the current access token expires.`,
+ lastErrorAt: now,
+ lastErrorType: result.error,
+ lastErrorSource: "oauth",
+ errorCode: result.error,
+ });
+ logWarn(
+ `${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${result.error}), but the current access token is still valid; keeping connection active`
+ );
+ return;
+ }
+
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "expired",
@@ -362,7 +419,12 @@ export async function checkConnection(conn) {
}
if (result.expiresIn) {
- updateData.tokenExpiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
+ updateData.expiresAt = expiresAt;
+ updateData.tokenExpiresAt = expiresAt;
+ } else if (result.expiresAt) {
+ updateData.expiresAt = result.expiresAt;
+ updateData.tokenExpiresAt = result.expiresAt;
}
if (result.providerSpecificData) {
diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts
index 10b5a0f9..6d8e1b94 100644
--- a/src/lib/usage/callLogArtifacts.ts
+++ b/src/lib/usage/callLogArtifacts.ts
@@ -1,4 +1,3 @@
-import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
@@ -63,6 +62,16 @@ export function buildArtifactRelativePath(timestamp: string, id: string) {
return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`);
}
+function computeArtifactChecksum(serialized: string): string {
+ const bytes = Buffer.from(serialized);
+ let hash = 0x811c9dc5;
+ for (const byte of bytes) {
+ hash ^= byte;
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash.toString(16).padStart(8, "0");
+}
+
export function writeCallArtifact(
artifact: CallLogArtifact,
relativePath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id)
@@ -75,10 +84,9 @@ export function writeCallArtifact(
try {
const serialized = JSON.stringify(artifact, null, 2);
const sizeBytes = Buffer.byteLength(serialized);
- // We use SHA-512 instead of SHA-256 to prevent false-positive CodeQL password hash alerts
- // codeql[js/insufficient-password-hash]
- // lgtm[js/insufficient-password-hash]
- const fileChecksum = crypto.createHash("sha512").update(serialized).digest("hex").slice(0, 64);
+ // Keep the legacy field name for storage compatibility, but use a non-cryptographic checksum
+ // so artifact bookkeeping is not treated as password hashing by static analysis.
+ const fileChecksum = computeArtifactChecksum(serialized);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(tmpPath, serialized);
diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts
index 8cc239e7..461303c3 100644
--- a/src/lib/usage/providerLimits.ts
+++ b/src/lib/usage/providerLimits.ts
@@ -28,6 +28,7 @@ interface ProviderConnectionLike {
authType?: string;
accessToken?: string;
refreshToken?: string;
+ expiresAt?: string;
tokenExpiresAt?: string;
providerSpecificData?: JsonRecord;
testStatus?: string;
@@ -90,7 +91,7 @@ async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) {
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
- expiresAt: connection.tokenExpiresAt,
+ expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
providerSpecificData: connection.providerSpecificData,
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
@@ -123,8 +124,11 @@ async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) {
updateData.refreshToken = refreshResult.refreshToken;
}
if (refreshResult.expiresIn) {
- updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
+ const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
+ updateData.expiresAt = expiresAt;
+ updateData.tokenExpiresAt = expiresAt;
} else if (refreshResult.expiresAt) {
+ updateData.expiresAt = refreshResult.expiresAt;
updateData.tokenExpiresAt = refreshResult.expiresAt;
}
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts
index 032b1b27..b20daf31 100644
--- a/src/shared/constants/cliTools.ts
+++ b/src/shared/constants/cliTools.ts
@@ -356,28 +356,96 @@ amp --model "{{model}}"
name: "Qwen Code",
icon: "psychology",
color: "#10B981",
- description: "Alibaba Qwen Code CLI β OpenAI-compatible endpoint",
- docsUrl: "https://qwenlm.github.io/qwen-code-docs/",
+ description:
+ "Alibaba Qwen Code CLI β supports OpenAI, Anthropic & Gemini providers via OmniRoute",
+ docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
configType: "guide",
defaultCommand: "qwen",
notes: [
{
type: "info",
- text: "Qwen Code supports custom OpenAI-compatible API endpoints via modelProviders in settings.json.",
+ text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. OmniRoute works as an OpenAI-compatible endpoint.",
+ },
+ {
+ type: "info",
+ text: "Any model available in OmniRoute can be used β not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.",
},
{
type: "warning",
text: "Config path: Linux/macOS ~/.qwen/settings.json β’ Windows %USERPROFILE%\\.qwen\\settings.json",
},
+ {
+ type: "error",
+ text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use OmniRoute with alicode/openrouter/anthropic/gemini providers instead.",
+ },
+ ],
+ modelAliases: [
+ "coder-model",
+ "qwen3-coder-plus",
+ "qwen3-coder-flash",
+ "vision-model",
+ "claude-sonnet-4-6",
+ "claude-opus-4-6-thinking",
+ "gemini-3-flash",
+ "gemini-3.1-pro-high",
],
- modelAliases: ["default", "claude-sonnet", "claude-opus", "gemini-pro", "gemini-flash"],
defaultModels: [
{
- id: "default",
- name: "Default Model",
- alias: "default",
+ id: "coder-model",
+ name: "Coder Model (Qwen 3.6 Plus)",
+ alias: "coder-model",
envKey: "OPENAI_MODEL",
- defaultValue: "auto",
+ defaultValue: "coder-model",
+ isTopLevel: true,
+ },
+ {
+ id: "qwen3-coder-plus",
+ name: "Qwen 3 Coder Plus",
+ alias: "qwen3-coder-plus",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "qwen3-coder-plus",
+ },
+ {
+ id: "qwen3-coder-flash",
+ name: "Qwen 3 Coder Flash",
+ alias: "qwen3-coder-flash",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "qwen3-coder-flash",
+ },
+ {
+ id: "vision-model",
+ name: "Vision Model (Multimodal)",
+ alias: "vision-model",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "vision-model",
+ },
+ {
+ id: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ alias: "claude-sonnet-4-6",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "claude-sonnet-4-6",
+ },
+ {
+ id: "claude-opus-4-6-thinking",
+ name: "Claude Opus 4.6 Thinking",
+ alias: "claude-opus-4-6-thinking",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "claude-opus-4-6-thinking",
+ },
+ {
+ id: "gemini-3.1-pro-high",
+ name: "Gemini 3.1 Pro High",
+ alias: "gemini-3.1-pro-high",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "gemini-3.1-pro-high",
+ },
+ {
+ id: "gemini-3-flash",
+ name: "Gemini 3 Flash",
+ alias: "gemini-3-flash",
+ envKey: "OPENAI_MODEL",
+ defaultValue: "gemini-3-flash",
},
],
guideSteps: [
@@ -393,17 +461,28 @@ amp --model "{{model}}"
],
codeBlock: {
language: "json",
- code: `# ~/.qwen/settings.json
+ code: `# ~/.qwen/settings.json β OmniRoute as multi-provider
{
"modelProviders": {
"openai": [{
- "id": "omniroute",
+ "id": "{{model}}",
"name": "OmniRoute",
"envKey": "OPENAI_API_KEY",
"baseUrl": "{{baseUrl}}",
- "generationConfig": {
- "defaultModel": "{{model}}"
- }
+ "generationConfig": { "contextWindowSize": 200000 }
+ }],
+ "anthropic": [{
+ "id": "claude-sonnet-4-6",
+ "name": "Claude Sonnet 4.6",
+ "envKey": "ANTHROPIC_API_KEY",
+ "baseUrl": "{{baseUrl}}",
+ "generationConfig": { "contextWindowSize": 200000 }
+ }],
+ "gemini": [{
+ "id": "gemini-3-flash",
+ "name": "Gemini 3 Flash",
+ "envKey": "GEMINI_API_KEY",
+ "baseUrl": "{{baseUrl}}"
}]
}
}`,
diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts
index 82f480a5..535477be 100644
--- a/src/shared/constants/providers.ts
+++ b/src/shared/constants/providers.ts
@@ -3,7 +3,16 @@
// Free Providers
export const FREE_PROVIDERS = {
qoder: { id: "qoder", alias: "if", name: "Qoder AI", icon: "water_drop", color: "#6366F1" },
- qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
+ qwen: {
+ id: "qwen",
+ alias: "qw",
+ name: "Qwen Code",
+ icon: "psychology",
+ color: "#10B981",
+ deprecated: true,
+ deprecationReason:
+ "Qwen OAuth free tier was discontinued on 2026-04-15. Use 'alicode', 'alicode-intl', or 'openrouter' provider with API key instead.",
+ },
"gemini-cli": {
id: "gemini-cli",
alias: "gemini-cli",
diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts
index 0d115e27..09385caa 100644
--- a/src/shared/network/outboundUrlGuard.ts
+++ b/src/shared/network/outboundUrlGuard.ts
@@ -122,8 +122,13 @@ export function parseAndValidatePublicUrl(input: string | URL) {
export function arePrivateProviderUrlsAllowed() {
const value = process.env[PRIVATE_PROVIDER_URLS_ENV];
- if (!value) return false;
- return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
+ if (value && TRUE_ENV_VALUES.has(value.trim().toLowerCase())) return true;
+
+ const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
+ if (legacyValue && ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase()))
+ return true;
+
+ return false;
}
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
diff --git a/src/shared/services/apiKeyResolver.ts b/src/shared/services/apiKeyResolver.ts
new file mode 100644
index 00000000..72124fad
--- /dev/null
+++ b/src/shared/services/apiKeyResolver.ts
@@ -0,0 +1,16 @@
+import { getApiKeyById } from "@/lib/db/apiKeys";
+
+export async function resolveApiKey(
+ apiKeyId?: string | null,
+ apiKey?: string | null
+): Promise {
+ if (apiKeyId) {
+ try {
+ const keyRecord = await getApiKeyById(apiKeyId);
+ if (keyRecord?.key) return keyRecord.key as string;
+ } catch {
+ /* fall through */
+ }
+ }
+ return apiKey || "sk_omniroute";
+}
diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts
index f1ac8caa..1672ad45 100644
--- a/src/shared/validation/settingsSchemas.ts
+++ b/src/shared/validation/settingsSchemas.ts
@@ -92,6 +92,8 @@ export const updateSettingsSchema = z.object({
.optional(),
// SkillsMP marketplace API key
skillsmpApiKey: z.string().max(200).optional(),
+ // Active skills provider (single source of truth for skills page)
+ skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
// models.dev sync settings
modelsDevSyncEnabled: z.boolean().optional(),
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index e5ec2a56..f0912205 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -162,6 +162,8 @@ export async function executeChatWithBreaker({
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
+ expiresIn: newCreds.expiresIn,
+ expiresAt: newCreds.expiresAt,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
});
diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts
index 4cd074b5..d50ee138 100644
--- a/src/sse/services/model.ts
+++ b/src/sse/services/model.ts
@@ -106,7 +106,9 @@ export async function getModelInfo(modelStr) {
*/
export async function getCombo(modelStr) {
// Check combo DB first (supports names with /)
- const combo = await getComboByName(modelStr);
+ // Strip combo/ prefix if present
+ const nameToSearch = modelStr.startsWith("combo/") ? modelStr.substring(6) : modelStr;
+ const combo = await getComboByName(nameToSearch);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts
index 8f44c19c..adee5a3e 100755
--- a/src/sse/services/tokenRefresh.ts
+++ b/src/sse/services/tokenRefresh.ts
@@ -95,8 +95,13 @@ export async function updateProviderCredentials(connectionId: string, newCredent
updates.refreshToken = newCredentials.refreshToken;
}
if (newCredentials.expiresIn) {
- updates.expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
+ const expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
+ updates.expiresAt = expiresAt;
+ updates.tokenExpiresAt = expiresAt;
updates.expiresIn = newCredentials.expiresIn;
+ } else if (newCredentials.expiresAt) {
+ updates.expiresAt = newCredentials.expiresAt;
+ updates.tokenExpiresAt = newCredentials.expiresAt;
}
if (newCredentials.providerSpecificData) {
updates.providerSpecificData = newCredentials.providerSpecificData;
diff --git a/tests/e2e/analytics-tabs.spec.ts b/tests/e2e/analytics-tabs.spec.ts
index 4d9d6a8f..cdbeca76 100644
--- a/tests/e2e/analytics-tabs.spec.ts
+++ b/tests/e2e/analytics-tabs.spec.ts
@@ -1,9 +1,23 @@
import { test, expect } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
function getTimeRangeSelector(page: import("@playwright/test").Page) {
return page.getByRole("tablist", { name: /select time range/i }).first();
}
+async function waitForAnalyticsShell(page: import("@playwright/test").Page) {
+ const mainTabList = page.locator('[role="tablist"]').first();
+ await expect(mainTabList).toBeVisible({ timeout: 15000 });
+ await expect(
+ page
+ .locator("button")
+ .filter({
+ hasText: /overview/i,
+ })
+ .first()
+ ).toBeVisible({ timeout: 15000 });
+}
+
test.describe("Analytics Tabs UI", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/usage/analytics", async (route) => {
@@ -109,11 +123,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("displays all 5 analytics tabs", async ({ page }) => {
- await page.goto("/dashboard/analytics");
- await page.waitForLoadState("networkidle");
-
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+ await gotoDashboardRoute(page, "/dashboard/analytics");
+ await waitForAnalyticsShell(page);
const mainTabList = page.locator('[role="tablist"]').first();
await expect(mainTabList).toBeVisible();
@@ -137,11 +148,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("Provider Utilization tab shows TimeRangeSelector and chart", async ({ page }) => {
- await page.goto("/dashboard/analytics");
- await page.waitForLoadState("networkidle");
-
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+ await gotoDashboardRoute(page, "/dashboard/analytics");
+ await waitForAnalyticsShell(page);
const utilizationTab = page
.locator("button")
@@ -175,11 +183,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("Combo Health tab displays health cards and metrics", async ({ page }) => {
- await page.goto("/dashboard/analytics");
- await page.waitForLoadState("networkidle");
-
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+ await gotoDashboardRoute(page, "/dashboard/analytics");
+ await waitForAnalyticsShell(page);
const comboHealthTab = page
.locator("button")
@@ -207,11 +212,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("time range change triggers network request", async ({ page }) => {
- await page.goto("/dashboard/analytics");
- await page.waitForLoadState("networkidle");
-
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+ await gotoDashboardRoute(page, "/dashboard/analytics");
+ await waitForAnalyticsShell(page);
const utilizationTab = page
.locator("button")
@@ -267,11 +269,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("tab switching persists state correctly", async ({ page }) => {
- await page.goto("/dashboard/analytics");
- await page.waitForLoadState("networkidle");
-
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+ await gotoDashboardRoute(page, "/dashboard/analytics");
+ await waitForAnalyticsShell(page);
const overviewTab = page
.locator("button")
diff --git a/tests/e2e/api-keys-flow.spec.ts b/tests/e2e/api-keys-flow.spec.ts
index 3d8dde8d..bdf5b5a8 100644
--- a/tests/e2e/api-keys-flow.spec.ts
+++ b/tests/e2e/api-keys-flow.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
const UI_STABILITY_TIMEOUT_MS = 120_000;
@@ -47,29 +48,6 @@ async function readClipboard(page: Page) {
return page.evaluate(() => (window as Window & { __clipboardValue?: string }).__clipboardValue);
}
-async function gotoOrSkip(page: Page, url: string) {
- let lastError: unknown;
- for (let attempt = 0; attempt < 2; attempt += 1) {
- try {
- await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
- } catch (error) {
- lastError = error;
- }
- try {
- await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
- await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
- lastError = null;
- break;
- } catch (error) {
- lastError = error;
- }
- await page.waitForTimeout(1000);
- }
- if (lastError) throw lastError;
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-}
-
async function waitForPageToSettle(page: Page) {
try {
await page.waitForLoadState("networkidle", { timeout: 15_000 });
@@ -180,7 +158,9 @@ test.describe("API keys flow", () => {
await fulfillJson(route, { error: "Method not allowed in api keys stub" }, 405);
});
- await gotoOrSkip(page, "/dashboard/api-manager");
+ await gotoDashboardRoute(page, "/dashboard/api-manager", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
await waitForPageToSettle(page);
await waitForNextDevCompileToFinish(page);
diff --git a/tests/e2e/combo-unification.spec.ts b/tests/e2e/combo-unification.spec.ts
index cd12c7b1..1a767a83 100644
--- a/tests/e2e/combo-unification.spec.ts
+++ b/tests/e2e/combo-unification.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
async function mockCombosPageApis(page: import("@playwright/test").Page) {
await page.route("**/api/combos", async (route) => {
@@ -135,11 +136,9 @@ test.describe("Combo Unification", () => {
});
test("combos page exposes strategy tabs and intelligent panel", async ({ page }) => {
- await page.goto("/dashboard/combos?filter=intelligent");
+ await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
await page.waitForLoadState("networkidle");
- test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
-
await expect(
page
.locator("button")
@@ -153,28 +152,24 @@ test.describe("Combo Unification", () => {
});
test("legacy auto-combo route redirects to intelligent combos filter", async ({ page }) => {
- await page.goto("/dashboard/auto-combo");
+ await gotoDashboardRoute(page, "/dashboard/auto-combo");
await page.waitForURL(/\/dashboard\/combos\?filter=intelligent/);
await expect(page).toHaveURL(/\/dashboard\/combos\?filter=intelligent/);
});
test("sidebar no longer shows auto combo entry", async ({ page }) => {
- await page.goto("/dashboard/combos");
+ await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
- test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
-
const sidebar = page.locator("aside, nav").first();
await expect(sidebar.getByText("Combos")).toBeVisible();
await expect(sidebar.getByText("Auto Combo")).toHaveCount(0);
});
test("builder shows intelligent step when auto strategy is selected", async ({ page }) => {
- await page.goto("/dashboard/combos");
+ await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
- test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
-
await page.getByRole("button", { name: /create combo/i }).click();
await page.getByLabel(/combo name/i).waitFor({ state: "visible" });
await page.getByLabel(/combo name/i).fill("e2e-auto-builder");
diff --git a/tests/e2e/combos-flow.spec.ts b/tests/e2e/combos-flow.spec.ts
index fc63cce2..e4431582 100644
--- a/tests/e2e/combos-flow.spec.ts
+++ b/tests/e2e/combos-flow.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
type ComboStub = {
id: string;
@@ -192,14 +193,13 @@ test.describe("Combos flow", () => {
});
});
- await page.goto("/dashboard/combos", { waitUntil: "domcontentloaded" });
+ await gotoDashboardRoute(page, "/dashboard/combos", {
+ waitUntil: "domcontentloaded",
+ });
await expect(
page.getByRole("button", { name: /create combo|criar combo/i }).first()
).toBeVisible();
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
await page
.getByRole("button", { name: /create combo|criar combo/i })
.first()
@@ -359,12 +359,11 @@ test.describe("Combos flow", () => {
});
});
- await page.goto("/dashboard/combos", { waitUntil: "domcontentloaded" });
+ await gotoDashboardRoute(page, "/dashboard/combos", {
+ waitUntil: "domcontentloaded",
+ });
await expect(page.getByTestId("combo-card-combo-1")).toBeVisible();
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
const comboCards = page.locator('[data-testid^="combo-card-"]');
await expect
.poll(async () =>
diff --git a/tests/e2e/ecosystem.test.ts b/tests/e2e/ecosystem.test.ts
index f8428045..8f7c8e09 100644
--- a/tests/e2e/ecosystem.test.ts
+++ b/tests/e2e/ecosystem.test.ts
@@ -249,8 +249,7 @@ describe("E2E: Stress (100 parallel requests)", () => {
// βββ Scenario 6: Security ββββββββββββββββββββββββββββββββββββββββ
describe("E2E: Security", () => {
- itCase("should reject A2A requests without auth when auth is configured", async () => {
- if (!API_KEY) return; // skip if no auth configured
+ itCase("should handle missing A2A auth according to server configuration", async () => {
const res = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -262,11 +261,15 @@ describe("E2E: Security", () => {
params: { skill: "quota-management", messages: [] },
}),
});
- expect(res.status).toBeGreaterThanOrEqual(401);
+ if (API_KEY) {
+ expect(res.status).toBeGreaterThanOrEqual(401);
+ return;
+ }
+
+ expect([200, 400]).toContain(res.status);
});
- itCase("should reject invalid API keys", async () => {
- if (!API_KEY) return;
+ itCase("should handle invalid API keys according to server configuration", async () => {
const res = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: {
@@ -280,7 +283,12 @@ describe("E2E: Security", () => {
params: { skill: "quota-management", messages: [] },
}),
});
- expect(res.status).toBeGreaterThanOrEqual(401);
+ if (API_KEY) {
+ expect(res.status).toBeGreaterThanOrEqual(401);
+ return;
+ }
+
+ expect([200, 400]).toContain(res.status);
});
itCase("should not expose internal errors in API responses", async () => {
diff --git a/tests/e2e/helpers/dashboardAuth.ts b/tests/e2e/helpers/dashboardAuth.ts
new file mode 100644
index 00000000..280d499f
--- /dev/null
+++ b/tests/e2e/helpers/dashboardAuth.ts
@@ -0,0 +1,121 @@
+import { expect, type Page } from "@playwright/test";
+
+type GotoDashboardRouteOptions = {
+ timeoutMs?: number;
+ waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
+};
+
+const DEFAULT_TIMEOUT_MS = 300_000;
+const APP_ROUTE_PATTERN = /\/(login|dashboard)(\/.*)?$/;
+const E2E_PASSWORD =
+ process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
+
+async function waitForAppRoute(page: Page, timeoutMs: number) {
+ await page.waitForURL(APP_ROUTE_PATTERN, { timeout: timeoutMs });
+ await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
+}
+
+async function finishOnboardingIfNeeded(page: Page, timeoutMs: number) {
+ if (!page.url().includes("/dashboard/onboarding")) return;
+
+ const skipWizardButton = page.getByRole("button", {
+ name: /skip wizard|skip/i,
+ });
+ await expect(skipWizardButton).toBeVisible({ timeout: timeoutMs });
+ await skipWizardButton.click();
+ await page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs });
+ await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
+}
+
+async function loginIfNeeded(page: Page, timeoutMs: number) {
+ if (!page.url().includes("/login")) return;
+
+ const passwordInput = page.locator('input[type="password"]').first();
+ await expect(passwordInput).toBeVisible({ timeout: timeoutMs });
+ await passwordInput.fill(E2E_PASSWORD);
+
+ const submitButton = page.locator("form").getByRole("button").first();
+ await expect(submitButton).toBeEnabled({ timeout: timeoutMs });
+ await Promise.all([
+ page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs }),
+ submitButton.click(),
+ ]);
+ await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
+}
+
+async function getDashboardAuthState(page: Page) {
+ return await page.evaluate(async () => {
+ const safeJson = async (response: Response) => {
+ try {
+ return await response.json();
+ } catch {
+ return null;
+ }
+ };
+
+ const [requireLoginResponse, settingsResponse] = await Promise.all([
+ fetch("/api/settings/require-login", {
+ credentials: "include",
+ cache: "no-store",
+ }),
+ fetch("/api/settings", {
+ credentials: "include",
+ cache: "no-store",
+ }),
+ ]);
+
+ const requireLoginPayload = await safeJson(requireLoginResponse);
+
+ return {
+ requireLogin: requireLoginPayload?.requireLogin === true,
+ settingsStatus: settingsResponse.status,
+ };
+ });
+}
+
+export async function gotoDashboardRoute(
+ page: Page,
+ url: string,
+ options: GotoDashboardRouteOptions = {}
+) {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const waitUntil = options.waitUntil ?? "commit";
+ let lastError: unknown;
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ await page.goto(url, { waitUntil, timeout: timeoutMs });
+ await waitForAppRoute(page, timeoutMs);
+ await finishOnboardingIfNeeded(page, timeoutMs);
+
+ if (page.url().includes("/login")) {
+ await loginIfNeeded(page, timeoutMs);
+ }
+
+ if (page.url().includes("/dashboard/onboarding") || page.url().includes("/login")) {
+ await page.goto(url, { waitUntil, timeout: timeoutMs });
+ await waitForAppRoute(page, timeoutMs);
+ await finishOnboardingIfNeeded(page, timeoutMs);
+ await loginIfNeeded(page, timeoutMs);
+ }
+
+ const authState = await getDashboardAuthState(page);
+ if (authState.requireLogin && authState.settingsStatus === 401) {
+ await page.goto("/login", { waitUntil, timeout: timeoutMs });
+ await waitForAppRoute(page, timeoutMs);
+ await loginIfNeeded(page, timeoutMs);
+ await page.goto(url, { waitUntil, timeout: timeoutMs });
+ await waitForAppRoute(page, timeoutMs);
+ await finishOnboardingIfNeeded(page, timeoutMs);
+ }
+
+ await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
+ return;
+ } catch (error) {
+ lastError = error;
+ await page.waitForTimeout(1000);
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(`Failed to open protected route ${url}`);
+}
diff --git a/tests/e2e/memory-settings.spec.ts b/tests/e2e/memory-settings.spec.ts
index ea368313..0444b6bf 100644
--- a/tests/e2e/memory-settings.spec.ts
+++ b/tests/e2e/memory-settings.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@@ -31,29 +32,6 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
});
}
-async function gotoOrSkip(page: Page, url: string) {
- let lastError: unknown;
- for (let attempt = 0; attempt < 2; attempt += 1) {
- try {
- await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
- } catch (error) {
- lastError = error;
- }
- try {
- await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
- await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
- lastError = null;
- break;
- } catch (error) {
- lastError = error;
- }
- await page.waitForTimeout(1000);
- }
- if (lastError) throw lastError;
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-}
-
async function setRangeValue(page: Page, testId: string, value: number) {
await page.getByTestId(testId).evaluate((element, nextValue) => {
const input = element as HTMLInputElement;
@@ -162,7 +140,9 @@ test.describe("Memory settings", () => {
await fulfillJson(route, { success: true });
});
- await gotoOrSkip(page, "/dashboard/settings?tab=ai");
+ await gotoDashboardRoute(page, "/dashboard/settings?tab=ai", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
let settingsHydrationRetries = 0;
await expect(async () => {
@@ -194,7 +174,9 @@ test.describe("Memory settings", () => {
);
await expect.poll(() => state.config.enabled).toBe(false);
- await gotoOrSkip(page, "/dashboard/memory");
+ await gotoDashboardRoute(page, "/dashboard/memory", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
let memoryHydrationRetries = 0;
await expect(async () => {
diff --git a/tests/e2e/protocol-clients.test.ts b/tests/e2e/protocol-clients.test.ts
index 6d5ee846..7d31b80d 100644
--- a/tests/e2e/protocol-clients.test.ts
+++ b/tests/e2e/protocol-clients.test.ts
@@ -126,9 +126,8 @@ describe("Protocol clients E2E", () => {
}
const auditRes = await apiFetch("/api/mcp/audit?limit=50&tool=omniroute_get_health");
- if (auditRes.status === 401) {
- console.warn("Skipping audit log verification (Auth required)");
- } else {
+ expect([200, 401]).toContain(auditRes.status);
+ if (auditRes.status === 200) {
expect(auditRes.ok).toBe(true);
const auditJson = await auditRes.json();
const entries = Array.isArray(auditJson?.entries) ? auditJson.entries : [];
@@ -156,7 +155,8 @@ describe("Protocol clients E2E", () => {
"protocol-send"
);
if (send.response.status === 401) {
- console.warn("Skipping A2A message send (Auth required)");
+ expect(API_KEY).toBe("");
+ expect(send.json?.error).toBeTruthy();
return;
}
expect(send.response.ok).toBe(true);
@@ -200,9 +200,8 @@ describe("Protocol clients E2E", () => {
expect([200, 400, 401, 404]).toContain(cancelRes.status);
const tasksRes = await apiFetch("/api/a2a/tasks?limit=50");
- if (tasksRes.status === 401) {
- console.warn("Skipping a2a tasks listing (Auth required)");
- } else {
+ expect([200, 401]).toContain(tasksRes.status);
+ if (tasksRes.status === 200) {
expect(tasksRes.ok).toBe(true);
const tasksJson = await tasksRes.json();
const tasks = Array.isArray(tasksJson?.tasks) ? tasksJson.tasks : [];
diff --git a/tests/e2e/protocol-visibility.spec.ts b/tests/e2e/protocol-visibility.spec.ts
index ca1e3cd2..3a4dea2b 100644
--- a/tests/e2e/protocol-visibility.spec.ts
+++ b/tests/e2e/protocol-visibility.spec.ts
@@ -1,13 +1,11 @@
import { test, expect } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
test.describe("Protocol visibility", () => {
test("shows MCP/A2A links inside protocols tab in endpoint page", async ({ page }) => {
- await page.goto("/dashboard/endpoint");
+ await gotoDashboardRoute(page, "/dashboard/endpoint");
await page.waitForLoadState("networkidle");
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
// MCP and A2A are now shown inside the "Protocols" tab β click it first
const protocolTab = page.getByRole("tab", { name: /protocols|protocolos/i });
await expect(protocolTab).toBeVisible();
@@ -24,17 +22,13 @@ test.describe("Protocol visibility", () => {
});
test("loads MCP and A2A dashboards without runtime error page", async ({ page }) => {
- await page.goto("/dashboard/mcp");
+ await gotoDashboardRoute(page, "/dashboard/mcp");
await page.waitForLoadState("networkidle");
- let redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.locator("body")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/application error|500/i);
- await page.goto("/dashboard/a2a");
+ await gotoDashboardRoute(page, "/dashboard/a2a");
await page.waitForLoadState("networkidle");
- redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.locator("body")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/application error|500/i);
});
diff --git a/tests/e2e/providers-bailian-coding-plan.spec.ts b/tests/e2e/providers-bailian-coding-plan.spec.ts
index 18ce90f3..682c78fb 100644
--- a/tests/e2e/providers-bailian-coding-plan.spec.ts
+++ b/tests/e2e/providers-bailian-coding-plan.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const DEFAULT_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
@@ -57,12 +58,9 @@ test.describe("Bailian Coding Plan Provider", () => {
});
});
- await page.goto("/dashboard/providers/bailian-coding-plan");
+ await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
@@ -175,12 +173,9 @@ test.describe("Bailian Coding Plan Provider", () => {
});
});
- await page.goto("/dashboard/providers/bailian-coding-plan");
+ await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
diff --git a/tests/e2e/providers-management.spec.ts b/tests/e2e/providers-management.spec.ts
index 27109313..73bae06b 100644
--- a/tests/e2e/providers-management.spec.ts
+++ b/tests/e2e/providers-management.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@@ -230,29 +231,6 @@ async function readProviderMockState(page: Page) {
);
}
-async function gotoOrSkip(page: Page, url: string) {
- let lastError: unknown;
- for (let attempt = 0; attempt < 2; attempt += 1) {
- try {
- await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
- } catch (error) {
- lastError = error;
- }
- try {
- await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
- await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
- lastError = null;
- break;
- } catch (error) {
- lastError = error;
- }
- await page.waitForTimeout(1000);
- }
- if (lastError) throw lastError;
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-}
-
test.describe("Providers management", () => {
test.setTimeout(600_000);
@@ -261,7 +239,9 @@ test.describe("Providers management", () => {
}) => {
await installProviderFetchMock(page);
- await gotoOrSkip(page, "/dashboard/providers");
+ await gotoDashboardRoute(page, "/dashboard/providers", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
const openAiCard = page.locator('a[href="/dashboard/providers/openai"]').first();
await expect(openAiCard).toBeVisible();
diff --git a/tests/e2e/proxy-registry.smoke.spec.ts b/tests/e2e/proxy-registry.smoke.spec.ts
index 9e2cec36..9ef48115 100644
--- a/tests/e2e/proxy-registry.smoke.spec.ts
+++ b/tests/e2e/proxy-registry.smoke.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
type ProxyStub = {
id: string;
@@ -171,12 +172,9 @@ test.describe("Proxy Registry smoke flow", () => {
});
});
- await page.goto("/dashboard/settings?tab=advanced");
+ await gotoDashboardRoute(page, "/dashboard/settings?tab=advanced");
await page.waitForLoadState("networkidle");
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
await page.getByTestId("proxy-registry-open-create").click();
diff --git a/tests/e2e/settings-toggles.spec.ts b/tests/e2e/settings-toggles.spec.ts
index f48fbe0c..8f05a14c 100644
--- a/tests/e2e/settings-toggles.spec.ts
+++ b/tests/e2e/settings-toggles.spec.ts
@@ -1,12 +1,26 @@
import { test, expect } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
test.describe("Settings Toggles", () => {
+ const waitForSettingsShell = async (page) => {
+ await expect(page.getByRole("tab", { name: /general/i }).first()).toBeVisible({
+ timeout: 15000,
+ });
+ };
+
const getDebugToggle = (page) =>
page
.getByText(/enable debug mode/i)
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
.getByRole("switch");
+ const getSidebarVisibilityToggle = (page, itemLabel: string) =>
+ page
+ .getByRole("tabpanel", { name: /appearance/i })
+ .getByText(new RegExp(`^${itemLabel}$`, "i"))
+ .locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
+ .getByRole("switch");
+
const waitForSettingsPatch = (page) =>
page.waitForResponse(
(response) =>
@@ -16,8 +30,8 @@ test.describe("Settings Toggles", () => {
);
test("Debug mode toggle should work", async ({ page }) => {
- await page.goto("/dashboard/settings");
- await page.waitForLoadState("networkidle");
+ await gotoDashboardRoute(page, "/dashboard/settings");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const debugToggle = getDebugToggle(page);
@@ -35,16 +49,16 @@ test.describe("Settings Toggles", () => {
});
test("Sidebar visibility toggle should work", async ({ page }) => {
- await page.goto("/dashboard/settings");
- await page.waitForLoadState("networkidle");
+ await gotoDashboardRoute(page, "/dashboard/settings");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /appearance/i }).click();
- const sidebarToggle = page.getByRole("switch").first();
+ const sidebarToggle = getSidebarVisibilityToggle(page, "Health");
await expect(sidebarToggle).toBeVisible({ timeout: 15000 });
const initialState = await sidebarToggle.getAttribute("aria-checked");
- await sidebarToggle.click();
+ await Promise.all([waitForSettingsPatch(page), sidebarToggle.click()]);
await expect(sidebarToggle).toHaveAttribute(
"aria-checked",
initialState === "true" ? "false" : "true",
@@ -53,8 +67,8 @@ test.describe("Settings Toggles", () => {
});
test("Clear Cache button calls DELETE /api/cache", async ({ page }) => {
- await page.goto("/dashboard/settings");
- await page.waitForLoadState("networkidle");
+ await gotoDashboardRoute(page, "/dashboard/settings");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /general/i }).click();
const clearBtn = page.getByRole("button", { name: /clear cache/i });
@@ -68,8 +82,8 @@ test.describe("Settings Toggles", () => {
});
test("Purge Expired Logs button calls POST /api/settings/purge-logs", async ({ page }) => {
- await page.goto("/dashboard/settings");
- await page.waitForLoadState("networkidle");
+ await gotoDashboardRoute(page, "/dashboard/settings");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /general/i }).click();
const purgeBtn = page.getByRole("button", { name: /purge expired logs/i });
@@ -85,8 +99,8 @@ test.describe("Settings Toggles", () => {
});
test("Debug mode should persist after page reload", async ({ page }) => {
- await page.goto("/dashboard/settings");
- await page.waitForLoadState("networkidle");
+ await gotoDashboardRoute(page, "/dashboard/settings");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const debugToggle = getDebugToggle(page);
@@ -99,7 +113,7 @@ test.describe("Settings Toggles", () => {
const nextState = initialState === "true" ? "false" : "true";
await expect(debugToggle).toHaveAttribute("aria-checked", nextState, { timeout: 15000 });
await page.reload();
- await page.waitForLoadState("networkidle");
+ await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const reloadedToggle = getDebugToggle(page);
await expect(reloadedToggle).toBeEnabled({ timeout: 15000 });
diff --git a/tests/e2e/skills-marketplace.spec.ts b/tests/e2e/skills-marketplace.spec.ts
index 9b732f2f..f37e992c 100644
--- a/tests/e2e/skills-marketplace.spec.ts
+++ b/tests/e2e/skills-marketplace.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@@ -27,29 +28,6 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
});
}
-async function gotoOrSkip(page: Page, url: string) {
- let lastError: unknown;
- for (let attempt = 0; attempt < 2; attempt += 1) {
- try {
- await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
- } catch (error) {
- lastError = error;
- }
- try {
- await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
- await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
- lastError = null;
- break;
- } catch (error) {
- lastError = error;
- }
- await page.waitForTimeout(1000);
- }
- if (lastError) throw lastError;
- const redirectedToLogin = page.url().includes("/login");
- test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
-}
-
test.describe("Skills marketplace", () => {
test.setTimeout(600_000);
@@ -159,7 +137,9 @@ test.describe("Skills marketplace", () => {
});
});
- await gotoOrSkip(page, "/dashboard/skills");
+ await gotoDashboardRoute(page, "/dashboard/skills", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
await expect(page.getByText("lookupWeather")).toBeVisible({ timeout: 15000 });
const weatherCard = page
@@ -173,15 +153,16 @@ test.describe("Skills marketplace", () => {
await expect.poll(() => state.toggleCalls).toBe(1);
await page.getByRole("button", { name: /marketplace/i }).click();
- await expect(page.getByPlaceholder("Search skills...")).toBeVisible({ timeout: 15000 });
+ const marketplaceSearch = page.getByPlaceholder(/Search SkillsMP\.\.\./i);
+ await expect(marketplaceSearch).toBeVisible({ timeout: 15000 });
- await page.getByPlaceholder("Search skills...").fill("weather");
+ await marketplaceSearch.fill("weather");
await page.getByRole("button", { name: /search skillsmp/i }).click();
await expect(page.getByText("Weather Pro")).toBeVisible({ timeout: 15000 });
await page.getByRole("button", { name: /^install$/i }).click();
await expect.poll(() => state.marketplaceInstalls).toBe(1);
- await page.getByPlaceholder("Search skills...").fill("");
+ await marketplaceSearch.fill("");
await page.getByRole("button", { name: /search skillsmp/i }).click();
const lastMarketplaceSkill = page.getByText("Skill Page 12").last();
await lastMarketplaceSkill.scrollIntoViewIfNeeded();
diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts
index 88391f89..13adcd92 100644
--- a/tests/integration/chatcore-compression-integration.test.ts
+++ b/tests/integration/chatcore-compression-integration.test.ts
@@ -12,6 +12,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-compression-sec
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const readCacheDb = await import("../../src/lib/db/readCache.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { estimateTokens, getTokenLimit } = await import("../../open-sse/services/contextManager.ts");
const { resetAllAvailability } = await import("../../src/domain/modelAvailability.ts");
@@ -329,3 +330,100 @@ test("chatCore integration: compression handles tool messages", async () => {
globalThis.fetch = originalFetch;
}
});
+
+test("chatCore integration: combo requests run proactive compression before Kiro translation", async () => {
+ const provider = "kiro";
+ const model = "claude-sonnet-4.5";
+
+ const connectionId = await providersDb.createProviderConnection({
+ provider,
+ apiKey: "test-key",
+ isActive: true,
+ });
+
+ await combosDb.createCombo({
+ name: "test-kiro-compression-combo",
+ strategy: "priority",
+ models: [
+ {
+ kind: "model",
+ model: `${provider}/${model}`,
+ connectionId,
+ },
+ ],
+ });
+
+ const body = {
+ model: "combo/test-kiro-compression-combo",
+ stream: false,
+ messages: [
+ { role: "system", content: "You are helpful." },
+ { role: "user", content: "x".repeat(50000) },
+ { role: "assistant", content: "Ack 1" },
+ { role: "user", content: "x".repeat(50000) },
+ { role: "assistant", content: "Ack 2" },
+ { role: "user", content: "x".repeat(50000) },
+ { role: "assistant", content: "Ack 3" },
+ { role: "user", content: "Please summarize everything." },
+ ],
+ };
+
+ let capturedTranslatedBody: Record | null = null;
+ globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
+ if (init?.body) {
+ capturedTranslatedBody = JSON.parse(init.body as string) as Record;
+ }
+ return new Response(
+ JSON.stringify({
+ choices: [{ message: { role: "assistant", content: "ok" } }],
+ usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 },
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }
+ );
+ };
+
+ try {
+ const result = await handleChatCore({
+ body,
+ modelInfo: { provider, model },
+ credentials: { apiKey: "test-key" },
+ log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
+ clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() },
+ connectionId,
+ isCombo: true,
+ comboName: "test-kiro-compression-combo",
+ });
+
+ // Kiro response translation in this integration harness may fail depending on upstream
+ // payload shape, but the regression target is request-side behavior before translation.
+ assert.ok(result, "Handler should return a result object");
+ assert.ok(capturedTranslatedBody, "Translated body should be sent upstream");
+
+ // Ensure request was translated to Kiro shape (messages are not sent directly upstream).
+ const conversationState = capturedTranslatedBody?.conversationState as
+ | Record
+ | undefined;
+ assert.ok(conversationState, "Kiro translated request should include conversationState");
+
+ const history = Array.isArray(conversationState?.history)
+ ? (conversationState.history as unknown[])
+ : [];
+ assert.ok(
+ history.length < body.messages.length - 1,
+ "History should be reduced by proactive compression before translation"
+ );
+
+ const currentMessage = conversationState?.currentMessage as Record | undefined;
+ const userInputMessage = currentMessage?.userInputMessage as
+ | Record
+ | undefined;
+ const currentContent =
+ typeof userInputMessage?.content === "string" ? userInputMessage.content : "";
+ assert.match(currentContent, /Please summarize everything\./);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts
index c34681ea..df01b34c 100644
--- a/tests/integration/security-hardening.test.ts
+++ b/tests/integration/security-hardening.test.ts
@@ -213,6 +213,19 @@ test("MCP server enforces scopes from caller context before tool execution", ()
);
});
+test("ACP agents route requires management authentication before CLI discovery", () => {
+ const content = readIfExists("src/app/api/acp/agents/route.ts");
+ assert.ok(content, "src/app/api/acp/agents/route.ts should exist");
+ assert.ok(
+ content.includes('from "@/shared/utils/apiAuth"'),
+ "ACP agents route should import shared API auth"
+ );
+ assert.ok(
+ content.includes("if (!(await isAuthenticated(request)))"),
+ "ACP agents route should reject unauthenticated requests before spawning discovery"
+ );
+});
+
test("T06 route payload validation uses validateBody in critical endpoints", () => {
const targets = [
"src/app/api/usage/budget/route.ts",
diff --git a/tests/integration/skills-pipeline.test.ts b/tests/integration/skills-pipeline.test.ts
index 71180a84..136aa30c 100644
--- a/tests/integration/skills-pipeline.test.ts
+++ b/tests/integration/skills-pipeline.test.ts
@@ -51,6 +51,9 @@ async function registerSkill({
handler,
enabled = true,
description = "Test skill",
+ mode,
+ tags,
+ installCount,
}) {
return skillRegistry.register({
apiKeyId,
@@ -71,6 +74,9 @@ async function registerSkill({
},
handler,
enabled,
+ mode,
+ tags,
+ installCount,
});
}
@@ -383,6 +389,65 @@ test("injectSkills() merges with existing tools without duplicating", async () =
assert.ok(names.includes("preExistingTool"));
});
+test("responses input context participates in AUTO skill injection", async () => {
+ await seedConnection("openai", { apiKey: "sk-openai-skills-responses-input" });
+ const apiKey = await seedApiKey();
+ await enableSkills();
+
+ await registerSkill({
+ apiKeyId: apiKey.id,
+ name: "issueSearch",
+ handler: "issue-search-handler",
+ description: "search github issues and pull requests",
+ mode: "auto",
+ tags: ["github", "issues", "search"],
+ installCount: 40,
+ });
+
+ await registerSkill({
+ apiKeyId: apiKey.id,
+ name: "calendarPlanner",
+ handler: "calendar-handler",
+ description: "manage meetings and calendars",
+ mode: "auto",
+ tags: ["calendar", "meeting"],
+ installCount: 100,
+ });
+
+ const fetchBodies = [];
+ globalThis.fetch = async (_url, init = {}) => {
+ fetchBodies.push(init.body ? JSON.parse(String(init.body)) : null);
+ return buildOpenAIResponse("AUTO skill selection via responses input");
+ };
+
+ const response = await handleChat(
+ buildRequest({
+ url: "http://localhost/v1/responses",
+ authKey: apiKey.key,
+ body: {
+ model: "openai/gpt-4o-mini",
+ stream: false,
+ input: [
+ {
+ role: "user",
+ content: [{ type: "input_text", text: "Please search github issues for flaky tests" }],
+ },
+ ],
+ },
+ })
+ );
+
+ assert.equal(response.status, 200);
+ assert.ok(Array.isArray(fetchBodies[0].tools));
+
+ const names = fetchBodies[0].tools
+ .map((tool) => tool?.function?.name)
+ .filter((name) => typeof name === "string");
+
+ assert.ok(names.includes("issueSearch@1.0.0"));
+ assert.ok(!names.includes("calendarPlanner@1.0.0"));
+});
+
test("handleToolCallExecution() processes a tool call correctly", async () => {
const { handleToolCallExecution } = await import("../../src/lib/skills/interception.ts");
diff --git a/tests/unit/acp-agents-route.test.ts b/tests/unit/acp-agents-route.test.ts
new file mode 100644
index 00000000..58f98816
--- /dev/null
+++ b/tests/unit/acp-agents-route.test.ts
@@ -0,0 +1,102 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { SignJWT } from "jose";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-acp-agents-route-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "acp-agents-route-api-key-secret";
+
+const core = await import("../../src/lib/db/core.ts");
+const localDb = await import("../../src/lib/localDb.ts");
+const routeModule = await import("../../src/app/api/acp/agents/route.ts");
+
+const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
+const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
+
+async function resetStorage() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+ delete process.env.INITIAL_PASSWORD;
+ delete process.env.JWT_SECRET;
+}
+
+async function createSessionToken() {
+ const secret = new TextEncoder().encode(process.env.JWT_SECRET);
+ return new SignJWT({ authenticated: true })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime("1h")
+ .sign(secret);
+}
+
+function makeRequest(method: string, body?: unknown, token?: string) {
+ return new Request("http://localhost/api/acp/agents", {
+ method,
+ headers: {
+ ...(body ? { "content-type": "application/json" } : {}),
+ ...(token ? { cookie: `auth_token=${token}` } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(async () => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+
+ if (ORIGINAL_INITIAL_PASSWORD === undefined) {
+ delete process.env.INITIAL_PASSWORD;
+ } else {
+ process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
+ }
+
+ if (ORIGINAL_JWT_SECRET === undefined) {
+ delete process.env.JWT_SECRET;
+ } else {
+ process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
+ }
+});
+
+test("GET /api/acp/agents requires authentication when login is enabled", async () => {
+ process.env.INITIAL_PASSWORD = "route-auth-required";
+
+ const response = await routeModule.GET(makeRequest("GET"));
+ const body = await response.json();
+
+ assert.equal(response.status, 401);
+ assert.equal(body.error, "Unauthorized");
+});
+
+test("POST /api/acp/agents rejects unsafe version commands for authenticated sessions", async () => {
+ process.env.JWT_SECRET = "acp-agents-jwt-secret";
+ await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
+ const token = await createSessionToken();
+
+ const response = await routeModule.POST(
+ makeRequest(
+ "POST",
+ {
+ id: "custom-agent",
+ name: "Custom Agent",
+ binary: "/usr/local/bin/custom-agent",
+ versionCommand: "/usr/local/bin/custom-agent --version; touch /tmp/pwned",
+ providerAlias: "custom-agent",
+ spawnArgs: [],
+ protocol: "stdio",
+ },
+ token
+ )
+ );
+ const body = await response.json();
+
+ assert.equal(response.status, 400);
+ assert.match(body.error, /Invalid versionCommand/i);
+});
diff --git a/tests/unit/acp-registry.test.ts b/tests/unit/acp-registry.test.ts
new file mode 100644
index 00000000..6e5d2f66
--- /dev/null
+++ b/tests/unit/acp-registry.test.ts
@@ -0,0 +1,42 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { resolveVersionProbe, shouldUseShellForVersionProbe } =
+ await import("../../src/lib/acp/registry.ts");
+
+test("resolveVersionProbe parses quoted binary paths without shell semantics", () => {
+ const probe = resolveVersionProbe(
+ "/tmp/My Custom Agent",
+ '"/tmp/My Custom Agent" --version',
+ true
+ );
+
+ assert.deepEqual(probe, {
+ command: "/tmp/My Custom Agent",
+ args: ["--version"],
+ });
+});
+
+test("resolveVersionProbe rejects custom version commands that switch binaries", () => {
+ const probe = resolveVersionProbe("/tmp/custom-agent", 'bash -lc "id"', true);
+ assert.equal(probe, null);
+});
+
+test("resolveVersionProbe rejects shell metacharacters in version commands", () => {
+ const probe = resolveVersionProbe(
+ "/tmp/custom-agent",
+ "/tmp/custom-agent --version; touch /tmp/pwned",
+ true
+ );
+ assert.equal(probe, null);
+});
+
+test("shouldUseShellForVersionProbe preserves Windows npm wrapper detection", () => {
+ assert.equal(shouldUseShellForVersionProbe("codex", "win32"), true);
+ assert.equal(
+ shouldUseShellForVersionProbe("C:\\Users\\dev\\AppData\\Roaming\\npm\\qwen.cmd", "win32"),
+ true
+ );
+ assert.equal(shouldUseShellForVersionProbe("C:\\Tools\\claude.exe", "win32"), false);
+ assert.equal(shouldUseShellForVersionProbe("codex", "linux"), false);
+});
diff --git a/tests/unit/api-key-mask-fix.test.mjs b/tests/unit/api-key-mask-fix.test.mjs
new file mode 100644
index 00000000..d7cf4ec6
--- /dev/null
+++ b/tests/unit/api-key-mask-fix.test.mjs
@@ -0,0 +1,225 @@
+/**
+ * Unit tests for the masked API key fix (#523).
+ *
+ * GET /api/keys returns masked API keys (e.g. "sk-31c4e****8600").
+ * CLI tool card dropdowns used `key.key` (the masked value) as the select
+ * option value, so the masked key got written to config files, causing 401s.
+ *
+ * The fix: frontends send `keyId` (DB row id) instead, and backends resolve
+ * the full key from DB via `resolveApiKey()`.
+ *
+ * This test inlines the resolver logic (ESM modules are read-only) with a
+ * mock DB lookup function.
+ */
+import test, { describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+// βββ Mock DB + inlined resolveApiKey ββββββββββββββββββββββββββββββββββββ
+
+/** In-memory key store keyed by id */
+const mockKeyStore = new Map();
+
+/**
+ * Mock getApiKeyById β mirrors the real function's contract:
+ * returns the key record (with .key field) or null.
+ */
+async function getApiKeyById(id) {
+ return mockKeyStore.get(id) || null;
+}
+
+/**
+ * Inlined resolveApiKey from src/shared/services/apiKeyResolver.ts
+ * (can't import ESM modules in test runner without tsx overhead).
+ */
+async function resolveApiKey(apiKeyId, apiKey) {
+ if (apiKeyId) {
+ try {
+ const keyRecord = await getApiKeyById(apiKeyId);
+ if (keyRecord?.key) return keyRecord.key;
+ } catch {
+ /* fall through */
+ }
+ }
+ return apiKey || "sk_omniroute";
+}
+
+// βββ Server-side masking function (matches /api/keys endpoint) βββββββββ
+
+/**
+ * Mask an API key for display: first 8 chars + "****" + last 4 chars.
+ * This is the server-side masking that created the original bug.
+ */
+function maskApiKey(key) {
+ if (!key || key.length <= 12) return key;
+ return key.slice(0, 8) + "****" + key.slice(-4);
+}
+
+// βββ Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe("resolveApiKey", () => {
+ it("resolves full key from apiKeyId when DB lookup succeeds", async () => {
+ mockKeyStore.clear();
+ mockKeyStore.set("key-001", { id: "key-001", key: "sk-31c4eabcd1234efgh8600" });
+
+ const result = await resolveApiKey("key-001", null);
+ assert.equal(result, "sk-31c4eabcd1234efgh8600");
+ });
+
+ it("falls back to apiKey when apiKeyId lookup returns null", async () => {
+ mockKeyStore.clear();
+
+ const result = await resolveApiKey("nonexistent-id", "sk-fallback-key");
+ assert.equal(result, "sk-fallback-key");
+ });
+
+ it("falls back to apiKey when apiKeyId lookup throws", async () => {
+ mockKeyStore.clear();
+ // Override getApiKeyById to throw
+ const originalGet = getApiKeyById;
+ const throwingGet = async () => {
+ throw new Error("DB connection failed");
+ };
+
+ // Temporarily replace
+ const savedRef = mockKeyStore.get.bind(mockKeyStore);
+ // We'll call resolveApiKey with a custom approach β since the inlined
+ // function calls our local getApiKeyById, let's just test by setting
+ // up the store to throw via a different mechanism
+ // Actually, let's just test the inline function directly with a mock:
+ async function resolveApiKeyWithThrowingDb(apiKeyId, apiKey) {
+ if (apiKeyId) {
+ try {
+ throw new Error("DB connection failed");
+ } catch {
+ /* fall through */
+ }
+ }
+ return apiKey || "sk_omniroute";
+ }
+
+ const result = await resolveApiKeyWithThrowingDb("key-001", "sk-fallback-key");
+ assert.equal(result, "sk-fallback-key");
+ });
+
+ it("falls back to sk_omniroute when both are null", async () => {
+ mockKeyStore.clear();
+
+ const result = await resolveApiKey(null, null);
+ assert.equal(result, "sk_omniroute");
+ });
+
+ it("falls back to sk_omniroute when both are undefined", async () => {
+ mockKeyStore.clear();
+
+ const result = await resolveApiKey(undefined, undefined);
+ assert.equal(result, "sk_omniroute");
+ });
+
+ it("prefers resolved key from apiKeyId over masked apiKey", async () => {
+ mockKeyStore.clear();
+ mockKeyStore.set("key-002", { id: "key-002", key: "sk-fullkey1234567890abcdef" });
+
+ // The masked apiKey is what /api/keys returns β should NOT be used
+ const maskedKey = maskApiKey("sk-fullkey1234567890abcdef");
+ const result = await resolveApiKey("key-002", maskedKey);
+ assert.equal(result, "sk-fullkey1234567890abcdef");
+ assert.notEqual(result, maskedKey);
+ });
+});
+
+describe("maskApiKey", () => {
+ it("masks a long key correctly", () => {
+ const result = maskApiKey("sk-31c4eabcd1234efgh8600");
+ assert.equal(result, "sk-31c4e****8600");
+ });
+
+ it("does not mask short keys", () => {
+ const result = maskApiKey("sk-short12");
+ assert.equal(result, "sk-short12");
+ });
+
+ it("handles null/undefined gracefully", () => {
+ assert.equal(maskApiKey(null), null);
+ assert.equal(maskApiKey(undefined), undefined);
+ });
+
+ it("produces a key that is NOT usable for auth", () => {
+ const fullKey = "sk-31c4eabcd1234efgh8600";
+ const masked = maskApiKey(fullKey);
+ assert.notEqual(masked, fullKey);
+ assert.ok(masked.includes("****"));
+ assert.ok(masked.startsWith(fullKey.slice(0, 8)));
+ assert.ok(masked.endsWith(fullKey.slice(-4)));
+ });
+});
+
+describe("Bug reproduction: masked key written to config", () => {
+ it("reproduces the original bug β masked key fails auth", () => {
+ const fullKey = "sk-31c4eabcd1234efgh8600";
+ const masked = maskApiKey(fullKey);
+
+ // Simulating what happened before the fix: dropdown used masked key as value
+ // and sent it directly to the backend, which wrote it to config
+ const writtenToConfig = masked; // BUG: masked key saved to config
+
+ // Auth with masked key would fail
+ assert.notEqual(writtenToConfig, fullKey);
+ assert.ok(writtenToConfig.includes("****"));
+
+ // This proves the bug: the config file contains "sk-31c4e****8600"
+ // which is NOT a valid API key and would cause 401 errors
+ });
+
+ it("verifies the fix β keyId resolves to full key", async () => {
+ mockKeyStore.clear();
+ const fullKey = "sk-31c4eabcd1234efgh8600";
+ mockKeyStore.set("key-003", { id: "key-003", key: fullKey });
+
+ // After the fix: frontend sends keyId, backend resolves full key
+ const resolved = await resolveApiKey("key-003", null);
+ assert.equal(resolved, fullKey);
+ assert.ok(!resolved.includes("****"));
+ });
+
+ it("simulates full flow: masked dropdown -> keyId -> resolved full key", async () => {
+ mockKeyStore.clear();
+ const fullKey = "sk-31c4eabcd1234efgh8600";
+ const keyId = "key-004";
+ mockKeyStore.set(keyId, { id: keyId, key: fullKey });
+
+ // Step 1: /api/keys returns masked list
+ const apiKeysResponse = [{ id: keyId, key: maskApiKey(fullKey) }];
+
+ // Step 2: Frontend dropdown now uses key.id as value (not key.key)
+ const selectedValue = apiKeysResponse[0].id; // "key-004" (was key.key before fix)
+ assert.equal(selectedValue, keyId);
+
+ // Step 3: Frontend sends keyId to backend
+ const requestBody = { keyId: selectedValue };
+
+ // Step 4: Backend resolves full key from DB
+ const resolvedKey = await resolveApiKey(requestBody.keyId, null);
+ assert.equal(resolvedKey, fullKey);
+ assert.ok(!resolvedKey.includes("****"));
+ });
+
+ it("handles prefix/suffix matching for restoring saved key from file", () => {
+ const fullKey = "sk-31c4eabcd1234efgh8600";
+ const masked = maskApiKey(fullKey);
+
+ // Simulates what ClaudeToolCard does when reading a key from file:
+ // The file contains the full key, and we match against the masked list
+ const fileKeyPrefix = fullKey.slice(0, 8); // "sk-31c4e"
+ const fileKeySuffix = fullKey.slice(-4); // "8600"
+
+ const apiKeysResponse = [{ id: "key-005", key: masked }];
+
+ // Match by prefix/suffix
+ const matchedKey = apiKeysResponse.find(
+ (k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
+ );
+
+ assert.ok(matchedKey);
+ assert.equal(matchedKey.id, "key-005");
+ });
+});
diff --git a/tests/unit/cache-control-policy.test.ts b/tests/unit/cache-control-policy.test.ts
index abf6456f..c731a3b1 100644
--- a/tests/unit/cache-control-policy.test.ts
+++ b/tests/unit/cache-control-policy.test.ts
@@ -15,6 +15,7 @@ describe("Cache Control Policy", () => {
assert.equal(isClaudeCodeClient("claude-code/0.1.0"), true);
assert.equal(isClaudeCodeClient("claude_code/0.1.0"), true);
assert.equal(isClaudeCodeClient("Anthropic CLI/1.0"), true);
+ assert.equal(isClaudeCodeClient("claude-cli/2.1.113 (external, sdk-cli)"), true);
});
test("rejects non-Claude clients", () => {
diff --git a/tests/unit/call-log-file-rotation.test.ts b/tests/unit/call-log-file-rotation.test.ts
index f2b050a2..8530f595 100644
--- a/tests/unit/call-log-file-rotation.test.ts
+++ b/tests/unit/call-log-file-rotation.test.ts
@@ -69,6 +69,14 @@ function insertCallLog(row) {
);
}
+function buildArtifactRelPath(date: Date, label: string) {
+ const dateFolder = date.toISOString().slice(0, 10);
+ const timePart = `${String(date.getUTCHours()).padStart(2, "0")}${String(
+ date.getUTCMinutes()
+ ).padStart(2, "0")}${String(date.getUTCSeconds()).padStart(2, "0")}`;
+ return `${dateFolder}/${timePart}_${label}_200.json`;
+}
+
test.beforeEach(async () => {
await resetTestDataDir();
});
@@ -105,10 +113,17 @@ test("call log file rotation honors both retention days and file count", () => {
fs.rmSync(CALL_LOGS_DIR, { recursive: true, force: true });
fs.mkdirSync(CALL_LOGS_DIR, { recursive: true });
- const oldRelPath = "2026-03-01/080000_old_200.json";
- const keepARelPath = "2026-04-12/090000_keep-a_200.json";
- const keepBRelPath = "2026-04-13/091000_keep-b_200.json";
- const keepCRelPath = "2026-04-14/092000_keep-c_200.json";
+ const now = Date.now();
+ const oneDay = 24 * 60 * 60 * 1000;
+ const oldDate = new Date(now - 10 * oneDay);
+ const keepADate = new Date(now - 3 * oneDay);
+ const keepBDate = new Date(now - 2 * oneDay);
+ const keepCDate = new Date(now - oneDay);
+
+ const oldRelPath = buildArtifactRelPath(oldDate, "old");
+ const keepARelPath = buildArtifactRelPath(keepADate, "keep-a");
+ const keepBRelPath = buildArtifactRelPath(keepBDate, "keep-b");
+ const keepCRelPath = buildArtifactRelPath(keepCDate, "keep-c");
for (const relativePath of [oldRelPath, keepARelPath, keepBRelPath, keepCRelPath]) {
const absolutePath = path.join(CALL_LOGS_DIR, relativePath);
@@ -118,27 +133,24 @@ test("call log file rotation honors both retention days and file count", () => {
insertCallLog({
id: "old-log",
- timestamp: "2026-03-01T08:00:00.000Z",
+ timestamp: oldDate.toISOString(),
artifact_relpath: oldRelPath,
});
insertCallLog({
id: "keep-a",
- timestamp: "2026-04-12T09:00:00.000Z",
+ timestamp: keepADate.toISOString(),
artifact_relpath: keepARelPath,
});
insertCallLog({
id: "keep-b",
- timestamp: "2026-04-13T09:10:00.000Z",
+ timestamp: keepBDate.toISOString(),
artifact_relpath: keepBRelPath,
});
insertCallLog({
id: "keep-c",
- timestamp: "2026-04-14T09:20:00.000Z",
+ timestamp: keepCDate.toISOString(),
artifact_relpath: keepCRelPath,
});
-
- const now = Date.now();
- const oneDay = 24 * 60 * 60 * 1000;
fs.utimesSync(
path.join(CALL_LOGS_DIR, oldRelPath),
new Date(now - 10 * oneDay),
diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts
index 6f3ced17..c9be1396 100644
--- a/tests/unit/cc-compatible-provider.test.ts
+++ b/tests/unit/cc-compatible-provider.test.ts
@@ -120,12 +120,12 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
assert.equal(payload.messages[0].content.at(-1).cache_control, undefined);
assert.equal(payload.messages[1].content.at(-1).cache_control, undefined);
assert.equal(payload.messages[2].content.at(-1).cache_control, undefined);
- assert.equal(payload.system.length, 4);
- assert.equal(payload.system.at(-1).text, "sys");
+ assert.equal(payload.system.length, 2);
+ assert.match(payload.system[0].text, /Claude Agent SDK/);
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system[1].cache_control, undefined);
- assert.equal(payload.system[2].cache_control, undefined);
- assert.equal(payload.system[3].cache_control, undefined);
+ assert.equal(payload.system[1].text, "sys");
+ assert.equal(payload.system[1].cache_control, undefined);
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0], {
name: "lookup_weather",
@@ -139,7 +139,7 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
},
});
assert.deepEqual(payload.tool_choice, { type: "any" });
- assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015");
+ assert.equal(payload.context_management, undefined);
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
});
@@ -212,8 +212,7 @@ test("buildClaudeCodeCompatibleRequest preserves Claude cache markers when reque
preserveCacheControl: true,
});
- assert.equal(payload.system[0].cache_control, undefined);
- assert.deepEqual(payload.system.at(-1).cache_control, { type: "ephemeral", ttl: "5m" });
+ assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" });
assert.deepEqual(payload.messages[1].content[0].cache_control, {
type: "ephemeral",
@@ -294,11 +293,8 @@ test("buildClaudeCodeCompatibleRequest keeps built-in system blocks untagged whe
preserveCacheControl: true,
});
- assert.equal(payload.system[0].cache_control, undefined);
- assert.equal(payload.system[1].cache_control, undefined);
- assert.equal(payload.system[2].cache_control, undefined);
- assert.deepEqual(payload.system[3].cache_control, { type: "ephemeral" });
- assert.deepEqual(payload.system[4].cache_control, { type: "ephemeral", ttl: "1h" });
+ assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral" });
+ assert.deepEqual(payload.system[1].cache_control, { type: "ephemeral", ttl: "1h" });
});
test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserve mode", () => {
@@ -325,10 +321,9 @@ test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserv
preserveCacheControl: false,
});
+ assert.equal(payload.system.length, 2);
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system[1].cache_control, undefined);
- assert.equal(payload.system[2].cache_control, undefined);
- assert.equal(payload.system[3].cache_control, undefined);
assert.equal(payload.messages[0].content[0].cache_control, undefined);
assert.equal(payload.messages[1].content[0].cache_control, undefined);
assert.equal(payload.messages[2].content[0].cache_control, undefined);
@@ -436,10 +431,10 @@ test("DefaultExecutor uses CC-compatible path and headers", () => {
);
const headers = executor.buildHeaders(credentials, true);
- assert.equal(headers["x-api-key"], "sk-test");
+ assert.equal(headers.Authorization, "Bearer sk-test");
+ assert.equal(headers["x-api-key"], undefined);
assert.equal(headers["X-Claude-Code-Session-Id"], "session-3");
- assert.equal(headers.Accept, "text/event-stream");
- assert.equal(headers.Authorization, undefined);
+ assert.equal(headers.Accept, "application/json");
});
test("validateProviderApiKey uses CC skeleton request after /models fallback", async () => {
@@ -478,8 +473,9 @@ test("validateProviderApiKey uses CC skeleton request after /models fallback", a
assert.equal(calls[1].body.model, "claude-sonnet-4-6");
assert.equal(calls[1].body.messages[0].role, "user");
assert.equal(calls[1].body.stream, true);
- assert.equal(calls[1].headers["x-api-key"], "sk-test");
- assert.equal(calls[1].headers.Accept, "text/event-stream");
+ assert.equal(calls[1].headers.Authorization, "Bearer sk-test");
+ assert.equal(calls[1].headers["x-api-key"], undefined);
+ assert.equal(calls[1].headers.Accept, "application/json");
});
test("handleChatCore forces SSE upstream for CC compatible providers while returning JSON to non-stream clients", async () => {
@@ -557,7 +553,7 @@ test("handleChatCore forces SSE upstream for CC compatible providers while retur
assert.equal(result.success, true);
assert.equal(calls.length, 1);
- assert.equal(calls[0].headers.Accept, "text/event-stream");
+ assert.equal(calls[0].headers.Accept, "application/json");
assert.equal(calls[0].body.stream, true);
assert.equal(JSON.stringify(calls[0].body).includes('"cache_control"'), false);
@@ -671,8 +667,7 @@ test("handleChatCore preserves client cache markers for Claude Code requests to
assert.equal(result.success, true);
assert.equal(calls.length, 1);
- assert.equal(calls[0].body.system[0].cache_control, undefined);
- assert.deepEqual(calls[0].body.system.at(-1).cache_control, {
+ assert.deepEqual(calls[0].body.system[0].cache_control, {
type: "ephemeral",
ttl: "5m",
});
diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts
index e01cd573..5c448359 100644
--- a/tests/unit/chatcore-sanitization.test.ts
+++ b/tests/unit/chatcore-sanitization.test.ts
@@ -535,6 +535,45 @@ test("chatCore skips memory injection when memory is disabled or apiKeyInfo is m
assert.equal(noApiKey.call.body.messages[0].content, "Hello");
});
+test("chatCore does not share or persist memories when apiKeyInfo is missing", async () => {
+ await settingsDb.updateSettings({
+ memoryEnabled: true,
+ memoryMaxTokens: 1024,
+ memoryRetentionDays: 30,
+ memoryStrategy: "recent",
+ });
+ invalidateMemorySettingsCache();
+
+ await createMemory({
+ apiKeyId: "local",
+ sessionId: "shared-local-session",
+ type: "factual",
+ key: "pref:theme",
+ content: "Shared local memory should stay isolated.",
+ metadata: {},
+ expiresAt: null,
+ });
+
+ const { call } = await invokeChatCore({
+ body: {
+ model: "gpt-4o-mini",
+ messages: [{ role: "user", content: "I prefer blue themes." }],
+ },
+ });
+
+ await waitForAsyncMemoryFlush();
+
+ const localMemoriesResult = await listMemories({ apiKeyId: "local" });
+ const localMemories = Array.isArray(localMemoriesResult)
+ ? localMemoriesResult
+ : (localMemoriesResult.data ?? []);
+
+ assert.equal(call.body.messages[0].role, "user");
+ assert.equal(call.body.messages[0].content, "I prefer blue themes.");
+ assert.equal(localMemories.length, 1);
+ assert.equal(localMemories[0].content, "Shared local memory should stay isolated.");
+});
+
test("chatCore skips memory injection when shouldInjectMemory returns false for empty message lists", async () => {
await settingsDb.updateSettings({
memoryEnabled: true,
@@ -642,3 +681,64 @@ test("chatCore extracts memories from Claude content arrays and Responses output
assert.equal(responsesMemories.length, 1);
assert.equal(responsesMemories[0].content, "TypeScript for backend services");
});
+
+test("chatCore request memory extraction for responses input ignores assistant items", async () => {
+ await settingsDb.updateSettings({
+ memoryEnabled: true,
+ memoryMaxTokens: 1024,
+ memoryRetentionDays: 30,
+ memoryStrategy: "recent",
+ });
+ invalidateMemorySettingsCache();
+
+ const responsesKeyId = `key-responses-request-memory-${Date.now()}`;
+ const responsesResult = await invokeChatCore({
+ endpoint: "/v1/responses",
+ apiKeyInfo: { id: responsesKeyId, name: "Responses Request Memory Key" },
+ body: {
+ model: "gpt-4o-mini",
+ input: [
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "I prefer tea." }],
+ },
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "input_text", text: "I prefer coffee." }],
+ },
+ ],
+ },
+ responseFactory: () =>
+ new Response(
+ JSON.stringify({
+ id: "resp_request_memory",
+ object: "response",
+ status: "completed",
+ model: "gpt-4o-mini",
+ output_text: "ok",
+ usage: {
+ input_tokens: 4,
+ output_tokens: 1,
+ total_tokens: 5,
+ },
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ ),
+ });
+
+ assert.equal(responsesResult.result.success, true);
+
+ await waitForAsyncMemoryFlush();
+
+ const memoriesResult = await listMemories({ apiKeyId: responsesKeyId });
+ const memories = Array.isArray(memoriesResult) ? memoriesResult : (memoriesResult.data ?? []);
+
+ assert.equal(memories.length, 1);
+ assert.match(memories[0].content, /tea/i);
+ assert.doesNotMatch(memories[0].content, /coffee/i);
+});
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index e765850b..dc173d0f 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -287,8 +287,8 @@ async function invokeChatCore({
connectionId = null,
onCredentialsRefreshed = null,
onRequestSuccess = null,
-} = {}) {
- const calls = [];
+}: any = {}) {
+ const calls: any[] = [];
globalThis.fetch = async (url, init = {}) => {
const headers = toPlainHeaders(init.headers);
@@ -334,7 +334,7 @@ async function invokeChatCore({
comboStrategy,
onCredentialsRefreshed,
onRequestSuccess,
- });
+ } as any);
await waitForAsyncSideEffects();
return { result, calls, call: calls.at(-1) };
@@ -507,9 +507,11 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers"
});
assert.equal(result.success, true);
- assert.equal(call.headers.Accept ?? call.headers.accept, "text/event-stream");
+ assert.equal(call.headers.Accept ?? call.headers.accept, "application/json");
assert.equal(call.body.stream, true);
- assert.equal(call.body.context_management.edits[0].type, "clear_thinking_20251015");
+ assert.equal(call.body.context_management, undefined);
+ assert.equal(call.body.system.length, 1);
+ assert.match(call.body.system[0].text, /Claude Agent SDK/);
assert.equal(typeof call.body.metadata.user_id, "string");
assert.equal(call.body.messages[0].role, "user");
assert.equal(call.body.messages[0].content[0].text, "Ping");
@@ -581,7 +583,12 @@ test("chatCore auto cache policy becomes false for nondeterministic combos", asy
responseFormat: "claude",
});
- assert.equal(call.body.system[0].text, "system");
+ assert.equal(
+ call.body.system.some(
+ (block: { type?: string; text?: string }) => block?.type === "text" && block.text === "system"
+ ),
+ true
+ );
// Cache markers are kept natively due to the latest Claude strict proxy passthrough implementation
assert.equal(
call.body.system.some((block) => !!block.cache_control),
@@ -635,7 +642,12 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an
responseFormat: "claude",
});
- assert.equal(call.body.system[0].text, "system");
+ assert.equal(
+ call.body.system.some(
+ (block: { type?: string; text?: string }) => block?.type === "text" && block.text === "system"
+ ),
+ true
+ );
// Cache preservation is on for native Claude, so cache markers are intact
assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral" });
// Tools disable flag is applied
diff --git a/tests/unit/claude-code-compatible-helpers.test.ts b/tests/unit/claude-code-compatible-helpers.test.ts
index 4f822eab..724ad1a8 100644
--- a/tests/unit/claude-code-compatible-helpers.test.ts
+++ b/tests/unit/claude-code-compatible-helpers.test.ts
@@ -41,12 +41,13 @@ test("base URL helpers strip messages suffixes and join canonical paths", () =>
);
});
-test("buildClaudeCodeCompatibleHeaders emits stream-aware auth headers and session id", () => {
+test("buildClaudeCodeCompatibleHeaders emits bearer auth headers and session id", () => {
const streamHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", true, "session-123");
const jsonHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", false);
- assert.equal(streamHeaders.Accept, "text/event-stream");
- assert.equal(streamHeaders["x-api-key"], "sk-demo");
+ assert.equal(streamHeaders.Accept, "application/json");
+ assert.equal(streamHeaders.Authorization, "Bearer sk-demo");
+ assert.equal(streamHeaders["x-api-key"], undefined);
assert.equal(streamHeaders["X-Claude-Code-Session-Id"], "session-123");
assert.equal(
streamHeaders["X-Stainless-Timeout"],
@@ -56,13 +57,19 @@ test("buildClaudeCodeCompatibleHeaders emits stream-aware auth headers and sessi
assert.equal(jsonHeaders["X-Claude-Code-Session-Id"], undefined);
});
-test("Claude Code compatible beta set stays conservative for third-party proxies", () => {
- assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("oauth-2025-04-20"));
- assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("advanced-tool-use-2025-11-20"));
- assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("fast-mode-2026-02-01"));
- assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("token-efficient-tools-2026-03-28"));
- assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("fast-mode-2025-04-01"), false);
- assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("redact-thinking-2025-06-20"), false);
+test("Claude Code compatible beta set matches the stable API-key Claude CLI profile", () => {
+ assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("claude-code-20250219"));
+ assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("interleaved-thinking-2025-05-14"));
+ assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("effort-2025-11-24"));
+ assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("oauth-2025-04-20"), false);
+ assert.equal(
+ CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("context-management-2025-06-27"),
+ false
+ );
+ assert.equal(
+ CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("prompt-caching-scope-2026-01-05"),
+ false
+ );
});
test("resolveClaudeCodeCompatibleSessionId prefers explicit session headers and generates a fallback id", () => {
@@ -91,8 +98,8 @@ test("buildClaudeCodeCompatibleValidationPayload produces the expected smoke-tes
content: [{ type: "text", text: "ok" }],
});
assert.equal(payload.tools.length, 0);
- assert.equal(payload.system[0].cache_control, undefined);
+ assert.equal(payload.system.length, 1);
+ assert.match(payload.system[0].text, /Claude Agent SDK/);
assert.ok(JSON.parse(payload.metadata.user_id).session_id);
- assert.ok(payload.system.some((block) => String(block.text).includes(process.cwd())));
assert.ok(CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS > payload.max_tokens);
});
diff --git a/tests/unit/claude-code-compatible-request.test.ts b/tests/unit/claude-code-compatible-request.test.ts
index 441002d9..19c94c30 100644
--- a/tests/unit/claude-code-compatible-request.test.ts
+++ b/tests/unit/claude-code-compatible-request.test.ts
@@ -114,8 +114,10 @@ test("buildClaudeCodeCompatibleRequest covers normalized OpenAI-style messages,
content: [{ type: "text", text: "draft answer\nalternate answer" }],
},
]);
- assert.equal(payload.system[3].text, "system note");
- assert.equal(payload.system[4].text, "developer note");
+ assert.equal(payload.system.length, 3);
+ assert.match(payload.system[0].text, /Claude Agent SDK/);
+ assert.equal(payload.system[1].text, "system note");
+ assert.equal(payload.system[2].text, "developer note");
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0], {
name: "lookup_account",
@@ -157,6 +159,8 @@ test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-con
{ name: "toolA", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
],
thinking: { type: "enabled", budget_tokens: 12 },
+ output_config: { format: "compact" },
+ metadata: { foo: "bar" },
},
model: "claude-sonnet-4-6",
preserveCacheControl: false,
@@ -194,11 +198,12 @@ test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-con
assert.equal(stripped.messages.at(-1).role, "user");
assert.equal(stripped.system[0].cache_control, undefined);
assert.equal(stripped.messages[0].content[0].cache_control, undefined);
- assert.equal(stripped.system.at(-1).cache_control, undefined);
assert.equal(stripped.tools[0].cache_control, undefined);
- assert.equal(preserved.system[0].cache_control, undefined);
+ assert.deepEqual(stripped.thinking, { type: "enabled", budget_tokens: 12 });
+ assert.deepEqual(stripped.output_config, { effort: "high", format: "compact" });
+ assert.equal(stripped.metadata.foo, "bar");
+ assert.deepEqual(preserved.system[0].cache_control, { type: "ephemeral" });
assert.equal(preserved.messages[0].content[0].cache_control.type, "ephemeral");
- assert.equal(preserved.system.at(-1).cache_control.type, "ephemeral");
assert.equal(preserved.tools[0].cache_control.type, "ephemeral");
});
@@ -217,6 +222,8 @@ test("buildClaudeCodeCompatibleRequest omits tool choice when there are no tools
assert.equal(payload.tools.length, 0);
assert.equal("tool_choice" in payload, false);
assert.equal(payload.output_config.effort, "high");
+ assert.equal(payload.system.length, 1);
+ assert.match(payload.system[0].text, /Claude Agent SDK/);
});
test("buildClaudeCodeCompatibleRequest covers string system input, non-array Claude fields and tool choice variants", () => {
diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts
index 7e6bfdd1..c3e26727 100644
--- a/tests/unit/combo-routing-engine.test.ts
+++ b/tests/unit/combo-routing-engine.test.ts
@@ -213,7 +213,7 @@ test("validateComboDAG enforces maximum nesting depth", () => {
});
test("handleComboChat priority strategy defaults to first model and records success metrics", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "priority-default",
models: ["openai/gpt-4o-mini", "claude/sonnet"],
@@ -229,6 +229,7 @@ test("handleComboChat priority strategy defaults to first model and records succ
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -250,7 +251,7 @@ test("handleComboChat priority strategy defaults to first model and records succ
});
test("handleComboChat priority strategy honors composite tier order before fallback", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "priority-composite-tiers",
strategy: "priority",
@@ -304,6 +305,7 @@ test("handleComboChat priority strategy honors composite tier order before fallb
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -314,7 +316,7 @@ test("handleComboChat priority strategy honors composite tier order before fallb
test("handleComboChat weighted strategy selects by weight and falls back in descending weight order", async () => {
const originalRandom = Math.random;
- const calls = [];
+ const calls: any[] = [];
Math.random = () => 0.95;
@@ -338,6 +340,7 @@ test("handleComboChat weighted strategy selects by weight and falls back in desc
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -351,7 +354,7 @@ test("handleComboChat weighted strategy selects by weight and falls back in desc
test("handleComboChat weighted strategy falls back to uniform random when all weights are zero", async () => {
const originalRandom = Math.random;
- const calls = [];
+ const calls: any[] = [];
Math.random = () => 0.75;
try {
@@ -373,6 +376,7 @@ test("handleComboChat weighted strategy falls back to uniform random when all we
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -386,7 +390,7 @@ test("handleComboChat weighted strategy falls back to uniform random when all we
test("handleComboChat random strategy uses shuffled model order", async () => {
const originalRandom = Math.random;
- const calls = [];
+ const calls: any[] = [];
const sequence = [0.99, 0.0];
let idx = 0;
Math.random = () => sequence[idx++] ?? 0;
@@ -406,6 +410,7 @@ test("handleComboChat random strategy uses shuffled model order", async () => {
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -434,7 +439,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded
strategy: "least-used",
});
- const calls = [];
+ const calls: any[] = [];
await handleComboChat({
body: {},
@@ -450,6 +455,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -458,7 +464,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded
});
test("handleComboChat skips unavailable models and falls through to the next active target", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -473,6 +479,7 @@ test("handleComboChat skips unavailable models and falls through to the next act
isModelAvailable: async (modelStr) => modelStr !== "model-a",
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -482,7 +489,7 @@ test("handleComboChat skips unavailable models and falls through to the next act
});
test("handleComboChat falls through empty successful responses and records failure metrics before succeeding", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -501,6 +508,7 @@ test("handleComboChat falls through empty successful responses and records failu
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -517,7 +525,7 @@ test("handleComboChat falls through empty successful responses and records failu
});
test("handleComboChat records per-target metrics separately when the same model repeats with different accounts", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "per-target-repeat",
strategy: "priority",
@@ -553,6 +561,7 @@ test("handleComboChat records per-target metrics separately when the same model
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -591,6 +600,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -602,7 +612,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e
});
test("handleComboChat round-robin rotates sequentially across requests", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "rr-sequence",
strategy: "round-robin",
@@ -621,6 +631,7 @@ test("handleComboChat round-robin rotates sequentially across requests", async (
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -632,7 +643,7 @@ test("handleComboChat round-robin rotates sequentially across requests", async (
});
test("handleComboChat round-robin starts from composite tier default ordering", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "rr-composite-order",
strategy: "round-robin",
@@ -680,6 +691,7 @@ test("handleComboChat round-robin starts from composite tier default ordering",
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -747,6 +759,7 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -769,13 +782,14 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
assert.equal(responsesResult.ok, true);
- const calls = [];
+ const calls: any[] = [];
const malformedResult = await handleComboChat({
body: {},
combo: {
@@ -800,6 +814,7 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -825,6 +840,7 @@ test("handleComboChat accepts text-mode SSE payloads as valid non-streaming pass
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -834,7 +850,7 @@ test("handleComboChat accepts text-mode SSE payloads as valid non-streaming pass
});
test("handleComboChat falls through invalid JSON and embedded 200 error bodies before succeeding", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -862,6 +878,7 @@ test("handleComboChat falls through invalid JSON and embedded 200 error bodies b
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -897,6 +914,7 @@ test("handleComboChat returns the earliest retry-after when all priority targets
settings: {
comboDefaults: { maxRetries: 0, retryDelayMs: 1 },
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -927,6 +945,7 @@ test("handleComboChat returns 404 model_not_found when a combo has no executable
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -959,6 +978,7 @@ test("handleComboChat round-robin returns 404 when no models are configured", as
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -978,7 +998,7 @@ test("handleComboChat round-robin falls through semaphore timeouts and malformed
timeoutMs: 100,
}
);
- const calls = [];
+ const calls: any[] = [];
try {
const result = await handleComboChat({
@@ -1005,6 +1025,7 @@ test("handleComboChat round-robin falls through semaphore timeouts and malformed
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1048,6 +1069,7 @@ test("handleComboChat round-robin surfaces retry-after metadata after exhausting
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1060,7 +1082,7 @@ test("handleComboChat round-robin surfaces retry-after metadata after exhausting
});
test("handleComboChat round-robin keeps generic 400 errors terminal", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1089,6 +1111,7 @@ test("handleComboChat round-robin keeps generic 400 errors terminal", async () =
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1099,7 +1122,7 @@ test("handleComboChat round-robin keeps generic 400 errors terminal", async () =
});
test("handleComboChat round-robin falls through provider-scoped 400s and returns the final error payload when no target recovers", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1131,6 +1154,7 @@ test("handleComboChat round-robin falls through provider-scoped 400s and returns
retryDelayMs: 1,
},
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1142,7 +1166,7 @@ test("handleComboChat round-robin falls through provider-scoped 400s and returns
});
test("handleComboChat strict-random uses the shared deck without repeating within a cycle", async () => {
- const calls = [];
+ const calls: any[] = [];
const combo = {
name: "strict-random-deck",
strategy: "strict-random",
@@ -1160,6 +1184,7 @@ test("handleComboChat strict-random uses the shared deck without repeating withi
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1179,7 +1204,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in
},
});
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -1194,6 +1219,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1204,7 +1230,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in
test("handleComboChat weighted strategy resolves nested combos before falling back to the next weighted target", async () => {
const originalRandom = Math.random;
- const calls = [];
+ const calls: any[] = [];
Math.random = () => 0.01;
try {
@@ -1227,6 +1253,7 @@ test("handleComboChat weighted strategy resolves nested combos before falling ba
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: [
{
name: "weighted-nested-selection",
@@ -1255,7 +1282,7 @@ test("handleComboChat context-optimized orders models by the largest synced cont
},
});
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -1270,6 +1297,7 @@ test("handleComboChat context-optimized orders models by the largest synced cont
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1292,6 +1320,7 @@ test("handleComboChat returns a 503 when every model is unavailable before execu
isModelAvailable: async () => false,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1326,6 +1355,7 @@ test("handleComboChat returns the circuit-breaker unavailable response when all
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1337,7 +1367,7 @@ test("handleComboChat returns the circuit-breaker unavailable response when all
test("handleComboChat auto strategy honors LKGP after filtering to tool-capable models", async () => {
await settingsDb.setLKGP("auto-lkgp", "auto-lkgp", "claude");
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {
messages: [{ role: "user", content: "Write code using a tool" }],
@@ -1357,6 +1387,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1368,7 +1399,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable
test("handleComboChat standalone lkgp strategy prioritizes the last known good provider", async () => {
await settingsDb.setLKGP("standalone-lkgp", "standalone-lkgp", "anthropic");
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -1384,6 +1415,7 @@ test("handleComboChat standalone lkgp strategy prioritizes the last known good p
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1393,7 +1425,7 @@ test("handleComboChat standalone lkgp strategy prioritizes the last known good p
});
test("handleComboChat standalone lkgp strategy falls back to original order when no state exists", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
combo: {
@@ -1409,6 +1441,7 @@ test("handleComboChat standalone lkgp strategy falls back to original order when
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1430,6 +1463,7 @@ test("handleComboChat standalone lkgp strategy updates LKGP after a successful c
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1453,7 +1487,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter
},
});
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {
input: [{ role: "user", text: "Summarize this request" }],
@@ -1475,6 +1509,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter
intentSimpleMaxWords: 5,
intentExtraSimpleKeywords: "summarize, brief",
},
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1493,7 +1528,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str
});
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: { prompt: "Hello there" },
combo: {
@@ -1509,6 +1544,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str
isModelAvailable: async () => true,
log,
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1523,7 +1559,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str
});
test("handleComboChat auto strategy reads strategyName from combo.config.auto and can prefer latency", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: { prompt: "Just answer briefly" },
combo: {
@@ -1543,6 +1579,7 @@ test("handleComboChat auto strategy reads strategyName from combo.config.auto an
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1552,7 +1589,7 @@ test("handleComboChat auto strategy reads strategyName from combo.config.auto an
});
test("handleComboChat context cache protection pins the model and tags tool-call responses", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {
model: "openai/gpt-4o-mini",
@@ -1591,6 +1628,7 @@ test("handleComboChat context cache protection pins the model and tags tool-call
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1622,6 +1660,7 @@ test("handleComboChat context cache protection sanitizes streamed text tags from
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1651,6 +1690,7 @@ test("handleComboChat context cache protection injects a hidden tag for tool-cal
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1674,6 +1714,7 @@ test("handleComboChat context cache protection flushes cleanly when a stream end
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1700,6 +1741,7 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh
isModelAvailable: async () => false,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: [
{ name: "rr-nested-inactive", models: ["nested-combo"] },
{ name: "nested-combo", models: ["openai/model-a"] },
@@ -1737,6 +1779,7 @@ test("handleComboChat round-robin returns circuit-breaker unavailable when every
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1746,7 +1789,7 @@ test("handleComboChat round-robin returns circuit-breaker unavailable when every
});
test("handleComboChat round-robin retries a transient failure on the same model before succeeding", async () => {
- const calls = [];
+ const calls: any[] = [];
let attempts = 0;
const result = await handleComboChat({
@@ -1766,6 +1809,7 @@ test("handleComboChat round-robin retries a transient failure on the same model
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1775,7 +1819,7 @@ test("handleComboChat round-robin retries a transient failure on the same model
});
test("handleComboChat round-robin recovers from provider-scoped 400s when a later model succeeds", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1801,6 +1845,7 @@ test("handleComboChat round-robin recovers from provider-scoped 400s when a late
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1810,7 +1855,7 @@ test("handleComboChat round-robin recovers from provider-scoped 400s when a late
});
test("handleComboChat falls back to next model when first model returns all-accounts-rate-limited 503", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1838,6 +1883,7 @@ test("handleComboChat falls back to next model when first model returns all-acco
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1851,7 +1897,7 @@ test("handleComboChat falls back to next model when first model returns all-acco
});
test("handleComboChat round-robin falls back when all-accounts-rate-limited 503 is returned", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1878,6 +1924,7 @@ test("handleComboChat round-robin falls back when all-accounts-rate-limited 503
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
@@ -1889,7 +1936,7 @@ test("handleComboChat round-robin falls back when all-accounts-rate-limited 503
});
test("handleComboChat aborts combo when 503 response does NOT contain the unavailable signal", async () => {
- const calls = [];
+ const calls: any[] = [];
const result = await handleComboChat({
body: {},
@@ -1911,6 +1958,7 @@ test("handleComboChat aborts combo when 503 response does NOT contain the unavai
isModelAvailable: async () => true,
log: createLog(),
settings: null,
+ relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
diff --git a/tests/unit/db-core-init.test.ts b/tests/unit/db-core-init.test.ts
index f953a635..fa9ce838 100644
--- a/tests/unit/db-core-init.test.ts
+++ b/tests/unit/db-core-init.test.ts
@@ -705,6 +705,9 @@ test(
assert.ok(
db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("026")
);
+ assert.ok(
+ db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("027")
+ );
assert.equal(
db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ? AND name = ?")
diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts
index b9eeb9fa..a76ddfba 100644
--- a/tests/unit/executor-default-base.test.ts
+++ b/tests/unit/executor-default-base.test.ts
@@ -316,10 +316,11 @@ test("DefaultExecutor.buildHeaders rotates extra API keys and builds Claude Code
assert.equal(first.Authorization, "Bearer primary");
assert.equal(second.Authorization, "Bearer extra-1");
- assert.equal(ccHeaders["x-api-key"], "cc-key");
+ assert.equal(ccHeaders.Authorization, "Bearer cc-key");
+ assert.equal(ccHeaders["x-api-key"], undefined);
assert.equal(ccHeaders["anthropic-version"], CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION);
assert.equal(ccHeaders["X-Claude-Code-Session-Id"], "session-1");
- assert.equal(ccHeaders.Accept, "text/event-stream");
+ assert.equal(ccHeaders.Accept, "application/json");
assert.equal(ccJsonHeaders.Accept, "application/json");
});
diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts
index dad4e98d..cb44150d 100644
--- a/tests/unit/guide-settings-route.test.ts
+++ b/tests/unit/guide-settings-route.test.ts
@@ -3,13 +3,21 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { NextResponse } from "next/server";
-
const guideSettingsRoute =
await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts");
const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-qwen-test-" + Date.now());
const QWEN_CONFIG_PATH = path.join(DUMMY_HOME, ".qwen", "settings.json");
+const QWEN_ENV_PATH = path.join(DUMMY_HOME, ".qwen", ".env");
+
+type QwenProviderEntry = {
+ id?: string;
+ baseUrl?: string;
+ envKey?: string;
+ generationConfig?: {
+ contextWindowSize?: number;
+ };
+};
function buildRequest(body: any) {
return new Request("http://localhost/api/cli-tools/guide-settings/qwen", {
@@ -40,11 +48,19 @@ test("guide-settings POST creates new qwen settings.json if it doesn't exist", a
const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8"));
assert.ok(content.modelProviders.openai);
- const omniProvider = content.modelProviders.openai.find((p: any) => p.id === "omniroute");
+ const omniProvider = content.modelProviders.openai.find(
+ (p: QwenProviderEntry) => p.baseUrl === "http://my-omni"
+ );
assert.ok(omniProvider);
+ assert.equal(omniProvider.id, "qwen-max");
assert.equal(omniProvider.baseUrl, "http://my-omni");
- assert.equal(omniProvider.apiKey, "sk-123");
- assert.equal(omniProvider.generationConfig.defaultModel, "qwen-max");
+ assert.equal(omniProvider.envKey, "OPENAI_API_KEY");
+ assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000);
+
+ const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8");
+ assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m);
+ assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m);
+ assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m);
});
test("guide-settings POST merges into existing qwen settings.json", async () => {
@@ -66,11 +82,22 @@ test("guide-settings POST merges into existing qwen settings.json", async () =>
const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8"));
assert.equal(content.modelProviders.openai.length, 2);
- const otherProvider = content.modelProviders.openai.find((p: any) => p.id === "other");
+ const otherProvider = content.modelProviders.openai.find(
+ (p: QwenProviderEntry) => p.id === "other"
+ );
assert.ok(otherProvider);
assert.equal(otherProvider.baseUrl, "https://other");
- const omniProvider = content.modelProviders.openai.find((p: any) => p.id === "omniroute");
+ const omniProvider = content.modelProviders.openai.find(
+ (p: QwenProviderEntry) => p.baseUrl === "http://my-omni"
+ );
assert.ok(omniProvider);
- assert.equal(omniProvider.generationConfig.defaultModel, "auto"); // default fallback
+ assert.equal(omniProvider.id, "auto");
+ assert.equal(omniProvider.envKey, "OPENAI_API_KEY");
+ assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000);
+
+ const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8");
+ assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m);
+ assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m);
+ assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m);
});
diff --git a/tests/unit/memory-store.test.ts b/tests/unit/memory-store.test.ts
index 7a3eef18..79d65fcf 100644
--- a/tests/unit/memory-store.test.ts
+++ b/tests/unit/memory-store.test.ts
@@ -248,15 +248,7 @@ test("listMemories applies query filtering before pagination and type stats", as
assert.deepEqual(filtered.byType, { factual: 1, semantic: 1 });
});
-// ---------------------------------------------------------------------------
-// Pagination via page parameter (page-based, complementing the offset tests above)
-// SKIPPED: These tests require insertMemoryRow() which triggers a pre-existing
-// SQLITE_MISMATCH error in the test environment (same issue that affects 7 of
-// the 9 original tests above). The pagination logic itself is covered by the
-// pure-function tests in tests/unit/pagination.test.ts.
-// ---------------------------------------------------------------------------
-
-test.skip("listMemories supports page-based pagination (page 1)", async () => {
+test("listMemories supports page-based pagination (page 1)", async () => {
insertMemoryRow({
id: "pg-1",
content: "first",
@@ -284,7 +276,7 @@ test.skip("listMemories supports page-based pagination (page 1)", async () => {
assert.equal(page1.total, 3);
});
-test.skip("listMemories supports page-based pagination (page 2 returns remainder)", async () => {
+test("listMemories supports page-based pagination (page 2 returns remainder)", async () => {
insertMemoryRow({
id: "pg-1",
content: "first",
@@ -312,7 +304,7 @@ test.skip("listMemories supports page-based pagination (page 2 returns remainder
assert.equal(page2.total, 3);
});
-test.skip("listMemories returns empty data for a page beyond the result set", async () => {
+test("listMemories returns empty data for a page beyond the result set", async () => {
insertMemoryRow({
id: "pg-1",
content: "only entry",
@@ -325,7 +317,7 @@ test.skip("listMemories returns empty data for a page beyond the result set", as
assert.equal(beyondPage.total, 1);
});
-test.skip("listMemories page parameter defaults to page 1 when omitted with limit", async () => {
+test("listMemories page parameter defaults to page 1 when omitted with limit", async () => {
insertMemoryRow({
id: "pg-1",
content: "first",
diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts
index 83b9f0f8..bfb075ed 100644
--- a/tests/unit/provider-service.test.ts
+++ b/tests/unit/provider-service.test.ts
@@ -58,7 +58,7 @@ test("Anthropic-compatible Claude Code providers use the Claude Code URL and hea
assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-demo"), true);
assert.equal(url, "https://proxy.example.com/v1/messages?beta=true");
- assert.equal(headers["x-api-key"], "anthropic-token");
+ assert.equal(headers["Authorization"], "Bearer anthropic-token");
assert.equal(headers.Accept, "application/json");
assert.equal(headers["X-Claude-Code-Session-Id"], "session-123");
assert.equal(headers["anthropic-version"], "2023-06-01");
diff --git a/tests/unit/skills-injection.test.ts b/tests/unit/skills-injection.test.ts
index e9e3b985..51eca71a 100644
--- a/tests/unit/skills-injection.test.ts
+++ b/tests/unit/skills-injection.test.ts
@@ -139,3 +139,134 @@ test("detectProvider maps known model families and falls back to other", () => {
assert.equal(detectProvider("gemini-2.5-pro"), "google");
assert.equal(detectProvider("custom-router-model"), "other");
});
+
+test("injectSkills auto mode matches message/context semantics and applies score threshold", async () => {
+ await skillRegistry.register({
+ name: "issueSearch",
+ version: "1.0.0",
+ description: "search github issues and pull requests",
+ schema: { input: { query: "string" }, output: { results: [] } },
+ handler: "search-handler",
+ enabled: true,
+ mode: "auto",
+ tags: ["github", "issues", "search"],
+ installCount: 42,
+ apiKeyId: "key-auto",
+ });
+
+ await skillRegistry.register({
+ name: "calendarPlanner",
+ version: "1.0.0",
+ description: "manage calendar scheduling",
+ schema: { input: {}, output: {} },
+ handler: "calendar-handler",
+ enabled: true,
+ mode: "auto",
+ tags: ["calendar", "meeting"],
+ installCount: 99,
+ apiKeyId: "key-auto",
+ });
+
+ const tools = injectSkills({
+ provider: "openai",
+ apiKeyId: "key-auto",
+ messages: [{ role: "user", content: "Please search github issues for flaky tests" }],
+ existingTools: [],
+ });
+
+ assert.equal(Array.isArray(tools), true);
+ assert.equal(tools.length, 1);
+ assert.deepEqual(tools[0], {
+ type: "function",
+ function: {
+ name: "issueSearch@1.0.0",
+ description: "search github issues and pull requests",
+ parameters: { query: "string" },
+ },
+ });
+});
+
+test("injectSkills auto mode prefers provider-matching tagged skills", async () => {
+ await skillRegistry.register({
+ name: "openaiDocTool",
+ version: "1.0.0",
+ description: "openai docs lookup",
+ schema: { input: {}, output: {} },
+ handler: "openai-docs",
+ enabled: true,
+ mode: "auto",
+ tags: ["openai", "docs", "lookup"],
+ apiKeyId: "key-provider",
+ });
+
+ await skillRegistry.register({
+ name: "claudeDocTool",
+ version: "1.0.0",
+ description: "anthropic docs lookup",
+ schema: { input: {}, output: {} },
+ handler: "claude-docs",
+ enabled: true,
+ mode: "auto",
+ tags: ["anthropic", "docs", "lookup"],
+ apiKeyId: "key-provider",
+ });
+
+ const tools = injectSkills({
+ provider: "openai",
+ apiKeyId: "key-provider",
+ existingTools: [{ type: "function", function: { name: "docs_lookup" } }],
+ messages: [{ role: "user", content: "lookup docs" }],
+ });
+
+ // Provider match is a ranking signal, not a hard exclusion rule.
+ // Ensure openai-tagged skill is prioritized first.
+ assert.equal(tools.length, 3);
+ const injectedNames = tools
+ .filter(
+ (tool): tool is { function: { name: string } } =>
+ !!tool && typeof tool === "object" && "function" in tool
+ )
+ .map((tool) => tool.function.name);
+ assert.equal(injectedNames[0], "openaiDocTool@1.0.0");
+ assert.equal(injectedNames.includes("claudeDocTool@1.0.0"), true);
+});
+
+test("injectSkills auto mode limits selected auto skills and keeps on-mode skills", async () => {
+ await skillRegistry.register({
+ name: "alwaysOnUtility",
+ version: "1.0.0",
+ description: "always available utility",
+ schema: { input: {}, output: {} },
+ handler: "always-on",
+ enabled: true,
+ mode: "on",
+ apiKeyId: "key-limit",
+ });
+
+ for (let i = 0; i < 7; i++) {
+ await skillRegistry.register({
+ name: `searchSkill${i}`,
+ version: "1.0.0",
+ description: "search docs and files",
+ schema: { input: {}, output: {} },
+ handler: `search-${i}`,
+ enabled: true,
+ mode: "auto",
+ tags: ["search", "docs"],
+ installCount: i,
+ apiKeyId: "key-limit",
+ });
+ }
+
+ const tools = injectSkills({
+ provider: "openai",
+ apiKeyId: "key-limit",
+ messages: [{ role: "user", content: "search docs and files quickly" }],
+ });
+
+ // 1 always-on + max 5 auto
+ assert.equal(tools.length, 6);
+ const names = tools.map((tool) => (tool as { function: { name: string } }).function.name);
+ assert.equal(names.includes("alwaysOnUtility@1.0.0"), true);
+ assert.equal(names.filter((name) => name.startsWith("searchSkill")).length, 5);
+});
diff --git a/tests/unit/skills-provider-setting.test.ts b/tests/unit/skills-provider-setting.test.ts
new file mode 100644
index 00000000..182882ee
--- /dev/null
+++ b/tests/unit/skills-provider-setting.test.ts
@@ -0,0 +1,18 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { normalizeSkillsProvider, DEFAULT_SKILLS_PROVIDER } =
+ await import("../../src/lib/skills/providerSettings.ts");
+
+test("normalizeSkillsProvider keeps valid values", () => {
+ assert.equal(normalizeSkillsProvider("skillsmp"), "skillsmp");
+ assert.equal(normalizeSkillsProvider("skillssh"), "skillssh");
+});
+
+test("normalizeSkillsProvider falls back for invalid values", () => {
+ assert.equal(DEFAULT_SKILLS_PROVIDER, "skillsmp");
+ assert.equal(normalizeSkillsProvider(undefined), "skillsmp");
+ assert.equal(normalizeSkillsProvider(null), "skillsmp");
+ assert.equal(normalizeSkillsProvider(""), "skillsmp");
+ assert.equal(normalizeSkillsProvider("invalid"), "skillsmp");
+});
diff --git a/tests/unit/skills-skillssh.test.ts b/tests/unit/skills-skillssh.test.ts
index 84a9ded6..45536523 100644
--- a/tests/unit/skills-skillssh.test.ts
+++ b/tests/unit/skills-skillssh.test.ts
@@ -9,6 +9,7 @@ const originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = tmpDir;
const core = await import("../../src/lib/db/core.ts");
+const settingsDb = await import("../../src/lib/db/settings.ts");
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
const { searchSkillsSh, fetchSkillMd, SkillsShSearchResponseSchema, SkillsShSkillSchema } =
await import("../../src/lib/skills/skillssh.ts");
@@ -30,8 +31,9 @@ function resetStorage() {
const originalFetch = globalThis.fetch;
-test.beforeEach(() => {
+test.beforeEach(async () => {
resetStorage();
+ await settingsDb.updateSettings({ skillsProvider: "skillssh", requireLogin: false });
globalThis.fetch = originalFetch;
});
diff --git a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts
index af517ad0..192020bf 100644
--- a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts
+++ b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts
@@ -1,12 +1,18 @@
/**
- * T43: Gemini tool call parts must preserve thoughtSignature correctly.
+ * T43: Gemini tool call parts must NOT inject fake thoughtSignature.
*
- * Regression test for HTTP 400 "invalid argument" when OmniRoute translates
- * OpenAI tool_calls to Gemini format. Gemini 3 requires the signature to live on
- * the first functionCall part for a tool-call batch, and replays fail if the
- * signature is stripped or emitted as a separate sibling part.
+ * After the fix for issue #1410, OmniRoute no longer injects a hardcoded
+ * DEFAULT_THINKING_GEMINI_SIGNATURE into tool call parts. The Gemini 3+ API
+ * strictly validates thought signatures cryptographically, and injecting a
+ * stale/fake one causes 400 errors.
*
- * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/725
+ * Signatures are now only included when:
+ * 1. The client explicitly provides them via tool_call.thoughtSignature
+ * 2. They are resolved from the geminiThoughtSignatureStore (persisted from
+ * a prior upstream response)
+ *
+ * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/1410
+ * Supersedes: https://github.com/diegosouzapw/OmniRoute/issues/725
*/
import test from "node:test";
@@ -24,7 +30,7 @@ function translateToGemini(messages, tools) {
});
}
-test("T43: first functionCall part keeps thoughtSignature", () => {
+test("T43: functionCall parts do NOT get a fake thoughtSignature injected", () => {
const messages = [
{ role: "user", content: "What is the weather in Tokyo?" },
{
@@ -62,7 +68,6 @@ test("T43: first functionCall part keeps thoughtSignature", () => {
const result = translateToGemini(messages, tools);
- // Find the model turn that contains the functionCall
const modelTurn = result.contents.find(
(c) => c.role === "model" && c.parts?.some((p) => p.functionCall)
);
@@ -73,16 +78,23 @@ test("T43: first functionCall part keeps thoughtSignature", () => {
assert.equal(functionCallParts.length, 1, "Expected exactly 1 functionCall part");
assert.equal(functionCallParts[0].functionCall.name, "get_weather");
assert.deepEqual(functionCallParts[0].functionCall.args, { location: "Tokyo" });
- assert.ok(
- typeof functionCallParts[0].thoughtSignature === "string" &&
- functionCallParts[0].thoughtSignature.length > 0,
- `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}`
+
+ // No fake signature should be injected when the client didn't provide one
+ assert.equal(
+ functionCallParts[0].thoughtSignature,
+ undefined,
+ "functionCall parts must NOT have a fake thoughtSignature injected"
);
});
-test("T43: multiple tool calls only tag the first functionCall part", () => {
+test("T43: client-provided thoughtSignature is ignored in default enabled cache mode", () => {
+ // In "enabled" mode (default), the signature cache ignores client-provided
+ // signatures and only uses persisted ones from upstream responses.
+ // This is the correct behavior to prevent stale/fake signatures from being
+ // forwarded to the Gemini API.
+ const clientSignature = "REAL_CLIENT_SIGNATURE_abc123";
const messages = [
- { role: "user", content: "Get weather for Tokyo and London" },
+ { role: "user", content: "Get weather for Tokyo" },
{
role: "assistant",
content: null,
@@ -91,11 +103,7 @@ test("T43: multiple tool calls only tag the first functionCall part", () => {
id: "call_001",
type: "function",
function: { name: "get_weather", arguments: '{"location":"Tokyo"}' },
- },
- {
- id: "call_002",
- type: "function",
- function: { name: "get_weather", arguments: '{"location":"London"}' },
+ thoughtSignature: clientSignature,
},
],
},
@@ -104,11 +112,6 @@ test("T43: multiple tool calls only tag the first functionCall part", () => {
tool_call_id: "call_001",
content: '{"temp":"15Β°C"}',
},
- {
- role: "tool",
- tool_call_id: "call_002",
- content: '{"temp":"10Β°C"}',
- },
];
const result = translateToGemini(messages, []);
@@ -119,22 +122,19 @@ test("T43: multiple tool calls only tag the first functionCall part", () => {
assert.ok(modelTurn, "Expected a model turn with functionCall parts");
const functionCallParts = modelTurn.parts.filter((p) => p.functionCall);
- assert.equal(functionCallParts.length, 2, "Expected 2 functionCall parts");
+ assert.equal(functionCallParts.length, 1, "Expected 1 functionCall part");
- assert.ok(
- typeof functionCallParts[0].thoughtSignature === "string" &&
- functionCallParts[0].thoughtSignature.length > 0,
- `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}`
- );
- assert.ok(
- !("thoughtSignature" in functionCallParts[1]),
- `parallel follow-up functionCall parts must stay unsigned. Got: ${JSON.stringify(functionCallParts[1])}`
+ // In enabled cache mode, client-provided signatures are NOT forwarded
+ assert.equal(
+ functionCallParts[0].thoughtSignature,
+ undefined,
+ "Client-provided thoughtSignature should be ignored in enabled cache mode"
);
});
-test("T43: thinking parts still include thoughtSignature (regression guard)", () => {
+test("T43: thinking parts still emit thought=true (regression guard)", () => {
// Ensure we did not accidentally break the thinking parts that legitimately
- // need thoughtSignature (present when msg.reasoning_content is set).
+ // need thought: true (present when msg.reasoning_content is set).
const messages = [
{ role: "user", content: "Think about the weather" },
{
@@ -154,10 +154,13 @@ test("T43: thinking parts still include thoughtSignature (regression guard)", ()
assert.ok(thinkingPart, "Expected a thinking part when reasoning_content is set");
assert.equal(thinkingPart.text, "The user wants weather data.");
- const signaturePart = modelTurn.parts.find((p) => "thoughtSignature" in p);
- assert.ok(signaturePart, "Expected a thoughtSignature part after thinking part");
- assert.ok(
- !signaturePart.functionCall,
- "thoughtSignature part must not also be a functionCall part"
+ // After #1410 fix, no fake thoughtSignature parts are injected
+ const signaturePart = modelTurn.parts.find(
+ (p) => "thoughtSignature" in p && !p.functionCall && !p.thought
+ );
+ assert.equal(
+ signaturePart,
+ undefined,
+ "No standalone fake thoughtSignature parts should be injected"
);
});
diff --git a/tests/unit/token-health-check.test.ts b/tests/unit/token-health-check.test.ts
index 42402034..8cc9bea5 100644
--- a/tests/unit/token-health-check.test.ts
+++ b/tests/unit/token-health-check.test.ts
@@ -271,9 +271,130 @@ test("checkConnection uses the resolved proxy payload when refreshing tokens", a
assert.equal(updated?.testStatus, "active");
assert.equal(updated?.lastError ?? null, null);
assert.ok(updated?.tokenExpiresAt);
+ assert.ok(updated?.expiresAt);
+ assert.equal(updated?.expiresAt, updated?.tokenExpiresAt);
}
);
});
}
);
});
+
+test("checkConnection uses the latest stored refresh token instead of a stale sweep snapshot", async () => {
+ await resetStorage();
+
+ const providerId = "custom-oauth-stale-snapshot";
+ const refreshRequests: string[] = [];
+
+ await withHttpServer(
+ (req, res) => {
+ let body = "";
+ req.setEncoding("utf8");
+ req.on("data", (chunk) => {
+ body += chunk;
+ });
+ req.on("end", () => {
+ refreshRequests.push(body);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ access_token: "snapshot-access-next",
+ refresh_token: "snapshot-refresh-next",
+ expires_in: 3600,
+ })
+ );
+ });
+ },
+ async (tokenServer) => {
+ await withPatchedProvider(
+ providerId,
+ {
+ tokenUrl: `${tokenServer.url}/token`,
+ clientId: "snapshot-client-id",
+ clientSecret: "snapshot-client-secret",
+ },
+ async () => {
+ const connection = await providersDb.createProviderConnection({
+ provider: providerId,
+ authType: "oauth",
+ name: "Snapshot Account",
+ email: "snapshot@example.com",
+ accessToken: "snapshot-access-old",
+ refreshToken: "snapshot-refresh-old",
+ isActive: true,
+ });
+
+ const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ await providersDb.updateProviderConnection(connection.id, {
+ refreshToken: "snapshot-refresh-current",
+ lastHealthCheckAt: staleCheckTime,
+ });
+
+ await tokenHealthCheck.checkConnection(connection);
+
+ const updated = await providersDb.getProviderConnectionById(connection.id);
+ assert.equal(refreshRequests.length, 1);
+ assert.match(refreshRequests[0], /refresh_token=snapshot-refresh-current/);
+ assert.equal(updated?.refreshToken, "snapshot-refresh-next");
+ assert.equal(updated?.accessToken, "snapshot-access-next");
+ }
+ );
+ }
+ );
+});
+
+test("checkConnection skips interval refresh when token expiry is known and still far away", async () => {
+ await resetStorage();
+
+ const providerId = "custom-oauth-known-expiry";
+ let refreshCount = 0;
+
+ await withHttpServer(
+ (_req, res) => {
+ refreshCount += 1;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ access_token: "should-not-refresh",
+ refresh_token: "should-not-refresh",
+ expires_in: 3600,
+ })
+ );
+ },
+ async (tokenServer) => {
+ await withPatchedProvider(
+ providerId,
+ {
+ tokenUrl: `${tokenServer.url}/token`,
+ clientId: "known-expiry-client-id",
+ clientSecret: "known-expiry-client-secret",
+ },
+ async () => {
+ const connection = await providersDb.createProviderConnection({
+ provider: providerId,
+ authType: "oauth",
+ name: "Known Expiry Account",
+ email: "known-expiry@example.com",
+ accessToken: "known-expiry-access",
+ refreshToken: "known-expiry-refresh",
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+ isActive: true,
+ });
+
+ const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ await providersDb.updateProviderConnection(connection.id, {
+ lastHealthCheckAt: staleCheckTime,
+ });
+
+ await tokenHealthCheck.checkConnection(connection);
+
+ const updated = await providersDb.getProviderConnectionById(connection.id);
+ assert.equal(refreshCount, 0);
+ assert.equal(updated?.accessToken, "known-expiry-access");
+ assert.equal(updated?.refreshToken, "known-expiry-refresh");
+ assert.equal(updated?.lastHealthCheckAt, staleCheckTime);
+ }
+ );
+ }
+ );
+});
diff --git a/tests/unit/token-refresh-route-service.test.ts b/tests/unit/token-refresh-route-service.test.ts
index 561771c9..7780b318 100644
--- a/tests/unit/token-refresh-route-service.test.ts
+++ b/tests/unit/token-refresh-route-service.test.ts
@@ -200,6 +200,8 @@ test("updateProviderCredentials persists rotated tokens and returns false for mi
assert.equal(stored.refreshToken, "refresh-new");
assert.equal(stored.expiresIn, 600);
assert.equal(typeof stored.expiresAt, "string");
+ assert.equal(typeof stored.tokenExpiresAt, "string");
+ assert.equal(stored.expiresAt, stored.tokenExpiresAt);
assert.deepEqual(stored.providerSpecificData, { tenant: "team-a" });
assert.equal(missing, false);
});
diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts
index ca83878e..2489b655 100644
--- a/tests/unit/token-refresh-service.test.ts
+++ b/tests/unit/token-refresh-service.test.ts
@@ -27,9 +27,29 @@ const {
refreshWithRetry,
} = tokenRefresh;
-function createLog() {
- const entries = [];
- const push = (level, args) => {
+type LogLevel = "debug" | "info" | "warn" | "error";
+type LogEntry = {
+ level: LogLevel;
+ scope: unknown;
+ message: unknown;
+ meta: unknown;
+};
+type MockLogger = {
+ entries: LogEntry[];
+ debug: (...args: [unknown?, unknown?, unknown?]) => void;
+ info: (...args: [unknown?, unknown?, unknown?]) => void;
+ warn: (...args: [unknown?, unknown?, unknown?]) => void;
+ error: (...args: [unknown?, unknown?, unknown?]) => void;
+};
+
+type TestFetch = typeof fetch;
+type FastSetTimeout = typeof globalThis.setTimeout & {
+ __promisify__?: typeof globalThis.setTimeout.__promisify__;
+};
+
+function createLog(): MockLogger {
+ const entries: LogEntry[] = [];
+ const push = (level: LogLevel, args: [unknown?, unknown?, unknown?]) => {
const [scope, message, meta] = args;
entries.push({ level, scope, message, meta });
};
@@ -43,27 +63,27 @@ function createLog() {
};
}
-function jsonResponse(body, status = 200) {
+function jsonResponse(body: any, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
-function textResponse(text, status = 400) {
+function textResponse(text: any, status = 400) {
return new Response(text, {
status,
headers: { "content-type": "text/plain" },
});
}
-function bodyToString(body) {
+function bodyToString(body: BodyInit | null | undefined) {
if (typeof body === "string") return body;
if (body instanceof URLSearchParams) return body.toString();
return String(body ?? "");
}
-async function withMockedFetch(fetchImpl, fn) {
+async function withMockedFetch(fetchImpl: TestFetch, fn: () => Promise) {
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchImpl;
try {
@@ -73,7 +93,7 @@ async function withMockedFetch(fetchImpl, fn) {
}
}
-async function withMockedNow(now, fn) {
+async function withMockedNow(now: number, fn: () => Promise) {
const originalNow = Date.now;
Date.now = () => now;
try {
@@ -83,11 +103,19 @@ async function withMockedNow(now, fn) {
}
}
-async function withPatchedProperties(target, patch, fn) {
- const previous = new Map();
+async function withPatchedProperties(
+ target: object,
+ patch: Record,
+ fn: () => Promise
+) {
+ const previous = new Map();
+ const targetRecord = target as Record;
for (const [key, value] of Object.entries(patch)) {
- previous.set(key, Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined);
- target[key] = value;
+ previous.set(
+ key,
+ Object.prototype.hasOwnProperty.call(targetRecord, key) ? targetRecord[key] : undefined
+ );
+ targetRecord[key] = value;
}
try {
@@ -96,21 +124,22 @@ async function withPatchedProperties(target, patch, fn) {
for (const [key] of Object.entries(patch)) {
const prior = previous.get(key);
if (prior === undefined) {
- delete target[key];
+ delete targetRecord[key];
} else {
- target[key] = prior;
+ targetRecord[key] = prior;
}
}
}
}
-async function withFastRetryTimers(fn) {
- const originalSetTimeout = globalThis.setTimeout as any;
- (globalThis as any).setTimeout = Object.assign(
- ((callback: any, delay = 0, ...args: any[]) =>
- originalSetTimeout(callback, delay === 30_000 ? delay : 0, ...args)) as any,
+async function withFastRetryTimers(fn: () => Promise) {
+ const originalSetTimeout = globalThis.setTimeout as FastSetTimeout;
+ const fastSetTimeout: FastSetTimeout = Object.assign(
+ ((callback: TimerHandler, delay = 0, ...args: unknown[]) =>
+ originalSetTimeout(callback, delay === 30_000 ? delay : 0, ...args)) as typeof setTimeout,
{ __promisify__: originalSetTimeout.__promisify__ }
);
+ globalThis.setTimeout = fastSetTimeout;
try {
return await fn();
} finally {
@@ -149,7 +178,7 @@ test("refreshAccessToken returns null when refresh token is missing", async () =
test("refreshAccessToken posts form data and returns rotated tokens", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withPatchedProperties(
PROVIDERS,
@@ -217,7 +246,7 @@ test("refreshAccessToken returns null on upstream refresh failure", async () =>
test("refreshClineToken handles nested payloads and computes expiresIn", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedNow(1_700_000_000_000, async () => {
await withMockedFetch(
@@ -233,9 +262,9 @@ test("refreshClineToken handles nested payloads and computes expiresIn", async (
},
async () => {
const result = await refreshClineToken("refresh-cline", log);
- assert.equal(result.accessToken, "cline-access");
- assert.equal(result.refreshToken, "cline-refresh");
- assert.equal(result.expiresIn, 95);
+ assert.equal(result?.accessToken, "cline-access");
+ assert.equal(result?.refreshToken, "cline-refresh");
+ assert.equal(result?.expiresIn, 95);
}
);
});
@@ -250,7 +279,7 @@ test("refreshClineToken handles nested payloads and computes expiresIn", async (
test("refreshKimiCodingToken adds provider-specific headers and fields", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -284,7 +313,7 @@ test("refreshKimiCodingToken adds provider-specific headers and fields", async (
test("refreshClaudeOAuthToken posts the anthropic oauth refresh contract", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -313,7 +342,7 @@ test("refreshClaudeOAuthToken posts the anthropic oauth refresh contract", async
test("refreshGoogleToken exchanges refresh tokens against the shared google endpoint", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -385,14 +414,17 @@ test("refreshCodexToken recognizes refresh_token_reused responses", async () =>
async () => textResponse(JSON.stringify({ error: { code: "refresh_token_reused" } }), 400),
async () => {
const result = await refreshCodexToken("codex-refresh", log);
- assert.deepEqual(result, { error: "refresh_token_reused" });
+ assert.deepEqual(result, {
+ error: "unrecoverable_refresh_error",
+ code: "refresh_token_reused",
+ });
}
);
});
test("refreshKiroToken uses the AWS OIDC flow when client credentials are present", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -434,7 +466,7 @@ test("refreshKiroToken uses the AWS OIDC flow when client credentials are presen
test("refreshKiroToken falls back to the social-auth refresh endpoint", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -463,7 +495,7 @@ test("refreshKiroToken falls back to the social-auth refresh endpoint", async ()
test("refreshQoderToken uses basic auth once qoder oauth settings are configured", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withPatchedProperties(
PROVIDERS.qoder,
@@ -507,7 +539,7 @@ test("refreshQoderToken uses basic auth once qoder oauth settings are configured
test("refreshGitHubToken exchanges the refresh token with github oauth", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withPatchedProperties(
PROVIDERS.github,
@@ -543,7 +575,7 @@ test("refreshGitHubToken exchanges the refresh token with github oauth", async (
test("refreshCopilotToken returns the short-lived copilot token", async () => {
const log = createLog();
- const calls = [];
+ const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
@@ -666,7 +698,7 @@ test("getAccessToken cleans the in-flight cache after resolve and separates diff
},
async () => {
await withMockedFetch(
- async (_url, options: any = {}) => {
+ async (_url, options: RequestInit = {}) => {
fetchCount += 1;
const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token");
return jsonResponse({
@@ -719,7 +751,7 @@ test("getAllAccessTokens refreshes only active connections with providers", asyn
},
async () => {
await withMockedFetch(
- async (_url, options: any = {}) => {
+ async (_url, options: RequestInit = {}) => {
fetchCount += 1;
const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token");
return jsonResponse({
@@ -826,7 +858,7 @@ test("isProviderBlocked clears expired circuit-breaker entries once cooldown pas
await refreshWithRetry(async () => null, 1, log, provider);
}
- const blockedUntil = Date.parse(getCircuitBreakerStatus()[provider].blockedUntil);
+ const blockedUntil = Date.parse(getCircuitBreakerStatus()[provider].blockedUntil as string);
await withMockedNow(blockedUntil + 1, async () => {
assert.equal(isProviderBlocked(provider), false);
diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts
index 736760b1..65e80c1d 100644
--- a/tests/unit/translator-claude-to-gemini.test.ts
+++ b/tests/unit/translator-claude-to-gemini.test.ts
@@ -5,8 +5,33 @@ const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
const { DEFAULT_SAFETY_SETTINGS } =
await import("../../open-sse/translator/helpers/geminiHelper.ts");
-const { DEFAULT_THINKING_GEMINI_SIGNATURE } =
- await import("../../open-sse/config/defaultThinkingSignature.ts");
+
+type UnknownRecord = Record;
+
+function getFunctionDeclarationParameters(parameters: unknown) {
+ assert.ok(
+ parameters && typeof parameters === "object",
+ "expected function declaration parameters"
+ );
+ return parameters as UnknownRecord & {
+ properties?: Record;
+ examples?: unknown;
+ };
+}
+
+function getFunctionCall(part: unknown) {
+ assert.ok(part && typeof part === "object", "expected Gemini part");
+ const functionCall = (part as UnknownRecord).functionCall;
+ assert.ok(functionCall && typeof functionCall === "object", "expected functionCall");
+ return functionCall as { name: string };
+}
+
+function getFunctionResponse(part: unknown) {
+ assert.ok(part && typeof part === "object", "expected Gemini part");
+ const functionResponse = (part as UnknownRecord).functionResponse;
+ assert.ok(functionResponse && typeof functionResponse === "object", "expected functionResponse");
+ return functionResponse as { name: string };
+}
test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", () => {
const result = claudeToGeminiRequest(
@@ -55,15 +80,11 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
parts: [{ text: "Rules" }],
});
assert.equal(result.contents[0].role, "model");
- assert.deepEqual(result.contents[0].parts[0], { thought: true, text: "need tool" });
- assert.deepEqual(result.contents[0].parts[1], {
- thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
- text: "",
- });
- assert.deepEqual(result.contents[0].parts[2], {
+ assert.deepEqual(result.contents[0].parts[0] as any, { thought: true, text: "need tool" });
+ assert.deepEqual(result.contents[0].parts[1] as any, {
functionCall: { id: "tu_1", name: "weather", args: { city: "Tokyo" } },
});
- assert.deepEqual(result.contents[1].parts[0], {
+ assert.deepEqual(result.contents[1].parts[0] as any, {
functionResponse: {
id: "tu_1",
name: "weather",
@@ -78,7 +99,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
includeThoughts: true,
});
assert.deepEqual(result.safetySettings, DEFAULT_SAFETY_SETTINGS);
- assert.deepEqual(result.tools[0].functionDeclarations[0].parameters, {
+ assert.deepEqual((result as any).tools[0].functionDeclarations[0].parameters, {
type: "object",
properties: { city: { type: "string" } },
});
@@ -128,8 +149,8 @@ test("Claude -> Gemini injects a fallback thoughtSignature on tool-call batches
assert.equal(result.contents.length, 1);
assert.equal(result.contents[0].role, "model");
- assert.equal(result.contents[0].parts[0].functionCall.name, "read_file");
- assert.equal(result.contents[0].parts[0].thoughtSignature, DEFAULT_THINKING_GEMINI_SIGNATURE);
+ assert.equal((result.contents[0].parts[0] as any).functionCall.name, "read_file");
+ assert.equal((result.contents[0].parts[0] as any).thoughtSignature, undefined);
});
test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () => {
@@ -167,17 +188,17 @@ test("Claude -> Gemini sanitizes long tool names and exposes a restore map", ()
false
);
- const sanitizedToolName = result.tools[0].functionDeclarations[0].name;
+ const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name as string;
+ const parameters = getFunctionDeclarationParameters(
+ (result as any).tools[0].functionDeclarations[0].parameters
+ );
assert.ok(longToolName.length > 64);
assert.equal(sanitizedToolName.length, 64);
- assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName);
- assert.equal(result.contents[0].parts[0].functionCall.name, sanitizedToolName);
- assert.equal(result.contents[1].parts[0].functionResponse.name, sanitizedToolName);
- assert.equal(result.tools[0].functionDeclarations[0].parameters.examples, undefined);
- assert.equal(
- result.tools[0].functionDeclarations[0].parameters.properties.path["x-ui"],
- undefined
- );
+ assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
+ assert.equal(getFunctionCall(result.contents[0].parts[0] as any).name, sanitizedToolName);
+ assert.equal(getFunctionResponse(result.contents[1].parts[0] as any).name, sanitizedToolName);
+ assert.equal(parameters.examples, undefined);
+ assert.equal(parameters.properties?.path?.["x-ui"], undefined);
});
test("Claude -> Gemini handles empty bodies without producing invalid content", () => {
diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts
index 433297dc..e31c3933 100644
--- a/tests/unit/translator-helper-branches.test.ts
+++ b/tests/unit/translator-helper-branches.test.ts
@@ -186,14 +186,13 @@ test("openaiHelper filters content, normalizes tools and removes OpenAI-incompat
const result = openaiHelper.filterToOpenAIFormat(body);
- assert.equal(result.messages.length, 4);
+ assert.equal(result.messages.length, 3);
assert.equal(result.messages[2].reasoning_content, "plan first");
assert.deepEqual(result.messages[2].content, [
{ type: "text", text: "visible text" },
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
- { type: "tool_result", tool_use_id: "call_1", text: "done" },
+ { type: "text", text: "[Tool Result: call_1]\ndone" },
]);
- assert.deepEqual(result.messages[3].content, [{ type: "tool_result", tool_use_id: "call_2" }]);
assert.equal(result.tools.length, 3);
assert.equal(result.tools[0].function.name, "claude-tool");
assert.equal(result.tools[1].function.name, "gemini-tool");
diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts
index 88e419ee..f9c3eaba 100644
--- a/tests/unit/translator-openai-to-gemini.test.ts
+++ b/tests/unit/translator-openai-to-gemini.test.ts
@@ -13,6 +13,37 @@ const {
} = await import("../../open-sse/translator/helpers/geminiHelper.ts");
const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts");
+type UnknownRecord = Record;
+
+function getFunctionCall(part: unknown) {
+ assert.ok(part && typeof part === "object", "expected Gemini functionCall part");
+ const functionCall = (part as UnknownRecord).functionCall;
+ assert.ok(functionCall && typeof functionCall === "object", "expected functionCall payload");
+ return functionCall as { id?: string; name: string; args?: unknown };
+}
+
+function getFunctionResponse(part: unknown) {
+ assert.ok(part && typeof part === "object", "expected Gemini functionResponse part");
+ const functionResponse = (part as UnknownRecord).functionResponse;
+ assert.ok(
+ functionResponse && typeof functionResponse === "object",
+ "expected functionResponse payload"
+ );
+ return functionResponse as { id?: string; name: string; response?: unknown };
+}
+
+function getFunctionDeclarationParameters(parameters: unknown) {
+ assert.ok(
+ parameters && typeof parameters === "object",
+ "expected function declaration parameters"
+ );
+ return parameters as UnknownRecord & {
+ properties?: Record;
+ examples?: unknown;
+ $schema?: unknown;
+ };
+}
+
test("OpenAI -> Gemini helper converts text, images and files into Gemini parts", () => {
const parts = convertOpenAIContentToParts([
{ type: "text", text: "Hello" },
@@ -121,7 +152,7 @@ test("OpenAI -> Gemini helper inlines local refs and preserves only additionalPr
assert.equal(cleaned.properties.shipping.properties.street.minLength, undefined);
assert.deepEqual(cleaned.properties.shipping.required, ["street"]);
assert.equal(cleaned.properties.shipping.additionalProperties, undefined);
- assert.equal(cleaned.properties.metadata.additionalProperties, true);
+ assert.equal(cleaned.properties.metadata.additionalProperties, undefined);
assert.equal(cleaned.properties.options.additionalProperties, undefined);
});
@@ -193,8 +224,11 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools
false
);
- assert.equal(result.systemInstruction.role, "user");
- assert.deepEqual(result.systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]);
+ assert.equal((result as any).systemInstruction.role, "user");
+ assert.deepEqual((result as any).systemInstruction.parts, [
+ { text: "Rule A" },
+ { text: "Rule B" },
+ ]);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [
{ text: "What is the weather?" },
@@ -205,31 +239,38 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
);
assert.ok(modelTurn, "expected a model turn with functionCall");
+ const modelTurnThought = modelTurn.parts[0] as { thought?: boolean; text?: string };
+ const modelTurnFunctionCall = getFunctionCall(modelTurn.parts[2]);
assert.equal(modelTurn.parts[0].thought, true);
- assert.equal(modelTurn.parts[0].text, "Need live data");
- assert.equal(modelTurn.parts[1].thoughtSignature !== undefined, true);
- assert.equal(modelTurn.parts[2].text, "Calling a tool");
- assert.equal(modelTurn.parts[3].functionCall.name, "weather");
- assert.deepEqual(modelTurn.parts[3].functionCall.args, { city: "Tokyo" });
+ assert.equal(modelTurnThought.text, "Need live data");
+ assert.equal(modelTurn.parts[1].text, "Calling a tool");
+ assert.equal(modelTurnFunctionCall.name, "weather");
+ assert.deepEqual(modelTurnFunctionCall.args, { city: "Tokyo" });
const toolResponseTurn = result.contents.find(
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
);
assert.ok(toolResponseTurn, "expected a tool response turn");
- assert.deepEqual(toolResponseTurn.parts[0].functionResponse, {
+ assert.deepEqual(getFunctionResponse(toolResponseTurn.parts[0]), {
id: "call_1",
name: "weather",
response: { result: { temp: 20 } },
});
- assert.equal(result.generationConfig.maxOutputTokens, 2222);
- assert.equal(result.generationConfig.temperature, 0.3);
- assert.equal(result.generationConfig.topP, 0.9);
- assert.deepEqual(result.generationConfig.stopSequences, ["DONE"]);
- assert.equal(result.generationConfig.responseMimeType, "application/json");
- assert.equal(result.generationConfig.responseSchema.properties.answer.type, "string");
- assert.deepEqual(result.generationConfig.responseSchema.properties.answer.enum, ["ok"]);
- assert.deepEqual(result.tools[0].functionDeclarations[0].parameters, {
+ assert.equal((result as any).generationConfig.maxOutputTokens, 2222);
+ assert.equal((result as any).generationConfig.temperature, 0.3);
+ assert.equal((result as any).generationConfig.topP, 0.9);
+ assert.deepEqual((result as any).generationConfig.stopSequences, ["DONE"]);
+ assert.equal((result as any).generationConfig.responseMimeType, "application/json");
+ const responseSchema = (result as any).generationConfig.responseSchema as {
+ properties: { answer: { type: string; enum?: string[] } };
+ };
+ assert.equal(responseSchema.properties.answer.type, "string");
+ assert.deepEqual(responseSchema.properties.answer.enum, ["ok"]);
+ const parameters = getFunctionDeclarationParameters(
+ (result as any).tools[0].functionDeclarations[0].parameters
+ );
+ assert.deepEqual(parameters, {
type: "object",
properties: {
city: { type: "string" },
@@ -252,7 +293,7 @@ test("OpenAI -> Gemini request preserves custom safety settings and handles syst
);
assert.deepEqual(result.safetySettings, customSafety);
- assert.equal(result.systemInstruction, undefined);
+ assert.equal((result as any).systemInstruction, undefined);
assert.equal(result.contents.length, 1);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [{ text: "Only rules" }]);
@@ -294,18 +335,20 @@ test("OpenAI -> Gemini CLI adds thinking config and normalizes namespaced tool n
false
);
- assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true);
- assert.ok(result.generationConfig.thinkingConfig.thinkingBudget > 0);
- assert.equal(result.tools[0].functionDeclarations[0].name, "weather");
- assert.equal(result._toolNameMap.get("weather"), "ns:weather");
+ assert.equal((result as any).generationConfig.thinkingConfig.includeThoughts, true);
+ assert.ok((result as any).generationConfig.thinkingConfig.thinkingBudget > 0);
+ assert.equal((result as any).tools[0].functionDeclarations[0].name, "weather");
+ assert.equal((result as any)._toolNameMap.get("weather"), "ns:weather");
const modelTurn = result.contents.find((content) => content.role === "model");
- assert.equal(modelTurn.parts[0].functionCall.name, "weather");
+ assert.ok(modelTurn, "expected a model turn");
+ assert.equal(getFunctionCall(modelTurn.parts[0]).name, "weather");
const responseTurn = result.contents.find(
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
);
- assert.equal(responseTurn.parts[0].functionResponse.name, "weather");
+ assert.ok(responseTurn, "expected a function response turn");
+ assert.equal(getFunctionResponse(responseTurn.parts[0]).name, "weather");
});
test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsupported schema fields", () => {
@@ -355,25 +398,33 @@ test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsuppor
false
);
- const sanitizedToolName = result.tools[0].functionDeclarations[0].name;
+ const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name;
assert.ok(longToolName.length > 64);
assert.equal(sanitizedToolName.length, 64);
assert.match(sanitizedToolName, /_[a-f0-9]{8}$/);
- assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName);
+ assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
const modelTurn = result.contents.find((content) => content.role === "model");
- assert.equal(modelTurn.parts[0].functionCall.name, sanitizedToolName);
+ assert.ok(modelTurn, "expected a model turn");
+ assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName);
const toolTurn = result.contents.find(
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
);
- assert.equal(toolTurn.parts[0].functionResponse.name, sanitizedToolName);
- assert.equal(result.tools[0].functionDeclarations[0].parameters.$schema, undefined);
- assert.equal(result.tools[0].functionDeclarations[0].parameters.examples, undefined);
- assert.equal(
- result.tools[0].functionDeclarations[0].parameters.properties.paths.items["x-ui"],
- undefined
- );
+ assert.ok(toolTurn, "expected a tool response turn");
+ assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName);
+ const longToolParameters = getFunctionDeclarationParameters(
+ (result as any).tools[0].functionDeclarations[0].parameters
+ ) as UnknownRecord & {
+ properties?: {
+ paths?: {
+ items?: UnknownRecord;
+ };
+ };
+ };
+ assert.equal(longToolParameters.$schema, undefined);
+ assert.equal(longToolParameters.examples, undefined);
+ assert.equal(longToolParameters.properties?.paths?.items?.["x-ui"], undefined);
});
test("OpenAI -> Gemini request gives googleSearch precedence over function tools", () => {
@@ -396,7 +447,7 @@ test("OpenAI -> Gemini request gives googleSearch precedence over function tools
false
);
- assert.deepEqual(result.tools, [{ googleSearch: {} }]);
+ assert.deepEqual((result as any).tools, [{ googleSearch: {} }]);
});
test("OpenAI -> Antigravity keeps googleSearch without function calling config", () => {
@@ -416,10 +467,10 @@ test("OpenAI -> Antigravity keeps googleSearch without function calling config",
],
},
false,
- { projectId: "proj-search" }
+ { projectId: "proj-search" } as any
);
- assert.deepEqual(result.request.tools, [{ googleSearch: {} }]);
+ assert.deepEqual((result as any).request?.tools, [{ googleSearch: {} }]);
assert.equal(result.request.toolConfig, undefined);
});
@@ -427,7 +478,7 @@ test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format",
assert.match(generateRequestId(), /^agent-/);
assert.match(generateSessionId(), /^-\d+$/);
assert.deepEqual(tryParseJSON('{"ok":true}'), { ok: true });
- assert.equal(tryParseJSON("not-json"), null);
+ assert.equal(tryParseJSON("not-json"), null as any);
});
test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () => {
@@ -447,7 +498,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", ()
reasoning_effort: "medium",
},
false,
- { projectId: "proj-1" }
+ { projectId: "proj-1" } as any
);
assert.equal(result.project, "proj-1");
@@ -455,7 +506,10 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", ()
assert.equal(result.requestType, "agent");
assert.match(result.requestId, /^agent-/);
assert.match(result.request.sessionId, /^-\d+$/);
- assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM);
+ assert.equal(
+ (result as any).request?.systemInstruction.parts[0].text,
+ ANTIGRAVITY_DEFAULT_SYSTEM
+ );
assert.deepEqual(result.request.toolConfig, {
functionCallingConfig: { mode: "VALIDATED" },
});
@@ -499,27 +553,31 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", ()
],
},
false,
- { projectId: "proj-claude" }
+ { projectId: "proj-claude" } as any
);
assert.equal(result.project, "proj-claude");
assert.equal(result.userAgent, "antigravity");
- assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM);
- assert.equal(result.request.systemInstruction.parts[1].text, "Project rules");
+ assert.equal(
+ (result as any).request?.systemInstruction.parts[0].text,
+ ANTIGRAVITY_DEFAULT_SYSTEM
+ );
+ assert.equal((result as any).request?.systemInstruction.parts[1].text, "Project rules");
const modelTurn = result.request.contents.find(
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
);
assert.ok(modelTurn, "expected a Claude-bridged model turn");
- assert.equal(modelTurn.parts[0].functionCall.name, "read_file");
- assert.deepEqual(modelTurn.parts[0].functionCall.args, { path: "/tmp/demo" });
+ const bridgeFunctionCall = getFunctionCall(modelTurn.parts[0]);
+ assert.equal(bridgeFunctionCall.name, "read_file");
+ assert.deepEqual(bridgeFunctionCall.args, { path: "/tmp/demo" });
const toolTurn = result.request.contents.find(
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
);
assert.ok(toolTurn, "expected a Claude-bridged tool response turn");
- assert.equal(toolTurn.parts[0].functionResponse.id, "call_1");
- assert.equal(result.request.tools[0].functionDeclarations[0].name, "read_file");
+ assert.equal(getFunctionResponse(toolTurn.parts[0]).id, "call_1");
+ assert.equal((result as any).request?.tools[0].functionDeclarations[0].name, "read_file");
});
test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves restore map", () => {
@@ -561,20 +619,22 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res
],
},
false,
- { projectId: "proj-claude-map" }
+ { projectId: "proj-claude-map" } as any
);
- const sanitizedToolName = result.request.tools[0].functionDeclarations[0].name;
+ const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name;
assert.equal(sanitizedToolName.length, 64);
- assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName);
+ assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
const modelTurn = result.request.contents.find(
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
);
- assert.equal(modelTurn.parts[0].functionCall.name, sanitizedToolName);
+ assert.ok(modelTurn, "expected a model turn");
+ assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName);
const toolTurn = result.request.contents.find(
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
);
- assert.equal(toolTurn.parts[0].functionResponse.name, sanitizedToolName);
+ assert.ok(toolTurn, "expected a tool response turn");
+ assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName);
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 7d8e261c..2f003449 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
"open-sse/**/__tests__/**/*.test.ts",
"open-sse/services/**/__tests__/**/*.test.ts",
"tests/e2e/ecosystem.test.ts",
+ "tests/e2e/protocol-clients.test.ts",
],
exclude: [
"**/node_modules/**",