getstack 0.6.0__tar.gz → 0.8.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 (49) hide show
  1. {getstack-0.6.0 → getstack-0.8.0}/.gitignore +5 -0
  2. {getstack-0.6.0 → getstack-0.8.0}/PKG-INFO +1 -1
  3. {getstack-0.6.0 → getstack-0.8.0}/pyproject.toml +1 -1
  4. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/__init__.py +60 -0
  5. getstack-0.8.0/src/getstack/_async/llm.py +202 -0
  6. getstack-0.8.0/src/getstack/_async/missions.py +110 -0
  7. getstack-0.8.0/src/getstack/_async/passport_session.py +45 -0
  8. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/client.py +44 -2
  9. getstack-0.8.0/src/getstack/llm.py +265 -0
  10. getstack-0.8.0/src/getstack/missions.py +130 -0
  11. getstack-0.8.0/src/getstack/passport_session.py +71 -0
  12. {getstack-0.6.0 → getstack-0.8.0}/CLAUDE.md +0 -0
  13. {getstack-0.6.0 → getstack-0.8.0}/LICENSE +0 -0
  14. {getstack-0.6.0 → getstack-0.8.0}/README.md +0 -0
  15. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/__init__.py +0 -0
  16. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/agents.py +0 -0
  17. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/audit.py +0 -0
  18. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/credentials.py +0 -0
  19. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/dropoffs.py +0 -0
  20. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/identity.py +0 -0
  21. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/intents.py +0 -0
  22. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/notifications.py +0 -0
  23. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/passports.py +0 -0
  24. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/proxy.py +0 -0
  25. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/reviews.py +0 -0
  26. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/scan.py +0 -0
  27. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/security_events.py +0 -0
  28. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/services.py +0 -0
  29. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/skills.py +0 -0
  30. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/agent_auth.py +0 -0
  31. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/agents.py +0 -0
  32. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/audit.py +0 -0
  33. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/auth.py +0 -0
  34. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/browser_bootstrap.py +0 -0
  35. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/credentials.py +0 -0
  36. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/dropoffs.py +0 -0
  37. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/errors.py +0 -0
  38. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/identity.py +0 -0
  39. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/intents.py +0 -0
  40. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/notifications.py +0 -0
  41. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/passports.py +0 -0
  42. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/proxy.py +0 -0
  43. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/py.typed +0 -0
  44. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/reviews.py +0 -0
  45. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/scan.py +0 -0
  46. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/security_events.py +0 -0
  47. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/services.py +0 -0
  48. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/skills.py +0 -0
  49. {getstack-0.6.0 → getstack-0.8.0}/src/getstack/types.py +0 -0
@@ -60,6 +60,11 @@ scripts/.openapi-cache/
60
60
  # Local dev screenshots
61
61
  *-stripe.png
62
62
 
