From 050e56f20b5469be8a1f897d5908ccc01011a02b Mon Sep 17 00:00:00 2001
From: Arden Hermawan
Date: Thu, 7 May 2026 16:17:03 +0700
Subject: [PATCH] Fix compatible provider API key setup (#925)
---
.../providers/[id]/AddApiKeyModal.js | 24 ++-
.../dashboard/providers/[id]/page.js | 67 +++++--
.../compatible-provider-connections.test.js | 170 ++++++++++++++++++
3 files changed, 240 insertions(+), 21 deletions(-)
create mode 100644 tests/unit/compatible-provider-connections.test.js
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
index e1056439..cef80318 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
@@ -4,7 +4,7 @@ import { useState } from "react";
import PropTypes from "prop-types";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
-export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, onSave, onClose }) {
+export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
@@ -19,6 +19,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [formData, setFormData] = useState({
name: "",
apiKey: "",
+ defaultModel: "",
priority: 1,
proxyPoolId: NONE_PROXY_POOL_VALUE,
ollamaHostUrl: "",
@@ -76,6 +77,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
// Non-ollama providers require a name
if (!formData.name) return;
}
+ if (isCompatible && !formData.defaultModel.trim()) return;
setSaving(true);
try {
@@ -100,6 +102,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
await onSave({
name: formData.name || (isOllamaLocal ? "Ollama Local" : ""),
apiKey: formData.apiKey,
+ defaultModel: isCompatible ? formData.defaultModel.trim() : undefined,
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
testStatus: isValid ? "active" : "unknown",
@@ -167,6 +170,14 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
)}
)}
+ {isCompatible && (
+ setFormData({ ...formData, defaultModel: e.target.value })}
+ placeholder={isAnthropic ? "claude-3-5-sonnet-latest" : "gpt-4o-mini"}
+ />
+ )}
{isOllamaLocal && (
Leave blank to use http://localhost:11434. For remote Ollama, enter the full host URL (e.g. http://192.168.1.10:11434).
@@ -177,12 +188,12 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
{validationResult === "success" ? "Valid" : "Invalid"}
)}
+ {error && (
+
{error}
+ )}
{isCompatible && (
- {isAnthropic
- ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.`
- : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.`
- }
+ Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.
)}
{isCloudflareAi && (
@@ -260,7 +271,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
-
+
{saving ? "Saving..." : "Save"}
@@ -285,6 +296,7 @@ AddApiKeyModal.propTypes = {
id: PropTypes.string,
name: PropTypes.string,
})),
+ error: PropTypes.string,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index e7300ca6..87b07f72 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -28,6 +28,7 @@ export default function ProviderDetailPage() {
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
+ const [addConnectionError, setAddConnectionError] = useState("");
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
@@ -359,18 +360,31 @@ export default function ProviderDetailPage() {
};
const handleSaveApiKey = async (formData) => {
+ setAddConnectionError("");
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
});
+
+ let data = null;
+ try {
+ data = await res.json();
+ } catch {
+ data = null;
+ }
+
if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
+ return;
}
+
+ setAddConnectionError(data?.error || "Failed to save connection");
} catch (error) {
console.log("Error saving connection:", error);
+ setAddConnectionError("Failed to save connection");
}
};
@@ -918,11 +932,14 @@ export default function ProviderDetailPage() {
setShowAddApiKeyModal(true)}
+ onClick={() => {
+ setAddConnectionError("");
+ setShowAddApiKeyModal(true);
+ }}
disabled={connections.length > 0}
className="w-full sm:w-auto"
>
- Add
+ Add API Key
No connections yet
- {!isCompatible && (
-
- {providerId === "iflow" && (
-
setShowIFlowCookieModal(true)}>
- Cookie
-
- )}
-
isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
- {providerId === "iflow" ? "OAuth" : "Add Connection"}
+
+ {!isCompatible && providerId === "iflow" && (
+ setShowIFlowCookieModal(true)}>
+ Cookie
-
- )}
+ )}
+ {
+ if (isOAuth) {
+ setShowOAuthModal(true);
+ return;
+ }
+ setAddConnectionError("");
+ setShowAddApiKeyModal(true);
+ }}
+ >
+ {isCompatible ? "Add API Key" : (providerId === "iflow" ? "OAuth" : "Add Connection")}
+
+
) : (
<>
@@ -1060,7 +1086,14 @@ export default function ProviderDetailPage() {
isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
+ onClick={() => {
+ if (isOAuth) {
+ setShowOAuthModal(true);
+ return;
+ }
+ setAddConnectionError("");
+ setShowAddApiKeyModal(true);
+ }}
className="w-full sm:w-auto"
>
Add
@@ -1155,8 +1188,12 @@ export default function ProviderDetailPage() {
authHint={providerInfo?.authHint}
website={providerInfo?.website}
proxyPools={proxyPools}
+ error={addConnectionError}
onSave={handleSaveApiKey}
- onClose={() => setShowAddApiKeyModal(false)}
+ onClose={() => {
+ setAddConnectionError("");
+ setShowAddApiKeyModal(false);
+ }}
/>
({
+ NextResponse: {
+ json(body, init = {}) {
+ return new Response(JSON.stringify(body), {
+ status: init.status || 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ },
+ },
+ }));
+
+ const { POST } = await import("@/app/api/providers/route.js");
+ const {
+ createProviderNode,
+ getProviderConnections,
+ } = await import("@/models/index.js");
+
+ const node = await createProviderNode(nodeData);
+
+ return {
+ node,
+ POST,
+ getProviderConnections,
+ cleanup() {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ },
+ };
+}
+
+function makeRequest(provider) {
+ return new Request("https://9router.local/api/providers", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ provider,
+ apiKey: "test-key",
+ name: "Test Connection",
+ defaultModel: "test-model",
+ }),
+ });
+}
+
+function expectCompatibleConnection(connection, node, { apiType } = {}) {
+ expect(connection.provider).toBe(node.id);
+ expect(connection.authType).toBe("apikey");
+ expect(connection.defaultModel).toBe("test-model");
+ expect(connection.providerSpecificData).toMatchObject({
+ prefix: node.prefix,
+ baseUrl: node.baseUrl,
+ nodeName: node.name,
+ });
+
+ if (apiType !== undefined) {
+ expect(connection.providerSpecificData.apiType).toBe(apiType);
+ }
+}
+
+describe("compatible provider connections API", () => {
+ let cleanup = () => {};
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.doUnmock("next/server");
+ vi.resetModules();
+ vi.clearAllMocks();
+ cleanup();
+ cleanup = () => {};
+ if (originalDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = originalDataDir;
+ });
+
+ it("creates one API-key connection for an OpenAI-compatible node", async () => {
+ const ctx = await setupTestContext({
+ id: "openai-compatible-test",
+ type: "openai-compatible",
+ name: "OpenAI Compatible Test Node",
+ prefix: "oct",
+ apiType: "chat",
+ baseUrl: "https://openai-compatible.test/v1",
+ });
+ cleanup = ctx.cleanup;
+
+ const response = await ctx.POST(makeRequest(ctx.node.id));
+ const body = await response.json();
+ const connection = body.connection;
+ const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
+
+ expect(response.status).toBe(201);
+ expect(storedConnections).toHaveLength(1);
+ expectCompatibleConnection(connection, ctx.node, { apiType: "chat" });
+ expect(storedConnections[0]).toMatchObject({
+ provider: ctx.node.id,
+ authType: "apikey",
+ defaultModel: "test-model",
+ providerSpecificData: {
+ prefix: ctx.node.prefix,
+ apiType: "chat",
+ baseUrl: ctx.node.baseUrl,
+ nodeName: ctx.node.name,
+ },
+ });
+ });
+
+ it("creates one API-key connection for an Anthropic-compatible node", async () => {
+ const ctx = await setupTestContext({
+ id: "anthropic-compatible-test",
+ type: "anthropic-compatible",
+ name: "Anthropic Compatible Test Node",
+ prefix: "act",
+ baseUrl: "https://anthropic-compatible.test/v1",
+ });
+ cleanup = ctx.cleanup;
+
+ const response = await ctx.POST(makeRequest(ctx.node.id));
+ const body = await response.json();
+ const connection = body.connection;
+ const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
+
+ expect(response.status).toBe(201);
+ expect(storedConnections).toHaveLength(1);
+ expectCompatibleConnection(connection, ctx.node);
+ expect(storedConnections[0]).toMatchObject({
+ provider: ctx.node.id,
+ authType: "apikey",
+ defaultModel: "test-model",
+ providerSpecificData: {
+ prefix: ctx.node.prefix,
+ baseUrl: ctx.node.baseUrl,
+ nodeName: ctx.node.name,
+ },
+ });
+ });
+
+ it("returns 400 for a duplicate connection on the same compatible node", async () => {
+ const ctx = await setupTestContext({
+ id: "openai-compatible-duplicate-test",
+ type: "openai-compatible",
+ name: "Duplicate Guard Node",
+ prefix: "dup",
+ apiType: "chat",
+ baseUrl: "https://duplicate-guard.test/v1",
+ });
+ cleanup = ctx.cleanup;
+
+ const firstResponse = 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 });
+
+ expect(firstResponse.status).toBe(201);
+ expect(secondResponse.status).toBe(400);
+ expect(secondBody.error).toContain("Only one connection is allowed");
+ expect(storedConnections).toHaveLength(1);
+ expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" });
+ });
+});