From 6565e0198cb808cd021141ba971b2993d905ac26 Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:41:59 -0700 Subject: [PATCH 1/2] feat: support image generation via OpenRouter's dedicated image API Adds an image-model factory to the OpenRouter provider so the Image Creator can generate images through OpenRouter. Bumps @openrouter/ai-sdk-provider to ^2.10.0, whose OpenRouterImageModel uses the dedicated POST /api/v1/images endpoint (image-generation requests were previously routed through /chat/completions). - OpenRouter model class: implement getImageModel() so paint() works - Provider defaults: list current image-capable OpenRouter models - useImageModelGroups: surface OpenRouter image models (default + user-added type=image models) in the Image Creator model picker --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/renderer/hooks/useImageModelGroups.ts | 19 ++++ .../definitions/models/openrouter.test.ts | 103 ++++++++++++++++++ .../definitions/models/openrouter.ts | 5 + .../providers/definitions/openrouter.ts | 16 +++ 6 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/shared/providers/definitions/models/openrouter.test.ts diff --git a/package.json b/package.json index 6f23871149..3224ca7ca9 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@mantine/spotlight": "^7.17.7", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.11", - "@openrouter/ai-sdk-provider": "^2.0.0", + "@openrouter/ai-sdk-provider": "^2.10.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@radix-ui/react-dialog": "^1.0.5", "@sentry/react": "^10.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32c39bb65..b6819196ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,8 +267,8 @@ importers: specifier: ^5.11.11 version: 5.18.0(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(@types/react@18.3.27)(react@18.3.1))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@openrouter/ai-sdk-provider': - specifier: ^2.0.0 - version: 2.1.1(ai@6.0.67(zod@4.3.6))(zod@4.3.6) + specifier: ^2.10.0 + version: 2.10.0(ai@6.0.67(zod@4.3.6))(zod@4.3.6) '@pmmmwh/react-refresh-webpack-plugin': specifier: ^0.5.10 version: 0.5.17(react-refresh@0.14.2)(type-fest@4.41.0)(webpack-dev-server@4.15.2)(webpack@5.104.1) @@ -2921,8 +2921,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@openrouter/ai-sdk-provider@2.1.1': - resolution: {integrity: sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ==} + '@openrouter/ai-sdk-provider@2.10.0': + resolution: {integrity: sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==} engines: {node: '>=18'} peerDependencies: ai: ^6.0.0 @@ -16439,7 +16439,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.67(zod@4.3.6))(zod@4.3.6)': + '@openrouter/ai-sdk-provider@2.10.0(ai@6.0.67(zod@4.3.6))(zod@4.3.6)': dependencies: ai: 6.0.67(zod@4.3.6) zod: 4.3.6 diff --git a/src/renderer/hooks/useImageModelGroups.ts b/src/renderer/hooks/useImageModelGroups.ts index 662f780628..d56d94f896 100644 --- a/src/renderer/hooks/useImageModelGroups.ts +++ b/src/renderer/hooks/useImageModelGroups.ts @@ -94,6 +94,7 @@ export function useImageModelGroups(): ImageModelGroup[] { const chatboxProvider = providers.find((p) => p.id === ModelProviderEnum.ChatboxAI) const openAIProvider = providers.find((p) => p.id === ModelProviderEnum.OpenAI) const geminiProvider = providers.find((p) => p.id === ModelProviderEnum.Gemini) + const openRouterProvider = providers.find((p) => p.id === ModelProviderEnum.OpenRouter) const customGeminiProviders = providers.filter((p) => p.isCustom && p.type === ModelProviderType.Gemini) const openAIImageModels = useProviderImageModels(ModelProviderEnum.OpenAI, !!openAIProvider) @@ -161,11 +162,29 @@ export function useImageModelGroups(): ImageModelGroup[] { } } + if (openRouterProvider) { + const defaultModels = (openRouterProvider.defaultSettings?.models || []) + .filter((model) => model.type === 'image') + .map(providerModelToOption) + const manualModels = (providerSettingsMap?.[openRouterProvider.id]?.models || []) + .filter((model) => model.type === 'image') + .map(manualImageModelToOption) + const models = mergeImageModels(defaultModels, manualModels) + if (models.length > 0) { + groups.push({ + label: openRouterProvider.name, + providerId: openRouterProvider.id, + models, + }) + } + } + return groups }, [ chatboxProvider, openAIProvider, geminiProvider, + openRouterProvider, customGeminiProviders, providerSettingsMap, chatboxAIImageModels, diff --git a/src/shared/providers/definitions/models/openrouter.test.ts b/src/shared/providers/definitions/models/openrouter.test.ts new file mode 100644 index 0000000000..a747aa3a8c --- /dev/null +++ b/src/shared/providers/definitions/models/openrouter.test.ts @@ -0,0 +1,103 @@ +import type { ProviderModelInfo } from 'src/shared/types' +import type { ModelDependencies } from 'src/shared/types/adapters' +import type { SentryScope } from 'src/shared/utils/sentry_adapter' +import { afterEach, describe, expect, it, vi } from 'vitest' +import OpenRouter from './openrouter' + +const mockScope: SentryScope = { + setTag: vi.fn(), + setExtra: vi.fn(), +} + +function createDependencies(): ModelDependencies { + return { + request: { + fetchWithOptions: vi.fn(), + apiRequest: vi.fn(), + }, + storage: { + saveImage: vi.fn(), + getImage: vi.fn(), + }, + sentry: { + captureException: vi.fn(), + withScope: vi.fn((callback: (scope: SentryScope) => void) => callback(mockScope)), + }, + getRemoteConfig: vi.fn(), + platformType: 'desktop', + oauth: { + refreshCredential: vi.fn(), + persistCredential: vi.fn(), + clearCredential: vi.fn(), + }, + } +} + +function createModel(modelId: string, type?: ProviderModelInfo['type']) { + const model: ProviderModelInfo = { + modelId, + type, + } + return new OpenRouter( + { + apiKey: 'test-api-key', + model, + }, + createDependencies() + ) +} + +// A 1x1 transparent PNG +const TEST_IMAGE_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + +describe('OpenRouter image generation', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should generate images via the dedicated /images endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + created: 1700000000, + data: [{ b64_json: TEST_IMAGE_BASE64 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const model = createModel('google/gemini-2.5-flash-image', 'image') + const results = await model.paint({ prompt: 'a red apple', num: 1 }) + + expect(results).toHaveLength(1) + expect(results[0]).toContain(`base64,${TEST_IMAGE_BASE64}`) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe('https://openrouter.ai/api/v1/images') + const body = JSON.parse(init.body as string) + expect(body.model).toBe('google/gemini-2.5-flash-image') + expect(body.prompt).toBe('a red apple') + }) + + it('should invoke the progressive callback with data urls', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ b64_json: TEST_IMAGE_BASE64 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + const model = createModel('google/gemini-2.5-flash-image', 'image') + const callback = vi.fn() + await model.paint({ prompt: 'a red apple', num: 1 }, undefined, callback) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][0]).toMatch(/^data:image\/png;base64,/) + }) +}) diff --git a/src/shared/providers/definitions/models/openrouter.ts b/src/shared/providers/definitions/models/openrouter.ts index 8c442f00d8..1afcfb0034 100644 --- a/src/shared/providers/definitions/models/openrouter.ts +++ b/src/shared/providers/definitions/models/openrouter.ts @@ -50,6 +50,11 @@ export default class OpenRouter extends AbstractAISDKModel { }) } + protected getImageModel() { + const provider = this.getProvider() + return provider.imageModel(this.options.model.modelId) + } + public async listModels(): Promise { return fetchRemoteModels( { diff --git a/src/shared/providers/definitions/openrouter.ts b/src/shared/providers/definitions/openrouter.ts index be7da4eff1..c8fb2b079e 100644 --- a/src/shared/providers/definitions/openrouter.ts +++ b/src/shared/providers/definitions/openrouter.ts @@ -168,6 +168,22 @@ export const openRouterProvider = defineProvider({ contextWindow: 32_800, maxOutput: 32_800, }, + // --- Image generation models --- + { + modelId: 'google/gemini-3.1-flash-image', + nickname: 'Nano Banana 2 (Gemini 3.1 Flash Image)', + type: 'image', + }, + { + modelId: 'google/gemini-3-pro-image', + nickname: 'Nano Banana Pro (Gemini 3 Pro Image)', + type: 'image', + }, + { + modelId: 'openai/gpt-5.4-image-2', + nickname: 'GPT-5.4 Image 2', + type: 'image', + }, ], }, createModel: (config) => { From d2b5f4814430a6bd7be216e75b5d7412d7f3a5df Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:31:01 -0700 Subject: [PATCH 2/2] feat: expand default OpenRouter image models Broaden the out-of-box image model set from 3 to 8 flagship models spanning the major image providers on OpenRouter (Google, OpenAI, ByteDance, Black Forest Labs, Sourceful, Recraft). Users can still add any other OpenRouter image model manually via provider settings. --- .../providers/definitions/openrouter.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/shared/providers/definitions/openrouter.ts b/src/shared/providers/definitions/openrouter.ts index c8fb2b079e..e5b53c777f 100644 --- a/src/shared/providers/definitions/openrouter.ts +++ b/src/shared/providers/definitions/openrouter.ts @@ -184,6 +184,31 @@ export const openRouterProvider = defineProvider({ nickname: 'GPT-5.4 Image 2', type: 'image', }, + { + modelId: 'openai/gpt-image-1', + nickname: 'GPT Image 1', + type: 'image', + }, + { + modelId: 'bytedance-seed/seedream-4.5', + nickname: 'Seedream 4.5', + type: 'image', + }, + { + modelId: 'black-forest-labs/flux.2-pro', + nickname: 'FLUX.2 Pro', + type: 'image', + }, + { + modelId: 'sourceful/riverflow-v2.5-pro', + nickname: 'Riverflow V2.5 Pro', + type: 'image', + }, + { + modelId: 'recraft/recraft-v4', + nickname: 'Recraft V4', + type: 'image', + }, ], }, createModel: (config) => {