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>
);
}
+2 -2
View File
@@ -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) {
+53 -3
View File
@@ -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;
}
+18 -7
View File
@@ -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}`);
}
+21
View File
@@ -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);
}
+21
View File
@@ -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);
}