# v0.4.29 (2026-05-10)
## Features - Add Cline & Kilo Code tool cards - Tailscale TUN mode for stable Funnel TLS - Sort APIKEY providers by usage, collapse to top 20 ## Improvements - Local Material Symbols font (no Google Fonts) - Docker base: Bun → Node 22-alpine - MITM reads aliases from JSON cache (no native sqlite) - Stream stall timeout (2 min) in open-sse ## Fixes - Fal.ai key test: use stable models endpoint
This commit is contained in:
@@ -9,6 +9,8 @@ import { GET as openclawGet } from "../openclaw-settings/route";
|
||||
import { GET as hermesGet } from "../hermes-settings/route";
|
||||
import { GET as coworkGet } from "../cowork-settings/route";
|
||||
import { GET as copilotGet } from "../copilot-settings/route";
|
||||
import { GET as clineGet } from "../cline-settings/route";
|
||||
import { GET as kiloGet } from "../kilo-settings/route";
|
||||
|
||||
const STATUS_GETTERS = {
|
||||
claude: claudeGet,
|
||||
@@ -19,6 +21,8 @@ const STATUS_GETTERS = {
|
||||
hermes: hermesGet,
|
||||
cowork: coworkGet,
|
||||
copilot: copilotGet,
|
||||
cline: clineGet,
|
||||
kilo: kiloGet,
|
||||
};
|
||||
|
||||
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMitmAlias, setMitmAliasAll } from "@/models";
|
||||
import { getMitmStatus } from "@/mitm/manager";
|
||||
import { writeAliasForTool } from "@/lib/mitmAliasCache";
|
||||
|
||||
// GET - Get MITM aliases for a tool
|
||||
export async function GET(request) {
|
||||
@@ -43,6 +44,7 @@ export async function PUT(request) {
|
||||
}
|
||||
|
||||
await setMitmAliasAll(tool, filtered);
|
||||
writeAliasForTool(tool, filtered);
|
||||
return NextResponse.json({ success: true, aliases: filtered });
|
||||
} catch (error) {
|
||||
console.log("Error saving MITM aliases:", error.message);
|
||||
|
||||
133
src/app/api/cli-tools/cline-settings/route.js
Normal file
133
src/app/api/cli-tools/cline-settings/route.js
Normal file
@@ -0,0 +1,133 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDataDir = () => path.join(os.homedir(), ".cline", "data");
|
||||
const getGlobalStatePath = () => path.join(getDataDir(), "globalState.json");
|
||||
const getSecretsPath = () => path.join(getDataDir(), "secrets.json");
|
||||
|
||||
const checkInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where cline" : "which cline";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getGlobalStatePath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (globalState) => {
|
||||
if (!globalState) return false;
|
||||
const isOpenAi =
|
||||
globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai";
|
||||
const baseUrl = globalState.openAiBaseUrl || "";
|
||||
return isOpenAi && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router"));
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "Cline CLI is not installed" });
|
||||
}
|
||||
const globalState = await readJson(getGlobalStatePath());
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: {
|
||||
actModeApiProvider: globalState?.actModeApiProvider,
|
||||
planModeApiProvider: globalState?.planModeApiProvider,
|
||||
openAiBaseUrl: globalState?.openAiBaseUrl,
|
||||
openAiModelId: globalState?.openAiModelId,
|
||||
},
|
||||
has9Router: has9RouterConfig(globalState),
|
||||
globalStatePath: getGlobalStatePath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(getDataDir(), { recursive: true });
|
||||
|
||||
// Cline expects base WITHOUT /v1
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
|
||||
|
||||
const globalState = (await readJson(getGlobalStatePath())) || {};
|
||||
globalState.actModeApiProvider = "openai";
|
||||
globalState.planModeApiProvider = "openai";
|
||||
globalState.openAiBaseUrl = normalizedBaseUrl;
|
||||
globalState.openAiModelId = model;
|
||||
globalState.planModeOpenAiModelId = model;
|
||||
await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
|
||||
|
||||
const secrets = (await readJson(getSecretsPath())) || {};
|
||||
secrets.openAiApiKey = apiKey;
|
||||
await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
|
||||
|
||||
return NextResponse.json({ success: true, message: "Cline settings applied successfully!", globalStatePath: getGlobalStatePath() });
|
||||
} catch (error) {
|
||||
console.log("Error updating cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const globalState = await readJson(getGlobalStatePath());
|
||||
if (!globalState) {
|
||||
return NextResponse.json({ success: true, message: "No settings file to reset" });
|
||||
}
|
||||
|
||||
if (globalState.actModeApiProvider === "openai") {
|
||||
delete globalState.openAiBaseUrl;
|
||||
delete globalState.openAiModelId;
|
||||
delete globalState.planModeOpenAiModelId;
|
||||
globalState.actModeApiProvider = "cline";
|
||||
globalState.planModeApiProvider = "cline";
|
||||
}
|
||||
await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
|
||||
|
||||
const secrets = (await readJson(getSecretsPath())) || {};
|
||||
delete secrets.openAiApiKey;
|
||||
await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
|
||||
|
||||
return NextResponse.json({ success: true, message: "9Router settings removed from Cline" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
131
src/app/api/cli-tools/kilo-settings/route.js
Normal file
131
src/app/api/cli-tools/kilo-settings/route.js
Normal file
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDataDir = () => path.join(os.homedir(), ".local", "share", "kilo");
|
||||
const getAuthPath = () => path.join(getDataDir(), "auth.json");
|
||||
const getVscodeSettingsPath = () => path.join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
const checkInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where kilo" : "which kilo";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getAuthPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (auth) => {
|
||||
if (!auth) return false;
|
||||
const entry = auth["openai-compatible"] || auth["9router"];
|
||||
if (!entry) return false;
|
||||
const baseUrl = entry.baseUrl || entry.baseURL || "";
|
||||
return baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router");
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "Kilo Code CLI is not installed" });
|
||||
}
|
||||
const auth = await readJson(getAuthPath());
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: { auth: auth ? Object.keys(auth) : [] },
|
||||
has9Router: has9RouterConfig(auth),
|
||||
authPath: getAuthPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(getDataDir(), { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
|
||||
const auth = (await readJson(getAuthPath())) || {};
|
||||
auth["openai-compatible"] = {
|
||||
type: "api-key",
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
model,
|
||||
};
|
||||
await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
|
||||
|
||||
// Best-effort: update VS Code extension settings
|
||||
try {
|
||||
const vscode = (await readJson(getVscodeSettingsPath())) || {};
|
||||
vscode["kilocode.customProvider"] = { name: "9Router", baseURL: normalizedBaseUrl, apiKey };
|
||||
vscode["kilocode.defaultModel"] = model;
|
||||
await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
|
||||
} catch { /* VS Code settings not writable */ }
|
||||
|
||||
return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", authPath: getAuthPath() });
|
||||
} catch (error) {
|
||||
console.log("Error updating kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const auth = await readJson(getAuthPath());
|
||||
if (!auth) {
|
||||
return NextResponse.json({ success: true, message: "No settings file to reset" });
|
||||
}
|
||||
delete auth["openai-compatible"];
|
||||
delete auth["9router"];
|
||||
await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
|
||||
|
||||
try {
|
||||
const vscode = await readJson(getVscodeSettingsPath());
|
||||
if (vscode) {
|
||||
delete vscode["kilocode.customProvider"];
|
||||
delete vscode["kilocode.defaultModel"];
|
||||
await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return NextResponse.json({ success: true, message: "9Router settings removed from Kilo Code" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
export async function POST(request) {
|
||||
@@ -7,8 +8,7 @@ export async function POST(request) {
|
||||
const { model, kind } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
|
||||
const baseUrl = process.env.BASE_URL ||
|
||||
(() => { const u = new URL(request.url); return `${u.protocol}//${u.host}`; })();
|
||||
const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
|
||||
|
||||
// Get an active internal API key for auth (if requireApiKey is enabled)
|
||||
let apiKey = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
|
||||
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
/**
|
||||
* Get an active API key to pass through auth when requireApiKey is enabled.
|
||||
@@ -64,10 +65,12 @@ export async function POST(request, { params }) {
|
||||
|
||||
let models = getProviderModels(alias);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
|
||||
|
||||
// Compatible providers: fetch live model list
|
||||
if (isCompatible && models.length === 0) {
|
||||
try {
|
||||
const modelsRes = await fetch(`${getBaseUrl(request)}/api/providers/${id}/models`);
|
||||
const modelsRes = await fetch(`${baseUrl}/api/providers/${id}/models`);
|
||||
if (modelsRes.ok) {
|
||||
const data = await modelsRes.json();
|
||||
models = (data.models || []).map((m) => ({ id: m.id || m.name, name: m.name || m.id }));
|
||||
@@ -79,7 +82,6 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl(request);
|
||||
const apiKey = await getInternalApiKey();
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
@@ -104,8 +106,3 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(request) {
|
||||
const url = new URL(request.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
@@ -551,6 +551,11 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "fal-ai": {
|
||||
const res = await fetchWithConnectionProxy("https://api.fal.ai/v1/models?limit=1", { headers: { Authorization: `Key ${connection.apiKey}` } }, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "chutes": {
|
||||
const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
|
||||
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
@@ -41,7 +42,8 @@ export async function GET() {
|
||||
installed ? isDaemonRunning() : Promise.resolve(false),
|
||||
]);
|
||||
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning });
|
||||
const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, hasCachedPassword });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user