mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function GET() {
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, models } = body;
|
||||
const { name, models, kind } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
@@ -38,7 +38,7 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
||||
}
|
||||
|
||||
const combo = await createCombo({ name, models: models || [] });
|
||||
const combo = await createCombo({ name, models: models || [], kind: kind || null });
|
||||
|
||||
return NextResponse.json(combo, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderNodeById } from "@/models";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
|
||||
|
||||
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
|
||||
// Returns true if API key is accepted (status !== 401 && !== 403).
|
||||
async function probeWebProvider(provider, apiKey) {
|
||||
const p = AI_PROVIDERS[provider];
|
||||
if (!p) return null;
|
||||
// Skip if provider has dual-purpose (LLM + search), let LLM validate handle it
|
||||
const kinds = p.serviceKinds || ["llm"];
|
||||
const isWebOnly = kinds.every((k) => k === "webSearch" || k === "webFetch");
|
||||
if (!isWebOnly) return null;
|
||||
const cfg = p.searchConfig || p.fetchConfig;
|
||||
if (!cfg) return null;
|
||||
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
|
||||
|
||||
let url = cfg.baseUrl;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
let body;
|
||||
|
||||
// Apply auth based on authHeader
|
||||
switch (cfg.authHeader) {
|
||||
case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; break;
|
||||
case "x-api-key": headers["x-api-key"] = apiKey; break;
|
||||
case "x-subscription-token":headers["x-subscription-token"] = apiKey; break;
|
||||
case "key": url += `?key=${encodeURIComponent(apiKey)}&q=ping&cx=test`; break; // google-pse
|
||||
case "api_key": url += `?api_key=${encodeURIComponent(apiKey)}&q=ping&engine=google`; break; // searchapi
|
||||
}
|
||||
|
||||
// Minimal body for POST endpoints; GET sends nothing
|
||||
if (cfg.method === "POST") {
|
||||
body = JSON.stringify({ query: "ping", q: "ping", url: "https://example.com" });
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: cfg.method, headers, body, signal: AbortSignal.timeout(8000) });
|
||||
return res.status !== 401 && res.status !== 403;
|
||||
}
|
||||
|
||||
// POST /api/providers/validate - Validate API key with provider
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { provider, apiKey, providerSpecificData } = body;
|
||||
|
||||
if (!provider || (!apiKey && provider !== "ollama-local")) {
|
||||
const isNoAuth = AI_PROVIDERS[provider]?.noAuth === true;
|
||||
if (!provider || (!apiKey && provider !== "ollama-local" && !isNoAuth)) {
|
||||
return NextResponse.json({ error: "Provider and API key required" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -147,6 +183,15 @@ export async function POST(request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generic probe for webSearch/webFetch providers (config-driven)
|
||||
const webResult = await probeWebProvider(provider, apiKey);
|
||||
if (webResult !== null) {
|
||||
return NextResponse.json({
|
||||
valid: webResult,
|
||||
error: webResult ? null : "Invalid API key",
|
||||
});
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
||||
@@ -294,7 +339,12 @@ export async function POST(request) {
|
||||
const headers = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(endpoints[provider], { headers });
|
||||
isValid = res.ok;
|
||||
// xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401.
|
||||
if (provider === "xai") {
|
||||
isValid = res.status === 200 || res.status === 403;
|
||||
} else {
|
||||
isValid = res.ok;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import "open-sse/index.js";
|
||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||
import { getExecutor } from "open-sse/executors/index.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
|
||||
// Detect auth-expired messages returned by usage providers instead of throwing
|
||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
||||
@@ -18,7 +19,7 @@ function isAuthExpiredMessage(usage) {
|
||||
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
|
||||
* @returns Promise<{ connection, refreshed: boolean }>
|
||||
*/
|
||||
async function refreshAndUpdateCredentials(connection, force = false) {
|
||||
async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
|
||||
const executor = getExecutor(connection.provider);
|
||||
|
||||
// Build credentials object from connection
|
||||
@@ -39,8 +40,8 @@ async function refreshAndUpdateCredentials(connection, force = false) {
|
||||
return { connection, refreshed: false };
|
||||
}
|
||||
|
||||
// Use executor's refreshCredentials method
|
||||
const refreshResult = await executor.refreshCredentials(credentials, console);
|
||||
// Use executor's refreshCredentials method (with optional proxy)
|
||||
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
|
||||
|
||||
if (!refreshResult) {
|
||||
// Refresh failed but we still have an accessToken — try with existing token
|
||||
@@ -117,9 +118,19 @@ export async function GET(request, { params }) {
|
||||
return Response.json({ message: "Usage not available for API key connections" });
|
||||
}
|
||||
|
||||
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
|
||||
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
||||
const proxyOptions = {
|
||||
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
||||
connectionNoProxy: proxyConfig.connectionNoProxy || "",
|
||||
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
|
||||
strictProxy: false,
|
||||
};
|
||||
|
||||
// Refresh credentials if needed using executor
|
||||
try {
|
||||
const result = await refreshAndUpdateCredentials(connection);
|
||||
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||
connection = result.connection;
|
||||
} catch (refreshError) {
|
||||
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||
@@ -129,15 +140,15 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
// Fetch usage from provider API
|
||||
let usage = await getUsageForProvider(connection);
|
||||
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||
|
||||
// If provider returned an auth-expired message instead of throwing,
|
||||
// force-refresh token and retry once
|
||||
if (isAuthExpiredMessage(usage) && connection.refreshToken) {
|
||||
try {
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true);
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||
connection = retryResult.connection;
|
||||
usage = await getUsageForProvider(connection);
|
||||
usage = await getUsageForProvider(connection, proxyOptions);
|
||||
} catch (retryError) {
|
||||
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { handleSearch } from "@/sse/handlers/search.js";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/search - Web search endpoint
|
||||
*/
|
||||
export async function POST(request) {
|
||||
return await handleSearch(request);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { handleFetch } from "@/sse/handlers/fetch.js";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/web/fetch - Web URL fetch/extract endpoint
|
||||
*/
|
||||
export async function POST(request) {
|
||||
return await handleFetch(request);
|
||||
}
|
||||
Reference in New Issue
Block a user