2026-01-04 21:58:59 -05:00
|
|
|
"use client";
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
2026-02-02 07:45:12 -05:00
|
|
|
import { useParams, useRouter } from "next/navigation";
|
2026-01-04 21:58:59 -05:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import Image from "next/image";
|
2026-05-13 04:32:17 -04:00
|
|
|
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
|
2026-04-22 00:58:53 -04:00
|
|
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
|
2026-01-04 21:58:59 -05:00
|
|
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
|
|
|
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
2026-06-05 23:22:03 -04:00
|
|
|
import { translate } from "@/i18n/runtime";
|
2026-03-30 00:28:07 -04:00
|
|
|
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
2026-04-13 04:52:01 -04:00
|
|
|
import ModelRow from "./ModelRow";
|
|
|
|
|
import PassthroughModelsSection from "./PassthroughModelsSection";
|
|
|
|
|
import CompatibleModelsSection from "./CompatibleModelsSection";
|
|
|
|
|
import ConnectionRow from "./ConnectionRow";
|
|
|
|
|
import AddApiKeyModal from "./AddApiKeyModal";
|
|
|
|
|
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
|
|
|
|
import AddCustomModelModal from "./AddCustomModelModal";
|
2026-06-07 23:08:46 -04:00
|
|
|
import BulkImportCodexModal from "./BulkImportCodexModal";
|
2026-01-04 21:58:59 -05:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
const ONE_BY_ONE_DELAY_MS = 1000;
|
|
|
|
|
|
|
|
|
|
function sleep(ms) {
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
export default function ProviderDetailPage() {
|
|
|
|
|
const params = useParams();
|
2026-02-02 07:45:12 -05:00
|
|
|
const router = useRouter();
|
2026-01-04 21:58:59 -05:00
|
|
|
const providerId = params.id;
|
|
|
|
|
const [connections, setConnections] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-02-02 07:45:12 -05:00
|
|
|
const [providerNode, setProviderNode] = useState(null);
|
2026-03-09 04:46:06 -04:00
|
|
|
const [proxyPools, setProxyPools] = useState([]);
|
2026-01-04 21:58:59 -05:00
|
|
|
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
2026-03-04 23:25:03 -05:00
|
|
|
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
2026-01-04 21:58:59 -05:00
|
|
|
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
2026-05-07 05:17:03 -04:00
|
|
|
const [addConnectionError, setAddConnectionError] = useState("");
|
2026-06-07 23:08:46 -04:00
|
|
|
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
|
2026-01-04 21:58:59 -05:00
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
2026-02-02 07:45:12 -05:00
|
|
|
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
2026-03-09 04:46:06 -04:00
|
|
|
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
|
2026-01-04 21:58:59 -05:00
|
|
|
const [selectedConnection, setSelectedConnection] = useState(null);
|
|
|
|
|
const [modelAliases, setModelAliases] = useState({});
|
2026-02-02 10:26:40 -05:00
|
|
|
const [headerImgError, setHeaderImgError] = useState(false);
|
2026-02-26 23:15:12 -05:00
|
|
|
const [modelTestResults, setModelTestResults] = useState({});
|
2026-03-04 23:25:03 -05:00
|
|
|
const [modelsTestError, setModelsTestError] = useState("");
|
2026-02-28 04:33:18 -05:00
|
|
|
const [testingModelId, setTestingModelId] = useState(null);
|
2026-02-27 22:04:57 -05:00
|
|
|
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
2026-03-09 04:46:06 -04:00
|
|
|
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
|
|
|
|
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
|
|
|
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
2026-05-13 04:32:17 -04:00
|
|
|
const [providerStrategy, setProviderStrategy] = useState(null);
|
2026-03-11 07:04:38 -04:00
|
|
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
2026-04-13 01:04:57 -04:00
|
|
|
const [thinkingMode, setThinkingMode] = useState("auto");
|
2026-03-30 00:28:07 -04:00
|
|
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
2026-03-30 22:22:21 -04:00
|
|
|
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
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
2026-05-04 23:32:59 -04:00
|
|
|
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
2026-05-13 04:32:17 -04:00
|
|
|
const [confirmState, setConfirmState] = useState(null);
|
2026-05-18 05:26:35 -04:00
|
|
|
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
|
2026-05-21 01:15:07 -04:00
|
|
|
const [oneByOneRunning, setOneByOneRunning] = useState(false);
|
|
|
|
|
const [oneByOneStopping, setOneByOneStopping] = useState(false);
|
|
|
|
|
const [oneByOneCurrentConnectionId, setOneByOneCurrentConnectionId] = useState(null);
|
|
|
|
|
const [oneByOneResults, setOneByOneResults] = useState({});
|
|
|
|
|
const [oneByOneSummary, setOneByOneSummary] = useState(null);
|
|
|
|
|
const stopOneByOneRef = useRef(false);
|
2026-06-05 23:22:03 -04:00
|
|
|
const [importingQoderModels, setImportingQoderModels] = useState(false);
|
2026-01-04 21:58:59 -05:00
|
|
|
const { copied, copy } = useCopyToClipboard();
|
|
|
|
|
|
2026-05-18 05:26:35 -04:00
|
|
|
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
|
|
|
|
|
2026-05-20 23:18:12 -04:00
|
|
|
const openOAuthConnection = () => {
|
|
|
|
|
setShowOAuthModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const triggerOAuthConnection = () => {
|
2026-05-18 05:26:35 -04:00
|
|
|
if (providerId === "antigravity" && typeof window !== "undefined") {
|
|
|
|
|
const confirmed = window.localStorage.getItem(AG_RISK_STORAGE_KEY) === "true";
|
|
|
|
|
if (!confirmed) {
|
|
|
|
|
setShowAgRiskModal(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (isOAuth) {
|
2026-05-20 23:18:12 -04:00
|
|
|
openOAuthConnection();
|
2026-05-18 05:26:35 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setAddConnectionError("");
|
|
|
|
|
setShowAddApiKeyModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-20 23:18:12 -04:00
|
|
|
const triggerApiKeyConnection = () => {
|
|
|
|
|
setAddConnectionError("");
|
|
|
|
|
setShowAddApiKeyModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const triggerAddConnection = () => {
|
|
|
|
|
if (isOAuth) {
|
|
|
|
|
triggerOAuthConnection();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
triggerApiKeyConnection();
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 05:26:35 -04:00
|
|
|
const handleAgRiskConfirm = () => {
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true");
|
|
|
|
|
}
|
|
|
|
|
setShowAgRiskModal(false);
|
|
|
|
|
if (isOAuth) {
|
2026-05-20 23:18:12 -04:00
|
|
|
openOAuthConnection();
|
2026-05-18 05:26:35 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-20 23:18:12 -04:00
|
|
|
triggerApiKeyConnection();
|
2026-05-18 05:26:35 -04:00
|
|
|
};
|
|
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
const providerInfo = providerNode
|
|
|
|
|
? {
|
|
|
|
|
id: providerNode.id,
|
2026-02-03 03:11:41 -05:00
|
|
|
name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"),
|
|
|
|
|
color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
|
|
|
|
|
textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
|
2026-02-02 07:45:12 -05:00
|
|
|
apiType: providerNode.apiType,
|
|
|
|
|
baseUrl: providerNode.baseUrl,
|
2026-02-03 03:11:41 -05:00
|
|
|
type: providerNode.type,
|
2026-02-02 07:45:12 -05:00
|
|
|
}
|
2026-04-22 00:58:53 -04:00
|
|
|
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]);
|
2026-05-20 23:18:12 -04:00
|
|
|
const authModes = providerInfo?.authModes || [];
|
|
|
|
|
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
|
|
|
|
|
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
|
2026-04-06 06:32:44 -04:00
|
|
|
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
2026-01-04 21:58:59 -05:00
|
|
|
const models = getModelsByProviderId(providerId);
|
|
|
|
|
const providerAlias = getProviderAlias(providerId);
|
2026-02-03 03:11:41 -05:00
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
2026-02-03 03:11:41 -05:00
|
|
|
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
|
|
|
|
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
2026-05-20 23:18:12 -04:00
|
|
|
const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth;
|
|
|
|
|
const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth";
|
|
|
|
|
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
|
2026-04-13 01:04:57 -04:00
|
|
|
const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended;
|
2026-02-03 03:11:41 -05:00
|
|
|
|
|
|
|
|
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
|
|
|
|
const providerDisplayAlias = isCompatible
|
2026-02-02 07:45:12 -05:00
|
|
|
? (providerNode?.prefix || providerId)
|
|
|
|
|
: providerAlias;
|
2026-01-04 21:58:59 -05:00
|
|
|
|
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
2026-05-04 23:32:59 -04:00
|
|
|
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;
|
2026-05-13 04:32:17 -04:00
|
|
|
setConfirmState({
|
|
|
|
|
title: "Disable All Models",
|
|
|
|
|
message: `Disable all ${ids.length} model(s)?`,
|
|
|
|
|
onConfirm: async () => {
|
|
|
|
|
setConfirmState(null);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
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
2026-05-04 23:32:59 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-20 01:16:34 -05:00
|
|
|
// Define callbacks BEFORE the useEffect that uses them
|
2026-01-19 05:15:11 -05:00
|
|
|
const fetchAliases = useCallback(async () => {
|
2026-01-04 21:58:59 -05:00
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/models/alias");
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setModelAliases(data.aliases || {});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error fetching aliases:", error);
|
|
|
|
|
}
|
2026-01-19 05:15:11 -05:00
|
|
|
}, []);
|
2026-01-04 21:58:59 -05:00
|
|
|
|
2026-03-30 22:22:21 -04:00
|
|
|
// Fetch free models from Kilo API for kilocode provider
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (providerId !== "kilocode") return;
|
|
|
|
|
fetch("/api/providers/kilo/free-models")
|
|
|
|
|
.then((res) => res.json())
|
|
|
|
|
.then((data) => { if (data.models?.length) setKiloFreeModels(data.models); })
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, [providerId]);
|
|
|
|
|
|
2026-01-20 01:16:34 -05:00
|
|
|
const fetchConnections = useCallback(async () => {
|
|
|
|
|
try {
|
2026-03-11 07:04:38 -04:00
|
|
|
const [connectionsRes, nodesRes, proxyPoolsRes, settingsRes] = await Promise.all([
|
2026-02-06 08:54:19 -05:00
|
|
|
fetch("/api/providers", { cache: "no-store" }),
|
|
|
|
|
fetch("/api/provider-nodes", { cache: "no-store" }),
|
2026-03-09 04:46:06 -04:00
|
|
|
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
2026-03-11 07:04:38 -04:00
|
|
|
fetch("/api/settings", { cache: "no-store" }),
|
2026-02-02 07:45:12 -05:00
|
|
|
]);
|
|
|
|
|
const connectionsData = await connectionsRes.json();
|
|
|
|
|
const nodesData = await nodesRes.json();
|
2026-03-09 04:46:06 -04:00
|
|
|
const proxyPoolsData = await proxyPoolsRes.json();
|
2026-03-11 07:04:38 -04:00
|
|
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
2026-02-02 07:45:12 -05:00
|
|
|
if (connectionsRes.ok) {
|
|
|
|
|
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
2026-01-20 01:16:34 -05:00
|
|
|
setConnections(filtered);
|
|
|
|
|
}
|
2026-03-09 04:46:06 -04:00
|
|
|
if (proxyPoolsRes.ok) {
|
|
|
|
|
setProxyPools(proxyPoolsData.proxyPools || []);
|
|
|
|
|
}
|
2026-03-11 07:04:38 -04:00
|
|
|
// Load per-provider strategy override
|
|
|
|
|
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
|
|
|
|
setProviderStrategy(override.fallbackStrategy || null);
|
|
|
|
|
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
2026-04-13 01:04:57 -04:00
|
|
|
// Load per-provider thinking config
|
|
|
|
|
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
|
|
|
|
setThinkingMode(thinkingCfg.mode || "auto");
|
2026-02-02 07:45:12 -05:00
|
|
|
if (nodesRes.ok) {
|
2026-02-06 08:54:19 -05:00
|
|
|
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
|
|
|
|
|
|
|
|
|
// Newly created compatible nodes can be briefly unavailable on one worker.
|
|
|
|
|
// Retry a few times before showing "Provider not found".
|
|
|
|
|
if (!node && isCompatible) {
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
|
|
|
|
if (!retryRes.ok) continue;
|
|
|
|
|
const retryData = await retryRes.json();
|
|
|
|
|
node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null;
|
|
|
|
|
if (node) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
setProviderNode(node);
|
|
|
|
|
}
|
2026-01-20 01:16:34 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error fetching connections:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2026-02-07 12:19:50 -05:00
|
|
|
}, [providerId, isCompatible]);
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
const handleUpdateNode = async (formData) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/provider-nodes/${providerId}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(formData),
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setProviderNode(data.node);
|
|
|
|
|
await fetchConnections();
|
|
|
|
|
setShowEditNodeModal(false);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error updating provider node:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 07:04:38 -04:00
|
|
|
const saveProviderStrategy = async (strategy, stickyLimit) => {
|
|
|
|
|
try {
|
|
|
|
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
|
|
|
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
|
|
|
|
const current = settingsData.providerStrategies || {};
|
|
|
|
|
|
|
|
|
|
// Build override: null strategy means remove override, use global
|
|
|
|
|
const override = {};
|
|
|
|
|
if (strategy) override.fallbackStrategy = strategy;
|
|
|
|
|
if (strategy === "round-robin" && stickyLimit !== "") {
|
|
|
|
|
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = { ...current };
|
|
|
|
|
if (Object.keys(override).length === 0) {
|
|
|
|
|
delete updated[providerId];
|
|
|
|
|
} else {
|
|
|
|
|
updated[providerId] = override;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fetch("/api/settings", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ providerStrategies: updated }),
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error saving provider strategy:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRoundRobinToggle = (enabled) => {
|
|
|
|
|
const strategy = enabled ? "round-robin" : null;
|
|
|
|
|
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
|
|
|
|
|
if (enabled && !providerStickyLimit) setProviderStickyLimit("1");
|
|
|
|
|
setProviderStrategy(strategy);
|
|
|
|
|
saveProviderStrategy(strategy, sticky);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleStickyLimitChange = (value) => {
|
|
|
|
|
setProviderStickyLimit(value);
|
|
|
|
|
saveProviderStrategy("round-robin", value);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 01:04:57 -04:00
|
|
|
const saveThinkingConfig = async (mode) => {
|
|
|
|
|
try {
|
|
|
|
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
|
|
|
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
|
|
|
|
const current = settingsData.providerThinking || {};
|
|
|
|
|
const updated = { ...current };
|
|
|
|
|
if (!mode || mode === "auto") {
|
|
|
|
|
delete updated[providerId];
|
|
|
|
|
} else {
|
|
|
|
|
updated[providerId] = { mode };
|
|
|
|
|
}
|
|
|
|
|
await fetch("/api/settings", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ providerThinking: updated }),
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error saving thinking config:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleThinkingModeChange = (mode) => {
|
|
|
|
|
setThinkingMode(mode);
|
|
|
|
|
saveThinkingConfig(mode);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-20 01:16:34 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchConnections();
|
|
|
|
|
fetchAliases();
|
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
2026-05-04 23:32:59 -04:00
|
|
|
fetchDisabledModels();
|
|
|
|
|
}, [fetchConnections, fetchAliases, fetchDisabledModels]);
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-03-30 00:28:07 -04:00
|
|
|
// Fetch suggested models from provider's public API (if configured)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher;
|
|
|
|
|
if (!fetcher) return;
|
|
|
|
|
fetchSuggestedModels(fetcher).then(setSuggestedModels);
|
|
|
|
|
}, [providerId]);
|
|
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
|
|
|
|
|
const fullModel = `${providerAliasOverride}/${modelId}`;
|
2026-01-04 21:58:59 -05:00
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/models/alias", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ model: fullModel, alias }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
await fetchAliases();
|
|
|
|
|
} else {
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
alert(data.error || "Failed to set alias");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error setting alias:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteAlias = async (alias) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
await fetchAliases();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error deleting alias:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-05 23:22:03 -04:00
|
|
|
// Fetch Qoder model list and automatically add to available models
|
|
|
|
|
const handleImportQoderModels = async () => {
|
|
|
|
|
if (importingQoderModels) return;
|
|
|
|
|
const activeConnection = connections.find((conn) => conn.isActive !== false);
|
|
|
|
|
if (!activeConnection) {
|
|
|
|
|
alert(translate("Please add an active Qoder connection first"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setImportingQoderModels(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
alert(data.error || translate("Failed to fetch models"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const models = data.models || [];
|
|
|
|
|
if (models.length === 0) {
|
|
|
|
|
alert(translate("No models returned"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let importedCount = 0;
|
|
|
|
|
for (const model of models) {
|
|
|
|
|
const modelId = model.id || model.name;
|
|
|
|
|
if (!modelId) continue;
|
|
|
|
|
|
|
|
|
|
// Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix
|
|
|
|
|
const cleanModelId = modelId.replace(/^qoder\//, "");
|
|
|
|
|
const fullModel = `${providerStorageAlias}/${cleanModelId}`;
|
|
|
|
|
|
|
|
|
|
// Check if already exists
|
|
|
|
|
if (Object.values(modelAliases).includes(fullModel)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use model ID as alias
|
|
|
|
|
const alias = cleanModelId;
|
|
|
|
|
if (modelAliases[alias]) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await handleSetAlias(cleanModelId, alias, providerStorageAlias);
|
|
|
|
|
importedCount += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (importedCount === 0) {
|
|
|
|
|
alert(translate("All models already exist, no new models added"));
|
|
|
|
|
} else {
|
|
|
|
|
alert(translate("Successfully added") + ` ${importedCount} ` + translate("models"));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error importing Qoder models:", error);
|
|
|
|
|
alert(translate("Error fetching models") + ": " + error.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setImportingQoderModels(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
const handleRunOneByOneTest = async () => {
|
|
|
|
|
if (oneByOneRunning || connections.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const queuedState = Object.fromEntries(
|
|
|
|
|
connections.map((connection) => [connection.id, { state: "queued", error: null }]),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
stopOneByOneRef.current = false;
|
|
|
|
|
setOneByOneRunning(true);
|
|
|
|
|
setOneByOneStopping(false);
|
|
|
|
|
setOneByOneCurrentConnectionId(null);
|
|
|
|
|
setOneByOneResults(queuedState);
|
|
|
|
|
setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false });
|
|
|
|
|
|
|
|
|
|
let passed = 0;
|
|
|
|
|
let failed = 0;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
for (let index = 0; index < connections.length; index += 1) {
|
|
|
|
|
if (stopOneByOneRef.current) {
|
|
|
|
|
setOneByOneSummary({
|
|
|
|
|
total: connections.length,
|
|
|
|
|
completed: index,
|
|
|
|
|
passed,
|
|
|
|
|
failed,
|
|
|
|
|
stopped: true,
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connection = connections[index];
|
|
|
|
|
setOneByOneCurrentConnectionId(connection.id);
|
|
|
|
|
setOneByOneResults((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[connection.id]: { state: "testing", error: null },
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
const valid = !!data.valid;
|
|
|
|
|
|
|
|
|
|
if (valid) {
|
|
|
|
|
passed += 1;
|
|
|
|
|
} else {
|
|
|
|
|
failed += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOneByOneResults((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[connection.id]: {
|
|
|
|
|
state: valid ? "success" : "failed",
|
|
|
|
|
error: valid ? null : (data.error || null),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failed += 1;
|
|
|
|
|
setOneByOneResults((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[connection.id]: {
|
|
|
|
|
state: "failed",
|
|
|
|
|
error: error.message || "Test failed",
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOneByOneSummary({
|
|
|
|
|
total: connections.length,
|
|
|
|
|
completed: index + 1,
|
|
|
|
|
passed,
|
|
|
|
|
failed,
|
|
|
|
|
stopped: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (index < connections.length - 1) {
|
|
|
|
|
await sleep(ONE_BY_ONE_DELAY_MS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setOneByOneCurrentConnectionId(null);
|
|
|
|
|
setOneByOneRunning(false);
|
|
|
|
|
setOneByOneStopping(false);
|
|
|
|
|
stopOneByOneRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleStopOneByOneTest = () => {
|
|
|
|
|
if (!oneByOneRunning) return;
|
|
|
|
|
stopOneByOneRef.current = true;
|
|
|
|
|
setOneByOneStopping(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
const handleDelete = async (id) => {
|
2026-05-13 04:32:17 -04:00
|
|
|
setConfirmState({
|
|
|
|
|
title: "Delete Connection",
|
|
|
|
|
message: "Delete this connection?",
|
|
|
|
|
onConfirm: async () => {
|
|
|
|
|
setConfirmState(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setConnections(connections.filter(c => c.id !== id));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error deleting connection:", error);
|
|
|
|
|
}
|
2026-01-04 21:58:59 -05:00
|
|
|
}
|
2026-05-13 04:32:17 -04:00
|
|
|
});
|
2026-01-04 21:58:59 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleOAuthSuccess = () => {
|
|
|
|
|
fetchConnections();
|
|
|
|
|
setShowOAuthModal(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-04 23:25:03 -05:00
|
|
|
const handleIFlowCookieSuccess = () => {
|
|
|
|
|
fetchConnections();
|
|
|
|
|
setShowIFlowCookieModal(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
const handleSaveApiKey = async (formData) => {
|
2026-05-07 05:17:03 -04:00
|
|
|
setAddConnectionError("");
|
2026-01-04 21:58:59 -05:00
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/providers", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ provider: providerId, ...formData }),
|
|
|
|
|
});
|
2026-05-07 05:17:03 -04:00
|
|
|
|
|
|
|
|
let data = null;
|
|
|
|
|
try {
|
|
|
|
|
data = await res.json();
|
|
|
|
|
} catch {
|
|
|
|
|
data = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
if (res.ok) {
|
|
|
|
|
await fetchConnections();
|
|
|
|
|
setShowAddApiKeyModal(false);
|
2026-05-07 05:17:03 -04:00
|
|
|
return;
|
2026-01-04 21:58:59 -05:00
|
|
|
}
|
2026-05-07 05:17:03 -04:00
|
|
|
|
|
|
|
|
setAddConnectionError(data?.error || "Failed to save connection");
|
2026-01-04 21:58:59 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error saving connection:", error);
|
2026-05-07 05:17:03 -04:00
|
|
|
setAddConnectionError("Failed to save connection");
|
2026-01-04 21:58:59 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUpdateConnection = async (formData) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${selectedConnection.id}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(formData),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
await fetchConnections();
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error updating connection:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-06 14:46:38 -05:00
|
|
|
const handleUpdateConnectionStatus = async (id, isActive) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${id}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ isActive }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setConnections(prev => prev.map(c => c.id === id ? { ...c, isActive } : c));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error updating connection status:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-19 11:47:32 -04:00
|
|
|
const handleSwapPriority = async (index1, index2) => {
|
|
|
|
|
// Optimistic update state
|
|
|
|
|
const newConnections = [...connections];
|
|
|
|
|
[newConnections[index1], newConnections[index2]] = [newConnections[index2], newConnections[index1]];
|
|
|
|
|
setConnections(newConnections);
|
2026-01-06 15:14:10 -05:00
|
|
|
|
2026-03-19 11:47:32 -04:00
|
|
|
try {
|
2026-01-04 21:58:59 -05:00
|
|
|
await Promise.all([
|
2026-03-19 11:47:32 -04:00
|
|
|
fetch(`/api/providers/${newConnections[index1].id}`, {
|
2026-01-04 21:58:59 -05:00
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-03-19 11:47:32 -04:00
|
|
|
body: JSON.stringify({ priority: index1 }),
|
2026-01-04 21:58:59 -05:00
|
|
|
}),
|
2026-03-19 11:47:32 -04:00
|
|
|
fetch(`/api/providers/${newConnections[index2].id}`, {
|
2026-01-04 21:58:59 -05:00
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-03-19 11:47:32 -04:00
|
|
|
body: JSON.stringify({ priority: index2 }),
|
2026-01-04 21:58:59 -05:00
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error swapping priority:", error);
|
2026-03-19 11:47:32 -04:00
|
|
|
await fetchConnections();
|
2026-01-04 21:58:59 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-09 04:46:06 -04:00
|
|
|
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
|
|
|
|
|
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
|
|
|
|
|
|
|
|
|
|
const toggleSelectConnection = (connectionId) => {
|
|
|
|
|
setSelectedConnectionIds((prev) => (
|
|
|
|
|
prev.includes(connectionId)
|
|
|
|
|
? prev.filter((id) => id !== connectionId)
|
|
|
|
|
: [...prev, connectionId]
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleSelectAllConnections = () => {
|
|
|
|
|
if (allSelected) {
|
|
|
|
|
setSelectedConnectionIds([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSelectedConnectionIds(connections.map((conn) => conn.id));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearSelection = () => {
|
|
|
|
|
setSelectedConnectionIds([]);
|
|
|
|
|
setBulkProxyPoolId("__none__");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
|
|
|
|
}, [connections]);
|
|
|
|
|
|
|
|
|
|
const selectedProxySummary = (() => {
|
|
|
|
|
if (selectedConnections.length === 0) return "";
|
|
|
|
|
const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"));
|
|
|
|
|
if (poolIds.size === 1) {
|
|
|
|
|
const onlyId = [...poolIds][0];
|
|
|
|
|
if (onlyId === "__none__") return "All selected currently unbound";
|
|
|
|
|
const pool = proxyPools.find((p) => p.id === onlyId);
|
|
|
|
|
return `All selected currently bound to ${pool?.name || onlyId}`;
|
|
|
|
|
}
|
|
|
|
|
return "Selected connections have mixed proxy bindings";
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const openBulkProxyModal = () => {
|
|
|
|
|
if (selectedConnections.length === 0) return;
|
|
|
|
|
const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))];
|
|
|
|
|
setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__");
|
|
|
|
|
setShowBulkProxyModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeBulkProxyModal = () => {
|
|
|
|
|
if (bulkUpdatingProxy) return;
|
|
|
|
|
setShowBulkProxyModal(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 09:22:21 -04:00
|
|
|
const applyProxyAssignments = async (assignments) => {
|
2026-03-09 04:46:06 -04:00
|
|
|
setBulkUpdatingProxy(true);
|
|
|
|
|
try {
|
2026-05-07 04:45:09 -04:00
|
|
|
let failed = 0;
|
|
|
|
|
for (const { connectionId, proxyPoolId } of assignments) {
|
2026-03-09 04:48:21 -04:00
|
|
|
try {
|
2026-03-09 04:46:06 -04:00
|
|
|
const res = await fetch(`/api/providers/${connectionId}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ proxyPoolId }),
|
|
|
|
|
});
|
2026-05-07 04:45:09 -04:00
|
|
|
if (!res.ok) failed += 1;
|
2026-03-09 04:48:21 -04:00
|
|
|
} catch (e) {
|
2026-05-05 09:22:21 -04:00
|
|
|
console.log("Error applying proxy for", connectionId, e);
|
2026-05-07 04:45:09 -04:00
|
|
|
failed += 1;
|
2026-03-09 04:48:21 -04:00
|
|
|
}
|
2026-05-07 04:45:09 -04:00
|
|
|
}
|
2026-05-05 09:22:21 -04:00
|
|
|
if (failed > 0) alert(`Updated with ${failed} failed request(s).`);
|
2026-03-09 04:46:06 -04:00
|
|
|
await fetchConnections();
|
|
|
|
|
setShowBulkProxyModal(false);
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkUpdatingProxy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 09:22:21 -04:00
|
|
|
const handleApplySinglePool = (proxyPoolId) => {
|
|
|
|
|
const targets = connections.map((c) => ({ connectionId: c.id, proxyPoolId }));
|
|
|
|
|
return applyProxyAssignments(targets);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleApplyOneToOne = () => {
|
|
|
|
|
const activePools = proxyPools.filter((p) => p.isActive === true);
|
|
|
|
|
if (activePools.length === 0) {
|
|
|
|
|
alert("No active proxy pools available.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const targets = connections.map((c, i) => ({
|
|
|
|
|
connectionId: c.id,
|
|
|
|
|
proxyPoolId: activePools[i % activePools.length].id,
|
|
|
|
|
}));
|
|
|
|
|
return applyProxyAssignments(targets);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-09 04:46:06 -04:00
|
|
|
|
|
|
|
|
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
|
|
|
|
|
|
|
|
|
|
const connectionsList = (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
2026-03-09 04:46:06 -04:00
|
|
|
{connections
|
|
|
|
|
.map((conn, index) => (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div key={conn.id} className="flex min-w-0 items-stretch">
|
2026-03-09 04:46:06 -04:00
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<ConnectionRow
|
|
|
|
|
connection={conn}
|
|
|
|
|
proxyPools={proxyPools}
|
|
|
|
|
isOAuth={isOAuth}
|
|
|
|
|
isFirst={index === 0}
|
|
|
|
|
isLast={index === connections.length - 1}
|
2026-03-19 11:47:32 -04:00
|
|
|
onMoveUp={() => handleSwapPriority(index, index - 1)}
|
|
|
|
|
onMoveDown={() => handleSwapPriority(index, index + 1)}
|
2026-03-09 04:46:06 -04:00
|
|
|
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
2026-03-09 06:17:59 -04:00
|
|
|
onUpdateProxy={async (proxyPoolId) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/providers/${conn.id}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ proxyPoolId: proxyPoolId || null }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setConnections(prev => prev.map(c =>
|
|
|
|
|
c.id === conn.id
|
|
|
|
|
? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } }
|
|
|
|
|
: c
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error updating proxy:", error);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-03-09 04:46:06 -04:00
|
|
|
onEdit={() => {
|
|
|
|
|
setSelectedConnection(conn);
|
|
|
|
|
setShowEditModal(true);
|
|
|
|
|
}}
|
|
|
|
|
onDelete={() => handleDelete(conn.id)}
|
2026-05-21 01:15:07 -04:00
|
|
|
oneByOneStatus={oneByOneResults[conn.id] || null}
|
2026-03-09 04:46:06 -04:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-05 09:22:21 -04:00
|
|
|
const activePools = proxyPools.filter((p) => p.isActive === true);
|
2026-03-09 04:46:06 -04:00
|
|
|
|
|
|
|
|
const bulkActionModal = (
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen={showBulkProxyModal}
|
|
|
|
|
onClose={closeBulkProxyModal}
|
2026-05-05 09:22:21 -04:00
|
|
|
title={`Apply Proxy (${connections.length} connections)`}
|
2026-03-09 04:46:06 -04:00
|
|
|
>
|
2026-05-05 09:22:21 -04:00
|
|
|
<div className="flex flex-col gap-3">
|
2026-05-07 04:45:09 -04:00
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleApplyOneToOne}
|
|
|
|
|
disabled={bulkUpdatingProxy || activePools.length === 0}
|
|
|
|
|
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-text-muted text-[18px]">sync_alt</span>
|
|
|
|
|
<span className="text-sm text-text-main">One-to-one (rotate)</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleApplySinglePool(null)}
|
|
|
|
|
disabled={bulkUpdatingProxy}
|
|
|
|
|
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-text-muted text-[18px]">link_off</span>
|
|
|
|
|
<span className="text-sm text-text-main">None (unbind all)</span>
|
|
|
|
|
</button>
|
|
|
|
|
{proxyPools.map((pool) => (
|
2026-05-05 09:22:21 -04:00
|
|
|
<button
|
2026-05-07 04:45:09 -04:00
|
|
|
key={pool.id}
|
|
|
|
|
onClick={() => handleApplySinglePool(pool.id)}
|
|
|
|
|
disabled={bulkUpdatingProxy || pool.isActive !== true}
|
|
|
|
|
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
|
2026-05-05 09:22:21 -04:00
|
|
|
>
|
2026-05-07 04:45:09 -04:00
|
|
|
<span className="material-symbols-outlined text-text-muted text-[18px]">lan</span>
|
|
|
|
|
<span className="truncate text-sm text-text-main">{pool.name}</span>
|
|
|
|
|
{pool.isActive !== true && (
|
|
|
|
|
<span className="text-[10px] text-text-muted">(inactive)</span>
|
|
|
|
|
)}
|
2026-05-05 09:22:21 -04:00
|
|
|
</button>
|
2026-05-07 04:45:09 -04:00
|
|
|
))}
|
2026-03-09 04:46:06 -04:00
|
|
|
</div>
|
2026-05-05 09:22:21 -04:00
|
|
|
|
|
|
|
|
{bulkUpdatingProxy && <p className="text-xs text-text-muted">Applying...</p>}
|
|
|
|
|
|
|
|
|
|
<Button onClick={closeBulkProxyModal} variant="ghost" fullWidth disabled={bulkUpdatingProxy}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
2026-03-09 04:46:06 -04:00
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-28 04:33:18 -05:00
|
|
|
const handleTestModel = async (modelId) => {
|
|
|
|
|
if (testingModelId) return;
|
|
|
|
|
setTestingModelId(modelId);
|
2026-02-26 23:15:12 -05:00
|
|
|
try {
|
2026-02-28 04:33:18 -05:00
|
|
|
const res = await fetch("/api/models/test", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }),
|
|
|
|
|
});
|
2026-02-26 23:15:12 -05:00
|
|
|
const data = await res.json();
|
2026-02-28 04:33:18 -05:00
|
|
|
setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" }));
|
2026-03-04 23:25:03 -05:00
|
|
|
setModelsTestError(data.ok ? "" : (data.error || "Model not reachable"));
|
2026-02-26 23:15:12 -05:00
|
|
|
} catch {
|
2026-02-28 04:33:18 -05:00
|
|
|
setModelTestResults((prev) => ({ ...prev, [modelId]: "error" }));
|
2026-03-04 23:25:03 -05:00
|
|
|
setModelsTestError("Network error");
|
2026-02-26 23:15:12 -05:00
|
|
|
} finally {
|
2026-02-28 04:33:18 -05:00
|
|
|
setTestingModelId(null);
|
2026-02-26 23:15:12 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 18:31:08 -05:00
|
|
|
const renderModelsSection = () => {
|
2026-02-03 03:11:41 -05:00
|
|
|
if (isCompatible) {
|
2026-02-02 07:45:12 -05:00
|
|
|
return (
|
2026-02-03 03:11:41 -05:00
|
|
|
<CompatibleModelsSection
|
2026-02-02 07:45:12 -05:00
|
|
|
providerStorageAlias={providerStorageAlias}
|
|
|
|
|
providerDisplayAlias={providerDisplayAlias}
|
|
|
|
|
modelAliases={modelAliases}
|
|
|
|
|
copied={copied}
|
|
|
|
|
onCopy={copy}
|
|
|
|
|
onSetAlias={handleSetAlias}
|
|
|
|
|
onDeleteAlias={handleDeleteAlias}
|
|
|
|
|
connections={connections}
|
2026-02-03 03:11:41 -05:00
|
|
|
isAnthropic={isAnthropicCompatible}
|
2026-02-02 07:45:12 -05:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-30 22:22:21 -04:00
|
|
|
// Combine hardcoded models with Kilo free models (deduplicated)
|
2026-04-09 23:17:53 -04:00
|
|
|
// Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers
|
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
2026-05-04 23:32:59 -04:00
|
|
|
const allModels = [
|
2026-03-30 22:22:21 -04:00
|
|
|
...models,
|
|
|
|
|
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
2026-06-14 05:21:20 -04:00
|
|
|
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; });
|
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
2026-05-04 23:32:59 -04:00
|
|
|
const disabledSet = new Set(disabledModelIds);
|
|
|
|
|
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
|
|
|
|
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
2026-02-27 22:04:57 -05:00
|
|
|
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
|
|
|
|
|
const customModels = Object.entries(modelAliases)
|
|
|
|
|
.filter(([alias, fullModel]) => {
|
|
|
|
|
const prefix = `${providerStorageAlias}/`;
|
|
|
|
|
if (!fullModel.startsWith(prefix)) return false;
|
|
|
|
|
const modelId = fullModel.slice(prefix.length);
|
|
|
|
|
// Only show if not already in hardcoded list
|
2026-03-30 00:28:07 -04:00
|
|
|
// For passthroughModels, include all aliases (model IDs may contain slashes like "anthropic/claude-3")
|
|
|
|
|
if (providerInfo.passthroughModels) return !models.some((m) => m.id === modelId);
|
2026-02-27 22:04:57 -05:00
|
|
|
return !models.some((m) => m.id === modelId) && alias === modelId;
|
|
|
|
|
})
|
|
|
|
|
.map(([alias, fullModel]) => ({
|
|
|
|
|
id: fullModel.slice(`${providerStorageAlias}/`.length),
|
|
|
|
|
alias,
|
|
|
|
|
fullModel,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-19 18:31:08 -05:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap gap-3">
|
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
2026-05-04 23:32:59 -04:00
|
|
|
{/* 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}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
2026-03-30 22:22:21 -04:00
|
|
|
{displayModels.map((model) => {
|
2026-02-02 07:45:12 -05:00
|
|
|
const fullModel = `${providerStorageAlias}/${model.id}`;
|
2026-01-19 18:31:08 -05:00
|
|
|
const oldFormatModel = `${providerId}/${model.id}`;
|
|
|
|
|
const existingAlias = Object.entries(modelAliases).find(
|
|
|
|
|
([, m]) => m === fullModel || m === oldFormatModel
|
|
|
|
|
)?.[0];
|
|
|
|
|
return (
|
|
|
|
|
<ModelRow
|
|
|
|
|
key={model.id}
|
|
|
|
|
model={model}
|
2026-02-02 07:45:12 -05:00
|
|
|
fullModel={`${providerDisplayAlias}/${model.id}`}
|
2026-01-19 18:31:08 -05:00
|
|
|
alias={existingAlias}
|
|
|
|
|
copied={copied}
|
|
|
|
|
onCopy={copy}
|
2026-02-02 07:45:12 -05:00
|
|
|
onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)}
|
2026-01-19 18:31:08 -05:00
|
|
|
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
2026-02-26 23:15:12 -05:00
|
|
|
testStatus={modelTestResults[model.id]}
|
2026-04-06 06:32:44 -04:00
|
|
|
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
2026-02-28 04:33:18 -05:00
|
|
|
isTesting={testingModelId === model.id}
|
2026-03-30 22:22:21 -04:00
|
|
|
isFree={model.isFree}
|
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
2026-05-04 23:32:59 -04:00
|
|
|
onDisable={() => handleDisableModel(model.id)}
|
2026-01-19 18:31:08 -05:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-02-27 22:04:57 -05:00
|
|
|
|
|
|
|
|
{/* Add model button — inline, same style as model chips */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowAddCustomModel(true)}
|
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
2026-05-04 23:32:59 -04:00
|
|
|
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"
|
2026-02-27 22:04:57 -05:00
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-sm">add</span>
|
|
|
|
|
Add Model
|
|
|
|
|
</button>
|
2026-03-30 00:28:07 -04:00
|
|
|
|
2026-06-05 23:22:03 -04:00
|
|
|
{/* Import Qoder models button — only show for qoder provider */}
|
|
|
|
|
{providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleImportQoderModels}
|
|
|
|
|
disabled={importingQoderModels}
|
|
|
|
|
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-blue-500/40 px-3 py-2 text-xs text-blue-600 dark:text-blue-400 transition-colors hover:border-blue-500 hover:bg-blue-500/5 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-sm" style={importingQoderModels ? { animation: "spin 1s linear infinite" } : undefined}>
|
|
|
|
|
{importingQoderModels ? "progress_activity" : "download"}
|
|
|
|
|
</span>
|
|
|
|
|
{importingQoderModels ? translate("Fetching...") : translate("Fetch Qoder Models")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-30 00:28:07 -04:00
|
|
|
{/* Suggested models from provider API — show only models not yet added */}
|
|
|
|
|
{suggestedModels.length > 0 && (() => {
|
|
|
|
|
const addedFullModels = new Set(Object.values(modelAliases));
|
2026-04-14 23:45:46 -04:00
|
|
|
const hardcodedIds = new Set(models.map((m) => m.id));
|
2026-03-30 00:28:07 -04:00
|
|
|
const notAdded = suggestedModels.filter(
|
2026-04-14 23:45:46 -04:00
|
|
|
(m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id)
|
2026-03-30 00:28:07 -04:00
|
|
|
);
|
|
|
|
|
if (notAdded.length === 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full mt-2">
|
|
|
|
|
<p className="text-xs text-text-muted mb-2">Suggested free models (≥200k context):</p>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{notAdded.map((m) => (
|
|
|
|
|
<button
|
|
|
|
|
key={m.id}
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
const alias = m.id.split("/").pop();
|
|
|
|
|
await handleSetAlias(m.id, alias, providerStorageAlias);
|
|
|
|
|
}}
|
|
|
|
|
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border 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={`${m.name} · ${(m.contextLength / 1000).toFixed(0)}k ctx`}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-[13px]">add</span>
|
|
|
|
|
{m.id.split("/").pop()}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
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
2026-05-04 23:32:59 -04:00
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
)}
|
2026-01-19 18:31:08 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-02 07:45:12 -05:00
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-8">
|
|
|
|
|
<CardSkeleton />
|
|
|
|
|
<CardSkeleton />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-02-02 10:26:40 -05:00
|
|
|
}
|
2026-02-02 07:45:12 -05:00
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
if (!providerInfo) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-center py-20">
|
|
|
|
|
<p className="text-text-muted">Provider not found</p>
|
|
|
|
|
<Link href="/dashboard/providers" className="text-primary mt-4 inline-block">
|
|
|
|
|
Back to Providers
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 10:26:40 -05:00
|
|
|
// Determine icon path: OpenAI Compatible providers use specialized icons
|
|
|
|
|
const getHeaderIconPath = () => {
|
|
|
|
|
if (isOpenAICompatible && providerInfo.apiType) {
|
|
|
|
|
return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
|
|
|
|
}
|
2026-02-03 03:11:41 -05:00
|
|
|
if (isAnthropicCompatible) {
|
2026-02-03 03:41:10 -05:00
|
|
|
return "/providers/anthropic-m.png";
|
2026-02-03 03:11:41 -05:00
|
|
|
}
|
2026-02-02 10:26:40 -05:00
|
|
|
return `/providers/${providerInfo.id}.png`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
return (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex min-w-0 flex-col gap-6 px-1 sm:gap-8 sm:px-0">
|
2026-01-04 21:58:59 -05:00
|
|
|
{/* Header */}
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="min-w-0">
|
2026-01-04 21:58:59 -05:00
|
|
|
<Link
|
|
|
|
|
href="/dashboard/providers"
|
|
|
|
|
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
|
|
|
|
Back to Providers
|
|
|
|
|
</Link>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
|
2026-01-04 21:58:59 -05:00
|
|
|
<div
|
2026-05-01 05:34:07 -04:00
|
|
|
className="flex size-12 shrink-0 items-center justify-center rounded-lg"
|
2026-01-04 21:58:59 -05:00
|
|
|
style={{ backgroundColor: `${providerInfo.color}15` }}
|
|
|
|
|
>
|
2026-02-02 10:26:40 -05:00
|
|
|
{headerImgError ? (
|
2026-02-02 07:45:12 -05:00
|
|
|
<span className="text-sm font-bold" style={{ color: providerInfo.color }}>
|
2026-02-02 10:26:40 -05:00
|
|
|
{providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()}
|
2026-02-02 07:45:12 -05:00
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Image
|
2026-02-02 10:26:40 -05:00
|
|
|
src={getHeaderIconPath()}
|
2026-02-02 07:45:12 -05:00
|
|
|
alt={providerInfo.name}
|
|
|
|
|
width={48}
|
|
|
|
|
height={48}
|
2026-05-01 05:34:07 -04:00
|
|
|
className="max-h-12 max-w-12 rounded-lg object-contain"
|
2026-02-02 07:45:12 -05:00
|
|
|
sizes="48px"
|
2026-02-02 10:26:40 -05:00
|
|
|
onError={() => setHeaderImgError(true)}
|
2026-02-02 07:45:12 -05:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-01-04 21:58:59 -05:00
|
|
|
</div>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="min-w-0">
|
2026-05-01 06:03:13 -04:00
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
<h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{providerInfo.name}</h1>
|
2026-05-03 07:00:35 -04:00
|
|
|
{(providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website) && (
|
2026-05-01 06:03:13 -04:00
|
|
|
<a
|
2026-05-03 07:00:35 -04:00
|
|
|
href={providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website}
|
2026-05-01 06:03:13 -04:00
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-sm">open_in_new</span>
|
2026-05-03 07:00:35 -04:00
|
|
|
{providerInfo.notice?.apiKeyUrl ? "Get API Key" : "Sign up / Learn more"}
|
2026-05-01 06:03:13 -04:00
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-04 21:58:59 -05:00
|
|
|
<p className="text-text-muted">
|
2026-01-19 18:31:08 -05:00
|
|
|
{connections.length} connection{connections.length === 1 ? "" : "s"}
|
2026-01-04 21:58:59 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-27 00:45:54 -04:00
|
|
|
{providerInfo.deprecated && (
|
2026-04-13 01:04:57 -04:00
|
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
|
|
|
|
<span className="material-symbols-outlined text-[16px] text-yellow-500 mt-0.5 shrink-0">warning</span>
|
|
|
|
|
<p className="text-xs text-red-600 dark:text-yellow-400 leading-relaxed">{providerInfo.deprecationNotice}</p>
|
2026-03-27 00:45:54 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-01 06:03:13 -04:00
|
|
|
{providerInfo.notice?.text && !providerInfo.deprecated && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex flex-col gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 sm:flex-row sm:items-center">
|
2026-04-13 01:04:57 -04:00
|
|
|
<span className="material-symbols-outlined text-[16px] text-blue-500 shrink-0">info</span>
|
2026-05-01 05:34:07 -04:00
|
|
|
<p className="min-w-0 flex-1 text-xs leading-relaxed text-blue-600 dark:text-blue-400">{providerInfo.notice.text}</p>
|
2026-03-30 00:28:07 -04:00
|
|
|
{providerInfo.notice.apiKeyUrl && (
|
|
|
|
|
<a
|
|
|
|
|
href={providerInfo.notice.apiKeyUrl}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
2026-05-01 05:34:07 -04:00
|
|
|
className="inline-flex justify-center rounded bg-blue-500 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-600 sm:py-0.5"
|
2026-03-30 00:28:07 -04:00
|
|
|
>
|
|
|
|
|
Get API Key →
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-03 03:11:41 -05:00
|
|
|
{isCompatible && providerNode && (
|
2026-02-02 07:45:12 -05:00
|
|
|
<Card>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
<div className="min-w-0">
|
2026-02-03 03:11:41 -05:00
|
|
|
<h2 className="text-lg font-semibold">{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}</h2>
|
2026-05-01 05:34:07 -04:00
|
|
|
<p className="break-all text-sm text-text-muted">
|
2026-02-03 03:11:41 -05:00
|
|
|
{isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
|
|
|
|
{isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")}
|
2026-02-02 07:45:12 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
2026-02-02 07:45:12 -05:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="add"
|
2026-05-07 05:17:03 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
setAddConnectionError("");
|
|
|
|
|
setShowAddApiKeyModal(true);
|
|
|
|
|
}}
|
2026-05-01 05:34:07 -04:00
|
|
|
className="w-full sm:w-auto"
|
2026-02-02 07:45:12 -05:00
|
|
|
>
|
2026-05-07 05:17:03 -04:00
|
|
|
Add API Key
|
2026-02-02 07:45:12 -05:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
icon="edit"
|
|
|
|
|
onClick={() => setShowEditNodeModal(true)}
|
2026-05-01 05:34:07 -04:00
|
|
|
className="w-full sm:w-auto"
|
2026-02-02 07:45:12 -05:00
|
|
|
>
|
|
|
|
|
Edit
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
icon="delete"
|
|
|
|
|
onClick={async () => {
|
2026-05-13 04:32:17 -04:00
|
|
|
setConfirmState({
|
|
|
|
|
title: "Delete Compatible Node",
|
|
|
|
|
message: `Delete this ${isAnthropicCompatible ? "Anthropic" : "OpenAI"} Compatible node?`,
|
|
|
|
|
onConfirm: async () => {
|
|
|
|
|
setConfirmState(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" });
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
router.push("/dashboard/providers");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error deleting provider node:", error);
|
|
|
|
|
}
|
2026-02-02 07:45:12 -05:00
|
|
|
}
|
2026-05-13 04:32:17 -04:00
|
|
|
});
|
2026-02-02 07:45:12 -05:00
|
|
|
}}
|
2026-05-01 05:34:07 -04:00
|
|
|
className="w-full sm:w-auto"
|
2026-02-02 07:45:12 -05:00
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
{/* Connections */}
|
2026-04-06 06:32:44 -04:00
|
|
|
{isFreeNoAuth ? (
|
2026-04-28 00:07:39 -04:00
|
|
|
<NoAuthProxyCard providerId={providerId} />
|
2026-04-06 06:32:44 -04:00
|
|
|
) : (
|
|
|
|
|
<Card>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
2026-04-06 06:32:44 -04:00
|
|
|
<h2 className="text-lg font-semibold">Connections</h2>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
2026-05-05 09:22:21 -04:00
|
|
|
{connections.length > 0 && proxyPools.length > 0 && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
icon="lan"
|
|
|
|
|
onClick={() => setShowBulkProxyModal(true)}
|
|
|
|
|
>
|
|
|
|
|
Apply Proxy
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-05-21 01:15:07 -04:00
|
|
|
{connections.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
icon="sync"
|
|
|
|
|
onClick={handleRunOneByOneTest}
|
|
|
|
|
disabled={oneByOneRunning}
|
|
|
|
|
>
|
|
|
|
|
{oneByOneRunning ? "Testing Connection One-by-One..." : "Test Connection One-by-One"}
|
|
|
|
|
</Button>
|
|
|
|
|
{oneByOneRunning && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
icon="stop"
|
|
|
|
|
onClick={handleStopOneByOneTest}
|
|
|
|
|
disabled={oneByOneStopping}
|
|
|
|
|
>
|
|
|
|
|
{oneByOneStopping ? "Stopping..." : "Stop"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-04-13 01:04:57 -04:00
|
|
|
{/* Thinking config */}
|
2026-04-14 00:41:06 -04:00
|
|
|
{/* {thinkingConfig && (
|
2026-04-13 01:04:57 -04:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-text-muted font-medium">Thinking</span>
|
|
|
|
|
<select
|
|
|
|
|
value={thinkingMode}
|
|
|
|
|
onChange={(e) => handleThinkingModeChange(e.target.value)}
|
|
|
|
|
className="text-xs px-2 py-1 border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
|
|
|
|
>
|
|
|
|
|
{thinkingConfig.options.map((opt) => (
|
|
|
|
|
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-04-06 06:32:44 -04:00
|
|
|
</div>
|
2026-04-14 00:41:06 -04:00
|
|
|
)} */}
|
2026-04-13 01:04:57 -04:00
|
|
|
{/* Round Robin toggle */}
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
2026-04-13 01:04:57 -04:00
|
|
|
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
|
|
|
|
<Toggle
|
|
|
|
|
checked={providerStrategy === "round-robin"}
|
|
|
|
|
onChange={handleRoundRobinToggle}
|
|
|
|
|
/>
|
|
|
|
|
{providerStrategy === "round-robin" && (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-xs text-text-muted">Sticky:</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
value={providerStickyLimit}
|
|
|
|
|
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
|
|
|
|
placeholder="1"
|
|
|
|
|
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-31 00:58:04 -05:00
|
|
|
</div>
|
2026-01-04 21:58:59 -05:00
|
|
|
</div>
|
2026-04-06 06:32:44 -04:00
|
|
|
|
|
|
|
|
{connections.length === 0 ? (
|
2026-05-03 07:00:35 -04:00
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-primary/10 text-primary shrink-0">
|
|
|
|
|
<span className="material-symbols-outlined text-[18px]">{isOAuth ? "lock" : "key"}</span>
|
|
|
|
|
</div>
|
2026-05-20 23:18:12 -04:00
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-sm text-text-muted">No connections yet</p>
|
|
|
|
|
{hasDualAuthModes && (
|
|
|
|
|
<p className="text-xs text-text-muted">
|
|
|
|
|
Choose {oauthConnectionLabel} or {apiKeyConnectionLabel}.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-06 06:32:44 -04:00
|
|
|
</div>
|
2026-05-07 05:17:03 -04:00
|
|
|
<div className="flex gap-2">
|
2026-05-20 23:18:12 -04:00
|
|
|
{hasDualAuthModes ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button size="sm" icon="lock" variant="secondary" onClick={triggerOAuthConnection}>
|
|
|
|
|
{oauthConnectionLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" icon="key" onClick={triggerApiKeyConnection}>
|
|
|
|
|
{apiKeyConnectionLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{!isCompatible && providerId === "iflow" && (
|
|
|
|
|
<Button size="sm" icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
|
|
|
|
|
Cookie
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-06-07 23:08:46 -04:00
|
|
|
{providerId === "codex" && (
|
|
|
|
|
<Button size="sm" icon="playlist_add" variant="secondary" onClick={() => setShowBulkImportCodex(true)}>
|
|
|
|
|
{translate("Bulk Add")}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-05-20 23:18:12 -04:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="add"
|
|
|
|
|
onClick={triggerAddConnection}
|
|
|
|
|
>
|
|
|
|
|
{isCompatible ? "Add API Key" : (providerId === "iflow" ? "OAuth" : "Add Connection")}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
2026-05-07 05:17:03 -04:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-06 06:32:44 -04:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-05-21 01:15:07 -04:00
|
|
|
{oneByOneSummary && (
|
|
|
|
|
<div className="mb-4 rounded-lg border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
|
|
|
<span>Total: {oneByOneSummary.total}</span>
|
|
|
|
|
<span>Completed: {oneByOneSummary.completed}</span>
|
|
|
|
|
<span>Passed: {oneByOneSummary.passed}</span>
|
|
|
|
|
<span>Failed: {oneByOneSummary.failed}</span>
|
|
|
|
|
{oneByOneSummary.stopped && (
|
|
|
|
|
<span className="text-amber-600 dark:text-amber-400">Stopped</span>
|
|
|
|
|
)}
|
|
|
|
|
{oneByOneRunning && oneByOneCurrentConnectionId && (
|
|
|
|
|
<span>Running: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-06 06:32:44 -04:00
|
|
|
{connectionsList}
|
|
|
|
|
{!isCompatible && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
2026-04-06 06:32:44 -04:00
|
|
|
{providerId === "iflow" && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="cookie"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={() => setShowIFlowCookieModal(true)}
|
|
|
|
|
title="Add connection using browser cookie"
|
2026-05-01 05:34:07 -04:00
|
|
|
className="w-full sm:w-auto"
|
2026-04-06 06:32:44 -04:00
|
|
|
>
|
|
|
|
|
Cookie
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-06-07 23:08:46 -04:00
|
|
|
{providerId === "codex" && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="playlist_add"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={() => setShowBulkImportCodex(true)}
|
|
|
|
|
title={translate("Bulk import codex accounts from JSON")}
|
|
|
|
|
className="w-full sm:w-auto"
|
|
|
|
|
>
|
|
|
|
|
{translate("Bulk Add")}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-05-20 23:18:12 -04:00
|
|
|
{hasDualAuthModes ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="lock"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={triggerOAuthConnection}
|
|
|
|
|
className="w-full sm:w-auto"
|
|
|
|
|
>
|
|
|
|
|
{oauthConnectionLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="key"
|
|
|
|
|
onClick={triggerApiKeyConnection}
|
|
|
|
|
className="w-full sm:w-auto"
|
|
|
|
|
>
|
|
|
|
|
{apiKeyConnectionLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
icon="add"
|
|
|
|
|
onClick={triggerAddConnection}
|
|
|
|
|
className="w-full sm:w-auto"
|
|
|
|
|
>
|
|
|
|
|
Add
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-04-06 06:32:44 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2026-01-04 21:58:59 -05:00
|
|
|
|
|
|
|
|
{/* Models */}
|
|
|
|
|
<Card>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
2026-02-26 23:15:12 -05:00
|
|
|
<h2 className="text-lg font-semibold">
|
2026-03-30 00:28:07 -04:00
|
|
|
{"Available Models"}
|
2026-02-26 23:15:12 -05:00
|
|
|
</h2>
|
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
2026-05-04 23:32:59 -04:00
|
|
|
{!isCompatible && (() => {
|
|
|
|
|
const allIds = [
|
|
|
|
|
...models,
|
|
|
|
|
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
2026-06-14 05:21:20 -04:00
|
|
|
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; }).map((m) => m.id);
|
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
2026-05-04 23:32:59 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
2026-02-26 23:15:12 -05:00
|
|
|
</div>
|
2026-03-04 23:25:03 -05:00
|
|
|
{!!modelsTestError && (
|
|
|
|
|
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
|
|
|
|
|
)}
|
2026-01-19 18:31:08 -05:00
|
|
|
{renderModelsSection()}
|
2026-01-04 21:58:59 -05:00
|
|
|
</Card>
|
|
|
|
|
|
2026-03-09 04:46:06 -04:00
|
|
|
{bulkActionModal}
|
|
|
|
|
|
2026-01-04 21:58:59 -05:00
|
|
|
{/* Modals */}
|
2026-01-26 23:35:28 -05:00
|
|
|
{providerId === "kiro" ? (
|
|
|
|
|
<KiroOAuthWrapper
|
|
|
|
|
isOpen={showOAuthModal}
|
|
|
|
|
providerInfo={providerInfo}
|
|
|
|
|
onSuccess={handleOAuthSuccess}
|
|
|
|
|
onClose={() => setShowOAuthModal(false)}
|
|
|
|
|
/>
|
2026-02-04 00:07:29 -05:00
|
|
|
) : providerId === "cursor" ? (
|
|
|
|
|
<CursorAuthModal
|
|
|
|
|
isOpen={showOAuthModal}
|
|
|
|
|
onSuccess={handleOAuthSuccess}
|
2026-01-26 23:35:28 -05:00
|
|
|
onClose={() => setShowOAuthModal(false)}
|
|
|
|
|
/>
|
2026-03-30 00:28:07 -04:00
|
|
|
) : providerId === "gitlab" ? (
|
|
|
|
|
<GitLabAuthModal
|
|
|
|
|
isOpen={showOAuthModal}
|
|
|
|
|
providerInfo={providerInfo}
|
|
|
|
|
onSuccess={handleOAuthSuccess}
|
|
|
|
|
onClose={() => setShowOAuthModal(false)}
|
|
|
|
|
/>
|
2026-01-26 23:35:28 -05:00
|
|
|
) : (
|
|
|
|
|
<OAuthModal
|
|
|
|
|
isOpen={showOAuthModal}
|
|
|
|
|
provider={providerId}
|
|
|
|
|
providerInfo={providerInfo}
|
|
|
|
|
onSuccess={handleOAuthSuccess}
|
|
|
|
|
onClose={() => setShowOAuthModal(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-04 23:25:03 -05:00
|
|
|
{providerId === "iflow" && (
|
|
|
|
|
<IFlowCookieModal
|
|
|
|
|
isOpen={showIFlowCookieModal}
|
|
|
|
|
onSuccess={handleIFlowCookieSuccess}
|
|
|
|
|
onClose={() => setShowIFlowCookieModal(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-01-04 21:58:59 -05:00
|
|
|
<AddApiKeyModal
|
|
|
|
|
isOpen={showAddApiKeyModal}
|
|
|
|
|
provider={providerId}
|
2026-02-02 07:45:12 -05:00
|
|
|
providerName={providerInfo.name}
|
2026-02-03 03:11:41 -05:00
|
|
|
isCompatible={isCompatible}
|
|
|
|
|
isAnthropic={isAnthropicCompatible}
|
2026-04-22 00:58:53 -04:00
|
|
|
authType={providerInfo?.authType}
|
|
|
|
|
authHint={providerInfo?.authHint}
|
|
|
|
|
website={providerInfo?.website}
|
2026-03-09 04:46:06 -04:00
|
|
|
proxyPools={proxyPools}
|
2026-05-07 05:17:03 -04:00
|
|
|
error={addConnectionError}
|
2026-01-04 21:58:59 -05:00
|
|
|
onSave={handleSaveApiKey}
|
2026-05-09 21:44:14 -04:00
|
|
|
onBulkDone={fetchConnections}
|
2026-05-07 05:17:03 -04:00
|
|
|
onClose={() => {
|
|
|
|
|
setAddConnectionError("");
|
|
|
|
|
setShowAddApiKeyModal(false);
|
|
|
|
|
}}
|
2026-01-04 21:58:59 -05:00
|
|
|
/>
|
|
|
|
|
<EditConnectionModal
|
|
|
|
|
isOpen={showEditModal}
|
|
|
|
|
connection={selectedConnection}
|
2026-03-09 04:46:06 -04:00
|
|
|
proxyPools={proxyPools}
|
2026-01-04 21:58:59 -05:00
|
|
|
onSave={handleUpdateConnection}
|
|
|
|
|
onClose={() => setShowEditModal(false)}
|
|
|
|
|
/>
|
2026-02-03 03:11:41 -05:00
|
|
|
{isCompatible && (
|
|
|
|
|
<EditCompatibleNodeModal
|
|
|
|
|
isOpen={showEditNodeModal}
|
|
|
|
|
node={providerNode}
|
|
|
|
|
onSave={handleUpdateNode}
|
|
|
|
|
onClose={() => setShowEditNodeModal(false)}
|
|
|
|
|
isAnthropic={isAnthropicCompatible}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-30 00:28:07 -04:00
|
|
|
{!isCompatible && (
|
2026-02-27 22:04:57 -05:00
|
|
|
<AddCustomModelModal
|
|
|
|
|
isOpen={showAddCustomModel}
|
|
|
|
|
providerAlias={providerStorageAlias}
|
|
|
|
|
providerDisplayAlias={providerDisplayAlias}
|
|
|
|
|
onSave={async (modelId) => {
|
2026-03-30 00:28:07 -04:00
|
|
|
// For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts
|
|
|
|
|
const alias = providerInfo?.passthroughModels
|
|
|
|
|
? modelId.split("/").pop()
|
|
|
|
|
: modelId;
|
|
|
|
|
await handleSetAlias(modelId, alias, providerStorageAlias);
|
2026-02-27 22:04:57 -05:00
|
|
|
setShowAddCustomModel(false);
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => setShowAddCustomModel(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-13 04:32:17 -04:00
|
|
|
|
2026-06-07 23:08:46 -04:00
|
|
|
{providerId === "codex" && (
|
|
|
|
|
<BulkImportCodexModal
|
|
|
|
|
isOpen={showBulkImportCodex}
|
|
|
|
|
onClose={() => setShowBulkImportCodex(false)}
|
|
|
|
|
onSuccess={fetchConnections}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-18 05:26:35 -04:00
|
|
|
{/* AG Risk Confirmation Modal */}
|
|
|
|
|
<ConfirmModal
|
|
|
|
|
isOpen={showAgRiskModal}
|
|
|
|
|
onClose={() => setShowAgRiskModal(false)}
|
|
|
|
|
onConfirm={handleAgRiskConfirm}
|
|
|
|
|
title="Risk Notice"
|
|
|
|
|
message={providerInfo?.deprecationNotice}
|
|
|
|
|
confirmText="I Understand, Continue"
|
|
|
|
|
cancelText="Cancel"
|
|
|
|
|
variant="danger"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-05-13 04:32:17 -04:00
|
|
|
{/* Confirm Modal */}
|
|
|
|
|
<ConfirmModal
|
|
|
|
|
isOpen={!!confirmState}
|
|
|
|
|
onClose={() => setConfirmState(null)}
|
|
|
|
|
onConfirm={confirmState?.onConfirm}
|
|
|
|
|
title={confirmState?.title || "Confirm"}
|
|
|
|
|
message={confirmState?.message}
|
|
|
|
|
variant="danger"
|
|
|
|
|
/>
|
2026-01-04 21:58:59 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|