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:
@@ -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
3
.gitignore
vendored
@@ -89,3 +89,6 @@ graphify-out/*
|
||||
|
||||
# CommandCode CLI local state (auth/taste/projects)
|
||||
.commandcode/
|
||||
|
||||
# Pi subagent run artifacts
|
||||
.pi-subagents/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -27,6 +27,7 @@ const DEFAULT_SETTINGS = {
|
||||
requireApiKey: true,
|
||||
tunnelDashboardAccess: true,
|
||||
authMode: "password",
|
||||
logLevel: "info",
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcClientSecret: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user