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 Configuration
+
+
-
-
+
+
-
- For privacy reasons, your API key will be hidden after you reload the page.
+
+
+
+
+
+
+
+
+
+ For privacy reasons, your API key will be hidden after you reload the page.
+
+
+
+
+
+
+ Service Account Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paste the complete JSON content of your Google Cloud Service Account key file. The content will be stored securely.
+
+
+
+
+
+
+
+
+
+
+
+
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