Skip to content

Commit 283df2f

Browse files
author
jingyang.jiang
committed
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
1 parent ab952ee commit 283df2f

5 files changed

Lines changed: 155 additions & 13 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { resolveGeminiImageProviderConfig } from '../../lib/image-generator';
4+
5+
describe('image-generator provider config', () => {
6+
it('forwards custom base URL to Google SDK config', () => {
7+
const config = resolveGeminiImageProviderConfig({
8+
api_key: 'gemini-key',
9+
base_url: 'https://proxy.example.com/google/v1beta',
10+
extra_env: '{"GEMINI_IMAGE_MODEL":"gemini-3-pro-image-preview"}',
11+
});
12+
13+
assert.equal(config.apiKey, 'gemini-key');
14+
assert.equal(config.baseURL, 'https://proxy.example.com/google/v1beta');
15+
assert.equal(config.model, 'gemini-3-pro-image-preview');
16+
});
17+
18+
it('falls back to default model and omits empty base URL', () => {
19+
const config = resolveGeminiImageProviderConfig({
20+
api_key: 'gemini-key',
21+
base_url: '',
22+
extra_env: '{}',
23+
});
24+
25+
assert.equal(config.apiKey, 'gemini-key');
26+
assert.equal(config.baseURL, undefined);
27+
assert.equal(config.model, 'gemini-3.1-flash-image-preview');
28+
});
29+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'fs';
4+
import path from 'path';
5+
import {
6+
VENDOR_PRESETS,
7+
findPresetForLegacy,
8+
} from '../../lib/provider-catalog';
9+
10+
const providerPresetsSource = fs.readFileSync(
11+
path.join(process.cwd(), 'src/components/settings/provider-presets.tsx'),
12+
'utf-8',
13+
);
14+
15+
describe('Gemini image third-party provider wiring', () => {
16+
it('adds a Gemini third-party media preset to the vendor catalog', () => {
17+
const preset = VENDOR_PRESETS.find(p => p.key === 'gemini-image-thirdparty');
18+
assert.ok(preset, 'gemini-image-thirdparty preset not found in vendor catalog');
19+
assert.equal(preset?.protocol, 'gemini-image');
20+
assert.equal(preset?.category, 'media');
21+
assert.ok(preset?.fields.includes('base_url'), 'third-party catalog preset should expose base_url');
22+
});
23+
24+
it('matches gemini-image custom base URLs to the third-party preset', () => {
25+
const preset = findPresetForLegacy('https://proxy.example.com/google/v1beta', 'gemini-image');
26+
assert.ok(preset);
27+
assert.equal(preset.key, 'gemini-image-thirdparty');
28+
});
29+
30+
it('quick preset source includes Gemini third-party entry with base_url field', () => {
31+
assert.match(providerPresetsSource, /key:\s*"gemini-image-thirdparty"/);
32+
assert.match(providerPresetsSource, /name:\s*"Google Gemini \(Third-party API\)"/);
33+
assert.match(providerPresetsSource, /provider_type:\s*"gemini-image"/);
34+
assert.match(providerPresetsSource, /category:\s*"media"/);
35+
assert.match(providerPresetsSource, /fields:\s*\["api_key",\s*"base_url"\]/);
36+
});
37+
38+
it('quick preset matcher routes custom gemini-image URLs to the third-party preset', () => {
39+
assert.match(
40+
providerPresetsSource,
41+
/provider\.provider_type === "gemini-image" && provider\.base_url === "https:\/\/generativelanguage\.googleapis\.com\/v1beta"/,
42+
);
43+
assert.match(
44+
providerPresetsSource,
45+
/provider\.provider_type === "gemini-image" && provider\.base_url[\s\S]*gemini-image-thirdparty/,
46+
);
47+
});
48+
});

src/components/settings/provider-presets.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,19 @@ export const QUICK_PRESETS: QuickPreset[] = [
278278
fields: ["api_key"],
279279
category: "media",
280280
},
281+
{
282+
key: "gemini-image-thirdparty",
283+
name: "Google Gemini (Third-party API)",
284+
description: "Gemini image API via a third-party compatible endpoint",
285+
descriptionZh: "通过第三方兼容端点接入 Gemini 图片 API",
286+
icon: <Google size={18} />,
287+
provider_type: "gemini-image",
288+
protocol: "gemini-image",
289+
base_url: "",
290+
extra_env: '{"GEMINI_API_KEY":""}',
291+
fields: ["api_key", "base_url"],
292+
category: "media",
293+
},
281294
];
282295

283296
// ---------------------------------------------------------------------------
@@ -315,6 +328,12 @@ export function findMatchingPreset(provider: ApiProvider): QuickPreset | undefin
315328
if (provider.provider_type === "bedrock") return QUICK_PRESETS.find(p => p.key === "bedrock");
316329
if (provider.provider_type === "vertex") return QUICK_PRESETS.find(p => p.key === "vertex");
317330
if (provider.provider_type === "openrouter") return QUICK_PRESETS.find(p => p.key === "openrouter");
331+
if (provider.provider_type === "gemini-image" && provider.base_url === "https://generativelanguage.googleapis.com/v1beta") {
332+
return QUICK_PRESETS.find(p => p.key === "gemini-image");
333+
}
334+
if (provider.provider_type === "gemini-image" && provider.base_url) {
335+
return QUICK_PRESETS.find(p => p.key === "gemini-image-thirdparty");
336+
}
318337
if (provider.provider_type === "gemini-image") return QUICK_PRESETS.find(p => p.key === "gemini-image");
319338
if (provider.provider_type === "anthropic" && provider.base_url === "https://api.anthropic.com") {
320339
return QUICK_PRESETS.find(p => p.key === "anthropic-official");

src/lib/image-generator.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,32 @@ export interface GenerateSingleImageResult {
2626
elapsedMs: number;
2727
}
2828

29+
interface GeminiImageProviderRecord {
30+
api_key: string;
31+
base_url?: string;
32+
extra_env?: string;
33+
}
34+
35+
export function resolveGeminiImageProviderConfig(provider: GeminiImageProviderRecord): {
36+
apiKey: string;
37+
baseURL?: string;
38+
model: string;
39+
} {
40+
let configuredModel = 'gemini-3.1-flash-image-preview';
41+
try {
42+
const env = JSON.parse(provider.extra_env || '{}');
43+
if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL;
44+
} catch {
45+
// keep default model when extra_env is missing or invalid
46+
}
47+
48+
return {
49+
apiKey: provider.api_key,
50+
baseURL: provider.base_url?.trim() || undefined,
51+
model: configuredModel,
52+
};
53+
}
54+
2955
/**
3056
* Shared image generation function.
3157
* Handles: Provider lookup → Gemini API call → file save → project dir copy → DB record.
@@ -35,25 +61,21 @@ export async function generateSingleImage(params: GenerateSingleImageParams): Pr
3561

3662
const db = getDb();
3763
const provider = db.prepare(
38-
"SELECT api_key, extra_env FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1"
39-
).get() as { api_key: string; extra_env?: string } | undefined;
64+
"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"
65+
).get() as GeminiImageProviderRecord | undefined;
4066

4167
if (!provider) {
4268
throw new Error('No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.');
4369
}
4470

45-
// Read configured model from extra_env, fall back to default
46-
let configuredModel = 'gemini-3.1-flash-image-preview';
47-
try {
48-
const env = JSON.parse(provider.extra_env || '{}');
49-
if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL;
50-
} catch { /* use default */ }
51-
52-
const requestedModel = params.model || configuredModel;
71+
const providerConfig = resolveGeminiImageProviderConfig(provider);
72+
const requestedModel = params.model || providerConfig.model;
5373
const aspectRatio = (params.aspectRatio || '1:1') as `${number}:${number}`;
5474
const imageSize = params.imageSize || '1K';
55-
56-
const google = createGoogleGenerativeAI({ apiKey: provider.api_key });
75+
const google = createGoogleGenerativeAI({
76+
apiKey: providerConfig.apiKey,
77+
baseURL: providerConfig.baseURL,
78+
});
5779

5880
// Build prompt: plain string or { text, images } for reference images
5981
// Combine both base64 data and file paths — both can be provided simultaneously

src/lib/provider-catalog.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,26 @@ export const VENDOR_PRESETS: VendorPreset[] = [
438438
iconKey: 'google',
439439
},
440440

441+
// ── Google Gemini (Third-party API) ──
442+
{
443+
key: 'gemini-image-thirdparty',
444+
name: 'Google Gemini (Third-party API)',
445+
description: 'Gemini image API via a third-party compatible endpoint',
446+
descriptionZh: '通过第三方兼容端点接入 Gemini 图片 API',
447+
protocol: 'gemini-image',
448+
authStyle: 'api_key',
449+
baseUrl: '',
450+
defaultEnvOverrides: { GEMINI_API_KEY: '' },
451+
defaultModels: [
452+
{ modelId: 'gemini-3.1-flash-image-preview', displayName: 'Nano Banana 2' },
453+
{ modelId: 'gemini-3-pro-image-preview', displayName: 'Nano Banana Pro' },
454+
{ modelId: 'gemini-2.5-flash-image', displayName: 'Nano Banana' },
455+
],
456+
fields: ['api_key', 'base_url'],
457+
category: 'media',
458+
iconKey: 'google',
459+
},
460+
441461
// ── Custom API (OpenAI-compatible) ──
442462
{
443463
key: 'custom-openai',
@@ -556,7 +576,11 @@ export function findPresetForLegacy(baseUrl: string, providerType: string, proto
556576
if (providerType === 'bedrock') return VENDOR_PRESETS.find(p => p.key === 'bedrock');
557577
if (providerType === 'vertex') return VENDOR_PRESETS.find(p => p.key === 'vertex');
558578
if (providerType === 'openrouter') return VENDOR_PRESETS.find(p => p.key === 'openrouter');
559-
if (providerType === 'gemini-image') return VENDOR_PRESETS.find(p => p.key === 'gemini-image');
579+
if (providerType === 'gemini-image') {
580+
return VENDOR_PRESETS.find(
581+
p => p.key === (baseUrl === 'https://generativelanguage.googleapis.com/v1beta' ? 'gemini-image' : 'gemini-image-thirdparty'),
582+
);
583+
}
560584
if (providerType === 'anthropic' && baseUrl === 'https://api.anthropic.com') {
561585
return VENDOR_PRESETS.find(p => p.key === 'anthropic-official');
562586
}

0 commit comments

Comments
 (0)