Merge pull request #4046 from InterestingDarknessII/vertexfull

Add Vertex AI Full Version support
This commit is contained in:
Cohee
2025-05-28 20:52:24 +03:00
committed by GitHub
8 changed files with 534 additions and 47 deletions

View File

@@ -2779,7 +2779,7 @@
<option value="deepseek">DeepSeek</option>
<option value="groq">Groq</option>
<option value="makersuite">Google AI Studio</option>
<option value="vertexai">Google Vertex AI (Express mode)</option>
<option value="vertexai">Google Vertex AI</option>
<option value="mistralai">MistralAI</option>
<option value="nanogpt">NanoGPT</option>
<option value="openrouter">OpenRouter</option>
@@ -3225,34 +3225,116 @@
</div>
</form>
<div id="vertexai_form" data-source="vertexai">
<h4>
<span data-i18n="Google Vertex AI API Key">
Google Vertex AI API Key
</span>
<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">
(Express mode keys only)
</a>
</h4>
<h4 data-i18n="Google Vertex AI Configuration">Google Vertex AI Configuration</h4>
<!-- Authentication Mode Selection -->
<div class="flex-container">
<input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_vertexai"></div>
<label for="vertexai_auth_mode" data-i18n="Authentication Mode">Authentication Mode:</label>
<select id="vertexai_auth_mode" class="text_pole">
<option value="express" data-i18n="Express Mode (API Key)">Express Mode (API Key)</option>
<option value="full" data-i18n="Full Version (Service Account)">Full Version (Service Account)</option>
</select>
</div>
<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.">
For privacy reasons, your API key will be hidden after you reload the page.
<!-- Express Mode Configuration -->
<div id="vertexai_express_config" class="vertexai-auth-section" data-mode="express">
<h4>
<span data-i18n="Google Vertex AI API Key">
Google Vertex AI API Key
</span>
<a href="https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview" data-i18n="(Express mode)" target="_blank" rel="noopener noreferrer">
(Express mode)
</a>
</h4>
<div class="flex-container">
<input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_vertexai"></div>
</div>
<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.">
For privacy reasons, your API key will be hidden after you reload the page.
</div>
</div>
<!-- Full Version Configuration -->
<div id="vertexai_full_config" class="vertexai-auth-section" data-mode="full">
<h4>
<span data-i18n="Service Account Configuration">
Service Account Configuration
</span>
<a href="https://cloud.google.com/vertex-ai/docs/authentication" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-circle-question"></i>
</a>
</h4>
<!-- Region -->
<div class="flex-container flexFlowColumn">
<label for="vertexai_region">
<span data-i18n="Region">
Region:
</span>
<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">
<span class="fa-solid fa-circle-question note-link-span"></span>
</a>
</label>
<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">
</div>
<!-- Service Account JSON Content -->
<div class="flex-container flexFlowColumn">
<label for="vertexai_service_account_json" data-i18n="Service Account JSON Content">Service Account JSON Content:</label>
<div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">
<span id="vertexai_service_account_info"></span>
</div>
<textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="4" placeholder='Paste your Service Account JSON content here, e.g.:
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "...",
"client_id": "...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}'></textarea>
<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.">
For privacy reasons, your Service Account JSON content will be hidden after you reload the page.
</div>
<div class="flex-container">
<button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>
<button type="button" id="vertexai_clear_service_account" class="menu_button menu_button_icon" data-i18n="Clear">Clear</button>
</div>
</div>
</div>
<!-- Model Selection -->
<div>
<h4 data-i18n="Google Model">Google Model</h4>
<select id="model_vertexai_select">
<optgroup label="Gemini 2.5">
<!-- Express Mode Models -->
<optgroup id="vertexai_express_models" label="Express Mode Models" data-mode="express">
<option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
<option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
<option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
<option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
</optgroup>
<optgroup label="Gemini 2.0">
<option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
<option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
</optgroup>
<!-- Full Version Models -->
<optgroup id="vertexai_full_gemini_25" label="Gemini 2.5" data-mode="full">
<option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
<option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
<option value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option>
<option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
<option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
</optgroup>
<optgroup id="vertexai_full_gemini_20" label="Gemini 2.0" data-mode="full">
<option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
<option value="gemini-2.0-flash-preview-image-generation">gemini-2.0-flash-preview-image-generation</option>
<option value="gemini-2.0-flash">gemini-2.0-flash</option>
<option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
</optgroup>
</select>
</div>
</div>

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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(`<i class="fa-solid fa-check-circle" style="color: green;"></i> ${message}`);
statusDiv.show();
} else if (!isValid && message) {
infoSpan.html(`<i class="fa-solid fa-exclamation-triangle" style="color: orange;"></i> ${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);

View File

@@ -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');

View File

@@ -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,
});

View File

@@ -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<string>} 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) {

View File

@@ -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