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.
- {liyaengine-0.2.0 → liyaengine-0.3.0}/PKG-INFO +30 -3
- {liyaengine-0.2.0 → liyaengine-0.3.0}/README.md +29 -2
- {liyaengine-0.2.0 → liyaengine-0.3.0}/pyproject.toml +1 -1
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/__init__.py +3 -1
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/client.py +2 -0
- liyaengine-0.3.0/src/liyaengine/resources/workflows.py +158 -0
- liyaengine-0.3.0/tests/test_workflows.py +163 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/.github/workflows/ci.yml +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/.gitignore +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/LICENSE +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/_http.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/errors.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/py.typed +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/resources/__init__.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/resources/agents.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/src/liyaengine/resources/collections.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/tests/test_agents.py +0 -0
- {liyaengine-0.2.0 → liyaengine-0.3.0}/tests/test_collections.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: liyaengine
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|