Files
sim/packages/ts-sdk/src/index.ts
Waleed dc0ed842c4 fix(sdk): improve input handling and separate input from options (#2993)
* fix(sdk): improve input handling and separate input from options

* fix(sdk): treat null as no input for consistency with Python SDK
2026-01-25 00:50:09 -08:00

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 }