getstack 0.7.0__tar.gz → 0.9.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.
Files changed (52) hide show
  1. {getstack-0.7.0 → getstack-0.9.0}/PKG-INFO +1 -1
  2. {getstack-0.7.0 → getstack-0.9.0}/pyproject.toml +1 -1
  3. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/__init__.py +62 -0
  4. getstack-0.9.0/src/getstack/_async/inbound_webhooks.py +39 -0
  5. getstack-0.9.0/src/getstack/_async/llm.py +202 -0
  6. getstack-0.9.0/src/getstack/_async/passport_session.py +45 -0
  7. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/client.py +44 -2
  8. getstack-0.9.0/src/getstack/evidence_packs.py +78 -0
  9. getstack-0.9.0/src/getstack/inbound_webhooks.py +56 -0
  10. getstack-0.9.0/src/getstack/llm.py +265 -0
  11. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/missions.py +62 -0
  12. getstack-0.9.0/src/getstack/passport_session.py +71 -0
  13. {getstack-0.7.0 → getstack-0.9.0}/.gitignore +0 -0
  14. {getstack-0.7.0 → getstack-0.9.0}/CLAUDE.md +0 -0
  15. {getstack-0.7.0 → getstack-0.9.0}/LICENSE +0 -0
  16. {getstack-0.7.0 → getstack-0.9.0}/README.md +0 -0
  17. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/__init__.py +0 -0
  18. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/agents.py +0 -0
  19. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/audit.py +0 -0
  20. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/credentials.py +0 -0
  21. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/dropoffs.py +0 -0
  22. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/identity.py +0 -0
  23. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/intents.py +0 -0
  24. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/missions.py +0 -0
  25. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/notifications.py +0 -0
  26. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/passports.py +0 -0
  27. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/proxy.py +0 -0
  28. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/reviews.py +0 -0
  29. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/scan.py +0 -0
  30. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/security_events.py +0 -0
  31. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/services.py +0 -0
  32. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/_async/skills.py +0 -0
  33. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/agent_auth.py +0 -0
  34. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/agents.py +0 -0
  35. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/audit.py +0 -0
  36. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/auth.py +0 -0
  37. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/browser_bootstrap.py +0 -0
  38. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/credentials.py +0 -0
  39. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/dropoffs.py +0 -0
  40. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/errors.py +0 -0
  41. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/identity.py +0 -0
  42. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/intents.py +0 -0
  43. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/notifications.py +0 -0
  44. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/passports.py +0 -0
  45. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/proxy.py +0 -0
  46. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/py.typed +0 -0
  47. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/reviews.py +0 -0
  48. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/scan.py +0 -0
  49. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/security_events.py +0 -0
  50. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/services.py +0 -0
  51. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/skills.py +0 -0
  52. {getstack-0.7.0 → getstack-0.9.0}/src/getstack/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Python SDK for STACK — trust infrastructure for AI agents
5
5
  Project-URL: Homepage, https://getstack.run
6
6
  Project-URL: Documentation, https://getstack.run/docs/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -34,6 +34,7 @@ For async usage::
34
34
  from __future__ import annotations
35
35
 
36
36
  import os
37
+ from typing import Any
37
38
 
38
39
  from .auth import ApiKeyAuth, SessionAuth, OAuthAuth, CredentialsFileAuth
39
40
  from .agent_auth import AgentKeypairAuth
@@ -51,8 +52,12 @@ from .scan import ScanService
51
52
  from .security_events import SecurityEventService as SecurityEventSvc
52
53
  from .skills import SkillService
53
54
  from .identity import IdentityService
55
+ from .inbound_webhooks import InboundWebhookService
54
56
  from .intents import IntentsService
55
57
  from .missions import MissionsService
