OpenRouter: add as image generation source

6fccccca6d7911555aba55e9b555f48ec925e0c5

Cohee <18619528+Cohee1207@users.noreply.github.com>

3 files changed, +169 -42Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+59 -0
@@ -91,6 +91,7 @@ const sources = {
91 xai: 'xai',91 xai: 'xai',
92 google: 'google',92 google: 'google',
93 zai: 'zai',93 zai: 'zai',
94 openrouter: 'openrouter',
94};95};
9596
96const initiators = {97const initiators = {
@@ -1325,6 +1326,7 @@ async function onModelChange() {
1325 sources.google,1326 sources.google,
1326 sources.chutes,1327 sources.chutes,
1327 sources.zai,1328 sources.zai,
1329 sources.openrouter,
1328 ];1330 ];
13291331
1330 if (cloudSources.includes(extension_settings.sd.source)) {1332 if (cloudSources.includes(extension_settings.sd.source)) {
@@ -1561,6 +1563,9 @@ async function loadSamplers() {
1561 case sources.zai:1563 case sources.zai:
1562 samplers = ['N/A'];1564 samplers = ['N/A'];
1563 break;1565 break;
1566 case sources.openrouter:
1567 samplers = ['N/A'];
1568 break;
1564 }1569 }
15651570
1566 for (const sampler of samplers) {1571 for (const sampler of samplers) {
@@ -1769,6 +1774,9 @@ async function loadModels() {
1769 case sources.zai:1774 case sources.zai:
1770 models = await loadZaiModels();1775 models = await loadZaiModels();
1771 break;1776 break;
1777 case sources.openrouter:
1778 models = await loadOpenRouterModels();
1779 break;
1772 }1780 }
17731781
1774 if (extension_settings.sd.source === sources.electronhub) {1782 if (extension_settings.sd.source === sources.electronhub) {
@@ -2253,6 +2261,19 @@ async function loadZaiModels() {
2253 return ['cogview-4-250304'].map(name => ({ value: name, text: name }));2261 return ['cogview-4-250304'].map(name => ({ value: name, text: name }));
2254}2262}
22552263
2264async function loadOpenRouterModels() {
2265 const result = await fetch('/api/openrouter/models/image', {
2266 method: 'POST',
2267 headers: getRequestHeaders({ omitContentType: true }),
2268 });
2269
2270 if (result.ok) {
2271 return await result.json();
2272 }
2273
2274 return [];
2275}
2276
2256function loadNovelSchedulers() {2277function loadNovelSchedulers() {
2257 return ['karras', 'native', 'exponential', 'polyexponential'];2278 return ['karras', 'native', 'exponential', 'polyexponential'];
2258}2279}
@@ -2347,6 +2368,9 @@ async function loadSchedulers() {
2347 case sources.zai:2368 case sources.zai:
2348 schedulers = ['N/A'];2369 schedulers = ['N/A'];
2349 break;2370 break;
2371 case sources.openrouter:
2372 schedulers = ['N/A'];
2373 break;
2350 }2374 }
23512375
2352 for (const scheduler of schedulers) {2376 for (const scheduler of schedulers) {
@@ -2453,6 +2477,9 @@ async function loadVaes() {
2453 case sources.zai:2477 case sources.zai:
2454 vaes = ['N/A'];2478 vaes = ['N/A'];
2455 break;2479 break;
2480 case sources.openrouter:
2481 vaes = ['N/A'];
2482 break;
2456 }2483 }
24572484
2458 for (const vae of vaes) {2485 for (const vae of vaes) {
@@ -3051,6 +3078,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
3051 case sources.zai:3078 case sources.zai:
3052 result = await generateZaiImage(prefixedPrompt, signal);3079 result = await generateZaiImage(prefixedPrompt, signal);
3053 break;3080 break;
3081 case sources.openrouter:
3082 result = await generateOpenRouterImage(prefixedPrompt, signal);
3083 break;
3054 }3084 }
30553085
3056 if (!result.data) {3086 if (!result.data) {
@@ -4161,6 +4191,33 @@ async function generateZaiImage(prompt, signal) {
4161 throw new Error(text);4191 throw new Error(text);
4162}4192}
41634193
4194/**
4195 * Generates an image using the OpenRouter API.
4196 * @param {string} prompt The main instruction used to guide the image generation.
4197 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
4198 * @returns {Promise<{format: string, data: string}>}
4199 */
4200async function generateOpenRouterImage(prompt, signal) {
4201 const result = await fetch('/api/openrouter/image/generate', {
4202 method: 'POST',
4203 headers: getRequestHeaders(),
4204 signal: signal,
4205 body: JSON.stringify({
4206 model: extension_settings.sd.model,
4207 prompt: prompt,
4208 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'stability'),
4209 }),
4210 });
4211
4212 if (result.ok) {
4213 const data = await result.json();
4214 return { format: 'jpg', data: data.image };
4215 }
4216
4217 const text = await result.text();
4218 throw new Error(text);
4219}
4220
4164async function onComfyOpenWorkflowEditorClick() {4221async function onComfyOpenWorkflowEditorClick() {
4165 let workflow = await (await fetch('/api/sd/comfy/workflow', {4222 let workflow = await (await fetch('/api/sd/comfy/workflow', {
4166 method: 'POST',4223 method: 'POST',
@@ -4469,6 +4526,8 @@ function isValidState() {
4469 return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT];4526 return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT];
4470 case sources.zai:4527 case sources.zai:
4471 return secret_state[SECRET_KEYS.ZAI];4528 return secret_state[SECRET_KEYS.ZAI];
4529 case sources.openrouter:
4530 return secret_state[SECRET_KEYS.OPENROUTER];
4472 default:4531 default:
4473 return false;4532 return false;
4474 }4533 }
public/scripts/extensions/stable-diffusion/settings.html+1 -0
@@ -50,6 +50,7 @@
50 <option value="nanogpt">NanoGPT</option>50 <option value="nanogpt">NanoGPT</option>
51 <option value="novel">NovelAI Diffusion</option>51 <option value="novel">NovelAI Diffusion</option>
52 <option value="openai">OpenAI</option>52 <option value="openai">OpenAI</option>
53 <option value="openrouter">OpenRouter</option>
53 <option value="pollinations">Pollinations</option>54 <option value="pollinations">Pollinations</option>
54 <option value="vlad">SD.Next (vladmandic)</option>55 <option value="vlad">SD.Next (vladmandic)</option>
55 <option value="stability">Stability AI</option>56 <option value="stability">Stability AI</option>
src/endpoints/openrouter.js+109 -42
@@ -1,5 +1,7 @@
1import express from 'express';1import express from 'express';
2import fetch from 'node-fetch';2import fetch from 'node-fetch';
3import mime from 'mime-types';
4import { readSecret, SECRET_KEYS } from './secrets.js';
35
4export const router = express.Router();6export const router = express.Router();
5const API_OPENROUTER = 'https://openrouter.ai/api/v1';7const API_OPENROUTER = 'https://openrouter.ai/api/v1';
@@ -30,37 +32,47 @@ router.post('/models/providers', async (req, res) => {
30 }32 }
31});33});
3234
33router.post('/models/multimodal', async (_req, res) => {35/**
34 try {36 * Fetches and filters models from OpenRouter API based on modality criteria.
35 // The endpoint is available without authentication37 * @param {string} endpoint - The API endpoint to fetch from
36 const response = await fetch(`${API_OPENROUTER}/models`, {38 * @param {string} inputModality - Required input modality
37 method: 'GET',39 * @param {string} outputModality - Required output modality
38 headers: {40 * @param {boolean} [idsOnly=false] - Whether to return only model IDs
39 'Accept': 'application/json',41 * @returns {Promise<any[]>} Filtered models or model IDs
40 },42 */
41 });43async function fetchModelsByModality(endpoint, inputModality, outputModality, idsOnly = false) {
44 const response = await fetch(`${API_OPENROUTER}${endpoint}`, {
45 method: 'GET',
46 headers: { 'Accept': 'application/json' },
47 });
48
49 if (!response.ok) {
50 console.warn('OpenRouter API request failed', response.statusText);
51 return [];
52 }
4253
43 if (!response.ok) {54 /** @type {any} */
44 return res.json([]);55 const data = await response.json();
45 }
4656
47 /** @type {any} */57 if (!Array.isArray(data?.data)) {
48 const data = await response.json();58 console.warn('OpenRouter API response was not an array');
59 return [];
60 }
4961
50 if (!Array.isArray(data?.data)) {62 const filtered = data.data
51 console.warn('OpenRouter API response was not an array');63 .filter(m => Array.isArray(m?.architecture?.input_modalities))
52 return res.json([]);64 .filter(m => m.architecture.input_modalities.includes(inputModality))
53 }65 .filter(m => Array.isArray(m?.architecture?.output_modalities))
66 .filter(m => m.architecture.output_modalities.includes(outputModality))
67 .sort((a, b) => a?.id && b?.id ? a.id.localeCompare(b.id) : 0);
5468
55 const multimodalModels = data.data69 return idsOnly ? filtered.map(m => m.id) : filtered;
56 .filter(m => Array.isArray(m?.architecture?.input_modalities))70}
57 .filter(m => m.architecture.input_modalities.includes('image'))
58 .filter(m => Array.isArray(m?.architecture?.output_modalities))
59 .filter(m => m.architecture.output_modalities.includes('text'))
60 .sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id))
61 .map(m => m.id);
6271
63 return res.json(multimodalModels);72router.post('/models/multimodal', async (_req, res) => {
73 try {
74 const models = await fetchModelsByModality('/models', 'image', 'text', true);
75 return res.json(models);
64 } catch (error) {76 } catch (error) {
65 console.error(error);77 console.error(error);
66 return res.sendStatus(500);78 return res.sendStatus(500);
@@ -69,35 +81,90 @@ router.post('/models/multimodal', async (_req, res) => {
6981
70router.post('/models/embedding', async (_req, res) => {82router.post('/models/embedding', async (_req, res) => {
71 try {83 try {
72 // The endpoint is available without authentication84 const models = await fetchModelsByModality('/embeddings/models', 'text', 'embeddings');
73 const response = await fetch(`${API_OPENROUTER}/embeddings/models`, {85 return res.json(models);
74 method: 'GET',86 } catch (error) {
87 console.error(error);
88 return res.sendStatus(500);
89 }
90});
91
92router.post('/models/image', async (_req, res) => {
93 try {
94 const models = await fetchModelsByModality('/models', 'text', 'image');
95 return res.json(models.map(m => ({ value: m.id, text: m.name || m.id })));
96 } catch (error) {
97 console.error(error);
98 return res.sendStatus(500);
99 }
100});
101
102router.post('/image/generate', async (req, res) => {
103 try {
104 const key = readSecret(req.user.directories, SECRET_KEYS.OPENROUTER);
105
106 if (!key) {
107 console.warn('OpenRouter API key not found');
108 return res.status(400).json({ error: 'OpenRouter API key not found' });
109 }
110
111 console.debug('OpenRouter image generation request', req.body);
112
113 const { model, prompt } = req.body;
114
115 if (!model || !prompt) {
116 return res.status(400).json({ error: 'Model and prompt are required' });
117 }
118
119 const response = await fetch(`${API_OPENROUTER}/chat/completions`, {
120 method: 'POST',
75 headers: {121 headers: {
76 'Accept': 'application/json',122 'Content-Type': 'application/json',
123 'Authorization': `Bearer ${key}`,
77 },124 },
125 body: JSON.stringify({
126 model: model,
127 messages: [
128 {
129 role: 'user',
130 content: prompt,
131 },
132 ],
133 modalities: ['image', 'text'],
134 image_config: {
135 aspect_ratio: req.body.aspect_ratio || '1:1',
136 },
137 }),
78 });138 });
79139
80 if (!response.ok) {140 if (!response.ok) {
81 console.warn('OpenRouter API request failed', response.statusText);141 console.warn('OpenRouter image generation failed', await response.text());
82 return res.json([]);142 return res.sendStatus(500);
83 }143 }
84144
85 /** @type {any} */145 /** @type {any} */
86 const data = await response.json();146 const data = await response.json();
87147
88 if (!Array.isArray(data?.data)) {148 const imageUrl = data?.choices?.[0]?.message?.images?.[0]?.image_url?.url;
89 console.warn('OpenRouter API response was not an array');149
90 return res.json([]);150 if (!imageUrl) {
151 console.warn('No image URL found in OpenRouter response', data);
152 return res.sendStatus(500);
153 }
154
155 const [mimeType, base64Data] = /^data:(.*);base64,(.*)$/.exec(imageUrl)?.slice(1) || [];
156
157 if (!mimeType || !base64Data) {
158 console.warn('Invalid image data format', imageUrl);
159 return res.sendStatus(500);
91 }160 }
92161
93 const embeddingModels = data.data162 const result = {
94 .filter(m => Array.isArray(m?.architecture?.input_modalities))163 format: mime.extension(mimeType) || 'png',
95 .filter(m => m.architecture.input_modalities.includes('text'))164 image: base64Data,
96 .filter(m => Array.isArray(m?.architecture?.output_modalities))165 };
97 .filter(m => m.architecture.output_modalities.includes('embeddings'))
98 .sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));
99166
100 return res.json(embeddingModels);167 return res.json(result);
101 } catch (error) {168 } catch (error) {
102 console.error(error);169 console.error(error);
103 return res.sendStatus(500);170 return res.sendStatus(500);