Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/docs/content/docs/en/api-reference/python.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,8 +275,11 @@ class WorkflowExecutionResult:
metadata: Optional[Dict[str, Any]] = None
trace_spans: Optional[List[Any]] = None
total_duration: Optional[float] = None
status: Optional[str] = None
```

`success` is `True` only for the `completed` and `paused` statuses. `status` carries the server's terminal status verbatim, so a cancelled run (`success=False`, `error=None`) is distinguishable from a failed one.

### AsyncExecutionResult

```python
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions packages/python-sdk/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,22 @@

The official Python SDK for [Sim](https://sim.ai), allowing you to execute workflows programmatically from your Python applications.

## Server compatibility

`0.2.x` talks to the v2 API and has no fallback to the older endpoints, so it requires a Sim deployment that serves `POST /api/v2/workflows/{id}/execute`. That surface is newer than the endpoints `0.1.x` used, and a deployment can also have it switched off — a self-hosted build serves `/api/v2` only when the operator enables `V2_API`. Where it is unavailable every v2 route answers 404, so `execute_workflow` raises `SimStudioError('HTTP 404: Not Found')` — enable or upgrade the v2 API on the server, or pin `simstudio-sdk<0.2`, which keeps using `/api/workflows/{id}/execute` and `/api/jobs/{id}`.

## Upgrading from 0.1.x to 0.2.0

`0.2.0` is a breaking release.

- **Requests move to `/api/v2`.** `execute_workflow` posts to `/api/v2/workflows/{workflow_id}/execute`, sends the workflow input nested under `input`, and carries `async` / `executionTimeoutSeconds` in the body instead of the `X-Execution-Mode` and `X-Execution-Timeout-Seconds` headers.
- **`AsyncExecutionResult.job_id` is now `run_id`,** and `execution_id` has been removed from that dataclass. Replace `result.job_id` with `result.run_id`.
- **`get_job_status(job_id)` is legacy.** It still calls `/api/jobs/{job_id}` and only resolves IDs from a `0.1.x` async execution. For runs started by `0.2.x`, use `get_workflow_run(workflow_id, run_id)`, which reads `/api/v2/workflows/{workflow_id}/runs/{run_id}`.
- **`WorkflowExecutionResult.success` is derived from the run status** rather than read from the response body, and is `True` only for `completed` and `paused` runs — so a run cancelled while it was in flight now reports `success=False`, as it did before the v2 migration. The new `WorkflowExecutionResult.status` field carries the server's terminal status (`'completed'`, `'failed'`, `'paused'` or `'cancelled'`), which is how you tell a cancelled run from a failed one.
- **`metadata` is now built by the SDK,** with the keys `duration`, `runId`, `startTime` and `endTime`. The v2 response carries no execution logs or trace spans, so `logs` and `trace_spans` are always `None`; the pre-v2 `metadata['executionId']` is now `metadata['runId']`.

Note one deliberate difference from the TypeScript SDK: a failed synchronous run *throws* there, but here it returns normally with `error` set and `status='failed'`.

## Installation

```bash
Expand DownExpand Up@@ -245,8 +261,11 @@ class WorkflowExecutionResult:
metadata: Optional[Dict[str, Any]] = None
trace_spans: Optional[list] = None
total_duration: Optional[float] = None
status: Optional[str] = None
```

`success` is `True` only for the `completed` and `paused` statuses. `status` carries the server's terminal status verbatim, so a cancelled run (`success=False`, `error=None`) is distinguishable from a failed one.

### WorkflowStatus

```python
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "simstudio-sdk"
version = "0.1.2"
version = "0.2.0"
authors = [
{name = "Sim", email = "help@sim.ai"},
]
Expand Down
63 changes: 53 additions & 10 deletions packages/python-sdk/simstudio/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@

from typing import Any, Dict, Optional, Union
from dataclasses import dataclass
from datetime import datetime
import time
import random
import os
Expand All@@ -14,7 +15,12 @@

MAX_EXECUTION_TIMEOUT_SECONDS = 604_800

__version__ = "0.1.2"
# Run statuses that count as a successful synchronous execution. Deliberately a
# whitelist: a status later added to the API then defaults to "not successful"
# rather than silently reporting True.
_SUCCESSFUL_RUN_STATUSES = ('completed', 'paused')

__version__ = "0.2.0"
__all__ = [
"SimStudioClient",
"SimStudioError",
Expand All@@ -28,14 +34,22 @@

@dataclass
class WorkflowExecutionResult:
"""Result of a workflow execution."""
"""
Result of a workflow execution.

``success`` is True only for the 'completed' and 'paused' statuses, so a
run the server cancels and a run that fails both report False. Read
``status`` to tell those apart -- it carries the server's own terminal
status ('completed', 'failed', 'paused' or 'cancelled').
"""
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
status: Optional[str] = None


@dataclass
Expand All@@ -58,7 +72,13 @@ class AsyncExecutionResult:

@dataclass
class RateLimitInfo:
"""Rate limit information from API response headers."""
"""
Rate limit information from API response headers.

``reset`` is epoch milliseconds when the server sends the ISO 8601
``X-RateLimit-Reset`` the v2 API uses, and epoch seconds for the bare
integer older endpoints sent. ``retry_after`` is milliseconds.
"""
limit: int
remaining: int
reset: int
Expand All@@ -82,6 +102,23 @@ def __init__(self, message: str, code: Optional[str] = None, status: Optional[in
self.status = status


def _parse_reset_header(value: str) -> int:
"""
Parse the ``X-RateLimit-Reset`` header, matching the TypeScript SDK.

The v2 API sends an ISO 8601 timestamp, which yields epoch milliseconds;
older endpoints sent an epoch integer, which is kept as-is. A quota hint
must never take down the call it rode in on, so an unrecognised value
degrades to 0 rather than raising.
"""
if value.isdecimal():
return int(value)
try:
return int(datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp() * 1000)
except ValueError:
return 0


class SimStudioClient:
"""
Sim API client for executing workflows programmatically.
Expand DownExpand Up@@ -166,9 +203,11 @@ def execute_workflow(

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.
input: Input data to pass to the workflow, sent nested under the request
body's 'input' field. A dict becomes the workflow input as-is; any
other value (string, number, bool, list) is wrapped as
{'input': value}. File-like objects within it 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"])
Expand DownExpand Up@@ -270,15 +309,19 @@ def execute_workflow(
)

execution_error = result_data.get('error')
status = result_data.get('status')
return WorkflowExecutionResult(
success=result_data.get('status') != 'failed',
success=status in _SUCCESSFUL_RUN_STATUSES,
output=result_data.get('output'),
error=execution_error.get('message') if execution_error else None,
metadata={
'duration': result_data.get('durationMs'),
'runId': result_data['runId']
'runId': result_data['runId'],
'startTime': result_data.get('startedAt'),
'endTime': result_data.get('endedAt')
},
total_duration=result_data.get('durationMs')
total_duration=result_data.get('durationMs'),
status=status
)

except requests.Timeout:
Expand DownExpand Up@@ -593,7 +636,7 @@ def _update_rate_limit_info(self, response: requests.Response) -> None:
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,
reset=_parse_reset_header(reset) if reset else 0,
retry_after=int(retry_after) * 1000 if retry_after else None
)

Expand Down
109 changes: 106 additions & 3 deletions packages/python-sdk/tests/test_client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,19 +7,32 @@
from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus


def v2_execution_response(output=None):
def v2_execution_response(output=None, status="completed", error=None):
return {
"data": {
"runId": "execution-123",
"workflowId": "workflow-id",
"status": "completed",
"status": status,
"output": {} if output is None else output,
"error": None,
"error": error,
"startedAt": "2026-08-11T12:00:00.000Z",
"endedAt": "2026-08-11T12:00:00.010Z",
"durationMs": 10
}
}


def mock_execution_post(mock_post, status="completed", error=None, headers=None):
"""Wire a mocked 200 v2 execution response with the given terminal status."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = v2_execution_response(status=status, error=error)
mock_response.headers.get.side_effect = lambda h: (headers or {}).get(h)
mock_post.return_value = mock_response
return mock_response


def test_simstudio_client_initialization():
"""Test SimStudioClient initialization."""
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
Expand DownExpand Up@@ -161,10 +174,58 @@ def test_sync_execution_returns_result(mock_post):
)

assert result.success is True
assert result.status == "completed"
assert result.output == {"result": "completed"}
assert result.metadata == {
"duration": 10,
"runId": "execution-123",
"startTime": "2026-08-11T12:00:00.000Z",
"endTime": "2026-08-11T12:00:00.010Z"
}
assert not hasattr(result, 'task_id')


@patch('simstudio.requests.Session.post')
def test_sync_execution_cancelled_is_not_success(mock_post):
"""A run cancelled out of band is not a success, matching the TypeScript SDK."""
mock_execution_post(mock_post, status="cancelled")

client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow("workflow-id", {})

assert result.success is False
assert result.status == "cancelled"


@patch('simstudio.requests.Session.post')
def test_sync_execution_failed_is_not_success(mock_post):
"""A failed run is not a success and surfaces the server's error message."""
mock_execution_post(
mock_post,
status="failed",
error={"code": "BLOCK_EXECUTION_FAILED", "message": "Invalid credentials"}
)

client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow("workflow-id", {})

assert result.success is False
assert result.status == "failed"
assert result.error == "Invalid credentials"


@patch('simstudio.requests.Session.post')
def test_sync_execution_paused_is_success(mock_post):
"""A paused run is still a success -- it is waiting, not broken."""
mock_execution_post(mock_post, status="paused")

client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow("workflow-id", {})

assert result.success is True
assert result.status == "paused"


@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."""
Expand DownExpand Up@@ -497,6 +558,48 @@ def test_get_rate_limit_info_after_api_call(mock_post):
assert info.reset == 1704067200


@patch('simstudio.requests.Session.post')
def test_rate_limit_reset_accepts_iso_timestamp(mock_post):
"""The v2 API sends X-RateLimit-Reset as an ISO 8601 timestamp, not an epoch int."""
mock_execution_post(mock_post, headers={
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '99',
'x-ratelimit-reset': '2024-01-01T00:00:00.000Z'
})

client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow("workflow-id", {})

assert result.success is True
info = client.get_rate_limit_info()
assert info is not None
assert info.reset == 1704067200000


@pytest.mark.parametrize('reset_header', ['not-a-timestamp', '²'])
@patch('simstudio.requests.Session.post')
def test_rate_limit_reset_tolerates_unparseable_value(mock_post, reset_header):
"""
An unrecognised quota hint reports 0 rather than failing the execution.

'²' covers the digit-like characters str.isdigit() accepts but int()
rejects.
"""
mock_execution_post(mock_post, headers={
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '99',
'x-ratelimit-reset': reset_header
})

client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow("workflow-id", {})

assert result.success is True
info = client.get_rate_limit_info()
assert info is not None
assert info.reset == 0


@patch('simstudio.requests.Session.get')
def test_get_usage_limits_success(mock_get):
"""Test getting usage limits."""
Expand Down
14 changes: 14 additions & 0 deletions packages/ts-sdk/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,20 @@

The official TypeScript/JavaScript SDK for [Sim](https://sim.ai), allowing you to execute workflows programmatically from your applications.

## Server compatibility

`0.2.x` talks to the v2 API and has no fallback to the older endpoints, so it requires a Sim deployment that serves `POST /api/v2/workflows/{id}/execute`. That surface is newer than the endpoints `0.1.x` used, and a deployment can also have it switched off — a self-hosted build serves `/api/v2` only when the operator enables `V2_API`. Where it is unavailable every v2 route answers 404, so `executeWorkflow` fails with `HTTP 404: Not Found` — enable or upgrade the v2 API on the server, or stay on `simstudio-ts-sdk@0.1.x`, which keeps using `/api/workflows/{id}/execute` and `/api/jobs/{id}`.

## Upgrading from 0.1.x to 0.2.0

`0.2.0` is a breaking release. It is a minor bump rather than a patch precisely so that `^0.1.2` does not pick it up — you upgrade when you choose to.

- **Requests move to `/api/v2`.** `executeWorkflow` posts to `/api/v2/workflows/{id}/execute`, sends the workflow input nested under `input`, and carries `async` / `executionTimeoutSeconds` in the body instead of the `X-Execution-Mode` and `X-Execution-Timeout-Seconds` headers.
- **`AsyncExecutionResult.jobId` is now `runId`,** and `executionId` has been removed from that interface. Replace `result.jobId` with `result.runId`.
- **`getJobStatus(taskId)` is legacy.** It still calls `/api/jobs/{taskId}` and only resolves IDs from a `0.1.x` async execution. For runs started by `0.2.x`, use `getWorkflowRun(workflowId, runId)`, which reads `/api/v2/workflows/{id}/runs/{runId}` and returns a typed `WorkflowRunStatus`.
- **A failed synchronous run now throws.** Previously it resolved with `{ success: false }`; it now rejects with a `SimStudioError` carrying the server's `error.code` and `error.message`. Any `if (!result.success)` branch that handled failures must move into a `catch`.
- **`success` is derived from the run status.** It is `true` for `completed` and `paused` runs only, so a run cancelled while it was in flight resolves with `success: false` rather than throwing. Combined with the point above: a rejection means the run failed, and a resolved `success: false` means it was cancelled.

## Installation

```bash
Expand Down
2 changes: 1 addition & 1 deletion packages/ts-sdk/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "simstudio-ts-sdk",
"version": "0.1.3",
"version": "0.2.0",
"description": "Sim SDK - Execute workflows programmatically",
"type": "module",
"exports": {
Expand Down
Loading
Loading