feat(settings): runtime log level + free provider enable/disable

- Add LOG_LEVEL env + runtime setLogLevel (dashboard Settings → Logging),
  applied immediately, persisted across restarts; WARN/ERROR quiet production
  INFO lines (▶ POST / 📊 DONE / [COMBO] / [CHAT])
- Allow toggling free/noAuth providers (gemini-cli, kilo, etc.) off via
  providerStrategies.enabled from Providers page and provider detail page
- auth.js: honor disabled override before noAuth/connection branches
- CompatibleModelsSection: parallel model testing
This commit is contained in:
2026-08-17 00:17:55 +07:00
parent a3182a7265
commit de9e00c66d
11 changed files with 507 additions and 61 deletions

View File

@@ -14,6 +14,10 @@ NODE_ENV=production
API_KEY_SECRET=endpoint-proxy-api-key-secret
MACHINE_ID_SALT=endpoint-proxy-salt
ENABLE_REQUEST_LOGS=false
# Console verbosity: DEBUG | INFO | WARN | ERROR. Default INFO. In production set
# ERROR to only print important errors (hides ▶ POST / 📊 DONE / [COMBO] / [CHAT]).
# Can also be changed at runtime from dashboard Settings → Logging.
# LOG_LEVEL=ERROR
OBSERVABILITY_ENABLED=true
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false

3
.gitignore vendored
View File

