Skip to content

Commit e38500c

Browse files
sezeryavuzclaude
andcommitted
feat!: realign SDK with platform API (1.0.0)
Major, breaking release bringing the SDK back in line with the current platform. Verified: ruff + mypy clean, 124 tests passing. Core: - Fix SSE event dispatch: normalize event name from data["type"] (B-group streams) / event: field (A-group); terminal-event stop; heartbeat filter; typed connect errors. - Structured 402/billing denial mapping (BillingError + Payment/Quota/Credit/ Wallet subclasses); RateLimitError carries limit/remaining/reset. - _paginate supports page / offset (has_next|has_more|total) / cursor styles and nested data.<items> envelopes. - Pydantic v2 base (ModulexModel) + AsyncPage[T]; declare typing-extensions. - Packaging: version 1.0.0, Production classifier, CI on dev/staging, User-Agent header, env-var config, Idempotency-Key; remove dead _compat. Resources: - composer: chat(llm=dict) fix, resume() HITL, set_focus(), list(); drop history(). - executions: drop llm(410)/knowledge_config, add attribution_workflow_id + idempotency_key; add list_runs/iter_runs/get_run (/workflow-runs). - assistant: new resource (8 endpoints, HITL) sharing realtime models. - credentials: OAuth2 initiate/refresh + create(oauth_config=). - organizations: preview_invite, get_settings, set_llm_model_visibility, set_composer_llm. Types: all domains migrated TypedDict -> Pydantic v2; methods return typed models (dict-style access preserved via shim; unknown fields kept). Removed: templates resource, system.metrics, api_keys.is_revoked. Docs: README + CHANGELOG updated for the new typed surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 285aa90 commit e38500c

63 files changed

Lines changed: 4983 additions & 1575 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ name: CI
22

33
on:
44
push:
5-
branches: [main]
5+
branches: [main, dev, staging]
66
pull_request:
7-
branches: [main]
7+
branches: [main, dev, staging]
88

99
jobs:
1010
test:

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to the ModuleX Python SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.0] - 2026-06-19
9+
10+
Major release realigning the SDK with the current platform API. **Breaking.**
11+
12+
### Added
13+
14+
- **`assistant` resource** — agentic standard chat (chat/get/list/listen/resume/cancel/status/delete) with HITL.
15+
- **Composer HITL**`composer.resume()`, `composer.set_focus()`, `composer.list()`; `composer.chat(llm=...)` now takes a provider-config dict (`ComposerLLMConfig`).
16+
- **Execution history**`executions.list_runs()`, `executions.iter_runs()` (typed `AsyncPage`), `executions.get_run()`; `executions.run(idempotency_key=...)` and `attribution_workflow_id`.
17+
- **Credentials OAuth2**`initiate_oauth2()`, `refresh_oauth2()`, and `create(oauth_config=...)`.
18+
- **Organization settings**`preview_invite()`, `get_settings()`, `set_llm_model_visibility()`, `set_composer_llm()`.
19+
- **Structured billing errors**`BillingError` + `PaymentRequiredError`/`QuotaExceededError`/`CreditExhaustedError`/`WalletError` (402/403/429) exposing `code`/`layer`/`key`/`current`/`limit`/`reason`; `RateLimitError` now carries `limit`/`remaining`/`reset`.
20+
- **Typed responses** — every method returns a Pydantic v2 model (`ModulexModel`); dict-style access still works; unknown backend fields are preserved.
21+
- Environment-variable config (`MODULEX_API_KEY`/`MODULEX_BASE_URL`/`MODULEX_ORGANIZATION_ID`), `User-Agent` header, `default_headers`, `Idempotency-Key` support.
22+
23+
### Changed
24+
25+
- **SSE event dispatch fixed**`event.event` is normalized from `data["type"]`, so workflow/composer/assistant streams dispatch correctly; terminal events stop iteration; heartbeats filtered.
26+
- `_paginate` now supports page / offset (`has_next`|`has_more`|`total`) / cursor styles and nested `data.<items>` envelopes.
27+
- All response types migrated from `TypedDict` to Pydantic v2 models, aligned field-by-field with the backend.
28+
29+
### Removed
30+
31+
- **`templates` resource** (removed from the platform).
32+
- `system.metrics()` (endpoint no longer exists), composer workflow-`history()` (deprecated), `ApiKeyResponse.is_revoked`, the unused sync `_compat.run_sync` shim, and the removed `/workflows/run` `llm`/`knowledge_config` parameters.
33+
834
## [0.1.0] - 2026-03-09
935

