Merge pull request #4046 from InterestingDarknessII/vertexfull Add Vertex AI Full Version support

b61269445d730662e9af109e12f6655b9294a16b

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
8 files changed, +517 -30Showing whitespace changes
public/index.html+87 -5
@@ -2779,7 +2779,7 @@
27792779 <option value="deepseek">DeepSeek</option>
27802780 <option value="groq">Groq</option>
27812781 <option value="makersuite">Google AI Studio</option>
27822782 <option value="vertexai">Google Vertex AI (Express mode)</option>
27832783 <option value="mistralai">MistralAI</option>
27842784 <option value="nanogpt">NanoGPT</option>
27852785 <option value="openrouter">OpenRouter</option>
@@ -3225,12 +3225,25 @@
32253225 </div>
32263226 </form>
32273227 <div id="vertexai_form" data-source="vertexai">
3228+ <h4 data-i18n="Google Vertex AI Configuration">Google Vertex AI Configuration</h4>
3229+
3230+ <!-- Authentication Mode Selection -->
3231+ <div class="flex-container">
3232+ <label for="vertexai_auth_mode" data-i18n="Authentication Mode">Authentication Mode:</label>
3233+ <select id="vertexai_auth_mode" class="text_pole">
3234+ <option value="express" data-i18n="Express Mode (API Key)">Express Mode (API Key)</option>
3235+ <option value="full" data-i18n="Full Version (Service Account)">Full Version (Service Account)</option>
3236+ </select>
3237+ </div>
3238+
3239+ <!-- Express Mode Configuration -->
3240+ <div id="vertexai_express_config" class="vertexai-auth-section" data-mode="express">
32283241 <h4>
32293242 <span data-i18n="Google Vertex AI API Key">
32303243 Google Vertex AI API Key
32313244 </span>
32323245 <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview" data-i18n="(Express mode keys only)" target="_blank" rel="noopener noreferrer">
32333246 (Express mode keys only)
32343247 </a>
32353248 </h4>
32363249 <div class="flex-container">
@@ -3240,17 +3253,86 @@
32403253 <div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
32413254 For privacy reasons, your API key will be hidden after you reload the page.
32423255 </div>
3256+ </div>
3257+
3258+ <!-- Full Version Configuration -->
3259+ <div id="vertexai_full_config" class="vertexai-auth-section" data-mode="full">
3260+ <h4>
3261+ <span data-i18n="Service Account Configuration">
3262+ Service Account Configuration
3263+ </span>
3264+ <a href="https://cloud.google.com/vertex-ai/docs/authentication" target="_blank" rel="noopener noreferrer">
3265+ <i class="fa-solid fa-circle-question"></i>
3266+ </a>
3267+ </h4>
3268+
3269+ <!-- Region -->
3270+ <div class="flex-container flexFlowColumn">
3271+ <label for="vertexai_region">
3272+ <span data-i18n="Region">
3273+ Region:
3274+ </span>
3275+ <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations" target="_blank" rel="noopener noreferrer" title="View available regions and models" data-i18n="[title]View available regions and models" class="notes-link">
3276+ <span class="fa-solid fa-circle-question note-link-span"></span>
3277+ </a>
3278+ </label>
3279+ <input id="vertexai_region" name="vertexai_region" class="text_pole flex1" value="us-central1" type="text" autocomplete="off" placeholder="e.g., global, us-central1, europe-west1, asia-northeast1">
3280+ </div>
3281+
3282+ <!-- Service Account JSON Content -->
3283+ <div class="flex-container flexFlowColumn">
3284+ <label for="vertexai_service_account_json" data-i18n="Service Account JSON Content">Service Account JSON Content:</label>
3285+ <div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">
3286+ <span id="vertexai_service_account_info"></span>
3287+ </div>
3288+ <textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="4" placeholder='Paste your Service Account JSON content here, e.g.:
3289+{
3290+ "type": "service_account",
3291+ "project_id": "your-project-id",
3292+ "private_key_id": "...",
3293+ "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
3294+ "client_email": "...",
3295+ "client_id": "...",
3296+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
3297+ "token_uri": "https://oauth2.googleapis.com/token"
3298+}'></textarea>
3299+ <div data-for="vertexai_service_account_json" class="neutral_warning" data-i18n="For privacy reasons, your Service Account JSON content will be hidden after you reload the page.">
3300+ For privacy reasons, your Service Account JSON content will be hidden after you reload the page.
3301+ </div>
3302+ <div class="flex-container">
3303+ <button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>
3304+ <button type="button" id="vertexai_clear_service_account" class="menu_button menu_button_icon" data-i18n="Clear">Clear</button>
3305+ </div>
3306+ </div>
3307+ </div>
3308+
3309+ <!-- Model Selection -->
32433310 <div>
32443311 <h4 data-i18n="Google Model">Google Model</h4>
32453312 <select id="model_vertexai_select">
3246- <optgroup label="Gemini 2.5">
3313+ <!-- Express Mode Models -->
3314+ <optgroup id="vertexai_express_models" label="Express Mode Models" data-mode="express">
32473315 <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
32483316 <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
32493317 <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
32503318 <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
3319+ <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
3320+ <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
32513321 </optgroup>
3252- <optgroup label="Gemini 2.0">
3322+
3323+ <!-- Full Version Models -->
3324+ <optgroup id="vertexai_full_gemini_25" label="Gemini 2.5" data-mode="full">
3325+ <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
3326+ <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
3327+ <option value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option>
3328+ <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
3329+ <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
3330+ </optgroup>
3331+ <optgroup id="vertexai_full_gemini_20" label="Gemini 2.0" data-mode="full">
32533332 <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
3333+ <option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
3334+ <option value="gemini-2.0-flash-preview-image-generation">gemini-2.0-flash-preview-image-generation</option>
3335+ <option value="gemini-2.0-flash">gemini-2.0-flash</option>
32543336 <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
32553337 </optgroup>
32563338 </select>
public/scripts/RossAscends-mods.js+2 -1
@@ -402,7 +402,8 @@ function RA_autoconnect(PrevApi) {
402402 || (secret_state[SECRET_KEYS.OPENROUTER] && oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER)
403403 || (secret_state[SECRET_KEYS.AI21] && oai_settings.chat_completion_source == chat_completion_sources.AI21)
404404 || (secret_state[SECRET_KEYS.MAKERSUITE] && oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE)
405405 || (secret_state[SECRET_KEYS.VERTEXAI] && oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI && oai_settings.vertexai_auth_mode === 'express')
406+ || (secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT] && oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI && oai_settings.vertexai_auth_mode === 'full')
406407 || (secret_state[SECRET_KEYS.MISTRALAI] && oai_settings.chat_completion_source == chat_completion_sources.MISTRALAI)
407408 || (secret_state[SECRET_KEYS.COHERE] && oai_settings.chat_completion_source == chat_completion_sources.COHERE)
408409 || (secret_state[SECRET_KEYS.PERPLEXITY] && oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY)
public/scripts/extensions/shared.js+24 -2
@@ -56,6 +56,12 @@ export async function getMultimodalCaption(base64Img, prompt) {
5656 model: extension_settings.caption.multimodal_model || 'gpt-4-turbo',
5757 };
5858
59+ // Add Vertex AI specific parameters if using Vertex AI
60+ if (extension_settings.caption.multimodal_api === 'vertexai') {
61+ requestBody.vertexai_auth_mode = oai_settings.vertexai_auth_mode;
62+ requestBody.vertexai_region = oai_settings.vertexai_region;
63+ }
64+
5965 if (isOllama) {
6066 if (extension_settings.caption.multimodal_model === 'ollama_current') {
6167 requestBody.model = textgenerationwebui_settings.ollama_model;
@@ -164,8 +170,24 @@ function throwIfInvalidModel(useReverseProxy) {
164170 throw new Error('Google AI Studio API key is not set.');
165171 }
166172
167173 if (multimodalApi === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !useReverseProxy) {
168- throw new Error('Google Vertex AI API key is not set.');
174+ // Check based on authentication mode
175+ const authMode = oai_settings.vertexai_auth_mode || 'express';
176+
177+ if (authMode === 'express') {
178+ // Express mode requires API key
179+ if (!secret_state[SECRET_KEYS.VERTEXAI]) {
180+ throw new Error('Google Vertex AI API key is not set for Express mode.');
181+ }
182+ } else if (authMode === 'full') {
183+ // Full mode requires Service Account JSON and region settings
184+ if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) {
185+ throw new Error('Service Account JSON is required for Vertex AI Full mode. Please validate and save your Service Account JSON.');
186+ }
187+ if (!oai_settings.vertexai_region) {
188+ throw new Error('Region is required for Vertex AI Full mode.');
189+ }
190+ }
169191 }
170192
171193 if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) {
public/scripts/openai.js+173 -1
@@ -235,6 +235,7 @@ const sensitiveFields = [
235235 'custom_include_body',
236236 'custom_exclude_body',
237237 'custom_include_headers',
238+ 'vertexai_region',
238239];
239240
240241/**
@@ -306,6 +307,8 @@ export const settingsToUpdate = {
306307 assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false, false],
307308 claude_use_sysprompt: ['#claude_use_sysprompt', 'claude_use_sysprompt', true, false],
308309 use_makersuite_sysprompt: ['#use_makersuite_sysprompt', 'use_makersuite_sysprompt', true, false],
310+ vertexai_auth_mode: ['#vertexai_auth_mode', 'vertexai_auth_mode', false, true],
311+ vertexai_region: ['#vertexai_region', 'vertexai_region', false, true],
309312 use_alt_scale: ['#use_alt_scale', 'use_alt_scale', true, true],
310313 squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false],
311314 image_inlining: ['#openai_image_inlining', 'image_inlining', true, false],
@@ -387,6 +390,8 @@ const default_settings = {
387390 assistant_impersonation: '',
388391 claude_use_sysprompt: false,
389392 use_makersuite_sysprompt: true,
393+ vertexai_auth_mode: 'express',
394+ vertexai_region: 'us-central1',
390395 use_alt_scale: false,
391396 squash_system_messages: false,
392397 image_inlining: false,
@@ -471,6 +476,8 @@ const oai_settings = {
471476 assistant_impersonation: '',
472477 claude_use_sysprompt: false,
473478 use_makersuite_sysprompt: true,
479+ vertexai_auth_mode: 'express',
480+ vertexai_region: 'us-central1',
474481 use_alt_scale: false,
475482 squash_system_messages: false,
476483 image_inlining: false,
@@ -2188,6 +2195,10 @@ async function sendOpenAIRequest(type, messages, signal) {
21882195 generate_data['top_k'] = Number(oai_settings.top_k_openai);
21892196 generate_data['stop'] = getCustomStoppingStrings(stopStringsLimit).slice(0, stopStringsLimit).filter(x => x.length >= 1 && x.length <= 16);
21902197 generate_data['use_makersuite_sysprompt'] = oai_settings.use_makersuite_sysprompt;
2198+ if (isVertexAI) {
2199+ generate_data['vertexai_auth_mode'] = oai_settings.vertexai_auth_mode;
2200+ generate_data['vertexai_region'] = oai_settings.vertexai_region;
2201+ }
21912202 }
21922203
21932204 if (isMistral) {
@@ -3423,6 +3434,8 @@ function loadOpenAISettings(data, settings) {
34233434 if (settings.openai_model !== undefined) oai_settings.openai_model = settings.openai_model;
34243435 if (settings.claude_use_sysprompt !== undefined) oai_settings.claude_use_sysprompt = !!settings.claude_use_sysprompt;
34253436 if (settings.use_makersuite_sysprompt !== undefined) oai_settings.use_makersuite_sysprompt = !!settings.use_makersuite_sysprompt;
3437+ if (settings.vertexai_auth_mode !== undefined) oai_settings.vertexai_auth_mode = settings.vertexai_auth_mode;
3438+ if (settings.vertexai_region !== undefined) oai_settings.vertexai_region = settings.vertexai_region;
34263439 if (settings.use_alt_scale !== undefined) { oai_settings.use_alt_scale = !!settings.use_alt_scale; updateScaleForm(); }
34273440 $('#stream_toggle').prop('checked', oai_settings.stream_openai);
34283441 $('#api_url_scale').val(oai_settings.api_url_scale);
@@ -3478,6 +3491,11 @@ function loadOpenAISettings(data, settings) {
34783491 $('#openai_external_category').toggle(oai_settings.show_external_models);
34793492 $('#claude_use_sysprompt').prop('checked', oai_settings.claude_use_sysprompt);
34803493 $('#use_makersuite_sysprompt').prop('checked', oai_settings.use_makersuite_sysprompt);
3494+ $('#vertexai_auth_mode').val(oai_settings.vertexai_auth_mode);
3495+ $('#vertexai_region').val(oai_settings.vertexai_region);
3496+ // Don't display Service Account JSON in textarea - it's stored in backend secrets
3497+ $('#vertexai_service_account_json').val('');
3498+ updateVertexAIServiceAccountStatus();
34813499 $('#scale-alt').prop('checked', oai_settings.use_alt_scale);
34823500 $('#openrouter_use_fallback').prop('checked', oai_settings.openrouter_use_fallback);
34833501 $('#openrouter_group_models').prop('checked', oai_settings.openrouter_group_models);
@@ -3800,6 +3818,8 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
38003818 assistant_impersonation: settings.assistant_impersonation,
38013819 claude_use_sysprompt: settings.claude_use_sysprompt,
38023820 use_makersuite_sysprompt: settings.use_makersuite_sysprompt,
3821+ vertexai_auth_mode: settings.vertexai_auth_mode,
3822+ vertexai_region: settings.vertexai_region,
38033823 use_alt_scale: settings.use_alt_scale,
38043824 squash_system_messages: settings.squash_system_messages,
38053825 image_inlining: settings.image_inlining,
@@ -4975,6 +4995,8 @@ async function onConnectButtonClick(e) {
49754995 }
49764996
49774997 if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) {
4998+ if (oai_settings.vertexai_auth_mode === 'express') {
4999+ // Express mode - use API key
49785000 const api_key_vertexai = String($('#api_key_vertexai').val()).trim();
49795001
49805002 if (api_key_vertexai.length) {
@@ -4982,10 +5004,20 @@ async function onConnectButtonClick(e) {
49825004 }
49835005
49845006 if (!secret_state[SECRET_KEYS.VERTEXAI] && !oai_settings.reverse_proxy) {
49855007 console.log('No secret key saved for Vertex AI Express mode');
5008+ return;
5009+ }
5010+ } else {
5011+ // Full version - use service account
5012+ // Project ID will be extracted from the Service Account JSON
5013+
5014+ // Check if service account JSON is saved in backend
5015+ if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) {
5016+ toastr.error('Service Account JSON is required for Vertex AI full version. Please validate and save your Service Account JSON.');
49865017 return;
49875018 }
49885019 }
5020+ }
49895021
49905022 if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) {
49915023 const api_key_claude = String($('#api_key_claude').val()).trim();
@@ -5166,6 +5198,8 @@ function toggleChatCompletionForms() {
51665198 }
51675199 else if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) {
51685200 $('#model_vertexai_select').trigger('change');
5201+ // Update UI based on authentication mode
5202+ onVertexAIAuthModeChange.call($('#vertexai_auth_mode')[0]);
51695203 }
51705204 else if (oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER) {
51715205 $('#model_openrouter_select').trigger('change');
@@ -5476,6 +5510,136 @@ function runProxyCallback(_, value) {
54765510 return foundName;
54775511}
54785512
5513+/**
5514+ * Handle Vertex AI authentication mode change
5515+ */
5516+function onVertexAIAuthModeChange() {
5517+ const authMode = String($(this).val());
5518+ oai_settings.vertexai_auth_mode = authMode;
5519+
5520+ $('#vertexai_form [data-mode]').each(function () {
5521+ const mode = $(this).data('mode');
5522+ $(this).toggle(mode === authMode);
5523+ $(this).find('option').toggle(mode === authMode);
5524+ });
5525+
5526+ saveSettingsDebounced();
5527+}
5528+
5529+/**
5530+ * Validate Vertex AI service account JSON
5531+ */
5532+async function onVertexAIValidateServiceAccount() {
5533+ const jsonContent = String($('#vertexai_service_account_json').val()).trim();
5534+
5535+ if (!jsonContent) {
5536+ toastr.error(t`Please enter Service Account JSON content`);
5537+ return;
5538+ }
5539+
5540+ try {
5541+ const serviceAccount = JSON.parse(jsonContent);
5542+ const requiredFields = ['type', 'project_id', 'private_key', 'client_email', 'client_id'];
5543+ const missingFields = requiredFields.filter(field => !serviceAccount[field]);
5544+
5545+ if (missingFields.length > 0) {
5546+ toastr.error(t`Missing required fields: ${missingFields.join(', ')}`);
5547+ updateVertexAIServiceAccountStatus(false, t`Missing fields: ${missingFields.join(', ')}`);
5548+ return;
5549+ }
5550+
5551+ if (serviceAccount.type !== 'service_account') {
5552+ toastr.error(t`Invalid service account type. Expected "service_account"`);
5553+ updateVertexAIServiceAccountStatus(false, t`Invalid service account type`);
5554+ return;
5555+ }
5556+
5557+ // Save to backend secret storage
5558+ await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent);
5559+
5560+ // Show success status
5561+ updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);
5562+
5563+ toastr.success(t`Service Account JSON is valid and saved securely`);
5564+ saveSettingsDebounced();
5565+ } catch (error) {
5566+ console.error('JSON validation error:', error);
5567+ toastr.error(t`Invalid JSON format`);
5568+ updateVertexAIServiceAccountStatus(false, t`Invalid JSON format`);
5569+ }
5570+}
5571+
5572+/**
5573+ * Clear Vertex AI service account JSON
5574+ */
5575+async function onVertexAIClearServiceAccount() {
5576+ $('#vertexai_service_account_json').val('');
5577+
5578+ // Clear from backend secret storage
5579+ await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, '');
5580+
5581+ updateVertexAIServiceAccountStatus(false);
5582+ toastr.info(t`Service Account JSON cleared`);
5583+ saveSettingsDebounced();
5584+}
5585+
5586+/**
5587+ * Handle Vertex AI service account JSON input change
5588+ */
5589+function onVertexAIServiceAccountJsonChange() {
5590+ const jsonContent = String($(this).val()).trim();
5591+
5592+ if (jsonContent) {
5593+ // Auto-validate when content is pasted
5594+ try {
5595+ const serviceAccount = JSON.parse(jsonContent);
5596+ const requiredFields = ['type', 'project_id', 'private_key', 'client_email'];
5597+ const hasAllFields = requiredFields.every(field => serviceAccount[field]);
5598+
5599+ if (hasAllFields && serviceAccount.type === 'service_account') {
5600+ updateVertexAIServiceAccountStatus(false, t`JSON appears valid - click "Validate JSON" to save`);
5601+ } else {
5602+ updateVertexAIServiceAccountStatus(false, t`Incomplete or invalid JSON`);
5603+ }
5604+ } catch (error) {
5605+ updateVertexAIServiceAccountStatus(false, t`Invalid JSON format`);
5606+ }
5607+ } else {
5608+ updateVertexAIServiceAccountStatus(false);
5609+ }
5610+
5611+ // Don't save settings automatically
5612+ // saveSettingsDebounced();
5613+}
5614+
5615+/**
5616+ * Update the Vertex AI service account status display
5617+ * @param {boolean} isValid - Whether the service account is valid
5618+ * @param {string} message - Status message to display
5619+ */
5620+function updateVertexAIServiceAccountStatus(isValid = false, message = '') {
5621+ const statusDiv = $('#vertexai_service_account_status');
5622+ const infoSpan = $('#vertexai_service_account_info');
5623+
5624+ // If no explicit message provided, check if we have a saved service account
5625+ if (!message && secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) {
5626+ isValid = true;
5627+ message = t`Service Account JSON is saved and ready to use`;
5628+ }
5629+
5630+ if (isValid && message) {
5631+ infoSpan.html(`<i class="fa-solid fa-check-circle" style="color: green;"></i> ${message}`);
5632+ statusDiv.show();
5633+ } else if (!isValid && message) {
5634+ infoSpan.html(`<i class="fa-solid fa-exclamation-triangle" style="color: orange;"></i> ${message}`);
5635+ statusDiv.show();
5636+ } else {
5637+ statusDiv.hide();
5638+ }
5639+}
5640+
5641+
5642+
54795643export function initOpenAI() {
54805644 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
54815645 name: 'proxy',
@@ -5939,6 +6103,14 @@ export function initOpenAI() {
59396103 $('#model_scale_select').on('change', onModelChange);
59406104 $('#model_google_select').on('change', onModelChange);
59416105 $('#model_vertexai_select').on('change', onModelChange);
6106+ $('#vertexai_auth_mode').on('change', onVertexAIAuthModeChange);
6107+ $('#vertexai_region').on('input', function () {
6108+ oai_settings.vertexai_region = String($(this).val());
6109+ saveSettingsDebounced();
6110+ });
6111+ $('#vertexai_service_account_json').on('input', onVertexAIServiceAccountJsonChange);
6112+ $('#vertexai_validate_service_account').on('click', onVertexAIValidateServiceAccount);
6113+ $('#vertexai_clear_service_account').on('click', onVertexAIClearServiceAccount);
59426114 $('#model_openrouter_select').on('change', onModelChange);
59436115 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
59446116 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
public/scripts/secrets.js+9 -0
@@ -44,6 +44,7 @@ export const SECRET_KEYS = {
4444 SERPER: 'api_key_serper',
4545 FALAI: 'api_key_falai',
4646 XAI: 'api_key_xai',
47+ VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
4748};
4849
4950const INPUT_MAP = {
@@ -80,8 +81,13 @@ const INPUT_MAP = {
8081 [SECRET_KEYS.GENERIC]: '#api_key_generic',
8182 [SECRET_KEYS.DEEPSEEK]: '#api_key_deepseek',
8283 [SECRET_KEYS.XAI]: '#api_key_xai',
84+ [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
8385};
8486
87+const STATIC_PLACEHOLDER_KEYS = [
88+ SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT,
89+];
90+
8591async function clearSecret() {
8692 const key = $(this).data('key');
8793 await writeSecret(key, '');
@@ -93,6 +99,9 @@ async function clearSecret() {
9399
94100export function updateSecretDisplay() {
95101 for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
102+ if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) {
103+ continue;
104+ }
96105 const validSecret = !!secret_state[secret_key];
97106
98107 const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
src/endpoints/backends/chat-completions.js+56 -8
@@ -43,6 +43,7 @@ import {
4343 webTokenizers,
4444 getWebTokenizer,
4545} from '../tokenizers.js';
46+import { getVertexAIAuth, getProjectIdFromServiceAccount } from '../google.js';
4647
4748const API_OPENAI = 'https://api.openai.com/v1';
4849const API_CLAUDE = 'https://api.anthropic.com/v1';
@@ -348,13 +349,20 @@ async function sendMakerSuiteRequest(request, response) {
348349 let apiUrl;
349350 let apiKey;
350351
352+ let authHeader;
353+ let authType;
354+
351355 if (useVertexAi) {
352356 apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI);
353- apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
354357
355- if (!request.body.reverse_proxy && !apiKey) {
358+ try {
356- console.warn(`${apiName} API key is missing.`);
359+ const auth = await getVertexAIAuth(request);
357- return response.status(400).send({ error: true });
360+ authHeader = auth.authHeader;
361+ authType = auth.authType;
362+ console.debug(`Using Vertex AI authentication type: ${authType}`);
363+ } catch (error) {
364+ console.warn(`${apiName} authentication failed: ${error.message}`);
365+ return response.status(400).send({ error: true, message: error.message });
358366 }
359367 } else {
360368 apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
@@ -364,6 +372,9 @@ async function sendMakerSuiteRequest(request, response) {
364372 console.warn(`${apiName} API key is missing.`);
365373 return response.status(400).send({ error: true });
366374 }
375+
376+ authHeader = `Bearer ${apiKey}`;
377+ authType = 'api_key';
367378 }
368379
369380 const model = String(request.body.model);
@@ -391,6 +402,7 @@ async function sendMakerSuiteRequest(request, response) {
391402 const imageGenerationModels = [
392403 'gemini-2.0-flash-exp',
393404 'gemini-2.0-flash-exp-image-generation',
405+ 'gemini-2.0-flash-preview-image-generation',
394406 ];
395407
396408 // These models do not support setting the threshold to OFF at all.
@@ -499,17 +511,53 @@ async function sendMakerSuiteRequest(request, response) {
499511 const responseType = (stream ? 'streamGenerateContent' : 'generateContent');
500512
501513 let url;
514+ let headers = {
515+ 'Content-Type': 'application/json',
516+ };
517+
502518 if (useVertexAi) {
503- url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`;
519+ if (authType === 'express') {
520+ // For Express mode (API key authentication), use the key parameter
521+ const keyParam = authHeader.replace('Bearer ', '');
522+ url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${keyParam}${stream ? '&alt=sse' : ''}`;
523+ } else if (authType === 'full') {
524+ // For Full mode (service account authentication), use project-specific URL
525+ // Get project ID from Service Account JSON
526+ const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
527+ if (!serviceAccountJson) {
528+ console.warn('Vertex AI Service Account JSON is missing.');
529+ return response.status(400).send({ error: true });
530+ }
531+
532+ let projectId;
533+ try {
534+ const serviceAccount = JSON.parse(serviceAccountJson);
535+ projectId = getProjectIdFromServiceAccount(serviceAccount);
536+ } catch (error) {
537+ console.error('Failed to extract project ID from Service Account JSON:', error);
538+ return response.status(400).send({ error: true });
539+ }
540+ const region = request.body.vertexai_region || 'us-central1';
541+ // Handle global region differently - no region prefix in hostname
542+ if (region === 'global') {
543+ url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`;
544+ } else {
545+ url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`;
546+ }
547+ headers['Authorization'] = authHeader;
548+ } else {
549+ // For proxy mode, use the original URL with Authorization header
550+ url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`;
551+ headers['Authorization'] = authHeader;
552+ }
504553 } else {
505554 url = `${apiUrl.toString().replace(/\/$/, '')}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`;
506555 }
556+
507557 const generateResponse = await fetch(url, {
508558 body: JSON.stringify(body),
509559 method: 'POST',
510560 headers: {headers,
511- 'Content-Type': 'application/json',
512- },
513561 signal: controller.signal,
514562 });
515563
src/endpoints/google.js+165 -13
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
22import fetch from 'node-fetch';
33import express from 'express';
44import { speak, languages } from 'google-translate-api-x';
5+import crypto from 'node:crypto';
56
67import { readSecret, SECRET_KEYS } from './secrets.js';
78import { GEMINI_SAFETY } from '../constants.js';
@@ -9,6 +10,122 @@ import { GEMINI_SAFETY } from '../constants.js';
910const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
1011const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
1112
13+// Vertex AI authentication helper functions
14+export async function getVertexAIAuth(request) {
15+ const authMode = request.body.vertexai_auth_mode || 'express';
16+
17+ if (request.body.reverse_proxy) {
18+ return {
19+ authHeader: `Bearer ${request.body.proxy_password}`,
20+ authType: 'proxy',
21+ };
22+ }
23+
24+ if (authMode === 'express') {
25+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
26+ if (apiKey) {
27+ return {
28+ authHeader: `Bearer ${apiKey}`,
29+ authType: 'express',
30+ };
31+ }
32+ throw new Error('API key is required for Vertex AI Express mode');
33+ } else if (authMode === 'full') {
34+ // Get service account JSON from backend storage
35+ const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
36+
37+ if (serviceAccountJson) {
38+ try {
39+ const serviceAccount = JSON.parse(serviceAccountJson);
40+ const jwtToken = await generateJWTToken(serviceAccount);
41+ const accessToken = await getAccessToken(jwtToken);
42+ return {
43+ authHeader: `Bearer ${accessToken}`,
44+ authType: 'full',
45+ };
46+ } catch (error) {
47+ console.error('Failed to authenticate with service account:', error);
48+ throw new Error(`Service account authentication failed: ${error.message}`);
49+ }
50+ }
51+ throw new Error('Service Account JSON is required for Vertex AI Full mode');
52+ }
53+
54+ throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`);
55+}
56+
57+/**
58+ * Generates a JWT token for Google Cloud authentication using service account credentials.
59+ * @param {object} serviceAccount Service account JSON object
60+ * @returns {Promise<string>} JWT token
61+ */
62+export async function generateJWTToken(serviceAccount) {
63+ const now = Math.floor(Date.now() / 1000);
64+ const expiry = now + 3600; // 1 hour
65+
66+ const header = {
67+ alg: 'RS256',
68+ typ: 'JWT',
69+ };
70+
71+ const payload = {
72+ iss: serviceAccount.client_email,
73+ scope: 'https://www.googleapis.com/auth/cloud-platform',
74+ aud: 'https://oauth2.googleapis.com/token',
75+ iat: now,
76+ exp: expiry,
77+ };
78+
79+ const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url');
80+ const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
81+ const signatureInput = `${headerBase64}.${payloadBase64}`;
82+
83+ // Create signature using private key
84+ const sign = crypto.createSign('RSA-SHA256');
85+ sign.update(signatureInput);
86+ const signature = sign.sign(serviceAccount.private_key, 'base64url');
87+
88+ return `${signatureInput}.${signature}`;
89+}
90+
91+export async function getAccessToken(jwtToken) {
92+ const response = await fetch('https://oauth2.googleapis.com/token', {
93+ method: 'POST',
94+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
95+ body: new URLSearchParams({
96+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
97+ assertion: jwtToken,
98+ }),
99+ });
100+
101+ if (!response.ok) {
102+ const error = await response.text();
103+ throw new Error(`Failed to get access token: ${error}`);
104+ }
105+
106+ const data = await response.json();
107+ return data.access_token;
108+}
109+
110+/**
111+ * Extracts the project ID from a Service Account JSON object.
112+ * @param {object} serviceAccount Service account JSON object
113+ * @returns {string} Project ID
114+ * @throws {Error} If project ID is not found in the service account
115+ */
116+export function getProjectIdFromServiceAccount(serviceAccount) {
117+ if (!serviceAccount || typeof serviceAccount !== 'object') {
118+ throw new Error('Invalid service account object');
119+ }
120+
121+ const projectId = serviceAccount.project_id;
122+ if (!projectId || typeof projectId !== 'string') {
123+ throw new Error('Project ID not found in service account JSON');
124+ }
125+
126+ return projectId;
127+}
128+
12129export const router = express.Router();
13130
14131router.post('/caption-image', async (request, response) => {
@@ -17,20 +134,57 @@ router.post('/caption-image', async (request, response) => {
17134 const base64Data = request.body.image.split(',')[1];
18135 const useVertexAi = request.body.api === 'vertexai';
19136 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
20- let apiKey;
21- let apiUrl;
22- if (useVertexAi) {
23- apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
24- apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI);
25- } else {
26- apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
27- apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
28- }
29137 const model = request.body.model || 'gemini-2.0-flash';
138+
30139 let url;
140+ let headers = {
141+ 'Content-Type': 'application/json',
142+ };
143+
31144 if (useVertexAi) {
32- url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${apiKey}`;
145+ // Get authentication for Vertex AI
146+ const { authHeader, authType } = await getVertexAIAuth(request);
147+
148+ if (authType === 'express') {
149+ // Express mode: use API key parameter
150+ const keyParam = authHeader.replace('Bearer ', '');
151+ const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI);
152+ url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}`;
153+ } else if (authType === 'full') {
154+ // Full mode: use project-specific URL with Authorization header
155+ // Get project ID from Service Account JSON
156+ const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
157+ if (!serviceAccountJson) {
158+ console.warn('Vertex AI Service Account JSON is missing.');
159+ return response.status(400).send({ error: true });
160+ }
161+
162+ let projectId;
163+ try {
164+ const serviceAccount = JSON.parse(serviceAccountJson);
165+ projectId = getProjectIdFromServiceAccount(serviceAccount);
166+ } catch (error) {
167+ console.error('Failed to extract project ID from Service Account JSON:', error);
168+ return response.status(400).send({ error: true });
169+ }
170+ const region = request.body.vertexai_region || 'us-central1';
171+ // Handle global region differently - no region prefix in hostname
172+ if (region === 'global') {
173+ url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`;
174+ } else {
175+ url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`;
176+ }
177+ headers['Authorization'] = authHeader;
33178 } else {
179+ // Proxy mode: use Authorization header
180+ const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI);
181+ url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent`;
182+ headers['Authorization'] = authHeader;
183+ }
184+ } else {
185+ // Google AI Studio
186+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
187+ const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
34188 url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`;
35189 }
36190 const body = {
@@ -53,9 +207,7 @@ router.post('/caption-image', async (request, response) => {
53207 const result = await fetch(url, {
54208 body: JSON.stringify(body),
55209 method: 'POST',
56210 headers: {headers,
57- 'Content-Type': 'application/json',
58- },
59211 });
60212
61213 if (!result.ok) {
src/endpoints/secrets.js+1 -0
@@ -54,6 +54,7 @@ export const SECRET_KEYS = {
5454 DEEPSEEK: 'api_key_deepseek',
5555 SERPER: 'api_key_serper',
5656 XAI: 'api_key_xai',
57+ VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
5758};
5859
5960// These are the keys that are safe to expose, even if allowKeysExposure is false