ezop-sdk 0.1.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.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezop-sdk
3
+ Version: 0.1.0
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
+
13
+ # Ezop SDK
14
+
15
+ Python SDK for Ezop — The story of every AI agent.
16
+
17
+ ## Setup
18
+
19
+ ```bash
20
+ export EZOP_API_KEY=your-ezop-api-key-here
21
+ export EZOP_API_URL=https://api.ezop.ai
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install ezop
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### Initialize an agent
33
+
34
+ Register your agent and its current version with the Ezop platform at startup.
35
+
36
+ ```python
37
+ from ezop import Agent
38
+
39
+ agent = Agent.init(
40
+ name="customer-support-bot",
41
+ owner="growth-team",
42
+ version="v0.3",
43
+ runtime="langchain",
44
+ description="Handles tier-1 customer support tickets",
45
+ default_permissions=["read:tickets", "write:replies"],
46
+ )
47
+ ```
48
+
49
+ `Agent.init()` makes two API calls: one to create (or retrieve) the agent record, and one to register the version.
50
+
51
+ | Parameter | Required | Description |
52
+ |---|---|---|
53
+ | `name` | yes | Agent name |
54
+ | `owner` | yes | Team or user that owns the agent |
55
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
56
+ | `runtime` | no | Runtime framework (e.g. `"langchain"`, `"claude"`) |
57
+ | `description` | no | Human-readable description of the agent |
58
+ | `default_permissions` | no | Default permission scopes for the agent |
59
+ | `permissions` | no | Permission scopes for this specific version |
60
+ | `changelog` | no | Release notes for this version |
61
+
62
+ ### Track runs
63
+
64
+ Wrap each invocation with `start_run()` and `end_run()` to record it on the platform.
65
+
66
+ ```python
67
+ agent.start_run()
68
+
69
+ try:
70
+ result = agent.run(user_input)
71
+ agent.end_run(
72
+ status="completed",
73
+ total_tokens=result.usage.total_tokens,
74
+ total_cost=result.cost,
75
+ metadata={"user_id": user_id},
76
+ )
77
+ except Exception as e:
78
+ agent.end_run(status="failed", metadata={"error": str(e)})
79
+ raise
80
+ ```
81
+
82
+ `end_run()` parameters:
83
+
84
+ | Parameter | Default | Description |
85
+ |---|---|---|
86
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
87
+ | `total_tokens` | `None` | Total tokens consumed |
88
+ | `total_cost` | `None` | Total cost in USD |
89
+ | `metadata` | `None` | Arbitrary JSON metadata |
@@ -0,0 +1,77 @@
1
+ # Ezop SDK
2
+
3
+ Python SDK for Ezop — The story of every AI agent.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ export EZOP_API_KEY=your-ezop-api-key-here
9
+ export EZOP_API_URL=https://api.ezop.ai
10
+ ```
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install ezop
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Initialize an agent
21
+
22
+ Register your agent and its current version with the Ezop platform at startup.
23
+
24
+ ```python
25
+ from ezop import Agent
26
+
27
+ agent = Agent.init(
28
+ name="customer-support-bot",
29
+ owner="growth-team",
30
+ version="v0.3",
31
+ runtime="langchain",
32
+ description="Handles tier-1 customer support tickets",
33
+ default_permissions=["read:tickets", "write:replies"],
34
+ )
35
+ ```
36
+
37
+ `Agent.init()` makes two API calls: one to create (or retrieve) the agent record, and one to register the version.
38
+
39
+ | Parameter | Required | Description |
40
+ |---|---|---|
41
+ | `name` | yes | Agent name |
42
+ | `owner` | yes | Team or user that owns the agent |
43
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
44
+ | `runtime` | no | Runtime framework (e.g. `"langchain"`, `"claude"`) |
45
+ | `description` | no | Human-readable description of the agent |
46
+ | `default_permissions` | no | Default permission scopes for the agent |
47
+ | `permissions` | no | Permission scopes for this specific version |
48
+ | `changelog` | no | Release notes for this version |
49
+
50
+ ### Track runs
51
+
52
+ Wrap each invocation with `start_run()` and `end_run()` to record it on the platform.
53
+
54
+ ```python
55
+ agent.start_run()
56
+
57
+ try:
58
+ result = agent.run(user_input)
59
+ agent.end_run(
60
+ status="completed",
61
+ total_tokens=result.usage.total_tokens,
62
+ total_cost=result.cost,
63
+ metadata={"user_id": user_id},
64
+ )
65
+ except Exception as e:
66
+ agent.end_run(status="failed", metadata={"error": str(e)})
67
+ raise
68
+ ```
69
+
70
+ `end_run()` parameters:
71
+
72
+ | Parameter | Default | Description |
73
+ |---|---|---|
74
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
75
+ | `total_tokens` | `None` | Total tokens consumed |
76
+ | `total_cost` | `None` | Total cost in USD |
77
+ | `metadata` | `None` | Arbitrary JSON metadata |
@@ -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,122 @@
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("Initializing agent name=%s owner=%s version=%s runtime=%s", name, owner, version, runtime)
35
+ client = EzopClient()
36
+
37
+ agent_data = client.register_agent(
38
+ {
39
+ "name": name,
40
+ "owner": owner,
41
+ "runtime": runtime,
42
+ "description": description,
43
+ "default_permissions": default_permissions,
44
+ }
45
+ )["data"]
46
+
47
+ model = AgentModel(
48
+ id=agent_data["id"],
49
+ name=agent_data["name"],
50
+ owner=agent_data["owner"],
51
+ runtime=agent_data.get("runtime"),
52
+ description=agent_data.get("description"),
53
+ default_permissions=agent_data.get("default_permissions") or [],
54
+ )
55
+ logger.debug("Agent model built id=%s", model.id)
56
+
57
+ version_data = client.create_version(
58
+ model.id,
59
+ {
60
+ "version": version,
61
+ "permissions": permissions,
62
+ "changelog": changelog,
63
+ },
64
+ )["data"]
65
+
66
+ agent_version = AgentVersion(
67
+ id=version_data["id"],
68
+ agent_id=model.id,
69
+ version=version_data["version"],
70
+ permissions=version_data.get("permissions") or [],
71
+ changelog=version_data.get("changelog"),
72
+ )
73
+ logger.debug("Agent version built id=%s", agent_version.id)
74
+
75
+ logger.info("Agent initialized id=%s version_id=%s", model.id, agent_version.id)
76
+ return cls(model, agent_version)
77
+
78
+ def start_run(self) -> AgentRun:
79
+ """Start a new run for this agent version."""
80
+ logger.info("Starting run for agent id=%s version_id=%s", self.model.id, self.version.id)
81
+ run_data = self.client.start_run(self.model.id, self.version.id)["data"]
82
+ self.current_run = AgentRun(
83
+ id=run_data["id"],
84
+ agent_id=self.model.id,
85
+ version_id=self.version.id,
86
+ status=run_data.get("status", "running"),
87
+ )
88
+ logger.debug("Run created id=%s status=%s", self.current_run.id, self.current_run.status)
89
+ return self.current_run
90
+
91
+ def end_run(
92
+ self,
93
+ status: str = "completed",
94
+ total_tokens: Optional[int] = None,
95
+ total_cost: Optional[float] = None,
96
+ metadata: Optional[dict] = None,
97
+ ) -> AgentRun:
98
+ """End the current run."""
99
+ if self.current_run is None:
100
+ logger.error("end_run called with no active run")
101
+ raise RuntimeError("No active run. Call start_run() first.")
102
+
103
+ logger.info("Ending run id=%s status=%s", self.current_run.id, status)
104
+ run_data = self.client.end_run(
105
+ self.current_run.id,
106
+ status=status,
107
+ total_tokens=total_tokens,
108
+ total_cost=total_cost,
109
+ metadata=metadata,
110
+ )["data"]
111
+ self.current_run.status = run_data.get("status", status)
112
+ self.current_run.total_tokens = run_data.get("total_tokens")
113
+ self.current_run.total_cost = run_data.get("total_cost")
114
+ self.current_run.metadata = run_data.get("metadata")
115
+ logger.debug(
116
+ "Run ended id=%s status=%s total_tokens=%s total_cost=%s",
117
+ self.current_run.id,
118
+ self.current_run.status,
119
+ self.current_run.total_tokens,
120
+ self.current_run.total_cost,
121
+ )
122
+ return self.current_run
@@ -0,0 +1,79 @@
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) -> dict:
69
+ logger.info("Ending run id=%s status=%s", run_id, status)
70
+ body = {"status": status}
71
+ if total_tokens is not None:
72
+ body["total_tokens"] = total_tokens
73
+ if total_cost is not None:
74
+ body["total_cost"] = total_cost
75
+ if metadata is not None:
76
+ body["metadata"] = metadata
77
+ result = self._patch(f"/runs/{run_id}", body)
78
+ logger.info("Run ended id=%s status=%s total_tokens=%s total_cost=%s", run_id, status, total_tokens, total_cost)
79
+ 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,35 @@
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
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezop-sdk
3
+ Version: 0.1.0
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
+
13
+ # Ezop SDK
14
+
15
+ Python SDK for Ezop — The story of every AI agent.
16
+
17
+ ## Setup
18
+
19
+ ```bash
20
+ export EZOP_API_KEY=your-ezop-api-key-here
21
+ export EZOP_API_URL=https://api.ezop.ai
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install ezop
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### Initialize an agent
33
+
34
+ Register your agent and its current version with the Ezop platform at startup.
35
+
36
+ ```python
37
+ from ezop import Agent
38
+
39
+ agent = Agent.init(
40
+ name="customer-support-bot",
41
+ owner="growth-team",
42
+ version="v0.3",
43
+ runtime="langchain",
44
+ description="Handles tier-1 customer support tickets",
45
+ default_permissions=["read:tickets", "write:replies"],
46
+ )
47
+ ```
48
+
49
+ `Agent.init()` makes two API calls: one to create (or retrieve) the agent record, and one to register the version.
50
+
51
+ | Parameter | Required | Description |
52
+ |---|---|---|
53
+ | `name` | yes | Agent name |
54
+ | `owner` | yes | Team or user that owns the agent |
55
+ | `version` | yes | Version string for this deployment (e.g. `"v0.3"`) |
56
+ | `runtime` | no | Runtime framework (e.g. `"langchain"`, `"claude"`) |
57
+ | `description` | no | Human-readable description of the agent |
58
+ | `default_permissions` | no | Default permission scopes for the agent |
59
+ | `permissions` | no | Permission scopes for this specific version |
60
+ | `changelog` | no | Release notes for this version |
61
+
62
+ ### Track runs
63
+
64
+ Wrap each invocation with `start_run()` and `end_run()` to record it on the platform.
65
+
66
+ ```python
67
+ agent.start_run()
68
+
69
+ try:
70
+ result = agent.run(user_input)
71
+ agent.end_run(
72
+ status="completed",
73
+ total_tokens=result.usage.total_tokens,
74
+ total_cost=result.cost,
75
+ metadata={"user_id": user_id},
76
+ )
77
+ except Exception as e:
78
+ agent.end_run(status="failed", metadata={"error": str(e)})
79
+ raise
80
+ ```
81
+
82
+ `end_run()` parameters:
83
+
84
+ | Parameter | Default | Description |
85
+ |---|---|---|
86
+ | `status` | `"completed"` | Final run status (`"completed"`, `"failed"`, etc.) |
87
+ | `total_tokens` | `None` | Total tokens consumed |
88
+ | `total_cost` | `None` | Total cost in USD |
89
+ | `metadata` | `None` | Arbitrary JSON metadata |
@@ -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_sdk.egg-info/PKG-INFO
9
+ ezop_sdk.egg-info/SOURCES.txt
10
+ ezop_sdk.egg-info/dependency_links.txt
11
+ ezop_sdk.egg-info/requires.txt
12
+ ezop_sdk.egg-info/top_level.txt
13
+ tests/test_agent.py
14
+ tests/test_integration.py
@@ -0,0 +1,5 @@
1
+ requests
2
+
3
+ [dev]
4
+ pytest
5
+ responses
@@ -0,0 +1 @@
1
+ ezop
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "ezop-sdk"
3
+ version = "0.1.0"
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
+ ]
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=42", "wheel"]
20
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,164 @@
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"}}}
141
+ with patch("ezop.client.EzopClient.end_run", return_value=end_resp):
142
+ run = agent.end_run(status="failed", metadata={"error": "timeout"})
143
+ assert run.status == "failed"
144
+ assert run.metadata == {"error": "timeout"}
145
+
146
+ def test_end_run_calls_client_with_correct_args(self):
147
+ agent = make_agent()
148
+ with patch("ezop.client.EzopClient.start_run", return_value=RUN_RESP):
149
+ agent.start_run()
150
+ end_resp = {"success": True, "error": None, "data": {"status": "completed", "total_tokens": 100, "total_cost": 0.005, "metadata": {"k": "v"}}}
151
+ with patch("ezop.client.EzopClient.end_run", return_value=end_resp) as mock_end:
152
+ agent.end_run(status="completed", total_tokens=100, total_cost=0.005, metadata={"k": "v"})
153
+ mock_end.assert_called_once_with(
154
+ "run-uuid-789",
155
+ status="completed",
156
+ total_tokens=100,
157
+ total_cost=0.005,
158
+ metadata={"k": "v"},
159
+ )
160
+
161
+ def test_end_run_without_start_raises(self):
162
+ agent = make_agent()
163
+ with pytest.raises(RuntimeError, match="No active run"):
164
+ agent.end_run()
@@ -0,0 +1,352 @@
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": "claude",
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
+ def _wrap(data):
61
+ return {"success": True, "data": data, "error": None}
62
+
63
+ AGENT_PAYLOAD = _wrap(_AGENT_DATA)
64
+ VERSION_PAYLOAD = _wrap(_VERSION_DATA)
65
+ RUN_PAYLOAD = _wrap(_RUN_DATA)
66
+ END_RUN_PAYLOAD = _wrap(_END_RUN_DATA)
67
+
68
+
69
+ def register_mock_endpoints():
70
+ """Register all mock HTTP endpoints for a complete agent lifecycle."""
71
+ responses_lib.add(
72
+ responses_lib.POST,
73
+ f"{BASE_URL}/agents/register",
74
+ json=AGENT_PAYLOAD,
75
+ status=201,
76
+ )
77
+ responses_lib.add(
78
+ responses_lib.POST,
79
+ f"{BASE_URL}/agents/integ-agent-001/versions",
80
+ json=VERSION_PAYLOAD,
81
+ status=201,
82
+ )
83
+ responses_lib.add(
84
+ responses_lib.POST,
85
+ f"{BASE_URL}/agents/integ-agent-001/runs",
86
+ json=RUN_PAYLOAD,
87
+ status=201,
88
+ )
89
+ responses_lib.add(
90
+ responses_lib.PATCH,
91
+ f"{BASE_URL}/runs/integ-run-001",
92
+ json=END_RUN_PAYLOAD,
93
+ status=200,
94
+ )
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # HTTP-level integration tests (no real network — responses intercepts)
99
+ # ---------------------------------------------------------------------------
100
+
101
+ class TestAgentInitHTTP:
102
+ """Verify Agent.init() sends correct HTTP requests and parses responses."""
103
+
104
+ @responses_lib.activate
105
+ def test_register_agent_url_and_method(self):
106
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
107
+ json=AGENT_PAYLOAD, status=201)
108
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
109
+ json=VERSION_PAYLOAD, status=201)
110
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
111
+ assert responses_lib.calls[0].request.method == "POST"
112
+ assert responses_lib.calls[0].request.url == f"{BASE_URL}/agents/register"
113
+
114
+ @responses_lib.activate
115
+ def test_register_agent_request_body(self):
116
+ import json
117
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
118
+ json=AGENT_PAYLOAD, status=201)
119
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
120
+ json=VERSION_PAYLOAD, status=201)
121
+ Agent.init(
122
+ name="integ-bot",
123
+ owner="integ-team",
124
+ version="v1.0",
125
+ runtime="claude",
126
+ description="Integration test agent",
127
+ default_permissions=["read:logs"],
128
+ )
129
+ body = json.loads(responses_lib.calls[0].request.body)
130
+ assert body["name"] == "integ-bot"
131
+ assert body["owner"] == "integ-team"
132
+ assert body["runtime"] == "claude"
133
+ assert body["description"] == "Integration test agent"
134
+ assert body["default_permissions"] == ["read:logs"]
135
+
136
+ @responses_lib.activate
137
+ def test_register_agent_auth_header(self):
138
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
139
+ json=AGENT_PAYLOAD, status=201)
140
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
141
+ json=VERSION_PAYLOAD, status=201)
142
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
143
+ auth = responses_lib.calls[0].request.headers.get("Authorization", "")
144
+ assert auth.startswith("Bearer ")
145
+
146
+ @responses_lib.activate
147
+ def test_create_version_url_uses_agent_id(self):
148
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
149
+ json=AGENT_PAYLOAD, status=201)
150
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
151
+ json=VERSION_PAYLOAD, status=201)
152
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
153
+ assert responses_lib.calls[1].request.url == f"{BASE_URL}/agents/integ-agent-001/versions"
154
+
155
+ @responses_lib.activate
156
+ def test_create_version_request_body(self):
157
+ import json
158
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
159
+ json=AGENT_PAYLOAD, status=201)
160
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
161
+ json=VERSION_PAYLOAD, status=201)
162
+ Agent.init(
163
+ name="integ-bot",
164
+ owner="integ-team",
165
+ version="v1.0",
166
+ runtime="claude",
167
+ permissions=["read:logs", "write:reports"],
168
+ changelog="Integration test version",
169
+ )
170
+ body = json.loads(responses_lib.calls[1].request.body)
171
+ assert body["version"] == "v1.0"
172
+ assert body["permissions"] == ["read:logs", "write:reports"]
173
+ assert body["changelog"] == "Integration test version"
174
+
175
+ @responses_lib.activate
176
+ def test_agent_built_correctly_from_responses(self):
177
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
178
+ json=AGENT_PAYLOAD, status=201)
179
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
180
+ json=VERSION_PAYLOAD, status=201)
181
+ agent = Agent.init(
182
+ name="integ-bot",
183
+ owner="integ-team",
184
+ version="v1.0",
185
+ runtime="claude",
186
+ description="Integration test agent",
187
+ default_permissions=["read:logs"],
188
+ permissions=["read:logs", "write:reports"],
189
+ changelog="Integration test version",
190
+ )
191
+ assert agent.model.id == "integ-agent-001"
192
+ assert agent.model.name == "integ-bot"
193
+ assert agent.model.runtime == "claude"
194
+ assert agent.version.id == "integ-version-001"
195
+ assert agent.version.version == "v1.0"
196
+ assert agent.version.permissions == ["read:logs", "write:reports"]
197
+
198
+ @responses_lib.activate
199
+ def test_api_400_raises_exception(self):
200
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
201
+ json={"error": "invalid payload"}, status=400)
202
+ with pytest.raises(Exception, match="Ezop API error"):
203
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
204
+
205
+ @responses_lib.activate
206
+ def test_api_401_raises_exception(self):
207
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
208
+ json={"error": "unauthorized"}, status=401)
209
+ with pytest.raises(Exception, match="Ezop API error"):
210
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
211
+
212
+ @responses_lib.activate
213
+ def test_api_500_raises_exception(self):
214
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
215
+ json={"error": "server error"}, status=500)
216
+ with pytest.raises(Exception, match="Ezop API error"):
217
+ Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
218
+
219
+
220
+ class TestRunLifecycleHTTP:
221
+ """Verify start_run / end_run send correct HTTP requests."""
222
+
223
+ def _make_agent(self):
224
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/register",
225
+ json=AGENT_PAYLOAD, status=201)
226
+ responses_lib.add(responses_lib.POST, f"{BASE_URL}/agents/integ-agent-001/versions",
227
+ json=VERSION_PAYLOAD, status=201)
228
+ return Agent.init(name="integ-bot", owner="integ-team", version="v1.0", runtime="claude")
229
+
230
+ @responses_lib.activate
231
+ def test_start_run_url_and_method(self):
232
+ agent = self._make_agent()
233
+ responses_lib.add(responses_lib.POST,
234
+ f"{BASE_URL}/agents/integ-agent-001/runs",
235
+ json=RUN_PAYLOAD, status=201)
236
+ agent.start_run()
237
+ last_call = responses_lib.calls[-1]
238
+ assert last_call.request.method == "POST"
239
+ assert last_call.request.url == f"{BASE_URL}/agents/integ-agent-001/runs"
240
+
241
+ @responses_lib.activate
242
+ def test_start_run_request_body_contains_version_id(self):
243
+ import json
244
+ agent = self._make_agent()
245
+ responses_lib.add(responses_lib.POST,
246
+ f"{BASE_URL}/agents/integ-agent-001/runs",
247
+ json=RUN_PAYLOAD, status=201)
248
+ agent.start_run()
249
+ body = json.loads(responses_lib.calls[-1].request.body)
250
+ assert body["version_id"] == "integ-version-001"
251
+
252
+ @responses_lib.activate
253
+ def test_start_run_returns_correct_run(self):
254
+ agent = self._make_agent()
255
+ responses_lib.add(responses_lib.POST,
256
+ f"{BASE_URL}/agents/integ-agent-001/runs",
257
+ json=RUN_PAYLOAD, status=201)
258
+ run = agent.start_run()
259
+ assert run.id == "integ-run-001"
260
+ assert run.status == "running"
261
+ assert run.agent_id == "integ-agent-001"
262
+ assert run.version_id == "integ-version-001"
263
+
264
+ @responses_lib.activate
265
+ def test_end_run_url_and_method(self):
266
+ agent = self._make_agent()
267
+ responses_lib.add(responses_lib.POST,
268
+ f"{BASE_URL}/agents/integ-agent-001/runs",
269
+ json=RUN_PAYLOAD, status=201)
270
+ responses_lib.add(responses_lib.PATCH,
271
+ f"{BASE_URL}/runs/integ-run-001",
272
+ json=END_RUN_PAYLOAD, status=200)
273
+ agent.start_run()
274
+ agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
275
+ last_call = responses_lib.calls[-1]
276
+ assert last_call.request.method == "PATCH"
277
+ assert last_call.request.url == f"{BASE_URL}/runs/integ-run-001"
278
+
279
+ @responses_lib.activate
280
+ def test_end_run_request_body(self):
281
+ import json
282
+ agent = self._make_agent()
283
+ responses_lib.add(responses_lib.POST,
284
+ f"{BASE_URL}/agents/integ-agent-001/runs",
285
+ json=RUN_PAYLOAD, status=201)
286
+ responses_lib.add(responses_lib.PATCH,
287
+ f"{BASE_URL}/runs/integ-run-001",
288
+ json=END_RUN_PAYLOAD, status=200)
289
+ agent.start_run()
290
+ agent.end_run(
291
+ status="completed",
292
+ total_tokens=1200,
293
+ total_cost=0.024,
294
+ metadata={"user_id": "u-42"},
295
+ )
296
+ body = json.loads(responses_lib.calls[-1].request.body)
297
+ assert body["status"] == "completed"
298
+ assert body["total_tokens"] == 1200
299
+ assert body["total_cost"] == 0.024
300
+ assert body["metadata"] == {"user_id": "u-42"}
301
+
302
+ @responses_lib.activate
303
+ def test_end_run_body_omits_none_fields(self):
304
+ import json
305
+ agent = self._make_agent()
306
+ responses_lib.add(responses_lib.POST,
307
+ f"{BASE_URL}/agents/integ-agent-001/runs",
308
+ json=RUN_PAYLOAD, status=201)
309
+ responses_lib.add(responses_lib.PATCH,
310
+ f"{BASE_URL}/runs/integ-run-001",
311
+ json=_wrap({"id": "integ-run-001", "status": "failed", "total_tokens": None, "total_cost": None, "metadata": None}), status=200)
312
+ agent.start_run()
313
+ agent.end_run(status="failed")
314
+ body = json.loads(responses_lib.calls[-1].request.body)
315
+ assert "total_tokens" not in body
316
+ assert "total_cost" not in body
317
+ assert "metadata" not in body
318
+
319
+ @responses_lib.activate
320
+ def test_end_run_updates_agent_state(self):
321
+ agent = self._make_agent()
322
+ responses_lib.add(responses_lib.POST,
323
+ f"{BASE_URL}/agents/integ-agent-001/runs",
324
+ json=RUN_PAYLOAD, status=201)
325
+ responses_lib.add(responses_lib.PATCH,
326
+ f"{BASE_URL}/runs/integ-run-001",
327
+ json=END_RUN_PAYLOAD, status=200)
328
+ agent.start_run()
329
+ run = agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
330
+ assert run.status == "completed"
331
+ assert run.total_tokens == 1200
332
+ assert run.total_cost == 0.024
333
+
334
+ @responses_lib.activate
335
+ def test_full_lifecycle(self):
336
+ """Agent init → start_run → end_run, exactly 4 HTTP calls."""
337
+ register_mock_endpoints()
338
+ agent = Agent.init(
339
+ name="integ-bot",
340
+ owner="integ-team",
341
+ version="v1.0",
342
+ runtime="claude",
343
+ description="Integration test agent",
344
+ default_permissions=["read:logs"],
345
+ permissions=["read:logs", "write:reports"],
346
+ changelog="Integration test version",
347
+ )
348
+ run = agent.start_run()
349
+ assert run.status == "running"
350
+ result = agent.end_run(status="completed", total_tokens=1200, total_cost=0.024)
351
+ assert result.status == "completed"
352
+ assert len(responses_lib.calls) == 4