simpleflow-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.
@@ -0,0 +1,37 @@
1
+ from .auth import InvokeTokenVerifier
2
+ from .client import (
3
+ SimpleFlowAuthenticationError,
4
+ SimpleFlowAuthorizationError,
5
+ SimpleFlowClient,
6
+ SimpleFlowLifecycleError,
7
+ SimpleFlowRequestError,
8
+ TelemetryBridge,
9
+ )
10
+ from .contracts import (
11
+ ChatHistoryMessage,
12
+ ChatMessageWrite,
13
+ InvokeTrace,
14
+ QueueContract,
15
+ RuntimeEvent,
16
+ RuntimeRegistration,
17
+ TelemetrySpan,
18
+ WorkflowTraceTenant,
19
+ )
20
+
21
+ __all__ = [
22
+ "SimpleFlowClient",
23
+ "TelemetryBridge",
24
+ "SimpleFlowRequestError",
25
+ "SimpleFlowAuthenticationError",
26
+ "SimpleFlowAuthorizationError",
27
+ "SimpleFlowLifecycleError",
28
+ "InvokeTokenVerifier",
29
+ "RuntimeEvent",
30
+ "InvokeTrace",
31
+ "ChatHistoryMessage",
32
+ "ChatMessageWrite",
33
+ "QueueContract",
34
+ "RuntimeRegistration",
35
+ "TelemetrySpan",
36
+ "WorkflowTraceTenant",
37
+ ]
simpleflow_sdk/auth.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import jwt
6
+
7
+
8
+ class InvokeTokenVerifier:
9
+ """Verify invoke tokens using HS256 shared keys or RS256 public keys.
10
+
11
+ You can pass a verification key per call via ``verify(token, key=...)`` or
12
+ configure a default key using ``for_hs256_shared_key`` / ``for_rs256_public_key``.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ issuer: str,
18
+ audience: str,
19
+ algorithms: list[str] | None = None,
20
+ verification_key: str | bytes | Any | None = None,
21
+ ) -> None:
22
+ if issuer.strip() == "":
23
+ raise ValueError("simpleflow sdk auth config error: issuer is required")
24
+ if audience.strip() == "":
25
+ raise ValueError("simpleflow sdk auth config error: audience is required")
26
+
27
+ self._issuer = issuer
28
+ self._audience = audience
29
+ self._algorithms = algorithms if algorithms is not None else ["HS256", "RS256"]
30
+ self._verification_key = verification_key
31
+
32
+ @classmethod
33
+ def for_hs256_shared_key(
34
+ cls,
35
+ *,
36
+ issuer: str,
37
+ audience: str,
38
+ shared_key: str | bytes,
39
+ ) -> "InvokeTokenVerifier":
40
+ return cls(
41
+ issuer=issuer,
42
+ audience=audience,
43
+ algorithms=["HS256"],
44
+ verification_key=shared_key,
45
+ )
46
+
47
+ @classmethod
48
+ def for_rs256_public_key(
49
+ cls,
50
+ *,
51
+ issuer: str,
52
+ audience: str,
53
+ public_key: str | bytes,
54
+ ) -> "InvokeTokenVerifier":
55
+ return cls(
56
+ issuer=issuer,
57
+ audience=audience,
58
+ algorithms=["RS256"],
59
+ verification_key=public_key,
60
+ )
61
+
62
+ def verify(
63
+ self, token: str, key: str | bytes | Any | None = None
64
+ ) -> dict[str, Any]:
65
+ if token.strip() == "":
66
+ raise ValueError("simpleflow sdk auth error: token is required")
67
+ verification_key = key if key is not None else self._verification_key
68
+ if verification_key is None:
69
+ raise ValueError("simpleflow sdk auth error: verification key is required")
70
+
71
+ claims = jwt.decode(
72
+ token,
73
+ key=verification_key,
74
+ algorithms=self._algorithms,
75
+ audience=self._audience,
76
+ issuer=self._issuer,
77
+ options={"require": ["exp", "iat", "iss", "aud"]},
78
+ )
79
+
80
+ if (
81
+ str(claims.get("agent_id", "")).strip() == ""
82
+ or str(claims.get("org_id", "")).strip() == ""
83
+ ):
84
+ raise ValueError(
85
+ "simpleflow sdk auth error: token missing required agent_id or org_id"
86
+ )
87
+
88
+ return claims
@@ -0,0 +1,873 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from hashlib import sha256
5
+ from math import isfinite
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ import httpx
11
+
12
+
13
+ class SimpleFlowRequestError(RuntimeError):
14
+ def __init__(self, *, status_code: int, detail: str, path: str) -> None:
15
+ super().__init__(
16
+ f"simpleflow sdk request error: status={status_code} path={path} detail={detail}"
17
+ )
18
+ self.status_code = status_code
19
+ self.detail = detail
20
+ self.path = path
21
+
22
+
23
+ class SimpleFlowAuthenticationError(SimpleFlowRequestError):
24
+ pass
25
+
26
+
27
+ class SimpleFlowAuthorizationError(SimpleFlowRequestError):
28
+ pass
29
+
30
+
31
+ class SimpleFlowLifecycleError(SimpleFlowRequestError):
32
+ pass
33
+
34
+
35
+ def _normalize_payload(payload: Any) -> dict[str, Any]:
36
+ if payload is None:
37
+ return {}
38
+ if hasattr(payload, "__dataclass_fields__"):
39
+ return asdict(payload)
40
+ if isinstance(payload, dict):
41
+ return payload
42
+ raise TypeError(
43
+ "simpleflow sdk payload error: payload must be a dataclass, dict, or None"
44
+ )
45
+
46
+
47
+ def _validate_sample_rate(sample_rate: float | None) -> None:
48
+ if sample_rate is None:
49
+ return
50
+ if not isfinite(sample_rate) or sample_rate < 0.0 or sample_rate > 1.0:
51
+ raise ValueError(
52
+ "simpleflow sdk config error: telemetry sample_rate must be a finite value between 0.0 and 1.0"
53
+ )
54
+
55
+
56
+ def _count_workflow_events_by_type(workflow_result: dict[str, Any]) -> dict[str, int]:
57
+ events = workflow_result.get("events")
58
+ if not isinstance(events, list):
59
+ return {}
60
+
61
+ counts: dict[str, int] = {}
62
+ for event in events:
63
+ if not isinstance(event, dict):
64
+ continue
65
+ event_type = str(event.get("event_type", "")).strip()
66
+ if event_type == "":
67
+ continue
68
+ counts[event_type] = counts.get(event_type, 0) + 1
69
+ return counts
70
+
71
+
72
+ def _extract_workflow_nerdstats(
73
+ workflow_result: dict[str, Any],
74
+ ) -> dict[str, Any] | None:
75
+ events = workflow_result.get("events")
76
+ if not isinstance(events, list):
77
+ return None
78
+
79
+ for event in reversed(events):
80
+ if not isinstance(event, dict):
81
+ continue
82
+ if str(event.get("event_type", "")).strip() != "workflow_completed":
83
+ continue
84
+ metadata = event.get("metadata")
85
+ if not isinstance(metadata, dict):
86
+ continue
87
+ nerdstats = metadata.get("nerdstats")
88
+ if isinstance(nerdstats, dict):
89
+ return nerdstats
90
+ return None
91
+
92
+
93
+ def _build_trace_url(
94
+ trace_id: str, trace_ui_base_url: str = "http://localhost:16686"
95
+ ) -> str:
96
+ normalized_trace_id = trace_id.strip()
97
+ if normalized_trace_id == "":
98
+ return ""
99
+ normalized_base_url = trace_ui_base_url.strip().rstrip("/")
100
+ if normalized_base_url == "":
101
+ normalized_base_url = "http://localhost:16686"
102
+ return f"{normalized_base_url}/trace/{normalized_trace_id}"
103
+
104
+
105
+ def _should_sample(trace_id: str, sample_rate: float | None) -> bool:
106
+ if sample_rate is None:
107
+ return True
108
+ if sample_rate <= 0.0:
109
+ return False
110
+ if sample_rate >= 1.0:
111
+ return True
112
+ digest = sha256(trace_id.encode("utf-8")).digest()
113
+ value = int.from_bytes(digest[0:8], byteorder="big", signed=False)
114
+ ratio = value / ((1 << 64) - 1)
115
+ return ratio <= sample_rate
116
+
117
+
118
+ class SimpleFlowClient:
119
+ def __init__(
120
+ self,
121
+ base_url: str,
122
+ api_token: str | None = None,
123
+ oauth_client_id: str | None = None,
124
+ oauth_client_secret: str | None = None,
125
+ oauth_token_path: str = "/v1/oauth/token",
126
+ oauth_token_leeway_seconds: int = 30,
127
+ auth_sessions_path: str = "/v1/auth/sessions",
128
+ auth_current_session_path: str = "/v1/auth/sessions/current",
129
+ auth_me_path: str = "/v1/me",
130
+ runtime_register_path: str = "/v1/runtime/registrations",
131
+ runtime_invoke_path: str = "/v1/runtime/invoke",
132
+ runtime_events_path: str = "/v1/runtime/events",
133
+ runtime_activate_path: str = "/v1/runtime/registrations/{registration_id}/activate",
134
+ runtime_deactivate_path: str = "/v1/runtime/registrations/{registration_id}/deactivate",
135
+ runtime_validate_path: str = "/v1/runtime/registrations/{registration_id}/validate",
136
+ chat_messages_path: str = "/v1/runtime/chat/messages",
137
+ queue_contracts_path: str = "/v1/runtime/queue/contracts",
138
+ chat_sessions_path: str = "/v1/chat/history/sessions",
139
+ chat_history_path: str = "/v1/chat/history/messages",
140
+ timeout_seconds: float = 10.0,
141
+ ) -> None:
142
+ if base_url.strip() == "":
143
+ raise ValueError("simpleflow sdk config error: base_url is required")
144
+ self._base_url = base_url.rstrip("/")
145
+ self._api_token = api_token.strip() if api_token is not None else ""
146
+ self._oauth_client_id = (
147
+ oauth_client_id.strip() if oauth_client_id is not None else ""
148
+ )
149
+ self._oauth_client_secret = (
150
+ oauth_client_secret.strip() if oauth_client_secret is not None else ""
151
+ )
152
+ self._oauth_token_path = oauth_token_path
153
+ self._oauth_token_leeway_seconds = max(0, int(oauth_token_leeway_seconds))
154
+ self._oauth_access_token = ""
155
+ self._oauth_access_token_expires_at_unix = 0.0
156
+ self._auth_sessions_path = auth_sessions_path
157
+ self._auth_current_session_path = auth_current_session_path
158
+ self._auth_me_path = auth_me_path
159
+ self._runtime_register_path = runtime_register_path
160
+ self._runtime_invoke_path = runtime_invoke_path
161
+ self._runtime_events_path = runtime_events_path
162
+ self._runtime_activate_path = runtime_activate_path
163
+ self._runtime_deactivate_path = runtime_deactivate_path
164
+ self._runtime_validate_path = runtime_validate_path
165
+ self._chat_messages_path = chat_messages_path
166
+ self._queue_contracts_path = queue_contracts_path
167
+ self._chat_sessions_path = chat_sessions_path
168
+ self._chat_history_path = chat_history_path
169
+ self._client = httpx.Client(timeout=timeout_seconds)
170
+
171
+ def close(self) -> None:
172
+ self._client.close()
173
+
174
+ def create_session(self, email: str, password: str) -> dict[str, Any]:
175
+ payload = {"email": email, "password": password}
176
+ return self._post(self._auth_sessions_path, payload, auth_token="")
177
+
178
+ def delete_current_session(self, auth_token: str | None = None) -> None:
179
+ self._delete(self._auth_current_session_path, auth_token=auth_token)
180
+
181
+ def get_me(self, auth_token: str | None = None) -> dict[str, Any]:
182
+ return self._get(self._auth_me_path, auth_token=auth_token)
183
+
184
+ def register_runtime(
185
+ self, registration: Any, auth_token: str | None = None
186
+ ) -> dict[str, Any]:
187
+ return self._post(
188
+ self._runtime_register_path, registration, auth_token=auth_token
189
+ )
190
+
191
+ def list_runtime_registrations(
192
+ self,
193
+ *,
194
+ agent_id: str,
195
+ agent_version: str,
196
+ auth_token: str | None = None,
197
+ ) -> list[dict[str, Any]]:
198
+ path = self._path_with_query(
199
+ self._runtime_register_path,
200
+ {
201
+ "agent_id": agent_id,
202
+ "agent_version": agent_version,
203
+ },
204
+ )
205
+ response = self._get(path, auth_token=auth_token)
206
+ registrations = response.get("registrations")
207
+ if isinstance(registrations, list):
208
+ return [item for item in registrations if isinstance(item, dict)]
209
+ return []
210
+
211
+ def activate_runtime_registration(
212
+ self, registration_id: str, auth_token: str | None = None
213
+ ) -> None:
214
+ self._post(
215
+ self._runtime_registration_action_path(
216
+ self._runtime_activate_path, registration_id
217
+ ),
218
+ {},
219
+ auth_token=auth_token,
220
+ )
221
+
222
+ def deactivate_runtime_registration(
223
+ self, registration_id: str, auth_token: str | None = None
224
+ ) -> None:
225
+ self._post(
226
+ self._runtime_registration_action_path(
227
+ self._runtime_deactivate_path, registration_id
228
+ ),
229
+ {},
230
+ auth_token=auth_token,
231
+ )
232
+
233
+ def validate_runtime_registration(
234
+ self, registration_id: str, auth_token: str | None = None
235
+ ) -> dict[str, Any]:
236
+ return self._post(
237
+ self._runtime_registration_action_path(
238
+ self._runtime_validate_path, registration_id
239
+ ),
240
+ {},
241
+ auth_token=auth_token,
242
+ )
243
+
244
+ def ensure_runtime_registration_active(
245
+ self,
246
+ *,
247
+ registration: Any,
248
+ auth_token: str | None = None,
249
+ ) -> dict[str, Any]:
250
+ payload = _normalize_payload(registration)
251
+ requested_agent_id = str(payload.get("agent_id", "")).strip()
252
+ requested_agent_version = str(payload.get("agent_version", "")).strip()
253
+ if requested_agent_id == "" or requested_agent_version == "":
254
+ raise ValueError(
255
+ "simpleflow sdk payload error: registration agent_id and agent_version are required"
256
+ )
257
+
258
+ existing = self.list_runtime_registrations(
259
+ agent_id=requested_agent_id,
260
+ agent_version=requested_agent_version,
261
+ auth_token=auth_token,
262
+ )
263
+ for item in existing:
264
+ status = str(item.get("status", "")).strip().lower()
265
+ if status == "active":
266
+ return {
267
+ "status": "active",
268
+ "registration": item,
269
+ "registration_id": str(
270
+ item.get("id", item.get("registration_id", ""))
271
+ ).strip(),
272
+ "created": False,
273
+ "validated": False,
274
+ "activated": False,
275
+ }
276
+
277
+ target = existing[0] if len(existing) > 0 else None
278
+ created = False
279
+ registration_id = ""
280
+ if target is None:
281
+ target = self.register_runtime(payload, auth_token=auth_token)
282
+ created = True
283
+
284
+ registration_id = str(
285
+ target.get("id", target.get("registration_id", ""))
286
+ ).strip()
287
+ if registration_id == "":
288
+ raise SimpleFlowLifecycleError(
289
+ status_code=502,
290
+ detail="registration response did not include registration id",
291
+ path=self._runtime_register_path,
292
+ )
293
+
294
+ validation = self.validate_runtime_registration(
295
+ registration_id, auth_token=auth_token
296
+ )
297
+ self.activate_runtime_registration(registration_id, auth_token=auth_token)
298
+ return {
299
+ "status": "active",
300
+ "registration": target,
301
+ "registration_id": registration_id,
302
+ "validation": validation,
303
+ "created": created,
304
+ "validated": True,
305
+ "activated": True,
306
+ }
307
+
308
+ def invoke(self, request: Any, auth_token: str | None = None) -> dict[str, Any]:
309
+ response = self._post(self._runtime_invoke_path, request, auth_token=auth_token)
310
+ return response
311
+
312
+ def write_event(self, event: Any) -> None:
313
+ body = _normalize_payload(event)
314
+ event_type = str(body.get("event_type", "")).strip()
315
+ if event_type == "":
316
+ event_type = str(body.get("type", "")).strip()
317
+ body["event_type"] = event_type
318
+ body.pop("type", None)
319
+ idempotency_key = str(body.pop("idempotency_key", "")).strip()
320
+ allowed_keys = {
321
+ "agent_id",
322
+ "organization_id",
323
+ "run_id",
324
+ "event_type",
325
+ "trace_id",
326
+ "conversation_id",
327
+ "request_id",
328
+ "payload",
329
+ }
330
+ body = {key: value for key, value in body.items() if key in allowed_keys}
331
+ headers: dict[str, str] = {}
332
+ if idempotency_key != "":
333
+ headers["Idempotency-Key"] = idempotency_key
334
+ self._post(self._runtime_events_path, body, extra_headers=headers)
335
+
336
+ def report_runtime_event(self, event: Any) -> None:
337
+ self.write_event(event)
338
+
339
+ def write_chat_message(self, message: Any) -> None:
340
+ body = _normalize_payload(message)
341
+ idempotency_key = str(body.pop("idempotency_key", "")).strip()
342
+ body.pop("created_at_ms", None)
343
+ allowed_keys = {
344
+ "agent_id",
345
+ "organization_id",
346
+ "run_id",
347
+ "chat_id",
348
+ "message_id",
349
+ "role",
350
+ "direction",
351
+ "content",
352
+ "metadata",
353
+ }
354
+ body = {key: value for key, value in body.items() if key in allowed_keys}
355
+ direction = str(body.get("direction", "")).strip()
356
+ if direction == "":
357
+ body["direction"] = "outbound"
358
+ if body.get("content") is None:
359
+ body["content"] = {}
360
+ if body.get("metadata") is None:
361
+ body["metadata"] = {}
362
+ headers: dict[str, str] = {}
363
+ if idempotency_key != "":
364
+ headers["Idempotency-Key"] = idempotency_key
365
+ self._post(self._chat_messages_path, body, extra_headers=headers)
366
+
367
+ def publish_queue_contract(self, contract: Any) -> None:
368
+ body = _normalize_payload(contract)
369
+ if str(body.get("contract_name", "")).strip() == "":
370
+ body["contract_name"] = (
371
+ str(body.get("message_id", "")).strip() or "runtime.queue.contract"
372
+ )
373
+ if str(body.get("contract_version", "")).strip() == "":
374
+ body["contract_version"] = "v1"
375
+ if str(body.get("status", "")).strip() == "":
376
+ body["status"] = "draft"
377
+ if body.get("schema") is None:
378
+ body["schema"] = body.get("payload") or {}
379
+ if body.get("transport") is None:
380
+ body["transport"] = {}
381
+ idempotency_key = str(body.pop("idempotency_key", "")).strip()
382
+ headers: dict[str, str] = {}
383
+ if idempotency_key != "":
384
+ headers["Idempotency-Key"] = idempotency_key
385
+ self._post(self._queue_contracts_path, body, extra_headers=headers)
386
+
387
+ def list_chat_history_messages(
388
+ self,
389
+ *,
390
+ agent_id: str,
391
+ chat_id: str,
392
+ user_id: str,
393
+ limit: int = 50,
394
+ auth_token: str | None = None,
395
+ ) -> list[dict[str, Any]]:
396
+ path = self._path_with_query(
397
+ self._chat_history_path,
398
+ {
399
+ "agent_id": agent_id,
400
+ "chat_id": chat_id,
401
+ "user_id": user_id,
402
+ "limit": limit,
403
+ },
404
+ )
405
+ response = self._get(path, auth_token=auth_token)
406
+ messages = response.get("messages")
407
+ if isinstance(messages, list):
408
+ return [item for item in messages if isinstance(item, dict)]
409
+ return []
410
+
411
+ def list_chat_sessions(
412
+ self,
413
+ *,
414
+ agent_id: str,
415
+ user_id: str,
416
+ status: str = "active",
417
+ limit: int = 50,
418
+ auth_token: str | None = None,
419
+ ) -> list[dict[str, Any]]:
420
+ path = self._path_with_query(
421
+ self._chat_sessions_path,
422
+ {
423
+ "agent_id": agent_id,
424
+ "user_id": user_id,
425
+ "status": status,
426
+ "limit": limit,
427
+ },
428
+ )
429
+ response = self._get(path, auth_token=auth_token)
430
+ sessions = response.get("sessions")
431
+ if isinstance(sessions, list):
432
+ return [item for item in sessions if isinstance(item, dict)]
433
+ return []
434
+
435
+ def create_chat_history_message(
436
+ self, message: Any, auth_token: str | None = None
437
+ ) -> dict[str, Any]:
438
+ return self._post(self._chat_history_path, message, auth_token=auth_token)
439
+
440
+ def update_chat_history_message(
441
+ self,
442
+ *,
443
+ message_id: str,
444
+ agent_id: str,
445
+ chat_id: str,
446
+ user_id: str,
447
+ content: Any,
448
+ metadata: Any,
449
+ auth_token: str | None = None,
450
+ ) -> dict[str, Any]:
451
+ payload = {
452
+ "agent_id": agent_id,
453
+ "chat_id": chat_id,
454
+ "user_id": user_id,
455
+ "content": _normalize_payload(content),
456
+ "metadata": _normalize_payload(metadata),
457
+ }
458
+ return self._patch(
459
+ f"{self._chat_history_path}/{message_id}",
460
+ payload,
461
+ auth_token=auth_token,
462
+ )
463
+
464
+ def write_event_from_workflow_result(
465
+ self,
466
+ *,
467
+ agent_id: str,
468
+ workflow_result: Any,
469
+ event_type: str = "runtime.workflow.completed",
470
+ ) -> None:
471
+ normalized_result = _normalize_payload(workflow_result)
472
+ metadata = normalized_result.get("metadata")
473
+ metadata_dict = metadata if isinstance(metadata, dict) else {}
474
+ telemetry = metadata_dict.get("telemetry")
475
+ telemetry_dict = telemetry if isinstance(telemetry, dict) else {}
476
+ trace = metadata_dict.get("trace")
477
+ trace_dict = trace if isinstance(trace, dict) else {}
478
+ tenant = trace_dict.get("tenant")
479
+ tenant_dict = tenant if isinstance(tenant, dict) else {}
480
+
481
+ conversation_id = str(tenant_dict.get("conversation_id", "")).strip()
482
+ if conversation_id == "":
483
+ conversation_id = str(trace_dict.get("conversation_id", "")).strip()
484
+ request_id = str(tenant_dict.get("request_id", "")).strip()
485
+ if request_id == "":
486
+ request_id = str(trace_dict.get("request_id", "")).strip()
487
+ run_id = str(tenant_dict.get("run_id", "")).strip()
488
+ if run_id == "":
489
+ run_id = str(normalized_result.get("run_id", "")).strip()
490
+ trace_id = str(telemetry_dict.get("trace_id", "")).strip()
491
+ sampled_value = telemetry_dict.get("sampled")
492
+ sampled = sampled_value if isinstance(sampled_value, bool) else None
493
+
494
+ self.write_event(
495
+ {
496
+ "event_type": event_type,
497
+ "agent_id": agent_id,
498
+ "run_id": run_id,
499
+ "conversation_id": conversation_id,
500
+ "request_id": request_id,
501
+ "trace_id": trace_id,
502
+ "sampled": sampled,
503
+ "payload": normalized_result,
504
+ }
505
+ )
506
+
507
+ def write_chat_message_from_workflow_result(
508
+ self,
509
+ *,
510
+ agent_id: str,
511
+ organization_id: str,
512
+ run_id: str,
513
+ role: str,
514
+ workflow_result: Any,
515
+ trace_id: str = "",
516
+ span_id: str = "",
517
+ tenant_id: str = "",
518
+ trace_ui_base_url: str = "http://localhost:16686",
519
+ chat_id: str | None = None,
520
+ message_id: str | None = None,
521
+ direction: str | None = None,
522
+ created_at_ms: int | None = None,
523
+ idempotency_key: str | None = None,
524
+ ) -> None:
525
+ normalized_result = _normalize_payload(workflow_result)
526
+ normalized_trace_id = trace_id.strip()
527
+ events = normalized_result.get("events")
528
+ event_counts = _count_workflow_events_by_type(normalized_result)
529
+ nerdstats = _extract_workflow_nerdstats(normalized_result)
530
+
531
+ content = {
532
+ "reply": normalized_result.get("terminal_output"),
533
+ "terminal_output": normalized_result.get("terminal_output"),
534
+ "workflow": {
535
+ "workflow_id": normalized_result.get("workflow_id"),
536
+ "terminal_node": normalized_result.get("terminal_node"),
537
+ },
538
+ }
539
+
540
+ metadata: dict[str, Any] = {
541
+ "source": "runtime.workflow.invoke",
542
+ "workflow_id": normalized_result.get("workflow_id"),
543
+ "terminal_node": normalized_result.get("terminal_node"),
544
+ "trace": normalized_result.get("trace", []),
545
+ "step_timings": normalized_result.get("step_timings", []),
546
+ "event_counts": event_counts,
547
+ "nerdstats": nerdstats,
548
+ "llm_node_metrics": normalized_result.get("llm_node_metrics", {}),
549
+ "total_elapsed_ms": normalized_result.get("total_elapsed_ms"),
550
+ "trace_context": {
551
+ "trace_id": normalized_trace_id,
552
+ "span_id": span_id.strip(),
553
+ "tenant_id": tenant_id.strip(),
554
+ "trace_url": _build_trace_url(
555
+ normalized_trace_id, trace_ui_base_url=trace_ui_base_url
556
+ ),
557
+ },
558
+ }
559
+ if isinstance(events, list):
560
+ metadata["events"] = events
561
+
562
+ self.write_chat_message(
563
+ {
564
+ "agent_id": agent_id,
565
+ "organization_id": organization_id,
566
+ "run_id": run_id,
567
+ "role": role,
568
+ "chat_id": chat_id,
569
+ "message_id": message_id,
570
+ "direction": direction,
571
+ "content": content,
572
+ "metadata": metadata,
573
+ "idempotency_key": idempotency_key,
574
+ "created_at_ms": created_at_ms,
575
+ }
576
+ )
577
+
578
+ def with_telemetry(
579
+ self,
580
+ *,
581
+ mode: str = "simpleflow",
582
+ sample_rate: float | None = None,
583
+ otlp_sink: Any | None = None,
584
+ default_trace: dict[str, str] | None = None,
585
+ ) -> TelemetryBridge:
586
+ return TelemetryBridge(
587
+ client=self,
588
+ mode=mode,
589
+ sample_rate=sample_rate,
590
+ otlp_sink=otlp_sink,
591
+ default_trace=default_trace,
592
+ )
593
+
594
+ def _post(
595
+ self,
596
+ path: str,
597
+ payload: Any,
598
+ extra_headers: dict[str, str] | None = None,
599
+ auth_token: str | None = None,
600
+ ) -> dict[str, Any]:
601
+ normalized_path = path if path.startswith("/") else f"/{path}"
602
+ url = f"{self._base_url}{normalized_path}"
603
+ body = _normalize_payload(payload)
604
+
605
+ headers = {"Content-Type": "application/json"}
606
+ headers.update(self._authorization_headers(auth_token))
607
+ if extra_headers is not None:
608
+ headers.update(extra_headers)
609
+
610
+ response = self._client.post(url, json=body, headers=headers)
611
+ if response.status_code < 200 or response.status_code >= 300:
612
+ self._raise_request_error(
613
+ path=normalized_path,
614
+ status_code=response.status_code,
615
+ body=response.text,
616
+ )
617
+ if response.text.strip() == "":
618
+ return {}
619
+ try:
620
+ decoded = response.json()
621
+ except ValueError as exc:
622
+ raise RuntimeError(
623
+ "simpleflow sdk request error: expected JSON response body"
624
+ ) from exc
625
+ if isinstance(decoded, dict):
626
+ return decoded
627
+ raise RuntimeError(
628
+ "simpleflow sdk request error: expected JSON object response body"
629
+ )
630
+
631
+ def _get(self, path: str, auth_token: str | None = None) -> dict[str, Any]:
632
+ normalized_path = path if path.startswith("/") else f"/{path}"
633
+ url = f"{self._base_url}{normalized_path}"
634
+ headers = self._authorization_headers(auth_token)
635
+ response = self._client.get(url, headers=headers)
636
+ if response.status_code < 200 or response.status_code >= 300:
637
+ self._raise_request_error(
638
+ path=normalized_path,
639
+ status_code=response.status_code,
640
+ body=response.text,
641
+ )
642
+ if response.text.strip() == "":
643
+ return {}
644
+ decoded = response.json()
645
+ if isinstance(decoded, dict):
646
+ return decoded
647
+ raise RuntimeError(
648
+ "simpleflow sdk request error: expected JSON object response body"
649
+ )
650
+
651
+ def _patch(
652
+ self, path: str, payload: Any, auth_token: str | None = None
653
+ ) -> dict[str, Any]:
654
+ normalized_path = path if path.startswith("/") else f"/{path}"
655
+ url = f"{self._base_url}{normalized_path}"
656
+ body = _normalize_payload(payload)
657
+ headers = {"Content-Type": "application/json"}
658
+ headers.update(self._authorization_headers(auth_token))
659
+ response = self._client.patch(url, json=body, headers=headers)
660
+ if response.status_code < 200 or response.status_code >= 300:
661
+ self._raise_request_error(
662
+ path=normalized_path,
663
+ status_code=response.status_code,
664
+ body=response.text,
665
+ )
666
+ if response.text.strip() == "":
667
+ return {}
668
+ decoded = response.json()
669
+ if isinstance(decoded, dict):
670
+ return decoded
671
+ raise RuntimeError(
672
+ "simpleflow sdk request error: expected JSON object response body"
673
+ )
674
+
675
+ def _delete(self, path: str, auth_token: str | None = None) -> None:
676
+ normalized_path = path if path.startswith("/") else f"/{path}"
677
+ url = f"{self._base_url}{normalized_path}"
678
+ headers = self._authorization_headers(auth_token)
679
+ response = self._client.delete(url, headers=headers)
680
+ if response.status_code < 200 or response.status_code >= 300:
681
+ self._raise_request_error(
682
+ path=normalized_path,
683
+ status_code=response.status_code,
684
+ body=response.text,
685
+ )
686
+
687
+ def _authorization_headers(self, auth_token: str | None = None) -> dict[str, str]:
688
+ token = ""
689
+ if auth_token is None:
690
+ token = self._api_token
691
+ if (
692
+ token == ""
693
+ and self._oauth_client_id != ""
694
+ and self._oauth_client_secret != ""
695
+ ):
696
+ token = self._ensure_oauth_access_token()
697
+ else:
698
+ token = auth_token.strip()
699
+ if token == "":
700
+ return {}
701
+ return {"Authorization": f"Bearer {token}"}
702
+
703
+ def _ensure_oauth_access_token(self) -> str:
704
+ now = time.time()
705
+ if (
706
+ self._oauth_access_token != ""
707
+ and now + float(self._oauth_token_leeway_seconds)
708
+ < self._oauth_access_token_expires_at_unix
709
+ ):
710
+ return self._oauth_access_token
711
+
712
+ normalized_path = (
713
+ self._oauth_token_path
714
+ if self._oauth_token_path.startswith("/")
715
+ else f"/{self._oauth_token_path}"
716
+ )
717
+ url = f"{self._base_url}{normalized_path}"
718
+ response = self._client.post(
719
+ url,
720
+ json={
721
+ "grant_type": "client_credentials",
722
+ "client_id": self._oauth_client_id,
723
+ "client_secret": self._oauth_client_secret,
724
+ },
725
+ headers={"Content-Type": "application/json"},
726
+ )
727
+ if response.status_code < 200 or response.status_code >= 300:
728
+ self._raise_request_error(
729
+ path=normalized_path,
730
+ status_code=response.status_code,
731
+ body=response.text,
732
+ )
733
+
734
+ payload = response.json()
735
+ if not isinstance(payload, dict):
736
+ raise RuntimeError(
737
+ "simpleflow sdk oauth error: expected JSON object token response"
738
+ )
739
+ access_token = str(payload.get("access_token", "")).strip()
740
+ if access_token == "":
741
+ raise RuntimeError(
742
+ "simpleflow sdk oauth error: token response missing access_token"
743
+ )
744
+
745
+ expires_in_value = payload.get("expires_in", 0)
746
+ expires_in = 0.0
747
+ try:
748
+ expires_in = float(expires_in_value)
749
+ except (TypeError, ValueError):
750
+ expires_in = 0.0
751
+ if not isfinite(expires_in) or expires_in <= 0.0:
752
+ expires_in = 60.0
753
+
754
+ self._oauth_access_token = access_token
755
+ self._oauth_access_token_expires_at_unix = now + expires_in
756
+ return self._oauth_access_token
757
+
758
+ def _runtime_registration_action_path(
759
+ self, path_template: str, registration_id: str
760
+ ) -> str:
761
+ trimmed_id = registration_id.strip()
762
+ if trimmed_id == "":
763
+ raise ValueError(
764
+ "simpleflow sdk payload error: registration_id is required"
765
+ )
766
+ if "{registration_id}" in path_template:
767
+ return path_template.replace("{registration_id}", trimmed_id)
768
+ if path_template.endswith("/"):
769
+ return f"{path_template}{trimmed_id}"
770
+ return f"{path_template}/{trimmed_id}"
771
+
772
+ def _path_with_query(self, path: str, query: dict[str, Any]) -> str:
773
+ encoded = urlencode(query)
774
+ if encoded == "":
775
+ return path
776
+ return f"{path}?{encoded}"
777
+
778
+ def _raise_request_error(self, *, path: str, status_code: int, body: str) -> None:
779
+ detail = body.strip()
780
+ if detail == "":
781
+ detail = "request failed"
782
+ if status_code == 401:
783
+ raise SimpleFlowAuthenticationError(
784
+ status_code=status_code,
785
+ detail=detail,
786
+ path=path,
787
+ )
788
+ if status_code == 403:
789
+ raise SimpleFlowAuthorizationError(
790
+ status_code=status_code,
791
+ detail=detail,
792
+ path=path,
793
+ )
794
+ if self._is_lifecycle_path(path):
795
+ raise SimpleFlowLifecycleError(
796
+ status_code=status_code,
797
+ detail=detail,
798
+ path=path,
799
+ )
800
+ raise SimpleFlowRequestError(status_code=status_code, detail=detail, path=path)
801
+
802
+ def _is_lifecycle_path(self, path: str) -> bool:
803
+ normalized = path if path.startswith("/") else f"/{path}"
804
+ registration_root = self._runtime_register_path.rstrip("/")
805
+ return normalized.startswith(registration_root)
806
+
807
+
808
+ class TelemetryBridge:
809
+ def __init__(
810
+ self,
811
+ *,
812
+ client: SimpleFlowClient,
813
+ mode: str,
814
+ sample_rate: float | None,
815
+ otlp_sink: Any | None,
816
+ default_trace: dict[str, str] | None,
817
+ ) -> None:
818
+ normalized_mode = mode.strip().lower()
819
+ if normalized_mode not in {"simpleflow", "otlp"}:
820
+ raise ValueError(
821
+ "simpleflow sdk config error: telemetry mode must be one of simpleflow or otlp"
822
+ )
823
+ _validate_sample_rate(sample_rate)
824
+ self._client = client
825
+ self._mode = normalized_mode
826
+ self._sample_rate = sample_rate
827
+ self._otlp_sink = otlp_sink
828
+ self._default_trace = default_trace if default_trace is not None else {}
829
+
830
+ def emit_span(
831
+ self,
832
+ *,
833
+ span: Any,
834
+ agent_id: str = "",
835
+ run_id: str = "",
836
+ trace_id: str = "",
837
+ request_id: str = "",
838
+ conversation_id: str = "",
839
+ ) -> None:
840
+ normalized_span = _normalize_payload(span)
841
+ if trace_id.strip() != "" and not _should_sample(trace_id, self._sample_rate):
842
+ return
843
+
844
+ if self._mode == "otlp":
845
+ if callable(self._otlp_sink):
846
+ self._otlp_sink(normalized_span)
847
+ return
848
+
849
+ fallback_agent_id = str(self._default_trace.get("agent_id", "")).strip()
850
+ fallback_run_id = str(self._default_trace.get("run_id", "")).strip()
851
+ fallback_request_id = str(self._default_trace.get("request_id", "")).strip()
852
+ fallback_conversation_id = str(
853
+ self._default_trace.get("conversation_id", "")
854
+ ).strip()
855
+
856
+ self._client.write_event(
857
+ {
858
+ "type": "runtime.telemetry.span",
859
+ "agent_id": agent_id.strip()
860
+ if agent_id.strip() != ""
861
+ else fallback_agent_id,
862
+ "run_id": run_id.strip() if run_id.strip() != "" else fallback_run_id,
863
+ "request_id": request_id.strip()
864
+ if request_id.strip() != ""
865
+ else fallback_request_id,
866
+ "conversation_id": conversation_id.strip()
867
+ if conversation_id.strip() != ""
868
+ else fallback_conversation_id,
869
+ "trace_id": trace_id.strip(),
870
+ "sampled": True,
871
+ "payload": {"span": normalized_span},
872
+ }
873
+ )
@@ -0,0 +1,104 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class InvokeTrace:
7
+ trace_id: str
8
+ span_id: str
9
+ tenant_id: str
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class WorkflowTraceTenant:
14
+ conversation_id: str | None = None
15
+ request_id: str | None = None
16
+ run_id: str | None = None
17
+ agent_id: str | None = None
18
+ organization_id: str | None = None
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class RuntimeRegistration:
23
+ agent_id: str | None = None
24
+ agent_version: str | None = None
25
+ execution_mode: str | None = None
26
+ endpoint_url: str | None = None
27
+ auth_mode: str | None = None
28
+ capabilities: list[str] | None = None
29
+ metadata: dict[str, str] | None = None
30
+ runtime_id: str | None = None
31
+ runtime_version: str | None = None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class RuntimeEvent:
36
+ agent_id: str
37
+ event_type: str | None = None
38
+ type: str | None = None
39
+ agent_version: str | None = None
40
+ run_id: str | None = None
41
+ conversation_id: str | None = None
42
+ request_id: str | None = None
43
+ trace_id: str | None = None
44
+ sampled: bool | None = None
45
+ organization_id: str | None = None
46
+ user_id: str | None = None
47
+ timestamp_ms: int | None = None
48
+ idempotency_key: str | None = None
49
+ payload: dict[str, Any] | None = None
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class TelemetrySpan:
54
+ name: str
55
+ start_time_ms: int
56
+ end_time_ms: int
57
+ kind: str | None = None
58
+ attributes: dict[str, Any] | None = None
59
+ status: str | None = None
60
+ status_detail: str | None = None
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class ChatMessageWrite:
65
+ agent_id: str
66
+ organization_id: str
67
+ run_id: str
68
+ role: str
69
+ chat_id: str | None = None
70
+ message_id: str | None = None
71
+ direction: str | None = None
72
+ content: Any = None
73
+ metadata: dict[str, Any] | None = None
74
+ idempotency_key: str | None = None
75
+ created_at_ms: int | None = None
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class QueueContract:
80
+ queue_name: str
81
+ message_id: str
82
+ idempotency_key: str
83
+ retry_attempt: int
84
+ max_retry_attempt: int
85
+ agent_id: str | None = None
86
+ organization_id: str | None = None
87
+ run_id: str | None = None
88
+ contract_name: str | None = None
89
+ contract_version: str | None = None
90
+ schema: dict[str, Any] | None = None
91
+ transport: dict[str, Any] | None = None
92
+ status: str | None = None
93
+ payload: dict[str, Any] | None = None
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class ChatHistoryMessage:
98
+ agent_id: str
99
+ chat_id: str
100
+ message_id: str
101
+ user_id: str
102
+ role: str | None = None
103
+ content: dict[str, Any] | None = None
104
+ metadata: dict[str, Any] | None = None
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: simpleflow-sdk
3
+ Version: 0.1.0
4
+ Summary: SimpleFlow remote runtime SDK for Python
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx<1.0.0,>=0.27.0
7
+ Requires-Dist: PyJWT<3.0.0,>=2.10.0
@@ -0,0 +1,8 @@
1
+ simpleflow_sdk/__init__.py,sha256=aB9VSQemN76Jgja2EILErL0cpcJ5mf7ynU0wCdZo_zE,837
2
+ simpleflow_sdk/auth.py,sha256=CUcwL7zwe4OK-7lBN5bLt0--OqNDw8LzYrQeSbNQrbk,2656
3
+ simpleflow_sdk/client.py,sha256=SCIKI43fEQ2AzSVdqBb86hz-aVcE5RAyQLe5Pwci_Qc,31919
4
+ simpleflow_sdk/contracts.py,sha256=xGOc-uaFKuE5WMMwryBCEM-i2BgTmPHNkTeeNL_lqgQ,2622
5
+ simpleflow_sdk-0.1.0.dist-info/METADATA,sha256=ZbL9e6ddEk4z0gjKGXTQqnzrLfXYfGcYi1b-ZgoyPsQ,204
6
+ simpleflow_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ simpleflow_sdk-0.1.0.dist-info/top_level.txt,sha256=fWOaP2mG9_aU-fCd1OdZ-qnBJbsC_JC6bVh1gQSVMhA,15
8
+ simpleflow_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ simpleflow_sdk