@@ -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);
12621285route ( "POST" , "/billing/cancel" , handleCancelSubscription ) ;
12631286route ( "GET" , "/billing/transactions" , handleListTransactions ) ;
12641287
1265- // ── BYOK AI Diagnostics ──
1288+ // ── BYOK AI Diagnostics & Config ──
12661289route ( "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
40254100async 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