ezop 0.0.2__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.
ezop-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezop
3
+ Version: 0.0.2
4
+ Summary: Ezop SDK - The story of every AI agent
5
+ Author-email: Ezop <support@ezop.ai>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: responses; extra == "dev"
12
+ Requires-Dist: twine; extra == "dev"
13
+ Requires-Dist: build; extra == "dev"
14
+
15
+ # Ezop Python SDK
16
+
17
+ Ezop tracks the lifecycle of your AI agents — registrations, versions, and runs — so you have full observability across every deployment.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install ezop
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Set your API key before using the SDK:
28
+
29
+ ```bash
30
+ export EZOP_API_KEY=your-ezop-api-key-here
31
+ export EZOP_API_URL=https://api.ezop.ai # optional, this is the default
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ from ezop import Agent
38
+
39
+ # Register your agent at startup (safe to call on every deploy — idempotent)
40
+ agent = Agent.init(
41
+ name="customer-support-bot",
42
+ owner="growth-team",
43
+ version="v0.3",
44
+ runtime="langchain",
45
+ description="Handles tier-1 customer support tickets",
46
+ default_permissions=["read:tickets"],
47
+ permissions=["read:tickets", "write:replies"],
48
+ changelog="Switched to new retrieval pipeline",
49
+ )
50
+
51
+ # Track a run
52
+ agent.start_run()
53
+
54
+ try:
55
+ result = your_agent_logic(user_input)
56
+ agent.end_run(
57
+ status="completed",
58
+ total_tokens=result.usage.total_tokens,
59
+ total_cost=result.cost,
60
+ metadata={"user_id": user_id},
61
+ )
62
+ except Exception as e:
63
+ agent.end_run(status="failed", metadata={"error": str(e)})
64
+ raise
65
+ ```
66
+
67
+ ## API reference
68
+
69
+ ### `Agent.init()`
70
+
71
+ Registers the agent and its current version with the Ezop platform. On subsequent calls with the same `name` + `owner`, the agent record is updated in place — safe to call on every startup.
72
+
73
+ | Parameter | Required | Description |
74
+ |---|---|---|
75
+ | `name` | yes | Agent name |
76
+ | `owner` | yes | Team or user that owns the agent |
77
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
78
+ | `runtime` | yes | Runtime framework (e.g. `"langchain"`, `"langchain"`, `"python"`) |
79
+ | `description` | no | Human-readable description of the agent |
80
+ | `default_permissions` | no | Default permission scopes for the agent |
81
+ | `permissions` | no | Permission scopes for this specific version |
82
+ | `changelog` | no | Release notes for this version |
83
+
84
+ ### `agent.start_run()`
85
+
86
+ Creates a new run record with status `running`. Call this at the start of each agent invocation.
87
+
88
+ ### `agent.end_run()`
89
+
90
+ Closes the current run. Only provided fields are written.
91
+
92
+ | Parameter | Default | Description |
93
+ |---|---|---|
94
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
95
+ | `total_tokens` | `None` | Total tokens consumed |
96
+ | `total_cost` | `None` | Total cost in USD |
97
+ | `metadata` | `None` | Arbitrary JSON metadata |
98
+
99
+ ## Logging
100
+
101
+ The SDK uses Python's standard `logging` module under the `ezop` namespace. To enable logs in your application:
102
+
103
+ ```python
104
+ import logging
105
+ logging.getLogger("ezop").setLevel(logging.DEBUG)
106
+ ```
ezop-0.0.2/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Ezop Python SDK
2
+
3
+ Ezop tracks the lifecycle of your AI agents — registrations, versions, and runs — so you have full observability across every deployment.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install ezop
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Set your API key before using the SDK:
14
+
15
+ ```bash
16
+ export EZOP_API_KEY=your-ezop-api-key-here
17
+ export EZOP_API_URL=https://api.ezop.ai # optional, this is the default
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ from ezop import Agent
24
+
25
+ # Register your agent at startup (safe to call on every deploy — idempotent)
26
+ agent = Agent.init(
27
+ name="customer-support-bot",
28
+ owner="growth-team",
29
+ version="v0.3",
30
+ runtime="langchain",
31
+ description="Handles tier-1 customer support tickets",
32
+ default_permissions=["read:tickets"],
33
+ permissions=["read:tickets", "write:replies"],
34
+ changelog="Switched to new retrieval pipeline",
35
+ )
36
+
37
+ # Track a run
38
+ agent.start_run()
39
+
40
+ try:
41
+ result = your_agent_logic(user_input)
42
+ agent.end_run(
43
+ status="completed",
44
+ total_tokens=result.usage.total_tokens,
45
+ total_cost=result.cost,
46
+ metadata={"user_id": user_id},
47
+ )
48
+ except Exception as e:
49
+ agent.end_run(status="failed", metadata={"error": str(e)})
50
+ raise
51
+ ```
52
+
53
+ ## API reference
54
+
55
+ ### `Agent.init()`
56
+
57
+ Registers the agent and its current version with the Ezop platform. On subsequent calls with the same `name` + `owner`, the agent record is updated in place — safe to call on every startup.
58
+
59
+ | Parameter | Required | Description |
60
+ |---|---|---|
61
+ | `name` | yes | Agent name |
62
+ | `owner` | yes | Team or user that owns the agent |
63
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
64
+ | `runtime` | yes | Runtime framework (e.g. `"langchain"`, `"langchain"`, `"python"`) |
65
+ | `description` | no | Human-readable description of the agent |
66
+ | `default_permissions` | no | Default permission scopes for the agent |
67
+ | `permissions` | no | Permission scopes for this specific version |
68
+ | `changelog` | no | Release notes for this version |
69
+
70
+ ### `agent.start_run()`
71
+
72
+ Creates a new run record with status `running`. Call this at the start of each agent invocation.
73
+
74
+ ### `agent.end_run()`
75
+
76
+ Closes the current run. Only provided fields are written.
77
+
78
+ | Parameter | Default | Description |
79
+ |---|---|---|
80
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
81
+ | `total_tokens` | `None` | Total tokens consumed |
82
+ | `total_cost` | `None` | Total cost in USD |
83
+ | `metadata` | `None` | Arbitrary JSON metadata |
84
+
85
+ ## Logging
86
+
87
+ The SDK uses Python's standard `logging` module under the `ezop` namespace. To enable logs in your application:
88
+
89
+ ```python
90
+ import logging
91
+ logging.getLogger("ezop").setLevel(logging.DEBUG)
92
+ ```
@@ -0,0 +1,7 @@
1
+ import logging
2
+
3
+ from .agent import Agent
4
+
5
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
6
+
7
+ __all__ = ["Agent"]
@@ -0,0 +1,140 @@
1
+ import logging
2
+ from typing import Optional
3
+ from .models import AgentModel, AgentVersion, AgentRun
4
+ from .client import EzopClient
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class Agent:
10
+
11
+ def __init__(self, model: AgentModel, version: AgentVersion):
12
+ self.model = model
13
+ self.version = version
14
+ self.client = EzopClient()
15
+ self.current_run: Optional[AgentRun] = None
16
+
17
+ @classmethod
18
+ def init(
19
+ cls,
20
+ name: str,
21
+ owner: str,
22
+ version: str,
23
+ runtime: str,
24
+ *,
25
+ description: Optional[str] = None,
26
+ default_permissions: Optional[list[str]] = None,
27
+ permissions: Optional[list[str]] = None,
28
+ changelog: Optional[str] = None,
29
+ ) -> "Agent":
30
+ """
31
+ Initialize an Ezop agent and register it with the Ezop platform.
32
+ Creates the agent record and a corresponding version record.
33
+ """
34
+ logger.info(
35
+ "Initializing agent name=%s owner=%s version=%s runtime=%s",
36
+ name,
37
+ owner,
38
+ version,
39
+ runtime,
40
+ )
41
+ client = EzopClient()
42
+
43
+ agent_data = client.register_agent(
44
+ {
45
+ "name": name,
46
+ "owner": owner,
47
+ "runtime": runtime,
48
+ "description": description,
49
+ "default_permissions": default_permissions,
50
+ }
51
+ )["data"]
52
+
53
+ model = AgentModel(
54
+ id=agent_data["id"],
55
+ name=agent_data["name"],
56
+ owner=agent_data["owner"],
57
+ runtime=agent_data.get("runtime"),
58
+ description=agent_data.get("description"),
59
+ default_permissions=agent_data.get("default_permissions") or [],
60
+ )
61
+ logger.debug("Agent model built id=%s", model.id)
62
+
63
+ version_data = client.create_version(
64
+ model.id,
65
+ {
66
+ "version": version,
67
+ "permissions": permissions,
68
+ "changelog": changelog,
69
+ },
70
+ )["data"]
71
+
72
+ agent_version = AgentVersion(
73
+ id=version_data["id"],
74
+ agent_id=model.id,
75
+ version=version_data["version"],
76
+ permissions=version_data.get("permissions") or [],
77
+ changelog=version_data.get("changelog"),
78
+ )
79
+ logger.debug("Agent version built id=%s", agent_version.id)
80
+
81
+ logger.info("Agent initialized id=%s version_id=%s", model.id, agent_version.id)
82
+ return cls(model, agent_version)
83
+
84
+ def start_run(self) -> AgentRun:
85
+ """Start a new run for this agent version."""
86
+ logger.info(
87
+ "Starting run for agent id=%s version_id=%s", self.model.id, self.version.id
88
+ )
89
+ run_data = self.client.start_run(self.model.id, self.version.id)["data"]
90
+ self.current_run = AgentRun(
91
+ id=run_data["id"],
92
+ agent_id=self.model.id,
93
+ version_id=self.version.id,
94
+ status=run_data.get("status", "running"),
95
+ )
96
+ logger.debug(
97
+ "Run created id=%s status=%s", self.current_run.id, self.current_run.status
98
+ )
99
+ return self.current_run
100
+
101
+ def end_run(
102
+ self,
103
+ status: str = "running",
104
+ total_tokens: Optional[int] = None,
105
+ total_cost: Optional[float] = None,
106
+ metadata: Optional[dict] = None,
107
+ message: Optional[str] = None,
108
+ ) -> AgentRun:
109
+ """End the current run.
110
+
111
+ Args:
112
+ status: Final status of the run (e.g. "completed", "failed").
113
+ message: Optional human-readable message, e.g. a failure reason.
114
+ """
115
+ if self.current_run is None:
116
+ logger.error("end_run called with no active run")
117
+ raise RuntimeError("No active run. Call start_run() first.")
118
+
119
+ logger.info("Ending run id=%s status=%s", self.current_run.id, status)
120
+ run_data = self.client.end_run(
121
+ self.current_run.id,
122
+ status=status,
123
+ total_tokens=total_tokens,
124
+ total_cost=total_cost,
125
+ metadata=metadata,
126
+ message=message,
127
+ )["data"]
128
+ self.current_run.status = run_data.get("status", status)
129
+ self.current_run.total_tokens = run_data.get("total_tokens")
130
+ self.current_run.total_cost = run_data.get("total_cost")
131
+ self.current_run.metadata = run_data.get("metadata")
132
+ self.current_run.message = run_data.get("message")
133
+ logger.debug(
134
+ "Run ended id=%s status=%s total_tokens=%s total_cost=%s",
135
+ self.current_run.id,
136
+ self.current_run.status,
137
+ self.current_run.total_tokens,
138
+ self.current_run.total_cost,
139
+ )
140
+ return self.current_run
@@ -0,0 +1,82 @@
1
+ import logging
2
+ import requests
3
+ from .config import Config
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class EzopClient:
9
+
10
+ def _headers(self):
11
+ return {
12
+ "Authorization": f"Bearer {Config.EZOP_API_KEY}",
13
+ "Content-Type": "application/json",
14
+ }
15
+
16
+ def _post(self, path: str, body: dict) -> dict:
17
+ url = f"{Config.EZOP_API_URL}{path}"
18
+ logger.debug("POST %s body=%s", url, body)
19
+ response = requests.post(url, json=body, headers=self._headers())
20
+ if response.status_code not in (200, 201):
21
+ logger.error("POST %s failed status=%s body=%s", url, response.status_code, response.text)
22
+ raise Exception(f"Ezop API error: {response.text}")
23
+ logger.debug("POST %s status=%s", url, response.status_code)
24
+ return response.json()
25
+
26
+ def _patch(self, path: str, body: dict) -> dict:
27
+ url = f"{Config.EZOP_API_URL}{path}"
28
+ logger.debug("PATCH %s body=%s", url, body)
29
+ response = requests.patch(url, json=body, headers=self._headers())
30
+ if response.status_code != 200:
31
+ logger.error("PATCH %s failed status=%s body=%s", url, response.status_code, response.text)
32
+ raise Exception(f"Ezop API error: {response.text}")
33
+ logger.debug("PATCH %s status=%s", url, response.status_code)
34
+ return response.json()
35
+
36
+ def register_agent(self, agent_data: dict) -> dict:
37
+ logger.info("Registering agent name=%s owner=%s", agent_data.get("name"), agent_data.get("owner"))
38
+ result = self._post("/agents/register", agent_data)
39
+ logger.info("Agent registered id=%s", result.get("data", {}).get("id"))
40
+ return result
41
+
42
+ def create_version(self, agent_id: str, version_data: dict) -> dict:
43
+ logger.info("Creating version agent_id=%s version=%s", agent_id, version_data.get("version"))
44
+ result = self._post(f"/agents/{agent_id}/versions", version_data)
45
+ logger.info("Version created id=%s", result.get("data", {}).get("id"))
46
+ return result
47
+
48
+ def start_run(
49
+ self,
50
+ agent_id: str,
51
+ version_id: str | None = None,
52
+ user_id: str | None = None,
53
+ metadata: dict | None = None,
54
+ ) -> dict:
55
+ logger.info("Starting run agent_id=%s version_id=%s", agent_id, version_id)
56
+ body: dict = {}
57
+ if version_id is not None:
58
+ body["version_id"] = version_id
59
+ if user_id is not None:
60
+ body["user_id"] = user_id
61
+ if metadata is not None:
62
+ body["metadata"] = metadata
63
+ result = self._post(f"/agents/{agent_id}/runs", body)
64
+ logger.info("Run started id=%s", result.get("data", {}).get("id"))
65
+ return result
66
+
67
+ def end_run(self, run_id: str, status: str, total_tokens: int | None = None,
68
+ total_cost: float | None = None, metadata: dict | None = None,
69
+ message: str | None = None) -> dict:
70
+ logger.info("Ending run id=%s status=%s", run_id, status)
71
+ body = {"status": status}
72
+ if total_tokens is not None:
73
+ body["total_tokens"] = total_tokens
74
+ if total_cost is not None:
75
+ body["total_cost"] = total_cost
76
+ if metadata is not None:
77
+ body["metadata"] = metadata
78
+ if message is not None:
79
+ body["message"] = message
80
+ result = self._patch(f"/runs/{run_id}", body)
81
+ logger.info("Run ended id=%s status=%s total_tokens=%s total_cost=%s", run_id, status, total_tokens, total_cost)
82
+ return result
@@ -0,0 +1,6 @@
1
+ import os
2
+
3
+
4
+ class Config:
5
+ EZOP_API_URL = os.getenv("EZOP_API_URL", "https://api.ezop.ai")
6
+ EZOP_API_KEY = os.getenv("EZOP_API_KEY", None)
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class AgentModel:
8
+ id: str
9
+ name: str
10
+ owner: str
11
+ runtime: Optional[str]
12
+ description: Optional[str]
13
+ default_permissions: list[str]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgentVersion:
18
+ id: str
19
+ agent_id: str
20
+ version: str
21
+ permissions: list[str]
22
+ changelog: Optional[str]
23
+
24
+
25
+ @dataclass
26
+ class AgentRun:
27
+ id: str
28
+ agent_id: str
29
+ version_id: Optional[str]
30
+ status: str
31
+ start_time: Optional[datetime] = None
32
+ end_time: Optional[datetime] = None
33
+ total_tokens: Optional[int] = None
34
+ total_cost: Optional[float] = None
35
+ metadata: Optional[dict] = None
36
+ message: Optional[str] = None
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezop
3
+ Version: 0.0.2
4
+ Summary: Ezop SDK - The story of every AI agent
5
+ Author-email: Ezop <support@ezop.ai>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: responses; extra == "dev"
12
+ Requires-Dist: twine; extra == "dev"
13
+ Requires-Dist: build; extra == "dev"
14
+
15
+ # Ezop Python SDK
16
+
17
+ Ezop tracks the lifecycle of your AI agents — registrations, versions, and runs — so you have full observability across every deployment.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install ezop
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Set your API key before using the SDK:
28
+
29
+ ```bash
30
+ export EZOP_API_KEY=your-ezop-api-key-here
31
+ export EZOP_API_URL=https://api.ezop.ai # optional, this is the default
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ from ezop import Agent
38
+
39
+ # Register your agent at startup (safe to call on every deploy — idempotent)
40
+ agent = Agent.init(
41
+ name="customer-support-bot",
42
+ owner="growth-team",
43
+ version="v0.3",
44
+ runtime="langchain",
45
+ description="Handles tier-1 customer support tickets",
46
+ default_permissions=["read:tickets"],
47
+ permissions=["read:tickets", "write:replies"],
48
+ changelog="Switched to new retrieval pipeline",
49
+ )
50
+
51
+ # Track a run
52
+ agent.start_run()
53
+
54
+ try:
55
+ result = your_agent_logic(user_input)
56
+ agent.end_run(
57
+ status="completed",
58
+ total_tokens=result.usage.total_tokens,
59
+ total_cost=result.cost,
60
+ metadata={"user_id": user_id},
61
+ )
62
+ except Exception as e:
63
+ agent.end_run(status="failed", metadata={"error": str(e)})
64
+ raise
65
+ ```
66
+
67
+ ## API reference
68
+
69
+ ### `Agent.init()`
70
+
71
+ Registers the agent and its current version with the Ezop platform. On subsequent calls with the same `name` + `owner`, the agent record is updated in place — safe to call on every startup.
72
+
73
+ | Parameter | Required | Description |
74
+ |---|---|---|
75
+ | `name` | yes | Agent name |
76
+ | `owner` | yes | Team or user that owns the agent |
77
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
78
+ | `runtime` | yes | Runtime framework (e.g. `"langchain"`, `"langchain"`, `"python"`) |
79
+ | `description` | no | Human-readable description of the agent |
80
+ | `default_permissions` | no | Default permission scopes for the agent |
81
+ | `permissions` | no | Permission scopes for this specific version |
82
+ | `changelog` | no | Release notes for this version |
83
+
84
+ ### `agent.start_run()`
85
+
86
+ Creates a new run record with status `running`. Call this at the start of each agent invocation.
87
+
88
+ ### `agent.end_run()`
89
+
90
+ Closes the current run. Only provided fields are written.
91
+
92
+ | Parameter | Default | Description |
93
+ |---|---|---|
94
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
95
+ | `total_tokens` | `None` | Total tokens consumed |
96
+ | `total_cost` | `None` | Total cost in USD |
97
+ | `metadata` | `None` | Arbitrary JSON metadata |
98
+
99
+ ## Logging
100
+
101
+ The SDK uses Python's standard `logging` module under the `ezop` namespace. To enable logs in your application:
102
+
103
+ ```python
104
+ import logging
105
+ logging.getLogger("ezop").setLevel(logging.DEBUG)
106
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ ezop/__init__.py
4
+ ezop/agent.py
5
+ ezop/client.py
6
+ ezop/config.py
7
+ ezop/models.py
8
+ ezop.egg-info/PKG-INFO
9
+ ezop.egg-info/SOURCES.txt
10
+ ezop.egg-info/dependency_links.txt
11
+ ezop.egg-info/requires.txt
12
+ ezop.egg-info/top_level.txt
13
+ tests/test_agent.py
14
+ tests/test_integration.py
@@ -0,0 +1,7 @@
1
+ requests
2
+
3
+ [dev]
4
+ pytest
5
+ responses
6
+ twine
7
+ build
@@ -0,0 +1 @@
1
+ ezop
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "ezop"
3
+ version = "0.0.2"
4
+ description = "Ezop SDK - The story of every AI agent"
5
+ authors = [{ name = "Ezop", email = "support@ezop.ai" }]
6
+ readme = "README.md"
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "requests"
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "pytest",
15
+ "responses",
16
+ "twine",
17
+ "build",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=42", "wheel"]
22
+ build-backend = "setuptools.build_meta"
ezop-0.0.2/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,166 @@
1
+ from unittest.mock import patch, MagicMock
2
+ import pytest
3
+ from ezop import Agent
4
+ from ezop.models import AgentModel, AgentVersion, AgentRun
5
+
6
+
7
+ AGENT_RESP = {"success": True, "error": None, "data": {
8
+ "id": "agent-uuid-123",
9
+ "name": "support-bot",
10
+ "owner": "growth-team",
11
+ "runtime": "langchain",
12
+ "description": "Handles support tickets",
13
+ "default_permissions": ["read:tickets"],
14
+ }}
15
+
16
+ VERSION_RESP = {"success": True, "error": None, "data": {
17
+ "id": "version-uuid-456",
18
+ "version": "v0.3",
19
+ "permissions": ["read:tickets", "write:replies"],
20
+ "changelog": "Initial release",
21
+ }}
22
+
23
+ RUN_RESP = {"success": True, "error": None, "data": {
24
+ "id": "run-uuid-789",
25
+ "status": "running",
26
+ }}
27
+
28
+
29
+ def make_agent():
30
+ with patch("ezop.client.EzopClient.register_agent", return_value=AGENT_RESP), \
31
+ patch("ezop.client.EzopClient.create_version", return_value=VERSION_RESP):
32
+ return Agent.init(
33
+ name="support-bot",
34
+ owner="growth-team",
35
+ version="v0.3",
36
+ runtime="langchain",
37
+ description="Handles support tickets",
38
+ default_permissions=["read:tickets"],
39
+ permissions=["read:tickets", "write:replies"],
40
+ changelog="Initial release",
41
+ )
42
+
43
+
44
+ class TestAgentInit:
45
+ def test_returns_agent_instance(self):
46
+ agent = make_agent()
47
+ assert isinstance(agent, Agent)
48
+
49
+ def test_model_populated(self):
50
+ agent = make_agent()
51
+ assert agent.model.id == "agent-uuid-123"
52
+ assert agent.model.name == "support-bot"
53
+ assert agent.model.owner == "growth-team"
54
+ assert agent.model.runtime == "langchain"
55
+ assert agent.model.description == "Handles support tickets"
56
+ assert agent.model.default_permissions == ["read:tickets"]
57
+
58
+ def test_version_populated(self):
59
+ agent = make_agent()
60
+ assert agent.version.id == "version-uuid-456"
61
+ assert agent.version.agent_id == "agent-uuid-123"
62
+ assert agent.version.version == "v0.3"
63
+ assert agent.version.permissions == ["read:tickets", "write:replies"]
64
+ assert agent.version.changelog == "Initial release"
65
+
66
+ def test_no_active_run_on_init(self):
67
+ agent = make_agent()
68
+ assert agent.current_run is None
69
+
70
+ def test_calls_register_agent_with_correct_payload(self):
71
+ with patch("ezop.client.EzopClient.register_agent", return_value=AGENT_RESP) as mock_register, \
72
+ patch("ezop.client.EzopClient.create_version", return_value=VERSION_RESP):
73
+ Agent.init(name="support-bot", owner="growth-team", version="v0.3", runtime="langchain",
74
+ description="Handles support tickets", default_permissions=["read:tickets"])
75
+ mock_register.assert_called_once_with({
76
+ "name": "support-bot",
77
+ "owner": "growth-team",
78
+ "runtime": "langchain",
79
+ "description": "Handles support tickets",
80
+ "default_permissions": ["read:tickets"],
81
+ })
82
+
83
+ def test_calls_create_version_with_correct_payload(self):
84
+ with patch("ezop.client.EzopClient.register_agent", return_value=AGENT_RESP), \
85
+ patch("ezop.client.EzopClient.create_version", return_value=VERSION_RESP) as mock_version:
86
+ Agent.init(name="support-bot", owner="growth-team", version="v0.3", runtime="langchain",
87
+ permissions=["read:tickets"], changelog="Initial release")
88
+ mock_version.assert_called_once_with("agent-uuid-123", {
89
+ "version": "v0.3",
90
+ "permissions": ["read:tickets"],
91
+ "changelog": "Initial release",
92
+ })
93
+
94
+ def test_optional_fields_default_to_empty(self):
95
+ with patch("ezop.client.EzopClient.register_agent", return_value={**AGENT_RESP, "data": {**AGENT_RESP["data"], "default_permissions": None}}), \
96
+ patch("ezop.client.EzopClient.create_version", return_value={**VERSION_RESP, "data": {**VERSION_RESP["data"], "permissions": None}}):
97
+ agent = Agent.init(name="support-bot", owner="growth-team", version="v0.3", runtime="langchain")
98
+ assert agent.model.default_permissions == []
99
+ assert agent.version.permissions == []
100
+
101
+ def test_raises_on_api_error(self):
102
+ with patch("ezop.client.EzopClient.register_agent", side_effect=Exception("Ezop API error: 401")):
103
+ with pytest.raises(Exception, match="Ezop API error"):
104
+ Agent.init(name="support-bot", owner="growth-team", version="v0.3", runtime="langchain")
105
+
106
+
107
+ class TestAgentRuns:
108
+ def test_start_run_returns_agent_run(self):
109
+ agent = make_agent()
110
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
111
+ run = agent.start_run()
112
+ assert isinstance(run, AgentRun)
113
+ assert run.id == "run-uuid-789"
114
+ assert run.status == "running"
115
+ assert run.agent_id == "agent-uuid-123"
116
+ assert run.version_id == "version-uuid-456"
117
+
118
+ def test_start_run_sets_current_run(self):
119
+ agent = make_agent()
120
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
121
+ agent.start_run()
122
+ assert agent.current_run is not None
123
+ assert agent.current_run.id == "run-uuid-789"
124
+
125
+ def test_end_run_updates_status(self):
126
+ agent = make_agent()
127
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
128
+ agent.start_run()
129
+ end_resp = {"success": True, "error": None, "data": {"status": "completed", "total_tokens": 500, "total_cost": 0.01, "metadata": None}}
130
+ with patch("ezop.client.EzopClient.end_run", return_value=end_resp):
131
+ run = agent.end_run(status="completed", total_tokens=500, total_cost=0.01)
132
+ assert run.status == "completed"
133
+ assert run.total_tokens == 500
134
+ assert run.total_cost == 0.01
135
+
136
+ def test_end_run_failed_status(self):
137
+ agent = make_agent()
138
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
139
+ agent.start_run()
140
+ end_resp = {"success": True, "error": None, "data": {"status": "failed", "total_tokens": None, "total_cost": None, "metadata": {"error": "timeout"}, "message": "Rate limit exceeded"}}
141
+ with patch("ezop.client.EzopClient.end_run", return_value=end_resp):
142
+ run = agent.end_run(status="failed", metadata={"error": "timeout"}, message="Rate limit exceeded")
143
+ assert run.status == "failed"
144
+ assert run.metadata == {"error": "timeout"}
145
+ assert run.message == "Rate limit exceeded"
146
+
147
+ def test_end_run_calls_client_with_correct_args(self):
148
+ agent = make_agent()
149
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
150
+ agent.start_run()
151
+ end_resp = {"success": True, "error": None, "data": {"status": "completed", "total_tokens": 100, "total_cost": 0.005, "metadata": {"k": "v"}, "message": None}}
152
+ with patch("ezop.client.EzopClient.end_run", return_value=end_resp) as mock_end:
153
+ agent.end_run(status="completed", total_tokens=100, total_cost=0.005, metadata={"k": "v"})
154
+ mock_end.assert_called_once_with(
155
+ "run-uuid-789",
156
+ status="completed",
157
+ total_tokens=100,
158
+ total_cost=0.005,
159
+ metadata={"k": "v"},
160
+ message=None,
161
+ )
162
+
163
+ def test_end_run_without_start_raises(self):
164
+ agent = make_agent()
165
+ with pytest.raises(RuntimeError, match="No active run"):
166
+ agent.end_run()
@@ -0,0 +1,495 @@
1
+ """
2
+ Integration tests for the Ezop SDK.
3
+
4
+ These tests use the `responses` library to intercept HTTP calls at the network
5
+ level, exercising the full stack (Agent → EzopClient → requests → HTTP parsing)
6
+ without hitting a real server.
7
+
8
+ To run against the real Ezop API, set EZOP_API_KEY and EZOP_API_URL in your
9
+ environment and run:
10
+
11
+ pytest tests/test_integration.py -m live -v
12
+
13
+ Live tests are skipped by default unless EZOP_API_KEY is present.
14
+ """
15
+
16
+ import pytest
17
+ import responses as responses_lib
18
+ from responses import matchers
19
+ from ezop import Agent
20
+ from ezop.config import Config
21
+
22
+
23
+ BASE_URL = Config.EZOP_API_URL
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _AGENT_DATA = {
31
+ "id": "integ-agent-001",
32
+ "name": "integ-bot",
33
+ "owner": "integ-team",
34
+ "runtime": "langchain",
35
+ "description": "Integration test agent",
36
+ "default_permissions": ["read:logs"],
37
+ }
38
+
39
+ _VERSION_DATA = {
40
+ "id": "integ-version-001",
41
+ "agent_id": "integ-agent-001",
42
+ "version": "v1.0",
43
+ "permissions": ["read:logs", "write:reports"],
44
+ "changelog": "Integration test version",
45
+ }
46
+
47
+ _RUN_DATA = {
48
+ "id": "integ-run-001",
49
+ "status": "running",
50
+ }
51
+
52
+ _END_RUN_DATA = {
53
+ "id": "integ-run-001",
54
+ "status": "completed",
55
+ "total_tokens": 1200,
56
+ "total_cost": 0.024,
57
+ "metadata": None,
58
+ }
59
+
60
+
61
+ def _wrap(data):
62
+ return {"success": True, "data": data, "error": None}
63
+
64
+
65
+ AGENT_PAYLOAD = _wrap(_AGENT_DATA)
66
+ VERSION_PAYLOAD = _wrap(_VERSION_DATA)
67
+ RUN_PAYLOAD = _wrap(_RUN_DATA)
68
+ END_RUN_PAYLOAD = _wrap(_END_RUN_DATA)
69
+
70
+
71
+ def register_mock_endpoints():
72
+ """Register all mock HTTP endpoints for a complete agent lifecycle."""
73
+ responses_lib.add(
74
+ responses_lib.POST,
75
+ f"{BASE_URL}/agents/register",
76
+ json=AGENT_PAYLOAD,
77
+ status=201,
78
+ )
79
+ responses_lib.add(
80
+ responses_lib.POST,
81
+ f"{BASE_URL}/agents/integ-agent-001/versions",
82
+ json=VERSION_PAYLOAD,
83
+ status=201,
84
+ )
85
+ responses_lib.add(
86
+ responses_lib.POST,
87
+ f"{BASE_URL}/agents/integ-agent-001/runs",
88
+ json=RUN_PAYLOAD,
89
+ status=201,
90
+ )
91
+ responses_lib.add(
92
+ responses_lib.PATCH,
93
+ f"{BASE_URL}/runs/integ-run-001",
94
+ json=END_RUN_PAYLOAD,
95
+ status=200,
96
+ )
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # HTTP-level integration tests (no real network — responses intercepts)
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class TestAgentInitHTTP:
105
+ """Verify Agent.init() sends correct HTTP requests and parses responses."""
106
+
107
+ @responses_lib.activate
108
+ def test_register_agent_url_and_method(self):
109
+ responses_lib.add(
110
+ responses_lib.POST,
111
+ f"{BASE_URL}/agents/register",
112
+ json=AGENT_PAYLOAD,
113
+ status=201,
114
+ )
115
+ responses_lib.add(
116
+ responses_lib.POST,
117
+ f"{BASE_URL}/agents/integ-agent-001/versions",
118
+ json=VERSION_PAYLOAD,
119
+ status=201,
120
+ )
121
+ Agent.init(
122
+ name="integ-bot", owner="integ-team", version="v1.0", runtime="langchain"
123
+ )
124
+ assert responses_lib.calls[0].request.method == "POST"
125
+ assert responses_lib.calls[0].request.url == f"{BASE_URL}/agents/register"
126
+
127
+ @responses_lib.activate
128
+ def test_register_agent_request_body(self):
129
+ import json
130
+
131
+ responses_lib.add(
132
+ responses_lib.POST,
133
+ f"{BASE_URL}/agents/register",
134
+ json=AGENT_PAYLOAD,
135
+ status=201,
136
+ )
137
+ responses_lib.add(
138
+ responses_lib.POST,
139
+ f"{BASE_URL}/agents/integ-agent-001/versions",
140
+ json=VERSION_PAYLOAD,
141
+ status=201,
142
+ )
143
+ Agent.init(
144
+ name="integ-bot",
145
+ owner="integ-team",
146
+ version="v1.0",
147
+ runtime="langchain",
148
+ description="Integration test agent",
149
+ default_permissions=["read:logs"],
150
+ )
151
+ body = json.loads(responses_lib.calls[0].request.body)
152
+ assert body["name"] == "integ-bot"
153
+ assert body["owner"] == "integ-team"
154
+ assert body["runtime"] == "langchain"
155
+ assert body["description"] == "Integration test agent"
156
+ assert body["default_permissions"] == ["read:logs"]
157
+
158
+ @responses_lib.activate
159
+ def test_register_agent_auth_header(self):
160
+ responses_lib.add(
161
+ responses_lib.POST,
162
+ f"{BASE_URL}/agents/register",
163
+ json=AGENT_PAYLOAD,
164
+ status=201,
165
+ )
166
+ responses_lib.add(
167
+ responses_lib.POST,
168
+ f"{BASE_URL}/agents/integ-agent-001/versions",
169
+ json=VERSION_PAYLOAD,
170
+ status=201,
171
+ )
172
+ Agent.init(
173
+ name="integ-bot", owner="integ-team", version="v1.0", runtime="langchain"
174
+ )
175
+ auth = responses_lib.calls[0].request.headers.get("Authorization", "")
176
+ assert auth.startswith("Bearer ")
177
+
178
+ @responses_lib.activate
179
+ def test_create_version_url_uses_agent_id(self):
180
+ responses_lib.add(
181
+ responses_lib.POST,
182
+ f"{BASE_URL}/agents/register",
183
+ json=AGENT_PAYLOAD,
184
+ status=201,
185
+ )
186
+ responses_lib.add(
187
+ responses_lib.POST,
188
+ f"{BASE_URL}/agents/integ-agent-001/versions",
189
+ json=VERSION_PAYLOAD,
190
+ status=201,
191
+ )
192
+ Agent.init(
193
+ name="integ-bot", owner="integ-team", version="v1.0", runtime="langchain"
194
+ )
195
+ assert (
196
+ responses_lib.calls[1].request.url
197
+ == f"{BASE_URL}/agents/integ-agent-001/versions"
198
+ )
199
+
200
+ @responses_lib.activate
201
+ def test_create_version_request_body(self):
202
+ import json
203
+
204
+ responses_lib.add(
205
+ responses_lib.POST,
206
+ f"{BASE_URL}/agents/register",
207
+ json=AGENT_PAYLOAD,
208
+ status=201,
209
+ )
210
+ responses_lib.add(
211
+ responses_lib.POST,
212
+ f"{BASE_URL}/agents/integ-agent-001/versions",
213
+ json=VERSION_PAYLOAD,
214
+ status=201,
215
+ )
216
+ Agent.init(
217
+ name="integ-bot",
218
+ owner="integ-team",
219
+ version="v1.0",
220
+ runtime="langchain",
221
+ permissions=["read:logs", "write:reports"],
222
+ changelog="Integration test version",
223
+ )
224
+ body = json.loads(responses_lib.calls[1].request.body)
225
+ assert body["version"] == "v1.0"
226
+ assert body["permissions"] == ["read:logs", "write:reports"]
227
+ assert body["changelog"] == "Integration test version"
228
+
229
+ @responses_lib.activate
230
+ def test_agent_built_correctly_from_responses(self):
231
+ responses_lib.add(
232
+ responses_lib.POST,
233
+ f"{BASE_URL}/agents/register",
234
+ json=AGENT_PAYLOAD,
235
+ status=201,
236
+ )
237
+ responses_lib.add(
238
+ responses_lib.POST,
239
+ f"{BASE_URL}/agents/integ-agent-001/versions",
240
+ json=VERSION_PAYLOAD,
241
+ status=201,
242
+ )
243
+ agent = Agent.init(
244
+ name="integ-bot",
245
+ owner="integ-team",
246
+ version="v1.0",
247
+ runtime="lanchain",
248
+ description="Integration test agent",
249
+ default_permissions=["read:logs"],
250
+ permissions=["read:logs", "write:reports"],
251
+ changelog="Integration test version",
252
+ )
253
+ assert agent.model.id == "integ-agent-001"
254
+ assert agent.model.name == "integ-bot"
255
+ assert agent.model.runtime == "langchain"
256
+ assert agent.version.id == "integ-version-001"
257
+ assert agent.version.version == "v1.0"
258
+ assert agent.version.permissions == ["read:logs", "write:reports"]
259
+
260
+ @responses_lib.activate
261
+ def test_api_400_raises_exception(self):
262
+ responses_lib.add(
263
+ responses_lib.POST,
264
+ f"{BASE_URL}/agents/register",
265
+ json={"error": "invalid payload"},
266
+ status=400,
267
+ )
268
+ with pytest.raises(Exception, match="Ezop API error"):
269
+ Agent.init(
270
+ name="integ-bot",
271
+ owner="integ-team",
272
+ version="v1.0",
273
+ runtime="langchain",
274
+ )
275
+
276
+ @responses_lib.activate
277
+ def test_api_401_raises_exception(self):
278
+ responses_lib.add(
279
+ responses_lib.POST,
280
+ f"{BASE_URL}/agents/register",
281
+ json={"error": "unauthorized"},
282
+ status=401,
283
+ )
284
+ with pytest.raises(Exception, match="Ezop API error"):
285
+ Agent.init(
286
+ name="integ-bot",
287
+ owner="integ-team",
288
+ version="v1.0",
289
+ runtime="langchain",
290
+ )
291
+
292
+ @responses_lib.activate
293
+ def test_api_500_raises_exception(self):
294
+ responses_lib.add(
295
+ responses_lib.POST,
296
+ f"{BASE_URL}/agents/register",
297
+ json={"error": "server error"},
298
+ status=500,
299
+ )
300
+ with pytest.raises(Exception, match="Ezop API error"):
301
+ Agent.init(
302
+ name="integ-bot",
303
+ owner="integ-team",
304
+ version="v1.0",
305
+ runtime="langchain",
306
+ )
307
+
308
+
309
+ class TestRunLifecycleHTTP:
310
+ """Verify start_run / end_run send correct HTTP requests."""
311
+
312
+ def _make_agent(self):
313
+ responses_lib.add(
314
+ responses_lib.POST,
315
+ f"{BASE_URL}/agents/register",
316
+ json=AGENT_PAYLOAD,
317
+ status=201,
318
+ )
319
+ responses_lib.add(
320
+ responses_lib.POST,
321
+ f"{BASE_URL}/agents/integ-agent-001/versions",
322
+ json=VERSION_PAYLOAD,
323
+ status=201,
324
+ )
325
+ return Agent.init(
326
+ name="integ-bot", owner="integ-team", version="v1.0", runtime="langchain"
327
+ )
328
+
329
+ @responses_lib.activate
330
+ def test_start_run_url_and_method(self):
331
+ agent = self._make_agent()
332
+ responses_lib.add(
333
+ responses_lib.POST,
334
+ f"{BASE_URL}/agents/integ-agent-001/runs",
335
+ json=RUN_PAYLOAD,
336
+ status=201,
337
+ )
338
+ agent.start_run()
339
+ last_call = responses_lib.calls[-1]
340
+ assert last_call.request.method == "POST"
341
+ assert last_call.request.url == f"{BASE_URL}/agents/integ-agent-001/runs"
342
+
343
+ @responses_lib.activate
344
+ def test_start_run_request_body_contains_version_id(self):
345
+ import json
346
+
347
+ agent = self._make_agent()
348
+ responses_lib.add(
349
+ responses_lib.POST,
350
+ f"{BASE_URL}/agents/integ-agent-001/runs",
351
+ json=RUN_PAYLOAD,
352
+ status=201,
353
+ )
354
+ agent.start_run()
355
+ body = json.loads(responses_lib.calls[-1].request.body)
356
+ assert body["version_id"] == "integ-version-001"
357
+
358
+ @responses_lib.activate
359
+ def test_start_run_returns_correct_run(self):
360
+ agent = self._make_agent()
361
+ responses_lib.add(
362
+ responses_lib.POST,
363
+ f"{BASE_URL}/agents/integ-agent-001/runs",
364
+ json=RUN_PAYLOAD,
365
+ status=201,
366
+ )
367
+ run = agent.start_run()
368
+ assert run.id == "integ-run-001"
369
+ assert run.status == "running"
370
+ assert run.agent_id == "integ-agent-001"
371
+ assert run.version_id == "integ-version-001"
372
+
373
+ @responses_lib.activate
374
+ def test_end_run_url_and_method(self):
375
+ agent = self._make_agent()
376
+ responses_lib.add(
377
+ responses_lib.POST,
378
+ f"{BASE_URL}/agents/integ-agent-001/runs",
379
+ json=RUN_PAYLOAD,
380
+ status=201,
381
+ )
382
+ responses_lib.add(
383
+ responses_lib.PATCH,
384
+ f"{BASE_URL}/runs/integ-run-001",
385
+ json=END_RUN_PAYLOAD,
386
+ status=200,
387
+ )
388
+ agent.start_run()
389
+ agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
390
+ last_call = responses_lib.calls[-1]
391
+ assert last_call.request.method == "PATCH"
392
+ assert last_call.request.url == f"{BASE_URL}/runs/integ-run-001"
393
+
394
+ @responses_lib.activate
395
+ def test_end_run_request_body(self):
396
+ import json
397
+
398
+ agent = self._make_agent()
399
+ responses_lib.add(
400
+ responses_lib.POST,
401
+ f"{BASE_URL}/agents/integ-agent-001/runs",
402
+ json=RUN_PAYLOAD,
403
+ status=201,
404
+ )
405
+ responses_lib.add(
406
+ responses_lib.PATCH,
407
+ f"{BASE_URL}/runs/integ-run-001",
408
+ json=END_RUN_PAYLOAD,
409
+ status=200,
410
+ )
411
+ agent.start_run()
412
+ agent.end_run(
413
+ status="completed",
414
+ total_tokens=1200,
415
+ total_cost=0.024,
416
+ metadata={"user_id": "u-42"},
417
+ )
418
+ body = json.loads(responses_lib.calls[-1].request.body)
419
+ assert body["status"] == "completed"
420
+ assert body["total_tokens"] == 1200
421
+ assert body["total_cost"] == 0.024
422
+ assert body["metadata"] == {"user_id": "u-42"}
423
+
424
+ @responses_lib.activate
425
+ def test_end_run_body_omits_none_fields(self):
426
+ import json
427
+
428
+ agent = self._make_agent()
429
+ responses_lib.add(
430
+ responses_lib.POST,
431
+ f"{BASE_URL}/agents/integ-agent-001/runs",
432
+ json=RUN_PAYLOAD,
433
+ status=201,
434
+ )
435
+ responses_lib.add(
436
+ responses_lib.PATCH,
437
+ f"{BASE_URL}/runs/integ-run-001",
438
+ json=_wrap(
439
+ {
440
+ "id": "integ-run-001",
441
+ "status": "failed",
442
+ "total_tokens": None,
443
+ "total_cost": None,
444
+ "metadata": None,
445
+ }
446
+ ),
447
+ status=200,
448
+ )
449
+ agent.start_run()
450
+ agent.end_run(status="failed")
451
+ body = json.loads(responses_lib.calls[-1].request.body)
452
+ assert "total_tokens" not in body
453
+ assert "total_cost" not in body
454
+ assert "metadata" not in body
455
+
456
+ @responses_lib.activate
457
+ def test_end_run_updates_agent_state(self):
458
+ agent = self._make_agent()
459
+ responses_lib.add(
460
+ responses_lib.POST,
461
+ f"{BASE_URL}/agents/integ-agent-001/runs",
462
+ json=RUN_PAYLOAD,
463
+ status=201,
464
+ )
465
+ responses_lib.add(
466
+ responses_lib.PATCH,
467
+ f"{BASE_URL}/runs/integ-run-001",
468
+ json=END_RUN_PAYLOAD,
469
+ status=200,
470
+ )
471
+ agent.start_run()
472
+ run = agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
473
+ assert run.status == "completed"
474
+ assert run.total_tokens == 1200
475
+ assert run.total_cost == 0.024
476
+
477
+ @responses_lib.activate
478
+ def test_full_lifecycle(self):
479
+ """Agent init → start_run → end_run, exactly 4 HTTP calls."""
480
+ register_mock_endpoints()
481
+ agent = Agent.init(
482
+ name="integ-bot",
483
+ owner="integ-team",
484
+ version="v1.0",
485
+ runtime="langchain",
486
+ description="Integration test agent",
487
+ default_permissions=["read:logs"],
488
+ permissions=["read:logs", "write:reports"],
489
+ changelog="Integration test version",
490
+ )
491
+ run = agent.start_run()
492
+ assert run.status == "running"
493
+ result = agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
494
+ assert result.status == "completed"
495
+ assert len(responses_lib.calls) == 4