mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
Add optional modelID input for custom API Key Providers testing (#315)
* feat: add modelId fallback for provider validation - If /models endpoint unavailable, validate via /chat/completions - Add optional Model ID input in EditCompatibleNodeModal - Improves compatibility with providers lacking /models endpoint * feat: improve provider validation with modelId fallback - Add Model ID input for chat/completions fallback validation - Reorder UI: API Key → Model ID → Check button + Badge - Display detailed BE error messages in FE - Add status-specific error handling (401/403/400/404/5xx) - Add unit tests for error message helpers - Add vitest devDependency
This commit is contained in:
@@ -1987,6 +1987,7 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [checkModelId, setCheckModelId] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
|
||||
@@ -2030,10 +2031,11 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
const res = await fetch("/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: isAnthropic ? "anthropic-compatible" : "openai-compatible"
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: isAnthropic ? "anthropic-compatible" : "openai-compatible",
|
||||
modelId: checkModelId.trim() || undefined
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -2093,6 +2095,13 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Model ID (optional)"
|
||||
value={checkModelId}
|
||||
onChange={(e) => setCheckModelId(e.target.value)}
|
||||
placeholder="e.g. my-model-id"
|
||||
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
||||
/>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
|
||||
@@ -657,6 +657,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [checkModelId, setCheckModelId] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
|
||||
@@ -705,17 +706,43 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const res = await fetch("/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "openai-compatible" }),
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "openai-compatible",
|
||||
modelId: checkModelId.trim() || undefined
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
setValidationResult(data);
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to render validation result
|
||||
const renderValidationResult = () => {
|
||||
if (!validationResult) return null;
|
||||
const { valid, error, method } = validationResult;
|
||||
|
||||
if (valid) {
|
||||
return (
|
||||
<>
|
||||
<Badge variant="success">Valid</Badge>
|
||||
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="error">Invalid</Badge>
|
||||
{error && <span className="text-sm text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -746,25 +773,25 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
placeholder="https://api.openai.com/v1"
|
||||
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key (for Check)"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label="API Key (for Check)"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Model ID (optional)"
|
||||
value={checkModelId}
|
||||
onChange={(e) => setCheckModelId(e.target.value)}
|
||||
placeholder="e.g. gpt-4, claude-3-opus"
|
||||
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
{renderValidationResult()}
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
@@ -790,13 +817,15 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [checkModelId, setCheckModelId] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [validationResult, setValidationResult] = useState(null); // { valid, error, method }
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
setCheckModelId("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -834,17 +863,43 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const res = await fetch("/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible" }),
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "anthropic-compatible",
|
||||
modelId: checkModelId.trim() || undefined
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
setValidationResult(data);
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to render validation result
|
||||
const renderValidationResult = () => {
|
||||
if (!validationResult) return null;
|
||||
const { valid, error, method } = validationResult;
|
||||
|
||||
if (valid) {
|
||||
return (
|
||||
<>
|
||||
<Badge variant="success">Valid</Badge>
|
||||
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="error">Invalid</Badge>
|
||||
{error && <span className="text-sm text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -869,25 +924,25 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
placeholder="https://api.anthropic.com/v1"
|
||||
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key (for Check)"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label="API Key (for Check)"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Model ID (optional)"
|
||||
value={checkModelId}
|
||||
onChange={(e) => setCheckModelId(e.target.value)}
|
||||
placeholder="e.g. claude-3-opus"
|
||||
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
{renderValidationResult()}
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
|
||||
@@ -32,11 +32,28 @@ const getErrorMessage = (error) => {
|
||||
return "Network connection failed - check URL and network connectivity";
|
||||
};
|
||||
|
||||
// Get status-specific error message for /models endpoint
|
||||
const getModelsErrorMessage = (status) => {
|
||||
if (status === 401 || status === 403) return "API key unauthorized";
|
||||
if (status === 404) return "/models endpoint not found - try chat validation with model ID";
|
||||
if (status >= 500) return "Server error - try again later";
|
||||
return `Unexpected response (${status})`;
|
||||
};
|
||||
|
||||
// Get status-specific error message for /chat/completions endpoint
|
||||
const getChatErrorMessage = (status) => {
|
||||
if (status === 401 || status === 403) return "API key unauthorized";
|
||||
if (status === 400) return "Invalid model or bad request";
|
||||
if (status === 404) return "Chat endpoint not found";
|
||||
if (status >= 500) return "Server error - try again later";
|
||||
return `Chat request failed (${status})`;
|
||||
};
|
||||
|
||||
// POST /api/provider-nodes/validate - Validate API key against base URL
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { baseUrl, apiKey, type } = body;
|
||||
const { baseUrl, apiKey, type, modelId } = body;
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 });
|
||||
@@ -49,25 +66,55 @@ export async function POST(request) {
|
||||
|
||||
// Anthropic Compatible Validation
|
||||
if (type === "anthropic-compatible") {
|
||||
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
|
||||
let normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
||||
if (normalizedBase.endsWith("/messages")) {
|
||||
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
|
||||
normalizedBase = normalizedBase.slice(0, -9);
|
||||
}
|
||||
|
||||
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
|
||||
|
||||
const modelsUrl = `${normalizedBase}/models`;
|
||||
|
||||
const res = await fetchWithTimeout(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Authorization": `Bearer ${apiKey}` // Add Bearer token for hybrid proxies
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key or unauthorized" });
|
||||
if (res.ok) return NextResponse.json({ valid: true });
|
||||
|
||||
// Auth errors - no point trying chat fallback
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return NextResponse.json({ valid: false, error: "API key unauthorized" });
|
||||
}
|
||||
|
||||
// Fallback: try chat/completions if modelId provided
|
||||
if (modelId) {
|
||||
const chatRes = await fetchWithTimeout(`${normalizedBase}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1
|
||||
})
|
||||
});
|
||||
if (chatRes.ok) {
|
||||
return NextResponse.json({ valid: true, method: "chat" });
|
||||
}
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: getChatErrorMessage(chatRes.status),
|
||||
method: "chat"
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) });
|
||||
}
|
||||
|
||||
// OpenAI Compatible Validation (Default)
|
||||
@@ -76,7 +123,38 @@ export async function POST(request) {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||
});
|
||||
|
||||
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key or unauthorized" });
|
||||
if (res.ok) return NextResponse.json({ valid: true });
|
||||
|
||||
// Auth errors - no point trying chat fallback
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return NextResponse.json({ valid: false, error: "API key unauthorized" });
|
||||
}
|
||||
|
||||
// Fallback: try chat/completions if modelId provided
|
||||
if (modelId) {
|
||||
const chatRes = await fetchWithTimeout(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1
|
||||
})
|
||||
});
|
||||
if (chatRes.ok) {
|
||||
return NextResponse.json({ valid: true, method: "chat" });
|
||||
}
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: getChatErrorMessage(chatRes.status),
|
||||
method: "chat"
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) });
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
console.error("Error validating provider node:", {
|
||||
|
||||
Reference in New Issue
Block a user