getstack 0.7.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.7.0 → getstack-0.8.0}/PKG-INFO +1 -1
- {getstack-0.7.0 → getstack-0.8.0}/pyproject.toml +1 -1
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/__init__.py +56 -0
- getstack-0.8.0/src/getstack/_async/llm.py +202 -0
- getstack-0.8.0/src/getstack/_async/passport_session.py +45 -0
- {getstack-0.7.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/passport_session.py +71 -0
- {getstack-0.7.0 → getstack-0.8.0}/.gitignore +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/CLAUDE.md +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/LICENSE +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/README.md +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/agents.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/audit.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/identity.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/intents.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/missions.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/passports.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/proxy.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/scan.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/services.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/_async/skills.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/agent_auth.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/agents.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/audit.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/auth.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/credentials.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/dropoffs.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/errors.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/identity.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/intents.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/missions.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/notifications.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/passports.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/proxy.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/py.typed +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/reviews.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/scan.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/security_events.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/services.py +0 -0
- {getstack-0.7.0 → getstack-0.8.0}/src/getstack/skills.py +0 -0
- {getstack-0.7.0 → getstack-0.8.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
|
|
@@ -53,6 +54,8 @@ from .skills import SkillService
|
|
|
53
54
|
from .identity import IdentityService
|
|
54
55
|
from .intents import IntentsService
|
|
55
56
|
from .missions import MissionsService
|
|
57
|
+
from .llm import LlmService
|
|
58
|
+
from .passport_session import PassportSession
|
|
56
59
|
from .types import (
|
|
57
60
|
Agent,
|
|
58
61
|
Passport,
|
|
@@ -218,6 +221,34 @@ class Stack:
|
|
|
218
221
|
self.identity = IdentityService(self._client)
|
|
219
222
|
self.intents = IntentsService(self._client)
|
|
220
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)
|
|
221
252
|
|
|
222
253
|
@classmethod
|
|
223
254
|
def from_session(cls, session_token: str, **kwargs) -> Stack:
|
|
@@ -329,6 +360,8 @@ class AsyncStack:
|
|
|
329
360
|
from ._async.identity import AsyncIdentityService
|
|
330
361
|
from ._async.intents import AsyncIntentsService
|
|
331
362
|
from ._async.missions import AsyncMissionsService
|
|
363
|
+
from ._async.llm import AsyncLlmService
|
|
364
|
+
from ._async.passport_session import AsyncPassportSession
|
|
332
365
|
|
|
333
366
|
self.agents = AsyncAgentService(self._client)
|
|
334
367
|
self.passports = AsyncPassportService(self._client)
|
|
@@ -345,6 +378,29 @@ class AsyncStack:
|
|
|
345
378
|
self.identity = AsyncIdentityService(self._client)
|
|
346
379
|
self.intents = AsyncIntentsService(self._client)
|
|
347
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)
|
|
348
404
|
|
|
349
405
|
@classmethod
|
|
350
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,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,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
|
|
File without changes
|