1036
### Added

README.md

Lines changed: 85 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -113,43 +113,46 @@ result = await client.executions.run(
113113
workflow_id="workflow-uuid",
114114
input={"messages": [{"role": "user", "content": "Hello!"}]},
115115
)
116+
print(result.run_id) # typed attribute access (responses are Pydantic models)
116117

117-
# Direct LLM call
118+
# Safely retry a run without double-execution
118119
result = await client.executions.run(
119-
llm={
120-
"integration_name": "openai",
121-
"provider_id": "openai",
122-
"model_id": "gpt-4o-mini",
123-
"temperature": 0.4,
124-
},
125-
input={"messages": [{"role": "user", "content": "Hello!"}]},
120+
workflow_id="workflow-uuid",
121+
input={"messages": [...]},
122+
idempotency_key="order-4823", # stable key across retries
126123
)
127124

128-
# Get execution state
125+
# Get execution state / resume after interrupt / cancel
129126
state = await client.executions.get_state(thread_id="thread-uuid")
130-
131-
# Resume after interrupt
132-
await client.executions.resume(
133-
thread_id="thread-uuid",
134-
run_id="run-uuid",
135-
resume_value="user input",
136-
)
137-
138-
# Cancel execution
127+
await client.executions.resume(thread_id="thread-uuid", run_id="run-uuid", resume_value="user input")
139128
await client.executions.cancel(run_id="run-uuid", reason="No longer needed")
129+
130+
# Run history (workflow-runs)
131+
runs = await client.executions.list_runs(workflow_id="workflow-uuid", limit=50)
132+
async for run in client.executions.iter_runs(status="succeeded"): # auto-paginates
133+
print(run.run_id, run.status)
134+
detail = await client.executions.get_run(run_pk="run-row-id")
140135
```
141136

137+
> Agentic ("direct LLM") chat moved off `/workflows/run` — use `client.assistant.chat(...)` instead.
138+
142139
### SSE Streaming
143140

141+
The backend carries the event type in the SSE `event:` field for `/chats/stream`, and in the JSON
142+
`data["type"]` for workflow/composer/assistant streams. The SDK normalizes both, so `event.event`
143+
always holds the logical type. Streams stop after a terminal event (`done`/`error`/`cancelled`/
144+
`interrupted`) and heartbeats are filtered by default.
145+
144146
```python
145147
# Listen to workflow execution events
146148
async for event in client.executions.listen(run_id="run-uuid"):
147149
if event.event == "node_update":
148-
print(f"Node {event.data['node_id']}: {event.data['status']}")
149-
elif event.event == "done":
150-
print(f"Completed in {event.data['total_execution_time_ms']}ms")
151-
elif event.event == "error":
152-
print(f"Error: {event.data['error_message']}")
150+
print(f"Node {event.data['node']}: {event.data.get('output')}")
151+
elif event.event == "interrupt":
152+
payload = event.data["data"] # InterruptEventData is nested under data["data"]
153+
print(f"Needs input: {payload.get('message')}")
154+
elif event.is_terminal:
155+
print(f"Stream ended: {event.event}")
153156

154157
# Listen to chat list updates
155158
async for event in client.chats.stream():
@@ -237,20 +240,6 @@ runs = await client.schedules.list_runs(schedule["id"])
237240
stats = await client.schedules.run_stats(schedule["id"], days=30)
238241
```
239242

240-
### Templates
241-
242-
```python
243-
# Browse templates
244-
templates = await client.templates.list()
245-
246-
# Use a template
247-
result = await client.templates.use("template-id")
248-
print(f"Created workflow: {result['workflow']['id']}")
249-
250-
# Like a template
251-
await client.templates.like("template-id")
252-
```
253-
254243
### Deployments
255244

256245
```python
@@ -269,23 +258,48 @@ await client.deployments.deactivate("workflow-uuid")
269258

