OpenRouter: add as image generation source
| @@ -91,6 +91,7 @@ const sources = { | ||
| 91 | 91 | xai: 'xai', |
| 92 | 92 | google: 'google', |
| 93 | 93 | zai: 'zai', |
| 94 | + openrouter: 'openrouter', | |
| 94 | 95 | }; |
| 95 | 96 | |
| 96 | 97 | const initiators = { |
| @@ -1325,6 +1326,7 @@ async function onModelChange() { | ||
| 1325 | 1326 | sources.google, |
| 1326 | 1327 | sources.chutes, |
| 1327 | 1328 | sources.zai, |
| 1329 | + sources.openrouter, | |
| 1328 | 1330 | ]; |
| 1329 | 1331 | |
| 1330 | 1332 | if (cloudSources.includes(extension_settings.sd.source)) { |
| @@ -1561,6 +1563,9 @@ async function loadSamplers() { | ||
| 1561 | 1563 | case sources.zai: |
| 1562 | 1564 | samplers = ['N/A']; |
| 1563 | 1565 | break; |
| 1566 | + case sources.openrouter: | |
| 1567 | + samplers = ['N/A']; | |
| 1568 | + break; | |
| 1564 | 1569 | } |
| 1565 | 1570 | |
| 1566 | 1571 | for (const sampler of samplers) { |
| @@ -1769,6 +1774,9 @@ async function loadModels() { | ||
| 1769 | 1774 | case sources.zai: |
| 1770 | 1775 | models = await loadZaiModels(); |
| 1771 | 1776 | break; |
| 1777 | + case sources.openrouter: | |
| 1778 | + models = await loadOpenRouterModels(); | |
| 1779 | + break; | |
| 1772 | 1780 | } |
| 1773 | 1781 | |
| 1774 | 1782 | if (extension_settings.sd.source === sources.electronhub) { |
| @@ -2253,6 +2261,19 @@ async function loadZaiModels() { | ||
| 2253 | 2261 | return ['cogview-4-250304'].map(name => ({ value: name, text: name })); |
| 2254 | 2262 | } |
| 2255 | 2263 | |
| 2264 | +async 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 | + | |
| 2256 | 2277 | function loadNovelSchedulers() { |
| 2257 | 2278 | return ['karras', 'native', 'exponential', 'polyexponential']; |
| 2258 | 2279 | } |
| @@ -2347,6 +2368,9 @@ async function loadSchedulers() { | ||
| 2347 | 2368 | case sources.zai: |
| 2348 | 2369 | schedulers = ['N/A']; |
| 2349 | 2370 | break; |
| 2371 | + case sources.openrouter: | |
| 2372 | + schedulers = ['N/A']; | |
| 2373 | + break; | |
| 2350 | 2374 | } |
| 2351 | 2375 | |
| 2352 | 2376 | for (const scheduler of schedulers) { |
| @@ -2453,6 +2477,9 @@ async function loadVaes() { | ||
| 2453 | 2477 | case sources.zai: |
| 2454 | 2478 | vaes = ['N/A']; |
| 2455 | 2479 | break; |
| 2480 | + case sources.openrouter: | |
| 2481 | + vaes = ['N/A']; | |
| 2482 | + break; | |
| 2456 | 2483 | } |
| 2457 | 2484 | |
| 2458 | 2485 | for (const vae of vaes) { |
| @@ -3051,6 +3078,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3051 | 3078 | case sources.zai: |
| 3052 | 3079 | result = await generateZaiImage(prefixedPrompt, signal); |
| 3053 | 3080 | break; |
| 3081 | + case sources.openrouter: | |
| 3082 | + result = await generateOpenRouterImage(prefixedPrompt, signal); | |
| 3083 | + break; | |
| 3054 | 3084 | } |
| 3055 | 3085 | |
| 3056 | 3086 | if (!result.data) { |
| @@ -4161,6 +4191,33 @@ async function generateZaiImage(prompt, signal) { | ||
| 4161 | 4191 | throw new Error(text); |
| 4162 | 4192 | } |
| 4163 | 4193 | |
| 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 | + */ | |
| 4200 | +async 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 | + | |
| 4164 | 4221 | async function onComfyOpenWorkflowEditorClick() { |
| 4165 | 4222 | let workflow = await (await fetch('/api/sd/comfy/workflow', { |
| 4166 | 4223 | method: 'POST', |
| @@ -4469,6 +4526,8 @@ function isValidState() { | ||
| 4469 | 4526 | return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]; |
| 4470 | 4527 | case sources.zai: |
| 4471 | 4528 | return secret_state[SECRET_KEYS.ZAI]; |
| 4529 | + case sources.openrouter: | |
| 4530 | + return secret_state[SECRET_KEYS.OPENROUTER]; | |
| 4472 | 4531 | default: |
| 4473 | 4532 | return false; |
| 4474 | 4533 | } |
| @@ -50,6 +50,7 @@ | ||
| 50 | 50 | <option value="nanogpt">NanoGPT</option> |
| 51 | 51 | <option value="novel">NovelAI Diffusion</option> |
| 52 | 52 | <option value="openai">OpenAI</option> |
| 53 | + <option value="openrouter">OpenRouter</option> | |
| 53 | 54 | <option value="pollinations">Pollinations</option> |
| 54 | 55 | <option value="vlad">SD.Next (vladmandic)</option> |
| 55 | 56 | <option value="stability">Stability AI</option> |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import express from 'express'; |
| 2 | 2 | import fetch from 'node-fetch'; |
| 3 | +import mime from 'mime-types'; | |
| 4 | +import { readSecret, SECRET_KEYS } from './secrets.js'; | |
| 3 | 5 | |
| 4 | 6 | export const router = express.Router(); |
| 5 | 7 | const API_OPENROUTER = 'https://openrouter.ai/api/v1'; |
| @@ -30,18 +32,23 @@ router.post('/models/providers', async (req, res) => { | ||
| 30 | 32 | } |
| 31 | 33 | }); |
| 32 | 34 | |
| 33 | -router.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 authentication | |
| 37 | + * @param {string} endpoint - The API endpoint to fetch from | |
| 36 | - const response = await fetch(`${API_OPENROUTER}/models`, { | |
| 38 | + * @param {string} inputModality - Required input modality | |
| 39 | + * @param {string} outputModality - Required output modality | |
| 40 | + * @param {boolean} [idsOnly=false] - Whether to return only model IDs | |
| 41 | + * @returns {Promise<any[]>} Filtered models or model IDs | |
| 42 | + */ | |
| 43 | +async function fetchModelsByModality(endpoint, inputModality, outputModality, idsOnly = false) { | |
| 44 | + const response = await fetch(`${API_OPENROUTER}${endpoint}`, { | |
| 37 | 45 | method: 'GET', |
| 38 | - headers: { | |
| 46 | + headers: { 'Accept': 'application/json' }, | |
| 39 | - 'Accept': 'application/json', | |
| 40 | - }, | |
| 41 | 47 | }); |
| 42 | 48 | |
| 43 | 49 | if (!response.ok) { |
| 44 | - return res.json([]); | |
| 50 | + console.warn('OpenRouter API request failed', response.statusText); | |
| 51 | + return []; | |
| 45 | 52 | } |
| 46 | 53 | |
| 47 | 54 | /** @type {any} */ |
| @@ -49,18 +56,23 @@ router.post('/models/multimodal', async (_req, res) => { | ||
| 49 | 56 | |
| 50 | 57 | if (!Array.isArray(data?.data)) { |
| 51 | 58 | console.warn('OpenRouter API response was not an array'); |
| 52 | 59 | return res.json([]); |
| 53 | 60 | } |
| 54 | 61 | |
| 55 | 62 | const multimodalModelsfiltered = data.data |
| 56 | 63 | .filter(m => Array.isArray(m?.architecture?.input_modalities)) |
| 57 | 64 | .filter(m => m.architecture.input_modalities.includes('image'inputModality)) |
| 58 | 65 | .filter(m => Array.isArray(m?.architecture?.output_modalities)) |
| 59 | 66 | .filter(m => m.architecture.output_modalities.includes('text'outputModality)) |
| 60 | 67 | .sort((a, b) => a?.id && b?.id &&? a.id.localeCompare(b.id) : 0); |
| 61 | - .map(m => m.id); | |
| 68 | + | |
| 69 | + return idsOnly ? filtered.map(m => m.id) : filtered; | |
| 70 | +} | |
| 62 | 71 | |
| 63 | - return res.json(multimodalModels); | |
| 72 | +router.post('/models/multimodal', async (_req, res) => { | |
| 73 | + try { | |
| 74 | + const models = await fetchModelsByModality('/models', 'image', 'text', true); | |
| 75 | + return res.json(models); | |
| 64 | 76 | } catch (error) { |
| 65 | 77 | console.error(error); |
| 66 | 78 | return res.sendStatus(500); |
| @@ -69,35 +81,90 @@ router.post('/models/multimodal', async (_req, res) => { | ||
| 69 | 81 | |
| 70 | 82 | router.post('/models/embedding', async (_req, res) => { |
| 71 | 83 | try { |
| 72 | - // The endpoint is available without authentication | |
| 84 | + 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 | + | |
| 92 | +router.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 | + | |
| 102 | +router.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 | 121 | headers: { |
| 76 | 122 | 'AcceptContent-Type': 'application/json', |
| 123 | + 'Authorization': `Bearer ${key}`, | |
| 124 | + }, | |
| 125 | + body: JSON.stringify({ | |
| 126 | + model: model, | |
| 127 | + messages: [ | |
| 128 | + { | |
| 129 | + role: 'user', | |
| 130 | + content: prompt, | |
| 77 | 131 | }, |
| 132 | + ], | |
| 133 | + modalities: ['image', 'text'], | |
| 134 | + image_config: { | |
| 135 | + aspect_ratio: req.body.aspect_ratio || '1:1', | |
| 136 | + }, | |
| 137 | + }), | |
| 78 | 138 | }); |
| 79 | 139 | |
| 80 | 140 | if (!response.ok) { |
| 81 | 141 | console.warn('OpenRouter APIimage requestgeneration failed', await response.statusTexttext()); |
| 82 | 142 | return res.jsonsendStatus([]500); |
| 83 | 143 | } |
| 84 | 144 | |
| 85 | 145 | /** @type {any} */ |
| 86 | 146 | const data = await response.json(); |
| 87 | 147 | |
| 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); | |
| 91 | 153 | } |
| 92 | 154 | |
| 93 | - const embeddingModels = data.data | |
| 155 | + const [mimeType, base64Data] = /^data:(.*);base64,(.*)$/.exec(imageUrl)?.slice(1) || []; | |
| 94 | - .filter(m => Array.isArray(m?.architecture?.input_modalities)) | |
| 156 | + | |
| 95 | - .filter(m => m.architecture.input_modalities.includes('text')) | |
| 157 | + if (!mimeType || !base64Data) { | |
| 96 | - .filter(m => Array.isArray(m?.architecture?.output_modalities)) | |
| 158 | + console.warn('Invalid image data format', imageUrl); | |
| 97 | - .filter(m => m.architecture.output_modalities.includes('embeddings')) | |
| 159 | + return res.sendStatus(500); | |
| 98 | - .sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id)); | |
| 160 | + } | |
| 161 | + | |
| 162 | + const result = { | |
| 163 | + format: mime.extension(mimeType) || 'png', | |
| 164 | + image: base64Data, | |
| 165 | + }; | |
| 99 | 166 | |
| 100 | 167 | return res.json(embeddingModelsresult); |
| 101 | 168 | } catch (error) { |
| 102 | 169 | console.error(error); |
| 103 | 170 | return res.sendStatus(500); |