Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/__tests__/unit/image-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
48 changes: 48 additions & 0 deletions src/__tests__/unit/provider-presets.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
19 changes: 19 additions & 0 deletions src/components/settings/provider-presets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Google size={18} />,
provider_type: "gemini-image",
protocol: "gemini-image",
base_url: "",
extra_env: '{"GEMINI_API_KEY":""}',
fields: ["api_key", "base_url"],
category: "media",
},
];

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
Expand Down
46 changes: 34 additions & 12 deletions src/lib/image-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
26 changes: 25 additions & 1 deletion src/lib/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
}
Expand Down