UNPKG

@sogni-ai/sogni-creative-agent-skill

Version:

Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.

1,339 lines (1,225 loc) 422 kB
#!/usr/bin/env node /** * sogni-agent - Generate images, videos, and music using Sogni AI * Usage: sogni-agent [options] "prompt" */ // Must be first: a zero-dependency Node.js version guard that runs before // `sharp` / the Sogni SDK load, so an unsupported Node prints a clear message // instead of a cryptic native/ESM crash. import './node-version-check.mjs'; import JSON5 from 'json5'; import { createHash, randomBytes } from 'crypto'; import { createRequire } from 'module'; import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync, readdirSync, realpathSync, lstatSync, unlinkSync, rmdirSync, rmSync } from 'fs'; import { join, dirname, basename, extname, sep, resolve } from 'path'; import { homedir, tmpdir } from 'os'; import sharp from 'sharp'; import { getEnv, hasEnv } from './env.mjs'; import { PACKAGE_VERSION } from './version.mjs'; import { assertSafeUrl, fetchSafeUrl } from './ssrf-guard.mjs'; import { INTERNAL_FLAG as UPDATE_CHECK_INTERNAL_FLAG, runForegroundCheck as runUpdateCheckForeground, maybeSpawnBackgroundCheck as maybeSpawnUpdateCheck, getQueuedNotice as getUpdateCheckNotice, runSelfUpdate as runSogniSelfUpdate, snoozeUpdate as snoozeSogniUpdate, runWhatsNew as runSogniWhatsNew, readState as readUpdateCheckState, compareSemver as compareSogniSemver, } from './update-check.mjs'; import { fileURLToPath } from 'url'; import { LTX23_WORKFLOW_MODELS, PUBLIC_SKILL_DEFAULT_TOOL_DEFINITIONS, PUBLIC_SKILL_DEFAULT_TOOL_NAMES, QUALITY_TIERS, SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS, VIDEO_WORKFLOW_DEFAULT_MODELS, buildStoryboardProject, buildStoryboardVideoHostedToolSequenceInput, classifyPublicSkillTurn, classifySkillError, compileForModel, compilePublicSkillToolSurface, composeAdapterPromptGuidance, createPublicSkillDefaultContractRuntime, detectReferenceAudioFormat, dimensionsForAspectRatio, dimensionsWithShortSide, dispatchPublicSkillToolCall, getModelDefaults, getVideoPromptGuardrailPlan, inferVideoWorkflowFromAssets, inferVideoWorkflowFromModel, isHappyHorseModel, isHappyHorseModelSelection, isLtx2Model, isSeedanceModel, isSeedanceModelSelection, normalizeReferenceAudioMimeType, normalizeVideoWorkflow, planCliVideoBrain, resolveVideoControlNetStrength, resolveVideoModelAlias, resolveVideoSteps, sanitizeMessagesForLlm, sanitizeBatchPrompt, selectDefaultVideoModel, shouldTrimSeedanceV2VSourceVideo, workflowRequiresImage } from '@sogni-ai/sogni-intelligence-client/public-skill-runtime'; import { redactPayload, redactRunRecord } from '@sogni-ai/sogni-intelligence-client/replay'; import { extractToolCallProgressUpdate } from '@sogni-ai/sogni-intelligence-client/chatRun'; import { SEEDANCE_R2V_REFERENCE_AUDIO_MAX_DURATION_SECONDS, prepareSeedanceV2VSourceVideo as prepareSharedSeedanceV2VSourceVideo } from '@sogni-ai/sogni-intelligence-client/media'; import { HAPPYHORSE_REFERENCE_LIMITS, HappyHorseReferenceLimitError, SEEDANCE_REFERENCE_LIMITS, SeedanceReferenceLimitError, getHappyHorseReferenceLimits, happyhorseTerminalGenerationFailurePayloadFromError, happyhorseTerminalPolicyPayloadFromError, seedanceTerminalGenerationFailurePayloadFromError, seedanceTerminalPolicyPayloadFromError, validateHappyHorseReferenceCounts, validateSeedanceReferenceCounts } from '@sogni-ai/sogni-intelligence-client/tools'; const SPARK_PACKS_PURCHASE_URL = 'https://docs.sogni.ai/pricing/#spark-packs'; const SPARK_PACKS_PURCHASE_HINT = `Buy Spark Packs to continue: ${SPARK_PACKS_PURCHASE_URL}`; const UNLIMITED_PLAN_URL = 'https://docs.sogni.ai/pricing/unlimited-plan-details'; // Skill-local fallback guidance for Sogni Unlimited subscription billing errors, // keyed on the canonical socket error code (a structured fact, not an English // message). Mirrors the `insufficient_credits` enrichment below: it adds an // actionable hint/category so an agent responds correctly instead of looping a // covered job that cannot bill. Codes are the source of truth in // sogni-socket/constants/errorCodes.js (4078-4081). const SUBSCRIPTION_BILLING_ERROR_CODES = new Set(['4078', '4079', '4080', '4081']); function subscriptionBillingFallback(code) { if (code === undefined || code === null) return null; const key = String(code); if (!SUBSCRIPTION_BILLING_ERROR_CODES.has(key)) return null; switch (key) { case '4078': // Vendor model the subscription never covers, or no verified entitlement. return { retryable: false, hint: 'This generation is not covered by Sogni Unlimited. Vendor models (GPT Image 2, Seedance, HappyHorse) always require Premium Spark; otherwise reconnect and try again. ' + SPARK_PACKS_PURCHASE_HINT, purchaseLabel: 'Get Premium Spark', purchaseUrl: SPARK_PACKS_PURCHASE_URL, }; case '4079': // Per-plan queued-job ceiling reached. return { retryable: true, hint: 'Unlimited plan: maximum queued jobs reached. Wait for queued jobs to finish before submitting more.', }; case '4080': // Renewal payment retry — Unlimited access paused. Do NOT auto-retry the // covered job; it will keep failing until billing recovers. return { retryable: false, hint: 'Unlimited renewal payment is being retried and access is paused. Render now with Spark or SOGNI (--token-type spark|sogni); Unlimited resumes automatically once the renewal succeeds. Do not auto-retry the covered job.', }; case '4081': // Feature requires a higher subscription tier. return { retryable: false, hint: `This feature requires a higher subscription plan. Upgrade to Unlimited Pro: ${UNLIMITED_PLAN_URL}`, purchaseLabel: 'Upgrade to Unlimited Pro', purchaseUrl: UNLIMITED_PLAN_URL, }; default: return null; } } // Apply subscription-billing fallback enrichment to an error payload. Returns // true when the code matched (so callers skip the generic insufficient_credits // branch — subscription denials get subscription-specific guidance, never a bare // "Buy Spark Packs"). Extracts the code from the canonical shapes the SDK and // socket surface it in. function applySubscriptionBillingEnrichment(payload, code) { const fallback = subscriptionBillingFallback(code); if (!fallback) return false; payload.errorCode = String(code); payload.errorCategory = 'subscription_billing'; payload.retryable = fallback.retryable; if (!payload.hint) payload.hint = fallback.hint; if (fallback.purchaseUrl) { payload.purchaseAction = true; payload.purchaseLabel = fallback.purchaseLabel; payload.purchaseUrl = fallback.purchaseUrl; payload.purchaseReason = fallback.hint; } return true; } function subscriptionBillingCodeFromError(error) { if (!error || typeof error !== 'object') return undefined; const record = error; return record.code ?? record.errorCode ?? record.error_code ?? record.payload?.errorCode ?? record.payload?.error_code; } const require = createRequire(import.meta.url); const rootClientModule = process.env.SOGNI_AGENT_TEST_STATE_PATH ? await import('@sogni-ai/sogni-intelligence-client') : require('@sogni-ai/sogni-intelligence-client'); const { SogniClientWrapper, ClientEvent, getMaxContextImages: getWrapperMaxContextImages, parseCreativeWorkflowSseChunk } = rootClientModule; // --------------------------------------------------------------------------- // Path sanitization — defense-in-depth for any value that becomes a file path // or process argument. execaSync runs argument arrays without shell expansion, // so classic shell injection is not possible. These checks guard against: // • null-byte injection (can truncate paths at the C level) // • control-character injection // • FFMPEG_PATH pointing to a non-ffmpeg binary // --------------------------------------------------------------------------- /** * Reject null bytes and control characters in a path string. * Returns the path unchanged when valid; throws otherwise. */ function sanitizePath(p, label) { if (typeof p !== 'string') { const err = new Error(`${label || 'Path'} must be a string.`); err.code = 'INVALID_PATH'; throw err; } if (p.includes('\0')) { const err = new Error(`${label || 'Path'} contains a null byte.`); err.code = 'INVALID_PATH'; throw err; } // Reject ASCII control characters except tab (\x09), newline (\x0a), carriage return (\x0d) if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(p)) { const err = new Error(`${label || 'Path'} contains invalid control characters.`); err.code = 'INVALID_PATH'; throw err; } // Expand a leading `~`/`~/` so quoted paths (where the shell didn't expand it, // e.g. --ref "~/face.jpg") and agent-passed literals resolve to the home dir. return expandHomePath(p); } const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.config', 'sogni', 'credentials'); const DEFAULT_LAST_RENDER_PATH = join(homedir(), '.config', 'sogni', 'last-render.json'); const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); // Current OpenClaw home, with a fallback to the legacy clawdbot-era directory // for installs that predate the rename. Only used when neither // SOGNI_MEDIA_INBOUND_DIR nor the OpenClaw plugin config overrides it. const OPENCLAW_MEDIA_INBOUND_DIR = join(homedir(), '.openclaw', 'media', 'inbound'); const LEGACY_MEDIA_INBOUND_DIR = join(homedir(), '.clawdbot', 'media', 'inbound'); const DEFAULT_MEDIA_INBOUND_DIR = !existsSync(OPENCLAW_MEDIA_INBOUND_DIR) && existsSync(LEGACY_MEDIA_INBOUND_DIR) ? LEGACY_MEDIA_INBOUND_DIR : OPENCLAW_MEDIA_INBOUND_DIR; const DEFAULT_MEMORIES_PATH = join(homedir(), '.config', 'sogni', 'memories.json'); const DEFAULT_PERSONALITY_PATH = join(homedir(), '.config', 'sogni', 'personality.txt'); const DEFAULT_PERSONAS_DIR = join(homedir(), '.config', 'sogni', 'personas'); const DEFAULT_PERSONAS_INDEX_PATH = join(homedir(), '.config', 'sogni', 'personas', 'index.json'); const DEFAULT_API_MEDIA_REFERENCE_MAX_BYTES = 100 * 1024 * 1024; const DEFAULT_API_BASE_URL = 'https://api.sogni.ai'; const DEFAULT_SAFE_API_HOSTS = Object.freeze(['api.sogni.ai']); const LOOPBACK_API_HOSTS = Object.freeze(['localhost', '127.0.0.1', '::1']); const DEFAULT_LLM_MODEL = 'qwen3.6-35b-a3b-gguf-iq4xs'; const VALID_API_TASK_PROFILES = new Set(['general', 'coding', 'reasoning']); const SOGNI_APP_SOURCE = 'sogni-creative-agent-skill'; const OPENCLAW_CONFIG_PATH = getEnv('OPENCLAW_CONFIG_PATH') || DEFAULT_OPENCLAW_CONFIG_PATH; const IS_OPENCLAW_INVOCATION = Boolean(getEnv('OPENCLAW_PLUGIN_CONFIG')); const RAW_ARGS = process.argv.slice(2); const CLI_WANTS_JSON = RAW_ARGS.includes('--json'); const JSON_ERROR_MODE = CLI_WANTS_JSON || IS_OPENCLAW_INVOCATION; // --- Update-check entry points -------------------------------------------- // Internal mode: the detached background child that fetches the npm registry. if (RAW_ARGS[0] === UPDATE_CHECK_INTERNAL_FLAG) { await runUpdateCheckForeground({ currentVersion: PACKAGE_VERSION }); process.exit(0); } // User-facing subcommand: `sogni-agent self-update` if (RAW_ARGS[0] === 'self-update') { process.exit(runSogniSelfUpdate({})); } // `--snooze-update`: pause reminders for the currently pending update // (escalating backoff: 1 day → 2 days → 1 week; a newer release resets it). if (RAW_ARGS[0] === '--snooze-update') { const result = snoozeSogniUpdate({ currentVersion: PACKAGE_VERSION }); if (result.snoozed) { console.error(`Update reminders for v${result.version} snoozed until ${new Date(result.until).toISOString()}.`); } else { console.error('No pending update to snooze.'); } process.exit(0); } // `--whats-new [since-version]`: print the bundled CHANGELOG entries for the // installed version, or everything after <since-version>. if (RAW_ARGS[0] === '--whats-new') { const sinceVersion = RAW_ARGS[1] && !RAW_ARGS[1].startsWith('-') ? RAW_ARGS[1] : null; process.exit(runSogniWhatsNew({ changelogPath: join(dirname(fileURLToPath(import.meta.url)), 'CHANGELOG.md'), currentVersion: PACKAGE_VERSION, sinceVersion, })); } // Fire-and-forget background check (no-op when throttled or skipped) try { maybeSpawnUpdateCheck({ cliPath: process.argv[1] }); } catch { /* never break the CLI */ } // Trailing notice on exit, if a newer version is on file process.on('exit', () => { try { const notice = getUpdateCheckNotice({ currentVersion: PACKAGE_VERSION }); if (notice) process.stderr.write(notice + '\n'); } catch { /* never break exit */ } }); // --- Temp-dir lifecycle ------------------------------------------------------ // Every transient directory the CLI creates is registered here and removed on // normal exit, fatal error, or signal. Ctrl-C during a long video job is the // common case that used to orphan directories under os.tmpdir(). const TRACKED_TEMP_DIRS = new Set(); function createTrackedTempDir(prefix) { const dir = mkdtempSync(join(tmpdir(), prefix)); TRACKED_TEMP_DIRS.add(dir); return dir; } function cleanupTrackedTempDirs() { for (const dir of TRACKED_TEMP_DIRS) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } TRACKED_TEMP_DIRS.delete(dir); } } process.on('exit', cleanupTrackedTempDirs); // 128 + signal number is the conventional shell exit code for a signal death. const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { process.on(signal, () => { // process.exit() fires the 'exit' handlers above (temp cleanup + update // notice); the OS tears down any open SDK socket with the process. process.exit(SIGNAL_EXIT_CODES[signal]); }); } const SOCKET_EVENT_SUBSCRIPTIONS = Object.freeze({ modelAvailability: false }); const MUSIC_MODEL_IDS = { turbo: 'ace_step_1.5_xl_turbo', speed: 'ace_step_1.5_xl_turbo', fast: 'ace_step_1.5_xl_turbo', sft: 'ace_step_1.5_xl_sft', lyrics: 'ace_step_1.5_xl_sft', lyric: 'ace_step_1.5_xl_sft' }; const MUSIC_MODEL_DEFAULTS = { 'ace_step_1.5_xl_turbo': { steps: { min: 4, max: 16, default: 8 }, shift: { min: 1, max: 6, default: 3 }, sampler: { allowed: ['euler', 'euler_ancestral'], default: 'euler' }, scheduler: { allowed: ['simple'], default: 'simple' } }, 'ace_step_1.5_xl_sft': { steps: { min: 10, max: 100, default: 50 }, guidance: { min: 1, max: 15, default: 5 }, shift: { min: 1, max: 6, default: 3 }, sampler: { allowed: ['euler', 'euler_ancestral', 'er_sde'], default: 'er_sde' }, scheduler: { allowed: ['simple', 'linear_quadratic'], default: 'linear_quadratic' } }, 'ace_step_1.5_turbo': { steps: { min: 4, max: 16, default: 8 }, shift: { min: 1, max: 6, default: 3 }, sampler: { allowed: ['euler', 'euler_ancestral'], default: 'euler' }, scheduler: { allowed: ['simple'], default: 'simple' } }, 'ace_step_1.5_sft': { steps: { min: 10, max: 100, default: 50 }, guidance: { min: 1, max: 15, default: 5 }, shift: { min: 1, max: 6, default: 3 }, sampler: { allowed: ['euler', 'euler_ancestral', 'er_sde'], default: 'er_sde' }, scheduler: { allowed: ['simple', 'linear_quadratic'], default: 'linear_quadratic' } } }; const MUSIC_DURATION_LIMITS = { min: 10, max: 600, default: 30 }; const MUSIC_BPM_LIMITS = { min: 30, max: 300, default: 120 }; const MUSIC_PROMPT_STRENGTH_LIMITS = { min: 0, max: 10 }; const MUSIC_CREATIVITY_LIMITS = { min: 0, max: 2 }; const MUSIC_OUTPUT_FORMATS = new Set(['mp3', 'flac', 'wav']); const MUSIC_TIME_SIGNATURES = new Set(['2', '3', '4', '6']); function expandHomePath(rawPath) { if (typeof rawPath !== 'string') return rawPath; if (rawPath === '~') return homedir(); if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) { return join(homedir(), rawPath.slice(2)); } return rawPath; } function resolveConfiguredPath(rawPath, fallbackPath, label) { const candidate = expandHomePath(rawPath) || fallbackPath; return sanitizePath(candidate, label); } async function disableLiveModelAvailabilityEvents(wrapper) { const sdkClient = wrapper?.client; try { if (typeof sdkClient?.setSocketEventSubscriptions === 'function') { await sdkClient.setSocketEventSubscriptions(SOCKET_EVENT_SUBSCRIPTIONS); } } catch (err) { // Subscription optimization is best-effort and must not block generation. } } function isPathWithinBase(basePath, targetPath) { return targetPath === basePath || targetPath.startsWith(`${basePath}${sep}`); } function buildCliErrorPayload({ message, code, details, hint, prompt }) { const classified = classifyCliError({ message, code }); const payload = { success: false, error: classified.message || message || 'Unknown error', errorType: classified.error_type, errorCategory: classified.category, retryable: classified.retryable, prompt: prompt ?? null }; if (classified.metadata) payload.metadata = classified.metadata; if (classified.technicalError && classified.technicalError !== payload.error) { payload.technicalError = classified.technicalError; } if (code) payload.errorCode = code; if (details) payload.errorDetails = details; if (hint) payload.hint = hint; if (applySubscriptionBillingEnrichment(payload, code)) { // Subscription-billing code (4078-4081) — handled with subscription-specific // guidance; skip the generic "Buy Spark Packs" credits branch. } else if (classified.category === 'insufficient_credits') { payload.purchaseAction = true; payload.purchaseLabel = 'Buy Spark Packs'; payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL; payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT; if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT; } payload.timestamp = new Date().toISOString(); payload.node = process.versions.node; payload.cwd = process.cwd(); if (IS_OPENCLAW_INVOCATION) payload.openclaw = true; return payload; } function cliErrorMessage(error) { if (typeof error === 'string') return error; if (error instanceof Error) return error.message || String(error); if (error && typeof error === 'object') { const record = error; if (typeof record.message === 'string') return record.message; if (typeof record.error === 'string') return record.error; } return String(error ?? 'Unknown error'); } function seedanceFriendlyGenerationMessage(payload) { const raw = [ payload?.message, payload?.vendorError, payload?.vendorErrorCode ].filter(Boolean).join(' '); if (/\baudio\s+format\b[\s\S]{0,120}\b(?:not valid|invalid)\b/i.test(raw)) { return 'Seedance rejected the audio reference format for this model. Try a different audio file, trim/convert the clip, or use a non-Seedance audio-driven workflow such as LTX sound-to-video.'; } return payload?.message || 'Seedance could not complete this video.'; } // Classify a HappyHorse terminal policy / generation-failure error into the // canonical CLI error shape, or null when neither HappyHorse matcher applies. // Factored out so it can run either AFTER the Seedance matchers (default order) // or BEFORE them when the failing model is known to be HappyHorse (see // classifyCliError). function classifyHappyHorseCliError(error, rawMessage) { const happyhorsePolicyPayload = happyhorseTerminalPolicyPayloadFromError(error); if (happyhorsePolicyPayload) { return { error_type: 'SAFETY_REJECTED', category: 'content_refused', message: happyhorsePolicyPayload.message, retryable: false, metadata: happyhorsePolicyPayload, technicalError: rawMessage }; } const happyhorseGenerationPayload = happyhorseTerminalGenerationFailurePayloadFromError(error); if (happyhorseGenerationPayload) { const vendorCode = happyhorseGenerationPayload.vendorErrorCode; const isInvalidParameter = vendorCode === 'InvalidParameter' || happyhorseGenerationPayload.error === 'happyhorse_input_download_failed'; return { error_type: isInvalidParameter ? 'PARAMETER_INVALID' : 'GPU_WORKER_FAILED', category: isInvalidParameter ? 'schema_validation' : 'transient_failure', message: happyhorseGenerationPayload.message || 'HappyHorse could not complete this video.', retryable: !isInvalidParameter, metadata: happyhorseGenerationPayload, technicalError: rawMessage }; } return null; } function classifyCliError(error, context = {}) { const rawMessage = cliErrorMessage(error); // Client-side CLI rejections are the plain `{ message, code }` shape built by // buildCliErrorPayload (never an Error instance and never a vendor poll body). // They are argument-validation failures, so skip the HappyHorse terminal // matchers — the HappyHorse generation matcher keys off a bare "happyhorse" // mention and would otherwise re-wrap a validation message (e.g. "HappyHorse // models do not support ControlNet") as a retryable vendor generation failure. const isClientSideCliPayload = error && typeof error === 'object' && !(error instanceof Error) && typeof error.message === 'string' && !('output' in error) && !('vendorError' in error) && !('vendorErrorCode' in error); // When the failing model is HappyHorse, run the HappyHorse matchers BEFORE the // generic Seedance generation matcher. That matcher keys off vendor-agnostic // socket failure text ("All N video generation jobs failed", "Vendor task ... // status=failed", "Vendor job failed"), which a HappyHorse failure also // produces, so without this model-aware reordering a HappyHorse vendor failure // would be misattributed to Seedance. The client-side-payload guard still // applies so a CLI validation message that merely names HappyHorse is not // re-wrapped as a retryable vendor failure. const modelHint = context?.modelId; const preferHappyHorse = isHappyHorseModel(modelHint) || isHappyHorseModelSelectionLocal(modelHint); if (preferHappyHorse && !isClientSideCliPayload) { const happyhorseClassified = classifyHappyHorseCliError(error, rawMessage); if (happyhorseClassified) return happyhorseClassified; } const seedancePolicyPayload = seedanceTerminalPolicyPayloadFromError(error); if (seedancePolicyPayload) { return { error_type: 'SAFETY_REJECTED', category: 'content_refused', message: seedancePolicyPayload.message, retryable: false, metadata: seedancePolicyPayload, technicalError: rawMessage }; } const seedanceGenerationPayload = seedanceTerminalGenerationFailurePayloadFromError(error); if (seedanceGenerationPayload) { const vendorCode = seedanceGenerationPayload.vendorErrorCode; const isInvalidParameter = vendorCode === 'InvalidParameter' || seedanceGenerationPayload.error === 'seedance_reference_audio_too_long'; return { error_type: isInvalidParameter ? 'PARAMETER_INVALID' : 'GPU_WORKER_FAILED', category: isInvalidParameter ? 'schema_validation' : 'transient_failure', message: seedanceFriendlyGenerationMessage(seedanceGenerationPayload), retryable: !isInvalidParameter, metadata: seedanceGenerationPayload, technicalError: rawMessage }; } // Default order: when the model is not known to be HappyHorse, run the // HappyHorse matchers after the Seedance matchers (preferHappyHorse already // ran them above when the model hint matched). if (!preferHappyHorse && !isClientSideCliPayload) { const happyhorseClassified = classifyHappyHorseCliError(error, rawMessage); if (happyhorseClassified) return happyhorseClassified; } return classifySkillError(error); } function addCanonicalErrorFields(payload, error, context = {}) { const classified = classifyCliError(error, context); payload.error = classified.message; payload.errorType = classified.error_type; payload.errorCategory = classified.category; payload.retryable = classified.retryable; if (classified.metadata) payload.metadata = classified.metadata; if (classified.technicalError && classified.technicalError !== classified.message) { payload.technicalError = classified.technicalError; } const subscriptionCode = subscriptionBillingCodeFromError(error); if (applySubscriptionBillingEnrichment(payload, subscriptionCode)) { // Subscription-billing code (4078-4081) — handled with subscription-specific // guidance; skip the generic "Buy Spark Packs" credits branch. } else if (classified.category === 'insufficient_credits') { payload.purchaseAction = true; payload.purchaseLabel = 'Buy Spark Packs'; payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL; payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT; if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT; } return payload; } // Human-facing twin of addCanonicalErrorFields: print the classified, friendly // message (with the raw message as a detail line when it differs) so human // users get the same quality of error JSON consumers already receive. function printHumanError(error, context = {}) { let classified = null; try { classified = classifyCliError(error, context); } catch { /* fall back to raw */ } const message = classified?.message || error?.message || String(error); console.error(`Error: ${message}`); if (classified?.technicalError && classified.technicalError !== message) { console.error(`Details: ${classified.technicalError}`); } const hint = error?.hint || (classified?.category === 'insufficient_credits' ? SPARK_PACKS_PURCHASE_HINT : null); if (hint) console.error(`Hint: ${hint}`); } function fatalCliError(message, opts = {}) { let prompt = opts.prompt; if (prompt === undefined) { try { // If parsing already populated options, include prompt for better downstream reporting. prompt = options?.prompt ?? null; } catch (e) { prompt = null; } } const payload = buildCliErrorPayload({ message, code: opts.code, details: opts.details, hint: opts.hint, prompt }); if (JSON_ERROR_MODE) { console.log(JSON.stringify(payload)); if (!CLI_WANTS_JSON) { // OpenClaw expects JSON, but humans still benefit from stderr. console.error(`Error: ${payload.error}`); if (payload.hint) console.error(`Hint: ${payload.hint}`); } } else { console.error(`Error: ${payload.error}`); if (payload.hint) console.error(`Hint: ${payload.hint}`); } process.exit(1); } // Friendly guidance shown when the Sogni API key is missing or rejected. const INVALID_API_KEY_HINT = 'Your Sogni API key was rejected. Verify it — or generate a new one — by ' + 'logging into https://dashboard.sogni.ai and opening the account menu. ' + "If you don't have a Sogni account yet, create one there first, then add its API key."; // Detect an invalid/rejected API key across the several shapes the SDK can // surface it in. The SDK reports the REST 401 directly (ApiError with // status/errorCode), but it can also cascade: a 401 triggers // ApiKeyAuthManager.clear(), which tears down the socket and re-throws as an // unhandled "WebSocket was closed before the connection was established" // error whose only auth fingerprint is the stack frame. function isInvalidApiKeyError(error) { if (!error) return false; const status = error.status ?? error.statusCode ?? error?.payload?.status; const apiCode = error?.payload?.errorCode ?? error?.errorCode; if (status === 401 || apiCode === 101) return true; const message = (cliErrorMessage(error) || '').toLowerCase(); if (message.includes('invalid api key')) return true; const stack = (typeof error?.stack === 'string' ? error.stack : '').toLowerCase(); if (stack.includes('apikeyauthmanager') || stack.includes('handleauthupdated')) return true; return false; } // Last line of defense. The SDK can reject from a detached promise or emit an // unhandled 'error' event during connect, which escapes main()'s try/catch and // crashes the process with a raw stack trace. These handlers turn any such // fatal into the same clean `Error:`/`Hint:` (or JSON) output as every other // CLI error path, and exit 1. let __fatalReported = false; function reportFatalError(error) { if (__fatalReported) { try { process.exit(1); } catch (_) { /* already exiting */ } return; } __fatalReported = true; if (getEnv('SOGNI_DEBUG') || getEnv('DEBUG')) { console.error(error?.stack || String(error)); } if (isInvalidApiKeyError(error)) { fatalCliError('Invalid Sogni API key.', { code: 'INVALID_API_KEY', hint: INVALID_API_KEY_HINT }); return; } fatalCliError(cliErrorMessage(error), { code: error?.code, details: error?.details, hint: error?.hint }); } process.on('uncaughtException', reportFatalError); process.on('unhandledRejection', reportFatalError); // Connect to Sogni, mapping a rejected connection into a clean auth error // where we can. (Detached SDK failures that never reach this await are caught // by the global handlers above.) async function connectSogniClient(client) { try { await client.connect(); } catch (error) { if (isInvalidApiKeyError(error) && !error.hint) { error.hint = INVALID_API_KEY_HINT; if (!error.code) error.code = 'INVALID_API_KEY'; } throw error; } } function applyVideoPromptGuardrails() { if (!options.video || !options.prompt) return; if (options._literalPrompt) return; const plan = getVideoPromptGuardrailPlan({ prompt: options.prompt, duration: options.duration, frames: options.frames, fps: options.fps, durationExplicit: cliSet.duration, referenceAudioIdentity: options.referenceAudioIdentity, voiceName: options._voicePersonaResolvedName || options.voicePersonaName || 'SPEAKER' }); options.prompt = plan.prompt; options.duration = plan.duration; if (!options.quiet) { for (const warning of plan.warnings) { console.error(warning.message); } } } function applyCreativeBrainPreflight() { if (!options.video || !options.prompt) return; const plan = planCliVideoBrain({ video: options.video, prompt: options.prompt, model: options.model, workflow: options.videoWorkflow, width: options.width, height: options.height, duration: options.duration, frames: options.frames, targetResolution: options.targetResolution, refImage: options.refImage, refImageEnd: options.refImageEnd, refAudio: options.refAudio, refVideo: options.refVideo, cliSet: { model: cliSet.model, workflow: cliSet.workflow, width: cliSet.width, height: cliSet.height, targetResolution: cliSet.targetResolution, duration: cliSet.duration, frames: cliSet.frames } }); if (plan.literalPrompt) { options._literalPrompt = true; } if (plan.prompt && plan.prompt !== options.prompt) { options.prompt = plan.prompt; } if (plan.model && !cliSet.model) { options.model = plan.model; } if (plan.workflow && !cliSet.workflow) { options.videoWorkflow = plan.workflow; } if (Number.isFinite(plan.duration) && !cliSet.duration && !cliSet.frames) { options.duration = plan.duration; durationFromPrompt = true; } if ( plan.dimensionSource === 'exact' && Number.isFinite(plan.width) && Number.isFinite(plan.height) && !cliSet.width && !cliSet.height ) { options.width = plan.width; options.height = plan.height; widthFromPrompt = true; heightFromPrompt = true; } if (plan.dimensionSource === 'aspect' && plan.aspectRatio && !cliSet.width && !cliSet.height) { aspectRatioFromPrompt = plan.aspectRatio; } if ( Number.isFinite(plan.targetResolution) && !cliSet.targetResolution && !cliSet.width && !cliSet.height && !widthFromPrompt && !heightFromPrompt ) { options.targetResolution = plan.targetResolution; targetResolutionFromPrompt = true; } if (plan.storyboard) { options._seedanceStoryboardPlan = plan.storyboard; } } function normalizeSeedStrategy(value) { if (!value) return null; const normalized = value.toLowerCase(); if (normalized === 'random') return 'random'; if (normalized === 'prompt-hash' || normalized === 'prompt_hash') return 'prompt-hash'; return null; } function normalizeApiToolMode(value) { const normalized = String(value || 'creative-agent').toLowerCase(); if (normalized === 'creative-agent') return 'creative-agent'; if (normalized === 'creative-tools') return 'creative-tools'; if (normalized === 'true') return true; if (normalized === 'none' || normalized === 'false') return false; return null; } function normalizeApiWorkflowTemplate(value) { const normalized = String(value || '').toLowerCase().replace(/-/g, '_'); if (normalized === 'storyboard_video' || normalized === 'storyboard_to_video' || normalized === 'gpt_image_2_seedance' || normalized === 'gpt_image_seedance') { return 'storyboard_video'; } return null; } function appendApiPath(baseUrl, path) { const base = String(baseUrl || DEFAULT_API_BASE_URL).replace(/\/+$/, ''); const suffix = path.startsWith('/') ? path : `/${path}`; return `${base}${suffix}`; } function getApiBaseUrl() { return options.apiBaseUrl || getEnv('SOGNI_API_BASE_URL') || getEnv('SOGNI_REST_ENDPOINT') || DEFAULT_API_BASE_URL; } function getApiAllowedHosts() { const configured = String(getEnv('SOGNI_API_ALLOWED_HOSTS') || '') .split(',') .map((host) => host.trim().toLowerCase()) .filter(Boolean); return Array.from(new Set([...DEFAULT_SAFE_API_HOSTS, ...configured])); } function allowUnsafeApiBaseUrl() { return getEnv('SOGNI_ALLOW_UNSAFE_API_BASE_URL') === '1'; } function isLoopbackApiUrl(parsed) { return LOOPBACK_API_HOSTS.includes(parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()); } async function buildSafeApiUrl(path) { const url = appendApiPath(getApiBaseUrl(), path); const unsafeAllowed = allowUnsafeApiBaseUrl(); let parsed; try { parsed = new URL(url); } catch { const err = new Error('Invalid Sogni API base URL.'); err.code = 'INVALID_API_BASE_URL'; throw err; } const hasEmbeddedCredentials = Boolean(parsed['user' + 'name'] || parsed['pass' + 'word']); if (hasEmbeddedCredentials) { const err = new Error('Sogni API base URL must not contain credentials.'); err.code = 'UNSAFE_API_BASE_URL'; throw err; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { const err = new Error(`Sogni API URL protocol ${parsed.protocol} is not allowed.`); err.code = 'UNSAFE_API_BASE_URL'; throw err; } if (unsafeAllowed) return url; if (isLoopbackApiUrl(parsed)) { const err = new Error('Loopback Sogni API base URLs require SOGNI_ALLOW_UNSAFE_API_BASE_URL=1 for isolated local testing.'); err.code = 'UNSAFE_API_BASE_URL'; throw err; } try { await assertSafeUrl(url, { allowedProtocols: ['https:'], allowedHosts: getApiAllowedHosts() }); } catch (err) { const wrapped = new Error( `${err.message}. Set SOGNI_API_ALLOWED_HOSTS for a trusted custom API host, or SOGNI_ALLOW_UNSAFE_API_BASE_URL=1 for isolated local testing.` ); wrapped.code = 'UNSAFE_API_BASE_URL'; wrapped.cause = err; throw wrapped; } return url; } function generateRandomSeed() { return randomBytes(4).readUInt32BE(0); } // --------------------------------------------------------------------------- // Dynamic prompt variations — {option1|option2|option3} syntax // For count > 1, cycles through options sequentially per image. // --------------------------------------------------------------------------- const VARIATION_PATTERN = /\{([^}]+)\}/g; function hasPromptVariations(prompt) { return /\{[^}]+\}/.test(prompt); } function expandPromptVariation(prompt, index) { return prompt.replace(VARIATION_PATTERN, (_match, group) => { const options = group.split('|').map(s => s.trim()); return options[index % options.length]; }); } function computePromptHashSeed(opts) { const payload = { prompt: opts.prompt || '', model: opts.model || '', workflow: opts.video ? opts.videoWorkflow : opts.music ? 'music' : 'image', width: opts.width, height: opts.height, azimuth: opts.azimuth || '', elevation: opts.elevation || '', distance: opts.distance || '', angleDescription: opts.angleDescription || '', outputFormat: opts.outputFormat || '', sampler: opts.sampler || '', scheduler: opts.scheduler || '', musicLyrics: opts.musicLyrics || '', musicLanguage: opts.musicLanguage || '', musicBpm: opts.musicBpm ?? null, musicKeyscale: opts.musicKeyscale || '', musicTimesig: opts.musicTimesig || '', musicComposerMode: opts.musicComposerMode ?? null, musicPromptStrength: opts.musicPromptStrength ?? null, musicCreativity: opts.musicCreativity ?? null, musicShift: opts.musicShift ?? null, targetResolution: opts.targetResolution ?? null, loras: opts.loras || [], loraStrengths: opts.loraStrengths || [], refImage: opts.refImage || '', refImageEnd: opts.refImageEnd || '', refAudio: opts.refAudio || '', audioStart: opts.audioStart ?? null, audioDuration: opts.audioDuration ?? null, referenceAudioIdentity: opts.referenceAudioIdentity || '', refVideo: opts.refVideo || '', videoStart: opts.videoStart ?? null, contextImages: opts.contextImages || [], autoResizeVideoAssets: opts.autoResizeVideoAssets, tokenType: opts.tokenType || '', steps: opts.steps ?? null, guidance: opts.guidance ?? null }; const hash = createHash('sha256').update(JSON.stringify(payload)).digest(); return hash.readUInt32BE(0); } function parseCsv(value) { if (!value) return []; return value.split(',').map((entry) => entry.trim()).filter(Boolean); } function parseNumberValue(raw, flagName) { const num = Number(raw); if (!Number.isFinite(num)) { fatalCliError(`${flagName} must be a number.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw } }); } return num; } function parseNonNegativeNumberValue(raw, flagName) { const num = parseNumberValue(raw, flagName); if (num < 0) { fatalCliError(`${flagName} must be >= 0.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, min: 0 } }); } return num; } function parseNumberList(raw, flagName) { const entries = parseCsv(raw); return entries.map((entry) => parseNumberValue(entry, flagName)); } function parseBoundedNumberValue(raw, flagName, limits) { const num = parseNumberValue(raw, flagName); if (num < limits.min || num > limits.max) { fatalCliError(`${flagName} must be between ${limits.min} and ${limits.max}.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, min: limits.min, max: limits.max } }); } return num; } function requireFlagValue(argv, index, flagName) { const value = argv[index + 1]; if (value === undefined) { fatalCliError(`${flagName} requires a value.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName } }); } return value; } function parseIntegerValue(raw, flagName) { const num = Number(raw); if (!Number.isInteger(num)) { fatalCliError(`${flagName} must be an integer.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw } }); } return num; } function parsePositiveIntegerValue(raw, flagName, min = 1, max = Infinity) { const num = parseIntegerValue(raw, flagName); if (num < min) { fatalCliError(`${flagName} must be >= ${min}.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, min } }); } if (num > max) { fatalCliError(`${flagName} must be <= ${max}.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, max } }); } return num; } // Sanity ceiling for image dimensions — well above any model's real maximum, // just large enough to catch obvious typos (e.g. a stray extra zero) before // they waste a round-trip or blow up local memory. const MAX_IMAGE_DIMENSION = 8192; // Safety cap for -n/--count: every output is a paid generation, so a typo like // `-n 1000` (meant `-n 10`) must not launch a thousand-render batch. Raise // deliberately with SOGNI_MAX_COUNT when a bigger batch is really wanted. const DEFAULT_MAX_COUNT = 16; const MAX_COUNT = (() => { const raw = Number.parseInt(getEnv('SOGNI_MAX_COUNT') || '', 10); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_COUNT; })(); function parseSeedValue(raw, flagName) { const num = parseIntegerValue(raw, flagName); if (num < 0 || num > 0xFFFFFFFF) { fatalCliError(`${flagName} must be between 0 and 4294967295.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw } }); } return num; } function formatTokenValue(value) { if (!Number.isFinite(value)) return 'unknown'; return value.toFixed(2); } function parseCostEstimate(estimate, tokenType) { if (!estimate) return null; const raw = tokenType === 'sogni' ? estimate.sogni ?? estimate.token : estimate.spark ?? estimate.token; const value = Number.parseFloat(raw); return Number.isFinite(value) ? value : null; } function buildBalanceError(message, details) { const err = new Error(message); err.code = 'INSUFFICIENT_BALANCE'; err.details = details || null; err.hint = SPARK_PACKS_PURCHASE_HINT; return err; } function isStructuredInsufficientBalanceError(error) { return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_BALANCE'); } /** * Build an Error from an SDK project result that signals failure via * `{ error, message, code, details, hint }` fields instead of throwing. * Preserving `code` is critical — without it, downstream classification * (auto-fallback retry via `isStructuredInsufficientBalanceError`, and * the `insufficient_credits` payload enrichment in `buildCliErrorPayload` * / `addCanonicalErrorFields`) cannot tell that the failure is e.g. * `INSUFFICIENT_BALANCE`, so the "Buy Spark Packs" CTA silently no-ops. */ function buildProjectResultError(projectResult) { const message = projectResult?.error || projectResult?.message || 'Project failed'; const err = new Error(message); if (projectResult?.code) err.code = projectResult.code; if (projectResult?.details) err.details = projectResult.details; if (projectResult?.hint) err.hint = projectResult.hint; if (classifyCliError(err).category === 'insufficient_credits' && !err.hint) { err.hint = SPARK_PACKS_PURCHASE_HINT; } return err; } function gcdInt(a, b) { let x = Math.abs(Math.trunc(a)); let y = Math.abs(Math.trunc(b)); while (y !== 0) { const t = y; y = x % y; x = t; } return x || 1; } function isHttpUrl(value) { return typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://')); } function isHttpsUrl(value) { if (typeof value !== 'string') return false; try { return new URL(value).protocol === 'https:'; } catch { return false; } } function getPngDimensions(buffer) { if (!buffer || buffer.length < 24) return null; // PNG signature: 89 50 4E 47 0D 0A 1A 0A if ( buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47 || buffer[4] !== 0x0D || buffer[5] !== 0x0A || buffer[6] !== 0x1A || buffer[7] !== 0x0A ) { return null; } try { const width = buffer.readUInt32BE(16); const height = buffer.readUInt32BE(20); if (!width || !height) return null; return { width, height, type: 'png' }; } catch { return null; } } function getJpegDimensions(buffer) { if (!buffer || buffer.length < 4) return null; // JPEG SOI: FF D8 if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) return null; // Walk segments until we find a Start Of Frame marker that contains dimensions. // Common SOF markers: C0 (baseline), C1, C2 (progressive), C3, C5-C7, C9-CB, CD-CF let i = 2; while (i + 9 < buffer.length) { // Find marker prefix 0xFF if (buffer[i] !== 0xFF) { i++; continue; } // Skip fill bytes 0xFF while (i < buffer.length && buffer[i] === 0xFF) i++; if (i >= buffer.length) break; const marker = buffer[i]; i++; // Markers without a length field if (marker === 0xD9 || marker === 0xDA) break; // EOI or SOS if (marker >= 0xD0 && marker <= 0xD7) continue; // RSTn if (i + 1 >= buffer.length) break; const segmentLength = buffer.readUInt16BE(i); if (segmentLength < 2) break; const segmentStart = i + 2; const isSof = (marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF); if (isSof) { if (segmentStart + 7 >= buffer.length) break; try { const height = buffer.readUInt16BE(segmentStart + 1); const width = buffer.readUInt16BE(segmentStart + 3); if (!width || !height) return null; return { width, height, type: 'jpg' }; } catch { return null; } } i = segmentStart + (segmentLength - 2); } return null; } function getImageDimensionsFromBuffer(buffer) { return getPngDimensions(buffer) || getJpegDimensions(buffer); } const DEFAULT_VIDEO_DIMENSION_RULES = { minDimension: 480, maxDimension: 1536, dimensionMultiple: 16 }; const WRAPPER_MAX_VIDEO_DIMENSION = 2048; const WRAPPER_MAX_WAN_VIDEO_DIMENSION = 1536; const VIDEO_DIMENSION_MULTIPLE = DEFAULT_VIDEO_DIMENSION_RULES.dimensionMultiple; function isWanVideoModelId(modelId) { return typeof modelId === 'string' && modelId.startsWith('wan_'); } function isWanAnimateVideoModelId(modelId) { return typeof modelId === 'string' && ( modelId.includes('_animate-move') || modelId.includes('_animate-replace') || modelId.includes('_animate_move') || modelId.includes('_animate_replace') ); } function isGptImage2ModelSelection(modelId) { const normalized = String(modelId || '').trim().toLowerCase(); return ['gpt-image-2', 'gptimage2', 'gpt-image', 'gpt_image_2'].includes(normalized); } function normalizeMusicModelId(value) { const raw = String(value || '').trim(); if (!raw) return null; const normalized = raw.toLowerCase().replace(/-/g, '_').replace(/ace_step_1_5/g, 'ace_step_1.5'); return MUSIC_MODEL_IDS[normalized] || (MUSIC_MODEL_DEFAULTS[normalized] ? normalized : null); } function getMusicModelDefaults(modelId) { return MUSIC_MODEL_DEFAULTS[normalizeMusicModelId(modelId)] || null; } function normalizeMusicTimeSignature(value) { const raw = String(value || '').trim(); if (!raw) return null; const match = raw.match(/^([2346])(?:\s*\/\s*(?:4|8))?$/); return match ? match[1] : raw; } function requiresSparkOnlyToken(modelId) { return isGptImage2ModelSelection(modelId) || isSeedanceModel(modelId) || isHappyHorseModel(modelId); } // Attach the user's explicit --billing-mode to a project config. Omitted by // default so the server keeps deciding coverage (Unlimited members get 'auto' // coverage server-side; token payers are unaffected). function withBillingMode(config) { if (!options.billingMode) return config; return { ...config, billingMode: options.billingMode }; } // Best-effort Sogni Unlimited entitlement lookup. Returns null (never throws) // when the wrapper predates getSubscriptionStatus() or the request fails, so // callers degrade to balance-only behavior. async function fetchSubscriptionSnapshot(client, log) { if (typeof client?.getSubscriptionStatus !== 'function') return null; try { return await client.getSubscriptionStatus(); } catch (err) { if (!options.quiet && typeof log === 'function') { log(`Warning: could not fetch subscription status (${err?.message || 'error'})`); } return null; } } const SUBSCRIPTION_TIER_LABELS = { unlimited: 'Sogni Unlimited', unlimited_pro: 'Sogni Unlimited Pro', }; function describeSubscription(subscription) { if (!subscription || subscription.active !== true) return 'none'; const tierKey = String(subscription.tier || '').toLowerCase(); const label = SUBSCRIPTION_TIER_LABELS[tierKey] || subscription.tier || 'subscription'; return `${label} (${subscription.status || 'active'})`; } // HappyHorse 1.1 ships three discrete vendor models (no mini/fast). The shared // intel client only resolves the fully-qualified ids; accept the bare // `happyhorse` / `happyhorse-1.1` selector here so `-m happyhorse` works the way // `-m seedance2` does, and pin the concrete per-mode model id. const HAPPYHORSE_VIDEO_MODES = new Set(['t2v', 'i2v', 'r2v']); function isHappyHorseModelSelectionLocal(modelId) { if (isHappyHorseModelSelection(modelId)) return true; const key = String(modelId || '').trim().toLowerCase(); return key === 'happyhorse' || key === 'happyhorse-1.1'; } function resolveHappyHorseModelId(modelId, workflow) { if (!isHappyHorseModelSelectionLocal(modelId)) return modelId; const key = String(modelId || '').trim().toLowerCase(); if (key === 'happyhorse' || key === 'happyhorse-1.1') { const mode = HAPPYHORSE_VIDEO_MODES.has(workflow) ? workflow : 't2v'; return `happyhorse-1.1-${mode}`; } return modelId; } // The per-mode workflow pinned by a concrete `happyhorse-1.1-<mode>` model id, or // null for the bare `happyhorse` / `happyhorse-1.1` alias (mode inferred from refs). function happyHorseModeFromModelId(modelId) { const match = String(modelId || '').trim().toLowerCase().match(/^happyhorse-1\.1-(t2v|i2v|r2v)$/); return match ? match[1] : null; } function getMaxContextImages(modelId) { if (isGptImage2ModelSel