aegis-gateway-sdk 2.0.0a0__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.
- aegis_gateway_sdk-2.0.0a0/.gitignore +100 -0
- aegis_gateway_sdk-2.0.0a0/PKG-INFO +11 -0
- aegis_gateway_sdk-2.0.0a0/pyproject.toml +26 -0
- aegis_gateway_sdk-2.0.0a0/src/aegis_sdk/__init__.py +16 -0
- aegis_gateway_sdk-2.0.0a0/src/aegis_sdk/client.py +217 -0
- aegis_gateway_sdk-2.0.0a0/src/aegis_sdk/models.py +35 -0
- aegis_gateway_sdk-2.0.0a0/tests/test_async_client.py +130 -0
- aegis_gateway_sdk-2.0.0a0/tests/test_sync_client.py +195 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Node/npm local installs
|
|
2
|
+
node_modules/
|
|
3
|
+
npm-debug.log*
|
|
4
|
+
yarn-debug.log*
|
|
5
|
+
yarn-error.log*
|
|
6
|
+
.pnpm-store/
|
|
7
|
+
|
|
8
|
+
# Local environment files
|
|
9
|
+
.env
|
|
10
|
+
.env.*
|
|
11
|
+
!.env.example
|
|
12
|
+
|
|
13
|
+
## jj
|
|
14
|
+
.jj
|
|
15
|
+
|
|
16
|
+
# Codex local configuration
|
|
17
|
+
.codex/
|
|
18
|
+
AGENTS.md
|
|
19
|
+
|
|
20
|
+
# Claude Flow runtime data
|
|
21
|
+
.claude-flow/data/
|
|
22
|
+
.claude-flow/logs/
|
|
23
|
+
CLAUDE.md
|
|
24
|
+
.claude
|
|
25
|
+
.claude-flow
|
|
26
|
+
|
|
27
|
+
# MCP
|
|
28
|
+
.mcp.json
|
|
29
|
+
ruvector.db
|
|
30
|
+
|
|
31
|
+
# ruflo agents
|
|
32
|
+
.agents
|
|
33
|
+
.swarm
|
|
34
|
+
.agentdb
|
|
35
|
+
agentdb.db
|
|
36
|
+
|
|
37
|
+
# Environment variables
|
|
38
|
+
.env
|
|
39
|
+
.env.local
|
|
40
|
+
.env.*.local
|
|
41
|
+
|
|
42
|
+
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode
|
|
43
|
+
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode
|
|
44
|
+
|
|
45
|
+
### VisualStudioCode ###
|
|
46
|
+
.vscode/*
|
|
47
|
+
!.vscode/settings.json
|
|
48
|
+
!.vscode/tasks.json
|
|
49
|
+
!.vscode/launch.json
|
|
50
|
+
!.vscode/extensions.json
|
|
51
|
+
!.vscode/*.code-snippets
|
|
52
|
+
.vscode
|
|
53
|
+
|
|
54
|
+
# Local History for Visual Studio Code
|
|
55
|
+
.history/
|
|
56
|
+
|
|
57
|
+
# Built Visual Studio Code Extensions
|
|
58
|
+
*.vsix
|
|
59
|
+
|
|
60
|
+
### VisualStudioCode Patch ###
|
|
61
|
+
# Ignore all local history of files
|
|
62
|
+
.history
|
|
63
|
+
.ionide
|
|
64
|
+
|
|
65
|
+
### Misc files
|
|
66
|
+
gemma4
|
|
67
|
+
.postman/
|
|
68
|
+
.openclaude/
|
|
69
|
+
postman/
|
|
70
|
+
|
|
71
|
+
### OpenClaude
|
|
72
|
+
.openclaude-profile.json
|
|
73
|
+
|
|
74
|
+
# Python
|
|
75
|
+
__pycache__/
|
|
76
|
+
*.py[cod]
|
|
77
|
+
*.egg-info/
|
|
78
|
+
.pytest_cache/
|
|
79
|
+
dist/
|
|
80
|
+
build/
|
|
81
|
+
*.egg
|
|
82
|
+
.venv/
|
|
83
|
+
.uv-cache/
|
|
84
|
+
.tmp/
|
|
85
|
+
.ruff_cache/
|
|
86
|
+
|
|
87
|
+
# Docker
|
|
88
|
+
.docker/
|
|
89
|
+
|
|
90
|
+
### Build plans
|
|
91
|
+
BUILD_PLAN.md
|
|
92
|
+
CLAUDE.md
|
|
93
|
+
PROJECT_SPEC.md
|
|
94
|
+
AMEND_PLAN.md
|
|
95
|
+
SOURCE_OF_TRUTH.md
|
|
96
|
+
repo-seed/
|
|
97
|
+
.import_linter_cache/
|
|
98
|
+
site/
|
|
99
|
+
|
|
100
|
+
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aegis-gateway-sdk
|
|
3
|
+
Version: 2.0.0a0
|
|
4
|
+
Summary: Aegis v2 — first-party Python SDK (sync + async clients).
|
|
5
|
+
Project-URL: Homepage, https://github.com/e-choness/aegis
|
|
6
|
+
Project-URL: Repository, https://github.com/e-choness/aegis
|
|
7
|
+
Project-URL: Issues, https://github.com/e-choness/aegis/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aegis-gateway-sdk"
|
|
3
|
+
version = "2.0.0a0"
|
|
4
|
+
description = "Aegis v2 — first-party Python SDK (sync + async clients)."
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"pydantic>=2",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/e-choness/aegis"
|
|
15
|
+
Repository = "https://github.com/e-choness/aegis"
|
|
16
|
+
Issues = "https://github.com/e-choness/aegis/issues"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
# No workspace dependencies — the SDK is standalone.
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/aegis_sdk"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Aegis v2 Python SDK — sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from aegis_sdk.client import AegisClient, AsyncAegisClient
|
|
6
|
+
from aegis_sdk.models import ResumeResponse, RunCreateResponse, RunStatusResponse
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AegisClient",
|
|
10
|
+
"AsyncAegisClient",
|
|
11
|
+
"ResumeResponse",
|
|
12
|
+
"RunCreateResponse",
|
|
13
|
+
"RunStatusResponse",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "2.0.0a0"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Sync and async Aegis API clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from aegis_sdk.models import ResumeResponse, RunCreateResponse, RunStatusResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AegisClient:
|
|
15
|
+
"""Synchronous Aegis API client."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
base_url: str = "http://localhost:8767",
|
|
20
|
+
api_key: str = "",
|
|
21
|
+
*,
|
|
22
|
+
transport: httpx.BaseTransport | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
headers: dict[str, str] = {}
|
|
25
|
+
if api_key:
|
|
26
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
27
|
+
kwargs: dict[str, Any] = {"base_url": base_url, "headers": headers}
|
|
28
|
+
if transport is not None:
|
|
29
|
+
kwargs["transport"] = transport
|
|
30
|
+
self._client = httpx.Client(**kwargs)
|
|
31
|
+
|
|
32
|
+
def create_run(
|
|
33
|
+
self,
|
|
34
|
+
messages: list[dict[str, str]],
|
|
35
|
+
*,
|
|
36
|
+
route: str = "default",
|
|
37
|
+
background: bool = False,
|
|
38
|
+
approvers: list[str] | None = None,
|
|
39
|
+
) -> RunCreateResponse:
|
|
40
|
+
body = {
|
|
41
|
+
"messages": messages,
|
|
42
|
+
"route": route,
|
|
43
|
+
"background": background,
|
|
44
|
+
"approvers": approvers or [],
|
|
45
|
+
}
|
|
46
|
+
resp = self._client.post("/v1/runs", json=body)
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
return RunCreateResponse(**resp.json())
|
|
49
|
+
|
|
50
|
+
def get_run(self, run_id: str) -> RunStatusResponse:
|
|
51
|
+
resp = self._client.get(f"/v1/runs/{run_id}")
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return RunStatusResponse(**resp.json())
|
|
54
|
+
|
|
55
|
+
def resume_run(self, run_id: str, decision: str) -> ResumeResponse:
|
|
56
|
+
resp = self._client.post(f"/v1/runs/{run_id}/resume", json={"decision": decision})
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
return ResumeResponse(**resp.json())
|
|
59
|
+
|
|
60
|
+
def list_runs(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
principal: str | None = None,
|
|
64
|
+
route: str | None = None,
|
|
65
|
+
since: str | None = None,
|
|
66
|
+
) -> list[dict[str, Any]]:
|
|
67
|
+
params: dict[str, str] = {}
|
|
68
|
+
if principal:
|
|
69
|
+
params["principal"] = principal
|
|
70
|
+
if route:
|
|
71
|
+
params["route"] = route
|
|
72
|
+
if since:
|
|
73
|
+
params["since"] = since
|
|
74
|
+
resp = self._client.get("/v1/audit", params=params)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
result: list[dict[str, Any]] = resp.json()["runs"]
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def chat(
|
|
80
|
+
self,
|
|
81
|
+
messages: list[dict[str, str]],
|
|
82
|
+
*,
|
|
83
|
+
model: str = "default",
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
body = {"model": model, "messages": messages, "stream": False}
|
|
86
|
+
resp = self._client.post("/v1/chat/completions", json=body)
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
result: dict[str, Any] = resp.json()
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def stream_chat(
|
|
92
|
+
self,
|
|
93
|
+
messages: list[dict[str, str]],
|
|
94
|
+
*,
|
|
95
|
+
model: str = "default",
|
|
96
|
+
) -> Iterator[dict[str, Any]]:
|
|
97
|
+
body = {"model": model, "messages": messages, "stream": True}
|
|
98
|
+
with self._client.stream("POST", "/v1/chat/completions", json=body) as resp:
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
for line in resp.iter_lines():
|
|
101
|
+
if line.startswith("data: "):
|
|
102
|
+
data = line[6:]
|
|
103
|
+
if data.strip() == "[DONE]":
|
|
104
|
+
break
|
|
105
|
+
yield json.loads(data)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
self._client.close()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> AegisClient:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *args: object) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AsyncAegisClient:
|
|
118
|
+
"""Asynchronous Aegis API client."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
base_url: str = "http://localhost:8767",
|
|
123
|
+
api_key: str = "",
|
|
124
|
+
*,
|
|
125
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
headers: dict[str, str] = {}
|
|
128
|
+
if api_key:
|
|
129
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
130
|
+
kwargs: dict[str, Any] = {"base_url": base_url, "headers": headers}
|
|
131
|
+
if transport is not None:
|
|
132
|
+
kwargs["transport"] = transport
|
|
133
|
+
self._client = httpx.AsyncClient(**kwargs)
|
|
134
|
+
|
|
135
|
+
async def create_run(
|
|
136
|
+
self,
|
|
137
|
+
messages: list[dict[str, str]],
|
|
138
|
+
*,
|
|
139
|
+
route: str = "default",
|
|
140
|
+
background: bool = False,
|
|
141
|
+
approvers: list[str] | None = None,
|
|
142
|
+
) -> RunCreateResponse:
|
|
143
|
+
body = {
|
|
144
|
+
"messages": messages,
|
|
145
|
+
"route": route,
|
|
146
|
+
"background": background,
|
|
147
|
+
"approvers": approvers or [],
|
|
148
|
+
}
|
|
149
|
+
resp = await self._client.post("/v1/runs", json=body)
|
|
150
|
+
resp.raise_for_status()
|
|
151
|
+
return RunCreateResponse(**resp.json())
|
|
152
|
+
|
|
153
|
+
async def get_run(self, run_id: str) -> RunStatusResponse:
|
|
154
|
+
resp = await self._client.get(f"/v1/runs/{run_id}")
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
return RunStatusResponse(**resp.json())
|
|
157
|
+
|
|
158
|
+
async def resume_run(self, run_id: str, decision: str) -> ResumeResponse:
|
|
159
|
+
resp = await self._client.post(f"/v1/runs/{run_id}/resume", json={"decision": decision})
|
|
160
|
+
resp.raise_for_status()
|
|
161
|
+
return ResumeResponse(**resp.json())
|
|
162
|
+
|
|
163
|
+
async def list_runs(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
principal: str | None = None,
|
|
167
|
+
route: str | None = None,
|
|
168
|
+
since: str | None = None,
|
|
169
|
+
) -> list[dict[str, Any]]:
|
|
170
|
+
params: dict[str, str] = {}
|
|
171
|
+
if principal:
|
|
172
|
+
params["principal"] = principal
|
|
173
|
+
if route:
|
|
174
|
+
params["route"] = route
|
|
175
|
+
if since:
|
|
176
|
+
params["since"] = since
|
|
177
|
+
resp = await self._client.get("/v1/audit", params=params)
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
result: list[dict[str, Any]] = resp.json()["runs"]
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
async def chat(
|
|
183
|
+
self,
|
|
184
|
+
messages: list[dict[str, str]],
|
|
185
|
+
*,
|
|
186
|
+
model: str = "default",
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
body = {"model": model, "messages": messages, "stream": False}
|
|
189
|
+
resp = await self._client.post("/v1/chat/completions", json=body)
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
result: dict[str, Any] = resp.json()
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
async def stream_chat(
|
|
195
|
+
self,
|
|
196
|
+
messages: list[dict[str, str]],
|
|
197
|
+
*,
|
|
198
|
+
model: str = "default",
|
|
199
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
200
|
+
body = {"model": model, "messages": messages, "stream": True}
|
|
201
|
+
async with self._client.stream("POST", "/v1/chat/completions", json=body) as resp:
|
|
202
|
+
resp.raise_for_status()
|
|
203
|
+
async for line in resp.aiter_lines():
|
|
204
|
+
if line.startswith("data: "):
|
|
205
|
+
data = line[6:]
|
|
206
|
+
if data.strip() == "[DONE]":
|
|
207
|
+
break
|
|
208
|
+
yield json.loads(data)
|
|
209
|
+
|
|
210
|
+
async def aclose(self) -> None:
|
|
211
|
+
await self._client.aclose()
|
|
212
|
+
|
|
213
|
+
async def __aenter__(self) -> AsyncAegisClient:
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
async def __aexit__(self, *args: object) -> None:
|
|
217
|
+
await self.aclose()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pydantic models for Aegis API request/response shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RunCreateRequest(BaseModel):
|
|
9
|
+
messages: list[dict[str, str]]
|
|
10
|
+
route: str = "default"
|
|
11
|
+
approvers: list[str] = []
|
|
12
|
+
background: bool = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunCreateResponse(BaseModel):
|
|
16
|
+
run_id: str
|
|
17
|
+
response: str | None
|
|
18
|
+
principal_id: str
|
|
19
|
+
events: list[dict[str, object]]
|
|
20
|
+
status: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunStatusResponse(BaseModel):
|
|
24
|
+
run_id: str
|
|
25
|
+
route: str
|
|
26
|
+
principal_id: str
|
|
27
|
+
status: str
|
|
28
|
+
approvers: list[str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResumeResponse(BaseModel):
|
|
32
|
+
run_id: str
|
|
33
|
+
status: str
|
|
34
|
+
response: str | None
|
|
35
|
+
events: list[dict[str, object]]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Async client integration tests against the ASGI app (PROJECT_SPEC D10).
|
|
2
|
+
|
|
3
|
+
Gate: DC uv run pytest sdk/python -q
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from aegis_core.pipeline.executor import PipelineExecutor
|
|
12
|
+
from aegis_core.testing.providers import FakeProvider
|
|
13
|
+
from aegis_sdk import AsyncAegisClient
|
|
14
|
+
from aegis_server.app import create_app
|
|
15
|
+
from aegis_server.auth import ApiKeyAuthenticator
|
|
16
|
+
from aegis_server.keys import KeyStore
|
|
17
|
+
from aegis_server.store.run_store import InMemoryRunStore
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_transport() -> tuple[httpx.ASGITransport, str]:
|
|
25
|
+
"""Build an ASGI transport and return (transport, api_key)."""
|
|
26
|
+
fake = FakeProvider(complete_response="sdk response")
|
|
27
|
+
ex = PipelineExecutor()
|
|
28
|
+
ex.register("default", provider=fake)
|
|
29
|
+
ks = KeyStore()
|
|
30
|
+
api_key = ks.create(principal_id="sdk-user", team="t")
|
|
31
|
+
store = InMemoryRunStore()
|
|
32
|
+
app = create_app(ex, authenticator=ApiKeyAuthenticator(ks), run_store=store)
|
|
33
|
+
return httpx.ASGITransport(app=app), api_key # type: ignore[arg-type]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Tests
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_async_create_run_returns_response() -> None:
|
|
43
|
+
"""AsyncAegisClient.create_run() returns a RunCreateResponse with status."""
|
|
44
|
+
transport, api_key = _make_transport()
|
|
45
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
46
|
+
result = await client.create_run([{"role": "user", "content": "hello"}])
|
|
47
|
+
assert result.run_id
|
|
48
|
+
assert result.status == "completed"
|
|
49
|
+
assert result.response == "sdk response"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_async_create_run_background_returns_pending() -> None:
|
|
54
|
+
"""AsyncAegisClient.create_run(background=True) returns status=pending."""
|
|
55
|
+
transport, api_key = _make_transport()
|
|
56
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
57
|
+
result = await client.create_run(
|
|
58
|
+
[{"role": "user", "content": "hi"}], background=True
|
|
59
|
+
)
|
|
60
|
+
assert result.status == "pending"
|
|
61
|
+
assert result.response is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_async_get_run_returns_status() -> None:
|
|
66
|
+
"""AsyncAegisClient.get_run() polls run status."""
|
|
67
|
+
transport, api_key = _make_transport()
|
|
68
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
69
|
+
created = await client.create_run([{"role": "user", "content": "hi"}])
|
|
70
|
+
polled = await client.get_run(created.run_id)
|
|
71
|
+
assert polled.run_id == created.run_id
|
|
72
|
+
assert polled.status == "completed"
|
|
73
|
+
assert polled.route == "default"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_async_list_runs_returns_entries() -> None:
|
|
78
|
+
"""AsyncAegisClient.list_runs() returns audit records."""
|
|
79
|
+
transport, api_key = _make_transport()
|
|
80
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
81
|
+
await client.create_run([{"role": "user", "content": "audit test"}])
|
|
82
|
+
runs = await client.list_runs()
|
|
83
|
+
assert len(runs) == 1
|
|
84
|
+
assert runs[0]["principal_id"] == "sdk-user"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_async_list_runs_filter_by_route() -> None:
|
|
89
|
+
"""AsyncAegisClient.list_runs(route=...) filters results."""
|
|
90
|
+
transport, api_key = _make_transport()
|
|
91
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
92
|
+
await client.create_run([{"role": "user", "content": "x"}], route="default")
|
|
93
|
+
runs = await client.list_runs(route="default")
|
|
94
|
+
no_runs = await client.list_runs(route="nonexistent")
|
|
95
|
+
assert len(runs) == 1
|
|
96
|
+
assert no_runs == []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_async_chat_returns_completion() -> None:
|
|
101
|
+
"""AsyncAegisClient.chat() calls /v1/chat/completions and returns JSON."""
|
|
102
|
+
transport, api_key = _make_transport()
|
|
103
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
104
|
+
result = await client.chat([{"role": "user", "content": "hi"}], model="default")
|
|
105
|
+
assert "choices" in result
|
|
106
|
+
assert result["choices"][0]["message"]["content"] == "sdk response"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_async_stream_chat_yields_chunks() -> None:
|
|
111
|
+
"""AsyncAegisClient.stream_chat() yields parsed SSE chunks."""
|
|
112
|
+
transport, api_key = _make_transport()
|
|
113
|
+
chunks: list[dict[str, object]] = []
|
|
114
|
+
async with AsyncAegisClient("http://test", api_key, transport=transport) as client:
|
|
115
|
+
async for chunk in client.stream_chat(
|
|
116
|
+
[{"role": "user", "content": "stream"}], model="default"
|
|
117
|
+
):
|
|
118
|
+
chunks.append(chunk)
|
|
119
|
+
assert len(chunks) >= 1
|
|
120
|
+
assert any(c.get("object") == "chat.completion.chunk" for c in chunks)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_async_unauthenticated_raises() -> None:
|
|
125
|
+
"""AsyncAegisClient without a key gets a 401 HTTPStatusError."""
|
|
126
|
+
transport, _key = _make_transport()
|
|
127
|
+
async with AsyncAegisClient("http://test", "", transport=transport) as client:
|
|
128
|
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
|
129
|
+
await client.create_run([{"role": "user", "content": "hi"}])
|
|
130
|
+
assert exc_info.value.response.status_code == 401
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Sync client unit tests — HTTP layer mocked (PROJECT_SPEC D10).
|
|
2
|
+
|
|
3
|
+
Gate: DC uv run pytest sdk/python -q
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from aegis_sdk import AegisClient
|
|
15
|
+
from aegis_sdk.models import ResumeResponse, RunCreateResponse, RunStatusResponse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mock_response(status_code: int, body: object) -> MagicMock:
|
|
19
|
+
resp = MagicMock(spec=httpx.Response)
|
|
20
|
+
resp.status_code = status_code
|
|
21
|
+
resp.json.return_value = body
|
|
22
|
+
resp.raise_for_status = MagicMock()
|
|
23
|
+
if status_code >= 400:
|
|
24
|
+
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
25
|
+
"error", request=MagicMock(), response=resp
|
|
26
|
+
)
|
|
27
|
+
return resp
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# create_run
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_sync_create_run_posts_to_v1_runs() -> None:
|
|
36
|
+
"""create_run() sends POST /v1/runs and returns RunCreateResponse."""
|
|
37
|
+
body = {
|
|
38
|
+
"run_id": "r1",
|
|
39
|
+
"response": "hello",
|
|
40
|
+
"principal_id": "u",
|
|
41
|
+
"events": [],
|
|
42
|
+
"status": "completed",
|
|
43
|
+
}
|
|
44
|
+
client = AegisClient("http://test", "key")
|
|
45
|
+
with patch.object(client._client, "post", return_value=_mock_response(200, body)) as mock_post:
|
|
46
|
+
result = client.create_run([{"role": "user", "content": "hi"}])
|
|
47
|
+
mock_post.assert_called_once()
|
|
48
|
+
call_kwargs = mock_post.call_args
|
|
49
|
+
assert "/v1/runs" in call_kwargs.args[0]
|
|
50
|
+
assert isinstance(result, RunCreateResponse)
|
|
51
|
+
assert result.run_id == "r1"
|
|
52
|
+
assert result.status == "completed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_sync_create_run_background_flag() -> None:
|
|
56
|
+
"""create_run(background=True) sends background=true in body."""
|
|
57
|
+
body = {
|
|
58
|
+
"run_id": "r2",
|
|
59
|
+
"response": None,
|
|
60
|
+
"principal_id": "u",
|
|
61
|
+
"events": [],
|
|
62
|
+
"status": "pending",
|
|
63
|
+
}
|
|
64
|
+
client = AegisClient("http://test", "key")
|
|
65
|
+
with patch.object(client._client, "post", return_value=_mock_response(200, body)) as mock_post:
|
|
66
|
+
result = client.create_run([{"role": "user", "content": "hi"}], background=True)
|
|
67
|
+
sent_json = mock_post.call_args.kwargs["json"]
|
|
68
|
+
assert sent_json["background"] is True
|
|
69
|
+
assert result.status == "pending"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# get_run
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_sync_get_run_gets_v1_runs_id() -> None:
|
|
78
|
+
"""get_run() sends GET /v1/runs/{run_id} and returns RunStatusResponse."""
|
|
79
|
+
body = {
|
|
80
|
+
"run_id": "r1",
|
|
81
|
+
"route": "default",
|
|
82
|
+
"principal_id": "u",
|
|
83
|
+
"status": "completed",
|
|
84
|
+
"approvers": [],
|
|
85
|
+
}
|
|
86
|
+
client = AegisClient("http://test", "key")
|
|
87
|
+
with patch.object(client._client, "get", return_value=_mock_response(200, body)) as mock_get:
|
|
88
|
+
result = client.get_run("r1")
|
|
89
|
+
mock_get.assert_called_once_with("/v1/runs/r1")
|
|
90
|
+
assert isinstance(result, RunStatusResponse)
|
|
91
|
+
assert result.run_id == "r1"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# resume_run
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_sync_resume_run_posts_decision() -> None:
|
|
100
|
+
"""resume_run() sends POST /v1/runs/{id}/resume with decision in body."""
|
|
101
|
+
body = {"run_id": "r1", "status": "completed", "response": "ok", "events": []}
|
|
102
|
+
client = AegisClient("http://test", "key")
|
|
103
|
+
with patch.object(client._client, "post", return_value=_mock_response(200, body)) as mock_post:
|
|
104
|
+
result = client.resume_run("r1", "approved")
|
|
105
|
+
call_url = mock_post.call_args.args[0]
|
|
106
|
+
assert "/v1/runs/r1/resume" in call_url
|
|
107
|
+
sent_json = mock_post.call_args.kwargs["json"]
|
|
108
|
+
assert sent_json["decision"] == "approved"
|
|
109
|
+
assert isinstance(result, ResumeResponse)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# list_runs
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_sync_list_runs_calls_audit_endpoint() -> None:
|
|
118
|
+
"""list_runs() calls GET /v1/audit and returns the runs list."""
|
|
119
|
+
body = {"runs": [{"run_id": "r1", "status": "completed"}]}
|
|
120
|
+
client = AegisClient("http://test", "key")
|
|
121
|
+
with patch.object(client._client, "get", return_value=_mock_response(200, body)) as mock_get:
|
|
122
|
+
runs = client.list_runs()
|
|
123
|
+
mock_get.assert_called_once()
|
|
124
|
+
assert "/v1/audit" in mock_get.call_args.args[0]
|
|
125
|
+
assert len(runs) == 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_sync_list_runs_passes_filters() -> None:
|
|
129
|
+
"""list_runs(principal=..., route=...) passes query params."""
|
|
130
|
+
body = {"runs": []}
|
|
131
|
+
client = AegisClient("http://test", "key")
|
|
132
|
+
with patch.object(client._client, "get", return_value=_mock_response(200, body)) as mock_get:
|
|
133
|
+
client.list_runs(principal="alice", route="default")
|
|
134
|
+
params = mock_get.call_args.kwargs.get("params", {})
|
|
135
|
+
assert params["principal"] == "alice"
|
|
136
|
+
assert params["route"] == "default"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# chat
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_sync_chat_posts_to_chat_completions() -> None:
|
|
145
|
+
"""chat() sends POST /v1/chat/completions."""
|
|
146
|
+
body = {
|
|
147
|
+
"id": "c1",
|
|
148
|
+
"object": "chat.completion",
|
|
149
|
+
"created": 0,
|
|
150
|
+
"model": "default",
|
|
151
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
|
152
|
+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
153
|
+
}
|
|
154
|
+
client = AegisClient("http://test", "key")
|
|
155
|
+
with patch.object(client._client, "post", return_value=_mock_response(200, body)) as mock_post:
|
|
156
|
+
result = client.chat([{"role": "user", "content": "hi"}])
|
|
157
|
+
assert "/v1/chat/completions" in mock_post.call_args.args[0]
|
|
158
|
+
assert result["choices"][0]["message"]["content"] == "hi"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# stream_chat
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_sync_stream_chat_yields_parsed_chunks() -> None:
|
|
167
|
+
"""stream_chat() parses SSE lines and yields dicts."""
|
|
168
|
+
chunk1 = json.dumps({"object": "chat.completion.chunk", "choices": [{"delta": {"content": "hello"}}]})
|
|
169
|
+
sse_lines = [f"data: {chunk1}", "data: [DONE]"]
|
|
170
|
+
|
|
171
|
+
mock_resp = MagicMock()
|
|
172
|
+
mock_resp.__enter__ = lambda s: s
|
|
173
|
+
mock_resp.__exit__ = MagicMock(return_value=False)
|
|
174
|
+
mock_resp.raise_for_status = MagicMock()
|
|
175
|
+
mock_resp.iter_lines.return_value = iter(sse_lines)
|
|
176
|
+
|
|
177
|
+
client = AegisClient("http://test", "key")
|
|
178
|
+
with patch.object(client._client, "stream", return_value=mock_resp):
|
|
179
|
+
chunks = list(client.stream_chat([{"role": "user", "content": "hi"}]))
|
|
180
|
+
|
|
181
|
+
assert len(chunks) == 1
|
|
182
|
+
assert chunks[0]["object"] == "chat.completion.chunk"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# error propagation
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_sync_create_run_raises_on_401() -> None:
|
|
191
|
+
"""create_run() raises HTTPStatusError on 401."""
|
|
192
|
+
client = AegisClient("http://test", "badkey")
|
|
193
|
+
with patch.object(client._client, "post", return_value=_mock_response(401, {"detail": "Unauthorized"})):
|
|
194
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
195
|
+
client.create_run([{"role": "user", "content": "hi"}])
|