diff --git a/apps/docs/content/docs/en/api-reference/python.mdx b/apps/docs/content/docs/en/api-reference/python.mdx index a9cb7b37726..367d84c1407 100644 --- a/apps/docs/content/docs/en/api-reference/python.mdx +++ b/apps/docs/content/docs/en/api-reference/python.mdx @@ -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 diff --git a/bun.lock b/bun.lock index bc17d95213d..efb1d9c2298 100644 --- a/bun.lock +++ b/bun.lock @@ -611,7 +611,7 @@ }, "packages/ts-sdk": { "name": "simstudio-ts-sdk", - "version": "0.1.3", + "version": "0.2.0", "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 0b096ccd87f..17d10731de9 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -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 @@ -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 diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 6812cc1d8a1..1552d4e1412 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -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"}, ] diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index 796c9b00b1c..cc577f82d87 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -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 @@ -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", @@ -28,7 +34,14 @@ @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 @@ -36,6 +49,7 @@ class WorkflowExecutionResult: metadata: Optional[Dict[str, Any]] = None trace_spans: Optional[list] = None total_duration: Optional[float] = None + status: Optional[str] = None @dataclass @@ -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 @@ -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. @@ -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"]) @@ -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: @@ -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 ) diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index b1b65a257f5..3ef5d711e88 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -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") @@ -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.""" @@ -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.""" diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index 10c9ddd008b..5f2a9b72506 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -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 diff --git a/packages/ts-sdk/package.json b/packages/ts-sdk/package.json index 21e38369e4e..2bcefefa0f9 100644 --- a/packages/ts-sdk/package.json +++ b/packages/ts-sdk/package.json @@ -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": { diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index ca7fef83f83..8050171dd7b 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -4,12 +4,12 @@ import { SimStudioClient, SimStudioError } from './index' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) -function v2ExecutionResponse(output: unknown = {}) { +function v2ExecutionResponse(output: unknown = {}, status = 'completed') { return { data: { runId: 'execution-123', workflowId: 'workflow-id', - status: 'completed', + status, output, error: null, startedAt: '2026-08-11T12:00:00.000Z', @@ -195,6 +195,32 @@ describe('SimStudioClient', () => { }) }) + it('reports a cancelled sync run as unsuccessful', async () => { + vi.mocked(mockFetch).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(v2ExecutionResponse({}, 'cancelled')), + headers: { get: vi.fn().mockReturnValue(null) }, + }) + + const result = await client.executeWorkflow('workflow-id', {}) + + expect(result).toHaveProperty('success', false) + }) + + it('reports a paused sync run as successful', async () => { + vi.mocked(mockFetch).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(v2ExecutionResponse({}, 'paused')), + headers: { get: vi.fn().mockReturnValue(null) }, + }) + + const result = await client.executeWorkflow('workflow-id', {}) + + expect(result).toHaveProperty('success', true) + }) + it('should not set X-Execution-Mode header when async is undefined', async () => { const mockResponse = { ok: true, @@ -512,6 +538,28 @@ describe('SimStudioClient', () => { expect(info?.remaining).toBe(95) expect(info?.reset).toBe(1704067200) }) + + it('parses an ISO x-ratelimit-reset, the format the v2 API sends', async () => { + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), + headers: { + get: vi.fn((header: string) => { + if (header === 'x-ratelimit-limit') return '100' + if (header === 'x-ratelimit-remaining') return '99' + if (header === 'x-ratelimit-reset') return '2024-01-01T00:00:00.000Z' + return null + }), + }, + } + + vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', {}) + + expect(client.getRateLimitInfo()?.reset).toBe(1704067200000) + }) }) describe('getUsageLimits', () => {