270259
### Composer
271260

261+
`llm` is a provider config dict ({integration_name, provider_id, model_id, credential_id?}) — pass a
262+
`ComposerLLMConfig` or an equivalent dict.
263+
272264
```python
273-
# Start a composer session
265+
from modulex.types import ComposerLLMConfig, YesNoResponse, user_input_request_from_event
266+
274267
result = await client.composer.chat(
275268
message="Add an LLM node that summarizes the input",
276269
workflow_id="workflow-uuid",
277-
llm={"integration_name": "anthropic", "model_id": "claude-sonnet-4-20250514"},
270+
llm=ComposerLLMConfig(integration_name="anthropic", provider_id="anthropic", model_id="claude-sonnet-4-20250514"),
278271
)
279272

280-
# Listen to composer events
281-
async for event in client.composer.listen(result["composer_chat_id"], result["run_id"]):
282-
if event.event == "workflow_change":
283-
print(f"Workflow modified: {event.data}")
284-
elif event.event == "done":
273+
# Listen, and answer a human-in-the-loop question (HITL) when the run pauses
274+
async for event in client.composer.listen(result.composer_chat_id, result.run_id):
275+
if event.event == "user_input_request":
276+
question = user_input_request_from_event(event.data) # typed UserInputRequest
277+
await client.composer.resume(
278+
result.composer_chat_id,
279+
request_id=question.request_id,
280+
response=YesNoResponse(answer=True),
281+
llm={"integration_name": "anthropic", "provider_id": "anthropic", "model_id": "claude-sonnet-4-20250514"},
282+
) # returns a NEW run_id — re-subscribe with listen() on it
283+
elif event.is_terminal:
285284
break
286285

287-
# Save or revert changes
288-
await client.composer.save(result["composer_chat_id"])
286+
chats = await client.composer.list(limit=20) # cursor-paginated
287+
await client.composer.save(result.composer_chat_id) # or .revert(...)
288+
```
289+
290+
### Assistant (agentic chat)
291+
292+
Shares the HITL contract with the composer. All endpoints are available to any org member.
293+
294+
```python
295+
result = await client.assistant.chat("Summarize my latest runs", llm=ComposerLLMConfig(
296+
integration_name="openai", provider_id="openai", model_id="gpt-4o-mini",
297+
))
298+
async for event in client.assistant.listen(result.chat_id, result.run_id):
299+
if event.event == "response_chunk":
300+
print(event.data.get("data", {}).get("text", ""), end="")
301+
elif event.is_terminal:
302+
break
289303
```
290304

