Skip to content

Commit b0b4e2b

Browse files
committed
fix(byok): backend BYOK config save/get endpoints, Gemini auth, enhanced logging
1 parent 41f8826 commit b0b4e2b

1 file changed

Lines changed: 205 additions & 15 deletions

File tree

backend/simpatico-ats.js

Lines changed: 205 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,8 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
628628
);
629629
}
630630

631+
console.log(`[BYOK-LLM] Calling provider=${cfg.provider}, model=${model}, url=${baseUrl}, keyPrefix=${cfg.apiKey?.substring(0, 8)}...`);
632+
631633
const isAnthropic = cfg.provider === "anthropic" && baseUrl.includes("anthropic.com");
632634

633635
if (isAnthropic) {
@@ -643,7 +645,10 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
643645
...(stream ? { stream: true } : {}),
644646
};
645647

646-
const res = await fetch(`${baseUrl}/messages`, {
648+
const anthropicUrl = `${baseUrl}/messages`;
649+
console.log(`[BYOK-LLM] Anthropic request: ${anthropicUrl}`);
650+
651+
const res = await fetch(anthropicUrl, {
647652
method: "POST",
648653
headers: {
649654
"Content-Type": "application/json",
@@ -655,6 +660,7 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
655660

656661
if (!res.ok) {
657662
const errText = await res.text().catch(() => "Unknown error");
663+
console.error(`[BYOK-LLM] Anthropic FAILED: status=${res.status}, body=${errText.substring(0, 300)}`);
658664
throw new AppError(
659665
`Anthropic API error (${res.status}): ${errText.substring(0, 200)}`,
660666
HTTP.UNAVAILABLE,
@@ -665,13 +671,16 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
665671
if (stream) return res.body;
666672

667673
const data = await res.json();
674+
console.log(`[BYOK-LLM] Anthropic SUCCESS: response_length=${data.content?.[0]?.text?.length || 0}`);
668675
return {
669676
response: data.content?.[0]?.text || "",
670677
usage: data.usage || null,
678+
_provider: "anthropic",
679+
_model: model,
671680
};
672681
}
673682

674-
// OpenAI-compatible endpoint (OpenAI, vLLM, custom, other)
683+
// OpenAI-compatible endpoint (OpenAI, Gemini, DeepSeek, Kimi, vLLM, custom, other)
675684
const chatUrl = baseUrl.replace(/\/$/, "") + "/chat/completions";
676685
const body = {
677686
model,
@@ -680,17 +689,27 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
680689
...(stream ? { stream: true } : {}),
681690
};
682691

692+
// Build auth headers — Gemini uses x-goog-api-key, others use Bearer
693+
const headers = { "Content-Type": "application/json" };
694+
const isGemini = cfg.provider === "gemini" || baseUrl.includes("googleapis.com");
695+
if (isGemini) {
696+
// Gemini OpenAI-compatible endpoint accepts both, but x-goog-api-key is more reliable
697+
headers["x-goog-api-key"] = cfg.apiKey;
698+
} else {
699+
headers["Authorization"] = `Bearer ${cfg.apiKey}`;
700+
}
701+
702+
console.log(`[BYOK-LLM] Request: POST ${chatUrl}, model=${model}, isGemini=${isGemini}`);
703+
683704
const res = await fetch(chatUrl, {
684705
method: "POST",
685-
headers: {
686-
"Content-Type": "application/json",
687-
Authorization: `Bearer ${cfg.apiKey}`,
688-
},
706+
headers,
689707
body: JSON.stringify(body),
690708
});
691709

692710
if (!res.ok) {
693711
const errText = await res.text().catch(() => "Unknown error");
712+
console.error(`[BYOK-LLM] FAILED: status=${res.status}, url=${chatUrl}, body=${errText.substring(0, 300)}`);
694713
throw new AppError(
695714
`Custom AI API error (${res.status}): ${errText.substring(0, 200)}`,
696715
HTTP.UNAVAILABLE,
@@ -701,9 +720,13 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
701720
if (stream) return res.body;
702721

703722
const data = await res.json();
723+
const responseText = data.choices?.[0]?.message?.content || "";
724+
console.log(`[BYOK-LLM] SUCCESS: provider=${cfg.provider}, model=${model}, response_length=${responseText.length}`);
704725
return {
705-
response: data.choices?.[0]?.message?.content || "",
726+
response: responseText,
706727
usage: data.usage || null,
728+
_provider: cfg.provider,
729+
_model: model,
707730
};
708731
}
709732

@@ -1262,8 +1285,11 @@ route("GET", "/billing/subscription", handleGetSubscription);
12621285
route("POST", "/billing/cancel", handleCancelSubscription);
12631286
route("GET", "/billing/transactions", handleListTransactions);
12641287

1265-
// ── BYOK AI Diagnostics ──
1288+
// ── BYOK AI Diagnostics & Config ──
12661289
route("GET", "/ai/byok-test", handleBYOKTest);
1290+
route("POST", "/ai/byok-cache-clear", handleBYOKCacheClear);
1291+
route("POST", "/ai/byok-config", handleBYOKConfigSave);
1292+
route("GET", "/ai/byok-config", handleBYOKConfigGet);
12671293

12681294
// ===============================================================
12691295
// § 13. MAIN ENTRY POINT
@@ -4000,26 +4026,75 @@ async function handleInterviewQuestion(request, env, ctx) {
40004026
let tenantId = ctx.tenantId;
40014027
if ((!tenantId || tenantId === "default") && token && env.SUPABASE_URL) {
40024028
try {
4003-
const ivRes = await sbFetch(env, "GET",
4004-
`/rest/v1/interviews?token=eq.${encodeURIComponent(token)}&select=company_id&limit=1`,
4005-
null, false, "default");
4029+
// Direct Supabase REST call (bypasses tenant-scoped sbFetch) to resolve company_id from token
4030+
const url = `${env.SUPABASE_URL}/rest/v1/interviews?token=eq.${encodeURIComponent(token)}&select=company_id&limit=1`;
4031+
const ivRes = await fetch(url, {
4032+
headers: {
4033+
apikey: env.SUPABASE_SERVICE_KEY,
4034+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
4035+
},
4036+
});
40064037
if (ivRes.ok) {
40074038
const rows = await ivRes.json();
4008-
if (rows?.[0]?.company_id) tenantId = rows[0].company_id;
4039+
if (rows?.[0]?.company_id) {
4040+
tenantId = rows[0].company_id;
4041+
console.log(`[interview] Resolved tenant from token: ${tenantId}`);
4042+
}
40094043
}
40104044
} catch (e) { console.warn("[interview] tenant lookup failed:", e.message); }
40114045
}
40124046

4047+
console.log(`[interview] Generating question: tenantId=${tenantId}, token_hint=${token?.slice(0, 8) || 'none'}`);
4048+
40134049
// Use tenant's custom AI if configured (BYOK), otherwise use platform default (70B)
40144050
// This enables enterprise customers to use GPT-4o, Claude, Gemini etc. for interviews
40154051
const maxTok = Math.min(Math.max(parseInt(max_tokens) || 600, 100), 2048);
4016-
const result = await runLLM(env, tenantId, messages, maxTok);
4052+
4053+
let result;
4054+
let modelUsed = "platform-default";
4055+
let fallbackUsed = false;
4056+
4057+
try {
4058+
result = await runLLM(env, tenantId, messages, maxTok);
4059+
modelUsed = result?._model || result?._provider || "byok";
4060+
} catch (aiErr) {
4061+
console.error(`[interview] LLM call FAILED (tenant=${tenantId}): ${aiErr.message}`);
4062+
// If BYOK fails, attempt fallback to platform default so interview isn't broken
4063+
if (tenantId && tenantId !== "default") {
4064+
console.warn(`[interview] BYOK failed (${aiErr.message}), falling back to platform default model`);
4065+
fallbackUsed = true;
4066+
try {
4067+
result = await runLLM(env, "default", messages, maxTok);
4068+
modelUsed = "platform-fallback";
4069+
} catch (fallbackErr) {
4070+
console.error("[interview] Fallback LLM also failed:", fallbackErr.message);
4071+
throw new AppError(
4072+
`AI interview engine temporarily unavailable: ${aiErr.message}`,
4073+
HTTP.UNAVAILABLE,
4074+
"AI_UNAVAILABLE",
4075+
);
4076+
}
4077+
} else {
4078+
throw new AppError(
4079+
`AI interview engine temporarily unavailable: ${aiErr.message}`,
4080+
HTTP.UNAVAILABLE,
4081+
"AI_UNAVAILABLE",
4082+
);
4083+
}
4084+
}
4085+
4086+
console.log(`[interview] Question generated: model=${modelUsed}, fallback=${fallbackUsed}, tenant=${tenantId}`);
40174087

40184088
await audit(env, ctx, "interview.ai_question", "interviews", null, {
40194089
token_hint: token?.slice(0, 8),
40204090
tenant_resolved: tenantId,
4091+
model_used: modelUsed,
4092+
fallback_used: fallbackUsed,
4093+
});
4094+
return apiResponse({
4095+
response: result.response,
4096+
_debug: { model: modelUsed, tenant: tenantId, fallback: fallbackUsed },
40214097
});
4022-
return apiResponse({ response: result.response });
40234098
}
40244099

40254100
async function handleExpenseOCR(request, env, ctx) {
@@ -6961,7 +7036,8 @@ async function handleBYOKTest(request, env, ctx) {
69617036
requireAuth(ctx);
69627037
const tenantId = ctx.tenantId;
69637038

6964-
// 1. Fetch AI config
7039+
// 1. Clear cache and re-fetch to ensure we test the latest saved config
7040+
delete _aiConfigCache[tenantId];
69657041
const aiConfig = await getCompanyAIConfig(env, tenantId);
69667042

69677043
if (!aiConfig) {
@@ -7020,3 +7096,117 @@ async function handleBYOKTest(request, env, ctx) {
70207096
}, HTTP.OK); // Return 200 with error details for diagnostic visibility
70217097
}
70227098
}
7099+
7100+
/**
7101+
* POST /ai/byok-cache-clear — Invalidate cached BYOK config for the tenant.
7102+
* Call this after saving AI settings so the next AI request uses fresh config.
7103+
*/
7104+
async function handleBYOKCacheClear(request, env, ctx) {
7105+
requireAuth(ctx);
7106+
const tenantId = ctx.tenantId;
7107+
if (tenantId && _aiConfigCache[tenantId]) {
7108+
delete _aiConfigCache[tenantId];
7109+
console.log(`[BYOK] Cache cleared for tenant=${tenantId}`);
7110+
}
7111+
return apiResponse({ cleared: true, tenant: tenantId });
7112+
}
7113+
7114+
/**
7115+
* POST /ai/byok-config — Save BYOK AI configuration.
7116+
* Uses service key to bypass RLS (dashboard direct save may be blocked by RLS).
7117+
* Body: { ai_provider, ai_api_key, ai_base_url, ai_model }
7118+
*/
7119+
async function handleBYOKConfigSave(request, env, ctx) {
7120+
requireAuth(ctx);
7121+
const tenantId = ctx.tenantId;
7122+
if (!tenantId || tenantId === "default") {
7123+
throw new ValidationError("Tenant ID required to save AI config");
7124+
}
7125+
7126+
const body = await safeJson(request);
7127+
const updates = {
7128+
ai_provider: body.ai_provider || null,
7129+
ai_api_key: body.ai_api_key || null,
7130+
ai_base_url: body.ai_base_url || null,
7131+
ai_model: body.ai_model || null,
7132+
};
7133+
7134+
console.log(`[BYOK-Save] Saving config for tenant=${tenantId}: provider=${updates.ai_provider}, model=${updates.ai_model}`);
7135+
7136+
// Use service key to bypass RLS
7137+
const url = `${env.SUPABASE_URL}/rest/v1/companies?id=eq.${tenantId}`;
7138+
const res = await fetch(url, {
7139+
method: "PATCH",
7140+
headers: {
7141+
"Content-Type": "application/json",
7142+
apikey: env.SUPABASE_SERVICE_KEY,
7143+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
7144+
Prefer: "return=representation",
7145+
},
7146+
body: JSON.stringify(updates),
7147+
});
7148+
7149+
if (!res.ok) {
7150+
const errText = await res.text().catch(() => "Unknown error");
7151+
console.error(`[BYOK-Save] FAILED: status=${res.status}, body=${errText}`);
7152+
throw new AppError(`Failed to save AI config: ${errText.substring(0, 200)}`, res.status, "DB_ERROR");
7153+
}
7154+
7155+
const rows = await res.json();
7156+
console.log(`[BYOK-Save] SUCCESS: updated ${rows?.length || 0} row(s) for tenant=${tenantId}`);
7157+
7158+
// Clear the in-memory cache so next request uses fresh config
7159+
delete _aiConfigCache[tenantId];
7160+
7161+
await audit(env, ctx, "byok.config_saved", "companies", tenantId, {
7162+
provider: updates.ai_provider,
7163+
model: updates.ai_model,
7164+
});
7165+
7166+
return apiResponse({
7167+
saved: true,
7168+
tenant: tenantId,
7169+
provider: updates.ai_provider,
7170+
model: updates.ai_model,
7171+
rows_updated: rows?.length || 0,
7172+
});
7173+
}
7174+
7175+
/**
7176+
* GET /ai/byok-config — Read current BYOK AI configuration for the tenant.
7177+
* Uses service key to bypass RLS.
7178+
*/
7179+
async function handleBYOKConfigGet(request, env, ctx) {
7180+
requireAuth(ctx);
7181+
const tenantId = ctx.tenantId;
7182+
if (!tenantId || tenantId === "default") {
7183+
return apiResponse({ configured: false, message: "No tenant ID" });
7184+
}
7185+
7186+
const url = `${env.SUPABASE_URL}/rest/v1/companies?id=eq.${tenantId}&select=ai_provider,ai_base_url,ai_model&limit=1`;
7187+
const res = await fetch(url, {
7188+
headers: {
7189+
apikey: env.SUPABASE_SERVICE_KEY,
7190+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
7191+
},
7192+
});
7193+
7194+
if (!res.ok) {
7195+
console.error(`[BYOK-Get] Failed to read config: ${res.status}`);
7196+
return apiResponse({ configured: false, error: "Failed to read config" });
7197+
}
7198+
7199+
const rows = await res.json();
7200+
const row = rows?.[0];
7201+
if (!row || !row.ai_provider) {
7202+
return apiResponse({ configured: false, provider: "cloudflare", model: "platform-default" });
7203+
}
7204+
7205+
return apiResponse({
7206+
configured: true,
7207+
provider: row.ai_provider,
7208+
model: row.ai_model || "(default)",
7209+
base_url: row.ai_base_url || "(provider default)",
7210+
has_api_key: true, // Don't expose the key itself
7211+
});
7212+
}

0 commit comments

Comments
 (0)