@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
JavaScript
#!/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