diff --git a/open-sse/providers/registry/xiaomi-tokenplan.js b/open-sse/providers/registry/xiaomi-tokenplan.js index 55441434..8503d251 100644 --- a/open-sse/providers/registry/xiaomi-tokenplan.js +++ b/open-sse/providers/registry/xiaomi-tokenplan.js @@ -21,6 +21,11 @@ export default { }, category: "apikey", hasProviderSpecificData: true, + regions: [ + { id: "sgp", label: "Singapore (新加坡)" }, + { id: "cn", label: "China (中国大陆)" }, + { id: "ams", label: "Amsterdam (阿姆斯特丹)" }, + ], defaultRegion: "sgp", transport: { baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions", diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 1a664f4a..7e1842c8 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -126,18 +126,11 @@ export async function POST(request) { let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData); - // Compatible/embedding nodes allow exactly one connection each. These guards were - // dropped accidentally during the bun:sqlite refactor (v0.4.28); restored to honor - // the contract locked in by tests/unit/compatible-provider-connections.test.js (#925). if (isOpenAICompatibleProvider(provider)) { const node = await getProviderNodeById(provider); if (!node) { return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, apiType: node.apiType, @@ -149,10 +142,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, @@ -163,10 +152,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 85001371..d5684091 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -380,10 +380,13 @@ export async function POST(request) { }; const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const res = await fetch(endpoints[provider], { headers }); + const res = await fetch(endpoints[provider], { headers, signal: AbortSignal.timeout(8000) }); // xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401. if (provider === "xai") { isValid = res.status === 200 || res.status === 403; + } else if (provider === "xiaomi-tokenplan") { + // /models returns 403 for valid keys lacking list permission; only 401 means invalid + isValid = res.status !== 401; } else { isValid = res.ok; } diff --git a/src/shared/components/EditConnectionModal.js b/src/shared/components/EditConnectionModal.js index 4823f501..1cf13f16 100644 --- a/src/shared/components/EditConnectionModal.js +++ b/src/shared/components/EditConnectionModal.js @@ -6,7 +6,8 @@ import Modal from "@/shared/components/Modal"; import Input from "@/shared/components/Input"; import Button from "@/shared/components/Button"; import Badge from "@/shared/components/Badge"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; +import Select from "@/shared/components/Select"; export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) { const [formData, setFormData] = useState({ @@ -21,6 +22,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on organization: "", }); const [cloudflareData, setCloudflareData] = useState({ accountId: "" }); + const [region, setRegion] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [validating, setValidating] = useState(false); @@ -46,6 +48,12 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) { setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" }); } + // Load region for providers that support it (e.g. xiaomi-tokenplan) + const providerCfg = AI_PROVIDERS?.[connection.provider]; + if (providerCfg?.regions) { + const savedRegion = connection.providerSpecificData?.region || providerCfg.defaultRegion || providerCfg.regions[0]?.id || ""; + setRegion(savedRegion); + } setTestResult(null); setValidationResult(null); } @@ -57,6 +65,13 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on const isCompatible = connection ? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider)) : false; + const providerRegions = connection ? (AI_PROVIDERS?.[connection.provider]?.regions || null) : null; + + // Build providerSpecificData for region-aware providers + const buildRegionSpecificData = () => { + if (providerRegions && region) return { ...((connection?.providerSpecificData) || {}), region }; + return undefined; + }; const handleTest = async () => { if (!connection?.provider) return; @@ -86,6 +101,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on apiKey: formData.apiKey, ...(isAzure ? { providerSpecificData: azureData } : {}), ...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}), + ...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}), }), }); const data = await res.json(); @@ -120,6 +136,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on apiKey: formData.apiKey, ...(isAzure ? { providerSpecificData: azureData } : {}), ...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}), + ...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}), }), }); const data = await res.json(); @@ -150,6 +167,10 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on if (isCloudflareAi) { updates.providerSpecificData = { accountId: cloudflareData.accountId }; } + // Persist updated region for region-aware providers + if (providerRegions && region) { + updates.providerSpecificData = buildRegionSpecificData(); + } await onSave(updates); } finally { @@ -243,6 +264,15 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on )} + {providerRegions && ( +