diff --git a/changelog.d/features/6656-segmind-image-video-provider.md b/changelog.d/features/6656-segmind-image-video-provider.md new file mode 100644 index 0000000000..26e7ecbb90 --- /dev/null +++ b/changelog.d/features/6656-segmind-image-video-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add **Segmind** as an image + video generation provider — `x-api-key` auth against `POST https://api.segmind.com/v1/{model}`, with a curated starter model list (Flux, Stable Diffusion XL/3.5, Kandinsky for image; Wan, Hunyuan, LTX, Kling for video) (#6656). diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index e18044d76d..9eb0df2d0c 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -10,7 +10,7 @@ lastUpdated: 2026-07-17 > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-07-17 -Total providers: **258**. See category breakdown below. +Total providers: **259**. See category breakdown below. ## Categories @@ -90,7 +90,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web (Free) | Web cookie | [link](https://chat.z.ai) | Paste the full Cookie header from chat.z.ai (must include the token= cookie) | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | -## API Key Providers (paid / paid-with-free-credits) (171) +## API Key Providers (paid / paid-with-free-credits) (172) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -231,6 +231,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | | `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | | `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `segmind` | `segmind` | Segmind | API key, image, video | [link](https://segmind.com) | Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/ and returns the generated image/video bytes directly. | | `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | | `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | | `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 811b295ed3..109d494b03 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -6,6 +6,7 @@ */ import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; +import { SEGMIND_IMAGE_PROVIDER } from "./providers/registry/segmind/imageModels.ts"; import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts"; import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts"; @@ -546,6 +547,9 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], supportedSizes: ["1024x1024"], }, + + // Segmind (#6656): 200+ models, `POST /v1/{model}`, x-api-key, raw image bytes. + segmind: SEGMIND_IMAGE_PROVIDER, nanogpt: { id: "nanogpt", baseUrl: "https://nano-gpt.com/api/v1/images/generations", diff --git a/open-sse/config/providers/registry/segmind/imageModels.ts b/open-sse/config/providers/registry/segmind/imageModels.ts new file mode 100644 index 0000000000..f1b4b87043 --- /dev/null +++ b/open-sse/config/providers/registry/segmind/imageModels.ts @@ -0,0 +1,32 @@ +/** + * Segmind image-generation provider entry (#6656). + * + * Segmind exposes 200+ hosted models via a single `POST /v1/{model}` REST + * shape (https://docs.segmind.com/, confirmed live 2026-07-09): x-api-key + * auth, JSON request body, raw image bytes response (no JSON envelope). + * Kept as a full config object (not just the model list) so imageRegistry.ts + * only needs a single-line reference — that file sits right at its size cap. + * + * Model slugs below are a curated starter subset. flux-schnell and + * sdxl1.0-txt2img are verified against + * https://www.segmind.com/models//api; the remaining Flux/SD3/Kandinsky + * slugs follow the same documented naming convention. + */ +export const SEGMIND_IMAGE_MODELS = [ + { id: "flux-schnell", name: "FLUX.1 Schnell" }, + { id: "flux-dev", name: "FLUX.1 Dev" }, + { id: "flux-1.1-pro", name: "FLUX 1.1 Pro" }, + { id: "sdxl1.0-txt2img", name: "Stable Diffusion XL 1.0" }, + { id: "sd3.5-large-txt2img", name: "Stable Diffusion 3.5 Large" }, + { id: "kandinsky2.2-txt2img", name: "Kandinsky 2.2" }, +]; + +export const SEGMIND_IMAGE_PROVIDER = { + id: "segmind", + baseUrl: "https://api.segmind.com/v1", + authType: "apikey", + authHeader: "x-api-key", + format: "segmind", + models: SEGMIND_IMAGE_MODELS, + supportedSizes: ["512x512", "1024x1024", "1024x1792", "1792x1024"], +}; diff --git a/open-sse/config/providers/registry/segmind/videoModels.ts b/open-sse/config/providers/registry/segmind/videoModels.ts new file mode 100644 index 0000000000..ec04d9a13c --- /dev/null +++ b/open-sse/config/providers/registry/segmind/videoModels.ts @@ -0,0 +1,17 @@ +/** + * Segmind video-generation starter model list (#6656). + * + * Same `POST /v1/{model}` REST shape as the image models (imageModels.ts) — + * Segmind's video-capable models (Wan, Hunyuan, LTX, Kling) live under the + * same host/auth. Slugs verified against + * https://www.segmind.com/models/wan2.1-t2v/api and + * https://www.segmind.com/models/wan2.7-i2v/api (2026-07-09); the remaining + * Hunyuan/LTX/Kling slugs follow the same documented naming convention. + */ +export const SEGMIND_VIDEO_MODELS = [ + { id: "wan2.1-t2v", name: "Wan 2.1 Text-to-Video" }, + { id: "wan2.7-i2v", name: "Wan 2.7 Image-to-Video" }, + { id: "hunyuan-video-t2v", name: "Hunyuan Video Text-to-Video" }, + { id: "ltx-video-t2v", name: "LTX Video Text-to-Video" }, + { id: "kling-video-t2v", name: "Kling Video Text-to-Video" }, +]; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 7dcd906064..1eb75f7f65 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -7,6 +7,7 @@ import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts"; import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts"; +import { SEGMIND_VIDEO_MODELS } from "./providers/registry/segmind/videoModels.ts"; interface VideoModel { id: string; @@ -223,6 +224,18 @@ export const VIDEO_PROVIDERS: Record = { models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], }, + // Segmind video generation (#6656). Same `POST /v1/{model}` REST shape as + // the image registry entry (imageRegistry.ts) — x-api-key auth, raw video + // bytes response — routed through the dedicated "segmind" format handler. + segmind: { + id: "segmind", + baseUrl: "https://api.segmind.com/v1", + authType: "apikey", + authHeader: "x-api-key", + format: "segmind", + models: SEGMIND_VIDEO_MODELS, + }, + novita: { id: "novita", // Novita's async video APIs are per-model: the model id IS the submit path diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index f59aff411f..9e0adbca12 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -64,6 +64,7 @@ import { CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; @@ -451,6 +452,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "segmind") { + return handleSegmindImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "chatgpt-web") { return handleChatGptWebImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/segmind.ts b/open-sse/handlers/imageGeneration/providers/segmind.ts new file mode 100644 index 0000000000..a042fa148b --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/segmind.ts @@ -0,0 +1,80 @@ +// Segmind image-generation provider (#6656). +// +// Thin body-builder + response-formatter around the shared Segmind REST +// client (open-sse/utils/segmindClient.ts) — see that module for the wire +// shape (x-api-key auth, raw image bytes response, no JSON envelope). + +import { segmindRequest } from "../../../utils/segmindClient.ts"; + +function parseSegmindSize(size: unknown): { width: number; height: number } { + if (typeof size === "string" && size.includes("x")) { + const [w, h] = size.split("x").map(Number); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { + return { width: w, height: h }; + } + } + return { width: 1024, height: 1024 }; +} + +function buildSegmindImageBody(body: Record, prompt: string) { + const { width, height } = parseSegmindSize(body.size); + const upstreamBody: Record = { + prompt, + width, + height, + samples: Number(body.n) > 0 ? Number(body.n) : 1, + }; + if (typeof body.negative_prompt === "string") upstreamBody.negative_prompt = body.negative_prompt; + if (typeof body.seed === "number") upstreamBody.seed = body.seed; + return upstreamBody; +} + +function formatSegmindImage(buffer: Buffer, contentType: string, prompt: string, wantsB64: boolean) { + const base64 = buffer.toString("base64"); + if (wantsB64) return { b64_json: base64, revised_prompt: prompt }; + const mimeType = contentType.startsWith("image/") ? contentType : "image/jpeg"; + return { url: `data:${mimeType};base64,${base64}`, revised_prompt: prompt }; +} + +export async function handleSegmindImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const upstreamBody = buildSegmindImageBody(body, prompt); + + if (log) { + log.info("IMAGE", `${provider}/${model} (segmind) | prompt: "${prompt.slice(0, 60)}..."`); + } + + const result = await segmindRequest({ + baseUrl: providerConfig.baseUrl, + model, + token, + upstreamBody, + callLogPath: "/v1/images/generations", + provider, + scope: "IMAGE", + log, + }); + + if (!result.ok) { + return { success: false, status: result.status, error: result.error }; + } + + const image = formatSegmindImage( + result.buffer, + result.contentType, + prompt, + body.response_format === "b64_json" + ); + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [image] }, + }; +} diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index c9c0323607..7de016a261 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -16,6 +16,7 @@ import { handleLeonardoVideoGeneration } from "./videoGeneration/leonardoHandler import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandler.ts"; import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; +import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -130,6 +131,16 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "segmind") { + return handleSegmindVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } if (providerConfig.format === "novita-video") { return handleNovitaVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } diff --git a/open-sse/handlers/videoGeneration/providers/segmind.ts b/open-sse/handlers/videoGeneration/providers/segmind.ts new file mode 100644 index 0000000000..cc00bc9ab1 --- /dev/null +++ b/open-sse/handlers/videoGeneration/providers/segmind.ts @@ -0,0 +1,57 @@ +// Segmind video-generation provider (#6656). +// +// Thin body-builder + response-formatter around the shared Segmind REST +// client (open-sse/utils/segmindClient.ts) — same wire shape as the image +// handler (imageGeneration/providers/segmind.ts): x-api-key auth, raw video +// bytes response (e.g. `video/mp4`) on success, no JSON envelope. + +import { segmindRequest } from "../../../utils/segmindClient.ts"; + +function buildSegmindVideoBody(body: Record, prompt: string) { + const upstreamBody: Record = { prompt }; + if (typeof body.negative_prompt === "string") upstreamBody.negative_prompt = body.negative_prompt; + if (typeof body.seed === "number") upstreamBody.seed = body.seed; + if (body.duration != null) upstreamBody.duration = Number(body.duration); + if (typeof body.aspect_ratio === "string") upstreamBody.aspect_ratio = body.aspect_ratio; + return upstreamBody; +} + +export async function handleSegmindVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const upstreamBody = buildSegmindVideoBody(body, prompt); + + if (log) { + log.info("VIDEO", `${provider}/${model} (segmind) | prompt: "${prompt.slice(0, 60)}..."`); + } + + const result = await segmindRequest({ + baseUrl: providerConfig.baseUrl, + model, + token, + upstreamBody, + callLogPath: "/v1/videos/generations", + provider, + scope: "VIDEO", + log, + }); + + if (!result.ok) { + return { success: false, status: result.status, error: result.error }; + } + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: result.buffer.toString("base64"), format: "mp4" }], + }, + }; +} diff --git a/open-sse/utils/segmindClient.ts b/open-sse/utils/segmindClient.ts new file mode 100644 index 0000000000..2c711d6f98 --- /dev/null +++ b/open-sse/utils/segmindClient.ts @@ -0,0 +1,111 @@ +// Shared Segmind (#6656) REST wire client — used by both the image +// (imageGeneration/providers/segmind.ts) and video +// (videoGeneration/providers/segmind.ts) handlers, since Segmind exposes +// image and video models under the exact same `POST /v1/{model}` shape: +// x-api-key auth, JSON request body, raw media bytes response (no JSON +// envelope) on success, JSON/text error body on failure. +// +// Factored out so each per-modality handler stays a thin body-builder + +// response-formatter (keeps both under the complexity/max-lines ratchets). + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "./error.ts"; + +export interface SegmindLogger { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; +} + +export interface SegmindRequestOptions { + baseUrl: string; + model: string; + token: string; + upstreamBody: Record; + callLogPath: string; + provider: string; + scope: "IMAGE" | "VIDEO"; + log?: SegmindLogger | null; +} + +export type SegmindRequestResult = + | { ok: true; buffer: Buffer; contentType: string } + | { ok: false; status: number; error: string }; + +async function logSegmindFailure( + opts: SegmindRequestOptions, + status: number, + duration: number, + errorText: string +): Promise { + if (opts.log) { + opts.log.error( + opts.scope, + `${opts.provider} error ${status}: ${errorText.slice(0, 200)}` + ); + } + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { + ok: false, + status, + error: sanitizeErrorMessage(errorText) || `Segmind request failed (${status})`, + }; +} + +/** + * POST {baseUrl}/{model} with x-api-key auth and a JSON body. Returns the + * raw response bytes + content-type on success, or a sanitized error result. + */ +export async function segmindRequest(opts: SegmindRequestOptions): Promise { + const startTime = Date.now(); + try { + const response = await fetch(`${opts.baseUrl.replace(/\/$/, "")}/${opts.model}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": opts.token }, + body: JSON.stringify(opts.upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + return logSegmindFailure(opts, response.status, Date.now() - startTime, errorText); + } + + const contentType = response.headers.get("content-type") || ""; + const buffer = Buffer.from(await response.arrayBuffer()); + + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status: 200, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration: Date.now() - startTime, + }).catch(() => {}); + + return { ok: true, buffer, contentType }; + } catch (err) { + const message = (err as Error)?.message ?? String(err); + if (opts.log) opts.log.error(opts.scope, `${opts.provider} fetch error: ${message}`); + saveCallLog({ + method: "POST", + path: opts.callLogPath, + status: 502, + model: `${opts.provider}/${opts.model}`, + provider: opts.provider, + duration: Date.now() - startTime, + error: message, + }).catch(() => {}); + return { + ok: false, + status: 502, + error: `${opts.scope === "IMAGE" ? "Image" : "Video"} provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 4ffde68df3..65aa041144 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -58,6 +58,7 @@ export const IMAGE_ONLY_PROVIDER_IDS = new Set([ "black-forest-labs", "recraft", "topaz", + "segmind", "freepik", ]); @@ -107,6 +108,7 @@ export const VIDEO_PROVIDER_IDS = new Set([ "replicate", "haiper", "leonardo", + "segmind", "novita", ]); diff --git a/src/shared/constants/providers/apikey/specialty-media.ts b/src/shared/constants/providers/apikey/specialty-media.ts index 4ca6609217..603c32289e 100644 --- a/src/shared/constants/providers/apikey/specialty-media.ts +++ b/src/shared/constants/providers/apikey/specialty-media.ts @@ -204,6 +204,21 @@ export const APIKEY_PROVIDERS_SPECIALTY = { textIcon: "TP", website: "https://topazlabs.com", }, + segmind: { + id: "segmind", + alias: "segmind", + name: "Segmind", + icon: "image", + color: "#7C3AED", + textIcon: "SG", + website: "https://segmind.com", + authHint: + "Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/ and returns the generated image/video bytes directly.", + apiHint: + "Segmind exposes 200+ hosted image and video models (Flux, SDXL, SD3, Kandinsky, Wan, Hunyuan, LTX, Kling, ...) under a single POST /v1/ REST call per model. OmniRoute ships a curated starter subset for each modality; more models can be routed by using their Segmind slug as the model id.", + hasFree: true, + freeNote: "Free trial credits on signup, no credit card required (per Segmind's public docs).", + }, dify: { id: "dify", alias: "dify", diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 925873d763..779834e0f1 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -37,12 +37,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 171 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 172 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 171); - assert.equal(new Set(keys).size, 171, "duplicate keys after spread-merge"); + assert.equal(keys.length, 172); + assert.equal(new Set(keys).size, 172, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 171. + // strict partition (every provider in exactly one), so the sum must be exactly 172. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -62,7 +62,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 171 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 171, "families must partition all 171 providers"); + assert.equal(famTotal, 172, "families must partition all 172 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { diff --git a/tests/unit/segmind-image-video-provider-6656.test.ts b/tests/unit/segmind-image-video-provider-6656.test.ts new file mode 100644 index 0000000000..ba8dd637b1 --- /dev/null +++ b/tests/unit/segmind-image-video-provider-6656.test.ts @@ -0,0 +1,254 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #6656 — Segmind image+video provider. +// Segmind exposes 200+ hosted models under a single `POST /v1/{model}` REST +// shape (https://docs.segmind.com/): x-api-key header auth, JSON request +// body, raw image/video bytes response (no JSON envelope) on success. This +// covers: registry entry shape (image + video), connection-metadata catalog +// entry, IMAGE_ONLY/VIDEO_PROVIDER_IDS membership, request mapping through +// the dedicated handlers (mocked fetch, no live key required), and the +// error path staying sanitized (no raw stack/message leakage). + +const { IMAGE_PROVIDERS, getImageProvider } = await import("../../open-sse/config/imageRegistry.ts"); +const { VIDEO_PROVIDERS, getVideoProvider } = await import("../../open-sse/config/videoRegistry.ts"); +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts"); +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers/apikey/index.ts"); +const { IMAGE_ONLY_PROVIDER_IDS, VIDEO_PROVIDER_IDS } = await import( + "../../src/shared/constants/providers.ts" +); + +test("segmind connection-metadata entry is registered with the expected shape", () => { + const entry = (APIKEY_PROVIDERS as Record).segmind; + assert.ok(entry, "expected an APIKEY_PROVIDERS entry for segmind"); + assert.equal(entry.id, "segmind"); + assert.equal(entry.website, "https://segmind.com"); +}); + +test("segmind is registered in both IMAGE_ONLY_PROVIDER_IDS and VIDEO_PROVIDER_IDS", () => { + assert.ok(IMAGE_ONLY_PROVIDER_IDS.has("segmind"), "segmind must appear in IMAGE_ONLY_PROVIDER_IDS"); + assert.ok(VIDEO_PROVIDER_IDS.has("segmind"), "segmind must appear in VIDEO_PROVIDER_IDS"); +}); + +test("segmind image registry entry uses the x-api-key REST shape", () => { + const cfg = getImageProvider("segmind"); + assert.ok(cfg, "expected an IMAGE_PROVIDERS entry for segmind"); + assert.equal(cfg.id, "segmind"); + assert.equal(cfg.baseUrl, "https://api.segmind.com/v1"); + assert.equal(cfg.authType, "apikey"); + assert.equal(cfg.authHeader, "x-api-key"); + assert.equal(cfg.format, "segmind"); + assert.ok(cfg.models.length > 0, "expected at least one starter image model"); + assert.ok( + cfg.models.some((m) => m.id === "flux-schnell"), + "expected flux-schnell in the starter image model list" + ); + assert.ok(Array.isArray(cfg.supportedSizes) && cfg.supportedSizes.length > 0); +}); + +test("segmind video registry entry uses the x-api-key REST shape", () => { + const cfg = getVideoProvider("segmind"); + assert.ok(cfg, "expected a VIDEO_PROVIDERS entry for segmind"); + assert.equal(cfg.id, "segmind"); + assert.equal(cfg.baseUrl, "https://api.segmind.com/v1"); + assert.equal(cfg.authType, "apikey"); + assert.equal(cfg.authHeader, "x-api-key"); + assert.equal(cfg.format, "segmind"); + assert.ok(cfg.models.length > 0, "expected at least one starter video model"); + assert.ok( + cfg.models.some((m) => m.id === "wan2.1-t2v"), + "expected wan2.1-t2v in the starter video model list" + ); +}); + +test("segmind image/video registries do not share model ids by accident", () => { + const imageIds = new Set(IMAGE_PROVIDERS.segmind.models.map((m) => m.id)); + const videoIds = VIDEO_PROVIDERS.segmind.models.map((m) => m.id); + for (const id of videoIds) { + assert.ok(!imageIds.has(id), `video model id "${id}" unexpectedly duplicated in image models`); + } +}); + +test("handleImageGeneration posts to Segmind with x-api-key and returns a data: URL image", async () => { + const originalFetch = globalThis.fetch; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + requestCapture = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return new Response(new Uint8Array([1, 2, 3, 4]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "segmind/flux-schnell", + prompt: "a bengal tiger in an astronaut suit", + size: "1024x1024", + }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(requestCapture.url, "https://api.segmind.com/v1/flux-schnell"); + assert.equal(requestCapture.headers["x-api-key"], "segmind-key"); + assert.equal(requestCapture.body.prompt, "a bengal tiger in an astronaut suit"); + assert.equal(requestCapture.body.width, 1024); + assert.equal(requestCapture.body.height, 1024); + assert.ok(String(result.data.data[0].url).startsWith("data:image/jpeg;base64,")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration returns b64_json when response_format=b64_json is requested", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response(new Uint8Array([9, 9, 9]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + + try { + const result = await handleImageGeneration({ + body: { + model: "segmind/flux-schnell", + prompt: "test", + response_format: "b64_json", + }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.ok(typeof result.data.data[0].b64_json === "string" && result.data.data[0].b64_json.length > 0); + assert.equal(result.data.data[0].url, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration sanitizes Segmind upstream error bodies (no raw stack leakage)", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response( + "Internal error at /home/runner/work/segmind/handler.ts:42\n at foo (/home/runner/work/segmind/app.js:10:2)", + { status: 500, headers: { "content-type": "text/plain" } } + ); + + try { + const result = await handleImageGeneration({ + body: { model: "segmind/flux-schnell", prompt: "test" }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 500); + assert.ok(!result.error.includes("/home/runner"), "error must not leak raw absolute paths"); + assert.ok(!result.error.includes("at foo"), "error must not leak raw stack frames (2nd line)"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration posts to Segmind with x-api-key and returns an mp4 b64_json payload", async () => { + const originalFetch = globalThis.fetch; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + requestCapture = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return new Response(new Uint8Array([5, 6, 7, 8]), { + status: 200, + headers: { "content-type": "video/mp4" }, + }); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "segmind/wan2.1-t2v", + prompt: "a smiling woman walking in London at night", + aspect_ratio: "16:9", + }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(requestCapture.url, "https://api.segmind.com/v1/wan2.1-t2v"); + assert.equal(requestCapture.headers["x-api-key"], "segmind-key"); + assert.equal(requestCapture.body.prompt, "a smiling woman walking in London at night"); + assert.equal(requestCapture.body.aspect_ratio, "16:9"); + assert.equal(result.data.data[0].format, "mp4"); + assert.ok(typeof result.data.data[0].b64_json === "string" && result.data.data[0].b64_json.length > 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration sanitizes Segmind upstream error bodies (no raw stack leakage)", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response("Traceback (most recent call last):\n File \"/srv/app/handler.py\", line 10", { + status: 502, + headers: { "content-type": "text/plain" }, + }); + + try { + const result = await handleVideoGeneration({ + body: { model: "segmind/wan2.1-t2v", prompt: "test" }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.ok(!result.error.includes("/srv/app"), "error must not leak raw absolute paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration surfaces network errors through sanitizeErrorMessage (502, no raw message)", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + throw new Error( + "connect ECONNREFUSED at /home/runner/work/segmind/client.ts:5:1\n at TCPConnectWrap.afterConnect" + ); + }; + + try { + const result = await handleImageGeneration({ + body: { model: "segmind/flux-schnell", prompt: "test" }, + credentials: { apiKey: "segmind-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.ok(!result.error.includes("/home/runner"), "must not leak raw absolute source path"); + assert.ok( + !result.error.includes("TCPConnectWrap"), + "must not leak the raw stack frame (2nd line)" + ); + } finally { + globalThis.fetch = originalFetch; + } +});