diff --git a/public/index.html b/public/index.html index c37e49d70..29a27cf95 100644 --- a/public/index.html +++ b/public/index.html @@ -2779,7 +2779,7 @@ - + @@ -3225,34 +3225,116 @@
-

- - 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. +
+ + +
+

+ + Service Account Configuration + + + + +

+ + +
+ + +
+ + +
+ + + +
+ For privacy reasons, your Service Account JSON content will be hidden after you reload the page. +
+
+ + +
+
+
+ +

Google Model

diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index b3e9e92e7..63b131753 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -402,7 +402,8 @@ function RA_autoconnect(PrevApi) { || (secret_state[SECRET_KEYS.OPENROUTER] && oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER) || (secret_state[SECRET_KEYS.AI21] && oai_settings.chat_completion_source == chat_completion_sources.AI21) || (secret_state[SECRET_KEYS.MAKERSUITE] && oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE) - || (secret_state[SECRET_KEYS.VERTEXAI] && oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) + || (secret_state[SECRET_KEYS.VERTEXAI] && oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI && oai_settings.vertexai_auth_mode === 'express') + || (secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT] && oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI && oai_settings.vertexai_auth_mode === 'full') || (secret_state[SECRET_KEYS.MISTRALAI] && oai_settings.chat_completion_source == chat_completion_sources.MISTRALAI) || (secret_state[SECRET_KEYS.COHERE] && oai_settings.chat_completion_source == chat_completion_sources.COHERE) || (secret_state[SECRET_KEYS.PERPLEXITY] && oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY) diff --git a/public/scripts/extensions/shared.js b/public/scripts/extensions/shared.js index 01a6e8219..c68d6c575 100644 --- a/public/scripts/extensions/shared.js +++ b/public/scripts/extensions/shared.js @@ -56,6 +56,12 @@ 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_region = oai_settings.vertexai_region; + } + if (isOllama) { if (extension_settings.caption.multimodal_model === 'ollama_current') { requestBody.model = textgenerationwebui_settings.ollama_model; @@ -164,8 +170,24 @@ 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 region 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_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 025afe46e..ec09a35b7 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -235,6 +235,7 @@ const sensitiveFields = [ 'custom_include_body', 'custom_exclude_body', 'custom_include_headers', + 'vertexai_region', ]; /** @@ -306,6 +307,8 @@ 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_region: ['#vertexai_region', 'vertexai_region', 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 +390,8 @@ const default_settings = { assistant_impersonation: '', claude_use_sysprompt: false, use_makersuite_sysprompt: true, + vertexai_auth_mode: 'express', + vertexai_region: 'us-central1', use_alt_scale: false, squash_system_messages: false, image_inlining: false, @@ -471,6 +476,8 @@ const oai_settings = { assistant_impersonation: '', claude_use_sysprompt: false, use_makersuite_sysprompt: true, + vertexai_auth_mode: 'express', + vertexai_region: 'us-central1', use_alt_scale: false, squash_system_messages: false, image_inlining: false, @@ -2188,6 +2195,10 @@ 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_region'] = oai_settings.vertexai_region; + } } if (isMistral) { @@ -3423,6 +3434,8 @@ 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_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); @@ -3478,6 +3491,11 @@ 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_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); @@ -3800,6 +3818,8 @@ 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_region: settings.vertexai_region, use_alt_scale: settings.use_alt_scale, squash_system_messages: settings.squash_system_messages, image_inlining: settings.image_inlining, @@ -4975,15 +4995,27 @@ 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 + // Project ID will be extracted from the Service Account JSON + + // 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; + } } } @@ -5166,6 +5198,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'); @@ -5476,6 +5510,136 @@ function runProxyCallback(_, value) { return foundName; } +/** + * Handle Vertex AI authentication mode change + */ +function onVertexAIAuthModeChange() { + const authMode = String($(this).val()); + oai_settings.vertexai_auth_mode = authMode; + + $('#vertexai_form [data-mode]').each(function () { + const mode = $(this).data('mode'); + $(this).toggle(mode === authMode); + $(this).find('option').toggle(mode === authMode); + }); + + saveSettingsDebounced(); +} + +/** + * Validate Vertex AI service account JSON + */ +async function onVertexAIValidateServiceAccount() { + const jsonContent = String($('#vertexai_service_account_json').val()).trim(); + + if (!jsonContent) { + toastr.error(t`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(t`Missing required fields: ${missingFields.join(', ')}`); + updateVertexAIServiceAccountStatus(false, t`Missing fields: ${missingFields.join(', ')}`); + return; + } + + if (serviceAccount.type !== 'service_account') { + toastr.error(t`Invalid service account type. Expected "service_account"`); + updateVertexAIServiceAccountStatus(false, t`Invalid service account type`); + return; + } + + // Save to backend secret storage + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent); + + // Show success status + updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`); + + toastr.success(t`Service Account JSON is valid and saved securely`); + saveSettingsDebounced(); + } catch (error) { + console.error('JSON validation error:', error); + toastr.error(t`Invalid JSON format`); + updateVertexAIServiceAccountStatus(false, t`Invalid JSON format`); + } +} + +/** + * Clear Vertex AI service account JSON + */ +async function onVertexAIClearServiceAccount() { + $('#vertexai_service_account_json').val(''); + + // Clear from backend secret storage + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, ''); + + updateVertexAIServiceAccountStatus(false); + toastr.info(t`Service Account JSON cleared`); + saveSettingsDebounced(); +} + +/** + * Handle Vertex AI service account JSON input change + */ +function onVertexAIServiceAccountJsonChange() { + const jsonContent = String($(this).val()).trim(); + + 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, t`JSON appears valid - click "Validate JSON" to save`); + } else { + updateVertexAIServiceAccountStatus(false, t`Incomplete or invalid JSON`); + } + } catch (error) { + updateVertexAIServiceAccountStatus(false, t`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 = t`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', @@ -5939,6 +6103,14 @@ 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_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..279a28351 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 = { @@ -80,8 +81,13 @@ const INPUT_MAP = { [SECRET_KEYS.GENERIC]: '#api_key_generic', [SECRET_KEYS.DEEPSEEK]: '#api_key_deepseek', [SECRET_KEYS.XAI]: '#api_key_xai', + [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json', }; +const STATIC_PLACEHOLDER_KEYS = [ + SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, +]; + async function clearSecret() { const key = $(this).data('key'); await writeSecret(key, ''); @@ -93,6 +99,9 @@ async function clearSecret() { export function updateSecretDisplay() { for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) { + if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) { + continue; + } const validSecret = !!secret_state[secret_key]; const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text'); diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 19a32b07b..c8a7c5f7e 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -43,6 +43,7 @@ import { webTokenizers, getWebTokenizer, } from '../tokenizers.js'; +import { getVertexAIAuth, getProjectIdFromServiceAccount } from '../google.js'; const API_OPENAI = 'https://api.openai.com/v1'; const API_CLAUDE = 'https://api.anthropic.com/v1'; @@ -348,13 +349,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 +372,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); @@ -391,6 +402,7 @@ async function sendMakerSuiteRequest(request, response) { const imageGenerationModels = [ 'gemini-2.0-flash-exp', 'gemini-2.0-flash-exp-image-generation', + 'gemini-2.0-flash-preview-image-generation', ]; // These models do not support setting the threshold to OFF at all. @@ -499,17 +511,53 @@ 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 + // Get project ID from Service Account JSON + const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); + if (!serviceAccountJson) { + console.warn('Vertex AI Service Account JSON is missing.'); + return response.status(400).send({ error: true }); + } + + let projectId; + try { + const serviceAccount = JSON.parse(serviceAccountJson); + projectId = getProjectIdFromServiceAccount(serviceAccount); + } catch (error) { + console.error('Failed to extract project ID from Service Account JSON:', error); + return response.status(400).send({ error: true }); + } + 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..b0c24557f 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,122 @@ 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 +export 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') { + // Get service account JSON from backend storage + const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); + + 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 + */ +export 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}`; +} + +export 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; +} + +/** + * Extracts the project ID from a Service Account JSON object. + * @param {object} serviceAccount Service account JSON object + * @returns {string} Project ID + * @throws {Error} If project ID is not found in the service account + */ +export function getProjectIdFromServiceAccount(serviceAccount) { + if (!serviceAccount || typeof serviceAccount !== 'object') { + throw new Error('Invalid service account object'); + } + + const projectId = serviceAccount.project_id; + if (!projectId || typeof projectId !== 'string') { + throw new Error('Project ID not found in service account JSON'); + } + + return projectId; +} + export const router = express.Router(); router.post('/caption-image', async (request, response) => { @@ -17,20 +134,57 @@ 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 + // Get project ID from Service Account JSON + const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); + if (!serviceAccountJson) { + console.warn('Vertex AI Service Account JSON is missing.'); + return response.status(400).send({ error: true }); + } + + let projectId; + try { + const serviceAccount = JSON.parse(serviceAccountJson); + projectId = getProjectIdFromServiceAccount(serviceAccount); + } catch (error) { + console.error('Failed to extract project ID from Service Account JSON:', error); + return response.status(400).send({ error: true }); + } + 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 +207,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