From 78c1ae93713cdb6c7454c08b6fd5f1b9f80082de Mon Sep 17 00:00:00 2001 From: spiritree Date: Mon, 23 Mar 2026 19:32:24 +0800 Subject: [PATCH] feat: add Gemini third-party image provider settings/providers: - add a Google Gemini (Third-party API) media preset and match custom Gemini base URLs back to that preset - keep official Google Gemini (Image) behavior unchanged for the default Google endpoint runtime/tests: - forward gemini-image provider base_url into createGoogleGenerativeAI in image-generator - add regression tests for preset matching and image provider config resolution --- src/__tests__/unit/image-generator.test.ts | 29 ++++++++++++ src/__tests__/unit/provider-presets.test.ts | 48 ++++++++++++++++++++ src/components/settings/provider-presets.tsx | 19 ++++++++ src/lib/image-generator.ts | 46 ++++++++++++++----- src/lib/provider-catalog.ts | 26 ++++++++++- 5 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/unit/image-generator.test.ts create mode 100644 src/__tests__/unit/provider-presets.test.ts diff --git a/src/__tests__/unit/image-generator.test.ts b/src/__tests__/unit/image-generator.test.ts new file mode 100644 index 00000000..2ee5db93 --- /dev/null +++ b/src/__tests__/unit/image-generator.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveGeminiImageProviderConfig } from '../../lib/image-generator'; + +describe('image-generator provider config', () => { + it('forwards custom base URL to Google SDK config', () => { + const config = resolveGeminiImageProviderConfig({ + api_key: 'gemini-key', + base_url: 'https://proxy.example.com/google/v1beta', + extra_env: '{"GEMINI_IMAGE_MODEL":"gemini-3-pro-image-preview"}', + }); + + assert.equal(config.apiKey, 'gemini-key'); + assert.equal(config.baseURL, 'https://proxy.example.com/google/v1beta'); + assert.equal(config.model, 'gemini-3-pro-image-preview'); + }); + + it('falls back to default model and omits empty base URL', () => { + const config = resolveGeminiImageProviderConfig({ + api_key: 'gemini-key', + base_url: '', + extra_env: '{}', + }); + + assert.equal(config.apiKey, 'gemini-key'); + assert.equal(config.baseURL, undefined); + assert.equal(config.model, 'gemini-3.1-flash-image-preview'); + }); +}); diff --git a/src/__tests__/unit/provider-presets.test.ts b/src/__tests__/unit/provider-presets.test.ts new file mode 100644 index 00000000..d4158c07 --- /dev/null +++ b/src/__tests__/unit/provider-presets.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { + VENDOR_PRESETS, + findPresetForLegacy, +} from '../../lib/provider-catalog'; + +const providerPresetsSource = fs.readFileSync( + path.join(process.cwd(), 'src/components/settings/provider-presets.tsx'), + 'utf-8', +); + +describe('Gemini image third-party provider wiring', () => { + it('adds a Gemini third-party media preset to the vendor catalog', () => { + const preset = VENDOR_PRESETS.find(p => p.key === 'gemini-image-thirdparty'); + assert.ok(preset, 'gemini-image-thirdparty preset not found in vendor catalog'); + assert.equal(preset?.protocol, 'gemini-image'); + assert.equal(preset?.category, 'media'); + assert.ok(preset?.fields.includes('base_url'), 'third-party catalog preset should expose base_url'); + }); + + it('matches gemini-image custom base URLs to the third-party preset', () => { + const preset = findPresetForLegacy('https://proxy.example.com/google/v1beta', 'gemini-image'); + assert.ok(preset); + assert.equal(preset.key, 'gemini-image-thirdparty'); + }); + + it('quick preset source includes Gemini third-party entry with base_url field', () => { + assert.match(providerPresetsSource, /key:\s*"gemini-image-thirdparty"/); + assert.match(providerPresetsSource, /name:\s*"Google Gemini \(Third-party API\)"/); + assert.match(providerPresetsSource, /provider_type:\s*"gemini-image"/); + assert.match(providerPresetsSource, /category:\s*"media"/); + assert.match(providerPresetsSource, /fields:\s*\["api_key",\s*"base_url"\]/); + }); + + it('quick preset matcher routes custom gemini-image URLs to the third-party preset', () => { + assert.match( + providerPresetsSource, + /provider\.provider_type === "gemini-image" && provider\.base_url === "https:\/\/generativelanguage\.googleapis\.com\/v1beta"/, + ); + assert.match( + providerPresetsSource, + /provider\.provider_type === "gemini-image" && provider\.base_url[\s\S]*gemini-image-thirdparty/, + ); + }); +}); diff --git a/src/components/settings/provider-presets.tsx b/src/components/settings/provider-presets.tsx index fb6f7410..6a308179 100644 --- a/src/components/settings/provider-presets.tsx +++ b/src/components/settings/provider-presets.tsx @@ -278,6 +278,19 @@ export const QUICK_PRESETS: QuickPreset[] = [ fields: ["api_key"], category: "media", }, + { + key: "gemini-image-thirdparty", + name: "Google Gemini (Third-party API)", + description: "Gemini image API via a third-party compatible endpoint", + descriptionZh: "通过第三方兼容端点接入 Gemini 图片 API", + icon: , + provider_type: "gemini-image", + protocol: "gemini-image", + base_url: "", + extra_env: '{"GEMINI_API_KEY":""}', + fields: ["api_key", "base_url"], + category: "media", + }, ]; // --------------------------------------------------------------------------- @@ -315,6 +328,12 @@ export function findMatchingPreset(provider: ApiProvider): QuickPreset | undefin if (provider.provider_type === "bedrock") return QUICK_PRESETS.find(p => p.key === "bedrock"); if (provider.provider_type === "vertex") return QUICK_PRESETS.find(p => p.key === "vertex"); if (provider.provider_type === "openrouter") return QUICK_PRESETS.find(p => p.key === "openrouter"); + if (provider.provider_type === "gemini-image" && provider.base_url === "https://generativelanguage.googleapis.com/v1beta") { + return QUICK_PRESETS.find(p => p.key === "gemini-image"); + } + if (provider.provider_type === "gemini-image" && provider.base_url) { + return QUICK_PRESETS.find(p => p.key === "gemini-image-thirdparty"); + } if (provider.provider_type === "gemini-image") return QUICK_PRESETS.find(p => p.key === "gemini-image"); if (provider.provider_type === "anthropic" && provider.base_url === "https://api.anthropic.com") { return QUICK_PRESETS.find(p => p.key === "anthropic-official"); diff --git a/src/lib/image-generator.ts b/src/lib/image-generator.ts index 3f76468a..f2f8abdf 100644 --- a/src/lib/image-generator.ts +++ b/src/lib/image-generator.ts @@ -26,6 +26,32 @@ export interface GenerateSingleImageResult { elapsedMs: number; } +interface GeminiImageProviderRecord { + api_key: string; + base_url?: string; + extra_env?: string; +} + +export function resolveGeminiImageProviderConfig(provider: GeminiImageProviderRecord): { + apiKey: string; + baseURL?: string; + model: string; +} { + let configuredModel = 'gemini-3.1-flash-image-preview'; + try { + const env = JSON.parse(provider.extra_env || '{}'); + if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL; + } catch { + // keep default model when extra_env is missing or invalid + } + + return { + apiKey: provider.api_key, + baseURL: provider.base_url?.trim() || undefined, + model: configuredModel, + }; +} + /** * Shared image generation function. * Handles: Provider lookup → Gemini API call → file save → project dir copy → DB record. @@ -35,25 +61,21 @@ export async function generateSingleImage(params: GenerateSingleImageParams): Pr const db = getDb(); const provider = db.prepare( - "SELECT api_key, extra_env FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1" - ).get() as { api_key: string; extra_env?: string } | undefined; + "SELECT api_key, base_url, extra_env FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' ORDER BY sort_order ASC, created_at ASC LIMIT 1" + ).get() as GeminiImageProviderRecord | undefined; if (!provider) { throw new Error('No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.'); } - // Read configured model from extra_env, fall back to default - let configuredModel = 'gemini-3.1-flash-image-preview'; - try { - const env = JSON.parse(provider.extra_env || '{}'); - if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL; - } catch { /* use default */ } - - const requestedModel = params.model || configuredModel; + const providerConfig = resolveGeminiImageProviderConfig(provider); + const requestedModel = params.model || providerConfig.model; const aspectRatio = (params.aspectRatio || '1:1') as `${number}:${number}`; const imageSize = params.imageSize || '1K'; - - const google = createGoogleGenerativeAI({ apiKey: provider.api_key }); + const google = createGoogleGenerativeAI({ + apiKey: providerConfig.apiKey, + baseURL: providerConfig.baseURL, + }); // Build prompt: plain string or { text, images } for reference images // Combine both base64 data and file paths — both can be provided simultaneously diff --git a/src/lib/provider-catalog.ts b/src/lib/provider-catalog.ts index 236ff332..89b88e0b 100644 --- a/src/lib/provider-catalog.ts +++ b/src/lib/provider-catalog.ts @@ -438,6 +438,26 @@ export const VENDOR_PRESETS: VendorPreset[] = [ iconKey: 'google', }, + // ── Google Gemini (Third-party API) ── + { + key: 'gemini-image-thirdparty', + name: 'Google Gemini (Third-party API)', + description: 'Gemini image API via a third-party compatible endpoint', + descriptionZh: '通过第三方兼容端点接入 Gemini 图片 API', + protocol: 'gemini-image', + authStyle: 'api_key', + baseUrl: '', + defaultEnvOverrides: { GEMINI_API_KEY: '' }, + defaultModels: [ + { modelId: 'gemini-3.1-flash-image-preview', displayName: 'Nano Banana 2' }, + { modelId: 'gemini-3-pro-image-preview', displayName: 'Nano Banana Pro' }, + { modelId: 'gemini-2.5-flash-image', displayName: 'Nano Banana' }, + ], + fields: ['api_key', 'base_url'], + category: 'media', + iconKey: 'google', + }, + // ── Custom API (OpenAI-compatible) ── { key: 'custom-openai', @@ -556,7 +576,11 @@ export function findPresetForLegacy(baseUrl: string, providerType: string, proto if (providerType === 'bedrock') return VENDOR_PRESETS.find(p => p.key === 'bedrock'); if (providerType === 'vertex') return VENDOR_PRESETS.find(p => p.key === 'vertex'); if (providerType === 'openrouter') return VENDOR_PRESETS.find(p => p.key === 'openrouter'); - if (providerType === 'gemini-image') return VENDOR_PRESETS.find(p => p.key === 'gemini-image'); + if (providerType === 'gemini-image') { + return VENDOR_PRESETS.find( + p => p.key === (baseUrl === 'https://generativelanguage.googleapis.com/v1beta' ? 'gemini-image' : 'gemini-image-thirdparty'), + ); + } if (providerType === 'anthropic' && baseUrl === 'https://api.anthropic.com') { return VENDOR_PRESETS.find(p => p.key === 'anthropic-official'); }