chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Virtual environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
test_env/
# IDEs
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Environments
.env
.env.local
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# uv
uv.lock
+530
View File
@@ -0,0 +1,530 @@
# Sim Python SDK
The official Python SDK for [Sim](https://sim.ai), allowing you to execute workflows programmatically from your Python applications.
## Installation
```bash
pip install simstudio-sdk
```
## Quick Start
```python
import os
from simstudio import SimStudioClient
# Initialize the client
client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY", "your-api-key-here"),
base_url="https://sim.ai" # optional, defaults to https://sim.ai
)
# Execute a workflow
try:
result = client.execute_workflow("workflow-id")
print("Workflow executed successfully:", result)
except Exception as error:
print("Workflow execution failed:", error)
```
## API Reference
### SimStudioClient
#### Constructor
```python
SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
```
- `api_key` (str): Your Sim API key
- `base_url` (str, optional): Base URL for the Sim API (defaults to `https://sim.ai`)
#### Methods
##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None)
Execute a workflow with optional input data.
```python
# With dict input (spread at root level of request body)
result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
# With primitive input (wrapped as { input: value })
result = client.execute_workflow("workflow-id", "NVDA")
# With options (keyword-only arguments)
result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64.
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
- `stream` (bool, keyword-only): Enable streaming responses
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
##### get_workflow_status(workflow_id)
Get the status of a workflow (deployment status, etc.).
```python
status = client.get_workflow_status("workflow-id")
print("Is deployed:", status.is_deployed)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow
**Returns:** `WorkflowStatus`
##### validate_workflow(workflow_id)
Validate that a workflow is ready for execution.
```python
is_ready = client.validate_workflow("workflow-id")
if is_ready:
# Workflow is deployed and ready
pass
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow
**Returns:** `bool`
##### execute_workflow_sync(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None)
Execute a workflow synchronously (ensures non-async mode).
```python
result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, timeout=60.0)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input` (any, optional): Input data to pass to the workflow
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
- `stream` (bool, keyword-only): Enable streaming responses
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
**Returns:** `WorkflowExecutionResult`
##### get_job_status(job_id)
Get the status of an async job.
```python
status = client.get_job_status("job-id-from-async-execution")
print("Job status:", status)
```
**Parameters:**
- `job_id` (str): The job ID returned from async execution
**Returns:** `dict`
##### execute_with_retry(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, max_retries=3, initial_delay=1.0, max_delay=30.0, backoff_multiplier=2.0)
Execute a workflow with automatic retry on rate limit errors.
```python
result = client.execute_with_retry(
"workflow-id",
{"message": "Hello"},
timeout=30.0,
max_retries=3,
initial_delay=1.0,
max_delay=30.0,
backoff_multiplier=2.0
)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input` (any, optional): Input data to pass to the workflow
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
- `stream` (bool, keyword-only): Enable streaming responses
- `selected_outputs` (list, keyword-only): Block outputs to stream
- `async_execution` (bool, keyword-only): Execute asynchronously
- `max_retries` (int, keyword-only): Maximum retry attempts (default: 3)
- `initial_delay` (float, keyword-only): Initial delay in seconds (default: 1.0)
- `max_delay` (float, keyword-only): Maximum delay in seconds (default: 30.0)
- `backoff_multiplier` (float, keyword-only): Backoff multiplier (default: 2.0)
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
##### get_rate_limit_info()
Get current rate limit information from the last API response.
```python
rate_info = client.get_rate_limit_info()
if rate_info:
print("Remaining requests:", rate_info.remaining)
```
**Returns:** `RateLimitInfo` or `None`
##### get_usage_limits()
Get current usage limits and quota information.
```python
limits = client.get_usage_limits()
print("Current usage:", limits.usage)
```
**Returns:** `UsageLimits`
##### set_api_key(api_key)
Update the API key.
```python
client.set_api_key("new-api-key")
```
##### set_base_url(base_url)
Update the base URL.
```python
client.set_base_url("https://my-custom-domain.com")
```
##### close()
Close the underlying HTTP session.
```python
client.close()
```
## Data Classes
### WorkflowExecutionResult
```python
@dataclass
class WorkflowExecutionResult:
success: bool
output: Optional[Any] = None
error: Optional[str] = None
logs: Optional[list] = None
metadata: Optional[Dict[str, Any]] = None
trace_spans: Optional[list] = None
total_duration: Optional[float] = None
```
### WorkflowStatus
```python
@dataclass
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
needs_redeployment: bool = False
```
### SimStudioError
```python
class SimStudioError(Exception):
def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None):
super().__init__(message)
self.code = code
self.status = status
```
### AsyncExecutionResult
```python
@dataclass
class AsyncExecutionResult:
success: bool
job_id: str
status_url: str
execution_id: Optional[str] = None
message: str = ""
async_execution: bool = True
```
### RateLimitInfo
```python
@dataclass
class RateLimitInfo:
limit: int
remaining: int
reset: int
retry_after: Optional[int] = None
```
### UsageLimits
```python
@dataclass
class UsageLimits:
success: bool
rate_limit: Dict[str, Any]
usage: Dict[str, Any]
```
## Examples
### Basic Workflow Execution
```python
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
# Check if workflow is ready
is_ready = client.validate_workflow("my-workflow-id")
if not is_ready:
raise Exception("Workflow is not deployed or ready")
# Execute the workflow
result = client.execute_workflow(
"my-workflow-id",
{
"message": "Process this data",
"user_id": "12345"
}
)
if result.success:
print("Output:", result.output)
print("Duration:", result.metadata.get("duration") if result.metadata else None)
else:
print("Workflow failed:", result.error)
except Exception as error:
print("Error:", error)
run_workflow()
```
### Error Handling
```python
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
result = client.execute_workflow("workflow-id")
return result
except SimStudioError as error:
if error.code == "UNAUTHORIZED":
print("Invalid API key")
elif error.code == "TIMEOUT":
print("Workflow execution timed out")
elif error.code == "USAGE_LIMIT_EXCEEDED":
print("Usage limit exceeded")
elif error.code == "INVALID_JSON":
print("Invalid JSON in request body")
else:
print(f"Workflow error: {error}")
raise
except Exception as error:
print(f"Unexpected error: {error}")
raise
```
### Context Manager Usage
```python
from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
```
### Environment Configuration
```python
import os
from simstudio import SimStudioClient
# Using environment variables
client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
### File Upload
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format:
The SDK converts file objects to this format:
```python
{
'type': 'file',
'data': 'data:mime/type;base64,base64data',
'name': 'filename',
'mime': 'mime/type'
}
```
Alternatively, you can manually provide files using the URL format:
```python
{
'type': 'url',
'data': 'https://example.com/file.pdf',
'name': 'file.pdf',
'mime': 'application/pdf'
}
```
```python
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
# Upload a single file - include it under the field name from your API trigger
with open('document.pdf', 'rb') as f:
result = client.execute_workflow(
'workflow-id',
{
'documents': [f], # Must match your workflow's "files" field name
'instructions': 'Analyze this document'
}
)
# Upload multiple files
with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
result = client.execute_workflow(
'workflow-id',
{
'attachments': [f1, f2], # Must match your workflow's "files" field name
'query': 'Compare these documents'
}
)
```
### Batch Workflow Execution
```python
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
results = []
for workflow_id, workflow_input in workflow_data_pairs:
try:
# Validate workflow before execution
if not client.validate_workflow(workflow_id):
print(f"Skipping {workflow_id}: not deployed")
continue
result = client.execute_workflow(workflow_id, workflow_input)
results.append({
"workflow_id": workflow_id,
"success": result.success,
"output": result.output,
"error": result.error
})
except Exception as error:
results.append({
"workflow_id": workflow_id,
"success": False,
"error": str(error)
})
return results
# Example usage
workflows = [
("workflow-1", {"type": "analysis", "data": "sample1"}),
("workflow-2", {"type": "processing", "data": "sample2"}),
]
results = execute_workflows_batch(workflows)
for result in results:
print(f"Workflow {result['workflow_id']}: {'Success' if result['success'] else 'Failed'}")
```
## Getting Your API Key
1. Log in to your [Sim](https://sim.ai) account
2. Navigate to your workflow
3. Click on "Deploy" to deploy your workflow
4. Select or create an API key during the deployment process
5. Copy the API key to use in your application
## Development
### Running Tests
To run the tests locally:
1. Clone the repository and navigate to the Python SDK directory:
```bash
cd packages/python-sdk
```
2. Create and activate a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install the package in development mode with test dependencies:
```bash
pip install -e ".[dev]"
```
4. Run the tests:
```bash
pytest tests/ -v
```
### Code Quality
Run code quality checks:
```bash
# Code formatting
black simstudio/
# Linting
flake8 simstudio/ --max-line-length=100
# Type checking
mypy simstudio/
# Import sorting
isort simstudio/
```
## Requirements
- Python 3.8+
- requests >= 2.25.0
## License
Apache-2.0
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Basic usage examples for the Sim Python SDK
"""
import os
from simstudio import SimStudioClient, SimStudioError
def basic_example():
"""Example 1: Basic workflow execution"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
# Execute a workflow without input
result = client.execute_workflow("your-workflow-id")
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
if result.metadata:
print(f"Duration: {result.metadata.get('duration')} ms")
else:
print(f"❌ Workflow failed: {result.error}")
except SimStudioError as error:
print(f"SDK Error: {error} (Code: {error.code})")
except Exception as error:
print(f"Unexpected error: {error}")
def with_input_example():
"""Example 2: Workflow execution with input data"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow(
"your-workflow-id",
input_data={
"message": "Hello from Python SDK!",
"user_id": "12345",
"data": {
"type": "analysis",
"parameters": {
"include_metadata": True,
"format": "json"
}
}
},
timeout=60.0 # 60 seconds
)
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
if result.metadata:
print(f"Duration: {result.metadata.get('duration')} ms")
else:
print(f"❌ Workflow failed: {result.error}")
except SimStudioError as error:
print(f"SDK Error: {error} (Code: {error.code})")
except Exception as error:
print(f"Unexpected error: {error}")
def status_example():
"""Example 3: Workflow validation and status checking"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
# Check if workflow is ready
is_ready = client.validate_workflow("your-workflow-id")
print(f"Workflow ready: {is_ready}")
# Get detailed status
status = client.get_workflow_status("your-workflow-id")
print(f"Status: {{\n"
f" deployed: {status.is_deployed},\n"
f" needs_redeployment: {status.needs_redeployment},\n"
f" deployed_at: {status.deployed_at}\n"
f"}}")
if status.is_deployed:
# Execute the workflow
result = client.execute_workflow("your-workflow-id")
print(f"Result: {result}")
except Exception as error:
print(f"Error: {error}")
def context_manager_example():
"""Example 4: Using context manager"""
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
try:
result = client.execute_workflow("your-workflow-id")
print(f"Result: {result}")
except Exception as error:
print(f"Error: {error}")
# Session is automatically closed here
def batch_execution_example():
"""Example 5: Batch workflow execution"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
workflows = [
("workflow-1", {"type": "analysis", "data": "sample1"}),
("workflow-2", {"type": "processing", "data": "sample2"}),
("workflow-3", {"type": "validation", "data": "sample3"}),
]
results = []
for workflow_id, input_data in workflows:
try:
# Validate workflow before execution
if not client.validate_workflow(workflow_id):
print(f"⚠️ Skipping {workflow_id}: not deployed")
continue
result = client.execute_workflow(workflow_id, input_data)
results.append({
"workflow_id": workflow_id,
"success": result.success,
"output": result.output,
"error": result.error
})
status = "✅ Success" if result.success else "❌ Failed"
print(f"{status}: {workflow_id}")
except SimStudioError as error:
results.append({
"workflow_id": workflow_id,
"success": False,
"error": str(error)
})
print(f"❌ SDK Error in {workflow_id}: {error}")
except Exception as error:
results.append({
"workflow_id": workflow_id,
"success": False,
"error": str(error)
})
print(f"❌ Unexpected error in {workflow_id}: {error}")
# Summary
successful = sum(1 for r in results if r["success"])
total = len(results)
print(f"\n📊 Summary: {successful}/{total} workflows completed successfully")
return results
def streaming_example():
"""Example 6: Workflow execution with streaming"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow(
"your-workflow-id",
input_data={"message": "Count to five"},
stream=True,
selected_outputs=["agent1.content"], # Use blockName.attribute format
timeout=60.0
)
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
if result.metadata:
print(f"Duration: {result.metadata.get('duration')} ms")
else:
print(f"❌ Workflow failed: {result.error}")
except SimStudioError as error:
print(f"SDK Error: {error} (Code: {error.code})")
except Exception as error:
print(f"Unexpected error: {error}")
def error_handling_example():
"""Example 7: Comprehensive error handling"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow("your-workflow-id")
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
return result
else:
print(f"❌ Workflow failed: {result.error}")
return result
except SimStudioError as error:
if error.code == "UNAUTHORIZED":
print("❌ Invalid API key")
elif error.code == "TIMEOUT":
print("⏱️ Workflow execution timed out")
elif error.code == "USAGE_LIMIT_EXCEEDED":
print("💳 Usage limit exceeded")
elif error.code == "INVALID_JSON":
print("📝 Invalid JSON in request body")
elif error.status == 404:
print("🔍 Workflow not found")
elif error.status == 403:
print("🚫 Workflow is not deployed")
else:
print(f"⚠️ Workflow error: {error}")
raise
except Exception as error:
print(f"💥 Unexpected error: {error}")
raise
if __name__ == "__main__":
print("🚀 Running Sim Python SDK Examples\n")
# Check if API key is set
if not os.getenv("SIM_API_KEY"):
print("❌ Please set SIM_API_KEY environment variable")
exit(1)
try:
print("1️⃣ Basic Example:")
basic_example()
print("\n✅ Basic example completed\n")
print("2️⃣ Input Example:")
with_input_example()
print("\n✅ Input example completed\n")
print("3️⃣ Status Example:")
status_example()
print("\n✅ Status example completed\n")
print("4️⃣ Context Manager Example:")
context_manager_example()
print("\n✅ Context manager example completed\n")
print("5️⃣ Batch Execution Example:")
batch_execution_example()
print("\n✅ Batch execution example completed\n")
print("6️⃣ Streaming Example:")
streaming_example()
print("\n✅ Streaming example completed\n")
print("7️⃣ Error Handling Example:")
error_handling_example()
print("\n✅ Error handling example completed\n")
except Exception as e:
print(f"\n💥 Example failed: {e}")
exit(1)
print("🎉 All examples completed successfully!")
@@ -0,0 +1,55 @@
"""
Example: Upload files with workflow execution
This example demonstrates how to upload files when executing a workflow.
Files are automatically detected and converted to base64 format.
"""
from simstudio import SimStudioClient
import os
def main():
# Initialize the client
api_key = os.getenv('SIM_API_KEY')
if not api_key:
raise ValueError('SIM_API_KEY environment variable is required')
client = SimStudioClient(api_key=api_key)
# Example 1: Upload a single file
# Include file under the field name from your workflow's API trigger input format
print("Example 1: Upload a single file")
with open('document.pdf', 'rb') as f:
result = client.execute_workflow(
workflow_id='your-workflow-id',
input_data={
'documents': [f], # Field name must match your API trigger's file input field
'instructions': 'Analyze this document'
}
)
if result.success:
print(f"Success! Output: {result.output}")
else:
print(f"Failed: {result.error}")
# Example 2: Upload multiple files
print("\nExample 2: Upload multiple files")
with open('document1.pdf', 'rb') as f1, open('document2.pdf', 'rb') as f2:
result = client.execute_workflow(
workflow_id='your-workflow-id',
input_data={
'attachments': [f1, f2], # Field name must match your API trigger's file input field
'query': 'Compare these documents'
}
)
if result.success:
print(f"Success! Output: {result.output}")
else:
print(f"Failed: {result.error}")
if __name__ == '__main__':
main()
+84
View File
@@ -0,0 +1,84 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "simstudio-sdk"
version = "0.1.2"
authors = [
{name = "Sim", email = "help@sim.ai"},
]
description = "Sim SDK - Execute workflows programmatically"
readme = "README.md"
license = {text = "Apache-2.0"}
requires-python = ">=3.8"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
keywords = ["simstudio", "ai", "workflow", "sdk", "api", "automation"]
dependencies = [
"requests>=2.25.0",
"typing-extensions>=4.0.0; python_version<'3.10'",
]
[project.optional-dependencies]
dev = [
"pytest>=6.0.0",
"pytest-asyncio>=0.18.0",
"black>=22.0.0",
"flake8>=4.0.0",
"mypy>=0.910",
"isort>=5.0.0",
"types-requests>=2.25.0",
]
[project.urls]
Homepage = "https://sim.ai"
Documentation = "https://docs.sim.ai"
Repository = "https://github.com/simstudioai/sim"
"Bug Reports" = "https://github.com/simstudioai/sim/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["simstudio*"]
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
[tool.isort]
profile = "black"
line_length = 100
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
+51
View File
@@ -0,0 +1,51 @@
from setuptools import setup, find_packages
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="simstudio-sdk",
version="0.1.1",
author="Sim",
author_email="help@sim.ai",
description="Sim SDK - Execute workflows programmatically",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/simstudioai/sim",
packages=find_packages(),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.8",
install_requires=[
"requests>=2.25.0",
"typing-extensions>=4.0.0; python_version<'3.10'",
],
extras_require={
"dev": [
"pytest>=6.0.0",
"pytest-asyncio>=0.18.0",
"black>=22.0.0",
"flake8>=4.0.0",
"mypy>=0.910",
],
"test": [
"pytest>=6.0.0",
],
},
keywords=["simstudio", "ai", "workflow", "sdk", "api", "automation"],
project_urls={
"Bug Reports": "https://github.com/simstudioai/sim/issues",
"Source": "https://github.com/simstudioai/sim",
"Documentation": "https://docs.sim.ai",
},
)
+568
View File
@@ -0,0 +1,568 @@
"""
Sim SDK for Python
Official Python SDK for Sim, allowing you to execute workflows programmatically.
"""
from typing import Any, Dict, Optional, Union
from dataclasses import dataclass
import time
import random
import os
import requests
__version__ = "0.1.2"
__all__ = [
"SimStudioClient",
"SimStudioError",
"WorkflowExecutionResult",
"WorkflowStatus",
"AsyncExecutionResult",
"RateLimitInfo",
"UsageLimits",
]
@dataclass
class WorkflowExecutionResult:
"""Result of a workflow execution."""
success: bool
output: Optional[Any] = None
error: Optional[str] = None
logs: Optional[list] = None
metadata: Optional[Dict[str, Any]] = None
trace_spans: Optional[list] = None
total_duration: Optional[float] = None
@dataclass
class WorkflowStatus:
"""Status of a workflow."""
is_deployed: bool
deployed_at: Optional[str] = None
needs_redeployment: bool = False
@dataclass
class AsyncExecutionResult:
"""Result of an async workflow execution."""
success: bool
job_id: str
status_url: str
execution_id: Optional[str] = None
message: str = ""
async_execution: bool = True
@dataclass
class RateLimitInfo:
"""Rate limit information from API response headers."""
limit: int
remaining: int
reset: int
retry_after: Optional[int] = None
@dataclass
class UsageLimits:
"""Usage limits and quota information."""
success: bool
rate_limit: Dict[str, Any]
usage: Dict[str, Any]
class SimStudioError(Exception):
"""Exception raised for Sim API errors."""
def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None):
super().__init__(message)
self.code = code
self.status = status
class SimStudioClient:
"""
Sim API client for executing workflows programmatically.
Args:
api_key: Your Sim API key
base_url: Base URL for the Sim API (defaults to https://sim.ai)
"""
def __init__(self, api_key: str, base_url: str = "https://sim.ai"):
self.api_key = api_key
self.base_url = base_url.rstrip('/')
self._session = requests.Session()
self._session.headers.update({
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
})
self._rate_limit_info: Optional[RateLimitInfo] = None
def _convert_files_to_base64(self, value: Any) -> Any:
"""
Convert file objects in input to API format (base64).
Recursively processes nested dicts and lists.
"""
import base64
# Check if this is a file-like object
if hasattr(value, 'read') and callable(value.read):
# Save current position if seekable
initial_pos = value.tell() if hasattr(value, 'tell') else None
# Read file bytes
file_bytes = value.read()
# Restore position if seekable
if initial_pos is not None and hasattr(value, 'seek'):
value.seek(initial_pos)
# Encode to base64
base64_data = base64.b64encode(file_bytes).decode('utf-8')
# Get file metadata
filename = getattr(value, 'name', 'file')
if isinstance(filename, str):
filename = os.path.basename(filename)
content_type = getattr(value, 'content_type', 'application/octet-stream')
return {
'type': 'file',
'data': f'data:{content_type};base64,{base64_data}',
'name': filename,
'mime': content_type
}
# Recursively process lists
if isinstance(value, list):
return [self._convert_files_to_base64(item) for item in value]
# Recursively process dicts
if isinstance(value, dict):
return {k: self._convert_files_to_base64(v) for k, v in value.items()}
return value
def execute_workflow(
self,
workflow_id: str,
input: Optional[Any] = None,
*,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
async_execution: Optional[bool] = None
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
"""
Execute a workflow with optional input data.
If async_execution is True, returns immediately with a task ID.
File objects in input will be automatically detected and converted to base64.
Args:
workflow_id: The ID of the workflow to execute
input: Input data to pass to the workflow. Can be a dict (spread at root level),
primitive value (string, number, bool), or list (wrapped in 'input' field).
File-like objects within dicts are automatically converted to base64.
timeout: Timeout in seconds (default: 30.0)
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
async_execution: Execute asynchronously (default: None)
Returns:
WorkflowExecutionResult or AsyncExecutionResult object
Raises:
SimStudioError: If the workflow execution fails
"""
url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
# Build headers - async execution uses X-Execution-Mode header
headers = self._session.headers.copy()
if async_execution:
headers['X-Execution-Mode'] = 'async'
try:
# Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
body = {}
if input is not None:
if isinstance(input, dict):
# Dict input: spread at root level (matches curl/API behavior)
body = input.copy()
else:
# Primitive or list input: wrap in 'input' field
body = {'input': input}
# Convert any file objects in the input to base64 format
body = self._convert_files_to_base64(body)
if stream is not None:
body['stream'] = stream
if selected_outputs is not None:
body['selectedOutputs'] = selected_outputs
response = self._session.post(
url,
json=body,
headers=headers,
timeout=timeout
)
# Update rate limit info
self._update_rate_limit_info(response)
# Handle rate limiting
if response.status_code == 429:
retry_after = self._rate_limit_info.retry_after if self._rate_limit_info else 1000
raise SimStudioError(
f'Rate limit exceeded. Retry after {retry_after}ms',
'RATE_LIMIT_EXCEEDED',
429
)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
result_data = response.json()
# Check if this is an async execution response (202 status)
if response.status_code == 202 and 'jobId' in result_data:
return AsyncExecutionResult(
success=result_data.get('success', True),
job_id=result_data['jobId'],
status_url=result_data['statusUrl'],
execution_id=result_data.get('executionId'),
message=result_data.get('message', ''),
async_execution=result_data.get('async', True)
)
return WorkflowExecutionResult(
success=result_data['success'],
output=result_data.get('output'),
error=result_data.get('error'),
logs=result_data.get('logs'),
metadata=result_data.get('metadata'),
trace_spans=result_data.get('traceSpans'),
total_duration=result_data.get('totalDuration')
)
except requests.Timeout:
raise SimStudioError(f'Workflow execution timed out after {timeout} seconds', 'TIMEOUT')
except requests.RequestException as e:
raise SimStudioError(f'Failed to execute workflow: {str(e)}', 'EXECUTION_ERROR')
def get_workflow_status(self, workflow_id: str) -> WorkflowStatus:
"""
Get the status of a workflow (deployment status, etc.).
Args:
workflow_id: The ID of the workflow
Returns:
WorkflowStatus object containing the workflow status
Raises:
SimStudioError: If getting the status fails
"""
url = f"{self.base_url}/api/workflows/{workflow_id}/status"
try:
response = self._session.get(url)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
status_data = response.json()
return WorkflowStatus(
is_deployed=status_data.get('isDeployed', False),
deployed_at=status_data.get('deployedAt'),
needs_redeployment=status_data.get('needsRedeployment', False)
)
except requests.RequestException as e:
raise SimStudioError(f'Failed to get workflow status: {str(e)}', 'STATUS_ERROR')
def validate_workflow(self, workflow_id: str) -> bool:
"""
Validate that a workflow is ready for execution.
Args:
workflow_id: The ID of the workflow
Returns:
True if the workflow is deployed and ready, False otherwise
"""
try:
status = self.get_workflow_status(workflow_id)
return status.is_deployed
except SimStudioError:
return False
def execute_workflow_sync(
self,
workflow_id: str,
input: Optional[Any] = None,
*,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None
) -> WorkflowExecutionResult:
"""
Execute a workflow synchronously (ensures non-async mode).
Args:
workflow_id: The ID of the workflow to execute
input: Input data to pass to the workflow (can include file-like objects)
timeout: Timeout for the initial request in seconds
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
Returns:
WorkflowExecutionResult object containing the execution result
Raises:
SimStudioError: If the workflow execution fails
"""
return self.execute_workflow(
workflow_id,
input,
timeout=timeout,
stream=stream,
selected_outputs=selected_outputs,
async_execution=False
)
def set_api_key(self, api_key: str) -> None:
"""
Update the API key.
Args:
api_key: New API key
"""
self.api_key = api_key
self._session.headers.update({'X-API-Key': api_key})
def set_base_url(self, base_url: str) -> None:
"""
Update the base URL.
Args:
base_url: New base URL
"""
self.base_url = base_url.rstrip('/')
def close(self) -> None:
"""Close the underlying HTTP session."""
self._session.close()
def get_job_status(self, job_id: str) -> Dict[str, Any]:
"""
Get the status of an async job.
Args:
job_id: The job ID returned from async execution
Returns:
Dictionary containing the job status
Raises:
SimStudioError: If getting the status fails
"""
url = f"{self.base_url}/api/jobs/{job_id}"
try:
response = self._session.get(url)
self._update_rate_limit_info(response)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
return response.json()
except requests.RequestException as e:
raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR')
def execute_with_retry(
self,
workflow_id: str,
input: Optional[Any] = None,
*,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
async_execution: Optional[bool] = None,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_multiplier: float = 2.0
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
"""
Execute workflow with automatic retry on rate limit.
Args:
workflow_id: The ID of the workflow to execute
input: Input data to pass to the workflow (can include file-like objects)
timeout: Timeout in seconds
stream: Enable streaming responses
selected_outputs: Block outputs to stream
async_execution: Execute asynchronously
max_retries: Maximum number of retries (default: 3)
initial_delay: Initial delay in seconds (default: 1.0)
max_delay: Maximum delay in seconds (default: 30.0)
backoff_multiplier: Backoff multiplier (default: 2.0)
Returns:
WorkflowExecutionResult or AsyncExecutionResult object
Raises:
SimStudioError: If max retries exceeded or other error occurs
"""
last_error = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return self.execute_workflow(
workflow_id,
input,
timeout=timeout,
stream=stream,
selected_outputs=selected_outputs,
async_execution=async_execution
)
except SimStudioError as e:
if e.code != 'RATE_LIMIT_EXCEEDED':
raise
last_error = e
# Don't retry after last attempt
if attempt == max_retries:
break
# Use retry-after if provided, otherwise use exponential backoff
wait_time = (
self._rate_limit_info.retry_after / 1000
if self._rate_limit_info and self._rate_limit_info.retry_after
else min(delay, max_delay)
)
# Add jitter (±25%)
jitter = wait_time * (0.75 + random.random() * 0.5)
time.sleep(jitter)
# Exponential backoff for next attempt
delay *= backoff_multiplier
raise last_error or SimStudioError('Max retries exceeded', 'MAX_RETRIES_EXCEEDED')
def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
"""
Get current rate limit information.
Returns:
RateLimitInfo object or None if no rate limit info available
"""
return self._rate_limit_info
def _update_rate_limit_info(self, response: requests.Response) -> None:
"""
Update rate limit info from response headers.
Args:
response: The response object to extract headers from
"""
limit = response.headers.get('x-ratelimit-limit')
remaining = response.headers.get('x-ratelimit-remaining')
reset = response.headers.get('x-ratelimit-reset')
retry_after = response.headers.get('retry-after')
if limit or remaining or reset:
self._rate_limit_info = RateLimitInfo(
limit=int(limit) if limit else 0,
remaining=int(remaining) if remaining else 0,
reset=int(reset) if reset else 0,
retry_after=int(retry_after) * 1000 if retry_after else None
)
def get_usage_limits(self) -> UsageLimits:
"""
Get current usage limits and quota information.
Returns:
UsageLimits object containing usage and quota data
Raises:
SimStudioError: If getting usage limits fails
"""
url = f"{self.base_url}/api/users/me/usage-limits"
try:
response = self._session.get(url)
self._update_rate_limit_info(response)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
data = response.json()
return UsageLimits(
success=data.get('success', True),
rate_limit=data.get('rateLimit', {}),
usage=data.get('usage', {})
)
except requests.RequestException as e:
raise SimStudioError(f'Failed to get usage limits: {str(e)}', 'USAGE_ERROR')
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
# For backward compatibility
Client = SimStudioClient
+537
View File
@@ -0,0 +1,537 @@
"""
Tests for the Sim Python SDK
"""
import pytest
from unittest.mock import Mock, patch
from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus
def test_simstudio_client_initialization():
"""Test SimStudioClient initialization."""
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
assert client.api_key == "test-api-key"
assert client.base_url == "https://test.sim.ai"
def test_simstudio_client_default_base_url():
"""Test SimStudioClient with default base URL."""
client = SimStudioClient(api_key="test-api-key")
assert client.api_key == "test-api-key"
assert client.base_url == "https://sim.ai"
def test_set_api_key():
"""Test setting a new API key."""
client = SimStudioClient(api_key="test-api-key")
client.set_api_key("new-api-key")
assert client.api_key == "new-api-key"
def test_set_base_url():
"""Test setting a new base URL."""
client = SimStudioClient(api_key="test-api-key")
client.set_base_url("https://new.sim.ai/")
assert client.base_url == "https://new.sim.ai"
def test_set_base_url_strips_trailing_slash():
"""Test that base URL strips trailing slash."""
client = SimStudioClient(api_key="test-api-key")
client.set_base_url("https://test.sim.ai/")
assert client.base_url == "https://test.sim.ai"
@patch('simstudio.requests.Session.get')
def test_validate_workflow_returns_false_on_error(mock_get):
"""Test that validate_workflow returns False when request fails."""
mock_get.side_effect = SimStudioError("Network error")
client = SimStudioClient(api_key="test-api-key")
result = client.validate_workflow("test-workflow-id")
assert result is False
mock_get.assert_called_once_with("https://sim.ai/api/workflows/test-workflow-id/status")
def test_simstudio_error():
"""Test SimStudioError creation."""
error = SimStudioError("Test error", "TEST_CODE", 400)
assert str(error) == "Test error"
assert error.code == "TEST_CODE"
assert error.status == 400
def test_workflow_execution_result():
"""Test WorkflowExecutionResult data class."""
result = WorkflowExecutionResult(
success=True,
output={"data": "test"},
metadata={"duration": 1000}
)
assert result.success is True
assert result.output == {"data": "test"}
assert result.metadata == {"duration": 1000}
def test_workflow_status():
"""Test WorkflowStatus data class."""
status = WorkflowStatus(
is_deployed=True,
deployed_at="2023-01-01T00:00:00Z",
needs_redeployment=False
)
assert status.is_deployed is True
assert status.deployed_at == "2023-01-01T00:00:00Z"
assert status.needs_redeployment is False
@patch('simstudio.requests.Session.close')
def test_context_manager(mock_close):
"""Test SimStudioClient as context manager."""
with SimStudioClient(api_key="test-api-key") as client:
assert client.api_key == "test-api-key"
mock_close.assert_called_once()
@patch('simstudio.requests.Session.post')
def test_async_execution_returns_job_id(mock_post):
"""Test async execution returns AsyncExecutionResult."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 202
mock_response.json.return_value = {
"success": True,
"jobId": "job-123",
"statusUrl": "https://test.sim.ai/api/jobs/job-123",
"executionId": "execution-123",
"message": "Workflow execution started",
"async": True
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
{"message": "Hello"},
async_execution=True
)
assert result.success is True
assert result.job_id == "job-123"
assert result.status_url == "https://test.sim.ai/api/jobs/job-123"
assert result.execution_id == "execution-123"
assert result.async_execution is True
call_args = mock_post.call_args
assert call_args[1]["headers"]["X-Execution-Mode"] == "async"
@patch('simstudio.requests.Session.post')
def test_sync_execution_returns_result(mock_post):
"""Test sync execution returns WorkflowExecutionResult."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"output": {"result": "completed"},
"logs": []
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
{"message": "Hello"},
async_execution=False
)
assert result.success is True
assert result.output == {"result": "completed"}
assert not hasattr(result, 'task_id')
@patch('simstudio.requests.Session.post')
def test_async_header_not_set_when_false(mock_post):
"""Test X-Execution-Mode header is not set when async_execution is None."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", {"message": "Hello"})
call_args = mock_post.call_args
assert "X-Execution-Mode" not in call_args[1]["headers"]
@patch('simstudio.requests.Session.get')
def test_get_job_status_success(mock_get):
"""Test getting job status."""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {
"success": True,
"taskId": "task-123",
"status": "completed",
"metadata": {
"startedAt": "2024-01-01T00:00:00Z",
"completedAt": "2024-01-01T00:01:00Z",
"duration": 60000
},
"output": {"result": "done"}
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
result = client.get_job_status("task-123")
assert result["taskId"] == "task-123"
assert result["status"] == "completed"
assert result["output"]["result"] == "done"
mock_get.assert_called_once_with("https://test.sim.ai/api/jobs/task-123")
@patch('simstudio.requests.Session.get')
def test_get_job_status_not_found(mock_get):
"""Test job not found error."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
mock_response.reason = "Not Found"
mock_response.json.return_value = {
"error": "Job not found",
"code": "JOB_NOT_FOUND"
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.get_job_status("invalid-task")
assert "Job not found" in str(exc_info.value)
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
"""Test retry succeeds on first attempt."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"output": {"result": "success"}
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry("workflow-id", {"message": "test"})
assert result.success is True
assert mock_post.call_count == 1
assert mock_sleep.call_count == 0
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
"""Test retry retries on rate limit error."""
rate_limit_response = Mock()
rate_limit_response.ok = False
rate_limit_response.status_code = 429
rate_limit_response.json.return_value = {
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED"
}
import time
rate_limit_response.headers.get.side_effect = lambda h: {
'retry-after': '1',
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': str(int(time.time()) + 60)
}.get(h)
success_response = Mock()
success_response.ok = True
success_response.status_code = 200
success_response.json.return_value = {
"success": True,
"output": {"result": "success"}
}
success_response.headers.get.return_value = None
mock_post.side_effect = [rate_limit_response, success_response]
client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry(
"workflow-id",
{"message": "test"},
max_retries=3,
initial_delay=0.01
)
assert result.success is True
assert mock_post.call_count == 2
assert mock_sleep.call_count == 1
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post):
"""Test retry throws after max retries."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 429
mock_response.json.return_value = {
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED"
}
mock_response.headers.get.side_effect = lambda h: '1' if h == 'retry-after' else None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry(
"workflow-id",
{"message": "test"},
max_retries=2,
initial_delay=0.01
)
assert "Rate limit exceeded" in str(exc_info.value)
assert mock_post.call_count == 3 # Initial + 2 retries
@patch('simstudio.requests.Session.post')
def test_execute_with_retry_no_retry_on_other_errors(mock_post):
"""Test retry does not retry on non-rate-limit errors."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.reason = "Internal Server Error"
mock_response.json.return_value = {
"error": "Server error",
"code": "INTERNAL_ERROR"
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry("workflow-id", {"message": "test"})
assert "Server error" in str(exc_info.value)
assert mock_post.call_count == 1 # No retries
def test_get_rate_limit_info_returns_none_initially():
"""Test rate limit info is None before any API calls."""
client = SimStudioClient(api_key="test-api-key")
info = client.get_rate_limit_info()
assert info is None
@patch('simstudio.requests.Session.post')
def test_get_rate_limit_info_after_api_call(mock_post):
"""Test rate limit info is populated after API call."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.side_effect = lambda h: {
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '95',
'x-ratelimit-reset': '1704067200'
}.get(h)
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", {})
info = client.get_rate_limit_info()
assert info is not None
assert info.limit == 100
assert info.remaining == 95
assert info.reset == 1704067200
@patch('simstudio.requests.Session.get')
def test_get_usage_limits_success(mock_get):
"""Test getting usage limits."""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {
"success": True,
"rateLimit": {
"sync": {
"isLimited": False,
"limit": 100,
"remaining": 95,
"resetAt": "2024-01-01T01:00:00Z"
},
"async": {
"isLimited": False,
"limit": 50,
"remaining": 48,
"resetAt": "2024-01-01T01:00:00Z"
},
"authType": "api"
},
"usage": {
"currentPeriodCost": 1.23,
"limit": 100.0,
"plan": "pro"
}
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
result = client.get_usage_limits()
assert result.success is True
assert result.rate_limit["sync"]["limit"] == 100
assert result.rate_limit["async"]["limit"] == 50
assert result.usage["currentPeriodCost"] == 1.23
assert result.usage["plan"] == "pro"
mock_get.assert_called_once_with("https://test.sim.ai/api/users/me/usage-limits")
@patch('simstudio.requests.Session.get')
def test_get_usage_limits_unauthorized(mock_get):
"""Test usage limits with invalid API key."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 401
mock_response.reason = "Unauthorized"
mock_response.json.return_value = {
"error": "Invalid API key",
"code": "UNAUTHORIZED"
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="invalid-key")
with pytest.raises(SimStudioError) as exc_info:
client.get_usage_limits()
assert "Invalid API key" in str(exc_info.value)
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
"""Test execution with stream and selectedOutputs parameters."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow(
"workflow-id",
{"message": "test"},
stream=True,
selected_outputs=["agent1.content", "agent2.content"]
)
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["message"] == "test"
assert request_body["stream"] is True
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
# Tests for primitive and list inputs
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_string_input(mock_post):
"""Test execution with primitive string input wraps in input field."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", "NVDA")
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["input"] == "NVDA"
assert "0" not in request_body # Should not spread string characters
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_number_input(mock_post):
"""Test execution with primitive number input wraps in input field."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", 42)
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["input"] == 42
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_list_input(mock_post):
"""Test execution with list input wraps in input field."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", ["NVDA", "AAPL", "GOOG"])
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["input"] == ["NVDA", "AAPL", "GOOG"]
assert "0" not in request_body # Should not spread list
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
"""Test execution with dict input spreads at root level."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", {"ticker": "NVDA", "quantity": 100})
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["ticker"] == "NVDA"
assert request_body["quantity"] == 100
assert "input" not in request_body # Should not wrap in input field