63
+ # Root-level scratch images (founder workspace — never commit)
64
+ /*.png
65
+ /*.jpg
66
+ /*.jpeg
67
+
63
68
  # YC application session exports (founder voice + redacted creds — never commit)
64
69
  docs/yc-q18/
65
70
  docs/yc-application-session-preface.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.6.0
3
+ Version: 0.8.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.6.0"
7
+ version = "0.8.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
@@ -52,6 +53,9 @@ from .security_events import SecurityEventService as SecurityEventSvc
52
53
  from .skills import SkillService
53
54
  from .identity import IdentityService
54
55
  from .intents import IntentsService
56
+ from .missions import MissionsService
57
+ from .llm import LlmService
58
+ from .passport_session import PassportSession
55
59
  from .types import (
56
60
  Agent,
57
61
  Passport,
@@ -216,6 +220,35 @@ class Stack:
216
220
  self.skills = SkillService(self._client)
217
221
  self.identity = IdentityService(self._client)
218
222
  self.intents = IntentsService(self._client)
223
+ self.missions = MissionsService(self._client)
224
+ self.llm = LlmService(self._client)
225
+
226
+ def issue_passport(
227
+ self,
228
+ *,
229
+ agent_id: str,
230
+ services: list[dict[str, Any]] | None = None,
231
+ intents: list[dict[str, Any]] | None = None,
232
+ ttl_seconds: int | None = None,
233
+ accountability_mode: str | None = None,
234
+ **kwargs: Any,
235
+ ) -> PassportSession:
236
+ """Phase 5.2.6 — issue a passport and return a PassportSession
237
+ that holds the token + auto-refreshes ~60s before expiry. Pass
238
+ the returned session to LLM calls instead of threading a raw
239
+ token per call::
240
+
241
+ session = stack.issue_passport(agent_id="agt_x", services=[...])
242
+ for i in range(1000):
243
+ stack.llm.anthropic.messages.create(body, passport=session)
244
+ """
245
+ issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
246
+ if services is not None: issue_input["services"] = services
247
+ if intents is not None: issue_input["intents"] = intents
248
+ if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
249
+ if accountability_mode is not None: issue_input["accountability_mode"] = accountability_mode
250
+ issued = self._client.post("/v1/passports/issue", json=issue_input)
251
+ return PassportSession(self._client, issued, issue_input)
219
252
 
220
253
  @classmethod
221
254
  def from_session(cls, session_token: str, **kwargs) -> Stack:
@@ -326,6 +359,9 @@ class AsyncStack:
326
359
  from ._async.skills import AsyncSkillService
327
360
  from ._async.identity import AsyncIdentityService
328
361
  from ._async.intents import AsyncIntentsService
362
+ from ._async.missions import AsyncMissionsService
363
+ from ._async.llm import AsyncLlmService
364
+ from ._async.passport_session import AsyncPassportSession
329
365
 
330
366
  self.agents = AsyncAgentService(self._client)
331
367
  self.passports = AsyncPassportService(self._client)
@@ -341,6 +377,30 @@ class AsyncStack:
341
377
  self.skills = AsyncSkillService(self._client)
342
378
  self.identity = AsyncIdentityService(self._client)
343
379
  self.intents = AsyncIntentsService(self._client)
380
+ self.missions = AsyncMissionsService(self._client)
381
+ self.llm = AsyncLlmService(self._client)
382
+ # Stash for issue_passport() — Python doesn't easily expose the
383
+ # class binding without re-importing inside the method body.
384
+ self._AsyncPassportSession = AsyncPassportSession
385
+
386
+ async def issue_passport(
387
+ self,
388
+ *,
389
+ agent_id: str,
390
+ services: list[dict[str, Any]] | None = None,
391
+ intents: list[dict[str, Any]] | None = None,
392
+ ttl_seconds: int | None = None,
393
+ accountability_mode: str | None = None,
394
+ **kwargs: Any,
395
+ ):
396
+ """Phase 5.2.6 — async passport issue + session wrapper."""
397
+ issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
398
+ if services is not None: issue_input["services"] = services
399
+ if intents is not None: issue_input["intents"] = intents
400
+ if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
401
+ if accountability_mode is not None: issue_input["accountability_mode"] = accountability_mode
402
+ issued = await self._client.post("/v1/passports/issue", json=issue_input)
403
+ return self._AsyncPassportSession(self._client, issued, issue_input)
344
404
 
345
405
  @classmethod
346
406
  def from_session(cls, session_token: str, **kwargs) -> AsyncStack:
@@ -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,110 @@
1
+ """Async mirror of MissionsService. Same surface, async/await."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..client import AsyncHttpClient
8
+
9
+
10
+ class AsyncMissionsService:
11
+ def __init__(self, client: AsyncHttpClient):
12
+ self._client = client
13
+
14
+ async def open(
15
+ self,
16
+ intent_summary: str,
17
+ *,
18
+ intent_services: list[str] | None = None,
19
+ expected_counterparties: list[str] | None = None,
20
+ baseline_profile: dict[str, Any] | None = None,
21
+ expected_output_shape: dict[str, Any] | None = None,
22
+ max_actions: int | None = None,
23
+ max_dropoff_bytes: int | None = None,
24
+ coverage_cap_cents: int | None = None,
25
+ max_duration_seconds: int | None = None,
26
+ max_concurrent_intents: int | None = None,
27
+ max_actions_per_service: dict[str, int] | None = None,
28
+ cap_behavior: dict[str, str] | None = None,
29
+ expires_in_seconds: int | None = None,
30
+ parent_passport_services: list[str] | None = None,
31
+ ) -> dict[str, Any]:
32
+ body: dict[str, Any] = {"intent_summary": intent_summary}
33
+ if intent_services is not None:
34
+ body["intent_services"] = intent_services
35
+ if expected_counterparties is not None:
36
+ body["expected_counterparties"] = expected_counterparties
37
+ if baseline_profile is not None:
38
+ body["baseline_profile"] = baseline_profile
39
+ if expected_output_shape is not None:
40
+ body["expected_output_shape"] = expected_output_shape
41
+ if max_actions is not None:
42
+ body["max_actions"] = max_actions
43
+ if max_dropoff_bytes is not None:
44
+ body["max_dropoff_bytes"] = max_dropoff_bytes
45
+ if coverage_cap_cents is not None:
46
+ body["coverage_cap_cents"] = coverage_cap_cents
47
+ if max_duration_seconds is not None:
48
+ body["max_duration_seconds"] = max_duration_seconds
49
+ if max_concurrent_intents is not None:
50
+ body["max_concurrent_intents"] = max_concurrent_intents
51
+ if max_actions_per_service is not None:
52
+ body["max_actions_per_service"] = max_actions_per_service
53
+ if cap_behavior is not None:
54
+ body["cap_behavior"] = cap_behavior
55
+ if expires_in_seconds is not None:
56
+ body["expires_in_seconds"] = expires_in_seconds
57
+ if parent_passport_services is not None:
58
+ body["parent_passport_services"] = parent_passport_services
59
+ return await self._client.request("POST", "/v1/missions", json=body)
60
+
61
+ async def complete(
62
+ self,
63
+ mission_id: str,
64
+ *,
65
+ output: dict[str, Any] | None = None,
66
+ ) -> dict[str, Any]:
67
+ body: dict[str, Any] = {}
68
+ if output is not None:
69
+ body["output"] = output
70
+ return await self._client.request(
71
+ "POST", f"/v1/missions/{mission_id}/complete", json=body
72
+ )
73
+
74
+ async def revoke(
75
+ self,
76
+ mission_id: str,
77
+ *,
78
+ reason: str,
79
+ notes: str | None = None,
80
+ ) -> dict[str, Any]:
81
+ body: dict[str, Any] = {"reason": reason}
82
+ if notes is not None:
83
+ body["notes"] = notes
84
+ return await self._client.request(
85
+ "POST", f"/v1/missions/{mission_id}/revoke", json=body
86
+ )
87
+
88
+ async def get(self, mission_id: str) -> dict[str, Any]:
89
+ return await self._client.request("GET", f"/v1/missions/{mission_id}")
90
+
91
+ async def list(
92
+ self,
93
+ *,
94
+ status: str | None = None,
95
+ page: int | None = None,
96
+ limit: int | None = None,
97
+ ) -> dict[str, Any]:
98
+ params: dict[str, Any] = {}
99
+ if status is not None:
100
+ params["status"] = status
101
+ if page is not None:
102
+ params["page"] = page
103
+ if limit is not None:
104
+ params["limit"] = limit
105
+ path = "/v1/missions"
106
+ if params:
107
+ from urllib.parse import urlencode
108
+
109
+ path = f"{path}?{urlencode(params)}"
110
+ return await self._client.request("GET", path)
@@ -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,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)
@@ -0,0 +1,130 @@
1
+ """Mission lifecycle — open / complete / revoke / get / list.
2
+
3
+ Phase 5.0 sub-chunk 5.0.8.
4
+
5
+ open() Open a new mission. Returns mission_id (ms_*) + the
6
+ signed mission_open claim_id.
7
+ complete() Mark a mission complete (terminal_state='completed_successful').
8
+ Idempotent; second call returns the prior terminal state.
9
+ revoke() Revoke a mission. reason='adversarial' triggers forward
10
+ cascade (passports bound to the mission also revoke);
11
+ reason='operator_initiated_clean' leaves passports alive.
12
+ get() Fetch full mission view by id.
13
+ list() List missions for the calling operator.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .client import HttpClient
21
+
22
+
23
+ class MissionsService:
24
+ def __init__(self, client: HttpClient):
25
+ self._client = client
26
+
27
+ def open(
28
+ self,
29
+ intent_summary: str,
30
+ *,
31
+ intent_services: list[str] | None = None,
32
+ expected_counterparties: list[str] | None = None,
33
+ baseline_profile: dict[str, Any] | None = None,
34
+ expected_output_shape: dict[str, Any] | None = None,
35
+ max_actions: int | None = None,
36
+ max_dropoff_bytes: int | None = None,
37
+ coverage_cap_cents: int | None = None,
38
+ max_duration_seconds: int | None = None,
39
+ max_concurrent_intents: int | None = None,
40
+ max_actions_per_service: dict[str, int] | None = None,
41
+ cap_behavior: dict[str, str] | None = None,
42
+ expires_in_seconds: int | None = None,
43
+ parent_passport_services: list[str] | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Open a new mission. Returns dict with mission_id, claim_id,
46
+ expires_at."""
47
+ body: dict[str, Any] = {"intent_summary": intent_summary}
48
+ if intent_services is not None:
49
+ body["intent_services"] = intent_services
50
+ if expected_counterparties is not None:
51
+ body["expected_counterparties"] = expected_counterparties
52
+ if baseline_profile is not None:
53
+ body["baseline_profile"] = baseline_profile
54
+ if expected_output_shape is not None:
55
+ body["expected_output_shape"] = expected_output_shape
56
+ if max_actions is not None:
57
+ body["max_actions"] = max_actions
58
+ if max_dropoff_bytes is not None:
59
+ body["max_dropoff_bytes"] = max_dropoff_bytes
60
+ if coverage_cap_cents is not None:
61
+ body["coverage_cap_cents"] = coverage_cap_cents
62
+ if max_duration_seconds is not None:
63
+ body["max_duration_seconds"] = max_duration_seconds
64
+ if max_concurrent_intents is not None:
65
+ body["max_concurrent_intents"] = max_concurrent_intents
66
+ if max_actions_per_service is not None:
67
+ body["max_actions_per_service"] = max_actions_per_service
68
+ if cap_behavior is not None:
69
+ body["cap_behavior"] = cap_behavior
70
+ if expires_in_seconds is not None:
71
+ body["expires_in_seconds"] = expires_in_seconds
72
+ if parent_passport_services is not None:
73
+ body["parent_passport_services"] = parent_passport_services
74
+ return self._client.request("POST", "/v1/missions", json=body)
75
+
76
+ def complete(
77
+ self,
78
+ mission_id: str,
79
+ *,
80
+ output: dict[str, Any] | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Mark a mission complete. Returns dict with mission_id, claim_id,
83
+ terminal_state, completed_at, transitioned."""
84
+ body: dict[str, Any] = {}
85
+ if output is not None:
86
+ body["output"] = output
87
+ return self._client.request(
88
+ "POST", f"/v1/missions/{mission_id}/complete", json=body
89
+ )
90
+
91
+ def revoke(
92
+ self,
93
+ mission_id: str,
94
+ *,
95
+ reason: str,
96
+ notes: str | None = None,
97
+ ) -> dict[str, Any]:
98
+ """Revoke a mission. reason in {'adversarial', 'operator_initiated_clean'}."""
99
+ body: dict[str, Any] = {"reason": reason}
100
+ if notes is not None:
101
+ body["notes"] = notes
102
+ return self._client.request(
103
+ "POST", f"/v1/missions/{mission_id}/revoke", json=body
104
+ )
105
+
106
+ def get(self, mission_id: str) -> dict[str, Any]:
107
+ """Fetch a mission by id. 404 for unknown or cross-tenant."""
108
+ return self._client.request("GET", f"/v1/missions/{mission_id}")
109
+
110
+ def list(
111
+ self,
112
+ *,
113
+ status: str | None = None,
114
+ page: int | None = None,
115
+ limit: int | None = None,
116
+ ) -> dict[str, Any]:
117
+ """List missions for the calling operator. Returns dict with `items`."""
118
+ params: dict[str, Any] = {}
119
+ if status is not None:
120
+ params["status"] = status
121
+ if page is not None:
122
+ params["page"] = page
123
+ if limit is not None:
124
+ params["limit"] = limit
125
+ path = "/v1/missions"
126
+ if params:
127
+ from urllib.parse import urlencode
128
+
129
+ path = f"{path}?{urlencode(params)}"
130
+ return self._client.request("GET", path)
@@ -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