58
+ from .evidence_packs import EvidencePacksService
59
+ from .llm import LlmService
60
+ from .passport_session import PassportSession
56
61
  from .types import (
57
62
  Agent,
58
63
  Passport,
@@ -218,6 +223,36 @@ class Stack:
218
223
  self.identity = IdentityService(self._client)
219
224
  self.intents = IntentsService(self._client)
220
225
  self.missions = MissionsService(self._client)
226
+ self.evidence_packs = EvidencePacksService(self._client)
227
+ self.llm = LlmService(self._client)
228
+ self.inbound_webhooks = InboundWebhookService(self._client)
229
+
230
+ def issue_passport(
231
+ self,
232
+ *,
233
+ agent_id: str,
234
+ services: list[dict[str, Any]] | None = None,
235
+ intents: list[dict[str, Any]] | None = None,
236
+ ttl_seconds: int | None = None,
237
+ accountability_mode: str | None = None,
238
+ **kwargs: Any,
239
+ ) -> PassportSession:
240
+ """Phase 5.2.6 — issue a passport and return a PassportSession
241
+ that holds the token + auto-refreshes ~60s before expiry. Pass
242
+ the returned session to LLM calls instead of threading a raw
243
+ token per call::
244
+
245
+ session = stack.issue_passport(agent_id="agt_x", services=[...])
246
+ for i in range(1000):
247
+ stack.llm.anthropic.messages.create(body, passport=session)
248
+ """
249
+ issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
250
+ if services is not None: issue_input["services"] = services
251
+ if intents is not None: issue_input["intents"] = intents
252
+ if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
253
+ if accountability_mode is not None: issue_input["accountability_mode"] = accountability_mode
254
+ issued = self._client.post("/v1/passports/issue", json=issue_input)
255
+ return PassportSession(self._client, issued, issue_input)
221
256
 
222
257
  @classmethod
223
258
  def from_session(cls, session_token: str, **kwargs) -> Stack:
@@ -329,6 +364,9 @@ class AsyncStack:
329
364
  from ._async.identity import AsyncIdentityService
330
365
  from ._async.intents import AsyncIntentsService
331
366
  from ._async.missions import AsyncMissionsService
367
+ from ._async.llm import AsyncLlmService
368
+ from ._async.inbound_webhooks import AsyncInboundWebhookService
369
+ from ._async.passport_session import AsyncPassportSession
332
370
 
333
371
  self.agents = AsyncAgentService(self._client)
334
372
  self.passports = AsyncPassportService(self._client)
@@ -345,6 +383,30 @@ class AsyncStack:
345
383
  self.identity = AsyncIdentityService(self._client)
346
384
  self.intents = AsyncIntentsService(self._client)
347
385
  self.missions = AsyncMissionsService(self._client)
386
+ self.llm = AsyncLlmService(self._client)
387
+ self.inbound_webhooks = AsyncInboundWebhookService(self._client)
388
+ # Stash for issue_passport() — Python doesn't easily expose the
389
+ # class binding without re-importing inside the method body.
390
+ self._AsyncPassportSession = AsyncPassportSession
391
+
392
+ async def issue_passport(
393
+ self,
394
+ *,
395
+ agent_id: str,
396
+ services: list[dict[str, Any]] | None = None,
397
+ intents: list[dict[str, Any]] | None = None,
398
+ ttl_seconds: int | None = None,
399
+ accountability_mode: str | None = None,
400
+ **kwargs: Any,
401
+ ):
402
+ """Phase 5.2.6 — async passport issue + session wrapper."""
403
+ issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
404
+ if services is not None: issue_input["services"] = services
405
+ if intents is not None: issue_input["intents"] = intents
406
+ if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
407
+ if accountability_mode is not None: issue_input["accountability_mode"] = accountability_mode
408
+ issued = await self._client.post("/v1/passports/issue", json=issue_input)
409
+ return self._AsyncPassportSession(self._client, issued, issue_input)
348
410
 
349
411
  @classmethod
350
412
  def from_session(cls, session_token: str, **kwargs) -> AsyncStack:
@@ -0,0 +1,39 @@
1
+ """Async mirror of InboundWebhookService (Phase 5.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Optional
6
+
7
+ from ..client import AsyncHttpClient
8
+
9
+
10
+ Provider = Literal["agentmail", "generic"]
11
+
12
+
13
+ class AsyncInboundWebhookService:
14
+ def __init__(self, client: AsyncHttpClient):
15
+ self._client = client
16
+
17
+ async def create(
18
+ self,
19
+ provider: Provider,
20
+ agent_id: str,
21
+ forward_url: Optional[str] = None,
22
+ generic_config: Optional[dict[str, Any]] = None,
23
+ ) -> dict[str, Any]:
24
+ body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
25
+ if forward_url is not None:
26
+ body["forward_url"] = forward_url
27
+ if generic_config is not None:
28
+ body["generic_config"] = generic_config
29
+ return await self._client.post("/v1/operator/inbound-webhooks", json=body)
30
+
31
+ async def list(self) -> list[dict[str, Any]]:
32
+ res = await self._client.get("/v1/operator/inbound-webhooks")
33
+ return list(res.get("webhooks", []))
34
+
35
+ async def get(self, webhook_id: str) -> dict[str, Any]:
36
+ return await self._client.get(f"/v1/operator/inbound-webhooks/{webhook_id}")
37
+
38
+ async def revoke(self, webhook_id: str) -> dict[str, Any]:
39
+ return await self._client.delete(f"/v1/operator/inbound-webhooks/{webhook_id}")
@@ -0,0 +1,202 @@
1
+ """Phase 5.2.6 — async LLM gateway service mirror."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, AsyncIterator, Union
7
+
8
+ from ..client import AsyncHttpClient
9
+ from .passport_session import AsyncPassportSession
10
+
11
+ AsyncPassportInput = Union[str, AsyncPassportSession]
12
+
13
+
14
+ async def _resolve_passport(p: AsyncPassportInput) -> str:
15
+ if isinstance(p, str):
16
+ return p
17
+ return await p.get_token()
18
+
19
+
20
+ async def _build_headers(
21
+ passport: AsyncPassportInput,
22
+ intent_approval_id: str | None = None,
23
+ ) -> dict[str, str]:
24
+ headers = {"X-Passport-Token": await _resolve_passport(passport)}
25
+ if intent_approval_id:
26
+ headers["X-Intent-Approval-Id"] = intent_approval_id
27
+ return headers
28
+
29
+
30
+ async def _aiterate_sse(response: Any) -> AsyncIterator[dict[str, Any]]:
31
+ async for line in response.aiter_lines():
32
+ if not line or not line.startswith("data: "):
33
+ continue
34
+ payload = line[6:].strip()
35
+ if payload == "[DONE]" or payload == "":
36
+ continue
37
+ try:
38
+ yield json.loads(payload)
39
+ except json.JSONDecodeError:
40
+ continue
41
+
42
+
43
+ async def _stream_provider_call(
44
+ client: AsyncHttpClient,
45
+ path: str,
46
+ body: dict[str, Any],
47
+ headers: dict[str, str],
48
+ ) -> AsyncIterator[dict[str, Any]]:
49
+ async with client.stream_post(path, json=body, extra_headers=headers) as response:
50
+ if response.status_code >= 400:
51
+ try:
52
+ body_text = (await response.aread()).decode("utf-8")
53
+ except Exception:
54
+ body_text = response.reason_phrase
55
+ raise RuntimeError(f"LLM streaming call failed: {response.status_code} {body_text}")
56
+ async for event in _aiterate_sse(response):
57
+ yield event
58
+
59
+
60
+ # ── Provider sub-services ────────────────────────────────────────────
61
+
62
+
63
+ class _AsyncAnthropicMessages:
64
+ def __init__(self, client: AsyncHttpClient):
65
+ self._client = client
66
+
67
+ async def create(
68
+ self,
69
+ body: dict[str, Any],
70
+ *,
71
+ passport: AsyncPassportInput,
72
+ stream: bool | None = None,
73
+ intent_approval_id: str | None = None,
74
+ ) -> Any:
75
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
76
+ headers = await _build_headers(passport, intent_approval_id)
77
+ if effective_stream:
78
+ body = {**body, "stream": True}
79
+ return _stream_provider_call(self._client, "/v1/llm/anthropic/messages", body, headers)
80
+ return await self._client.post("/v1/llm/anthropic/messages", json=body, extra_headers=headers)
81
+
82
+
83
+ class _AsyncAnthropicProvider:
84
+ def __init__(self, client: AsyncHttpClient):
85
+ self.messages = _AsyncAnthropicMessages(client)
86
+
87
+
88
+ class _AsyncOpenAIChatCompletions:
89
+ def __init__(self, client: AsyncHttpClient):
90
+ self._client = client
91
+
92
+ async def create(
93
+ self,
94
+ body: dict[str, Any],
95
+ *,
96
+ passport: AsyncPassportInput,
97
+ stream: bool | None = None,
98
+ intent_approval_id: str | None = None,
99
+ ) -> Any:
100
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
101
+ headers = await _build_headers(passport, intent_approval_id)
102
+ if effective_stream:
103
+ body = {**body, "stream": True}
104
+ return _stream_provider_call(self._client, "/v1/llm/openai/chat/completions", body, headers)
105
+ return await self._client.post("/v1/llm/openai/chat/completions", json=body, extra_headers=headers)
106
+
107
+
108
+ class _AsyncOpenAIChat:
109
+ def __init__(self, client: AsyncHttpClient):
110
+ self.completions = _AsyncOpenAIChatCompletions(client)
111
+
112
+
113
+ class _AsyncOpenAIProvider:
114
+ def __init__(self, client: AsyncHttpClient):
115
+ self.chat = _AsyncOpenAIChat(client)
116
+
117
+
118
+ class _AsyncOpenRouterChatCompletions:
119
+ def __init__(self, client: AsyncHttpClient):
120
+ self._client = client
121
+
122
+ async def create(
123
+ self,
124
+ body: dict[str, Any],
125
+ *,
126
+ passport: AsyncPassportInput,
127
+ stream: bool | None = None,
128
+ intent_approval_id: str | None = None,
129
+ ) -> Any:
130
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
131
+ headers = await _build_headers(passport, intent_approval_id)
132
+ if effective_stream:
133
+ body = {**body, "stream": True}
134
+ return _stream_provider_call(self._client, "/v1/llm/openrouter/chat/completions", body, headers)
135
+ return await self._client.post("/v1/llm/openrouter/chat/completions", json=body, extra_headers=headers)
136
+
137
+
138
+ class _AsyncOpenRouterChat:
139
+ def __init__(self, client: AsyncHttpClient):
140
+ self.completions = _AsyncOpenRouterChatCompletions(client)
141
+
142
+
143
+ class _AsyncOpenRouterProvider:
144
+ def __init__(self, client: AsyncHttpClient):
145
+ self.chat = _AsyncOpenRouterChat(client)
146
+
147
+
148
+ class _AsyncLlmUsageQuery:
149
+ def __init__(self, client: AsyncHttpClient):
150
+ self._client = client
151
+
152
+ async def list(
153
+ self,
154
+ *,
155
+ limit: int | None = None,
156
+ cursor: str | None = None,
157
+ provider: str | None = None,
158
+ passport_jti: str | None = None,
159
+ mission_id: str | None = None,
160
+ agent_id: str | None = None,
161
+ since: str | None = None,
162
+ until: str | None = None,
163
+ ) -> dict[str, Any]:
164
+ params: dict[str, Any] = {}
165
+ if limit is not None: params["limit"] = limit
166
+ if cursor is not None: params["cursor"] = cursor
167
+ if provider is not None: params["provider"] = provider
168
+ if passport_jti is not None: params["passport_jti"] = passport_jti
169
+ if mission_id is not None: params["mission_id"] = mission_id
170
+ if agent_id is not None: params["agent_id"] = agent_id
171
+ if since is not None: params["since"] = since
172
+ if until is not None: params["until"] = until
173
+ return await self._client.get("/v1/llm/usage", params=params)
174
+
175
+ async def summary(
176
+ self,
177
+ *,
178
+ provider: str | None = None,
179
+ passport_jti: str | None = None,
180
+ mission_id: str | None = None,
181
+ agent_id: str | None = None,
182
+ since: str | None = None,
183
+ until: str | None = None,
184
+ group_by_model: bool | None = None,
185
+ ) -> dict[str, Any]:
186
+ params: dict[str, Any] = {}
187
+ if provider is not None: params["provider"] = provider
188
+ if passport_jti is not None: params["passport_jti"] = passport_jti
189
+ if mission_id is not None: params["mission_id"] = mission_id
190
+ if agent_id is not None: params["agent_id"] = agent_id
191
+ if since is not None: params["since"] = since
192
+ if until is not None: params["until"] = until
193
+ if group_by_model is not None: params["group_by_model"] = str(group_by_model).lower()
194
+ return await self._client.get("/v1/llm/usage/summary", params=params)
195
+
196
+
197
+ class AsyncLlmService:
198
+ def __init__(self, client: AsyncHttpClient):
199
+ self.anthropic = _AsyncAnthropicProvider(client)
200
+ self.openai = _AsyncOpenAIProvider(client)
201
+ self.openrouter = _AsyncOpenRouterProvider(client)
202
+ self.usage = _AsyncLlmUsageQuery(client)
@@ -0,0 +1,45 @@
1
+ """Phase 5.2.6 — async PassportSession mirror."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from ..client import AsyncHttpClient
9
+ from ..passport_session import _decode_jwt_exp
10
+
11
+
12
+ class AsyncPassportSession:
13
+ def __init__(
14
+ self,
15
+ client: AsyncHttpClient,
16
+ issued: dict[str, Any],
17
+ issue_input: dict[str, Any],
18
+ ):
19
+ self._client = client
20
+ self._current_token: str = issued["token"]
21
+ self._exp_seconds: int = _decode_jwt_exp(self._current_token)
22
+ self._issue_input = issue_input
23
+
24
+ async def get_token(self, grace_seconds: int = 60) -> str:
25
+ now_sec = int(time.time())
26
+ if self._exp_seconds - now_sec > grace_seconds:
27
+ return self._current_token
28
+ refreshed = await self._client.post(
29
+ "/v1/passports/refresh",
30
+ json={
31
+ "token": self._current_token,
32
+ "ttl_seconds": self._issue_input.get("ttl_seconds"),
33
+ },
34
+ )
35
+ self._current_token = refreshed["token"]
36
+ self._exp_seconds = _decode_jwt_exp(self._current_token)
37
+ return self._current_token
38
+
39
+ @property
40
+ def token(self) -> str:
41
+ return self._current_token
42
+
43
+ @property
44
+ def expires_at_seconds(self) -> int:
45
+ return self._exp_seconds
@@ -45,8 +45,29 @@ class HttpClient:
45
45
  def get(self, path: str, params: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> Any:
46
46
  return self.request("GET", path, params=params, extra_headers=extra_headers)
47
47
 
48
- def post(self, path: str, json: Any = None) -> Any:
49
- return self.request("POST", path, json=json)
48
+ def post(self, path: str, json: Any = None, extra_headers: dict[str, str] | None = None) -> Any:
49
+ return self.request("POST", path, json=json, extra_headers=extra_headers)
50
+
51
+ def stream_post(
52
+ self,
53
+ path: str,
54
+ json: Any = None,
55
+ extra_headers: dict[str, str] | None = None,
56
+ ):
57
+ """Phase 5.2.6 — streaming POST for the LLM gateway. Returns an
58
+ httpx Response context manager; caller iterates `.iter_lines()`
59
+ and yields parsed SSE events. The caller MUST use this as a
60
+ context manager (`with client.stream_post(...) as resp:`) so the
61
+ underlying connection releases on exit.
62
+ """
63
+ headers = {
64
+ "Content-Type": "application/json",
65
+ "Accept": "text/event-stream",
66
+ **self.auth.get_headers(),
67
+ **(extra_headers or {}),
68
+ }
69
+ url = f"{self.base_url}{path}"
70
+ return self._client.stream("POST", url, json=json, headers=headers)
50
71
 
51
72
  def patch(self, path: str, json: Any = None) -> Any:
52
73
  return self.request("PATCH", path, json=json)
@@ -133,5 +154,26 @@ class AsyncHttpClient:
133
154
  ) -> Any:
134
155
  return await self.request("DELETE", path, extra_headers=extra_headers)
135
156
 
157
+ def stream_post(
158
+ self,
159
+ path: str,
160
+ json: Any = None,
161
+ extra_headers: dict[str, str] | None = None,
162
+ ):
163
+ """Phase 5.2.6 — streaming POST for the LLM gateway (async).
164
+ Returns an httpx async context manager; caller uses
165
+ `async with client.stream_post(...) as resp:` then
166
+ `async for line in resp.aiter_lines():`. Connection releases on
167
+ exit.
168
+ """
169
+ headers = {
170
+ "Content-Type": "application/json",
171
+ "Accept": "text/event-stream",
172
+ **self.auth.get_headers(),
173
+ **(extra_headers or {}),
174
+ }
175
+ url = f"{self.base_url}{path}"
176
+ return self._client.stream("POST", url, json=json, headers=headers)
177
+
136
178
  async def close(self) -> None:
137
179
  await self._client.aclose()
@@ -0,0 +1,78 @@
1
+ """Evidence packs — export, get, list.
2
+
3
+ Portable cryptographic bundles. Verifiable offline by any third party
4
+ via the open-source `@getstackrun/verify` package.
5
+
6
+ export() Export a pack for a mission / session / incident / period
7
+ scope. Returns the pack_id + the full canonical manifest
8
+ JSON inline. Persist the manifest to disk and hand it to
9
+ an auditor / insurer / regulator.
10
+ get() Fetch one pack by id. Returns the signed claim envelope
11
+ bytes alongside the parsed pack_claim.
12
+ list() List packs for the calling operator. Filter by scope.
13
+
14
+ Enterprise-tier only at the API layer.
15
+
16
+ Phase 5.4.7.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from typing import Any
23
+
24
+ from .client import HttpClient
25
+
26
+
27
+ class EvidencePacksService:
28
+ def __init__(self, client: HttpClient):
29
+ self._client = client
30
+
31
+ def export(
32
+ self,
33
+ *,
34
+ scope_kind: str,
35
+ scope_ref: str,
36
+ supersedes: str | None = None,
37
+ air_gap: bool | None = None,
38
+ output_path: str | None = None,
39
+ ) -> dict[str, Any]:
40
+ """Export an evidence pack.
41
+
42
+ `scope_kind` is one of `mission` | `session` | `incident` | `period`.
43
+ `scope_ref` is the matching reference (ms_* / ses_* / period:YYYY-Qn /
44
+ incident:<id>). The pack manifest is returned inline; pass
45
+ `output_path` to also write it to disk in one step.
46
+
47
+ Returns a dict with `pack_id`, `claim_id`, `pack_claim`, `manifest`.
48
+ """
49
+ body: dict[str, Any] = {
50
+ "scope": {"kind": scope_kind, "ref": scope_ref},
51
+ }
52
+ if supersedes is not None:
53
+ body["supersedes"] = supersedes
54
+ if air_gap is not None:
55
+ body["air_gap"] = air_gap
56
+ result = self._client.request("POST", "/v1/evidence-packs", json=body)
57
+ if output_path is not None and "manifest" in result:
58
+ with open(output_path, "w", encoding="utf-8") as f:
59
+ json.dump(result["manifest"], f, indent=2)
60
+ return result
61
+
62
+ def get(self, pack_id: str) -> dict[str, Any]:
63
+ """Fetch one pack by id. Returns the signed envelope_cose bytes and
64
+ the parsed `pack_claim` for convenient introspection."""
65
+ return self._client.request("GET", f"/v1/evidence-packs/{pack_id}")
66
+
67
+ def list(self, *, scope: str | None = None) -> dict[str, Any]:
68
+ """List packs for the calling operator.
69
+
70
+ `scope` filter is `<kind>:<ref>` (e.g. `mission:ms_abc`,
71
+ `period:2026-Q1`). Omit to list every pack. Newest first.
72
+ """
73
+ path = "/v1/evidence-packs"
74
+ if scope is not None:
75
+ from urllib.parse import urlencode
76
+
77
+ path = f"{path}?{urlencode({'scope': scope})}"
78
+ return self._client.request("GET", path)
@@ -0,0 +1,56 @@
1
+ """Hosted inbound webhook surface (Phase 5.5).
2
+
3
+ Operator creates a webhook row + receives a STACK-hosted URL + HMAC
4
+ secret. Paste both into a provider's (AgentMail / generic) webhook
5
+ config; STACK runs the prompt-injection detector on inbound content
6
+ before forwarding to a downstream URL.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, Optional
12
+
13
+ from .client import HttpClient
14
+
15
+
16
+ Provider = Literal["agentmail", "generic"]
17
+ SignatureAlgorithm = Literal["hmac-sha256", "hmac-sha1"]
18
+
19
+
20
+ class InboundWebhookService:
21
+ def __init__(self, client: HttpClient):
22
+ self._client = client
23
+
24
+ def create(
25
+ self,
26
+ provider: Provider,
27
+ agent_id: str,
28
+ forward_url: Optional[str] = None,
29
+ generic_config: Optional[dict[str, Any]] = None,
30
+ ) -> dict[str, Any]:
31
+ """Create a hosted inbound webhook.
32
+
33
+ Returns a dict with keys: id, provider, slug, webhook_url,
34
+ webhook_secret (plaintext, returned ONLY at create), agent_id,
35
+ forward_url, created_at. Capture webhook_secret immediately —
36
+ STACK never returns it again. If lost, revoke + recreate.
37
+ """
38
+ body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
39
+ if forward_url is not None:
40
+ body["forward_url"] = forward_url
41
+ if generic_config is not None:
42
+ body["generic_config"] = generic_config
43
+ return self._client.post("/v1/operator/inbound-webhooks", json=body)
44
+
45
+ def list(self) -> list[dict[str, Any]]:
46
+ """List all inbound webhooks for the operator (active + revoked)."""
47
+ res = self._client.get("/v1/operator/inbound-webhooks")
48
+ return list(res.get("webhooks", []))
49
+
50
+ def get(self, webhook_id: str) -> dict[str, Any]:
51
+ """Get a single inbound webhook by id. Never returns the plaintext secret."""
52
+ return self._client.get(f"/v1/operator/inbound-webhooks/{webhook_id}")
53
+
54
+ def revoke(self, webhook_id: str) -> dict[str, Any]:
55
+ """Soft-delete an inbound webhook. Idempotent on repeat calls."""
56
+ return self._client.delete(f"/v1/operator/inbound-webhooks/{webhook_id}")
@@ -0,0 +1,265 @@
1
+ """Phase 5.2.6 — LLM gateway SDK service (sync).
2
+
3
+ Drop-in compatible with each provider's official SDK call shape (with
4
+ one deviation: passport per-call as a second arg, because STACK
5
+ passport TTLs are 15 min default and would silently invalidate
6
+ mid-session in a constructor-time pattern).
7
+
8
+ Usage::
9
+
10
+ from getstack import Stack
11
+
12
+ stack = Stack(api_key="sk_live_...")
13
+ session = stack.issue_passport(agent_id="agt_x", services=[...])
14
+
15
+ # non-streaming
16
+ response = stack.llm.anthropic.messages.create(
17
+ body={
18
+ "model": "claude-opus-4-7",
19
+ "messages": [{"role": "user", "content": "hi"}],
20
+ "max_tokens": 100,
21
+ },
22
+ passport=session,
23
+ )
24
+
25
+ # streaming
26
+ for event in stack.llm.anthropic.messages.create(
27
+ body={...},
28
+ passport=session,
29
+ stream=True,
30
+ ):
31
+ print(event)
32
+
33
+ # observability
34
+ rows = stack.llm.usage.list(limit=10)
35
+ buckets = stack.llm.usage.summary(since="2026-05-01T00:00:00Z")
36
+
37
+ Wire shapes mirror packages/shared/src/schemas/llm/usage.ts. Types
38
+ stay loose (dicts) — same convention as missions.py.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ from typing import Any, Iterator, Union
45
+
46
+ from .client import HttpClient
47
+ from .passport_session import PassportSession
48
+
49
+ PassportInput = Union[str, PassportSession]
50
+
51
+
52
+ def _resolve_passport(p: PassportInput) -> str:
53
+ if isinstance(p, str):
54
+ return p
55
+ return p.get_token()
56
+
57
+
58
+ def _build_headers(passport: PassportInput, intent_approval_id: str | None = None) -> dict[str, str]:
59
+ headers = {"X-Passport-Token": _resolve_passport(passport)}
60
+ if intent_approval_id:
61
+ headers["X-Intent-Approval-Id"] = intent_approval_id
62
+ return headers
63
+
64
+
65
+ def _iterate_sse(response: Any) -> Iterator[dict[str, Any]]:
66
+ """Parse an httpx streaming Response's iter_lines into SSE events.
67
+ Yields each `data: { ... }` JSON payload. Skips `event:` / `id:` /
68
+ blank / comment lines. Terminates on `[DONE]` (OpenAI) or stream end.
69
+ """
70
+ for line in response.iter_lines():
71
+ if not line:
72
+ continue
73
+ # httpx returns str (text); strip the SSE `data: ` prefix.
74
+ if not line.startswith("data: "):
75
+ continue
76
+ payload = line[6:].strip()
77
+ if payload == "[DONE]" or payload == "":
78
+ continue
79
+ try:
80
+ yield json.loads(payload)
81
+ except json.JSONDecodeError:
82
+ continue
83
+
84
+
85
+ # ── Provider sub-services ────────────────────────────────────────────
86
+
87
+
88
+ class _AnthropicMessages:
89
+ def __init__(self, client: HttpClient):
90
+ self._client = client
91
+
92
+ def create(
93
+ self,
94
+ body: dict[str, Any],
95
+ *,
96
+ passport: PassportInput,
97
+ stream: bool | None = None,
98
+ intent_approval_id: str | None = None,
99
+ ) -> Any:
100
+ """If body['stream'] is True OR stream=True, returns an iterator
101
+ of parsed SSE events. Otherwise returns the JSON response body.
102
+ """
103
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
104
+ if effective_stream:
105
+ body = {**body, "stream": True}
106
+ headers = _build_headers(passport, intent_approval_id)
107
+ return _stream_provider_call(self._client, "/v1/llm/anthropic/messages", body, headers)
108
+ headers = _build_headers(passport, intent_approval_id)
109
+ return self._client.post("/v1/llm/anthropic/messages", json=body, extra_headers=headers)
110
+
111
+
112
+ class _AnthropicProvider:
113
+ def __init__(self, client: HttpClient):
114
+ self.messages = _AnthropicMessages(client)
115
+
116
+
117
+ class _OpenAIChatCompletions:
118
+ def __init__(self, client: HttpClient):
119
+ self._client = client
120
+
121
+ def create(
122
+ self,
123
+ body: dict[str, Any],
124
+ *,
125
+ passport: PassportInput,
126
+ stream: bool | None = None,
127
+ intent_approval_id: str | None = None,
128
+ ) -> Any:
129
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
130
+ if effective_stream:
131
+ body = {**body, "stream": True}
132
+ headers = _build_headers(passport, intent_approval_id)
133
+ return _stream_provider_call(self._client, "/v1/llm/openai/chat/completions", body, headers)
134
+ headers = _build_headers(passport, intent_approval_id)
135
+ return self._client.post("/v1/llm/openai/chat/completions", json=body, extra_headers=headers)
136
+
137
+
138
+ class _OpenAIChat:
139
+ def __init__(self, client: HttpClient):
140
+ self.completions = _OpenAIChatCompletions(client)
141
+
142
+
143
+ class _OpenAIProvider:
144
+ def __init__(self, client: HttpClient):
145
+ self.chat = _OpenAIChat(client)
146
+
147
+
148
+ class _OpenRouterChatCompletions:
149
+ def __init__(self, client: HttpClient):
150
+ self._client = client
151
+
152
+ def create(
153
+ self,
154
+ body: dict[str, Any],
155
+ *,
156
+ passport: PassportInput,
157
+ stream: bool | None = None,
158
+ intent_approval_id: str | None = None,
159
+ ) -> Any:
160
+ effective_stream = bool(stream if stream is not None else body.get("stream"))
161
+ if effective_stream:
162
+ body = {**body, "stream": True}
163
+ headers = _build_headers(passport, intent_approval_id)
164
+ return _stream_provider_call(self._client, "/v1/llm/openrouter/chat/completions", body, headers)
165
+ headers = _build_headers(passport, intent_approval_id)
166
+ return self._client.post("/v1/llm/openrouter/chat/completions", json=body, extra_headers=headers)
167
+
168
+
169
+ class _OpenRouterChat:
170
+ def __init__(self, client: HttpClient):
171
+ self.completions = _OpenRouterChatCompletions(client)
172
+
173
+
174
+ class _OpenRouterProvider:
175
+ def __init__(self, client: HttpClient):
176
+ self.chat = _OpenRouterChat(client)
177
+
178
+
179
+ # ── Usage query sub-service ──────────────────────────────────────────
180
+
181
+
182
+ class _LlmUsageQuery:
183
+ def __init__(self, client: HttpClient):
184
+ self._client = client
185
+
186
+ def list(
187
+ self,
188
+ *,
189
+ limit: int | None = None,
190
+ cursor: str | None = None,
191
+ provider: str | None = None,
192
+ passport_jti: str | None = None,
193
+ mission_id: str | None = None,
194
+ agent_id: str | None = None,
195
+ since: str | None = None,
196
+ until: str | None = None,
197
+ ) -> dict[str, Any]:
198
+ params: dict[str, Any] = {}
199
+ if limit is not None: params["limit"] = limit
200
+ if cursor is not None: params["cursor"] = cursor
201
+ if provider is not None: params["provider"] = provider
202
+ if passport_jti is not None: params["passport_jti"] = passport_jti
203
+ if mission_id is not None: params["mission_id"] = mission_id
204
+ if agent_id is not None: params["agent_id"] = agent_id
205
+ if since is not None: params["since"] = since
206
+ if until is not None: params["until"] = until
207
+ return self._client.get("/v1/llm/usage", params=params)
208
+
209
+ def summary(
210
+ self,
211
+ *,
212
+ provider: str | None = None,
213
+ passport_jti: str | None = None,
214
+ mission_id: str | None = None,
215
+ agent_id: str | None = None,
216
+ since: str | None = None,
217
+ until: str | None = None,
218
+ group_by_model: bool | None = None,
219
+ ) -> dict[str, Any]:
220
+ params: dict[str, Any] = {}
221
+ if provider is not None: params["provider"] = provider
222
+ if passport_jti is not None: params["passport_jti"] = passport_jti
223
+ if mission_id is not None: params["mission_id"] = mission_id
224
+ if agent_id is not None: params["agent_id"] = agent_id
225
+ if since is not None: params["since"] = since
226
+ if until is not None: params["until"] = until
227
+ if group_by_model is not None: params["group_by_model"] = str(group_by_model).lower()
228
+ return self._client.get("/v1/llm/usage/summary", params=params)
229
+
230
+
231
+ # ── Top-level service ────────────────────────────────────────────────
232
+
233
+
234
+ class LlmService:
235
+ def __init__(self, client: HttpClient):
236
+ self.anthropic = _AnthropicProvider(client)
237
+ self.openai = _OpenAIProvider(client)
238
+ self.openrouter = _OpenRouterProvider(client)
239
+ self.usage = _LlmUsageQuery(client)
240
+
241
+
242
+ # ── Streaming helper ─────────────────────────────────────────────────
243
+
244
+
245
+ def _stream_provider_call(
246
+ client: HttpClient,
247
+ path: str,
248
+ body: dict[str, Any],
249
+ headers: dict[str, str],
250
+ ) -> Iterator[dict[str, Any]]:
251
+ """Open a streaming connection and yield parsed SSE events. The httpx
252
+ response context manager is held open across the yield — caller MUST
253
+ fully drain the iterator (or `break` early; the GC closes the
254
+ connection). Keeps the streaming UX clean: `for event in
255
+ stack.llm.anthropic.messages.create(..., stream=True): ...`.
256
+ """
257
+ with client.stream_post(path, json=body, extra_headers=headers) as response:
258
+ if response.status_code >= 400:
259
+ # Read the error body before yielding (no chunks to emit).
260
+ try:
261
+ body_text = response.read().decode("utf-8")
262
+ except Exception:
263
+ body_text = response.reason_phrase
264
+ raise RuntimeError(f"LLM streaming call failed: {response.status_code} {body_text}")
265
+ yield from _iterate_sse(response)
@@ -128,3 +128,65 @@ class MissionsService:
128
128
 
