import { isIP } from "node:net"; // Litellm provider module implements model/runtime integration. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createOpenAiCompatibleImageGenerationProvider, imageSourceUploadFileName, type ImageGenerationProvider, } from "openclaw/plugin-sdk/image-generation"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LITELLM_BASE_URL } from "./onboard.js"; const DEFAULT_SIZE = "1024x1024"; const DEFAULT_LITELLM_IMAGE_MODEL = "gpt-image-2"; const LITELLM_SUPPORTED_SIZES = [ "256x256", "512x512", "1024x1024", "1024x1536", "1024x1792", "1536x1024", "1792x1024", "2048x2048", "2048x1152", "3840x2160", "2160x3840", ] as const; const LITELLM_MAX_INPUT_IMAGES = 5; type LitellmProviderConfig = NonNullable< NonNullable["providers"] >[string]; function resolveLitellmProviderConfig( cfg: OpenClawConfig | undefined, ): LitellmProviderConfig | undefined { return cfg?.models?.providers?.litellm; } function resolveConfiguredLitellmBaseUrl(cfg: OpenClawConfig | undefined): string { return normalizeOptionalString(resolveLitellmProviderConfig(cfg)?.baseUrl) ?? LITELLM_BASE_URL; } // LiteLLM's default proxy is loopback. Auto-enable private-network access only // for loopback-style hosts; LAN/custom private endpoints should use the // explicit models.providers.litellm.request.allowPrivateNetwork opt-in. function isAutoAllowedLitellmHostname(hostname: string): boolean { if (!hostname) { return false; } // Strip IPv6 brackets if any: "[::1]" -> "::1". const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; const lowered = host.toLowerCase(); if ( lowered === "localhost" || lowered === "host.docker.internal" || lowered.endsWith(".localhost") ) { return true; } // Only IPv4 literals may use the 127/8 loopback exemption. if (isIP(lowered) === 4 && lowered.startsWith("127.")) { return true; } if (lowered === "::1" || lowered === "0:0:0:0:0:0:0:1") { return true; } return false; } function shouldAutoAllowPrivateLitellmEndpoint(baseUrl: string): boolean { try { const parsed = new URL(baseUrl); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return false; } return isAutoAllowedLitellmHostname(parsed.hostname); } catch { return false; } } export function buildLitellmImageGenerationProvider(): ImageGenerationProvider { return createOpenAiCompatibleImageGenerationProvider({ id: "litellm", label: "LiteLLM", defaultModel: DEFAULT_LITELLM_IMAGE_MODEL, models: [DEFAULT_LITELLM_IMAGE_MODEL], capabilities: { generate: { maxCount: 4, supportsSize: true, supportsAspectRatio: false, supportsResolution: false, }, edit: { enabled: true, maxCount: 4, maxInputImages: LITELLM_MAX_INPUT_IMAGES, supportsSize: true, supportsAspectRatio: false, supportsResolution: false, }, geometry: { sizes: [...LITELLM_SUPPORTED_SIZES], }, }, defaultBaseUrl: LITELLM_BASE_URL, resolveBaseUrl: ({ req }) => resolveConfiguredLitellmBaseUrl(req.cfg), resolveAllowPrivateNetwork: ({ baseUrl }) => shouldAutoAllowPrivateLitellmEndpoint(baseUrl) ? true : undefined, useConfiguredRequest: true, buildGenerateRequest: ({ req, model, count }) => ({ kind: "json", body: { model, prompt: req.prompt, n: count, size: req.size ?? DEFAULT_SIZE, }, }), // LiteLLM's /v1/images/edits is multipart (OpenAI's edits schema): the // reference image must be an uploaded file part, not a JSON field — a JSON // body fails before the request reaches the provider. buildEditRequest: ({ req, inputImages, model, count }) => { const form = new FormData(); form.set("model", model); form.set("prompt", req.prompt); form.set("n", String(count)); form.set("size", req.size ?? DEFAULT_SIZE); // OpenAI-compatible edits take repeated `image[]` parts when more than one // reference is supplied, and a single `image` part otherwise. const partName = inputImages.length > 1 ? "image[]" : "image"; for (const [index, image] of inputImages.entries()) { const mimeType = normalizeOptionalString(image.mimeType) ?? "image/png"; form.append( partName, new Blob([new Uint8Array(image.buffer)], { type: mimeType }), imageSourceUploadFileName({ image, index }), ); } return { kind: "multipart", form }; }, missingApiKeyError: "LiteLLM API key missing", failureLabels: { generate: "LiteLLM image generation failed", edit: "LiteLLM image edit failed", }, }); }