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.
- {getstack-0.6.0 → getstack-0.8.0}/.gitignore +5 -0
- {getstack-0.6.0 → getstack-0.8.0}/PKG-INFO +1 -1
- {getstack-0.6.0 → getstack-0.8.0}/pyproject.toml +1 -1
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/__init__.py +60 -0
- getstack-0.8.0/src/getstack/_async/llm.py +202 -0
- getstack-0.8.0/src/getstack/_async/missions.py +110 -0
- getstack-0.8.0/src/getstack/_async/passport_session.py +45 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/client.py +44 -2
- getstack-0.8.0/src/getstack/llm.py +265 -0
- getstack-0.8.0/src/getstack/missions.py +130 -0
- getstack-0.8.0/src/getstack/passport_session.py +71 -0
- {getstack-0.6.0 → getstack-0.8.0}/CLAUDE.md +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/LICENSE +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/README.md +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/agents.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/audit.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/identity.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/intents.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/passports.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/proxy.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/scan.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/services.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/_async/skills.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/agent_auth.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/agents.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/audit.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/auth.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/credentials.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/dropoffs.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/errors.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/identity.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/intents.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/notifications.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/passports.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/proxy.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/py.typed +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/reviews.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/scan.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/security_events.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/services.py +0 -0
- {getstack-0.6.0 → getstack-0.8.0}/src/getstack/skills.py +0 -0
- {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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|