129
129
  path = f"{path}?{urlencode(params)}"
130
130
  return self._client.request("GET", path)
131
+
132
+ # ─── Phase 5.4 — replay + dossier ────────────────────────────────
133
+
134
+ def replay(self, mission_id: str) -> dict[str, Any]:
135
+ """Reconstruct the forensic timeline for a mission.
136
+
137
+ Returns a dict matching the @stack/shared `ReplayedTimeline` shape:
138
+ events ordered chronologically, behavioral_diff (close vs baseline
139
+ genome), cap_usage, dossiers chain, twin_divergence. Same shape the
140
+ open-source verifier produces from an exported pack.
141
+
142
+ Phase 5.4.5 / 5.4.7.
143
+ """
144
+ return self._client.request(
145
+ "GET", f"/v1/missions/{mission_id}/replay"
146
+ )
147
+
148
+ def list_dossiers(self, mission_id: str) -> dict[str, Any]:
149
+ """List every dossier authored for a mission, ordered chronologically
150
+ by `authored_at_ms`. Returns dict with `items` (each carrying
151
+ `body.narrative`, `body.lessons_learned`, etc.). Empty list when no
152
+ dossier exists.
153
+
154
+ Phase 5.4.2 / 5.4.7.
155
+ """
156
+ return self._client.request(
157
+ "GET", f"/v1/missions/{mission_id}/dossiers"
158
+ )
159
+
160
+ def create_dossier(
161
+ self,
162
+ mission_id: str,
163
+ *,
164
+ narrative: str,
165
+ lessons_learned: str,
166
+ incident_classification: str,
167
+ decision_tree: list[dict[str, Any]] | None = None,
168
+ alerts_walkthrough: list[dict[str, Any]] | None = None,
169
+ ) -> dict[str, Any]:
170
+ """Author a fresh dossier for a mission. The mission must be in a
171
+ terminal state (completed_* or revoked_*) — 409 surfaces otherwise.
172
+ `authored_by_member_id` is derived from the calling auth context
173
+ server-side (not passed here). Enterprise-tier only.
174
+
175
+ `incident_classification` is one of:
176
+ `false_positive` | `true_positive_handled`
177
+ | `true_positive_escalated` | `inconclusive`.
178
+
179
+ Phase 5.4.2 / 5.4.7.
180
+ """
181
+ body: dict[str, Any] = {
182
+ "narrative": narrative,
183
+ "lessons_learned": lessons_learned,
184
+ "incident_classification": incident_classification,
185
+ }
186
+ if decision_tree is not None:
187
+ body["decision_tree"] = decision_tree
188
+ if alerts_walkthrough is not None:
189
+ body["alerts_walkthrough"] = alerts_walkthrough
190
+ return self._client.request(
191
+ "POST", f"/v1/missions/{mission_id}/dossier", json=body
192
+ )
@@ -0,0 +1,71 @@
1
+ """Phase 5.2.6 — PassportSession helper (sync).
2
+
3
+ Wraps a passport token + expiry; auto-refreshes via /v1/passports/refresh
4
+ when within `grace_seconds` of expiry. Lets operators write LLM-call
5
+ loops without manual passport bookkeeping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ import time
13
+ from typing import Any
14
+
15
+ from .client import HttpClient
16
+
17
+
18
+ class PassportSession:
19
+ def __init__(
20
+ self,
21
+ client: HttpClient,
22
+ issued: dict[str, Any],
23
+ issue_input: dict[str, Any],
24
+ ):
25
+ self._client = client
26
+ self._current_token: str = issued["token"]
27
+ self._exp_seconds: int = _decode_jwt_exp(self._current_token)
28
+ self._issue_input = issue_input
29
+
30
+ def get_token(self, grace_seconds: int = 60) -> str:
31
+ """Return the current token; auto-refresh if within grace."""
32
+ now_sec = int(time.time())
33
+ if self._exp_seconds - now_sec > grace_seconds:
34
+ return self._current_token
35
+ # Refresh — errors propagate (caller would have failed anyway with an
36
+ # expired token; surfacing here is the clearer signal).
37
+ refreshed = self._client.post(
38
+ "/v1/passports/refresh",
39
+ json={
40
+ "token": self._current_token,
41
+ "ttl_seconds": self._issue_input.get("ttl_seconds"),
42
+ },
43
+ )
44
+ self._current_token = refreshed["token"]
45
+ self._exp_seconds = _decode_jwt_exp(self._current_token)
46
+ return self._current_token
47
+
48
+ @property
49
+ def token(self) -> str:
50
+ """Current token (no refresh trigger)."""
51
+ return self._current_token
52
+
53
+ @property
54
+ def expires_at_seconds(self) -> int:
55
+ """Unix seconds since epoch."""
56
+ return self._exp_seconds
57
+
58
+
59
+ def _decode_jwt_exp(token: str) -> int:
60
+ parts = token.split(".")
61
+ if len(parts) != 3:
62
+ raise ValueError("PassportSession: token is not a JWT (expected 3 dot-separated segments)")
63
+ payload_b64 = parts[1]
64
+ # base64url → padding
65
+ padded = payload_b64 + "=" * ((4 - len(payload_b64) % 4) % 4)
66
+ decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
67
+ payload = json.loads(decoded)
68
+ exp = payload.get("exp")
69
+ if not isinstance(exp, int):
70
+ raise ValueError("PassportSession: token payload missing numeric `exp` claim")
71
+ return exp
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes