diff --git a/public/index.html b/public/index.html index a7c62b44f..0a4f008f0 100644 --- a/public/index.html +++ b/public/index.html @@ -2779,7 +2779,7 @@ - + @@ -3225,34 +3225,147 @@
-

- - Google Vertex AI API Key - - - (Express mode keys only) - -

+

Google Vertex AI Configuration

+ +
- - + +
-
- For privacy reasons, your API key will be hidden after you reload the page. + + +
+

+ + Google Vertex AI API Key + + + (Express mode) + +

+
+ + +
+
+ For privacy reasons, your API key will be hidden after you reload the page. +
+ + + + +

Google Model

diff --git a/public/scripts/extensions/shared.js b/public/scripts/extensions/shared.js index 01a6e8219..b8508ba05 100644 --- a/public/scripts/extensions/shared.js +++ b/public/scripts/extensions/shared.js @@ -56,6 +56,13 @@ export async function getMultimodalCaption(base64Img, prompt) { model: extension_settings.caption.multimodal_model || 'gpt-4-turbo', }; + // Add Vertex AI specific parameters if using Vertex AI + if (extension_settings.caption.multimodal_api === 'vertexai') { + requestBody.vertexai_auth_mode = oai_settings.vertexai_auth_mode; + requestBody.vertexai_project_id = oai_settings.vertexai_project_id; + requestBody.vertexai_region = oai_settings.vertexai_region; + } + if (isOllama) { if (extension_settings.caption.multimodal_model === 'ollama_current') { requestBody.model = textgenerationwebui_settings.ollama_model; @@ -164,8 +171,27 @@ function throwIfInvalidModel(useReverseProxy) { throw new Error('Google AI Studio API key is not set.'); } - if (multimodalApi === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !useReverseProxy) { - throw new Error('Google Vertex AI API key is not set.'); + if (multimodalApi === 'vertexai' && !useReverseProxy) { + // Check based on authentication mode + const authMode = oai_settings.vertexai_auth_mode || 'express'; + + if (authMode === 'express') { + // Express mode requires API key + if (!secret_state[SECRET_KEYS.VERTEXAI]) { + throw new Error('Google Vertex AI API key is not set for Express mode.'); + } + } else if (authMode === 'full') { + // Full mode requires Service Account JSON and project settings + if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { + throw new Error('Service Account JSON is required for Vertex AI Full mode. Please validate and save your Service Account JSON.'); + } + if (!oai_settings.vertexai_project_id) { + throw new Error('Project ID is required for Vertex AI Full mode.'); + } + if (!oai_settings.vertexai_region) { + throw new Error('Region is required for Vertex AI Full mode.'); + } + } } if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) { diff --git a/public/scripts/openai.js b/public/scripts/openai.js index ce05244a7..56ae2ad9b 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -306,6 +306,10 @@ export const settingsToUpdate = { assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false, false], claude_use_sysprompt: ['#claude_use_sysprompt', 'claude_use_sysprompt', true, false], use_makersuite_sysprompt: ['#use_makersuite_sysprompt', 'use_makersuite_sysprompt', true, false], + vertexai_auth_mode: ['#vertexai_auth_mode', 'vertexai_auth_mode', false, true], + vertexai_project_id: ['#vertexai_project_id', 'vertexai_project_id', false, true], + vertexai_region: ['#vertexai_region', 'vertexai_region', false, true], + vertexai_service_account_json: ['#vertexai_service_account_json', 'vertexai_service_account_json', false, true], use_alt_scale: ['#use_alt_scale', 'use_alt_scale', true, true], squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false], image_inlining: ['#openai_image_inlining', 'image_inlining', true, false], @@ -387,6 +391,10 @@ const default_settings = { assistant_impersonation: '', claude_use_sysprompt: false, use_makersuite_sysprompt: true, + vertexai_auth_mode: 'express', + vertexai_project_id: '', + vertexai_region: 'us-central1', + vertexai_service_account_json: '', use_alt_scale: false, squash_system_messages: false, image_inlining: false, @@ -471,6 +479,10 @@ const oai_settings = { assistant_impersonation: '', claude_use_sysprompt: false, use_makersuite_sysprompt: true, + vertexai_auth_mode: 'express', + vertexai_project_id: '', + vertexai_region: 'us-central1', + vertexai_service_account_json: '', use_alt_scale: false, squash_system_messages: false, image_inlining: false, @@ -2188,6 +2200,11 @@ async function sendOpenAIRequest(type, messages, signal) { generate_data['top_k'] = Number(oai_settings.top_k_openai); generate_data['stop'] = getCustomStoppingStrings(stopStringsLimit).slice(0, stopStringsLimit).filter(x => x.length >= 1 && x.length <= 16); generate_data['use_makersuite_sysprompt'] = oai_settings.use_makersuite_sysprompt; + if (isVertexAI) { + generate_data['vertexai_auth_mode'] = oai_settings.vertexai_auth_mode; + generate_data['vertexai_project_id'] = oai_settings.vertexai_project_id; + generate_data['vertexai_region'] = oai_settings.vertexai_region; + } } if (isMistral) { @@ -3427,6 +3444,9 @@ function loadOpenAISettings(data, settings) { if (settings.openai_model !== undefined) oai_settings.openai_model = settings.openai_model; if (settings.claude_use_sysprompt !== undefined) oai_settings.claude_use_sysprompt = !!settings.claude_use_sysprompt; if (settings.use_makersuite_sysprompt !== undefined) oai_settings.use_makersuite_sysprompt = !!settings.use_makersuite_sysprompt; + if (settings.vertexai_auth_mode !== undefined) oai_settings.vertexai_auth_mode = settings.vertexai_auth_mode; + if (settings.vertexai_project_id !== undefined) oai_settings.vertexai_project_id = settings.vertexai_project_id; + if (settings.vertexai_region !== undefined) oai_settings.vertexai_region = settings.vertexai_region; if (settings.use_alt_scale !== undefined) { oai_settings.use_alt_scale = !!settings.use_alt_scale; updateScaleForm(); } $('#stream_toggle').prop('checked', oai_settings.stream_openai); $('#api_url_scale').val(oai_settings.api_url_scale); @@ -3482,6 +3502,12 @@ function loadOpenAISettings(data, settings) { $('#openai_external_category').toggle(oai_settings.show_external_models); $('#claude_use_sysprompt').prop('checked', oai_settings.claude_use_sysprompt); $('#use_makersuite_sysprompt').prop('checked', oai_settings.use_makersuite_sysprompt); + $('#vertexai_auth_mode').val(oai_settings.vertexai_auth_mode); + $('#vertexai_project_id').val(oai_settings.vertexai_project_id); + $('#vertexai_region').val(oai_settings.vertexai_region); + // Don't display Service Account JSON in textarea - it's stored in backend secrets + $('#vertexai_service_account_json').val(''); + updateVertexAIServiceAccountStatus(); $('#scale-alt').prop('checked', oai_settings.use_alt_scale); $('#openrouter_use_fallback').prop('checked', oai_settings.openrouter_use_fallback); $('#openrouter_group_models').prop('checked', oai_settings.openrouter_group_models); @@ -3804,6 +3830,10 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { assistant_impersonation: settings.assistant_impersonation, claude_use_sysprompt: settings.claude_use_sysprompt, use_makersuite_sysprompt: settings.use_makersuite_sysprompt, + vertexai_auth_mode: settings.vertexai_auth_mode, + vertexai_project_id: settings.vertexai_project_id, + vertexai_region: settings.vertexai_region, + vertexai_service_account_json: settings.vertexai_service_account_json, use_alt_scale: settings.use_alt_scale, squash_system_messages: settings.squash_system_messages, image_inlining: settings.image_inlining, @@ -4979,15 +5009,30 @@ async function onConnectButtonClick(e) { } if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) { - const api_key_vertexai = String($('#api_key_vertexai').val()).trim(); + if (oai_settings.vertexai_auth_mode === 'express') { + // Express mode - use API key + const api_key_vertexai = String($('#api_key_vertexai').val()).trim(); - if (api_key_vertexai.length) { - await writeSecret(SECRET_KEYS.VERTEXAI, api_key_vertexai); - } + if (api_key_vertexai.length) { + await writeSecret(SECRET_KEYS.VERTEXAI, api_key_vertexai); + } - if (!secret_state[SECRET_KEYS.VERTEXAI] && !oai_settings.reverse_proxy) { - console.log('No secret key saved for Vertex AI'); - return; + if (!secret_state[SECRET_KEYS.VERTEXAI] && !oai_settings.reverse_proxy) { + console.log('No secret key saved for Vertex AI Express mode'); + return; + } + } else { + // Full version - use service account + if (!oai_settings.vertexai_project_id.trim()) { + toastr.error('Project ID is required for Vertex AI full version'); + return; + } + + // Check if service account JSON is saved in backend + if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { + toastr.error('Service Account JSON is required for Vertex AI full version. Please validate and save your Service Account JSON.'); + return; + } } } @@ -5170,6 +5215,8 @@ function toggleChatCompletionForms() { } else if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) { $('#model_vertexai_select').trigger('change'); + // Update UI based on authentication mode + onVertexAIAuthModeChange.call($('#vertexai_auth_mode')[0]); } else if (oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER) { $('#model_openrouter_select').trigger('change'); @@ -5480,6 +5527,160 @@ function runProxyCallback(_, value) { return foundName; } +/** + * Handle Vertex AI authentication mode change + */ +function onVertexAIAuthModeChange() { + const authMode = String($(this).val()); + oai_settings.vertexai_auth_mode = authMode; + + // Show/hide appropriate configuration sections + if (authMode === 'express') { + $('#vertexai_express_config').show(); + $('#vertexai_full_config').hide(); + $('#vertexai_express_models').show(); + // Hide all full version model groups + $('#vertexai_full_gemini_25').hide(); + $('#vertexai_full_gemini_20').hide(); + $('#vertexai_full_gemini_15').hide(); + $('#vertexai_full_gemma').hide(); + $('#vertexai_full_learnlm').hide(); + } else { + $('#vertexai_express_config').hide(); + $('#vertexai_full_config').show(); + $('#vertexai_express_models').hide(); + // Show all full version model groups + $('#vertexai_full_gemini_25').show(); + $('#vertexai_full_gemini_20').show(); + $('#vertexai_full_gemini_15').show(); + $('#vertexai_full_gemma').show(); + $('#vertexai_full_learnlm').show(); + } + + saveSettingsDebounced(); +} + +/** + * Validate Vertex AI service account JSON + */ +async function onVertexAIValidateServiceAccount() { + const jsonContent = String($('#vertexai_service_account_json').val()).trim(); + + if (!jsonContent) { + toastr.error('Please enter Service Account JSON content'); + return; + } + + try { + const serviceAccount = JSON.parse(jsonContent); + const requiredFields = ['type', 'project_id', 'private_key', 'client_email', 'client_id']; + const missingFields = requiredFields.filter(field => !serviceAccount[field]); + + if (missingFields.length > 0) { + toastr.error(`Missing required fields: ${missingFields.join(', ')}`); + updateVertexAIServiceAccountStatus(false, `Missing fields: ${missingFields.join(', ')}`); + return; + } + + if (serviceAccount.type !== 'service_account') { + toastr.error('Invalid service account type. Expected "service_account"'); + updateVertexAIServiceAccountStatus(false, 'Invalid service account type'); + return; + } + + // Save to backend secret storage + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent); + + // Clear the textarea and update settings + $('#vertexai_service_account_json').val(''); + oai_settings.vertexai_service_account_json = ''; + + // Show success status + updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`); + + toastr.success('Service Account JSON is valid and saved securely'); + saveSettingsDebounced(); + } catch (error) { + console.error('JSON validation error:', error); + toastr.error('Invalid JSON format'); + updateVertexAIServiceAccountStatus(false, 'Invalid JSON format'); + } +} + +/** + * Clear Vertex AI service account JSON + */ +async function onVertexAIClearServiceAccount() { + $('#vertexai_service_account_json').val(''); + oai_settings.vertexai_service_account_json = ''; + + // Clear from backend secret storage + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, ''); + + updateVertexAIServiceAccountStatus(false); + toastr.info('Service Account JSON cleared'); + saveSettingsDebounced(); +} + +/** + * Handle Vertex AI service account JSON input change + */ +function onVertexAIServiceAccountJsonChange() { + const jsonContent = String($(this).val()).trim(); + // Don't save to settings automatically - only save when validated + // oai_settings.vertexai_service_account_json = jsonContent; + + if (jsonContent) { + // Auto-validate when content is pasted + try { + const serviceAccount = JSON.parse(jsonContent); + const requiredFields = ['type', 'project_id', 'private_key', 'client_email']; + const hasAllFields = requiredFields.every(field => serviceAccount[field]); + + if (hasAllFields && serviceAccount.type === 'service_account') { + updateVertexAIServiceAccountStatus(false, 'JSON appears valid - click "Validate JSON" to save'); + } else { + updateVertexAIServiceAccountStatus(false, 'Incomplete or invalid JSON'); + } + } catch (error) { + updateVertexAIServiceAccountStatus(false, 'Invalid JSON format'); + } + } else { + updateVertexAIServiceAccountStatus(false); + } + + // Don't save settings automatically + // saveSettingsDebounced(); +} + +/** + * Update the Vertex AI service account status display + * @param {boolean} isValid - Whether the service account is valid + * @param {string} message - Status message to display + */ +function updateVertexAIServiceAccountStatus(isValid = false, message = '') { + const statusDiv = $('#vertexai_service_account_status'); + const infoSpan = $('#vertexai_service_account_info'); + + // If no explicit message provided, check if we have a saved service account + if (!message && secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { + isValid = true; + message = 'Service Account JSON is saved and ready to use'; + } + + if (isValid && message) { + infoSpan.html(` ${message}`); + statusDiv.show(); + } else if (!isValid && message) { + infoSpan.html(` ${message}`); + statusDiv.show(); + } else { + statusDiv.hide(); + } +} + + + export function initOpenAI() { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'proxy', @@ -5943,6 +6144,18 @@ export function initOpenAI() { $('#model_scale_select').on('change', onModelChange); $('#model_google_select').on('change', onModelChange); $('#model_vertexai_select').on('change', onModelChange); + $('#vertexai_auth_mode').on('change', onVertexAIAuthModeChange); + $('#vertexai_project_id').on('input', function () { + oai_settings.vertexai_project_id = String($(this).val()); + saveSettingsDebounced(); + }); + $('#vertexai_region').on('input', function () { + oai_settings.vertexai_region = String($(this).val()); + saveSettingsDebounced(); + }); + $('#vertexai_service_account_json').on('input', onVertexAIServiceAccountJsonChange); + $('#vertexai_validate_service_account').on('click', onVertexAIValidateServiceAccount); + $('#vertexai_clear_service_account').on('click', onVertexAIClearServiceAccount); $('#model_openrouter_select').on('change', onModelChange); $('#openrouter_group_models').on('change', onOpenrouterModelSortChange); $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange); diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index b0cc3d018..488acb22e 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -44,6 +44,7 @@ export const SECRET_KEYS = { SERPER: 'api_key_serper', FALAI: 'api_key_falai', XAI: 'api_key_xai', + VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json', }; const INPUT_MAP = { diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 5e0205771..38d420c6b 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -1,5 +1,6 @@ import process from 'node:process'; import util from 'node:util'; +import crypto from 'node:crypto'; import express from 'express'; import fetch from 'node-fetch'; @@ -60,6 +61,124 @@ const API_DEEPSEEK = 'https://api.deepseek.com/beta'; const API_XAI = 'https://api.x.ai/v1'; const API_POLLINATIONS = 'https://text.pollinations.ai/openai'; +/** + * Generates a JWT token for Google Cloud authentication using service account credentials. + * @param {object} serviceAccount Service account JSON object + * @returns {Promise} JWT token + */ +async function generateJWTToken(serviceAccount) { + const now = Math.floor(Date.now() / 1000); + const expiry = now + 3600; // 1 hour + + const header = { + alg: 'RS256', + typ: 'JWT' + }; + + const payload = { + iss: serviceAccount.client_email, + scope: 'https://www.googleapis.com/auth/cloud-platform', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: expiry + }; + + const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signatureInput = `${headerBase64}.${payloadBase64}`; + + // Create signature using private key + const sign = crypto.createSign('RSA-SHA256'); + sign.update(signatureInput); + const signature = sign.sign(serviceAccount.private_key, 'base64url'); + + return `${signatureInput}.${signature}`; +} + +/** + * Gets an access token from Google OAuth2 using JWT assertion. + * @param {string} jwtToken JWT token + * @returns {Promise} Access token + */ +async function getAccessToken(jwtToken) { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtToken + }) + }); + + if (!response.ok) { + throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.access_token; +} + +/** + * Gets authentication for Vertex AI - either API key or service account token. + * @param {object} request Express request + * @returns {Promise<{authHeader: string, authType: string}>} Authentication header and type + */ +async function getVertexAIAuth(request) { + // Get the authentication mode from frontend + const authMode = request.body.vertexai_auth_mode || 'express'; + + // Check if using reverse proxy + if (request.body.reverse_proxy) { + return { + authHeader: `Bearer ${request.body.proxy_password}`, + authType: 'proxy' + }; + } + + if (authMode === 'express') { + // Express mode: use API key + const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); + if (apiKey) { + return { + authHeader: `Bearer ${apiKey}`, + authType: 'express' + }; + } + throw new Error('API key is required for Vertex AI Express mode'); + } else if (authMode === 'full') { + // Full mode: use service account JSON + // First try to read from backend secret storage + let serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); + + // If not found in secrets, fall back to request body (for backward compatibility) + if (!serviceAccountJson) { + serviceAccountJson = request.body.vertexai_service_account_json; + } + + if (serviceAccountJson) { + try { + const serviceAccount = JSON.parse(serviceAccountJson); + + const jwtToken = await generateJWTToken(serviceAccount); + const accessToken = await getAccessToken(jwtToken); + + return { + authHeader: `Bearer ${accessToken}`, + authType: 'full' + }; + } catch (error) { + console.error('Failed to authenticate with service account:', error); + throw new Error(`Service account authentication failed: ${error.message}`); + } + } + throw new Error('Service Account JSON is required for Vertex AI Full mode'); + } + + throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`); +} + /** * Applies a post-processing step to the generated messages. * @param {object[]} messages Messages to post-process @@ -348,13 +467,20 @@ async function sendMakerSuiteRequest(request, response) { let apiUrl; let apiKey; + let authHeader; + let authType; + if (useVertexAi) { apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); - if (!request.body.reverse_proxy && !apiKey) { - console.warn(`${apiName} API key is missing.`); - return response.status(400).send({ error: true }); + try { + const auth = await getVertexAIAuth(request); + authHeader = auth.authHeader; + authType = auth.authType; + console.debug(`Using Vertex AI authentication type: ${authType}`); + } catch (error) { + console.warn(`${apiName} authentication failed: ${error.message}`); + return response.status(400).send({ error: true, message: error.message }); } } else { apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); @@ -364,6 +490,9 @@ async function sendMakerSuiteRequest(request, response) { console.warn(`${apiName} API key is missing.`); return response.status(400).send({ error: true }); } + + authHeader = `Bearer ${apiKey}`; + authType = 'api_key'; } const model = String(request.body.model); @@ -499,17 +628,39 @@ async function sendMakerSuiteRequest(request, response) { const responseType = (stream ? 'streamGenerateContent' : 'generateContent'); let url; + let headers = { + 'Content-Type': 'application/json', + }; + if (useVertexAi) { - url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`; + if (authType === 'express') { + // For Express mode (API key authentication), use the key parameter + const keyParam = authHeader.replace('Bearer ', ''); + url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${keyParam}${stream ? '&alt=sse' : ''}`; + } else if (authType === 'full') { + // For Full mode (service account authentication), use project-specific URL + const projectId = request.body.vertexai_project_id || 'your-project-id'; + const region = request.body.vertexai_region || 'us-central1'; + // Handle global region differently - no region prefix in hostname + if (region === 'global') { + url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; + } else { + url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; + } + headers['Authorization'] = authHeader; + } else { + // For proxy mode, use the original URL with Authorization header + url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; + headers['Authorization'] = authHeader; + } } else { url = `${apiUrl.toString().replace(/\/$/, '')}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`; } + const generateResponse = await fetch(url, { body: JSON.stringify(body), method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: headers, signal: controller.signal, }); diff --git a/src/endpoints/google.js b/src/endpoints/google.js index 435a3d388..950abed44 100644 --- a/src/endpoints/google.js +++ b/src/endpoints/google.js @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'; import fetch from 'node-fetch'; import express from 'express'; import { speak, languages } from 'google-translate-api-x'; +import crypto from 'node:crypto'; import { readSecret, SECRET_KEYS } from './secrets.js'; import { GEMINI_SAFETY } from '../constants.js'; @@ -9,6 +10,108 @@ import { GEMINI_SAFETY } from '../constants.js'; const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; +// Vertex AI authentication helper functions +async function getVertexAIAuth(request) { + const authMode = request.body.vertexai_auth_mode || 'express'; + + if (request.body.reverse_proxy) { + return { + authHeader: `Bearer ${request.body.proxy_password}`, + authType: 'proxy' + }; + } + + if (authMode === 'express') { + const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); + if (apiKey) { + return { + authHeader: `Bearer ${apiKey}`, + authType: 'express' + }; + } + throw new Error('API key is required for Vertex AI Express mode'); + } else if (authMode === 'full') { + // Try to get service account JSON from backend storage first + let serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); + + // If not found in backend storage, try from request body (for backward compatibility) + if (!serviceAccountJson) { + serviceAccountJson = request.body.vertexai_service_account_json; + } + + if (serviceAccountJson) { + try { + const serviceAccount = JSON.parse(serviceAccountJson); + const jwtToken = await generateJWTToken(serviceAccount); + const accessToken = await getAccessToken(jwtToken); + return { + authHeader: `Bearer ${accessToken}`, + authType: 'full' + }; + } catch (error) { + console.error('Failed to authenticate with service account:', error); + throw new Error(`Service account authentication failed: ${error.message}`); + } + } + throw new Error('Service Account JSON is required for Vertex AI Full mode'); + } + + throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`); +} + +/** + * Generates a JWT token for Google Cloud authentication using service account credentials. + * @param {object} serviceAccount Service account JSON object + * @returns {Promise} JWT token + */ +async function generateJWTToken(serviceAccount) { + const now = Math.floor(Date.now() / 1000); + const expiry = now + 3600; // 1 hour + + const header = { + alg: 'RS256', + typ: 'JWT' + }; + + const payload = { + iss: serviceAccount.client_email, + scope: 'https://www.googleapis.com/auth/cloud-platform', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: expiry + }; + + const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signatureInput = `${headerBase64}.${payloadBase64}`; + + // Create signature using private key + const sign = crypto.createSign('RSA-SHA256'); + sign.update(signatureInput); + const signature = sign.sign(serviceAccount.private_key, 'base64url'); + + return `${signatureInput}.${signature}`; +} + +async function getAccessToken(jwtToken) { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtToken + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get access token: ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + export const router = express.Router(); router.post('/caption-image', async (request, response) => { @@ -17,20 +120,43 @@ router.post('/caption-image', async (request, response) => { const base64Data = request.body.image.split(',')[1]; const useVertexAi = request.body.api === 'vertexai'; const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; - let apiKey; - let apiUrl; - if (useVertexAi) { - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); - apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); - } else { - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); - apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); - } const model = request.body.model || 'gemini-2.0-flash'; + let url; + let headers = { + 'Content-Type': 'application/json', + }; + if (useVertexAi) { - url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${apiKey}`; + // Get authentication for Vertex AI + const { authHeader, authType } = await getVertexAIAuth(request); + + if (authType === 'express') { + // Express mode: use API key parameter + const keyParam = authHeader.replace('Bearer ', ''); + const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); + url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}`; + } else if (authType === 'full') { + // Full mode: use project-specific URL with Authorization header + const projectId = request.body.vertexai_project_id || 'your-project-id'; + const region = request.body.vertexai_region || 'us-central1'; + // Handle global region differently - no region prefix in hostname + if (region === 'global') { + url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; + } else { + url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; + } + headers['Authorization'] = authHeader; + } else { + // Proxy mode: use Authorization header + const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); + url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent`; + headers['Authorization'] = authHeader; + } } else { + // Google AI Studio + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); + const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`; } const body = { @@ -53,9 +179,7 @@ router.post('/caption-image', async (request, response) => { const result = await fetch(url, { body: JSON.stringify(body), method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: headers, }); if (!result.ok) { diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js index 0eed2eb7f..d1f015b42 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -54,6 +54,7 @@ export const SECRET_KEYS = { DEEPSEEK: 'api_key_deepseek', SERPER: 'api_key_serper', XAI: 'api_key_xai', + VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json', }; // These are the keys that are safe to expose, even if allowKeysExposure is false