mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 07:24:55 -05:00
* fix(sdk): improve input handling and separate input from options * fix(sdk): treat null as no input for consistency with Python SDK
486 lines
12 KiB
TypeScript
486 lines
12 KiB
TypeScript
import fetch from 'node-fetch'
|
|
|
|
export interface SimStudioConfig {
|
|
apiKey: string
|
|
baseUrl?: string
|
|
}
|
|
|
|
export interface WorkflowExecutionResult {
|
|
success: boolean
|
|
output?: any
|
|
error?: string
|
|
logs?: any[]
|
|
metadata?: {
|
|
duration?: number
|
|
executionId?: string
|
|
[key: string]: any
|
|
}
|
|
traceSpans?: any[]
|
|
totalDuration?: number
|
|
}
|
|
|
|
export interface WorkflowStatus {
|
|
isDeployed: boolean
|
|
deployedAt?: string
|
|
needsRedeployment: boolean
|
|
}
|
|
|
|
export interface ExecutionOptions {
|
|
timeout?: number
|
|
stream?: boolean
|
|
selectedOutputs?: string[]
|
|
async?: boolean
|
|
}
|
|
|
|
export interface AsyncExecutionResult {
|
|
success: boolean
|
|
taskId: string
|
|
status: 'queued'
|
|
createdAt: string
|
|
links: {
|
|
status: string
|
|
}
|
|
}
|
|
|
|
export interface RateLimitInfo {
|
|
limit: number
|
|
remaining: number
|
|
reset: number
|
|
retryAfter?: number
|
|
}
|
|
|
|
export interface RetryOptions {
|
|
maxRetries?: number
|
|
initialDelay?: number
|
|
maxDelay?: number
|
|
backoffMultiplier?: number
|
|
}
|
|
|
|
export interface UsageLimits {
|
|
success: boolean
|
|
rateLimit: {
|
|
sync: {
|
|
isLimited: boolean
|
|
limit: number
|
|
remaining: number
|
|
resetAt: string
|
|
}
|
|
async: {
|
|
isLimited: boolean
|
|
limit: number
|
|
remaining: number
|
|
resetAt: string
|
|
}
|
|
authType: string
|
|
}
|
|
usage: {
|
|
currentPeriodCost: number
|
|
limit: number
|
|
plan: string
|
|
}
|
|
}
|
|
|
|
export class SimStudioError extends Error {
|
|
public code?: string
|
|
public status?: number
|
|
|
|
constructor(message: string, code?: string, status?: number) {
|
|
super(message)
|
|
this.name = 'SimStudioError'
|
|
this.code = code
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove trailing slashes from a URL
|
|
* Uses string operations instead of regex to prevent ReDoS attacks
|
|
* @param url - The URL to normalize
|
|
* @returns URL without trailing slashes
|
|
*/
|
|
function normalizeBaseUrl(url: string): string {
|
|
let normalized = url
|
|
while (normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
export class SimStudioClient {
|
|
private apiKey: string
|
|
private baseUrl: string
|
|
private rateLimitInfo: RateLimitInfo | null = null
|
|
|
|
constructor(config: SimStudioConfig) {
|
|
this.apiKey = config.apiKey
|
|
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai')
|
|
}
|
|
|
|
/**
|
|
* Convert File objects in input to API format (base64)
|
|
* Recursively processes nested objects and arrays
|
|
*/
|
|
private async convertFilesToBase64(
|
|
value: any,
|
|
visited: WeakSet<object> = new WeakSet()
|
|
): Promise<any> {
|
|
if (typeof File !== 'undefined' && value instanceof File) {
|
|
const arrayBuffer = await value.arrayBuffer()
|
|
const buffer = Buffer.from(arrayBuffer)
|
|
const base64 = buffer.toString('base64')
|
|
|
|
return {
|
|
type: 'file',
|
|
data: `data:${value.type || 'application/octet-stream'};base64,${base64}`,
|
|
name: value.name,
|
|
mime: value.type || 'application/octet-stream',
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
if (visited.has(value)) {
|
|
return '[Circular]'
|
|
}
|
|
visited.add(value)
|
|
const result = await Promise.all(
|
|
value.map((item) => this.convertFilesToBase64(item, visited))
|
|
)
|
|
visited.delete(value)
|
|
return result
|
|
}
|
|
|
|
if (value !== null && typeof value === 'object') {
|
|
if (visited.has(value)) {
|
|
return '[Circular]'
|
|
}
|
|
visited.add(value)
|
|
const converted: any = {}
|
|
for (const [key, val] of Object.entries(value)) {
|
|
converted[key] = await this.convertFilesToBase64(val, visited)
|
|
}
|
|
visited.delete(value)
|
|
return converted
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Execute a workflow with optional input data
|
|
* @param workflowId - The ID of the workflow to execute
|
|
* @param input - Input data to pass to the workflow (object, primitive, or array)
|
|
* @param options - Execution options (timeout, stream, async, etc.)
|
|
*/
|
|
async executeWorkflow(
|
|
workflowId: string,
|
|
input?: any,
|
|
options: ExecutionOptions = {}
|
|
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
|
|
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
|
|
const { timeout = 30000, stream, selectedOutputs, async } = options
|
|
|
|
try {
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error('TIMEOUT')), timeout)
|
|
})
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': this.apiKey,
|
|
}
|
|
if (async) {
|
|
headers['X-Execution-Mode'] = 'async'
|
|
}
|
|
|
|
let jsonBody: any = {}
|
|
if (input !== undefined && input !== null) {
|
|
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
|
|
jsonBody = { ...input }
|
|
} else {
|
|
jsonBody = { input }
|
|
}
|
|
}
|
|
|
|
jsonBody = await this.convertFilesToBase64(jsonBody)
|
|
|
|
if (stream !== undefined) {
|
|
jsonBody.stream = stream
|
|
}
|
|
if (selectedOutputs !== undefined) {
|
|
jsonBody.selectedOutputs = selectedOutputs
|
|
}
|
|
|
|
const fetchPromise = fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(jsonBody),
|
|
})
|
|
|
|
const response = await Promise.race([fetchPromise, timeoutPromise])
|
|
|
|
this.updateRateLimitInfo(response)
|
|
|
|
if (response.status === 429) {
|
|
const retryAfter = this.rateLimitInfo?.retryAfter || 1000
|
|
throw new SimStudioError(
|
|
`Rate limit exceeded. Retry after ${retryAfter}ms`,
|
|
'RATE_LIMIT_EXCEEDED',
|
|
429
|
|
)
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = (await response.json().catch(() => ({}))) as unknown as any
|
|
throw new SimStudioError(
|
|
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
|
|
errorData.code,
|
|
response.status
|
|
)
|
|
}
|
|
|
|
const result = await response.json()
|
|
return result as WorkflowExecutionResult | AsyncExecutionResult
|
|
} catch (error: any) {
|
|
if (error instanceof SimStudioError) {
|
|
throw error
|
|
}
|
|
|
|
if (error.message === 'TIMEOUT') {
|
|
throw new SimStudioError(`Workflow execution timed out after ${timeout}ms`, 'TIMEOUT')
|
|
}
|
|
|
|
throw new SimStudioError(error?.message || 'Failed to execute workflow', 'EXECUTION_ERROR')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the status of a workflow (deployment status, etc.)
|
|
*/
|
|
async getWorkflowStatus(workflowId: string): Promise<WorkflowStatus> {
|
|
const url = `${this.baseUrl}/api/workflows/${workflowId}/status`
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-API-Key': this.apiKey,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = (await response.json().catch(() => ({}))) as unknown as any
|
|
throw new SimStudioError(
|
|
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
|
|
errorData.code,
|
|
response.status
|
|
)
|
|
}
|
|
|
|
const result = await response.json()
|
|
return result as WorkflowStatus
|
|
} catch (error: any) {
|
|
if (error instanceof SimStudioError) {
|
|
throw error
|
|
}
|
|
|
|
throw new SimStudioError(error?.message || 'Failed to get workflow status', 'STATUS_ERROR')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a workflow synchronously (ensures non-async mode)
|
|
* @param workflowId - The ID of the workflow to execute
|
|
* @param input - Input data to pass to the workflow
|
|
* @param options - Execution options (timeout, stream, etc.)
|
|
*/
|
|
async executeWorkflowSync(
|
|
workflowId: string,
|
|
input?: any,
|
|
options: ExecutionOptions = {}
|
|
): Promise<WorkflowExecutionResult> {
|
|
const syncOptions = { ...options, async: false }
|
|
return this.executeWorkflow(workflowId, input, syncOptions) as Promise<WorkflowExecutionResult>
|
|
}
|
|
|
|
/**
|
|
* Validate that a workflow is ready for execution
|
|
*/
|
|
async validateWorkflow(workflowId: string): Promise<boolean> {
|
|
try {
|
|
const status = await this.getWorkflowStatus(workflowId)
|
|
return status.isDeployed
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a new API key
|
|
*/
|
|
setApiKey(apiKey: string): void {
|
|
this.apiKey = apiKey
|
|
}
|
|
|
|
/**
|
|
* Set a new base URL
|
|
*/
|
|
setBaseUrl(baseUrl: string): void {
|
|
this.baseUrl = normalizeBaseUrl(baseUrl)
|
|
}
|
|
|
|
/**
|
|
* Get the status of an async job
|
|
* @param taskId The task ID returned from async execution
|
|
*/
|
|
async getJobStatus(taskId: string): Promise<any> {
|
|
const url = `${this.baseUrl}/api/jobs/${taskId}`
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-API-Key': this.apiKey,
|
|
},
|
|
})
|
|
|
|
this.updateRateLimitInfo(response)
|
|
|
|
if (!response.ok) {
|
|
const errorData = (await response.json().catch(() => ({}))) as unknown as any
|
|
throw new SimStudioError(
|
|
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
|
|
errorData.code,
|
|
response.status
|
|
)
|
|
}
|
|
|
|
const result = await response.json()
|
|
return result
|
|
} catch (error: any) {
|
|
if (error instanceof SimStudioError) {
|
|
throw error
|
|
}
|
|
|
|
throw new SimStudioError(error?.message || 'Failed to get job status', 'STATUS_ERROR')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute workflow with automatic retry on rate limit
|
|
* @param workflowId - The ID of the workflow to execute
|
|
* @param input - Input data to pass to the workflow
|
|
* @param options - Execution options (timeout, stream, async, etc.)
|
|
* @param retryOptions - Retry configuration (maxRetries, delays, etc.)
|
|
*/
|
|
async executeWithRetry(
|
|
workflowId: string,
|
|
input?: any,
|
|
options: ExecutionOptions = {},
|
|
retryOptions: RetryOptions = {}
|
|
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
|
|
const {
|
|
maxRetries = 3,
|
|
initialDelay = 1000,
|
|
maxDelay = 30000,
|
|
backoffMultiplier = 2,
|
|
} = retryOptions
|
|
|
|
let lastError: SimStudioError | null = null
|
|
let delay = initialDelay
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
return await this.executeWorkflow(workflowId, input, options)
|
|
} catch (error: any) {
|
|
if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') {
|
|
throw error
|
|
}
|
|
|
|
lastError = error
|
|
|
|
if (attempt === maxRetries) {
|
|
break
|
|
}
|
|
|
|
const waitTime =
|
|
error.status === 429 && this.rateLimitInfo?.retryAfter
|
|
? this.rateLimitInfo.retryAfter
|
|
: Math.min(delay, maxDelay)
|
|
|
|
const jitter = waitTime * (0.75 + Math.random() * 0.5)
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, jitter))
|
|
|
|
delay *= backoffMultiplier
|
|
}
|
|
}
|
|
|
|
throw lastError || new SimStudioError('Max retries exceeded', 'MAX_RETRIES_EXCEEDED')
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit information
|
|
*/
|
|
getRateLimitInfo(): RateLimitInfo | null {
|
|
return this.rateLimitInfo
|
|
}
|
|
|
|
/**
|
|
* Update rate limit info from response headers
|
|
* @private
|
|
*/
|
|
private updateRateLimitInfo(response: any): void {
|
|
const limit = response.headers.get('x-ratelimit-limit')
|
|
const remaining = response.headers.get('x-ratelimit-remaining')
|
|
const reset = response.headers.get('x-ratelimit-reset')
|
|
const retryAfter = response.headers.get('retry-after')
|
|
|
|
if (limit || remaining || reset) {
|
|
this.rateLimitInfo = {
|
|
limit: limit ? Number.parseInt(limit, 10) : 0,
|
|
remaining: remaining ? Number.parseInt(remaining, 10) : 0,
|
|
reset: reset ? Number.parseInt(reset, 10) : 0,
|
|
retryAfter: retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : undefined,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current usage limits and quota information
|
|
*/
|
|
async getUsageLimits(): Promise<UsageLimits> {
|
|
const url = `${this.baseUrl}/api/users/me/usage-limits`
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-API-Key': this.apiKey,
|
|
},
|
|
})
|
|
|
|
this.updateRateLimitInfo(response)
|
|
|
|
if (!response.ok) {
|
|
const errorData = (await response.json().catch(() => ({}))) as unknown as any
|
|
throw new SimStudioError(
|
|
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
|
|
errorData.code,
|
|
response.status
|
|
)
|
|
}
|
|
|
|
const result = await response.json()
|
|
return result as UsageLimits
|
|
} catch (error: any) {
|
|
if (error instanceof SimStudioError) {
|
|
throw error
|
|
}
|
|
|
|
throw new SimStudioError(error?.message || 'Failed to get usage limits', 'USAGE_ERROR')
|
|
}
|
|
}
|
|
}
|
|
|
|
export { SimStudioClient as default }
|