elan-sdk 0.1.0__py3-none-any.whl

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.
elan_sdk/__init__.py ADDED
@@ -0,0 +1,175 @@
1
+ """The Elan API, as a developer's backend calls it.
2
+
3
+ from elan_sdk import AsyncElan
4
+
5
+ async with AsyncElan.from_env() as elan:
6
+ print((await elan.whoami())["tenant_id"])
7
+
8
+ ONE BASE URL AND ONE CREDENTIAL. Two services sit behind that host, split by
9
+ path, and the ledger and the action store are not publicly routed at all —
10
+ which is the client's problem and not yours.
11
+
12
+ WHAT THIS DOES NOT COVER, named here because it is the first question: projects,
13
+ deployments, secrets, validation and service users belong to the `elan` CLI and
14
+ are documented in `docs/developer-docs/api.md`. This is the runtime, the money
15
+ and the approvals — the calls a product makes while it is serving a request.
16
+ """
17
+
18
+ from elan_sdk._version import __version__
19
+ from elan_sdk.artifacts import UploadTicket
20
+ from elan_sdk.auth import ClientCredentials, StaticToken, TokenSource, claims_of
21
+ from elan_sdk.client import AsyncElan
22
+ from elan_sdk.config import Config
23
+ from elan_sdk.errors import (
24
+ BadRequest,
25
+ Conflict,
26
+ CredentialsRefused,
27
+ DependencyRefused,
28
+ ElanError,
29
+ EndUserEventsElsewhere,
30
+ Forbidden,
31
+ NotFound,
32
+ NotWired,
33
+ PaymentRequired,
34
+ Problem,
35
+ ProjectNotNamed,
36
+ ServerError,
37
+ StreamClosed,
38
+ StreamError,
39
+ StreamRefused,
40
+ TicketIsNotACredential,
41
+ TicketRefused,
42
+ TransportError,
43
+ Unauthenticated,
44
+ Unavailable,
45
+ )
46
+ from elan_sdk.models.actions import (
47
+ Action,
48
+ ActionBatch,
49
+ ActionPage,
50
+ BatchOutcome,
51
+ Decision,
52
+ Label,
53
+ Policy,
54
+ PolicyScopes,
55
+ StoredPolicy,
56
+ Ticket,
57
+ ToolCall,
58
+ ToolOutcome,
59
+ )
60
+ from elan_sdk.models.events import (
61
+ CONTENT_KINDS,
62
+ KNOWN_KINDS,
63
+ PROGRESS_KINDS,
64
+ TERMINAL_KINDS,
65
+ AgentEvent,
66
+ author_of,
67
+ dedupe_by_path,
68
+ text_of,
69
+ )
70
+ from elan_sdk.models.money import (
71
+ Balance,
72
+ ChargedRun,
73
+ CostRow,
74
+ HoldPage,
75
+ HoldRow,
76
+ InvocationCharge,
77
+ LatencyBucket,
78
+ LatencySeries,
79
+ ModelUsage,
80
+ MoneyBucket,
81
+ MoneySeries,
82
+ RunBucket,
83
+ RunSeries,
84
+ StatementEntry,
85
+ StatementPage,
86
+ TokenBucket,
87
+ TokenSeries,
88
+ ToolBucket,
89
+ ToolSeries,
90
+ Transaction,
91
+ UsageRow,
92
+ Wallet,
93
+ WalletPage,
94
+ )
95
+ from elan_sdk.money import MICROS_PER_USD, dollars, micros
96
+ from elan_sdk.sync import Elan
97
+
98
+ __all__ = [
99
+ "Action",
100
+ "ActionBatch",
101
+ "ActionPage",
102
+ "AgentEvent",
103
+ "AsyncElan",
104
+ "BadRequest",
105
+ "Balance",
106
+ "BatchOutcome",
107
+ "CONTENT_KINDS",
108
+ "ChargedRun",
109
+ "ClientCredentials",
110
+ "Config",
111
+ "Conflict",
112
+ "CostRow",
113
+ "CredentialsRefused",
114
+ "Decision",
115
+ "DependencyRefused",
116
+ "Elan",
117
+ "ElanError",
118
+ "EndUserEventsElsewhere",
119
+ "Forbidden",
120
+ "HoldPage",
121
+ "HoldRow",
122
+ "InvocationCharge",
123
+ "KNOWN_KINDS",
124
+ "Label",
125
+ "LatencyBucket",
126
+ "LatencySeries",
127
+ "MICROS_PER_USD",
128
+ "ModelUsage",
129
+ "MoneyBucket",
130
+ "MoneySeries",
131
+ "NotFound",
132
+ "NotWired",
133
+ "PROGRESS_KINDS",
134
+ "PaymentRequired",
135
+ "Policy",
136
+ "PolicyScopes",
137
+ "Problem",
138
+ "ProjectNotNamed",
139
+ "RunBucket",
140
+ "RunSeries",
141
+ "ServerError",
142
+ "StatementEntry",
143
+ "StatementPage",
144
+ "StaticToken",
145
+ "StoredPolicy",
146
+ "StreamClosed",
147
+ "StreamError",
148
+ "StreamRefused",
149
+ "TERMINAL_KINDS",
150
+ "Ticket",
151
+ "UploadTicket",
152
+ "TicketIsNotACredential",
153
+ "TicketRefused",
154
+ "TokenBucket",
155
+ "TokenSeries",
156
+ "TokenSource",
157
+ "ToolBucket",
158
+ "ToolCall",
159
+ "ToolOutcome",
160
+ "ToolSeries",
161
+ "Transaction",
162
+ "TransportError",
163
+ "Unauthenticated",
164
+ "Unavailable",
165
+ "UsageRow",
166
+ "Wallet",
167
+ "WalletPage",
168
+ "__version__",
169
+ "author_of",
170
+ "claims_of",
171
+ "dedupe_by_path",
172
+ "dollars",
173
+ "micros",
174
+ "text_of",
175
+ ]
elan_sdk/_tls.py ADDED
@@ -0,0 +1,35 @@
1
+ """A TLS context that trusts the same roots every HTTP call here does.
2
+
3
+ MOVED FROM `elan_cli/stream.py`, WHICH FOUND THIS THE HARD WAY. macOS's
4
+ python.org builds ship NO CA BUNDLE. OpenSSL's default path finds nothing, so a
5
+ perfectly valid Let's Encrypt certificate fails with
6
+
7
+ CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate
8
+
9
+ while `curl` and `openssl s_client` accept it on the same machine — and every
10
+ HTTP call in this SDK works, because httpx bundles certifi and `websockets`
11
+ uses the system default. So the symptom is ONE FEATURE failing on TLS while
12
+ everything else is fine, which reads as a server problem.
13
+
14
+ `scripts/zitadel_configure.py` carries the same note for the same reason. This
15
+ is that lesson arriving in a third place, which is why it is a module rather
16
+ than a line.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+
22
+ def trust() -> object | None:
23
+ """A certifi-backed SSL context, or None to fall back to the system's."""
24
+ import ssl # noqa: PLC0415
25
+
26
+ try:
27
+ import certifi # noqa: PLC0415
28
+ except ImportError: # pragma: no cover - certifi is a declared dependency
29
+ # The system default is still worth trying: on Linux it is correct, and
30
+ # failing outright here would be worse than failing at the handshake.
31
+ return None
32
+ return ssl.create_default_context(cafile=certifi.where())
33
+
34
+
35
+ __all__ = ["trust"]
elan_sdk/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """One version number, two registries.
2
+
3
+ The npm package publishes the same string, and
4
+ `tests/test_sdk_packaging.py` asserts they agree — because a developer reading
5
+ two SDKs' versions should be able to tell whether they describe the same API.
6
+ """
7
+
8
+ __version__ = "0.1.0"
elan_sdk/actions.py ADDED
@@ -0,0 +1,253 @@
1
+ """Tool approvals — and the batch, which is the thing to get right.
2
+
3
+ THE NEXT MESSAGE RESOLVES EVERY CONFIRMATION A TURN RAISED. ADK keeps no notion
4
+ of answering one of three, so deciding one and leaving the rest silently
5
+ abandons them while the run reports finished — and sending a plain message
6
+ abandons all of them with nothing said. So a turn's answers travel as ONE batch,
7
+ published only when its last member is decided.
8
+
9
+ `decide_batch` IS THEREFORE THE METHOD TO REACH FOR, and `decide` is honest
10
+ about what it returns: `Decision.published` is false until the batch completes,
11
+ which is the only way to know whether anything actually ran.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import Any, Literal
18
+
19
+ from elan_sdk.models.actions import (
20
+ Action,
21
+ ActionBatch,
22
+ ActionPage,
23
+ BatchOutcome,
24
+ Decision,
25
+ Label,
26
+ action_from,
27
+ )
28
+
29
+
30
+ class Actions:
31
+ """What is waiting on a person, and answering it."""
32
+
33
+ def __init__(self, http, config=None) -> None:
34
+ self._http = http
35
+ self._config = config
36
+
37
+ def _project(self, project: str | None) -> str:
38
+ """WHICH OF THE TENANT'S PRODUCTS. Narrows; the credential authorises.
39
+
40
+ An agent slug is unique within a PROJECT and not within a tenant
41
+ (ADR-0054), so without this one inbox holds every project's
42
+ confirmations for the same target. Defaults to the client's own
43
+ project; pass `""` to see every project the credential may see. A
44
+ credential issued for one project is narrowed to it server-side
45
+ whether it asked or not, and naming a sibling is a `Forbidden`.
46
+ """
47
+ if project is not None:
48
+ return project
49
+ return getattr(self._config, "project", "") or ""
50
+
51
+ async def list(self, *, project: str | None = None, status: str = "pending",
52
+ labels: Sequence[str] = (), target: str = "",
53
+ session_id: str = "",
54
+ scope: Literal["mine", "tenant"] = "mine",
55
+ automatic: bool | None = None,
56
+ kind: Literal["confirmation", "tool", "approval",
57
+ "all"] = "confirmation",
58
+ user: str = "",
59
+ limit: int = 100, offset: int = 0) -> ActionPage:
60
+ """Pending work, filtered.
61
+
62
+ `kind` DEFAULTS TO CONFIRMATIONS, which is a choice about what an inbox
63
+ is for: this store also holds long-running tool calls, whose answer is
64
+ a system's result rather than a person's judgement, and a queue mixing
65
+ the two is a queue nobody can act on. Those are `elan.tools`.
66
+
67
+ THERE ARE THREE KINDS, and `approval` was missing from this annotation
68
+ while the store accepted it throughout — so the one kind a developer
69
+ could not list through a typed client was the one with no namespace of
70
+ its own either. Those are `elan.approvals` now. `all` is every kind,
71
+ which the store accepts and anything else it refuses.
72
+
73
+ `automatic` IS TRI-STATE. None is every row, False is "only what a
74
+ person decided", True is "only what policy decided" — so a bool
75
+ default would delete one of the three.
76
+
77
+ LABELS COMBINE AS ANY-OF, because a category filter that narrowed to
78
+ the intersection usually shows nothing at all.
79
+
80
+ `user` NAMES ONE OF YOUR OWN END USERS as `eu:<id>`. It contradicts
81
+ `scope="tenant"`, which means every user, so sending both is a
82
+ `BadRequest` rather than a silent precedence.
83
+
84
+ `limit` REALLY IS THE LIMIT NOW. It was sent and dropped in transit, so
85
+ every caller received at most 100 rows beside a `total` reporting the
86
+ truth; `offset` walks the rest and `ActionPage.more` says whether to.
87
+ """
88
+ params: dict[str, Any] = {"project": self._project(project),
89
+ "status": status, "scope": scope,
90
+ "kind": kind, "limit": limit,
91
+ "offset": offset, "user": user,
92
+ "target": target, "session_id": session_id}
93
+ if automatic is not None:
94
+ params["automatic"] = automatic
95
+ if labels:
96
+ # A REPEATED PARAMETER AND NOT A JOINED ONE. The route declares
97
+ # `label: list[str] = Query(default=[])`, so `?label=a&label=b` is
98
+ # what it reads — a comma-joined value would arrive as one label
99
+ # named "a,b" and match nothing, silently.
100
+ params["label"] = list(labels)
101
+ answer = await self._http.get("/v1/actions", params=params)
102
+ rows = answer.get("actions", [])
103
+ return ActionPage(actions=tuple(action_from(row) for row in rows),
104
+ total=int(answer.get("total", len(rows))),
105
+ limit=int(answer.get("limit", limit)),
106
+ offset=int(answer.get("offset", offset)))
107
+
108
+ async def labels(self, *, scope: Literal["mine", "tenant"] = "mine",
109
+ project: str | None = None,
110
+ user: str = "") -> tuple[Label, ...]:
111
+ """Which categories have work waiting, and how much.
112
+
113
+ THE ROUTE ANSWERS A BARE LIST where `/v1/actions` answers an object.
114
+ Normalised here so a caller never has to know which.
115
+ """
116
+ rows = await self._http.get(
117
+ "/v1/actions/labels",
118
+ params={"scope": scope, "project": self._project(project),
119
+ "user": user})
120
+ return tuple(Label(label=row.get("label", ""),
121
+ pending=int(row.get("pending", 0)))
122
+ for row in rows or ())
123
+
124
+ async def get(self, action_id: str) -> Action | None:
125
+ """One action, or None.
126
+
127
+ VIA THE LIST, BECAUSE NO SINGLE-ACTION ROUTE IS PUBLISHED. rt-service
128
+ proxies the list, the labels and the decision, and nothing else — so
129
+ this is a filter rather than a fetch, and says so.
130
+ """
131
+ page = await self.list(status="all", kind="all", limit=500)
132
+ for action in page.actions:
133
+ if action.action_id == action_id:
134
+ return action
135
+ return None
136
+
137
+ async def batches(self, *, project: str | None = None,
138
+ session_id: str = "", target: str = "",
139
+ status: str = "pending",
140
+ kind: Literal["confirmation", "tool", "all"] = "all",
141
+ scope: Literal["mine", "tenant"] = "mine",
142
+ user: str = "") -> tuple[ActionBatch, ...]:
143
+ """Pending work GROUPED BY TURN.
144
+
145
+ THE API HAS NO BATCH FILTER AND THE GROUPING IS WHAT A CALLER MUST NOT
146
+ GET WRONG, so it is done here rather than left as an exercise: every
147
+ member of a turn has to be answered before any of them takes effect.
148
+
149
+ `kind` DEFAULTS TO EVERYTHING HERE, unlike `list()`, because a batch is
150
+ a unit of work rather than an inbox — and a mixed turn's confirmation
151
+ half is meaningless without knowing a tool call is also outstanding.
152
+
153
+ `scope` AND `user` WERE MISSING HERE while `list()` and `labels()` both
154
+ took them, which made an admin's tenant-wide view ungroupable and made
155
+ this method unusable for the shape the SDK is built around — a backend
156
+ answering for its end users, whose confirmations are recorded against
157
+ `eu:<id>` and which `scope="mine"` therefore never finds. TypeScript
158
+ gained `scope` and this did not; both have all three now.
159
+ """
160
+ per_page = 500
161
+ found: list[Action] = []
162
+ offset = 0
163
+ while True:
164
+ # EVERY PAGE, NOT THE FIRST. Grouping is the whole point here, and
165
+ # a turn straddling a page boundary would be grouped from whichever
166
+ # members landed on it — then handed to `decide_batch`, which would
167
+ # answer those and silently abandon the rest. That is the exact
168
+ # failure this type exists to prevent, so it cannot depend on
169
+ # whether a caller remembered to page.
170
+ page = await self.list(project=project, status=status, target=target,
171
+ session_id=session_id, kind=kind, scope=scope,
172
+ user=user,
173
+ limit=per_page, offset=offset)
174
+ found.extend(page.actions)
175
+ if not page.actions or not page.more:
176
+ break
177
+ offset += per_page
178
+ grouped: dict[str, list[Action]] = {}
179
+ for action in found:
180
+ grouped.setdefault(action.batch_id, []).append(action)
181
+ return tuple(
182
+ ActionBatch(batch_id=batch_id,
183
+ invocation_id=members[0].invocation_id,
184
+ session_id=members[0].session_id,
185
+ target_slug=members[0].target_slug,
186
+ actions=tuple(members))
187
+ for batch_id, members in grouped.items())
188
+
189
+ async def decide(self, action_id: str, *, approved: bool, note: str = "",
190
+ payload: Any = None,
191
+ approve_all: bool = False,
192
+ user: str = "") -> Decision:
193
+ """Answer ONE confirmation.
194
+
195
+ PREFER `decide_batch` UNLESS YOU KNOW THE TURN RAISED ONE. Answering
196
+ one of two silently abandons the other, and this method cannot tell you
197
+ that happened — only `published` tells you whether the batch completed.
198
+
199
+ `approve_all` WRITES A STANDING GRANT so the agent stops asking, and it
200
+ is recorded against the RUN'S OWNER rather than the decider: an admin
201
+ approving somebody else's card must not quietly grant themselves
202
+ autonomy while leaving the colleague asked about every call.
203
+
204
+ `user` NAMES THE END USER THIS CARD BELONGS TO, for a backend answering
205
+ on their behalf. It authorises the call and does not change
206
+ `decided_by`, which is always the identity that really decided.
207
+ """
208
+ body: dict[str, Any] = {"approved": approved, "note": note,
209
+ "payload": payload, "approve_all": approve_all}
210
+ if user:
211
+ body["user"] = user
212
+ answer = await self._http.post(
213
+ f"/v1/actions/{action_id}/decide", json=body)
214
+ return _decision(answer)
215
+
216
+ async def decide_batch(self, batch: ActionBatch | Sequence[str], *,
217
+ approved: bool, note: str = "", payload: Any = None,
218
+ approve_all: bool = False,
219
+ user: str = "") -> BatchOutcome:
220
+ """Answer a whole turn, in order, and report what happened.
221
+
222
+ THE LAST ANSWER IS THE ONE THAT PUBLISHES, so these go one at a time
223
+ and the outcome carries every decision — including any the engine
224
+ recorded as abandoned, which can follow an approval and means NOTHING
225
+ RAN.
226
+ """
227
+ ids = (list(batch.action_ids) if isinstance(batch, ActionBatch)
228
+ else list(batch))
229
+ decisions: list[Decision] = []
230
+ for action_id in ids:
231
+ decisions.append(await self.decide(
232
+ action_id, approved=approved, note=note, payload=payload,
233
+ approve_all=approve_all, user=user))
234
+ last = decisions[-1] if decisions else None
235
+ return BatchOutcome(
236
+ batch_id=last.batch_id if last else "",
237
+ decisions=tuple(decisions),
238
+ complete=bool(last and last.batch_complete),
239
+ published=any(d.published for d in decisions))
240
+
241
+
242
+ def _decision(answer: dict[str, Any]) -> Decision:
243
+ return Decision(
244
+ action_id=answer.get("action_id", ""),
245
+ status=answer.get("status", ""),
246
+ batch_id=answer.get("batch_id", ""),
247
+ batch_complete=bool(answer.get("batch_complete")),
248
+ published=bool(answer.get("published")),
249
+ rule_id=answer.get("rule_id"),
250
+ )
251
+
252
+
253
+ __all__ = ["Actions"]
elan_sdk/agents.py ADDED
@@ -0,0 +1,44 @@
1
+ """What this tenant has deployed.
2
+
3
+ A READ, AND ONLY A READ. Deploying is the CLI's and the console's business
4
+ (ADR-0104 scoped projects and deployments out of this SDK); what a backend
5
+ needs is what is running right now and what it declares — to render a card, or
6
+ to explain to one of your users why a permission they granted did not take
7
+ effect.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from elan_sdk.models.agents import DeployedAgent, agent_from
13
+
14
+
15
+ class Agents:
16
+ """Reading what is deployed, and what each target declares."""
17
+
18
+ def __init__(self, http, config) -> None:
19
+ self._http = http
20
+ self._config = config
21
+
22
+ async def list(self, *, project: str | None = None) -> list[DeployedAgent]:
23
+ """Every target your credential may see.
24
+
25
+ `project` DEFAULTS TO THE CLIENT'S OWN, and `""` means every project
26
+ the credential may see — the same convention as `actions.list()`.
27
+ """
28
+ wanted = self._config.project if project is None else project
29
+ answer = await self._http.get("/v1/agents", params={"project": wanted})
30
+ return [agent_from(row) for row in (answer or {}).get("agents") or ()]
31
+
32
+ async def get(self, slug: str, *,
33
+ project: str | None = None) -> DeployedAgent | None:
34
+ """One target, or None.
35
+
36
+ NONE RATHER THAN AN EXCEPTION, because "is this deployed" is a question
37
+ with a negative answer. There is no single-agent route, so this filters
38
+ the listing — which is also why it cannot tell "not deployed" from "not
39
+ yours", and must not, since the listing already collapses the two.
40
+ """
41
+ for agent in await self.list(project=project):
42
+ if agent.slug == slug:
43
+ return agent
44
+ return None