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