mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(xiaomi-tokenplan): region selector, key validation, multi-connection (#2251)
- Add top-level regions array so Add/Edit modals render region <Select> - EditConnectionModal: load/persist region generically for region-aware providers - validate: accept 403 for xiaomi-tokenplan valid keys, add 8s fetch timeout - Remove single-connection guard for compatible/embedding nodes Co-authored-by: MiQieR <122154116+MiQieR@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
ce6120ce7b
commit
9102c4c6d8
@@ -21,6 +21,11 @@ export default {
|
|||||||
},
|
},
|
||||||
category: "apikey",
|
category: "apikey",
|
||||||
hasProviderSpecificData: true,
|
hasProviderSpecificData: true,
|
||||||
|
regions: [
|
||||||
|
{ id: "sgp", label: "Singapore (新加坡)" },
|
||||||
|
{ id: "cn", label: "China (中国大陆)" },
|
||||||
|
{ id: "ams", label: "Amsterdam (阿姆斯特丹)" },
|
||||||
|
],
|
||||||
defaultRegion: "sgp",
|
defaultRegion: "sgp",
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions",
|
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions",
|
||||||
|
|||||||
@@ -126,18 +126,11 @@ export async function POST(request) {
|
|||||||
|
|
||||||
let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData);
|
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)) {
|
if (isOpenAICompatibleProvider(provider)) {
|
||||||
const node = await getProviderNodeById(provider);
|
const node = await getProviderNodeById(provider);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
apiType: node.apiType,
|
apiType: node.apiType,
|
||||||
@@ -149,10 +142,6 @@ export async function POST(request) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
baseUrl: node.baseUrl,
|
baseUrl: node.baseUrl,
|
||||||
@@ -163,10 +152,6 @@ export async function POST(request) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
baseUrl: node.baseUrl,
|
baseUrl: node.baseUrl,
|
||||||
|
|||||||
@@ -380,10 +380,13 @@ export async function POST(request) {
|
|||||||
};
|
};
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
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.
|
// xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401.
|
||||||
if (provider === "xai") {
|
if (provider === "xai") {
|
||||||
isValid = res.status === 200 || res.status === 403;
|
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 {
|
} else {
|
||||||
isValid = res.ok;
|
isValid = res.ok;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import Modal from "@/shared/components/Modal";
|
|||||||
import Input from "@/shared/components/Input";
|
import Input from "@/shared/components/Input";
|
||||||
import Button from "@/shared/components/Button";
|
import Button from "@/shared/components/Button";
|
||||||
import Badge from "@/shared/components/Badge";
|
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 }) {
|
export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -21,6 +22,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
organization: "",
|
organization: "",
|
||||||
});
|
});
|
||||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||||
|
const [region, setRegion] = useState("");
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
@@ -46,6 +48,12 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
|
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
|
||||||
setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" });
|
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);
|
setTestResult(null);
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,13 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
const isCompatible = connection
|
const isCompatible = connection
|
||||||
? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider))
|
? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider))
|
||||||
: false;
|
: 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 () => {
|
const handleTest = async () => {
|
||||||
if (!connection?.provider) return;
|
if (!connection?.provider) return;
|
||||||
@@ -86,6 +101,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
apiKey: formData.apiKey,
|
apiKey: formData.apiKey,
|
||||||
...(isAzure ? { providerSpecificData: azureData } : {}),
|
...(isAzure ? { providerSpecificData: azureData } : {}),
|
||||||
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
|
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
|
||||||
|
...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -120,6 +136,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
apiKey: formData.apiKey,
|
apiKey: formData.apiKey,
|
||||||
...(isAzure ? { providerSpecificData: azureData } : {}),
|
...(isAzure ? { providerSpecificData: azureData } : {}),
|
||||||
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
|
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
|
||||||
|
...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -150,6 +167,10 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
if (isCloudflareAi) {
|
if (isCloudflareAi) {
|
||||||
updates.providerSpecificData = { accountId: cloudflareData.accountId };
|
updates.providerSpecificData = { accountId: cloudflareData.accountId };
|
||||||
}
|
}
|
||||||
|
// Persist updated region for region-aware providers
|
||||||
|
if (providerRegions && region) {
|
||||||
|
updates.providerSpecificData = buildRegionSpecificData();
|
||||||
|
}
|
||||||
|
|
||||||
await onSave(updates);
|
await onSave(updates);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -243,6 +264,15 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{providerRegions && (
|
||||||
|
<Select
|
||||||
|
label="Region"
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
|
options={providerRegions.map((r) => ({ value: r.id, label: r.label }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isCompatible && !isAzure && !isCloudflareAi && (
|
{!isCompatible && !isAzure && !isCloudflareAi && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||||
|
|||||||
@@ -145,26 +145,25 @@ describe("compatible provider connections API", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for a duplicate connection on the same compatible node", async () => {
|
it("allows multiple connections on the same compatible node", async () => {
|
||||||
const ctx = await setupTestContext({
|
const ctx = await setupTestContext({
|
||||||
id: "openai-compatible-duplicate-test",
|
id: "openai-compatible-multiple-test",
|
||||||
type: "openai-compatible",
|
type: "openai-compatible",
|
||||||
name: "Duplicate Guard Node",
|
name: "Multiple Connections Node",
|
||||||
prefix: "dup",
|
prefix: "mul",
|
||||||
apiType: "chat",
|
apiType: "chat",
|
||||||
baseUrl: "https://duplicate-guard.test/v1",
|
baseUrl: "https://multiple-connections.test/v1",
|
||||||
});
|
});
|
||||||
cleanup = ctx.cleanup;
|
cleanup = ctx.cleanup;
|
||||||
|
|
||||||
const firstResponse = await ctx.POST(makeRequest(ctx.node.id));
|
const firstResponse = await ctx.POST(makeRequest(ctx.node.id));
|
||||||
const secondResponse = await ctx.POST(makeRequest(ctx.node.id));
|
const secondResponse = await ctx.POST(makeRequest(ctx.node.id));
|
||||||
const secondBody = await secondResponse.json();
|
|
||||||
const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
|
const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
|
||||||
|
|
||||||
expect(firstResponse.status).toBe(201);
|
expect(firstResponse.status).toBe(201);
|
||||||
expect(secondResponse.status).toBe(400);
|
expect(secondResponse.status).toBe(201);
|
||||||
expect(secondBody.error).toContain("Only one connection is allowed");
|
expect(storedConnections).toHaveLength(2);
|
||||||
expect(storedConnections).toHaveLength(1);
|
|
||||||
expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" });
|
expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" });
|
||||||
|
expectCompatibleConnection(storedConnections[1], ctx.node, { apiType: "chat" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user