Fix MITM on window
This commit is contained in:
@@ -240,7 +240,23 @@ export default function AntigravityToolCard({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{/* Start/Stop Button - always on top */}
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center gap-3">
|
||||
{[
|
||||
{ label: "DNS", ok: status?.dnsConfigured },
|
||||
{ label: "Cert", ok: status?.certExists },
|
||||
{ label: "Server", ok: status?.running },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<span className={`material-symbols-outlined text-[14px] ${ok ? "text-green-500" : "text-text-muted"}`}>
|
||||
{ok ? "check_circle" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<span className={`text-xs ${ok ? "text-green-500" : "text-text-muted"}`}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Start/Stop Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function ProviderDetailPage() {
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(false);
|
||||
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const providerInfo = providerNode
|
||||
@@ -307,9 +308,21 @@ export default function ProviderDetailPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return <p className="text-sm text-text-muted">No models configured</p>;
|
||||
}
|
||||
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
|
||||
const customModels = Object.entries(modelAliases)
|
||||
.filter(([alias, fullModel]) => {
|
||||
const prefix = `${providerStorageAlias}/`;
|
||||
if (!fullModel.startsWith(prefix)) return false;
|
||||
const modelId = fullModel.slice(prefix.length);
|
||||
// Only show if not already in hardcoded list
|
||||
return !models.some((m) => m.id === modelId) && alias === modelId;
|
||||
})
|
||||
.map(([alias, fullModel]) => ({
|
||||
id: fullModel.slice(`${providerStorageAlias}/`.length),
|
||||
alias,
|
||||
fullModel,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{models.map((model) => {
|
||||
@@ -332,6 +345,30 @@ export default function ProviderDetailPage() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom models inline */}
|
||||
{customModels.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={{ id: model.id }}
|
||||
fullModel={`${providerDisplayAlias}/${model.id}`}
|
||||
alias={model.alias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={() => {}}
|
||||
onDeleteAlias={() => handleDeleteAlias(model.alias)}
|
||||
isCustom
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add model button — inline, same style as model chips */}
|
||||
<button
|
||||
onClick={() => setShowAddCustomModel(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-dashed border-black/15 dark:border-white/15 text-xs text-text-muted hover:text-primary hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">add</span>
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -526,7 +563,8 @@ export default function ProviderDetailPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={testingModels ? "progress_activity" : "science"}
|
||||
icon="science"
|
||||
loading={testingModels}
|
||||
onClick={handleTestModels}
|
||||
disabled={testingModels}
|
||||
>
|
||||
@@ -584,11 +622,23 @@ export default function ProviderDetailPage() {
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
/>
|
||||
)}
|
||||
{!isCompatible && !providerInfo?.passthroughModels && (
|
||||
<AddCustomModelModal
|
||||
isOpen={showAddCustomModel}
|
||||
providerAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
onSave={async (modelId) => {
|
||||
await handleSetAlias(modelId, modelId, providerStorageAlias);
|
||||
setShowAddCustomModel(false);
|
||||
}}
|
||||
onClose={() => setShowAddCustomModel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
|
||||
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, onDeleteAlias }) {
|
||||
const borderColor = testStatus === "ok"
|
||||
? "border-green-500/40"
|
||||
: testStatus === "error"
|
||||
@@ -602,7 +652,7 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<div className={`group flex items-center gap-2 px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<span
|
||||
className="material-symbols-outlined text-base"
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
@@ -619,6 +669,15 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
|
||||
title="Remove custom model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -632,6 +691,8 @@ ModelRow.propTypes = {
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||
isCustom: PropTypes.bool,
|
||||
onDeleteAlias: PropTypes.func,
|
||||
};
|
||||
|
||||
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
|
||||
@@ -1553,3 +1614,115 @@ EditCompatibleNodeModal.propTypes = {
|
||||
isAnthropic: PropTypes.bool,
|
||||
};
|
||||
|
||||
function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) {
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
|
||||
const [testError, setTestError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
|
||||
}, [isOpen]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!modelId.trim()) return;
|
||||
setTestStatus("testing");
|
||||
setTestError("");
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestStatus(data.ok ? "ok" : "error");
|
||||
setTestError(data.error || "");
|
||||
} catch (err) {
|
||||
setTestStatus("error");
|
||||
setTestError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modelId.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(modelId.trim());
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") handleTest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Add Custom Model">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Model ID</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={modelId}
|
||||
onChange={(e) => { setModelId(e.target.value); setTestStatus(null); setTestError(""); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g. claude-opus-4-5"
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="science"
|
||||
loading={testStatus === "testing"}
|
||||
onClick={handleTest}
|
||||
disabled={!modelId.trim() || testStatus === "testing"}
|
||||
>
|
||||
{testStatus === "testing" ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Sent to provider as: <code className="font-mono bg-sidebar px-1 rounded">{modelId.trim() || "model-id"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testStatus === "ok" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
Model is reachable
|
||||
</div>
|
||||
)}
|
||||
{testStatus === "error" && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-500">
|
||||
<span className="material-symbols-outlined text-base shrink-0">cancel</span>
|
||||
<span>{testError || "Model not reachable"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
fullWidth
|
||||
size="sm"
|
||||
disabled={!modelId.trim() || saving}
|
||||
>
|
||||
{saving ? "Adding..." : "Add Model"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCustomModelModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
providerAlias: PropTypes.string.isRequired,
|
||||
providerDisplayAlias: PropTypes.string.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function ProvidersPage() {
|
||||
title="Test all OAuth connections"
|
||||
aria-label="Test all OAuth connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}>
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
||||
@@ -260,7 +260,7 @@ export default function ProvidersPage() {
|
||||
title="Test all Free connections"
|
||||
aria-label="Test all Free provider connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}>
|
||||
{testingMode === "free" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "free" ? "Testing..." : "Test All"}
|
||||
@@ -297,7 +297,7 @@ export default function ProvidersPage() {
|
||||
title="Test all API Key connections"
|
||||
aria-label="Test all API Key connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}>
|
||||
{testingMode === "apikey" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "apikey" ? "Testing..." : "Test All"}
|
||||
@@ -335,7 +335,7 @@ export default function ProvidersPage() {
|
||||
}`}
|
||||
title="Test all Compatible connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span className={`material-symbols-outlined text-[14px]${testingMode === "compatible" ? " animate-spin" : ""}`}>
|
||||
{testingMode === "compatible" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "compatible" ? "Testing..." : "Test All"}
|
||||
|
||||
@@ -144,7 +144,7 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
|
||||
const error = !active && errorSet.has(p.provider?.toLowerCase());
|
||||
const nodeId = `provider-${p.provider}`;
|
||||
const data = {
|
||||
label: config.name || p.name || p.provider,
|
||||
label: (config.name !== p.provider ? config.name : null) || p.name || p.provider,
|
||||
color: config.color || "#6b7280",
|
||||
imageUrl: getProviderImageUrl(p.provider),
|
||||
textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),
|
||||
|
||||
@@ -21,7 +21,7 @@ const checkClaudeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where claude" : "command -v claude";
|
||||
await execAsync(command);
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -76,7 +76,7 @@ const checkCodexInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where codex" : "command -v codex";
|
||||
await execAsync(command);
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -17,7 +17,7 @@ const checkDroidInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where droid" : "command -v droid";
|
||||
await execAsync(command);
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -17,7 +17,7 @@ const checkOpenClawInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where openclaw" : "command -v openclaw";
|
||||
await execAsync(command);
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
49
src/app/api/models/test/route.js
Normal file
49
src/app/api/models/test/route.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { model } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
// Get an active internal API key for auth (if requireApiKey is enabled)
|
||||
let apiKey = null;
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
apiKey = keys.find((k) => k.isActive !== false)?.key || null;
|
||||
} catch {}
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const start = Date.now();
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
// 200 = ok; 400 = bad request but auth passed (model reachable)
|
||||
const ok = res.status === 200 || res.status === 400;
|
||||
let error = null;
|
||||
if (!ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok, latencyMs, error });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, createProviderConnection, getProviderNodeById } from "@/models";
|
||||
import { getProviderConnections, createProviderConnection, getProviderNodeById, getProviderNodes } from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
@@ -7,15 +7,31 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
|
||||
// Build nodeNameMap for compatible providers (id → name)
|
||||
let nodeNameMap = {};
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
for (const node of nodes) {
|
||||
if (node.id && node.name) nodeNameMap[node.id] = node.name;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Hide sensitive fields
|
||||
const safeConnections = connections.map(c => ({
|
||||
...c,
|
||||
apiKey: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
}));
|
||||
// Hide sensitive fields, enrich name for compatible providers
|
||||
const safeConnections = connections.map(c => {
|
||||
const isCompatible = isOpenAICompatibleProvider(c.provider) || isAnthropicCompatibleProvider(c.provider);
|
||||
const name = isCompatible
|
||||
? (nodeNameMap[c.provider] || c.providerSpecificData?.nodeName || c.provider)
|
||||
: c.name;
|
||||
return {
|
||||
...c,
|
||||
name,
|
||||
apiKey: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ connections: safeConnections });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user