liyaengine 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: liyaengine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python client for the Liya Engine public API
5
5
  Project-URL: Homepage, https://liyaengine.ai
6
6
  Project-URL: Documentation, https://liyaengine.ai/docs/sdks/python
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  Official Python client for the [Liya Engine](https://liyaengine.ai) public API.
33
33
 
34
- > **Status: early access.** This SDK currently covers Collections and Agents. More resources (Domains, Run, Workflows, Guardrail Policies, Evals) ship incrementally — see [Roadmap](#roadmap).
34
+ > **Status: early access.** This SDK currently covers Collections, Agents, and Workflows. More resources (Domains, Run, Guardrail Policies, Evals) ship incrementally — see [Roadmap](#roadmap).
35
35
 
36
36
  ## Install
37
37
 
@@ -81,6 +81,33 @@ result = client.agents.run(agent.agent_key, input={"message": "My order hasn't a
81
81
  history = client.agents.list_runs(agent.agent_key)
82
82
  ```
83
83
 
84
+ ## Workflows
85
+
86
+ ```python
87
+ workflow = client.workflows.create(
88
+ name="Lead Intake",
89
+ steps=[{"step_type": "trigger", "config": {"trigger_subtype": "webhook"}}],
90
+ )
91
+
92
+ # Workflows are created in draft status — deploy to publish and make them
93
+ # callable. Deploying a webhook-triggered workflow for the first time mints
94
+ # its webhook secret; capture it immediately, it is never returned again.
95
+ deployed = client.workflows.deploy(workflow.workflow_key)
96
+ webhook_url, webhook_secret = deployed["webhook_url"], deployed["webhook_secret"]
97
+
98
+ # Roll the secret with a grace window so in-flight senders don't break.
99
+ client.workflows.rotate_webhook_secret(workflow.workflow_key, grace_period_seconds=300)
100
+
101
+ # Flip a deployed workflow on/off without touching its definition.
102
+ client.workflows.toggle(workflow.workflow_key)
103
+
104
+ result = client.workflows.run(workflow.workflow_key, input={"email": "ada@example.com"})
105
+
106
+ history = client.workflows.list_runs(workflow.workflow_key)
107
+ ```
108
+
109
+ > `deploy()` and `rotate_webhook_secret()` return the plaintext webhook secret exactly once. Store it immediately — subsequent reads (`get`, `list`) only ever expose `trigger_config["has_secret"]`.
110
+
84
111
  ## Error handling
85
112
 
86
113
  Every failed request raises `LiyaEngineAPIError`, carrying the API's `code`, `message`, and HTTP `status`:
@@ -114,9 +141,9 @@ LiyaEngine(
114
141
 
115
142
  - [x] Collections
116
143
  - [x] Agents (full CRUD, deploy, run, run/session history)
144
+ - [x] Workflows (full CRUD, toggle, deploy, webhook secret rotate, run, run history)
117
145
  - [ ] Domains (custom domain + intent CRUD)
118
146
  - [ ] Run / Run (streaming)
119
- - [ ] Workflows
120
147
  - [ ] Guardrail Policies
121
148
  - [ ] Evaluations
122
149
  - [ ] Async client
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official Python client for the [Liya Engine](https://liyaengine.ai) public API.
4
4
 
5
- > **Status: early access.** This SDK currently covers Collections and Agents. More resources (Domains, Run, Workflows, Guardrail Policies, Evals) ship incrementally — see [Roadmap](#roadmap).
5
+ > **Status: early access.** This SDK currently covers Collections, Agents, and Workflows. More resources (Domains, Run, Guardrail Policies, Evals) ship incrementally — see [Roadmap](#roadmap).
6
6
 
7
7
  ## Install
8
8
 
@@ -52,6 +52,33 @@ result = client.agents.run(agent.agent_key, input={"message": "My order hasn't a
52
52
  history = client.agents.list_runs(agent.agent_key)
53
53
  ```
54
54
 
55
+ ## Workflows
56
+
57
+ ```python
58
+ workflow = client.workflows.create(
59
+ name="Lead Intake",
60
+ steps=[{"step_type": "trigger", "config": {"trigger_subtype": "webhook"}}],
61
+ )
62
+
63
+ # Workflows are created in draft status — deploy to publish and make them
64
+ # callable. Deploying a webhook-triggered workflow for the first time mints
65
+ # its webhook secret; capture it immediately, it is never returned again.
66
+ deployed = client.workflows.deploy(workflow.workflow_key)
67
+ webhook_url, webhook_secret = deployed["webhook_url"], deployed["webhook_secret"]
68
+
69
+ # Roll the secret with a grace window so in-flight senders don't break.
70
+ client.workflows.rotate_webhook_secret(workflow.workflow_key, grace_period_seconds=300)
71
+
72
+ # Flip a deployed workflow on/off without touching its definition.
73
+ client.workflows.toggle(workflow.workflow_key)
74
+
75
+ result = client.workflows.run(workflow.workflow_key, input={"email": "ada@example.com"})
76
+
77
+ history = client.workflows.list_runs(workflow.workflow_key)
78
+ ```
79
+
80
+ > `deploy()` and `rotate_webhook_secret()` return the plaintext webhook secret exactly once. Store it immediately — subsequent reads (`get`, `list`) only ever expose `trigger_config["has_secret"]`.
81
+
55
82
  ## Error handling
56
83
 
57
84
  Every failed request raises `LiyaEngineAPIError`, carrying the API's `code`, `message`, and HTTP `status`:
@@ -85,9 +112,9 @@ LiyaEngine(
85
112
 
86
113
  - [x] Collections
87
114
  - [x] Agents (full CRUD, deploy, run, run/session history)
115
+ - [x] Workflows (full CRUD, toggle, deploy, webhook secret rotate, run, run history)
88
116
  - [ ] Domains (custom domain + intent CRUD)
89
117
  - [ ] Run / Run (streaming)
90
- - [ ] Workflows
91
118
  - [ ] Guardrail Policies
92
119
  - [ ] Evaluations
93
120
  - [ ] Async client
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "liyaengine"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Official Python client for the Liya Engine public API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,6 +2,7 @@ from .client import LiyaEngine
2
2
  from .errors import LiyaEngineAPIError, LiyaEngineNetworkError
3
3
  from .resources.agents import Agent
4
4
  from .resources.collections import Collection
5
+ from .resources.workflows import Workflow
5
6
 
6
7
  __all__ = [
7
8
  "LiyaEngine",
@@ -9,6 +10,7 @@ __all__ = [
9
10
  "LiyaEngineNetworkError",
10
11
  "Collection",
11
12
  "Agent",
13
+ "Workflow",
12
14
  ]
13
15
 
14
- __version__ = "0.2.0"
16
+ __version__ = "0.3.0"
@@ -7,6 +7,7 @@ import httpx
7
7
  from ._http import HttpClient
8
8
  from .resources.agents import AgentsResource
9
9
  from .resources.collections import CollectionsResource
10
+ from .resources.workflows import WorkflowsResource
10
11
 
11
12
  _DEFAULT_BASE_URL = "https://api.liyaengine.ai"
12
13
 
@@ -41,6 +42,7 @@ class LiyaEngine:
41
42
  )
42
43
  self.collections = CollectionsResource(self._http)
43
44
  self.agents = AgentsResource(self._http)
45
+ self.workflows = WorkflowsResource(self._http)
44
46
 
45
47
  def close(self) -> None:
46
48
  self._http.close()
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional, cast
5
+ from urllib.parse import quote, urlencode
6
+
7
+ from .._http import HttpClient
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Workflow:
12
+ id: str
13
+ tenant_id: str
14
+ name: str
15
+ workflow_key: str
16
+ description: Optional[str]
17
+ is_active: bool
18
+ status: str
19
+ trigger_type: str
20
+ trigger_config: Optional[Dict[str, Any]]
21
+ created_at: str
22
+ updated_at: str
23
+ steps: List[Dict[str, Any]]
24
+
25
+ @classmethod
26
+ def _from_dict(cls, data: Dict[str, Any]) -> "Workflow":
27
+ return cls(
28
+ id=data["id"],
29
+ tenant_id=data["tenant_id"],
30
+ name=data["name"],
31
+ workflow_key=data["workflow_key"],
32
+ description=data.get("description"),
33
+ is_active=data["is_active"],
34
+ status=data["status"],
35
+ trigger_type=data["trigger_type"],
36
+ trigger_config=data.get("trigger_config"),
37
+ created_at=data["created_at"],
38
+ updated_at=data["updated_at"],
39
+ steps=data.get("steps", []),
40
+ )
41
+
42
+
43
+ def _query(**params: Any) -> str:
44
+ pairs = {k: v for k, v in params.items() if v is not None}
45
+ return f"?{urlencode(pairs)}" if pairs else ""
46
+
47
+
48
+ class WorkflowsResource:
49
+ """Multi-step graphs of intents/agents/actions/conditions. Mirrors the
50
+ full /v1/workflows surface (see openapi.yaml): CRUD + toggle/deploy/
51
+ webhook-secret-rotate, plus run/run-history. `workflow_id_or_key` in
52
+ every method below accepts either the database id or the human-readable
53
+ workflow_key.
54
+ """
55
+
56
+ def __init__(self, http: HttpClient) -> None:
57
+ self._http = http
58
+
59
+ def list(self) -> List[Workflow]:
60
+ data = self._http.get("/v1/workflows")
61
+ return [Workflow._from_dict(w) for w in cast(List[Dict[str, Any]], data)]
62
+
63
+ def get(self, workflow_id_or_key: str) -> Workflow:
64
+ data = self._http.get(f"/v1/workflows/{quote(workflow_id_or_key)}")
65
+ return Workflow._from_dict(data)
66
+
67
+ def create(
68
+ self,
69
+ *,
70
+ name: str,
71
+ description: Optional[str] = None,
72
+ steps: Optional[List[Dict[str, Any]]] = None,
73
+ ) -> Workflow:
74
+ body: Dict[str, Any] = {"name": name}
75
+ if description is not None:
76
+ body["description"] = description
77
+ if steps is not None:
78
+ body["steps"] = steps
79
+
80
+ data = self._http.post("/v1/workflows", body)
81
+ return Workflow._from_dict(data["workflow"])
82
+
83
+ def update(
84
+ self,
85
+ workflow_id_or_key: str,
86
+ *,
87
+ name: Optional[str] = None,
88
+ description: Optional[str] = None,
89
+ workflow_key: Optional[str] = None,
90
+ steps: Optional[List[Dict[str, Any]]] = None,
91
+ ) -> Workflow:
92
+ """`steps` is upsert-by-id: an existing step id present in this list is
93
+ updated in place, a new one is created, and any existing step id
94
+ omitted from this list is deleted.
95
+ """
96
+ body: Dict[str, Any] = {}
97
+ if name is not None:
98
+ body["name"] = name
99
+ if description is not None:
100
+ body["description"] = description
101
+ if workflow_key is not None:
102
+ body["workflow_key"] = workflow_key
103
+ if steps is not None:
104
+ body["steps"] = steps
105
+
106
+ data = self._http.patch(f"/v1/workflows/{quote(workflow_id_or_key)}", body)
107
+ return Workflow._from_dict(data["workflow"])
108
+
109
+ def toggle(self, workflow_id_or_key: str) -> Workflow:
110
+ """Flips is_active on an already-deployed workflow. Blocked on a draft
111
+ (raises LiyaEngineAPIError(code='DEPLOY_REQUIRED')) — deploy() it first.
112
+ """
113
+ data = self._http.patch(f"/v1/workflows/{quote(workflow_id_or_key)}/toggle")
114
+ return Workflow._from_dict(data["workflow"])
115
+
116
+ def deploy(self, workflow_id_or_key: str) -> Dict[str, Any]:
117
+ """The draft->published transition. On first deploy of a
118
+ webhook-triggered workflow, also mints the webhook infrastructure and
119
+ returns the secret exactly once as `webhook_secret` — capture it
120
+ immediately, it is never retrievable again. Returns
121
+ {"workflow": Workflow-shaped dict, "webhook_url"?: str, "webhook_secret"?: str}.
122
+ """
123
+ return cast(Dict[str, Any], self._http.post(f"/v1/workflows/{quote(workflow_id_or_key)}/deploy"))
124
+
125
+ def rotate_webhook_secret(
126
+ self, workflow_id_or_key: str, *, grace_period_seconds: Optional[int] = None
127
+ ) -> Dict[str, Any]:
128
+ """Only valid for a webhook-triggered, already-deployed workflow. The
129
+ previous secret stays valid for `grace_period_seconds` (default 300,
130
+ max 3600) so external senders can roll over without downtime; pass 0
131
+ for an immediate hard cutover. The new secret is returned exactly once.
132
+ """
133
+ body = {"grace_period_seconds": grace_period_seconds} if grace_period_seconds is not None else None
134
+ return cast(Dict[str, Any], self._http.post(f"/v1/workflows/{quote(workflow_id_or_key)}/webhook-secret/rotate", body))
135
+
136
+ def delete(self, workflow_id_or_key: str) -> None:
137
+ """A hard delete — unlike Collections and Agents, there is no soft-delete/inactive state for a removed workflow."""
138
+ self._http.delete(f"/v1/workflows/{quote(workflow_id_or_key)}")
139
+
140
+ def run(
141
+ self, workflow_id_or_key: str, *, input: Optional[Dict[str, Any]] = None, conversation_id: Optional[str] = None
142
+ ) -> Dict[str, Any]:
143
+ body: Dict[str, Any] = {}
144
+ if input is not None:
145
+ body["input"] = input
146
+ if conversation_id is not None:
147
+ body["conversation_id"] = conversation_id
148
+ return cast(Dict[str, Any], self._http.post(f"/v1/workflows/{quote(workflow_id_or_key)}/run", body))
149
+
150
+ def list_runs(
151
+ self, workflow_id_or_key: str, *, page: Optional[int] = None, page_size: Optional[int] = None, status: Optional[str] = None,
152
+ ) -> Dict[str, Any]:
153
+ qs = _query(page=page, pageSize=page_size, status=status)
154
+ return cast(Dict[str, Any], self._http.get(f"/v1/workflows/{quote(workflow_id_or_key)}/runs{qs}"))
155
+
156
+ def get_run(self, workflow_id_or_key: str, run_id: str) -> Dict[str, Any]:
157
+ data = self._http.get(f"/v1/workflows/{quote(workflow_id_or_key)}/runs/{quote(run_id)}")
158
+ return cast(Dict[str, Any], data["run"])
@@ -0,0 +1,163 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from liyaengine import LiyaEngine, LiyaEngineAPIError
6
+
7
+ BASE_URL = "https://api.test.liyaengine.ai"
8
+
9
+ FIXTURE_WORKFLOW = {
10
+ "id": "wf_123",
11
+ "tenant_id": "tenant_1",
12
+ "name": "Lead Intake",
13
+ "workflow_key": "lead-intake",
14
+ "description": None,
15
+ "is_active": False,
16
+ "status": "draft",
17
+ "trigger_type": "webhook",
18
+ "trigger_config": None,
19
+ "created_at": "2026-01-01T00:00:00.000Z",
20
+ "updated_at": "2026-01-01T00:00:00.000Z",
21
+ "steps": [],
22
+ }
23
+
24
+
25
+ @pytest.fixture
26
+ def client():
27
+ with LiyaEngine(api_key="liya_test_key", base_url=BASE_URL) as c:
28
+ yield c
29
+
30
+
31
+ @respx.mock
32
+ def test_list_returns_deployed_workflows(client):
33
+ respx.get(f"{BASE_URL}/v1/workflows").mock(
34
+ return_value=httpx.Response(200, json={"success": True, "data": [{**FIXTURE_WORKFLOW, "status": "active", "is_active": True}]})
35
+ )
36
+ workflows = client.workflows.list()
37
+ assert len(workflows) == 1
38
+ assert workflows[0].workflow_key == "lead-intake"
39
+
40
+
41
+ @respx.mock
42
+ def test_get_returns_workflow(client):
43
+ respx.get(f"{BASE_URL}/v1/workflows/lead-intake").mock(
44
+ return_value=httpx.Response(200, json={"success": True, "data": FIXTURE_WORKFLOW})
45
+ )
46
+ workflow = client.workflows.get("lead-intake")
47
+ assert workflow.id == "wf_123"
48
+
49
+
50
+ @respx.mock
51
+ def test_get_raises_typed_404(client):
52
+ respx.get(f"{BASE_URL}/v1/workflows/missing").mock(
53
+ return_value=httpx.Response(404, json={"success": False, "error": {"code": "WORKFLOW_NOT_FOUND", "message": "not found"}})
54
+ )
55
+ with pytest.raises(LiyaEngineAPIError) as exc_info:
56
+ client.workflows.get("missing")
57
+ assert exc_info.value.code == "WORKFLOW_NOT_FOUND"
58
+
59
+
60
+ @respx.mock
61
+ def test_create_returns_draft_workflow(client):
62
+ respx.post(f"{BASE_URL}/v1/workflows").mock(
63
+ return_value=httpx.Response(201, json={"success": True, "data": {"workflow": {**FIXTURE_WORKFLOW, "id": "wf_new"}}})
64
+ )
65
+ workflow = client.workflows.create(name="Lead Intake")
66
+ assert workflow.id == "wf_new"
67
+ assert workflow.status == "draft"
68
+
69
+
70
+ @respx.mock
71
+ def test_update_patches_workflow(client):
72
+ respx.patch(f"{BASE_URL}/v1/workflows/lead-intake").mock(
73
+ return_value=httpx.Response(200, json={"success": True, "data": {"workflow": {**FIXTURE_WORKFLOW, "name": "Renamed"}}})
74
+ )
75
+ updated = client.workflows.update("lead-intake", name="Renamed")
76
+ assert updated.name == "Renamed"
77
+
78
+
79
+ @respx.mock
80
+ def test_toggle_flips_is_active(client):
81
+ respx.patch(f"{BASE_URL}/v1/workflows/lead-intake/toggle").mock(
82
+ return_value=httpx.Response(200, json={"success": True, "data": {"workflow": {**FIXTURE_WORKFLOW, "status": "active", "is_active": True}}})
83
+ )
84
+ toggled = client.workflows.toggle("lead-intake")
85
+ assert toggled.is_active is True
86
+
87
+
88
+ @respx.mock
89
+ def test_toggle_raises_typed_409_when_draft(client):
90
+ respx.patch(f"{BASE_URL}/v1/workflows/draft-wf/toggle").mock(
91
+ return_value=httpx.Response(409, json={"success": False, "error": {"code": "DEPLOY_REQUIRED", "message": "deploy first"}})
92
+ )
93
+ with pytest.raises(LiyaEngineAPIError) as exc_info:
94
+ client.workflows.toggle("draft-wf")
95
+ assert exc_info.value.code == "DEPLOY_REQUIRED"
96
+
97
+
98
+ @respx.mock
99
+ def test_deploy_returns_one_time_webhook_secret(client):
100
+ respx.post(f"{BASE_URL}/v1/workflows/lead-intake/deploy").mock(
101
+ return_value=httpx.Response(200, json={
102
+ "success": True,
103
+ "data": {
104
+ "workflow": {**FIXTURE_WORKFLOW, "status": "active", "is_active": True},
105
+ "webhook_url": "https://api.test.liyaengine.ai/webhooks/workflows/abc123",
106
+ "webhook_secret": "plaintext-secret-shown-once",
107
+ },
108
+ })
109
+ )
110
+ result = client.workflows.deploy("lead-intake")
111
+ assert result["workflow"]["status"] == "active"
112
+ assert result["webhook_secret"] == "plaintext-secret-shown-once"
113
+
114
+
115
+ @respx.mock
116
+ def test_rotate_webhook_secret_returns_new_secret(client):
117
+ respx.post(f"{BASE_URL}/v1/workflows/lead-intake/webhook-secret/rotate").mock(
118
+ return_value=httpx.Response(200, json={
119
+ "success": True,
120
+ "data": {
121
+ "webhook_url": "https://api.test.liyaengine.ai/webhooks/workflows/abc123",
122
+ "webhook_secret": "new-plaintext-secret",
123
+ "previous_secret_valid_until": "2026-01-01T00:05:00.000Z",
124
+ },
125
+ })
126
+ )
127
+ result = client.workflows.rotate_webhook_secret("lead-intake", grace_period_seconds=300)
128
+ assert result["webhook_secret"] == "new-plaintext-secret"
129
+ assert result["previous_secret_valid_until"] == "2026-01-01T00:05:00.000Z"
130
+
131
+
132
+ @respx.mock
133
+ def test_delete_does_not_raise(client):
134
+ respx.delete(f"{BASE_URL}/v1/workflows/lead-intake").mock(return_value=httpx.Response(200, json={"success": True}))
135
+ client.workflows.delete("lead-intake")
136
+
137
+
138
+ @respx.mock
139
+ def test_run_returns_result(client):
140
+ respx.post(f"{BASE_URL}/v1/workflows/lead-intake/run").mock(
141
+ return_value=httpx.Response(200, json={"success": True, "data": {"run_id": "run_1", "conversation_id": "convo_1", "status": "completed", "trace": []}})
142
+ )
143
+ result = client.workflows.run("lead-intake", input={"name": "Ada"})
144
+ assert result["run_id"] == "run_1"
145
+ assert result["status"] == "completed"
146
+
147
+
148
+ @respx.mock
149
+ def test_list_runs(client):
150
+ respx.get(f"{BASE_URL}/v1/workflows/lead-intake/runs").mock(
151
+ return_value=httpx.Response(200, json={"success": True, "data": {"runs": [], "pagination": {"page": 1}}})
152
+ )
153
+ result = client.workflows.list_runs("lead-intake")
154
+ assert result["runs"] == []
155
+
156
+
157
+ @respx.mock
158
+ def test_get_run(client):
159
+ respx.get(f"{BASE_URL}/v1/workflows/lead-intake/runs/run_1").mock(
160
+ return_value=httpx.Response(200, json={"success": True, "data": {"run": {"id": "run_1"}}})
161
+ )
162
+ run = client.workflows.get_run("lead-intake", "run_1")
163
+ assert run["id"] == "run_1"
File without changes
File without changes