@@ -89,3 +89,6 @@ graphify-out/*
# CommandCode CLI local state (auth/taste/projects)
.commandcode/
# Pi subagent run artifacts
.pi-subagents/

View File

@@ -536,6 +536,21 @@ export default function ProfilePage() {
}
};
const updateLogLevel = async (logLevel) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logLevel }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, logLevel }));
}
} catch (err) {
console.error("Failed to update logLevel:", err);
}
};
const updateShowOnlyComboModels = async (showOnlyComboModels) => {
try {
const res = await fetch("/api/settings", {
@@ -1448,6 +1463,41 @@ export default function ProfilePage() {
</div>
</Card>
{/* Logging Settings */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-slate-500/10 text-slate-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">
terminal
</span>
</div>
<h3 className="text-base sm:text-lg font-semibold">Logging</h3>
</div>
<div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Log level</p>
<p className="text-xs sm:text-sm text-text-muted">
Controls how much the server prints to console. In production,
set to <span className="font-medium">Error</span> to only show
important errors, hiding the per-request INFO lines ( POST,
📊 DONE, [COMBO], [CHAT]). Applied immediately, no restart
needed.
</p>
</div>
<select
value={settings.logLevel || "info"}
onChange={(e) => updateLogLevel(e.target.value)}
disabled={loading}
className="shrink-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</Card>
{/* Account actions */}
<div className="flex flex-col sm:flex-row gap-2">
<Button

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import PropTypes from "prop-types";
import { Button } from "@/shared/components";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
@@ -71,12 +71,6 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
);
}
const TEST_ALL_DELAY_MS = 500;
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic, onFetchModels, fetchingModels }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
@@ -86,7 +80,6 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
const [testAllResults, setTestAllResults] = useState(null);
const [failedIds, setFailedIds] = useState([]);
const [cleaning, setCleaning] = useState(false);
const stopRef = useRef(false);
const handleTestModel = async (modelId) => {
if (testingModelId) return;
@@ -136,47 +129,40 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
const handleTestAllClick = async () => {
if (testAllRunning || allModels.length === 0) return;
stopRef.current = false;
setTestAllRunning(true);
setTestAllResults(null);
setFailedIds([]);
setModelTestResults({});
const currentResults = { passed: 0, failed: 0, failedIds: [] };
for (const model of allModels) {
if (stopRef.current) break;
const targets = [...allModels];
setTestingModelId(model.id);
await sleep(100); // let React flush the spinning state
try {
const settled = await Promise.allSettled(
targets.map(async (model) => {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
});
const data = await res.json();
const ok = data.ok;
setModelTestResults((prev) => ({ ...prev, [model.id]: ok ? "ok" : "error" }));
if (ok) currentResults.passed++;
else { currentResults.failed++; currentResults.failedIds.push(model.id); }
} catch {
setModelTestResults((prev) => ({ ...prev, [model.id]: "error" }));
currentResults.failed++;
currentResults.failedIds.push(model.id);
const data = await res.json().catch(() => ({}));
return { id: model.id, ok: res.ok ? !!data.ok : false, error: res.ok ? (data.ok ? null : (data.error || "Test failed")) : (data.error || `HTTP ${res.status}`) };
}),
);
const currentResults = { passed: 0, failed: 0, failedIds: [] };
settled.forEach((result, index) => {
const id = targets[index].id;
const ok = result.status === "fulfilled" && result.value.ok;
setModelTestResults((prev) => ({ ...prev, [id]: ok ? "ok" : "error" }));
if (ok) {
currentResults.passed += 1;
} else {
currentResults.failed += 1;
currentResults.failedIds.push(id);
}
});
setTestingModelId(null);
// Update live summary
setTestAllResults({ passed: currentResults.passed, failed: currentResults.failed });
setFailedIds([...currentResults.failedIds]);
if (!stopRef.current && model !== allModels[allModels.length - 1]) {
await sleep(TEST_ALL_DELAY_MS);
}
}
setTestAllResults({ passed: currentResults.passed, failed: currentResults.failed });
setFailedIds(currentResults.failedIds);
setTestAllRunning(false);
};
@@ -204,11 +190,6 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
<Button size="sm" variant="secondary" icon="science" onClick={handleTestAllClick} disabled={allModels.length === 0 || testAllRunning}>
{testAllRunning ? "Testing..." : "Test All"}
</Button>
{testAllRunning && (
<Button size="sm" variant="ghost" icon="stop" onClick={() => { stopRef.current = true; }}>
Stop
</Button>
)}
</div>
{(testAllResults || testAllRunning) && (
@@ -225,15 +206,10 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
</span>
<span>
{testAllRunning
? `Testing... ${(testAllResults?.passed || 0) + (testAllResults?.failed || 0)}/${allModels.length}`
? `Testing... ${allModels.length} model${allModels.length > 1 ? "s" : ""} in parallel`
: `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed`
}
</span>
{testingModelId && testAllRunning && (
<span className="text-xs text-text-muted ml-1">
(current: {testingModelId})
</span>
)}
{!testAllRunning && failedIds.length > 0 && (
<button
onClick={async () => {
@@ -278,7 +254,7 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
testStatus={modelTestResults[id]}
isTesting={testingModelId === id}
isTesting={testAllRunning || testingModelId === id}
/>
))}
</div>

View File

@@ -64,6 +64,7 @@ export default function ProviderDetailPage() {
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [providerNoAuthEnabled, setProviderNoAuthEnabled] = useState(true);
const [thinkingMode, setThinkingMode] = useState("auto");
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]);
@@ -79,6 +80,13 @@ export default function ProviderDetailPage() {
const [oneByOneSummary, setOneByOneSummary] = useState(null);
const stopOneByOneRef = useRef(false);
const [importingQoderModels, setImportingQoderModels] = useState(false);
const [showTestAllKeysModal, setShowTestAllKeysModal] = useState(false);
const [testAllKeysModelId, setTestAllKeysModelId] = useState("");
const [testAllKeysRunning, setTestAllKeysRunning] = useState(false);
const [testAllKeysResults, setTestAllKeysResults] = useState([]);
const [testAllKeysError, setTestAllKeysError] = useState("");
const [testAllModelsRunning, setTestAllModelsRunning] = useState(false);
const [testAllModelsSummary, setTestAllModelsSummary] = useState(null);
const { copied, copy } = useCopyToClipboard();
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
@@ -143,6 +151,7 @@ export default function ProviderDetailPage() {
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
const isFreeProvider = !!FREE_PROVIDERS[providerId];
const staticModels = getModelsByProviderId(providerId);
const models = providerId === "cursor" && liveModels.length > 0
? liveModels
@@ -313,6 +322,7 @@ export default function ProviderDetailPage() {
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProviderStrategy(override.fallbackStrategy || null);
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
setProviderNoAuthEnabled(override.enabled !== false);
// Load per-provider thinking config
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
setThinkingMode(thinkingCfg.mode || "auto");
@@ -405,6 +415,24 @@ export default function ProviderDetailPage() {
saveProviderStrategy("round-robin", value);
};
const handleToggleNoAuthProvider = async (enabled) => {
setProviderNoAuthEnabled(enabled);
try {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
const override = { ...(current[providerId] || {}), enabled };
const updated = { ...current, [providerId]: override };
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: updated }),
});
} catch (error) {
console.log("Error toggling noAuth provider:", error);
}
};
const saveThinkingConfig = async (mode) => {
try {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
@@ -703,6 +731,58 @@ export default function ProviderDetailPage() {
setOneByOneStopping(true);
};
const handleTestAllKeys = async () => {
if (testAllKeysRunning) return;
const modelId = testAllKeysModelId.trim();
if (!modelId) {
setTestAllKeysError("Select a model to test");
return;
}
const targets = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
if (targets.length === 0) {
setTestAllKeysError("No connections selected");
return;
}
setTestAllKeysRunning(true);
setTestAllKeysError("");
setTestAllKeysResults(targets.map((conn) => ({
connectionId: conn.id,
name: conn.name || conn.email || conn.id,
ok: null,
latencyMs: null,
error: null,
})));
const fullModel = `${providerStorageAlias}/${modelId}`;
const settled = await Promise.allSettled(
targets.map(async (conn) => {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: fullModel, connectionId: conn.id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return { ok: false, latencyMs: data.latencyMs, error: data.error || `HTTP ${res.status}` };
}
return { ok: !!data.ok, latencyMs: data.latencyMs, error: data.ok ? null : (data.error || "Test failed") };
}),
);
setTestAllKeysResults(settled.map((result, index) => {
const base = {
connectionId: targets[index].id,
name: targets[index].name || targets[index].email || targets[index].id,
};
if (result.status === "fulfilled") {
return { ...base, ok: result.value.ok, latencyMs: result.value.latencyMs, error: result.value.error };
}
return { ...base, ok: false, latencyMs: null, error: result.reason?.message || "Network error" };
}));
setTestAllKeysRunning(false);
};
const handleDelete = async (id) => {
setConfirmState({
title: "Delete Connection",
@@ -940,6 +1020,19 @@ export default function ProviderDetailPage() {
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
// Models currently available for testing (custom + builtin LLM, minus disabled)
// Shared by the per-key Test modal (testModels) and the Test All Keys modal.
const availableModels = (() => {
const all = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
const disabledSet = new Set(disabledModelIds);
return all
.filter((m) => !disabledSet.has(m.id))
.map((m) => ({ id: m.id, name: m.name || m.id }));
})();
const connectionsList = (
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
@@ -992,6 +1085,8 @@ export default function ProviderDetailPage() {
}}
onDelete={() => handleDelete(conn.id)}
oneByOneStatus={oneByOneResults[conn.id] || null}
testModels={availableModels}
providerAlias={providerStorageAlias}
/>
</div>
</div>
@@ -1070,6 +1165,54 @@ export default function ProviderDetailPage() {
}
};
const handleTestAllModels = async () => {
if (testAllModelsRunning) return;
const targets = availableModels;
if (targets.length === 0) return;
setTestAllModelsRunning(true);
setTestAllModelsSummary(null);
setModelsTestError("");
setTestingModelIds((prev) => new Set([...prev, ...targets.map((m) => m.id)]));
let passed = 0;
let failed = 0;
const settled = await Promise.allSettled(
targets.map(async (model) => {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
});
const data = await res.json().catch(() => ({}));
return { id: model.id, ok: res.ok ? !!data.ok : false, error: res.ok ? (data.ok ? null : (data.error || "Test failed")) : (data.error || `HTTP ${res.status}`) };
}),
);
settled.forEach((result, index) => {
const id = targets[index].id;
if (result.status === "fulfilled") {
if (result.value.ok) {
passed += 1;
} else {
failed += 1;
}
setModelTestResults((prev) => ({ ...prev, [id]: result.value.ok ? "ok" : "error" }));
} else {
failed += 1;
setModelTestResults((prev) => ({ ...prev, [id]: "error" }));
}
});
setTestAllModelsSummary({ passed, failed, total: targets.length });
setTestingModelIds((prev) => {
const n = new Set(prev);
targets.forEach((m) => n.delete(m.id));
return n;
});
setTestAllModelsRunning(false);
};
const renderModelsSection = () => {
if (isCompatible) {
return (
@@ -1418,7 +1561,58 @@ export default function ProviderDetailPage() {
{/* Connections */}
{isFreeNoAuth ? (
<NoAuthProxyCard providerId={providerId} />
<div className="flex flex-col gap-4">
<Card>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">{providerNoAuthEnabled ? "lock_open" : "lock"}</span>
</div>
<div>
<p className="text-sm font-medium">{providerNoAuthEnabled ? "Enabled" : "Disabled"}</p>
<p className="text-xs text-text-muted">
{providerNoAuthEnabled
? "This provider is active and used when routing requests."
: "This provider is turned off and will not be used."}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Toggle
checked={providerNoAuthEnabled}
onChange={handleToggleNoAuthProvider}
title={providerNoAuthEnabled ? "Disable provider" : "Enable provider"}
/>
</div>
</div>
</Card>
{providerNoAuthEnabled && <NoAuthProxyCard providerId={providerId} />}
</div>
) : isFreeProvider && connections.length === 0 ? (
<Card>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">{providerNoAuthEnabled ? "lock_open" : "lock"}</span>
</div>
<div>
<p className="text-sm font-medium">{providerNoAuthEnabled ? "Enabled" : "Disabled"}</p>
<p className="text-xs text-text-muted">
{providerNoAuthEnabled
? "This free provider is active and used when routing requests."
: "This provider is turned off and will not be used."}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Toggle
checked={providerNoAuthEnabled}
onChange={handleToggleNoAuthProvider}
title={providerNoAuthEnabled ? "Disable provider" : "Enable provider"}
/>
</div>
</div>
</Card>
) : (
<Card>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -1446,6 +1640,22 @@ export default function ProviderDetailPage() {
Delete Selected ({selectedConnectionIds.length})
</Button>
)}
{selectedConnectionIds.length > 0 && (
<Button
size="sm"
variant="secondary"
icon="science"
onClick={() => {
setTestAllKeysModelId(availableModels[0]?.id || "");
setTestAllKeysResults([]);
setTestAllKeysError("");
setShowTestAllKeysModal(true);
}}
disabled={testAllKeysRunning || availableModels.length === 0}
>
Test Selected ({selectedConnectionIds.length})
</Button>
)}
<Button
size="sm"
variant="secondary"
@@ -1662,7 +1872,18 @@ export default function ProviderDetailPage() {
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
return (
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{activeIds.length > 0 && (
<Button
size="sm"
variant="secondary"
icon="science"
onClick={handleTestAllModels}
disabled={testAllModelsRunning || activeIds.length === 0}
>
{testAllModelsRunning ? "Testing..." : "Test All Models"}
</Button>
)}
{disabledModelIds.length > 0 && (
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
Active All
@@ -1680,11 +1901,112 @@ export default function ProviderDetailPage() {
{!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
)}
{testAllModelsSummary && !testAllModelsRunning && (
<p className="text-xs mb-3 break-words text-text-muted">
Test All Models: {testAllModelsSummary.passed} passed, {testAllModelsSummary.failed} failed (of {testAllModelsSummary.total})
</p>
)}
{renderModelsSection()}
</Card>
{bulkActionModal}
{/* Test All Keys Modal */}
<Modal
isOpen={showTestAllKeysModal}
onClose={() => {
if (testAllKeysRunning) return;
setShowTestAllKeysModal(false);
setTestAllKeysResults([]);
setTestAllKeysError("");
}}
title="Test Selected Keys"
>
<div className="flex flex-col gap-4">
<p className="text-xs text-text-muted">
Run one model against the selected keys in parallel.
</p>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted">Model</label>
<select
value={testAllKeysModelId}
onChange={(e) => {
setTestAllKeysModelId(e.target.value);
setTestAllKeysError("");
}}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
disabled={testAllKeysRunning}
>
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name && model.name !== model.id ? `${model.name} (${model.id})` : model.id}
</option>
))}
</select>
</div>
{!!testAllKeysError && (
<p className="text-xs text-red-500 break-words">{testAllKeysError}</p>
)}
{testAllKeysResults.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-64 overflow-y-auto">
{testAllKeysResults.map((result) => (
<div
key={result.connectionId}
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
result.ok === true
? "border-green-300 bg-green-500/10 text-green-700 dark:border-green-800 dark:text-green-400"
: result.ok === false
? "border-red-300 bg-red-500/10 text-red-600 dark:border-red-800 dark:text-red-400"
: "border-border bg-black/[0.02] text-text-muted dark:bg-white/[0.03]"
}`}
>
<span className="material-symbols-outlined shrink-0 text-[16px]">
{result.ok === true ? "check_circle" : result.ok === false ? "error" : "hourglass_empty"}
</span>
<div className="min-w-0 flex-1 break-words">
<p className="font-medium truncate">
{result.name}
{typeof result.latencyMs === "number" && result.ok !== null ? ` · ${result.latencyMs}ms` : ""}
</p>
{result.ok === false && result.error && (
<p className="mt-0.5 text-xs opacity-90 break-words">{result.error}</p>
)}
</div>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleTestAllKeys}
disabled={!testAllKeysModelId || testAllKeysRunning}
loading={testAllKeysRunning}
icon="science"
fullWidth
>
{testAllKeysRunning ? "Testing..." : "Run Test"}
</Button>
<Button
variant="secondary"
onClick={() => {
if (testAllKeysRunning) return;
setShowTestAllKeysModal(false);
setTestAllKeysResults([]);
setTestAllKeysError("");
}}
disabled={testAllKeysRunning}
fullWidth
>
Close
</Button>
</div>
</div>
</Modal>
{/* Modals */}
{providerId === "kiro" ? (
<KiroOAuthWrapper

View File

@@ -105,6 +105,7 @@ export default function ProvidersPage() {
useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const [providerStrategies, setProviderStrategies] = useState({});
const notify = useNotificationStore();
const searchQuery = useHeaderSearchStore((s) => s.query);
const registerSearch = useHeaderSearchStore((s) => s.register);
@@ -148,15 +149,18 @@ export default function ProvidersPage() {
useEffect(() => {
const fetchData = async () => {
try {
const [connectionsRes, nodesRes] = await Promise.all([
const [connectionsRes, nodesRes, settingsRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/provider-nodes"),
fetch("/api/settings", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
if (connectionsRes.ok)
setConnections(connectionsData.connections || []);
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
setProviderStrategies(settingsData.providerStrategies || {});
} catch (error) {
console.log("Error fetching data:", error);
} finally {
@@ -231,6 +235,25 @@ export default function ProvidersPage() {
);
};
// Toggle a free provider (noAuth or zero-connection) via providerStrategies.enabled.
const handleToggleNoAuthProvider = async (providerId, newActive) => {
try {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
const allStrategies = { ...(data.providerStrategies || {}) };
const override = { ...(allStrategies[providerId] || {}), enabled: newActive };
allStrategies[providerId] = override;
setProviderStrategies(allStrategies);
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: allStrategies }),
});
} catch (error) {
console.log("Error toggling noAuth provider:", error);
}
};
const handleBatchTest = async (mode, providerId = null) => {
if (testingMode) return;
setTestingMode(mode === "provider" ? providerId : mode);
@@ -509,9 +532,12 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, freeAuthTypes)}
authType="free"
isFree
providerStrategies={providerStrategies}
onToggle={(active) =>
handleToggleProvider(key, freeAuthTypes, active)
}
onToggleNoAuth={(active) => handleToggleNoAuthProvider(key, active)}
/>
);
})}
@@ -653,9 +679,23 @@ export default function ProvidersPage() {
);
}
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
function ProviderCard({ providerId, provider, stats, authType, onToggle, providerStrategies, onToggleNoAuth, isFree }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isNoAuth = !!provider.noAuth;
const isNoAuthDisabled = isNoAuth && providerStrategies?.[providerId]?.enabled === false;
const effectiveDisabled = allDisabled || isNoAuthDisabled;
// Free providers without real connections (noAuth, or free OAuth like gemini-cli
// with zero connections) toggle via providerStrategies.enabled.
const usesNoAuthToggle = isNoAuth || (isFree && stats.total === 0);
const handleToggleClick = () => {
if (usesNoAuthToggle) {
onToggleNoAuth(effectiveDisabled);
} else {
onToggle(!allDisabled ? false : true);
}
};
const dotColors = {
free: "bg-green-500",
@@ -674,7 +714,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Link href={`/dashboard/providers/${providerId}`} className="group min-w-0">
<Card
padding="xs"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${effectiveDisabled ? "opacity-50" : ""}`}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
@@ -698,7 +738,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<div className="min-w-0">
<h3 className="truncate font-semibold">{provider.name}</h3>
<div className="flex min-w-0 items-center gap-1.5 text-xs flex-wrap">
{allDisabled ? (
{effectiveDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">
@@ -721,20 +761,20 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{stats.total > 0 && (
{(stats.total > 0 || usesNoAuthToggle) && (
<div
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(!allDisabled ? false : true);
handleToggleClick();
}}
>
<Toggle
size="sm"
checked={!allDisabled}
checked={!effectiveDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={effectiveDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
)}
@@ -752,15 +792,21 @@ ProviderCard.propTypes = {
name: PropTypes.string.isRequired,
color: PropTypes.string,
textIcon: PropTypes.string,
noAuth: PropTypes.bool,
}).isRequired,
stats: PropTypes.shape({
connected: PropTypes.number,
error: PropTypes.number,
errorCode: PropTypes.string,
errorTime: PropTypes.string,
total: PropTypes.number,
allDisabled: PropTypes.bool,
}).isRequired,
authType: PropTypes.string,
onToggle: PropTypes.func,
providerStrategies: PropTypes.object,
onToggleNoAuth: PropTypes.func,
isFree: PropTypes.bool,
};
function ApiKeyProviderCard({

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import { resetComboRotation } from "open-sse/services/combo.js";
import { setLogLevel } from "@/sse/utils/logger";
import bcrypt from "bcryptjs";
export const dynamic = "force-dynamic";
@@ -78,6 +79,11 @@ export async function PATCH(request) {
const settings = await updateSettings(body);
// Apply log level immediately (no restart required)
if (Object.prototype.hasOwnProperty.call(body, "logLevel")) {
setLogLevel(body.logLevel);
}
// Apply outbound proxy settings immediately (no restart required)
if (
Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") ||

View File

@@ -27,6 +27,7 @@ const DEFAULT_SETTINGS = {
requireApiKey: true,
tunnelDashboardAccess: true,
authMode: "password",
logLevel: "info",
oidcIssuerUrl: "",
oidcClientId: "",
oidcClientSecret: "",

View File

@@ -3,6 +3,7 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { existsSync } from "fs";
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
import { setLogLevel } from "@/sse/utils/logger";
import {
enableTunnel, enableTailscale,
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
@@ -83,6 +84,14 @@ async function runHeavyStartup() {
await cleanupProviderConnections();
const settings = await getSettings();
// Apply persisted log level (dashboard Settings → Logging) so production
// logs stay quiet (ERROR only) even after a restart.
try {
setLogLevel(settings.logLevel);
} catch (e) {
console.warn("[InitApp] setLogLevel failed:", e.message);
}
// Auto-resume tunnel (once per process)
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
g.tunnelAutoResumed = true;

View File

@@ -41,10 +41,19 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
const providerId = resolveProviderId(provider);
// Any free-tier provider can be toggled off via settings (enabled: false),
// same as disabling connections. Checked before both the noAuth branch and
// the normal connections branch so e.g. gemini-cli (free OAuth) can also be turned off.
const earlySettings = await getSettings();
const earlyOverride = (earlySettings.providerStrategies || {})[providerId] || {};
if (FREE_PROVIDERS[providerId] && earlyOverride.enabled === false) {
log.debug("AUTH", `${provider} | free provider disabled via settings`);
return null;
}
// Inject a virtual connection for no-auth free providers (with optional proxy pool from settings)
if (FREE_PROVIDERS[providerId]?.noAuth) {
const settings = await getSettings();
const override = (settings.providerStrategies || {})[providerId] || {};
const override = earlyOverride;
const strategy = override.rotateStrategy || "none";
let pickedId = override.proxyPoolId || null;
if (strategy !== "none") {

View File

@@ -7,7 +7,23 @@ const LOG_LEVELS = {
ERROR: 3
};
const LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO;
// Runtime log level. Defaults from LOG_LEVEL env, but can be changed at runtime
// via setLogLevel (dashboard Settings → Logging). WARN/ERROR hide the noisy
// INFO request lines (▶ POST / 📊 DONE / [COMBO] / [CHAT] ...) in production.
let LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO;
export function setLogLevel(level) {
const normalized = String(level || "").toUpperCase();
if (Object.prototype.hasOwnProperty.call(LOG_LEVELS, normalized)) {
LEVEL = LOG_LEVELS[normalized];
return true;
}
return false;
}
export function getLogLevel() {
return Object.keys(LOG_LEVELS).find((key) => LOG_LEVELS[key] === LEVEL) || "INFO";
}
function formatTime() {
return new Date().toLocaleTimeString("en-US", { hour12: false });
@@ -33,6 +49,7 @@ export function tagForSession(seed) {
}
// Print one correlated line: [time] tag symbol message
// Visible at INFO and below (hidden by WARN/ERROR log levels in production).
export function line(tag, symbol, message) {
if (LEVEL > LOG_LEVELS.INFO) return;
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
@@ -95,17 +112,20 @@ export function error(tag, message, data) {
}
export function request(method, path, extra) {
if (LEVEL > LOG_LEVELS.INFO) return;
const dataStr = extra ? ` ${formatData(extra)}` : "";
console.log(`\x1b[36m[${formatTime()}] 📥 ${method} ${path}${dataStr}\x1b[0m`);
}
export function response(status, duration, extra) {
if (LEVEL > LOG_LEVELS.INFO) return;
const icon = status < 400 ? "📤" : "💥";
const dataStr = extra ? ` ${formatData(extra)}` : "";
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`);
}
export function stream(event, data) {
if (LEVEL > LOG_LEVELS.INFO) return;
const dataStr = data ? ` ${formatData(data)}` : "";
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${dataStr}`);
}