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
|
API_KEY_SECRET=endpoint-proxy-api-key-secret
|
||||||
MACHINE_ID_SALT=endpoint-proxy-salt
|
MACHINE_ID_SALT=endpoint-proxy-salt
|
||||||
ENABLE_REQUEST_LOGS=false
|
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
|
OBSERVABILITY_ENABLED=true
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
REQUIRE_API_KEY=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 CLI local state (auth/taste/projects)
|
||||||
.commandcode/
|
.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) => {
|
const updateShowOnlyComboModels = async (showOnlyComboModels) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
@@ -1448,6 +1463,41 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Account actions */}
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button } from "@/shared/components";
|
import { Button } from "@/shared/components";
|
||||||
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
|
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 }) {
|
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic, onFetchModels, fetchingModels }) {
|
||||||
const [newModel, setNewModel] = useState("");
|
const [newModel, setNewModel] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -86,7 +80,6 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
|||||||
const [testAllResults, setTestAllResults] = useState(null);
|
const [testAllResults, setTestAllResults] = useState(null);
|
||||||
const [failedIds, setFailedIds] = useState([]);
|
const [failedIds, setFailedIds] = useState([]);
|
||||||
const [cleaning, setCleaning] = useState(false);
|
const [cleaning, setCleaning] = useState(false);
|
||||||
const stopRef = useRef(false);
|
|
||||||
|
|
||||||
const handleTestModel = async (modelId) => {
|
const handleTestModel = async (modelId) => {
|
||||||
if (testingModelId) return;
|
if (testingModelId) return;
|
||||||
@@ -136,47 +129,40 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
|||||||
|
|
||||||
const handleTestAllClick = async () => {
|
const handleTestAllClick = async () => {
|
||||||
if (testAllRunning || allModels.length === 0) return;
|
if (testAllRunning || allModels.length === 0) return;
|
||||||
stopRef.current = false;
|
|
||||||
setTestAllRunning(true);
|
setTestAllRunning(true);
|
||||||
setTestAllResults(null);
|
setTestAllResults(null);
|
||||||
setFailedIds([]);
|
setFailedIds([]);
|
||||||
setModelTestResults({});
|
setModelTestResults({});
|
||||||
|
|
||||||
const currentResults = { passed: 0, failed: 0, failedIds: [] };
|
const targets = [...allModels];
|
||||||
for (const model of allModels) {
|
|
||||||
if (stopRef.current) break;
|
|
||||||
|
|
||||||
setTestingModelId(model.id);
|
const settled = await Promise.allSettled(
|
||||||
await sleep(100); // let React flush the spinning state
|
targets.map(async (model) => {
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/models/test", {
|
const res = await fetch("/api/models/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
|
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
const ok = data.ok;
|
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}`) };
|
||||||
setModelTestResults((prev) => ({ ...prev, [model.id]: ok ? "ok" : "error" }));
|
}),
|
||||||
if (ok) currentResults.passed++;
|
);
|
||||||
else { currentResults.failed++; currentResults.failedIds.push(model.id); }
|
|
||||||
} catch {
|
const currentResults = { passed: 0, failed: 0, failedIds: [] };
|
||||||
setModelTestResults((prev) => ({ ...prev, [model.id]: "error" }));
|
settled.forEach((result, index) => {
|
||||||
currentResults.failed++;
|
const id = targets[index].id;
|
||||||
currentResults.failedIds.push(model.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 });
|
setTestAllResults({ passed: currentResults.passed, failed: currentResults.failed });
|
||||||
setFailedIds([...currentResults.failedIds]);
|
setFailedIds(currentResults.failedIds);
|
||||||
|
|
||||||
if (!stopRef.current && model !== allModels[allModels.length - 1]) {
|
|
||||||
await sleep(TEST_ALL_DELAY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestAllRunning(false);
|
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}>
|
<Button size="sm" variant="secondary" icon="science" onClick={handleTestAllClick} disabled={allModels.length === 0 || testAllRunning}>
|
||||||
{testAllRunning ? "Testing..." : "Test All"}
|
{testAllRunning ? "Testing..." : "Test All"}
|
||||||
</Button>
|
</Button>
|
||||||
{testAllRunning && (
|
|
||||||
<Button size="sm" variant="ghost" icon="stop" onClick={() => { stopRef.current = true; }}>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(testAllResults || testAllRunning) && (
|
{(testAllResults || testAllRunning) && (
|
||||||
@@ -225,15 +206,10 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{testAllRunning
|
{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`
|
: `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed`
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
{testingModelId && testAllRunning && (
|
|
||||||
<span className="text-xs text-text-muted ml-1">
|
|
||||||
(current: {testingModelId})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!testAllRunning && failedIds.length > 0 && (
|
{!testAllRunning && failedIds.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -278,7 +254,7 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
|||||||
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
|
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
|
||||||
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
||||||
testStatus={modelTestResults[id]}
|
testStatus={modelTestResults[id]}
|
||||||
isTesting={testingModelId === id}
|
isTesting={testAllRunning || testingModelId === id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function ProviderDetailPage() {
|
|||||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
|
const [providerNoAuthEnabled, setProviderNoAuthEnabled] = useState(true);
|
||||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||||
@@ -79,6 +80,13 @@ export default function ProviderDetailPage() {
|
|||||||
const [oneByOneSummary, setOneByOneSummary] = useState(null);
|
const [oneByOneSummary, setOneByOneSummary] = useState(null);
|
||||||
const stopOneByOneRef = useRef(false);
|
const stopOneByOneRef = useRef(false);
|
||||||
const [importingQoderModels, setImportingQoderModels] = useState(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 { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
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 isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
|
||||||
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
|
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
|
||||||
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
||||||
|
const isFreeProvider = !!FREE_PROVIDERS[providerId];
|
||||||
const staticModels = getModelsByProviderId(providerId);
|
const staticModels = getModelsByProviderId(providerId);
|
||||||
const models = providerId === "cursor" && liveModels.length > 0
|
const models = providerId === "cursor" && liveModels.length > 0
|
||||||
? liveModels
|
? liveModels
|
||||||
@@ -313,6 +322,7 @@ export default function ProviderDetailPage() {
|
|||||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||||
setProviderStrategy(override.fallbackStrategy || null);
|
setProviderStrategy(override.fallbackStrategy || null);
|
||||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||||
|
setProviderNoAuthEnabled(override.enabled !== false);
|
||||||
// Load per-provider thinking config
|
// Load per-provider thinking config
|
||||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||||
setThinkingMode(thinkingCfg.mode || "auto");
|
setThinkingMode(thinkingCfg.mode || "auto");
|
||||||
@@ -405,6 +415,24 @@ export default function ProviderDetailPage() {
|
|||||||
saveProviderStrategy("round-robin", value);
|
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) => {
|
const saveThinkingConfig = async (mode) => {
|
||||||
try {
|
try {
|
||||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
@@ -703,6 +731,58 @@ export default function ProviderDetailPage() {
|
|||||||
setOneByOneStopping(true);
|
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) => {
|
const handleDelete = async (id) => {
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: "Delete Connection",
|
title: "Delete Connection",
|
||||||
@@ -940,6 +1020,19 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
|
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 = (
|
const connectionsList = (
|
||||||
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||||
{connections
|
{connections
|
||||||
@@ -992,6 +1085,8 @@ export default function ProviderDetailPage() {
|
|||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(conn.id)}
|
onDelete={() => handleDelete(conn.id)}
|
||||||
oneByOneStatus={oneByOneResults[conn.id] || null}
|
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||||
|
testModels={availableModels}
|
||||||
|
providerAlias={providerStorageAlias}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 = () => {
|
const renderModelsSection = () => {
|
||||||
if (isCompatible) {
|
if (isCompatible) {
|
||||||
return (
|
return (
|
||||||
@@ -1418,7 +1561,58 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
{/* Connections */}
|
{/* Connections */}
|
||||||
{isFreeNoAuth ? (
|
{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>
|
<Card>
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<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})
|
Delete Selected ({selectedConnectionIds.length})
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -1662,7 +1872,18 @@ export default function ProviderDetailPage() {
|
|||||||
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
|
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
|
||||||
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
||||||
return (
|
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 && (
|
{disabledModelIds.length > 0 && (
|
||||||
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
|
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
|
||||||
Active All
|
Active All
|
||||||
@@ -1680,11 +1901,112 @@ export default function ProviderDetailPage() {
|
|||||||
{!!modelsTestError && (
|
{!!modelsTestError && (
|
||||||
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
|
<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()}
|
{renderModelsSection()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{bulkActionModal}
|
{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 */}
|
{/* Modals */}
|
||||||
{providerId === "kiro" ? (
|
{providerId === "kiro" ? (
|
||||||
<KiroOAuthWrapper
|
<KiroOAuthWrapper
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export default function ProvidersPage() {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [testingMode, setTestingMode] = useState(null);
|
const [testingMode, setTestingMode] = useState(null);
|
||||||
const [testResults, setTestResults] = useState(null);
|
const [testResults, setTestResults] = useState(null);
|
||||||
|
const [providerStrategies, setProviderStrategies] = useState({});
|
||||||
const notify = useNotificationStore();
|
const notify = useNotificationStore();
|
||||||
const searchQuery = useHeaderSearchStore((s) => s.query);
|
const searchQuery = useHeaderSearchStore((s) => s.query);
|
||||||
const registerSearch = useHeaderSearchStore((s) => s.register);
|
const registerSearch = useHeaderSearchStore((s) => s.register);
|
||||||
@@ -148,15 +149,18 @@ export default function ProvidersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [connectionsRes, nodesRes] = await Promise.all([
|
const [connectionsRes, nodesRes, settingsRes] = await Promise.all([
|
||||||
fetch("/api/providers"),
|
fetch("/api/providers"),
|
||||||
fetch("/api/provider-nodes"),
|
fetch("/api/provider-nodes"),
|
||||||
|
fetch("/api/settings", { cache: "no-store" }),
|
||||||
]);
|
]);
|
||||||
const connectionsData = await connectionsRes.json();
|
const connectionsData = await connectionsRes.json();
|
||||||
const nodesData = await nodesRes.json();
|
const nodesData = await nodesRes.json();
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
if (connectionsRes.ok)
|
if (connectionsRes.ok)
|
||||||
setConnections(connectionsData.connections || []);
|
setConnections(connectionsData.connections || []);
|
||||||
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
|
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
|
||||||
|
setProviderStrategies(settingsData.providerStrategies || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching data:", error);
|
console.log("Error fetching data:", error);
|
||||||
} finally {
|
} 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) => {
|
const handleBatchTest = async (mode, providerId = null) => {
|
||||||
if (testingMode) return;
|
if (testingMode) return;
|
||||||
setTestingMode(mode === "provider" ? providerId : mode);
|
setTestingMode(mode === "provider" ? providerId : mode);
|
||||||
@@ -509,9 +532,12 @@ export default function ProvidersPage() {
|
|||||||
provider={info}
|
provider={info}
|
||||||
stats={getProviderStats(key, freeAuthTypes)}
|
stats={getProviderStats(key, freeAuthTypes)}
|
||||||
authType="free"
|
authType="free"
|
||||||
|
isFree
|
||||||
|
providerStrategies={providerStrategies}
|
||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(key, freeAuthTypes, 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 { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||||
const isNoAuth = !!provider.noAuth;
|
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 = {
|
const dotColors = {
|
||||||
free: "bg-green-500",
|
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">
|
<Link href={`/dashboard/providers/${providerId}`} className="group min-w-0">
|
||||||
<Card
|
<Card
|
||||||
padding="xs"
|
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 justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center 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">
|
<div className="min-w-0">
|
||||||
<h3 className="truncate font-semibold">{provider.name}</h3>
|
<h3 className="truncate font-semibold">{provider.name}</h3>
|
||||||
<div className="flex min-w-0 items-center gap-1.5 text-xs flex-wrap">
|
<div className="flex min-w-0 items-center gap-1.5 text-xs flex-wrap">
|
||||||
{allDisabled ? (
|
{effectiveDisabled ? (
|
||||||
<Badge variant="default" size="sm">
|
<Badge variant="default" size="sm">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined text-[12px]">
|
<span className="material-symbols-outlined text-[12px]">
|
||||||
@@ -721,20 +761,20 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{stats.total > 0 && (
|
{(stats.total > 0 || usesNoAuthToggle) && (
|
||||||
<div
|
<div
|
||||||
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
|
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle(!allDisabled ? false : true);
|
handleToggleClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={!allDisabled}
|
checked={!effectiveDisabled}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
title={effectiveDisabled ? "Enable provider" : "Disable provider"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -752,15 +792,21 @@ ProviderCard.propTypes = {
|
|||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
color: PropTypes.string,
|
color: PropTypes.string,
|
||||||
textIcon: PropTypes.string,
|
textIcon: PropTypes.string,
|
||||||
|
noAuth: PropTypes.bool,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
stats: PropTypes.shape({
|
stats: PropTypes.shape({
|
||||||
connected: PropTypes.number,
|
connected: PropTypes.number,
|
||||||
error: PropTypes.number,
|
error: PropTypes.number,
|
||||||
errorCode: PropTypes.string,
|
errorCode: PropTypes.string,
|
||||||
errorTime: PropTypes.string,
|
errorTime: PropTypes.string,
|
||||||
|
total: PropTypes.number,
|
||||||
|
allDisabled: PropTypes.bool,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
authType: PropTypes.string,
|
authType: PropTypes.string,
|
||||||
onToggle: PropTypes.func,
|
onToggle: PropTypes.func,
|
||||||
|
providerStrategies: PropTypes.object,
|
||||||
|
onToggleNoAuth: PropTypes.func,
|
||||||
|
isFree: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ApiKeyProviderCard({
|
function ApiKeyProviderCard({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||||
|
import { setLogLevel } from "@/sse/utils/logger";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -78,6 +79,11 @@ export async function PATCH(request) {
|
|||||||
|
|
||||||
const settings = await updateSettings(body);
|
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)
|
// Apply outbound proxy settings immediately (no restart required)
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") ||
|
Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") ||
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
requireApiKey: true,
|
requireApiKey: true,
|
||||||
tunnelDashboardAccess: true,
|
tunnelDashboardAccess: true,
|
||||||
authMode: "password",
|
authMode: "password",
|
||||||
|
logLevel: "info",
|
||||||
oidcIssuerUrl: "",
|
oidcIssuerUrl: "",
|
||||||
oidcClientId: "",
|
oidcClientId: "",
|
||||||
oidcClientSecret: "",
|
oidcClientSecret: "",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "url";
|
|||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
||||||
|
import { setLogLevel } from "@/sse/utils/logger";
|
||||||
import {
|
import {
|
||||||
enableTunnel, enableTailscale,
|
enableTunnel, enableTailscale,
|
||||||
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
||||||
@@ -83,6 +84,14 @@ async function runHeavyStartup() {
|
|||||||
await cleanupProviderConnections();
|
await cleanupProviderConnections();
|
||||||
const settings = await getSettings();
|
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)
|
// Auto-resume tunnel (once per process)
|
||||||
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
||||||
g.tunnelAutoResumed = true;
|
g.tunnelAutoResumed = true;
|
||||||
|
|||||||
@@ -41,10 +41,19 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
|||||||
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
|
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
|
||||||
const providerId = resolveProviderId(provider);
|
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)
|
// Inject a virtual connection for no-auth free providers (with optional proxy pool from settings)
|
||||||
if (FREE_PROVIDERS[providerId]?.noAuth) {
|
if (FREE_PROVIDERS[providerId]?.noAuth) {
|
||||||
const settings = await getSettings();
|
const override = earlyOverride;
|
||||||
const override = (settings.providerStrategies || {})[providerId] || {};
|
|
||||||
const strategy = override.rotateStrategy || "none";
|
const strategy = override.rotateStrategy || "none";
|
||||||
let pickedId = override.proxyPoolId || null;
|
let pickedId = override.proxyPoolId || null;
|
||||||
if (strategy !== "none") {
|
if (strategy !== "none") {
|
||||||
|
|||||||
@@ -7,7 +7,23 @@ const LOG_LEVELS = {
|
|||||||
ERROR: 3
|
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() {
|
function formatTime() {
|
||||||
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
||||||
@@ -33,6 +49,7 @@ export function tagForSession(seed) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Print one correlated line: [time] tag symbol message
|
// 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) {
|
export function line(tag, symbol, message) {
|
||||||
if (LEVEL > LOG_LEVELS.INFO) return;
|
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||||
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
|
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
|
||||||
@@ -95,17 +112,20 @@ export function error(tag, message, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function request(method, path, extra) {
|
export function request(method, path, extra) {
|
||||||
|
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||||
const dataStr = extra ? ` ${formatData(extra)}` : "";
|
const dataStr = extra ? ` ${formatData(extra)}` : "";
|
||||||
console.log(`\x1b[36m[${formatTime()}] 📥 ${method} ${path}${dataStr}\x1b[0m`);
|
console.log(`\x1b[36m[${formatTime()}] 📥 ${method} ${path}${dataStr}\x1b[0m`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function response(status, duration, extra) {
|
export function response(status, duration, extra) {
|
||||||
|
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||||
const icon = status < 400 ? "📤" : "💥";
|
const icon = status < 400 ? "📤" : "💥";
|
||||||
const dataStr = extra ? ` ${formatData(extra)}` : "";
|
const dataStr = extra ? ` ${formatData(extra)}` : "";
|
||||||
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`);
|
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stream(event, data) {
|
export function stream(event, data) {
|
||||||
|
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||||
const dataStr = data ? ` ${formatData(data)}` : "";
|
const dataStr = data ? ` ${formatData(data)}` : "";
|
||||||
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${dataStr}`);
|
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${dataStr}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user