@sogni-ai/sogni-creative-agent-skill
Version:
Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.
233 lines (211 loc) • 7.88 kB
JavaScript
/**
* Sogni Hosted Client Factory (Phase 6 P0).
*
* Wraps the SDK-backed client re-exported by
* `@sogni-ai/sogni-intelligence-client` for the durable hosted workflow +
* chat surfaces the skill needs, while preserving the skill's SSRF guard
* contract.
*
* The skill historically called these endpoints via `fetchApiJson` ->
* `buildSafeApiUrl` -> `assertSafeUrl`. Migrating to the SDK directly
* would lose the SSRF check because `SogniClient.createInstance`
* accepts an arbitrary `restEndpoint`. This factory closes the gap:
* - Validate the resolved REST + Socket endpoints via `assertSafeUrl`
* **before** constructing the client.
* - Pin the resolved endpoint on the client so subsequent SDK calls
* can't be redirected to an unsafe host.
* - Expose a narrow `withClient(apiKey, work)` pattern that
* constructs + disposes the SDK client per task, mirroring the
* short-lived per-request shape the skill already used for fetch.
*
* Opt-in: callers receive the factory only when
* `SOGNI_SKILL_USE_SDK_TRANSPORT` is truthy. The legacy fetch path
* remains the default until the durable chat run methods are
* battle-tested in production.
*/
import { createRequire } from 'node:module';
import { assertSafeUrl } from './ssrf-guard.mjs';
import { getOrCreateSogniAppId } from './sogni-app-id.mjs';
const require = createRequire(import.meta.url);
const { SogniClient } = require('@sogni-ai/sogni-intelligence-client');
function readBoolEnv(name) {
const raw = process.env[name];
if (!raw) return false;
const value = String(raw).trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
}
/**
* Returns true when the skill should route hosted operations through
* the SDK transport. Default: false. Operators opt in via
* `SOGNI_SKILL_USE_SDK_TRANSPORT=1` once they've validated the SDK
* path in their environment.
*/
export function shouldUseSdkTransport() {
return readBoolEnv('SOGNI_SKILL_USE_SDK_TRANSPORT');
}
/**
* Validate the resolved API endpoint via the SSRF guard.
*
* Accepts `restEndpoint` (https://api.sogni.ai) and `socketEndpoint`
* (wss://socket.sogni.ai) shaped values. Throws when either fails the
* guard so the caller can fall back to the legacy fetch path or abort
* cleanly.
*/
export async function assertSafeSogniEndpoints({ restEndpoint, socketEndpoint }) {
if (restEndpoint) await assertSafeUrl(restEndpoint);
if (socketEndpoint) {
// assertSafeUrl validates `http(s)` schemes; convert ws(s) → https for
// host/IP resolution checks. The actual websocket connection still
// uses the original scheme.
const httpsEquivalent = socketEndpoint.replace(/^ws/i, 'http');
await assertSafeUrl(httpsEquivalent);
}
}
export function buildHostedClientConfig({
apiKey,
restEndpoint,
socketEndpoint,
appSource,
appId,
attribution,
}) {
return {
appId: appId ?? getOrCreateSogniAppId(),
apiKey,
appSource: appSource ?? 'sogni-creative-agent-skill',
...(attribution ? { attribution } : {}),
logLevel: 'error',
...(restEndpoint ? { restEndpoint } : {}),
...(socketEndpoint ? { socketEndpoint } : {}),
// Every operation routed through this factory is REST or SSE. Avoid
// creating a short-lived WebSocket for each hosted call or upload-URL
// lookup; those connections would add noise to connection telemetry.
disableSocket: true,
socketEventSubscriptions: { modelAvailability: false }
};
}
/**
* Construct a managed `SogniClient` for the duration of `work`. Disposes
* the socket connection on completion (matching the per-request shape
* the skill already used for fetch). The skill is short-lived enough
* that pooling isn't required; the API process owns long-running pools
* via `SogniClientSessionService`.
*/
export async function withHostedClient({
apiKey,
restEndpoint,
socketEndpoint,
appSource,
appId,
attribution,
}, work) {
await assertSafeSogniEndpoints({ restEndpoint, socketEndpoint });
const client = await SogniClient.createInstance(buildHostedClientConfig({
apiKey,
restEndpoint,
socketEndpoint,
appSource,
appId,
attribution,
}));
try {
return await work(client);
} finally {
try {
client.dispose();
} catch {
// Best-effort dispose; do not mask the original error.
}
}
}
/**
* Helper: start a durable creative workflow via the SDK and return the
* resulting record. Caller is responsible for SSRF endpoint validation;
* the factory handles that via `withHostedClient`.
*/
export async function sdkStartCreativeWorkflow(client, input, options = {}) {
return client.workflows.start(input, options);
}
export async function sdkGetCreativeWorkflow(client, workflowId) {
return client.workflows.get(workflowId);
}
export async function sdkListCreativeWorkflows(client, options = {}) {
return client.workflows.list(options);
}
export async function sdkListCreativeWorkflowEvents(client, workflowId) {
return client.workflows.events(workflowId);
}
export async function sdkCancelCreativeWorkflow(client, workflowId) {
return client.workflows.cancel(workflowId);
}
/**
* SSE iterator wrapper. The underlying SDK iterator yields parsed
* `CreativeWorkflowSseEvent`s; the skill's UI loop already knows how
* to interpret them, so we forward as-is.
*/
export async function* sdkStreamCreativeWorkflowEvents(client, workflowId, options = {}) {
yield* client.workflows.streamEvents(workflowId, options);
}
/**
* Synchronous hosted chat completion. Mirrors the
* `POST /v1/chat/completions` REST surface but goes through the SDK so
* the SSRF guard runs at client construction and the SDK's typed
* `HostedChatCompletionParams` shape is enforced. Returns the raw
* `HostedChatCompletionResult` so the skill's existing extractors
* (`extractChatMessage`, `extractChatWorkflows`) keep working.
*/
export async function sdkChatHostedCreate(client, params) {
return client.chat.hosted.create(params);
}
/**
* Submit a durable hosted chat run. Returns the persisted record
* immediately; the executor drives the LLM/tool loop server-side.
*/
export async function sdkChatRunsCreate(client, params) {
return client.chat.runs.create(params);
}
export async function sdkChatRunsGet(client, runId) {
return client.chat.runs.get(runId);
}
export async function sdkChatRunsCancel(client, runId, reason) {
return client.chat.runs.cancel(runId, reason);
}
/**
* SSE iterator for durable chat run events. Honors `lastEventId` in
* `options` so callers can resume after disconnect via Last-Event-ID
* replay.
*/
export async function* sdkChatRunsStreamEvents(client, runId, options = {}) {
yield* client.chat.runs.streamEvents(runId, options);
}
/**
* Image asset upload URL. Mirrors `GET /v1/image/uploadUrl` and
* returns the presigned URL string. Params shape:
* { imageId, jobId, type: 'referenceImage'|'referenceImageEnd'|'contextImageN'|..., contentType? }
*
* Returns the URL string the caller should `PUT` the asset to.
*/
export async function sdkImageUploadUrl(client, params) {
return client.projects.uploadUrl(params);
}
/**
* Image asset download URL. Mirrors `GET /v1/image/downloadUrl`.
*/
export async function sdkImageDownloadUrl(client, params) {
return client.projects.downloadUrl(params);
}
/**
* Media (audio/video) asset upload URL. Mirrors `GET /v1/media/uploadUrl`.
* Params shape:
* { id?, jobId, type: 'referenceAudio'|'referenceVideo'|..., contentType? }
*/
export async function sdkMediaUploadUrl(client, params) {
return client.projects.mediaUploadUrl(params);
}
/**
* Media (audio/video) asset download URL. Mirrors
* `GET /v1/media/downloadUrl`.
*/
export async function sdkMediaDownloadUrl(client, params) {
return client.projects.mediaDownloadUrl(params);
}