Feat : Skills
This commit is contained in:
31
open-sse/handlers/imageProviders/_base.js
Normal file
31
open-sse/handlers/imageProviders/_base.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Shared helpers for image provider adapters
|
||||
|
||||
export const POLL_INTERVAL_MS = 1500;
|
||||
export const POLL_TIMEOUT_MS = 120000;
|
||||
|
||||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Map OpenAI size to provider-specific aspect ratio
|
||||
export function sizeToAspectRatio(size) {
|
||||
if (!size || typeof size !== "string") return "1:1";
|
||||
const map = {
|
||||
"1024x1024": "1:1",
|
||||
"1024x1792": "9:16",
|
||||
"1792x1024": "16:9",
|
||||
"1024x1536": "2:3",
|
||||
"1536x1024": "3:2",
|
||||
};
|
||||
return map[size] || "1:1";
|
||||
}
|
||||
|
||||
// Fetch URL → base64 (for providers returning image URLs)
|
||||
export async function urlToBase64(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
return Buffer.from(buf).toString("base64");
|
||||
}
|
||||
|
||||
export function nowSec() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
43
open-sse/handlers/imageProviders/blackForestLabs.js
Normal file
43
open-sse/handlers/imageProviders/blackForestLabs.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// Black Forest Labs (FLUX) — async submit + polling_url
|
||||
import { sleep, nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://api.bfl.ai/v1";
|
||||
|
||||
export default {
|
||||
async: true,
|
||||
buildUrl: (model) => `${BASE_URL}/${model}`,
|
||||
buildHeaders: (creds) => {
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
return { "Content-Type": "application/json", "x-key": key };
|
||||
},
|
||||
buildBody: (_model, body) => {
|
||||
const req = { prompt: body.prompt };
|
||||
if (body.size) {
|
||||
const [w, h] = body.size.split("x").map(Number);
|
||||
if (w) req.width = w;
|
||||
if (h) req.height = h;
|
||||
}
|
||||
if (body.image) req.image_prompt = body.image;
|
||||
return req;
|
||||
},
|
||||
async parseResponse(response, { headers }) {
|
||||
const data = await response.json();
|
||||
const pollingUrl = data.polling_url;
|
||||
if (!pollingUrl) throw new Error("BFL: no polling_url returned");
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const r = await fetch(pollingUrl, { headers: { "x-key": headers["x-key"], "Accept": "application/json" } });
|
||||
if (!r.ok) throw new Error(`BFL status ${r.status}`);
|
||||
const s = await r.json();
|
||||
if (s.status === "Ready") return s;
|
||||
if (s.status === "Error" || s.status === "Failed") throw new Error(s.error || "BFL generation failed");
|
||||
}
|
||||
throw new Error("BFL polling timeout");
|
||||
},
|
||||
normalize: (responseBody) => {
|
||||
const sample = responseBody.result?.sample;
|
||||
if (sample) return { created: nowSec(), data: [{ url: sample }] };
|
||||
return { created: nowSec(), data: [] };
|
||||
},
|
||||
};
|
||||
198
open-sse/handlers/imageProviders/codex.js
Normal file
198
open-sse/handlers/imageProviders/codex.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { nowSec } from "./_base.js";
|
||||
|
||||
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
const CODEX_USER_AGENT = "codex-imagen/0.2.6";
|
||||
const CODEX_VERSION = "0.122.0";
|
||||
const CODEX_ORIGINATOR = "codex_cli_rs";
|
||||
const CODEX_MODEL_SUFFIX = "-image";
|
||||
const CODEX_REF_DETAIL = "high";
|
||||
|
||||
function decodeAccountId(idToken) {
|
||||
try {
|
||||
const parts = String(idToken || "").split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = (4 - (b64.length % 4)) % 4;
|
||||
const payload = JSON.parse(Buffer.from(b64 + "=".repeat(pad), "base64").toString("utf8"));
|
||||
return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripImageSuffix(model) {
|
||||
return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model;
|
||||
}
|
||||
|
||||
function toDataUrl(input) {
|
||||
if (!input || typeof input !== "string") return null;
|
||||
if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input;
|
||||
return `data:image/png;base64,${input}`;
|
||||
}
|
||||
|
||||
function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) {
|
||||
const content = [];
|
||||
refs.forEach((url, index) => {
|
||||
content.push({ type: "input_text", text: `<image name=image${index + 1}>` });
|
||||
content.push({ type: "input_image", image_url: url, detail });
|
||||
content.push({ type: "input_text", text: "</image>" });
|
||||
});
|
||||
content.push({ type: "input_text", text: prompt });
|
||||
return content;
|
||||
}
|
||||
|
||||
// Parse Codex SSE stream → final base64 image. Optional callbacks for client streaming.
|
||||
async function parseStream(response, log, callbacks = {}) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let imageB64 = null;
|
||||
let lastEvent = null;
|
||||
let bytesReceived = 0;
|
||||
let lastProgressLogMs = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytesReceived += value?.byteLength || 0;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let sepIdx;
|
||||
while ((sepIdx = buffer.indexOf("\n\n")) !== -1) {
|
||||
const block = buffer.slice(0, sepIdx);
|
||||
buffer = buffer.slice(sepIdx + 2);
|
||||
|
||||
const lines = block.split("\n");
|
||||
let eventName = null;
|
||||
let dataStr = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) eventName = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
|
||||
}
|
||||
if (!eventName) continue;
|
||||
if (eventName !== lastEvent) {
|
||||
log?.info?.("IMAGE", `codex progress: ${eventName}`);
|
||||
lastEvent = eventName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (callbacks.onProgress && now - lastProgressLogMs > 200) {
|
||||
lastProgressLogMs = now;
|
||||
callbacks.onProgress({ stage: eventName, bytesReceived });
|
||||
}
|
||||
|
||||
if (eventName === "response.image_generation_call.partial_image" && dataStr) {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (callbacks.onPartialImage && data?.partial_image_b64) {
|
||||
callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (eventName === "response.output_item.done" && dataStr) {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
const item = data?.item;
|
||||
if (item?.type === "image_generation_call" && item.result) {
|
||||
imageB64 = item.result;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageB64;
|
||||
}
|
||||
|
||||
// SSE Response that pipes codex progress + partial + done events to client
|
||||
function buildSseResponse(providerResponse, log, onSuccess) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (event, data) => {
|
||||
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
try {
|
||||
const b64 = await parseStream(providerResponse, log, {
|
||||
onProgress: (info) => send("progress", info),
|
||||
onPartialImage: (info) => send("partial_image", info),
|
||||
});
|
||||
if (!b64) {
|
||||
send("error", { message: "Codex did not return an image. Account may not be entitled (Plus/Pro required)." });
|
||||
} else {
|
||||
if (onSuccess) await onSuccess();
|
||||
send("done", { created: nowSec(), data: [{ b64_json: b64 }] });
|
||||
}
|
||||
} catch (err) {
|
||||
send("error", { message: err?.message || "Stream failed" });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
stream: true,
|
||||
buildUrl: () => CODEX_RESPONSES_URL,
|
||||
buildHeaders: (creds) => {
|
||||
const accountId = creds?.providerSpecificData?.chatgptAccountId || decodeAccountId(creds?.idToken);
|
||||
return {
|
||||
"accept": "text/event-stream, application/json",
|
||||
"authorization": `Bearer ${creds?.accessToken || ""}`,
|
||||
"chatgpt-account-id": accountId || "",
|
||||
"content-type": "application/json",
|
||||
"originator": CODEX_ORIGINATOR,
|
||||
"session_id": randomUUID(),
|
||||
"user-agent": CODEX_USER_AGENT,
|
||||
"version": CODEX_VERSION,
|
||||
"x-client-request-id": randomUUID(),
|
||||
};
|
||||
},
|
||||
buildBody: (model, body) => {
|
||||
const refs = [];
|
||||
if (Array.isArray(body.images)) body.images.forEach((i) => { const u = toDataUrl(i); if (u) refs.push(u); });
|
||||
const single = toDataUrl(body.image);
|
||||
if (single) refs.push(single);
|
||||
const detail = body.image_detail || CODEX_REF_DETAIL;
|
||||
const imgTool = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase() };
|
||||
if (body.size && body.size !== "") imgTool.size = body.size;
|
||||
if (body.quality && body.quality !== "") imgTool.quality = body.quality;
|
||||
if (body.background && body.background !== "") imgTool.background = body.background;
|
||||
return {
|
||||
model: stripImageSuffix(model),
|
||||
instructions: "",
|
||||
input: [{ type: "message", role: "user", content: buildContent(body.prompt, refs, detail) }],
|
||||
tools: [imgTool],
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
prompt_cache_key: randomUUID(),
|
||||
stream: true,
|
||||
store: false,
|
||||
reasoning: null,
|
||||
};
|
||||
},
|
||||
// Custom: codex parses SSE → either pipe to client or collect b64
|
||||
async parseResponse(response, { log, streamToClient, onRequestSuccess }) {
|
||||
if (streamToClient) {
|
||||
return { sseResponse: buildSseResponse(response, log, onRequestSuccess) };
|
||||
}
|
||||
const b64 = await parseStream(response, log);
|
||||
if (!b64) {
|
||||
throw new Error("Codex did not return an image. Account may not be entitled (Plus/Pro required).");
|
||||
}
|
||||
return { created: nowSec(), data: [{ b64_json: b64 }] };
|
||||
},
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
8
open-sse/handlers/imageProviders/comfyui.js
Normal file
8
open-sse/handlers/imageProviders/comfyui.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// ComfyUI — local, noAuth (placeholder; full graph workflow not implemented)
|
||||
export default {
|
||||
noAuth: true,
|
||||
buildUrl: () => "http://localhost:8188",
|
||||
buildHeaders: () => ({ "Content-Type": "application/json" }),
|
||||
buildBody: (_model, body) => ({ prompt: body.prompt }),
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
41
open-sse/handlers/imageProviders/falAi.js
Normal file
41
open-sse/handlers/imageProviders/falAi.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Fal.ai — async submit + queue polling
|
||||
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://queue.fal.run";
|
||||
|
||||
export default {
|
||||
async: true,
|
||||
buildUrl: (model) => `${BASE_URL}/${model}`,
|
||||
buildHeaders: (creds) => {
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
return { "Content-Type": "application/json", "Authorization": `Key ${key}` };
|
||||
},
|
||||
buildBody: (_model, body) => {
|
||||
const req = { prompt: body.prompt, num_images: body.n || 1 };
|
||||
if (body.size) req.image_size = sizeToAspectRatio(body.size);
|
||||
if (body.image) req.image_url = body.image;
|
||||
return req;
|
||||
},
|
||||
async parseResponse(response, { headers }) {
|
||||
const { status_url, response_url } = await response.json();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const r = await fetch(status_url, { headers });
|
||||
if (!r.ok) throw new Error(`Fal status ${r.status}`);
|
||||
const s = await r.json();
|
||||
if (s.status === "COMPLETED") {
|
||||
const fr = await fetch(response_url, { headers });
|
||||
return await fr.json();
|
||||
}
|
||||
if (s.status === "FAILED") throw new Error(s.error || "Fal generation failed");
|
||||
}
|
||||
throw new Error("Fal polling timeout");
|
||||
},
|
||||
normalize: (responseBody) => {
|
||||
const images = Array.isArray(responseBody.images)
|
||||
? responseBody.images
|
||||
: (responseBody.image ? [responseBody.image] : []);
|
||||
return { created: nowSec(), data: images.map((img) => ({ url: img.url || img })) };
|
||||
},
|
||||
};
|
||||
25
open-sse/handlers/imageProviders/gemini.js
Normal file
25
open-sse/handlers/imageProviders/gemini.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Google Gemini adapter (Nano Banana models)
|
||||
import { nowSec } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
|
||||
|
||||
export default {
|
||||
buildUrl: (model, creds) => {
|
||||
const apiKey = creds?.apiKey || creds?.accessToken;
|
||||
const modelId = model.replace(/^models\//, "");
|
||||
return `${BASE_URL}/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
||||
},
|
||||
buildHeaders: () => ({ "Content-Type": "application/json" }),
|
||||
buildBody: (_model, body) => ({
|
||||
contents: [{ parts: [{ text: body.prompt }] }],
|
||||
generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
|
||||
}),
|
||||
normalize: (responseBody, prompt) => {
|
||||
const parts = responseBody.candidates?.[0]?.content?.parts || [];
|
||||
const images = parts.filter((p) => p.inlineData?.data).map((p) => ({ b64_json: p.inlineData.data }));
|
||||
return {
|
||||
created: nowSec(),
|
||||
data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }],
|
||||
};
|
||||
},
|
||||
};
|
||||
22
open-sse/handlers/imageProviders/huggingface.js
Normal file
22
open-sse/handlers/imageProviders/huggingface.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// HuggingFace Inference API — returns binary image
|
||||
import { nowSec } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://api-inference.huggingface.co/models";
|
||||
|
||||
export default {
|
||||
buildUrl: (model) => `${BASE_URL}/${model}`,
|
||||
buildHeaders: (creds) => {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
return headers;
|
||||
},
|
||||
buildBody: (_model, body) => ({ inputs: body.prompt }),
|
||||
// HF returns raw image bytes — convert to b64_json
|
||||
async parseResponse(response) {
|
||||
const buf = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buf).toString("base64");
|
||||
return { created: nowSec(), data: [{ b64_json: base64 }] };
|
||||
},
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
37
open-sse/handlers/imageProviders/index.js
Normal file
37
open-sse/handlers/imageProviders/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Image provider adapter registry
|
||||
import createOpenAIAdapter from "./openai.js";
|
||||
import gemini from "./gemini.js";
|
||||
import codex from "./codex.js";
|
||||
import sdwebui from "./sdwebui.js";
|
||||
import comfyui from "./comfyui.js";
|
||||
import huggingface from "./huggingface.js";
|
||||
import nanobanana from "./nanobanana.js";
|
||||
import falAi from "./falAi.js";
|
||||
import stabilityAi from "./stabilityAi.js";
|
||||
import blackForestLabs from "./blackForestLabs.js";
|
||||
import runwayml from "./runwayml.js";
|
||||
|
||||
const ADAPTERS = {
|
||||
openai: createOpenAIAdapter("openai"),
|
||||
minimax: createOpenAIAdapter("minimax"),
|
||||
openrouter: createOpenAIAdapter("openrouter"),
|
||||
recraft: createOpenAIAdapter("recraft"),
|
||||
gemini,
|
||||
codex,
|
||||
sdwebui,
|
||||
comfyui,
|
||||
huggingface,
|
||||
nanobanana,
|
||||
"fal-ai": falAi,
|
||||
"stability-ai": stabilityAi,
|
||||
"black-forest-labs": blackForestLabs,
|
||||
runwayml,
|
||||
};
|
||||
|
||||
export function getImageAdapter(provider) {
|
||||
return ADAPTERS[provider] || null;
|
||||
}
|
||||
|
||||
export function isImageProvider(provider) {
|
||||
return provider in ADAPTERS;
|
||||
}
|
||||
58
open-sse/handlers/imageProviders/nanobanana.js
Normal file
58
open-sse/handlers/imageProviders/nanobanana.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// NanoBanana API — async submit + poll record-info
|
||||
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
|
||||
|
||||
const SUBMIT_URL = "https://api.nanobananaapi.ai/api/v1/nanobanana/generate";
|
||||
const POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info";
|
||||
|
||||
export default {
|
||||
async: true,
|
||||
buildUrl: () => SUBMIT_URL,
|
||||
buildHeaders: (creds) => {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
return headers;
|
||||
},
|
||||
buildBody: (_model, body) => {
|
||||
const ratio = sizeToAspectRatio(body.size);
|
||||
const isEdit = !!(body.image || (Array.isArray(body.images) && body.images.length));
|
||||
const req = {
|
||||
prompt: body.prompt,
|
||||
type: isEdit ? "IMAGETOIAMGE" : "TEXTTOIAMGE",
|
||||
numImages: body.n || 1,
|
||||
image_size: ratio,
|
||||
// API requires callBackUrl; we poll instead so a dummy URL is fine.
|
||||
callBackUrl: "https://localhost/callback",
|
||||
};
|
||||
if (isEdit) {
|
||||
const urls = Array.isArray(body.images) ? body.images.filter(Boolean) : [];
|
||||
if (body.image) urls.push(body.image);
|
||||
req.imageUrls = urls;
|
||||
}
|
||||
return req;
|
||||
},
|
||||
// Async: parse submit → poll until SUCCESS, return raw poll data
|
||||
async parseResponse(response, { headers }) {
|
||||
const submitData = await response.json();
|
||||
if (submitData.code !== 200) throw new Error(submitData.msg || "NanoBanana submit failed");
|
||||
const taskId = submitData.data?.taskId;
|
||||
if (!taskId) throw new Error("NanoBanana: no taskId returned");
|
||||
const pollUrl = `${POLL_BASE}?taskId=${encodeURIComponent(taskId)}`;
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const r = await fetch(pollUrl, { headers });
|
||||
if (!r.ok) throw new Error(`NanoBanana status ${r.status}`);
|
||||
const s = await r.json();
|
||||
const flag = s.data?.successFlag;
|
||||
if (flag === 1) return s.data;
|
||||
if (flag === 2 || flag === 3) throw new Error(s.data?.errorMessage || "NanoBanana generation failed");
|
||||
}
|
||||
throw new Error("NanoBanana polling timeout");
|
||||
},
|
||||
normalize: (responseBody, prompt) => {
|
||||
const url = responseBody.response?.resultImageUrl || responseBody.response?.originImageUrl;
|
||||
if (url) return { created: nowSec(), data: [{ url, revised_prompt: prompt }] };
|
||||
return { created: nowSec(), data: [] };
|
||||
},
|
||||
};
|
||||
33
open-sse/handlers/imageProviders/openai.js
Normal file
33
open-sse/handlers/imageProviders/openai.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft)
|
||||
|
||||
const ENDPOINTS = {
|
||||
openai: "https://api.openai.com/v1/images/generations",
|
||||
minimax: "https://api.minimaxi.com/v1/images/generations",
|
||||
openrouter: "https://openrouter.ai/api/v1/images/generations",
|
||||
recraft: "https://external.api.recraft.ai/v1/images/generations",
|
||||
};
|
||||
|
||||
export default function createOpenAIAdapter(providerId) {
|
||||
return {
|
||||
buildUrl: () => ENDPOINTS[providerId],
|
||||
buildHeaders: (creds) => {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
if (providerId === "openrouter") {
|
||||
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
|
||||
headers["X-Title"] = "Endpoint Proxy";
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
buildBody: (model, body) => {
|
||||
const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body;
|
||||
const req = { model, prompt, n, size };
|
||||
if (quality) req.quality = quality;
|
||||
if (style) req.style = style;
|
||||
if (response_format) req.response_format = response_format;
|
||||
return req;
|
||||
},
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
}
|
||||
47
open-sse/handlers/imageProviders/runwayml.js
Normal file
47
open-sse/handlers/imageProviders/runwayml.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Runway ML — async submit + /tasks/{id} polling
|
||||
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://api.dev.runwayml.com/v1";
|
||||
|
||||
export default {
|
||||
async: true,
|
||||
buildUrl: (model) => {
|
||||
// Image models (gen4_image*) → text_to_image; video models → image_to_video
|
||||
return `${BASE_URL}/${model.includes("image") ? "text_to_image" : "image_to_video"}`;
|
||||
},
|
||||
buildHeaders: (creds) => {
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${key}`,
|
||||
"X-Runway-Version": "2024-11-06",
|
||||
};
|
||||
},
|
||||
buildBody: (model, body) => {
|
||||
const isVideo = !model.includes("image");
|
||||
const ratio = sizeToAspectRatio(body.size);
|
||||
if (isVideo) {
|
||||
return { promptText: body.prompt, model, ratio, duration: 5, ...(body.image ? { promptImage: body.image } : {}) };
|
||||
}
|
||||
return { promptText: body.prompt, model, ratio, ...(body.image ? { referenceImages: [{ uri: body.image }] } : {}) };
|
||||
},
|
||||
async parseResponse(response, { headers }) {
|
||||
const { id } = await response.json();
|
||||
if (!id) throw new Error("Runway: no task id returned");
|
||||
const taskUrl = `${BASE_URL}/tasks/${id}`;
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const r = await fetch(taskUrl, { headers });
|
||||
if (!r.ok) throw new Error(`Runway status ${r.status}`);
|
||||
const s = await r.json();
|
||||
if (s.status === "SUCCEEDED") return s;
|
||||
if (s.status === "FAILED" || s.status === "CANCELLED") throw new Error(s.failure || "Runway task failed");
|
||||
}
|
||||
throw new Error("Runway polling timeout");
|
||||
},
|
||||
normalize: (responseBody) => {
|
||||
const outputs = Array.isArray(responseBody.output) ? responseBody.output : [];
|
||||
return { created: nowSec(), data: outputs.map((url) => ({ url })) };
|
||||
},
|
||||
};
|
||||
17
open-sse/handlers/imageProviders/sdwebui.js
Normal file
17
open-sse/handlers/imageProviders/sdwebui.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// SD WebUI (AUTOMATIC1111) — local, noAuth
|
||||
import { nowSec } from "./_base.js";
|
||||
|
||||
export default {
|
||||
noAuth: true,
|
||||
buildUrl: () => "http://localhost:7860/sdapi/v1/txt2img",
|
||||
buildHeaders: () => ({ "Content-Type": "application/json" }),
|
||||
buildBody: (_model, body) => {
|
||||
const { prompt, n = 1, size = "1024x1024" } = body;
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
return { prompt, width: width || 512, height: height || 512, steps: 20, batch_size: n };
|
||||
},
|
||||
normalize: (responseBody) => {
|
||||
const images = Array.isArray(responseBody.images) ? responseBody.images.map((img) => ({ b64_json: img })) : [];
|
||||
return { created: nowSec(), data: images };
|
||||
},
|
||||
};
|
||||
34
open-sse/handlers/imageProviders/stabilityAi.js
Normal file
34
open-sse/handlers/imageProviders/stabilityAi.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Stability AI v2 — sync, returns { image: "<b64>" }
|
||||
import { nowSec, sizeToAspectRatio } from "./_base.js";
|
||||
|
||||
const BASE_URL = "https://api.stability.ai/v2beta/stable-image/generate";
|
||||
|
||||
// Map model id → endpoint segment
|
||||
function modelToEndpoint(model) {
|
||||
if (model.includes("ultra")) return "ultra";
|
||||
if (model.includes("sd3")) return "sd3";
|
||||
return "core";
|
||||
}
|
||||
|
||||
export default {
|
||||
buildUrl: (model) => `${BASE_URL}/${modelToEndpoint(model)}`,
|
||||
buildHeaders: (creds) => {
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${key}`,
|
||||
"Accept": "application/json",
|
||||
};
|
||||
},
|
||||
buildBody: (model, body) => {
|
||||
const req = { prompt: body.prompt, output_format: (body.output_format || "png").toLowerCase() };
|
||||
if (body.size) req.aspect_ratio = sizeToAspectRatio(body.size);
|
||||
if (body.style) req.style_preset = body.style;
|
||||
if (model.includes("sd3")) req.model = model;
|
||||
return req;
|
||||
},
|
||||
normalize: (responseBody) => {
|
||||
if (responseBody.image) return { created: nowSec(), data: [{ b64_json: responseBody.image }] };
|
||||
return { created: nowSec(), data: [] };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user