From 679049aee7f3b2d8c92518fa400ea20339b2a91f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 01:13:49 -0700 Subject: [PATCH] fix(sdk): make the 0.2.0 SDK release safe to publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 SDK migration (#5273, #6564) shipped five breaking changes in both SDKs but got the release mechanics wrong in three separate ways, and left one of the two rewrites unable to complete a single successful call. Versions. packages/ts-sdk/package.json read 0.1.3 -- a patch digit added inside an unrelated compatibility commit, never deliberated. npm expands ^0.1.2 to >=0.1.2 <0.2.0, so every existing consumer would have picked the break up on a lockfile refresh: AsyncExecutionResult.jobId renamed to runId, executionId dropped from that interface, a failed sync run now throwing instead of resolving {success:false}, the request body reshaped, and the endpoint moved to /api/v2 with no fallback. 0.2.0 excludes every existing range, so the upgrade becomes opt-in. packages/python-sdk carries the identical break and was never bumped at all, so its publish job would have skipped green at the "version already exists" gate and left the repo and PyPI silently divergent; it moves 0.1.2 -> 0.2.0 in lockstep, along with the __version__ string in simstudio/__init__.py, which tracks pyproject and would otherwise have started lying. setup.py is left at 0.1.1: it is unchanged from main and demonstrably unread (0.1.2 published from pyproject while setup.py already said 0.1.1). It wants deleting, in its own commit. A 404 fallback was considered and rejected. The legacy 202 body's statusUrl points at /api/jobs/{jobId}, so mapping jobId onto runId would hand the caller an id that getWorkflowRun cannot resolve against that same old server -- a successful execute followed by an inexplicable failure on the next call is a worse contract than a clean 404. Both READMEs instead state the minimum server version and name the endpoint to check for. Cancelled runs. packages/python-sdk computed success as status != 'failed', so a run cancelled out of band reported success=True. The TypeScript SDK uses a closed whitelist and reports False, and before the migration both SDKs read the server's own value, which was False -- so this was a Python regression, not merely an inconsistency. Fixed by mirroring the whitelist. The v2 contract enumerates exactly completed|failed|paused|cancelled, so narrowing the blacklist to a whitelist cannot drop a live value, and a status added later now defaults to "not successful" rather than silently reporting True. WorkflowExecutionResult gains a status field because Python, unlike TypeScript, does not throw on 'failed' -- so success=False alone is ambiguous there in a way it is not in the TypeScript SDK, which is why status is not added to both. Rate-limit header. Found while auditing the two SDKs for further divergence, and the reason the Python bump could not have shipped as it stood: every authenticated v2 response now carries X-RateLimit-Reset as an ISO 8601 timestamp (recorded by v2RateLimits.publicApi, stamped by withRouteHandler). The Python SDK parsed it with int(), raising a bare ValueError that no handler in execute_workflow catches -- so every successful v2 execution raised instead of returning. None of the legacy endpoints the SDK previously called record a rate-limit snapshot, which is why the latent int() survived until the v2 move. The TypeScript SDK already branches on the format; _parse_reset_header mirrors it, including degrading an unrecognised value to 0, because a quota hint must not take down the call it rode in on. Timing metadata. The v2 rewrite stopped forwarding startedAt/endedAt, which main passed through and the TypeScript SDK still reports; restored under the same startTime/endTime keys the TypeScript SDK uses. Tests: cancelled/failed/paused status coverage, the ISO reset header, and the restored metadata keys, each verified red against the unfixed line first. The TypeScript suite gains matching cancelled/paused and ISO-reset pins -- they pass against today's source by design, and were confirmed to fail against a deliberately degraded copy so they are not toothless. Deliberately not included: a CI guard failing a PR that changes SDK source without a version bump. It would have caught this twice over, but it is a new script and workflow rather than a fix to the defect at hand. Review revision. bun.lock recorded packages/ts-sdk at 0.1.3 and was left stale by the first pass, so the repo asserted two versions for the same workspace package -- in a change whose whole thesis is that the version strings had diverged. It does not break CI (bun 1.3.14 accepts the mismatch under --frozen-lockfile, confirmed here), but 092311ea68 bumped the lock in lockstep with package.json, and the next unfrozen install would otherwise drop the line into an unrelated PR. _parse_reset_header gated the numeric branch on str.isdigit(), which accepts characters int() rejects ('²'.isdigit() is True, int('²') raises) -- and that int() sits outside the try, so the one function added to stop a quota hint raising could still raise, contradicting its own docstring. str.isdecimal() is exactly the set int() accepts. The tolerates-unparseable test is parametrized over both forms and was confirmed red on '²' against isdigit. Docs and docstrings: apps/docs api-reference/python.mdx mirrors the README's dataclass block and was the only copy left without the new status field. RateLimitInfo now names its units, because reset is epoch seconds for the legacy integer and milliseconds for the ISO form that v2 sends. execute_workflow's Args entry still described the pre-v2 body shape ("spread at root level"); every input is nested under input now, and this is the commit that ships that help() text to PyPI. The "declared last so positional construction keeps working" sentence was a maintainer's note that belongs in this message, not in every user's help(WorkflowExecutionResult). --- .../content/docs/en/api-reference/python.mdx | 3 + bun.lock | 2 +- packages/python-sdk/README.md | 19 +++ packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/simstudio/__init__.py | 63 ++++++++-- packages/python-sdk/tests/test_client.py | 109 +++++++++++++++++- packages/ts-sdk/README.md | 14 +++ packages/ts-sdk/package.json | 2 +- packages/ts-sdk/src/index.test.ts | 52 ++++++++- 9 files changed, 248 insertions(+), 18 deletions(-) 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', () => {