Enhance token refresh functionality across multiple executors

- Updated refreshCredentials methods in various executors (Antigravity, Base, Default, Github, Kiro) to accept optional proxyOptions for improved proxy handling.
- Modified token refresh logic to utilize proxy-aware fetch for better network management.
- Enhanced usage retrieval functions to support proxy options, ensuring seamless integration with proxy configurations.
- Updated ModelSelectModal and ProviderInfoCard components to incorporate kind filtering for improved user experience in model selection.
- Added validation for API keys in the provider validation route, including support for webSearch/webFetch providers.
This commit is contained in:
decolua
2026-04-28 17:28:57 +07:00
parent 1bb621317d
commit 8f81363675
45 changed files with 2924 additions and 289 deletions
+2 -1
View File
@@ -339,7 +339,7 @@ function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown
);
}
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) {
// Initialize state with combo values - key prop on parent handles reset on remount
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(combo?.models || []);
@@ -504,6 +504,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
kindFilter={kindFilter}
/>
</>
);
@@ -1446,10 +1446,15 @@ export default function MediaProviderDetailPage() {
/>
)}
{/* Provider Info — config-driven, only for providers with searchConfig/fetchConfig */}
{!isCustom && (provider.searchConfig || provider.fetchConfig) && (
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, searchViaChat */}
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.searchViaChat) && (
<ProviderInfoCard
config={kind === "webFetch" ? provider.fetchConfig : provider.searchConfig}
config={
kind === "webFetch"
? provider.fetchConfig
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, costPerQuery: 0 }
}
provider={provider}
title={`${kindConfig.label} Config`}
/>
)}
@@ -1,6 +1,6 @@
"use client";
import { useParams, notFound } from "next/navigation";
import { useParams, notFound, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
@@ -72,10 +72,18 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) {
export default function MediaProviderKindPage() {
const { kind } = useParams();
const router = useRouter();
const [connections, setConnections] = useState([]);
const [customNodes, setCustomNodes] = useState([]);
const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false);
// webSearch/webFetch listing pages are merged into /web
useEffect(() => {
if (kind === "webSearch" || kind === "webFetch") {
router.replace("/dashboard/media-providers/web");
}
}, [kind, router]);
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
const isEmbedding = kind === "embedding";
@@ -0,0 +1,346 @@
"use client";
import { useParams, notFound, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, Button, Input, Toggle, Modal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
const KIND_LABELS = {
webSearch: "Web Search",
webFetch: "Web Fetch",
};
const EXAMPLE_PATHS = {
webSearch: "/v1/search",
webFetch: "/v1/web/fetch",
};
const EXAMPLE_BODIES = {
webSearch: (comboName) => ({ model: comboName, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }),
webFetch: (comboName) => ({ model: comboName, url: "https://example.com", format: "markdown" }),
};
function ProviderPickerModal({ isOpen, onClose, onPick, kind, currentIds, connections }) {
// Only show providers with at least one usable connection (active/success) or noAuth
const usableIds = new Set(
(connections || [])
.filter((c) => {
if (c.isActive === false) return false;
const s = c.testStatus;
return s === "active" || s === "success" || s === "unavailable";
})
.map((c) => c.provider)
);
const all = kind ? getProvidersByKind(kind) : [];
const providers = all.filter((p) => p.noAuth || usableIds.has(p.id));
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Add ${KIND_LABELS[kind] || ""} Provider`} size="md">
{providers.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section.
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-[400px] overflow-y-auto">
{providers.map((p) => {
const already = currentIds.includes(p.id);
return (
<button
key={p.id}
disabled={already}
onClick={() => { onPick(p.id); onClose(); }}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
already
? "border-border opacity-40 cursor-not-allowed"
: "border-border hover:border-primary/50 hover:bg-primary/5 cursor-pointer"
}`}
>
<ProviderIcon
src={`/providers/${p.id}.png`}
alt={p.name}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p.textIcon || p.id.slice(0, 2).toUpperCase()}
fallbackColor={p.color}
/>
<span className="text-xs font-medium truncate text-left">{p.name}</span>
{already && <span className="text-[9px] text-text-muted ml-auto">added</span>}
</button>
);
})}
</div>
)}
</Modal>
);
}
export default function ComboDetailPage() {
const { id } = useParams();
const router = useRouter();
const [combo, setCombo] = useState(null);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
const [nameError, setNameError] = useState("");
const [providers, setProviders] = useState([]);
const [roundRobin, setRoundRobin] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [logs, setLogs] = useState([]);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [apiKey, setApiKey] = useState("");
const [connections, setConnections] = useState([]);
const fetchAll = async () => {
try {
const [comboRes, settingsRes, logsRes, keysRes, connsRes] = await Promise.all([
fetch(`/api/combos/${id}`, { cache: "no-store" }),
fetch("/api/settings", { cache: "no-store" }),
fetch("/api/usage/logs", { cache: "no-store" }),
fetch("/api/keys", { cache: "no-store" }),
fetch("/api/providers", { cache: "no-store" }),
]);
if (keysRes.ok) {
const k = await keysRes.json();
setApiKey((k.keys || []).find((x) => x.isActive !== false)?.key || "");
}
if (connsRes.ok) setConnections((await connsRes.json()).connections || []);
if (!comboRes.ok) { setCombo(null); setLoading(false); return; }
const c = await comboRes.json();
setCombo(c);
setName(c.name);
setProviders(c.models || []);
const s = settingsRes.ok ? await settingsRes.json() : {};
setRoundRobin(s.comboStrategies?.[c.name]?.fallbackStrategy === "round-robin");
const allLogs = logsRes.ok ? await logsRes.json() : [];
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
} catch { /* noop */ }
setLoading(false);
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchAll(); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const validateName = (v) => {
if (!v.trim()) { setNameError("Name is required"); return false; }
if (!VALID_NAME_REGEX.test(v)) { setNameError("Only letters, numbers, -, _ and ."); return false; }
setNameError("");
return true;
};
const saveCombo = async (patch) => {
const res = await fetch(`/api/combos/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) { const err = await res.json(); alert(err.error || "Failed to save"); return false; }
return true;
};
const handleSaveName = async () => {
if (!validateName(name)) return;
if (name === combo.name) return;
const ok = await saveCombo({ name });
if (ok) await fetchAll();
};
const handleAddProvider = async (providerId) => {
const next = [...providers, providerId];
setProviders(next);
await saveCombo({ models: next });
};
const handleRemoveProvider = async (idx) => {
const next = providers.filter((_, i) => i !== idx);
setProviders(next);
await saveCombo({ models: next });
};
const handleMove = async (idx, dir) => {
const next = [...providers];
const swap = idx + dir;
if (swap < 0 || swap >= next.length) return;
[next[idx], next[swap]] = [next[swap], next[idx]];
setProviders(next);
await saveCombo({ models: next });
};
const handleToggleRoundRobin = async (enabled) => {
setRoundRobin(enabled);
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const s = settingsRes.ok ? await settingsRes.json() : {};
const updated = { ...(s.comboStrategies || {}) };
if (enabled) updated[combo.name] = { fallbackStrategy: "round-robin" };
else delete updated[combo.name];
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboStrategies: updated }),
});
};
const handleDelete = async () => {
if (!confirm(`Delete combo "${combo.name}"?`)) return;
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) router.push("/dashboard/media-providers/web");
};
const handleTest = async () => {
setTesting(true);
setTestResult("");
try {
const path = EXAMPLE_PATHS[combo.kind];
const body = EXAMPLE_BODIES[combo.kind](combo.name);
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`/api${path}`, { method: "POST", headers, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
setTestResult(JSON.stringify(data, null, 2));
} catch (e) {
setTestResult(`Error: ${e.message}`);
}
setTesting(false);
};
if (loading) return <div className="text-text-muted text-sm">Loading...</div>;
if (!combo) return notFound();
const kindLabel = KIND_LABELS[combo.kind] || "Web";
const examplePath = EXAMPLE_PATHS[combo.kind];
const exampleBody = combo.kind ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
const curlExample = examplePath
? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'`
: "";
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Link href="/dashboard/media-providers/web" className="text-text-muted hover:text-primary">
<span className="material-symbols-outlined">arrow_back</span>
</Link>
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-primary">layers</span>
</div>
<div className="min-w-0">
<p className="text-xs text-text-muted">{kindLabel} Combo</p>
<code className="text-lg font-semibold font-mono">{combo.name}</code>
</div>
</div>
<Button variant="outline" icon="delete" onClick={handleDelete} className="text-red-500 border-red-200 hover:bg-red-50">
Delete
</Button>
</div>
{/* Settings Card */}
<Card>
<h2 className="text-lg font-semibold mb-3">Settings</h2>
<div className="flex flex-col gap-4">
<div>
<Input label="Combo Name" value={name} onChange={(e) => { setName(e.target.value); validateName(e.target.value); }} onBlur={handleSaveName} error={nameError} />
<p className="text-[10px] text-text-muted mt-0.5">Only letters, numbers, -, _ and .</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Round Robin</p>
<p className="text-xs text-text-muted">Rotate providers across requests instead of strict fallback order.</p>
</div>
<Toggle checked={roundRobin} onChange={handleToggleRoundRobin} />
</div>
</div>
</Card>
{/* Providers Card */}
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-semibold">Providers</h2>
<p className="text-xs text-text-muted">Tried in order (top-down) or rotated when round-robin is on.</p>
</div>
<Button size="sm" icon="add" onClick={() => setShowPicker(true)}>Add Provider</Button>
</div>
{providers.length === 0 ? (
<div className="text-center py-6 border border-dashed border-border rounded-lg text-text-muted text-sm">
No providers yet.
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((pid, idx) => {
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<span className="text-xs text-text-muted w-5 text-center">{idx + 1}</span>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
<span className="text-sm font-medium flex-1 truncate">{p?.name || pid}</span>
<div className="flex items-center gap-0.5">
<button onClick={() => handleMove(idx, -1)} disabled={idx === 0} className={`p-1 rounded ${idx === 0 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move up">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
</button>
<button onClick={() => handleMove(idx, 1)} disabled={idx === providers.length - 1} className={`p-1 rounded ${idx === providers.length - 1 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move down">
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
</button>
<button onClick={() => handleRemoveProvider(idx)} className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10" title="Remove">
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
</div>
);
})}
</div>
)}
</Card>
{/* Test Example Card */}
{combo.kind && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Test Example</h2>
<Button size="sm" icon="play_arrow" onClick={handleTest} disabled={testing || providers.length === 0}>
{testing ? "Running..." : "Run"}
</Button>
</div>
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all">
{curlExample}
</pre>
{testResult && (
<pre className="mt-3 text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px]">
{testResult}
</pre>
)}
</Card>
)}
{/* Usage Logs Card */}
<Card>
<h2 className="text-lg font-semibold mb-3">Usage Logs</h2>
{logs.length === 0 ? (
<p className="text-xs text-text-muted italic">No usage yet.</p>
) : (
<pre className="text-[11px] font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[400px] whitespace-pre-wrap">
{logs.join("\n")}
</pre>
)}
</Card>
<ProviderPickerModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onPick={handleAddProvider}
kind={combo.kind}
currentIds={providers}
connections={connections}
/>
</div>
);
}
@@ -0,0 +1,208 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Card, Badge, Button } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
function getEffectiveStatus(conn) {
const isCooldown = Object.entries(conn).some(
([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now()
);
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
}
function ProviderCard({ provider, kind, connections }) {
const providerInfo = AI_PROVIDERS[provider.id];
const isNoAuth = !!providerInfo?.noAuth;
const providerConns = connections.filter((c) => c.provider === provider.id);
const connected = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "active" || s === "success"; }).length;
const error = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "error" || s === "expired" || s === "unavailable"; }).length;
const total = providerConns.length;
const allDisabled = total > 0 && providerConns.every((c) => c.isActive === false);
const renderStatus = () => {
if (isNoAuth) return <Badge variant="success" size="sm">Ready</Badge>;
if (allDisabled) return <Badge variant="default" size="sm">Disabled</Badge>;
if (total === 0) return <span className="text-xs text-text-muted">No connections</span>;
return (
<>
{connected > 0 && <Badge variant="success" size="sm" dot>{connected} Connected</Badge>}
{error > 0 && <Badge variant="error" size="sm" dot>{error} Error</Badge>}
{connected === 0 && error === 0 && <Badge variant="default" size="sm">{total} Added</Badge>}
</>
);
};
return (
<Link href={`/dashboard/media-providers/${kind}/${provider.id}`} className="group">
<Card padding="xs" className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}>
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
</div>
<div>
<h3 className="font-semibold text-sm">{provider.name}</h3>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">{renderStatus()}</div>
</div>
</div>
</Card>
</Link>
);
}
function ComboList({ combos }) {
if (combos.length === 0) {
return <p className="text-xs text-text-muted italic">No combos yet.</p>;
}
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/web/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
{/* Provider icons preview */}
<div className="flex items-center gap-1 shrink-0">
{combo.models.slice(0, 6).map((pid, i) => {
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${i}`} title={p?.name || pid} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={18}
className="object-contain rounded max-w-[18px] max-h-[18px]"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
</div>
);
})}
{combo.models.length > 6 && (
<span className="text-[10px] text-text-muted ml-1">+{combo.models.length - 6}</span>
)}
</div>
<span className="text-[11px] text-text-muted shrink-0">{combo.models.length}</span>
<span className="material-symbols-outlined text-text-muted text-[16px]">chevron_right</span>
</div>
</Card>
</Link>
))}
</div>
);
}
function Section({ title, icon, kind, providers, connections, combos, onCreateCombo }) {
return (
<div>
{/* Header — title left, Create Combo right */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary">{icon}</span>
<h2 className="text-base font-semibold">{title}</h2>
<span className="text-xs text-text-muted">({providers.length} providers · {combos.length} combos)</span>
</div>
<Button size="sm" icon="add" onClick={onCreateCombo}>Create Combo</Button>
</div>
{/* Combos — top */}
{combos.length > 0 && (
<div className="mb-4">
<ComboList combos={combos} />
</div>
)}
{/* Providers grid — bottom */}
{providers.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{providers.map((p) => (
<ProviderCard key={p.id} provider={p} kind={kind} connections={connections} />
))}
</div>
)}
</div>
);
}
export default function WebProvidersPage() {
const router = useRouter();
const [connections, setConnections] = useState([]);
const [combos, setCombos] = useState([]);
const fetchAll = async () => {
try {
const [connsRes, combosRes] = await Promise.all([
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/combos", { cache: "no-store" }),
]);
if (connsRes.ok) setConnections((await connsRes.json()).connections || []);
if (combosRes.ok) setCombos((await combosRes.json()).combos || []);
} catch { /* noop */ }
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchAll(); }, []);
const searchProviders = getProvidersByKind("webSearch");
const fetchProviders = getProvidersByKind("webFetch");
const searchCombos = combos.filter((c) => c.kind === "webSearch");
const fetchCombos = combos.filter((c) => c.kind === "webFetch");
const handleCreateCombo = async (kind) => {
// Generate unique default name
const base = kind === "webSearch" ? "search-combo" : "fetch-combo";
let name = base;
let i = 1;
const existing = new Set(combos.map((c) => c.name));
while (existing.has(name)) { name = `${base}-${i++}`; }
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, models: [], kind }),
});
if (res.ok) {
const created = await res.json();
router.push(`/dashboard/media-providers/web/combo/${created.id}`);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");
}
};
return (
<div className="flex flex-col gap-8">
<Section
title="Web Search" icon="search" kind="webSearch"
providers={searchProviders} connections={connections} combos={searchCombos}
onCreateCombo={() => handleCreateCombo("webSearch")}
/>
{/* Divider between sections */}
<div className="border-t border-border" />
<Section
title="Web Fetch" icon="travel_explore" kind="webFetch"
providers={fetchProviders} connections={connections} combos={fetchCombos}
onCreateCombo={() => handleCreateCombo("webFetch")}
/>
</div>
);
}