feat: add STT support, Gemini TTS, and expand usage tracking
- Speech-to-Text: full pipeline with sttCore handler, /v1/audio/transcriptions endpoint, sttConfig for OpenAI, Gemini, Groq, Deepgram, AssemblyAI, HuggingFace, NVIDIA Parakeet; new 9router-stt skill - Gemini TTS: add gemini provider with 30 prebuilt voices and TTS_PROVIDER_CONFIG - Usage: implement GLM (intl/cn) and MiniMax (intl/cn) quota fetchers; refactor Gemini CLI usage to use retrieveUserQuota with per-model buckets - Disabled models: lowdb-backed disabledModelsDb + /api/models/disabled route - Header search: reusable Zustand store (headerSearchStore) wired into Header - CLI tools: add Claude Cowork tool card and cowork-settings API - Providers: introduce mediaPriority sorting in getProvidersByKind, add Kimi K2.6, reorder hermes, drop qwen STT kind - UI: expand media-providers/[kind]/[id] page (+314), enhance OAuthModal, ModelSelectModal, ProviderTopology, ProxyPools, ProviderLimits - Assets: refresh provider PNGs (alicode, byteplus, cloudflare-ai, nvidia, ollama, vertex, volcengine-ark) and add aws-polly, fal-ai, jina-ai, recraft, runwayml, stability-ai, topaz, black-forest-labs
This commit is contained in:
1 parent
bfb7d42164
commit
d4bc42e1f5
67 files changed
+2930
-234
No files matched your search
@@ -15,15 +15,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
||||
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
|
||||
}, [isOpen]);
|
||||
|
||||
// Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider)
|
||||
const stripAlias = (id) => {
|
||||
const prefix = `${providerAlias}/`;
|
||||
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!modelId.trim()) return;
|
||||
const cleanId = stripAlias(modelId.trim());
|
||||
if (!cleanId) 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()}` }),
|
||||
body: JSON.stringify({ model: `${providerAlias}/${cleanId}` }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestStatus(data.ok ? "ok" : "error");
|
||||
@@ -35,10 +42,11 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modelId.trim() || saving) return;
|
||||
const cleanId = stripAlias(modelId.trim());
|
||||
if (!cleanId || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(modelId.trim());
|
||||
await onSave(cleanId);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -74,7 +82,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
||||
</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>
|
||||
Sent to provider as: <code className="font-mono bg-sidebar px-1 rounded">{stripAlias(modelId.trim()) || "model-id"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) {
|
||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable }) {
|
||||
const borderColor = testStatus === "ok"
|
||||
? "border-green-500/40"
|
||||
: testStatus === "error"
|
||||
@@ -55,7 +55,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
{isCustom && (
|
||||
{isCustom ? (
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
@@ -63,7 +63,15 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
)}
|
||||
) : onDisable ? (
|
||||
<button
|
||||
onClick={onDisable}
|
||||
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
title="Disable this model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -83,4 +91,5 @@ ModelRow.propTypes = {
|
||||
onDeleteAlias: PropTypes.func,
|
||||
onTest: PropTypes.func,
|
||||
isTesting: PropTypes.bool,
|
||||
onDisable: PropTypes.func,
|
||||
};
|
||||
@@ -46,6 +46,7 @@ export default function ProviderDetailPage() {
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const providerInfo = providerNode
|
||||
@@ -74,6 +75,62 @@ export default function ProviderDetailPage() {
|
||||
? (providerNode?.prefix || providerId)
|
||||
: providerAlias;
|
||||
|
||||
const fetchDisabledModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (res.ok) setDisabledModelIds(data.ids || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching disabled models:", error);
|
||||
}
|
||||
}, [providerStorageAlias]);
|
||||
|
||||
const handleDisableModel = async (modelId) => {
|
||||
try {
|
||||
const res = await fetch("/api/models/disabled", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }),
|
||||
});
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error disabling model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableModel = async (modelId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error enabling model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableAll = async (ids) => {
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`Disable all ${ids.length} model(s)?`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/models/disabled", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias: providerStorageAlias, ids }),
|
||||
});
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error disabling all models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableAll = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error enabling all models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Define callbacks BEFORE the useEffect that uses them
|
||||
const fetchAliases = useCallback(async () => {
|
||||
try {
|
||||
@@ -237,7 +294,8 @@ export default function ProviderDetailPage() {
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
}, [fetchConnections, fetchAliases]);
|
||||
fetchDisabledModels();
|
||||
}, [fetchConnections, fetchAliases, fetchDisabledModels]);
|
||||
|
||||
// Fetch suggested models from provider's public API (if configured)
|
||||
useEffect(() => {
|
||||
@@ -587,10 +645,13 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
// Combine hardcoded models with Kilo free models (deduplicated)
|
||||
// Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers
|
||||
const displayModels = [
|
||||
const allModels = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => !m.type || m.type === "llm");
|
||||
const disabledSet = new Set(disabledModelIds);
|
||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
||||
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
||||
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
|
||||
const customModels = Object.entries(modelAliases)
|
||||
.filter(([alias, fullModel]) => {
|
||||
@@ -610,6 +671,25 @@ export default function ProviderDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Custom models first */}
|
||||
{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)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isCustom
|
||||
isFree={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{displayModels.map((model) => {
|
||||
const fullModel = `${providerStorageAlias}/${model.id}`;
|
||||
const oldFormatModel = `${providerId}/${model.id}`;
|
||||
@@ -630,33 +710,15 @@ export default function ProviderDetailPage() {
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isFree={model.isFree}
|
||||
onDisable={() => handleDisableModel(model.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isCustom
|
||||
isFree={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add model button — inline, same style as model chips */}
|
||||
<button
|
||||
onClick={() => setShowAddCustomModel(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-black/15 px-3 py-2 text-xs text-text-muted transition-colors hover:border-primary/40 hover:text-primary sm:w-auto"
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2 text-xs text-primary transition-colors hover:border-primary hover:bg-primary/5 sm:w-auto"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">add</span>
|
||||
Add Model
|
||||
@@ -692,6 +754,26 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Disabled models — restorable */}
|
||||
{disabledDisplayModels.length > 0 && (
|
||||
<div className="w-full mt-2">
|
||||
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{disabledDisplayModels.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleEnableModel(m.id)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-dashed border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
||||
title="Restore model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">add</span>
|
||||
{m.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -969,6 +1051,27 @@ export default function ProviderDetailPage() {
|
||||
<h2 className="text-lg font-semibold">
|
||||
{"Available Models"}
|
||||
</h2>
|
||||
{!isCompatible && (() => {
|
||||
const allIds = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => !m.type || m.type === "llm").map((m) => m.id);
|
||||
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{disabledModelIds.length > 0 && (
|
||||
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
|
||||
Active All
|
||||
</Button>
|
||||
)}
|
||||
{activeIds.length > 0 && (
|
||||
<Button size="sm" variant="secondary" icon="block" onClick={() => handleDisableAll(activeIds)}>
|
||||
Disable All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{!!modelsTestError && (
|
||||
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
|
||||
|
||||
@@ -165,7 +165,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias, id: modelId, type: effectiveType }),
|
||||
});
|
||||
if (res.ok) await fetchData();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||
}
|
||||
} catch (e) { console.log("add custom model error:", e); }
|
||||
};
|
||||
|
||||
@@ -173,7 +176,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
||||
try {
|
||||
const params = new URLSearchParams({ providerAlias, id: modelId, type: effectiveType });
|
||||
const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchData();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||
}
|
||||
} catch (e) { console.log("delete custom model error:", e); }
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
|
||||
function getStatusDisplay(connected, error, errorCode) {
|
||||
@@ -103,6 +104,18 @@ export default function ProvidersPage() {
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const searchQuery = useHeaderSearchStore((s) => s.query);
|
||||
const registerSearch = useHeaderSearchStore((s) => s.register);
|
||||
const unregisterSearch = useHeaderSearchStore((s) => s.unregister);
|
||||
|
||||
useEffect(() => {
|
||||
registerSearch("Search providers...");
|
||||
return () => unregisterSearch();
|
||||
}, [registerSearch, unregisterSearch]);
|
||||
|
||||
const matchSearch = (name) =>
|
||||
!searchQuery.trim() ||
|
||||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -224,7 +237,8 @@ export default function ProvidersPage() {
|
||||
color: "#10A37F",
|
||||
textIcon: "OC",
|
||||
apiType: node.apiType,
|
||||
}));
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
|
||||
const anthropicCompatibleProviders = providerNodes
|
||||
.filter((node) => node.type === "anthropic-compatible")
|
||||
@@ -233,7 +247,22 @@ export default function ProvidersPage() {
|
||||
name: node.name || "Anthropic Compatible",
|
||||
color: "#D97757",
|
||||
textIcon: "AC",
|
||||
}));
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
|
||||
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
);
|
||||
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
);
|
||||
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
|
||||
([, info]) => matchSearch(info.name),
|
||||
);
|
||||
const apikeyEntries = Object.entries(APIKEY_PROVIDERS).filter(
|
||||
([, info]) =>
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -244,9 +273,27 @@ export default function ProvidersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyResult =
|
||||
oauthEntries.length > 0 ||
|
||||
freeEntries.length > 0 ||
|
||||
freeTierEntries.length > 0 ||
|
||||
apikeyEntries.length > 0 ||
|
||||
compatibleProviders.length > 0 ||
|
||||
anthropicCompatibleProviders.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||
{!hasAnyResult && (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-xl">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-text-muted text-sm">No providers match your search</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
{oauthEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
@@ -275,7 +322,7 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||
{oauthEntries.map(([key, info]) => (
|
||||
<ProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
@@ -287,8 +334,10 @@ export default function ProvidersPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free Tier Providers */}
|
||||
{(freeEntries.length > 0 || freeTierEntries.length > 0) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
@@ -314,7 +363,7 @@ export default function ProvidersPage() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(FREE_PROVIDERS).map(([key, info]) => (
|
||||
{freeEntries.map(([key, info]) => (
|
||||
<ProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
@@ -324,7 +373,7 @@ export default function ProvidersPage() {
|
||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(FREE_TIER_PROVIDERS).map(([key, info]) => (
|
||||
{freeTierEntries.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
@@ -336,8 +385,10 @@ export default function ProvidersPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Providers — fixed list */}
|
||||
{apikeyEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
@@ -363,20 +414,19 @@ export default function ProvidersPage() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(APIKEY_PROVIDERS)
|
||||
.filter(([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm"))
|
||||
.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
{apikeyEntries.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web Cookie Providers — use browser subscription cookie instead of API key */}
|
||||
{/* <div className="flex flex-col gap-4">
|
||||
|
||||
Reference in new issue
Block a user