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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions src/renderer/hooks/useImageModelGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions src/shared/providers/definitions/models/openrouter.test.ts
Original file line number Diff line number Diff line change
@@ -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,/)
})
})
5 changes: 5 additions & 0 deletions src/shared/providers/definitions/models/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export default class OpenRouter extends AbstractAISDKModel {
})
}

protected getImageModel() {
const provider = this.getProvider()
return provider.imageModel(this.options.model.modelId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Forward the Image Creator inputs to OpenRouter

This override only selects the SDK image model, so AbstractAISDKModel.paint() remains the request builder. That method sends only prompt and n: it explicitly drops params.images, and it never passes params.aspectRatio. The Image Creator supplies both fields when the user selects reference images or a non-auto ratio, while the OpenRouter image model supports them (prompt: { text, images } becomes input_references, and aspectRatio becomes aspect_ratio).

As a result, users can make a billable generation that silently ignores the selected reference images and aspect ratio. Please forward both fields, either in the shared base implementation or in an OpenRouter paint() override, and add a request-body test that asserts input_references and aspect_ratio.

}

public async listModels(): Promise<ProviderModelInfo[]> {
return fetchRemoteModels(
{
Expand Down
41 changes: 41 additions & 0 deletions src/shared/providers/definitions/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,47 @@ 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',
},
{
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) => {
Expand Down