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.
@@ -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"}])