OpenRouter: add as image generation source
| @@ -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 | }; |
| 95 | 96 | ||
| 96 | const initiators = { | 97 | const 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 | ]; |
| 1329 | 1331 | ||
| 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 | } |
| 1565 | 1570 | ||
| 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 | } |
| 1773 | 1781 | ||
| 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 | } |
| 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 | function loadNovelSchedulers() { | 2277 | function 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 | } |
| 2351 | 2375 | ||
| 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 | } |
| 2457 | 2484 | ||
| 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 | } |
| 3055 | 3085 | ||
| 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 | } |
| 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 | async function onComfyOpenWorkflowEditorClick() { | 4221 | async 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 | } |
| @@ -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> |
| @@ -1,5 +1,7 @@ | |||
| 1 | import express from 'express'; | 1 | import express from 'express'; |
| 2 | import fetch from 'node-fetch'; | 2 | import fetch from 'node-fetch'; |
| 3 | import mime from 'mime-types'; | ||
| 4 | import { readSecret, SECRET_KEYS } from './secrets.js'; | ||
| 3 | 5 | ||
| 4 | export const router = express.Router(); | 6 | export const router = express.Router(); |
| 5 | const API_OPENROUTER = 'https://openrouter.ai/api/v1'; | 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 | method: 'GET', | 45 | method: 'GET', |
| 38 | headers: { | 46 | headers: { 'Accept': 'application/json' }, |
| 39 | 'Accept': 'application/json', | ||
| 40 | }, | ||
| 41 | }); | 47 | }); |
| 42 | 48 | ||
| 43 | if (!response.ok) { | 49 | if (!response.ok) { |
| 44 | return res.json([]); | 50 | console.warn('OpenRouter API request failed', response.statusText); |
| 51 | return []; | ||
| 45 | } | 52 | } |
| 46 | 53 | ||
| 47 | /** @type {any} */ | 54 | /** @type {any} */ |
| @@ -49,18 +56,23 @@ router.post('/models/multimodal', async (_req, res) => { | |||
| 49 | 56 | ||
| 50 | if (!Array.isArray(data?.data)) { | 57 | if (!Array.isArray(data?.data)) { |
| 51 | console.warn('OpenRouter API response was not an array'); | 58 | console.warn('OpenRouter API response was not an array'); |
| 52 | return res.json([]); | 59 | return []; |
| 53 | } | 60 | } |
| 54 | 61 | ||
| 55 | const multimodalModels = data.data | 62 | const filtered = data.data |
| 56 | .filter(m => Array.isArray(m?.architecture?.input_modalities)) | 63 | .filter(m => Array.isArray(m?.architecture?.input_modalities)) |
| 57 | .filter(m => m.architecture.input_modalities.includes('image')) | 64 | .filter(m => m.architecture.input_modalities.includes(inputModality)) |
| 58 | .filter(m => Array.isArray(m?.architecture?.output_modalities)) | 65 | .filter(m => Array.isArray(m?.architecture?.output_modalities)) |
| 59 | .filter(m => m.architecture.output_modalities.includes('text')) | 66 | .filter(m => m.architecture.output_modalities.includes(outputModality)) |
| 60 | .sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id)) | 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 | } 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) => { | |||
| 69 | 81 | ||
| 70 | router.post('/models/embedding', async (_req, res) => { | 82 | router.post('/models/embedding', async (_req, res) => { |
| 71 | try { | 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 | headers: { | 121 | headers: { |
| 76 | 'Accept': 'application/json', | 122 | 'Content-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 | 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 | } |
| 84 | 144 | ||
| 85 | /** @type {any} */ | 145 | /** @type {any} */ |
| 86 | const data = await response.json(); | 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 | 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); |