Skip to content

Commit 6176cd7

Browse files
committed
feat(byok): API key validation, model discovery, Test Connection button with dynamic model dropdown
1 parent b0b4e2b commit 6176cd7

2 files changed

Lines changed: 306 additions & 8 deletions

File tree

backend/simpatico-ats.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,7 @@ route("GET", "/ai/byok-test", handleBYOKTest);
12901290
route("POST", "/ai/byok-cache-clear", handleBYOKCacheClear);
12911291
route("POST", "/ai/byok-config", handleBYOKConfigSave);
12921292
route("GET", "/ai/byok-config", handleBYOKConfigGet);
1293+
route("POST", "/ai/byok-validate", handleBYOKValidate);
12931294

12941295
// ===============================================================
12951296
// § 13. MAIN ENTRY POINT
@@ -7210,3 +7211,147 @@ async function handleBYOKConfigGet(request, env, ctx) {
72107211
has_api_key: true, // Don't expose the key itself
72117212
});
72127213
}
7214+
7215+
/**
7216+
* POST /ai/byok-validate — Validate an API key and return available models.
7217+
* Body: { provider, api_key, base_url? }
7218+
* Returns: { connected, provider, models: [{ id, name, context_window?, recommended? }], error? }
7219+
*/
7220+
async function handleBYOKValidate(request, env, ctx) {
7221+
requireAuth(ctx);
7222+
const body = await safeJson(request);
7223+
const { provider, api_key, base_url } = body;
7224+
7225+
if (!provider || !api_key) {
7226+
throw new ValidationError("provider and api_key are required");
7227+
}
7228+
7229+
console.log(`[BYOK-Validate] Testing connection: provider=${provider}, keyPrefix=${api_key.substring(0, 8)}...`);
7230+
7231+
const RECOMMENDED = {
7232+
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "o4-mini"],
7233+
gemini: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
7234+
anthropic: ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"],
7235+
deepseek: ["deepseek-chat", "deepseek-reasoner"],
7236+
kimi: ["kimi-k2-0520", "moonshot-v1-128k", "moonshot-v1-32k"],
7237+
};
7238+
7239+
try {
7240+
let models = [];
7241+
7242+
if (provider === "gemini" || provider === "google") {
7243+
const geminiUrl = (base_url || "https://generativelanguage.googleapis.com") + "/v1beta/models?key=" + api_key;
7244+
const res = await fetch(geminiUrl);
7245+
if (!res.ok) {
7246+
const errText = await res.text().catch(() => "");
7247+
console.error(`[BYOK-Validate] Gemini FAILED: ${res.status} ${errText.substring(0, 200)}`);
7248+
return apiResponse({
7249+
connected: false, provider: "gemini",
7250+
error: res.status === 400 || res.status === 403 ? "Invalid API key" : `API error ${res.status}: ${errText.substring(0, 100)}`,
7251+
});
7252+
}
7253+
const data = await res.json();
7254+
models = (data.models || [])
7255+
.filter(m => m.supportedGenerationMethods && m.supportedGenerationMethods.includes("generateContent"))
7256+
.map(m => {
7257+
const mid = m.name ? m.name.replace("models/", "") : m.name;
7258+
return {
7259+
id: mid, name: m.displayName || mid,
7260+
context_window: m.inputTokenLimit || null,
7261+
output_limit: m.outputTokenLimit || null,
7262+
recommended: (RECOMMENDED.gemini || []).includes(mid),
7263+
};
7264+
})
7265+
.sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0));
7266+
7267+
} else if (provider === "openai") {
7268+
const oaiUrl = (base_url || "https://api.openai.com/v1") + "/models";
7269+
const res = await fetch(oaiUrl, { headers: { Authorization: `Bearer ${api_key}` } });
7270+
if (!res.ok) {
7271+
const errText = await res.text().catch(() => "");
7272+
return apiResponse({
7273+
connected: false, provider: "openai",
7274+
error: res.status === 401 ? "Invalid API key" : `API error ${res.status}: ${errText.substring(0, 100)}`,
7275+
});
7276+
}
7277+
const data = await res.json();
7278+
models = (data.data || [])
7279+
.filter(m => m.id && (m.id.includes("gpt") || m.id.includes("o1") || m.id.includes("o3") || m.id.includes("o4") || m.id.includes("chatgpt")))
7280+
.map(m => ({ id: m.id, name: m.id, recommended: (RECOMMENDED.openai || []).includes(m.id) }))
7281+
.sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0));
7282+
7283+
} else if (provider === "anthropic") {
7284+
const antUrl = (base_url || "https://api.anthropic.com/v1") + "/messages";
7285+
const res = await fetch(antUrl, {
7286+
method: "POST",
7287+
headers: { "Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01" },
7288+
body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
7289+
});
7290+
if (!res.ok) {
7291+
const errText = await res.text().catch(() => "");
7292+
if (res.status === 401 || errText.includes("invalid x-api-key")) {
7293+
return apiResponse({ connected: false, provider: "anthropic", error: "Invalid API key" });
7294+
}
7295+
}
7296+
models = [
7297+
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4 (Latest)", recommended: true },
7298+
{ id: "claude-3-7-sonnet-20250219", name: "Claude 3.7 Sonnet", recommended: true },
7299+
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet", recommended: true },
7300+
{ id: "claude-3-haiku-20240307", name: "Claude 3 Haiku (Fast)", recommended: true },
7301+
{ id: "claude-3-opus-20240229", name: "Claude 3 Opus (Powerful)", recommended: false },
7302+
];
7303+
7304+
} else if (provider === "deepseek") {
7305+
const dsUrl = (base_url || "https://api.deepseek.com/v1") + "/models";
7306+
const res = await fetch(dsUrl, { headers: { Authorization: `Bearer ${api_key}` } });
7307+
if (!res.ok) {
7308+
const errText = await res.text().catch(() => "");
7309+
return apiResponse({
7310+
connected: false, provider: "deepseek",
7311+
error: res.status === 401 ? "Invalid API key" : `API error ${res.status}: ${errText.substring(0, 100)}`,
7312+
});
7313+
}
7314+
const data = await res.json();
7315+
models = (data.data || []).map(m => ({
7316+
id: m.id, name: m.id, recommended: (RECOMMENDED.deepseek || []).includes(m.id),
7317+
}));
7318+
7319+
} else if (provider === "kimi") {
7320+
const kimiUrl = (base_url || "https://api.moonshot.cn/v1") + "/models";
7321+
const res = await fetch(kimiUrl, { headers: { Authorization: `Bearer ${api_key}` } });
7322+
if (!res.ok) {
7323+
const errText = await res.text().catch(() => "");
7324+
return apiResponse({
7325+
connected: false, provider: "kimi",
7326+
error: res.status === 401 ? "Invalid API key" : `API error ${res.status}: ${errText.substring(0, 100)}`,
7327+
});
7328+
}
7329+
const data = await res.json();
7330+
models = (data.data || []).map(m => ({
7331+
id: m.id, name: m.id, recommended: (RECOMMENDED.kimi || []).includes(m.id),
7332+
}));
7333+
7334+
} else {
7335+
const customUrl = (base_url || "").replace(/\/$/, "") + "/models";
7336+
try {
7337+
const res = await fetch(customUrl, { headers: { Authorization: `Bearer ${api_key}` } });
7338+
if (res.ok) {
7339+
const data = await res.json();
7340+
models = (data.data || []).map(m => ({ id: m.id || m.name, name: m.id || m.name, recommended: false }));
7341+
}
7342+
} catch (e) {
7343+
console.warn(`[BYOK-Validate] Custom model listing failed: ${e.message}`);
7344+
}
7345+
if (models.length === 0) {
7346+
return apiResponse({ connected: true, provider, models: [], message: "Connected but model listing not supported. Enter model name manually." });
7347+
}
7348+
}
7349+
7350+
console.log(`[BYOK-Validate] SUCCESS: provider=${provider}, models_found=${models.length}`);
7351+
return apiResponse({ connected: true, provider, models, total: models.length });
7352+
7353+
} catch (err) {
7354+
console.error(`[BYOK-Validate] Error: ${err.message}`);
7355+
return apiResponse({ connected: false, provider, error: `Connection failed: ${err.message}` });
7356+
}
7357+
}

0 commit comments

Comments
 (0)