Integrate Cloudflare Workers AI text-to-image into SD extension (#5434) * feat: integrate Cloudflare Workers AI for text-to-image generation in SD extension Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/efc79e4d-2119-4cdb-8afb-f26e318a38ef Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * fix: address review - use oai_settings for account ID, sort dropdown alphabetically, remove Account ID input, move debug log Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/bf0dda38-df40-44f4-8a63-0c952b48905d Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Clean-up diffs * feat: add refresh models button to Workers AI section Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/ab6b5e7a-84d2-44d1-9f6e-3d330de04ef1 Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * fix: revert unrelated package-lock.json changes Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/ab6b5e7a-84d2-44d1-9f6e-3d330de04ef1 Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Fix models loading * refactor: update model refresh button ID and add class to select elements * Send formData to BFL models * fix: adjust use FormData condition * fix: validate Workers AI account ID before proceeding with image model loading --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -99,6 +99,7 @@ const sources = { | ||
| 99 | 99 | google: 'google', |
| 100 | 100 | zai: 'zai', |
| 101 | 101 | openrouter: 'openrouter', |
| 102 | + workersai: 'workersai', | |
| 102 | 103 | }; |
| 103 | 104 | const comfyTypes = { |
| 104 | 105 | standard: 'standard', |
| @@ -1747,6 +1748,9 @@ async function loadSamplers() { | ||
| 1747 | 1748 | case sources.openrouter: |
| 1748 | 1749 | samplers = ['N/A']; |
| 1749 | 1750 | break; |
| 1751 | + case sources.workersai: | |
| 1752 | + samplers = ['N/A']; | |
| 1753 | + break; | |
| 1750 | 1754 | } |
| 1751 | 1755 | |
| 1752 | 1756 | for (const sampler of samplers) { |
| @@ -1997,6 +2001,9 @@ async function loadModels() { | ||
| 1997 | 2001 | case sources.openrouter: |
| 1998 | 2002 | models = await loadOpenRouterModels(); |
| 1999 | 2003 | break; |
| 2004 | + case sources.workersai: | |
| 2005 | + models = await loadWorkersAIImageModels(); | |
| 2006 | + break; | |
| 2000 | 2007 | } |
| 2001 | 2008 | |
| 2002 | 2009 | if (extension_settings.sd.source === sources.electronhub) { |
| @@ -2124,6 +2131,33 @@ async function loadXAIModels() { | ||
| 2124 | 2131 | ]; |
| 2125 | 2132 | } |
| 2126 | 2133 | |
| 2134 | +async function loadWorkersAIImageModels() { | |
| 2135 | + $('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]); | |
| 2136 | + | |
| 2137 | + if (!secret_state[SECRET_KEYS.WORKERS_AI]) { | |
| 2138 | + return []; | |
| 2139 | + } | |
| 2140 | + | |
| 2141 | + if (!oai_settings.workers_ai_account_id) { | |
| 2142 | + toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation'); | |
| 2143 | + return []; | |
| 2144 | + } | |
| 2145 | + | |
| 2146 | + const result = await fetch('/api/sd/workersai/models', { | |
| 2147 | + method: 'POST', | |
| 2148 | + headers: getRequestHeaders(), | |
| 2149 | + body: JSON.stringify({ | |
| 2150 | + account_id: oai_settings.workers_ai_account_id, | |
| 2151 | + }), | |
| 2152 | + }); | |
| 2153 | + | |
| 2154 | + if (result.ok) { | |
| 2155 | + return await result.json(); | |
| 2156 | + } | |
| 2157 | + | |
| 2158 | + return []; | |
| 2159 | +} | |
| 2160 | + | |
| 2127 | 2161 | async function loadPollinationsModels() { |
| 2128 | 2162 | $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]); |
| 2129 | 2163 | |
| @@ -2609,6 +2643,9 @@ async function loadSchedulers() { | ||
| 2609 | 2643 | case sources.openrouter: |
| 2610 | 2644 | schedulers = ['N/A']; |
| 2611 | 2645 | break; |
| 2646 | + case sources.workersai: | |
| 2647 | + schedulers = ['N/A']; | |
| 2648 | + break; | |
| 2612 | 2649 | } |
| 2613 | 2650 | |
| 2614 | 2651 | for (const scheduler of schedulers) { |
| @@ -2729,6 +2766,9 @@ async function loadVaes() { | ||
| 2729 | 2766 | case sources.openrouter: |
| 2730 | 2767 | vaes = ['N/A']; |
| 2731 | 2768 | break; |
| 2769 | + case sources.workersai: | |
| 2770 | + vaes = ['N/A']; | |
| 2771 | + break; | |
| 2732 | 2772 | } |
| 2733 | 2773 | |
| 2734 | 2774 | for (const vae of vaes) { |
| @@ -3432,6 +3472,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3432 | 3472 | case sources.openrouter: |
| 3433 | 3473 | result = await generateOpenRouterImage(prefixedPrompt, signal); |
| 3434 | 3474 | break; |
| 3475 | + case sources.workersai: | |
| 3476 | + result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signal); | |
| 3477 | + break; | |
| 3435 | 3478 | } |
| 3436 | 3479 | |
| 3437 | 3480 | if (!result.data) { |
| @@ -4748,6 +4791,33 @@ async function generateOpenRouterImage(prompt, signal) { | ||
| 4748 | 4791 | throw new Error(text); |
| 4749 | 4792 | } |
| 4750 | 4793 | |
| 4794 | +async function generateWorkersAIImage(prompt, negativePrompt, signal) { | |
| 4795 | + const result = await fetch('/api/sd/workersai/generate', { | |
| 4796 | + method: 'POST', | |
| 4797 | + headers: getRequestHeaders(), | |
| 4798 | + signal: signal, | |
| 4799 | + body: JSON.stringify({ | |
| 4800 | + prompt: prompt, | |
| 4801 | + negative_prompt: negativePrompt, | |
| 4802 | + model: extension_settings.sd.model, | |
| 4803 | + width: extension_settings.sd.width, | |
| 4804 | + height: extension_settings.sd.height, | |
| 4805 | + steps: extension_settings.sd.steps, | |
| 4806 | + scale: extension_settings.sd.scale, | |
| 4807 | + seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined, | |
| 4808 | + account_id: oai_settings.workers_ai_account_id, | |
| 4809 | + }), | |
| 4810 | + }); | |
| 4811 | + | |
| 4812 | + if (result.ok) { | |
| 4813 | + const data = await result.json(); | |
| 4814 | + return { format: data?.format, data: data?.image }; | |
| 4815 | + } else { | |
| 4816 | + const text = await result.text(); | |
| 4817 | + throw new Error(text); | |
| 4818 | + } | |
| 4819 | +} | |
| 4820 | + | |
| 4751 | 4821 | async function onComfyOpenWorkflowEditorClick() { |
| 4752 | 4822 | let workflow = await (await fetch('/api/sd/comfy/workflow', { |
| 4753 | 4823 | method: 'POST', |
| @@ -5120,6 +5190,8 @@ function isValidState() { | ||
| 5120 | 5190 | return secret_state[SECRET_KEYS.ZAI]; |
| 5121 | 5191 | case sources.openrouter: |
| 5122 | 5192 | return secret_state[SECRET_KEYS.OPENROUTER]; |
| 5193 | + case sources.workersai: | |
| 5194 | + return !!oai_settings.workers_ai_account_id && secret_state[SECRET_KEYS.WORKERS_AI]; | |
| 5123 | 5195 | default: |
| 5124 | 5196 | return false; |
| 5125 | 5197 | } |
| @@ -5879,6 +5951,9 @@ export async function init() { | ||
| 5879 | 5951 | extension_settings.sd.google_duration = Number($(this).val()); |
| 5880 | 5952 | saveSettingsDebounced(); |
| 5881 | 5953 | }); |
| 5954 | + $('#sd_models_refresh').on('click', async () => { | |
| 5955 | + await loadModels(); | |
| 5956 | + }); | |
| 5882 | 5957 | $('#sd_electronhub_quality').on('change', function () { |
| 5883 | 5958 | extension_settings.sd.electronhub_quality = String($(this).val()); |
| 5884 | 5959 | saveSettingsDebounced(); |
| @@ -5922,6 +5997,7 @@ export async function init() { | ||
| 5922 | 5997 | [sources.aimlapi]: SECRET_KEYS.AIMLAPI, |
| 5923 | 5998 | [sources.comfy]: SECRET_KEYS.COMFY_RUNPOD, |
| 5924 | 5999 | [sources.pollinations]: SECRET_KEYS.POLLINATIONS, |
| 6000 | + [sources.workersai]: SECRET_KEYS.WORKERS_AI, | |
| 5925 | 6001 | }; |
| 5926 | 6002 | const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key); |
| 5927 | 6003 | if (!shouldReloadOptions) { |
| @@ -40,10 +40,11 @@ | ||
| 40 | 40 | <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span> |
| 41 | 41 | </label> |
| 42 | 42 | <label for="sd_source" data-i18n="Source">Source</label> |
| 43 | 43 | <select id="sd_source" class="text_pole"> |
| 44 | 44 | <option value="aimlapi">AI/ML API</option> |
| 45 | 45 | <option value="bfl">BFL (Black Forest Labs)</option> |
| 46 | 46 | <option value="chutes">Chutes</option> |
| 47 | + <option value="workersai">Cloudflare Workers AI</option> | |
| 47 | 48 | <option value="comfy">ComfyUI</option> |
| 48 | 49 | <option value="drawthings">DrawThings HTTP API</option> |
| 49 | 50 | <option value="electronhub">Electron Hub</option> |
| @@ -123,7 +124,7 @@ | ||
| 123 | 124 | <div class="flex-container" id="sd_electronhub_quality_row"> |
| 124 | 125 | <div class="flex1"> |
| 125 | 126 | <label for="sd_electronhub_quality" data-i18n="Image Quality">Image Quality</label> |
| 126 | 127 | <select id="sd_electronhub_quality" class="text_pole"></select> |
| 127 | 128 | </div> |
| 128 | 129 | </div> |
| 129 | 130 | </div> |
| @@ -191,14 +192,14 @@ | ||
| 191 | 192 | <div class="flex-container"> |
| 192 | 193 | <div data-sd-model="dall-e-3" class="flex1"> |
| 193 | 194 | <label for="sd_openai_style" data-i18n="Image Style">Image Style</label> |
| 194 | 195 | <select id="sd_openai_style" class="text_pole"> |
| 195 | 196 | <option value="vivid">Vivid</option> |
| 196 | 197 | <option value="natural">Natural</option> |
| 197 | 198 | </select> |
| 198 | 199 | </div> |
| 199 | 200 | <div data-sd-model="gpt-image" class="flex1"> |
| 200 | 201 | <label for="sd_openai_quality_gpt" data-i18n="Image Quality">Image Quality</label> |
| 201 | 202 | <select id="sd_openai_quality_gpt" class="text_pole"> |
| 202 | 203 | <option value="auto" data-i18n="Auto">Auto</option> |
| 203 | 204 | <option value="low" data-i18n="Low">Low</option> |
| 204 | 205 | <option value="medium" data-i18n="Medium">Medium</option> |
| @@ -207,7 +208,7 @@ | ||
| 207 | 208 | </div> |
| 208 | 209 | <div data-sd-model="dall-e-3,cogview-4,glm-image,cogvideox" class="flex1"> |
| 209 | 210 | <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label> |
| 210 | 211 | <select id="sd_openai_quality" class="text_pole"> |
| 211 | 212 | <option value="standard" data-i18n="Standard">Standard</option> |
| 212 | 213 | <option value="hd" data-i18n="HD">HD</option> |
| 213 | 214 | </select> |
| @@ -216,7 +217,7 @@ | ||
| 216 | 217 | <div data-sd-model="sora-2,sora-2-pro" class="flex-container"> |
| 217 | 218 | <div class="flex1"> |
| 218 | 219 | <label for="sd_openai_duration" data-i18n="Duration">Duration</label> |
| 219 | 220 | <select id="sd_openai_duration" class="text_pole"> |
| 220 | 221 | <option value="4" data-i18n="Short (4 seconds)">Short (4 seconds)</option> |
| 221 | 222 | <option value="8" data-i18n="Medium (8 seconds)">Medium (8 seconds)</option> |
| 222 | 223 | <option value="12" data-i18n="Long (16 seconds)">Long (12 seconds)</option> |
| @@ -226,7 +227,7 @@ | ||
| 226 | 227 | </div> |
| 227 | 228 | <div data-sd-source="comfy"> |
| 228 | 229 | <label for="sd_comfy_type">Server Type</label> |
| 229 | 230 | <select id="sd_comfy_type" class="text_pole"> |
| 230 | 231 | <option value="standard">Standard Server</option> |
| 231 | 232 | <option value="runpod_serverless">RunPod Serverless Endpoint</option> |
| 232 | 233 | </select> |
| @@ -318,7 +319,7 @@ | ||
| 318 | 319 | <div class="flex-container"> |
| 319 | 320 | <div class="flex1"> |
| 320 | 321 | <label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label> |
| 321 | 322 | <select id="sd_stability_style_preset" class="text_pole"> |
| 322 | 323 | <option value="anime">Anime</option> |
| 323 | 324 | <option value="3d-model">3D Model</option> |
| 324 | 325 | <option value="analog-film">Analog Film</option> |
| @@ -375,6 +376,20 @@ | ||
| 375 | 376 | </div> |
| 376 | 377 | </div> |
| 377 | 378 | |
| 379 | + <div data-sd-source="workersai"> | |
| 380 | + <a href="https://dash.cloudflare.com" target="_blank" rel="noopener noreferrer">Cloudflare Workers AI</a> | |
| 381 | + <div class="flex-container flexnowrap alignItemsBaseline marginBot5"> | |
| 382 | + <strong class="flex1" data-i18n="API Key">API Key</strong> | |
| 383 | + <div id="sd_cf_workers_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_workers_ai"> | |
| 384 | + <i class="fa-fw fa-solid fa-key"></i> | |
| 385 | + <span data-i18n="Click to set">Click to set</span> | |
| 386 | + </div> | |
| 387 | + </div> | |
| 388 | + <div class="flex-container flexnowrap alignItemsBaseline"> | |
| 389 | + <small class="flex1" data-i18n="Hint: Account ID and API key are pulled from API connections.">Hint: Account ID and API key are pulled from API connections.</small> | |
| 390 | + </div> | |
| 391 | + </div> | |
| 392 | + | |
| 378 | 393 | <div data-sd-source="google"> |
| 379 | 394 | <div class="flex-container"> |
| 380 | 395 | <div class="flex1"> |
| @@ -394,7 +409,7 @@ | ||
| 394 | 409 | </label> |
| 395 | 410 | <div class="flex1"> |
| 396 | 411 | <label for="sd_google_duration" data-i18n="Duration (Veo)">Duration (Veo)</label> |
| 397 | 412 | <select id="sd_google_duration" class="text_pole"> |
| 398 | 413 | <option value="4">Short (4 seconds)</option> |
| 399 | 414 | <option value="6">Medium (6 seconds)</option> |
| 400 | 415 | <option value="8">Long (8 seconds)</option> |
| @@ -405,37 +420,42 @@ | ||
| 405 | 420 | |
| 406 | 421 | <div class="flex-container"> |
| 407 | 422 | <div class="flex1"> |
| 408 | 423 | <label for="sd_model" data-i18nclass="Modelflex-container justifySpaceBetween">Model</label> |
| 409 | 424 | <selectspan iddata-i18n="sd_modelModel">Model</selectspan> |
| 425 | + <div id="sd_models_refresh" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list"> | |
| 426 | + <i class="fa-solid fa-sync"></i> | |
| 427 | + </div> | |
| 428 | + </label> | |
| 429 | + <select id="sd_model" class="text_pole"></select> | |
| 410 | 430 | </div> |
| 411 | 431 | |
| 412 | 432 | <div class="flex1" data-sd-source="comfy,auto"> |
| 413 | 433 | <label for="sd_vae">VAE</label> |
| 414 | 434 | <select id="sd_vae" class="text_pole"></select> |
| 415 | 435 | </div> |
| 416 | 436 | </div> |
| 417 | 437 | |
| 418 | 438 | <div class="flex-container"> |
| 419 | 439 | <div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy,sdcpp"> |
| 420 | 440 | <label for="sd_sampler" data-i18n="Sampling method">Sampling method</label> |
| 421 | 441 | <select id="sd_sampler" class="text_pole"></select> |
| 422 | 442 | </div> |
| 423 | 443 | |
| 424 | 444 | <div class="flex1" data-sd-source="comfy,auto,novel,sdcpp"> |
| 425 | 445 | <label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label> |
| 426 | 446 | <select id="sd_scheduler" class="text_pole"></select> |
| 427 | 447 | </div> |
| 428 | 448 | </div> |
| 429 | 449 | |
| 430 | 450 | <div class="flex-container"> |
| 431 | 451 | <div class="flex1"> |
| 432 | 452 | <label for="sd_resolution" data-i18n="Resolution">Resolution</label> |
| 433 | 453 | <select id="sd_resolution" class="text_pole"><!-- Populated in JS --></select> |
| 434 | 454 | </div> |
| 435 | 455 | |
| 436 | 456 | <div class="flex1" data-sd-source="auto,vlad,drawthings"> |
| 437 | 457 | <label for="sd_hr_upscaler" data-i18n="Upscaler">Upscaler</label> |
| 438 | 458 | <select id="sd_hr_upscaler" class="text_pole"></select> |
| 439 | 459 | </div> |
| 440 | 460 | </div> |
| 441 | 461 | |
| @@ -1,7 +1,3 @@ | ||
| 1 | -.sd_settings label:not(.checkbox_label) { | |
| 2 | - display: block; | |
| 3 | -} | |
| 4 | - | |
| 5 | 1 | #sd_dropdown { |
| 6 | 2 | z-index: 30000; |
| 7 | 3 | backdrop-filter: blur(var(--SmartThemeBlurStrength)); |
| @@ -5,7 +5,6 @@ import express from 'express'; | ||
| 5 | 5 | import fetch from 'node-fetch'; |
| 6 | 6 | import sanitize from 'sanitize-filename'; |
| 7 | 7 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 8 | -import FormData from 'form-data'; | |
| 9 | 8 | import urlJoin from 'url-join'; |
| 10 | 9 | import _ from 'lodash'; |
| 11 | 10 | import mime from 'mime-types'; |
| @@ -2031,6 +2030,145 @@ zai.post('/generate-video', async (request, response) => { | ||
| 2031 | 2030 | } |
| 2032 | 2031 | }); |
| 2033 | 2032 | |
| 2033 | +const workersai = express.Router(); | |
| 2034 | + | |
| 2035 | +workersai.post('/models', async (request, response) => { | |
| 2036 | + try { | |
| 2037 | + const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI); | |
| 2038 | + | |
| 2039 | + if (!key) { | |
| 2040 | + console.warn('Cloudflare Workers AI API key not found.'); | |
| 2041 | + return response.sendStatus(400); | |
| 2042 | + } | |
| 2043 | + | |
| 2044 | + const accountId = String(request.body.account_id || '').trim(); | |
| 2045 | + if (!accountId) { | |
| 2046 | + console.warn('Cloudflare Workers AI Account ID not found.'); | |
| 2047 | + return response.sendStatus(400); | |
| 2048 | + } | |
| 2049 | + | |
| 2050 | + const apiUrl = new URL(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/models/search`); | |
| 2051 | + apiUrl.searchParams.set('task', 'Text-to-Image'); | |
| 2052 | + apiUrl.searchParams.set('per_page', '1000'); | |
| 2053 | + const result = await fetch(apiUrl, { | |
| 2054 | + method: 'GET', | |
| 2055 | + headers: { | |
| 2056 | + 'Authorization': `Bearer ${key}`, | |
| 2057 | + }, | |
| 2058 | + }); | |
| 2059 | + | |
| 2060 | + if (!result.ok) { | |
| 2061 | + console.warn('Cloudflare Workers AI returned an error.', result.statusText); | |
| 2062 | + return response.sendStatus(500); | |
| 2063 | + } | |
| 2064 | + | |
| 2065 | + /** @type {any} */ | |
| 2066 | + const data = await result.json(); | |
| 2067 | + | |
| 2068 | + if (!data.success || !Array.isArray(data.result)) { | |
| 2069 | + console.warn('Cloudflare Workers AI returned invalid data.'); | |
| 2070 | + return response.sendStatus(500); | |
| 2071 | + } | |
| 2072 | + | |
| 2073 | + const models = data.result.map(x => ({ value: x.name, text: x.name })); | |
| 2074 | + return response.send(models); | |
| 2075 | + } catch (error) { | |
| 2076 | + console.error(error); | |
| 2077 | + return response.sendStatus(500); | |
| 2078 | + } | |
| 2079 | +}); | |
| 2080 | + | |
| 2081 | +workersai.post('/generate', async (request, response) => { | |
| 2082 | + try { | |
| 2083 | + const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI); | |
| 2084 | + | |
| 2085 | + if (!key) { | |
| 2086 | + console.warn('Cloudflare Workers AI API key not found.'); | |
| 2087 | + return response.sendStatus(400); | |
| 2088 | + } | |
| 2089 | + | |
| 2090 | + const accountId = String(request.body.account_id || '').trim(); | |
| 2091 | + if (!accountId) { | |
| 2092 | + console.warn('Cloudflare Workers AI Account ID not found.'); | |
| 2093 | + return response.sendStatus(400); | |
| 2094 | + } | |
| 2095 | + | |
| 2096 | + const model = String(request.body.model || '').trim(); | |
| 2097 | + if (!model) { | |
| 2098 | + console.warn('Cloudflare Workers AI model not specified.'); | |
| 2099 | + return response.sendStatus(400); | |
| 2100 | + } | |
| 2101 | + | |
| 2102 | + const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`; | |
| 2103 | + | |
| 2104 | + const body = { | |
| 2105 | + prompt: request.body.prompt, | |
| 2106 | + negative_prompt: request.body.negative_prompt || undefined, | |
| 2107 | + width: request.body.width ? Number(request.body.width) : undefined, | |
| 2108 | + height: request.body.height ? Number(request.body.height) : undefined, | |
| 2109 | + num_steps: request.body.steps ? Number(request.body.steps) : undefined, | |
| 2110 | + guidance: request.body.scale ? Number(request.body.scale) : undefined, | |
| 2111 | + seed: request.body.seed >= 0 ? Number(request.body.seed) : undefined, | |
| 2112 | + }; | |
| 2113 | + | |
| 2114 | + // Remove undefined values | |
| 2115 | + for (const prop of Object.keys(body)) { | |
| 2116 | + if (body[prop] === undefined) { | |
| 2117 | + delete body[prop]; | |
| 2118 | + } | |
| 2119 | + } | |
| 2120 | + | |
| 2121 | + console.debug('Cloudflare Workers AI request:', model, body); | |
| 2122 | + | |
| 2123 | + /** @type {import('node-fetch').RequestInit} */ | |
| 2124 | + const apiRequest = { | |
| 2125 | + method: 'POST', | |
| 2126 | + headers: { | |
| 2127 | + 'Authorization': `Bearer ${key}`, | |
| 2128 | + }, | |
| 2129 | + }; | |
| 2130 | + | |
| 2131 | + if (/flux-2/.test(model)) { | |
| 2132 | + const formData = new FormData(); | |
| 2133 | + for (const [key, value] of Object.entries(body)) { | |
| 2134 | + formData.append(key, String(value)); | |
| 2135 | + } | |
| 2136 | + apiRequest.body = formData; | |
| 2137 | + } else { | |
| 2138 | + apiRequest.headers = { ...apiRequest.headers, 'Content-Type': 'application/json' }; | |
| 2139 | + apiRequest.body = JSON.stringify(body); | |
| 2140 | + } | |
| 2141 | + | |
| 2142 | + const result = await fetch(apiUrl, apiRequest); | |
| 2143 | + if (!result.ok) { | |
| 2144 | + const text = await result.text(); | |
| 2145 | + console.warn('Cloudflare Workers AI returned an error.', result.status, result.statusText, text); | |
| 2146 | + return response.status(500).send(text); | |
| 2147 | + } | |
| 2148 | + | |
| 2149 | + const contentType = result.headers.get('content-type') || ''; | |
| 2150 | + | |
| 2151 | + // Partner models return JSON with base64 image | |
| 2152 | + if (contentType.includes('application/json')) { | |
| 2153 | + /** @type {any} */ | |
| 2154 | + const data = await result.json(); | |
| 2155 | + const image = data?.result?.image || data?.image; | |
| 2156 | + if (!image) { | |
| 2157 | + console.warn('Cloudflare Workers AI returned JSON without image data.'); | |
| 2158 | + return response.sendStatus(500); | |
| 2159 | + } | |
| 2160 | + return response.send({ format: 'png', image: image }); | |
| 2161 | + } | |
| 2162 | + | |
| 2163 | + // Non-partner models return raw binary image data | |
| 2164 | + const buffer = await result.arrayBuffer(); | |
| 2165 | + return response.send({ format: 'png', image: Buffer.from(buffer).toString('base64') }); | |
| 2166 | + } catch (error) { | |
| 2167 | + console.error(error); | |
| 2168 | + return response.sendStatus(500); | |
| 2169 | + } | |
| 2170 | +}); | |
| 2171 | + | |
| 2034 | 2172 | router.use('/comfy', comfy); |
| 2035 | 2173 | router.use('/comfyrunpod', comfyRunPod); |
| 2036 | 2174 | router.use('/together', together); |
| @@ -2047,3 +2185,4 @@ router.use('/falai', falai); | ||
| 2047 | 2185 | router.use('/xai', xai); |
| 2048 | 2186 | router.use('/aimlapi', aimlapi); |
| 2049 | 2187 | router.use('/zai', zai); |
| 2188 | +router.use('/workersai', workersai); | |