291305
### Other Resources
@@ -338,7 +352,7 @@ try:
338352
except NotFoundError:
339353
print("Workflow not found")
340354
except RateLimitError as e:
341-
print(f"Rate limited. Retry after {e.retry_after}s")
355+
print(f"Rate limited. Retry after {e.retry_after}s (limit={e.limit}, remaining={e.remaining})")
342356
except AuthenticationError:
343357
print("Invalid API key")
344358
except ValidationError as e:
@@ -347,13 +361,28 @@ except ModulexError as e:
347361
print(f"API error ({e.status_code}): {e.message}")
348362
```
349363

364+
Usage/billing denials (quota, credit, wallet) are surfaced structurally via `BillingError` and its
365+
subclasses, which expose `code`, `layer`, `key`, `current`, `limit`, and `reason`:
366+
367+
```python
368+
from modulex import BillingError, CreditExhaustedError
369+
370+
try:
371+
await client.executions.run(workflow_id="wf")
372+
except CreditExhaustedError as e: # 402, layer="credit"
373+
print(f"Out of credits: {e.current}/{e.limit}")
374+
except BillingError as e: # any quota/credit/wallet denial
375+
print(f"Denied ({e.layer}/{e.code}): {e.reason}")
376+
```
377+
350378
### Exception Hierarchy
351379

352380
| Exception | HTTP Status | Description |
353381
|-----------|-------------|-------------|
354382
| `ModulexError` || Base exception |
355383
| `BadRequestError` | 400 | Malformed request |
356384
| `AuthenticationError` | 401 | Invalid/missing auth |
385+
| `PaymentRequiredError` | 402 | Payment required (billing) |
357386
| `PermissionError` | 403 | Insufficient permissions |
358387
| `NotFoundError` | 404 | Resource not found |
359388
| `ConflictError` | 409 | Resource conflict |
@@ -362,12 +391,18 @@ except ModulexError as e:
362391
| `InternalError` | 500 | Server error |
363392
| `ExternalServiceError` | 502 | External service failure |
364393
| `ServiceUnavailableError` | 503 | Service unavailable |
394+
| `BillingError` | 402/403/429 | Usage denial (base) — `code`/`layer`/`reason` |
395+
| `QuotaExceededError` | 403 | Quota exceeded (`layer="quota"`) |
396+
| `CreditExhaustedError` | 402 | Credit plan exhausted (`layer="credit"`) |
397+
| `WalletError` | 402 | Wallet overage denied (`layer="wallet"`) |
365398
| `StreamError` || SSE stream error |
366399
| `TimeoutError` || Request timeout |
367400

368401
## Type Hints
369402

370-
All types are available for import:
403+
Responses are **Pydantic v2 models** — use typed attribute access (`result.id`) or, for
404+
compatibility, dict-style access (`result["id"]`). Unknown fields the backend may add are preserved.
405+
All models are importable:
371406

372407
```python
373408
from modulex import SSEEvent
@@ -377,6 +412,8 @@ from modulex.types import (
377412
EdgeDefinition,
378413
LLMConfig,
379414
RunResponse,
415+
AsyncPage, # typed auto-pagination (e.g. executions.iter_runs)
416+
ModulexModel, # base class for all response models
380417
)
381418
```
382419

examples/streaming_example.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
"""Detailed SSE streaming example with all event types."""
1+
"""Detailed SSE streaming example with all event types.
2+
3+
The backend emits workflow/composer/assistant events as raw ``data: {"type": ...}``
4+
frames (no SSE ``event:`` line). The SDK normalizes this so ``event.event`` carries
5+
the logical type. Note the nesting: ``interrupt`` and ``error`` carry their payload
6+
under ``event.data["data"]``, while ``node_update``/``done`` fields are root-level.
7+
"""
28

39
import asyncio
410

@@ -9,14 +15,13 @@ async def handle_workflow_events(client: Modulex, run_id: str) -> None:
915
"""Handle all SSE event types from workflow execution."""
1016
async for event in client.executions.listen(run_id):
1117
if event.event == "metadata":
12-
print(f"[META] Workflow: {event.data.get('workflow_name')} v{event.data.get('workflow_version')}")
13-
nodes = event.data.get("nodes", [])
14-
print(f" Nodes: {len(nodes)}")
18+
print(f"[META] workflow_type={event.data.get('workflow_type')}")
1519

1620
elif event.event == "node_update":
21+
# node_update fields are root-level (no "data" wrapper).
1722
node_id = event.data.get("node_id")
1823
node_type = event.data.get("node_type")
19-
status = event.data.get("status")
24+
status = event.data.get("status") # started | completed | error
2025
time_ms = event.data.get("execution_time_ms")
2126

2227
if status == "started":
@@ -27,28 +32,34 @@ async def handle_workflow_events(client: Modulex, run_id: str) -> None:
2732
print(f" [{node_type}] {node_id} FAILED: {event.data.get('error')}")
2833

2934
elif event.event == "interrupt":
30-
print(f"\n[INTERRUPT] {event.data.get('message')}")
31-
print(f" Node: {event.data.get('node_id')}")
32-
print(f" Instructions: {event.data.get('resume_instructions')}")
33-
34-
elif event.event == "resumed":
35-
print(f"[RESUMED] Thread: {event.data.get('thread_id')}")
35+
# InterruptEventData is nested under data["data"].
36+
payload = event.data.get("data", {})
37+
print(f"\n[INTERRUPT] {payload.get('message')}")
38+
print(f" Node: {payload.get('node_id')}")
39+
print(f" Instructions: {payload.get('resume_instructions')}")
3640

3741
elif event.event == "done":
42+
# Terminal event — the stream stops after this. Fields are root-level.
3843
print(f"\n[DONE] Steps: {event.data.get('steps_executed')}")
3944
print(f" Total time: {event.data.get('total_execution_time_ms')}ms")
4045

4146
elif event.event == "error":
42-
print(f"\n[ERROR] {event.data.get('error_type')}: {event.data.get('error_message')}")
43-
if event.data.get("node_id"):
44-
print(f" At node: {event.data['node_id']}")
47+
# ErrorEventData is nested under data["data"]. Terminal event.
48+
payload = event.data.get("data", {})
49+
print(f"\n[ERROR] {payload.get('error_type')}: {payload.get('error_message')}")
50+
if payload.get("node_id"):
51+
print(f" At node: {payload['node_id']}")
52+
53+
elif event.event == "cancelled":
54+
# Terminal — fields are root-level.
55+
print(f"\n[CANCELLED] reason={event.data.get('reason')}")
4556

4657
else:
4758
print(f"[{event.event}] {event.data}")
4859

4960

5061
async def handle_chat_stream(client: Modulex) -> None:
51-
"""Listen for real-time chat list updates."""
62+
"""Listen for real-time chat list updates (A-group: real SSE event: names)."""
5263
async for event in client.chats.stream():
5364
if event.event == "connected":
5465
print("Connected to chat stream")
@@ -61,13 +72,11 @@ async def main() -> None:
6172
api_key="mx_live_your_api_key_here",
6273
organization_id="your-org-id",
6374
) as client:
64-
# Run a workflow
75+
# Run a workflow, then listen to its event stream.
6576
result = await client.executions.run(
6677
workflow_id="your-workflow-id",
6778
input={"messages": [{"role": "user", "content": "Analyze this data"}]},
6879
)
69-
70-
# Handle all events
7180
await handle_workflow_events(client, result["run_id"])
7281

7382

pyproject.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "modulex-python"
7-
version = "0.1.0"
7+
version = "1.0.0"
88
description = "Official Python SDK for the ModuleX AI workflow orchestration platform"
99
readme = "README.md"
1010
license = "MIT"
@@ -14,7 +14,7 @@ authors = [
1414
]
1515
keywords = ["modulex", "ai", "workflow", "orchestration", "sdk"]
1616
classifiers = [
17-
"Development Status :: 4 - Beta",
17+
"Development Status :: 5 - Production/Stable",
1818
"Intended Audience :: Developers",
1919
"License :: OSI Approved :: MIT License",
2020
"Programming Language :: Python :: 3",
@@ -29,6 +29,8 @@ classifiers = [
2929
dependencies = [
3030
"httpx>=0.27",
3131
"httpx-sse>=0.4",
32+
"pydantic>=2.7",
33+
"typing-extensions>=4.10",
3234
]
3335

3436
[project.optional-dependencies]
@@ -68,6 +70,13 @@ line-length = 120
6870
[tool.ruff.lint]
6971
select = ["E", "F", "I", "N", "W", "UP"]
7072

73+
[tool.ruff.lint.per-file-ignores]
74+
# Pydantic models evaluate their annotations at runtime to build fields. On
75+
# Python 3.9 the PEP 604 `X | None` syntax raises TypeError when eval'd, so the
76+
# response-model files must keep explicit Optional/Union — exempt the whole
77+
# types/ package from the UP (pyupgrade) modernizers.
78+
"src/modulex/types/*.py" = ["UP"]
79+
7180
[tool.mypy]
7281
python_version = "3.9"
7382
strict = true

0 commit comments

Comments
 (0)