feat(providers): add SiliconFlow endpoint selector

Signed-off-by: Xiangzhe <xiangzhedev@gmail.com>
This commit is contained in:
Xiangzhe
2026-05-31 12:45:57 +08:00
parent e51ab949fa
commit 778f74c4cb
3 changed files with 131 additions and 15 deletions

View File

@@ -195,6 +195,10 @@ export class DefaultExecutor extends BaseExecutor {
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return buildMaritalkChatUrl(baseUrl);
}
case "siliconflow": {
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return normalizeOpenAIChatUrl(baseUrl);
}
case "lm-studio":
case "modal":
case "reka":

View File

@@ -788,6 +788,7 @@ interface AddApiKeyModalProps {
isOpen: boolean;
provider?: string;
providerName?: string;
initialBaseUrl?: string;
isCompatible?: boolean;
isAnthropic?: boolean;
isCcCompatible?: boolean;
@@ -821,6 +822,11 @@ type CommandCodeAuthFlowState = {
message?: string;
};
const SILICONFLOW_ENDPOINTS = [
{ id: "siliconflow", label: "Global", baseUrl: "https://api.siliconflow.com/v1" },
{ id: "siliconflow-cn", label: "China", baseUrl: "https://api.siliconflow.cn/v1" },
] as const;
interface EditConnectionModalConnection {
id?: string;
name?: string;
@@ -1361,6 +1367,8 @@ export default function ProviderDetailPage() {
const [showOAuthModal, _setShowOAuthModal] = useState(false);
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false);
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
phase: "idle",
@@ -1949,13 +1957,21 @@ export default function ProviderDetailPage() {
setShowOAuthModal(false);
}, [fetchConnections]);
const openApiKeyAddFlow = useCallback(() => {
if (providerId === "siliconflow") {
setShowSiliconFlowEndpointModal(true);
return;
}
setShowAddApiKeyModal(true);
}, [providerId]);
const openPrimaryAddFlow = useCallback(() => {
if (isOAuth) {
setShowOAuthModal(true);
return;
}
setShowAddApiKeyModal(true);
}, [isOAuth]);
openApiKeyAddFlow();
}, [isOAuth, openApiKeyAddFlow]);
const gateConnectionFlow = useCallback(
(callback: () => void) => {
@@ -1998,6 +2014,7 @@ export default function ProviderDetailPage() {
const handleCloseAddApiKeyModal = useCallback(() => {
clearCommandCodeAuthTimer();
setSiliconFlowInitialBaseUrl(undefined);
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
setCommandCodeAuthState({
@@ -2245,6 +2262,7 @@ export default function ProviderDetailPage() {
const newConnection = connectionData?.connection;
await fetchConnections();
setShowAddApiKeyModal(false);
setSiliconFlowInitialBaseUrl(undefined);
// For Gemini: show progress dialog and sync models from endpoint
if (providerId === "gemini" && newConnection?.id) {
@@ -3905,11 +3923,7 @@ export default function ProviderDetailPage() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(() => setShowAddApiKeyModal(true))}
>
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
{t("add")}
</Button>
<Button
@@ -4081,7 +4095,7 @@ export default function ProviderDetailPage() {
size="sm"
variant="secondary"
icon="add"
onClick={() => gateConnectionFlow(() => setShowAddApiKeyModal(true))}
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
Manual API key
</Button>
@@ -4133,11 +4147,7 @@ export default function ProviderDetailPage() {
</>
) : (
connections.length === 0 && (
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(() => setShowAddApiKeyModal(true))}
>
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
{t("add")}
</Button>
)
@@ -4172,7 +4182,7 @@ export default function ProviderDetailPage() {
<Button
variant="secondary"
icon="add"
onClick={() => gateConnectionFlow(() => setShowAddApiKeyModal(true))}
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
Manual API key
</Button>
@@ -4700,11 +4710,26 @@ export default function ProviderDetailPage() {
}}
/>
))}
{providerId === "siliconflow" && (
<SiliconFlowEndpointModal
isOpen={showSiliconFlowEndpointModal}
onSelect={(baseUrl) => {
setSiliconFlowInitialBaseUrl(baseUrl);
setShowSiliconFlowEndpointModal(false);
setShowAddApiKeyModal(true);
}}
onClose={() => {
setShowSiliconFlowEndpointModal(false);
setSiliconFlowInitialBaseUrl(undefined);
}}
/>
)}
{!isUpstreamProxyProvider && (
<AddApiKeyModal
isOpen={showAddApiKeyModal}
provider={providerId}
providerName={providerInfo.name}
initialBaseUrl={siliconFlowInitialBaseUrl}
isCompatible={isCompatible}
isAnthropic={isAnthropicProtocolCompatible}
isCcCompatible={isCcCompatible}
@@ -7295,6 +7320,7 @@ const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
"azure-ai",
"bailian-coding-plan",
"xiaomi-mimo",
"siliconflow",
"heroku",
"databricks",
"snowflake",
@@ -7307,6 +7333,7 @@ const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
"azure-ai": "https://example-resource.services.ai.azure.com/openai/v1",
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
"xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1",
siliconflow: "https://api.siliconflow.com/v1",
"searxng-search": "http://localhost:8888/search",
petals: "https://chat.petals.dev/api/v1/generate",
};
@@ -7372,6 +7399,8 @@ function getProviderBaseUrlPlaceholder(providerId?: string | null) {
case "bailian-coding-plan":
case "xiaomi-mimo":
return getProviderBaseUrlDefault(providerId);
case "siliconflow":
return "https://api.siliconflow.cn/v1";
case "heroku":
return "https://us.inference.heroku.com";
case "databricks":
@@ -7475,10 +7504,60 @@ function extractCommandCodeCredentialInput(value: string): string {
return trimmed;
}
function SiliconFlowEndpointModal({
isOpen,
onSelect,
onClose,
}: {
isOpen: boolean;
onSelect: (baseUrl: string) => void;
onClose: () => void;
}) {
const t = useTranslations("providers");
return (
<Modal
isOpen={isOpen}
title={providerText(t, "connectSiliconFlow", "Connect SiliconFlow")}
onClose={onClose}
size="lg"
>
<div className="space-y-3">
<p className="text-sm text-text-muted mb-4">
{providerText(t, "chooseSiliconFlowEndpoint", "Choose your SiliconFlow endpoint:")}
</p>
{SILICONFLOW_ENDPOINTS.map((endpoint) => (
<button
key={endpoint.id}
type="button"
onClick={() => onSelect(endpoint.baseUrl)}
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">public</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">
{providerText(
t,
endpoint.id === "siliconflow" ? "endpointGlobal" : "endpointChina",
endpoint.label
)}
</h3>
<p className="text-sm text-text-muted font-mono">{endpoint.baseUrl}</p>
</div>
</div>
</button>
))}
</div>
</Modal>
);
}
function AddApiKeyModal({
isOpen,
provider,
providerName,
initialBaseUrl,
isCompatible,
isAnthropic,
isCcCompatible,
@@ -7524,7 +7603,7 @@ function AddApiKeyModal({
name: "",
apiKey: "",
priority: 1,
baseUrl: defaultBaseUrl,
baseUrl: initialBaseUrl || defaultBaseUrl,
cx: "",
region: showsRegion ? defaultRegion : "",
apiRegion: "international",
@@ -7543,6 +7622,17 @@ function AddApiKeyModal({
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [copiedCommandCodeField, setCopiedCommandCodeField] = useState<string | null>(null);
const wasOpenRef = useRef(false);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = isOpen;
if (!isOpen || wasOpen) return;
setFormData((current) => ({
...current,
baseUrl: initialBaseUrl || defaultBaseUrl,
}));
}, [defaultBaseUrl, initialBaseUrl, isOpen]);
const bulkSupported = supportsBulkApiKey(provider);
const [mode, setMode] = useState<"single" | "bulk">("single");
@@ -7757,6 +7847,16 @@ function AddApiKeyModal({
setSaveError(null);
try {
let providerSpecificData: Record<string, unknown> | undefined;
if (usesBaseUrl) {
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl);
if (checked.error) {
setSaveError(checked.error);
return;
}
providerSpecificData = { baseUrl: checked.value };
}
const res = await fetch("/api/providers/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -7764,6 +7864,7 @@ function AddApiKeyModal({
provider,
entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })),
priority: formData.priority || 1,
providerSpecificData,
validateKeys: bulkValidateKeys,
}),
});

View File

@@ -184,6 +184,7 @@ test("DefaultExecutor.buildUrl normalizes configurable chat-openai-compat base U
const maritalk = new DefaultExecutor("maritalk");
const snowflake = new DefaultExecutor("snowflake");
const gigachat = new DefaultExecutor("gigachat");
const siliconflow = new DefaultExecutor("siliconflow");
assert.equal(
bailian.buildUrl("qwen3-coder-plus", true, 0, {
@@ -273,6 +274,16 @@ test("DefaultExecutor.buildUrl normalizes configurable chat-openai-compat base U
}),
"https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
);
assert.equal(
siliconflow.buildUrl("deepseek-ai/DeepSeek-V3.2", true),
"https://api.siliconflow.com/v1/chat/completions"
);
assert.equal(
siliconflow.buildUrl("deepseek-ai/DeepSeek-V3.2", true, 0, {
providerSpecificData: { baseUrl: "https://api.siliconflow.cn/v1" },
}),
"https://api.siliconflow.cn/v1/chat/completions"
);
});
test("DefaultExecutor.buildUrl falls back to OpenAI config for unknown providers", () => {