spanforge 1.0.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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_cli_cost.py ADDED
@@ -0,0 +1,243 @@
1
+ """Cost command group for the SpanForge CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def add_cost_subcommands(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser:
13
+ """Register cost-related CLI subcommands."""
14
+ cost_parser = sub.add_parser(
15
+ "cost",
16
+ help="Cost brief management",
17
+ )
18
+ cost_sub = cost_parser.add_subparsers(dest="cost_command", metavar="<action>")
19
+
20
+ brief_parser = cost_sub.add_parser("brief", help="Cost brief operations")
21
+ brief_sub = brief_parser.add_subparsers(dest="brief_command", metavar="<action>")
22
+
23
+ submit_parser = brief_sub.add_parser(
24
+ "submit",
25
+ help="Submit a cost brief JSON file to the local brief store",
26
+ )
27
+ submit_parser.add_argument(
28
+ "--file",
29
+ required=True,
30
+ metavar="BRIEF_JSON",
31
+ help="Path to a cost brief JSON file",
32
+ )
33
+ submit_parser.add_argument(
34
+ "--store",
35
+ default=".spanforge-cost-briefs.json",
36
+ metavar="STORE_JSON",
37
+ help="Path to the local cost brief store JSON file (default: .spanforge-cost-briefs.json)",
38
+ )
39
+
40
+ run_cost_parser = cost_sub.add_parser(
41
+ "run",
42
+ help="Show per-run cost breakdown for an agent run",
43
+ )
44
+ run_cost_parser.add_argument(
45
+ "--run-id",
46
+ required=True,
47
+ metavar="RUN_ID",
48
+ help="Agent run ID to look up",
49
+ )
50
+ run_cost_parser.add_argument(
51
+ "--input",
52
+ required=True,
53
+ metavar="JSONL",
54
+ help="Path to a JSONL events file to search",
55
+ )
56
+
57
+ return cost_parser
58
+
59
+
60
+ def dispatch_cost_command(args: argparse.Namespace, cost_parser: argparse.ArgumentParser) -> int | None:
61
+ """Dispatch cost-related commands when selected."""
62
+ if getattr(args, "command", None) != "cost":
63
+ return None
64
+
65
+ cost_action = getattr(args, "cost_command", None)
66
+ brief_action = getattr(args, "brief_command", None)
67
+ if cost_action == "brief" and brief_action == "submit":
68
+ return _cmd_cost_brief_submit(args)
69
+ if cost_action == "run":
70
+ return _cmd_cost_run(args)
71
+
72
+ cost_parser.print_help()
73
+ return 2
74
+
75
+
76
+ def _load_cost_brief_store_json(store_path: Path) -> dict[str, Any]:
77
+ """Load or initialise a JSON-file-backed cost brief store."""
78
+ if store_path.exists():
79
+ try:
80
+ data: dict[str, Any] = json.loads(store_path.read_text(encoding="utf-8"))
81
+ except (json.JSONDecodeError, OSError):
82
+ pass
83
+ else:
84
+ return data
85
+ return {}
86
+
87
+
88
+ def _cmd_cost_brief_submit(args: argparse.Namespace) -> int:
89
+ """Implement ``spanforge cost brief submit``."""
90
+ brief_path = Path(args.file)
91
+ if not brief_path.exists():
92
+ print(f"error: file not found: {brief_path}", file=sys.stderr)
93
+ return 2
94
+
95
+ try:
96
+ brief_data = json.loads(brief_path.read_text(encoding="utf-8"))
97
+ except json.JSONDecodeError as exc:
98
+ print(f"error: invalid JSON in {brief_path}: {exc}", file=sys.stderr)
99
+ return 2
100
+
101
+ required = {"model_id", "submitted_by", "resource_config", "scenarios"}
102
+ missing = required - set(brief_data.keys())
103
+ if missing:
104
+ print(
105
+ f"error: cost brief missing required fields: {', '.join(sorted(missing))}",
106
+ file=sys.stderr,
107
+ )
108
+ return 2
109
+
110
+ store_path = Path(args.store)
111
+ store = _load_cost_brief_store_json(store_path)
112
+
113
+ from datetime import datetime, timezone
114
+
115
+ store[brief_data["model_id"]] = {
116
+ **brief_data,
117
+ "stored_at": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+ store_path.parent.mkdir(parents=True, exist_ok=True)
120
+ store_path.write_text(json.dumps(store, indent=2), encoding="utf-8")
121
+
122
+ print(f"[✓] Cost brief submitted model_id={brief_data['model_id']!r} store={store_path}")
123
+ return 0
124
+
125
+
126
+ def _cmd_cost_run(args: argparse.Namespace) -> int:
127
+ """Implement ``spanforge cost run --run-id <id> --input <jsonl>``."""
128
+ run_id: str = args.run_id
129
+ events_path = Path(args.input)
130
+
131
+ if not events_path.exists():
132
+ print(f"error: file not found: {events_path}", file=sys.stderr)
133
+ return 2
134
+
135
+ cost_events: list[dict[str, Any]] = []
136
+ agent_run_event: dict[str, Any] | None = None
137
+
138
+ for line in events_path.read_text(encoding="utf-8").splitlines():
139
+ line = line.strip()
140
+ if not line:
141
+ continue
142
+ try:
143
+ event = json.loads(line)
144
+ except json.JSONDecodeError:
145
+ continue
146
+
147
+ payload = event.get("payload", {})
148
+ ns = event.get("namespace", "")
149
+
150
+ if ns.startswith("llm.cost.") and payload.get("agent_run_id") == run_id:
151
+ cost_events.append(event)
152
+ elif ns == "llm.trace.agent.completed" and payload.get("agent_run_id") == run_id:
153
+ agent_run_event = event
154
+
155
+ if not cost_events and agent_run_event is None:
156
+ print(f"error: no events found for run_id={run_id!r}", file=sys.stderr)
157
+ return 1
158
+
159
+ by_model: dict[str, dict[str, float]] = {}
160
+ total_usd = 0.0
161
+ total_input_tokens = 0
162
+ total_output_tokens = 0
163
+
164
+ for ev in cost_events:
165
+ payload = ev.get("payload", {})
166
+ cost_data = payload.get("cost", {})
167
+ model_data = payload.get("model", {})
168
+ model_name = (
169
+ model_data.get("name", "unknown") if isinstance(model_data, dict) else "unknown"
170
+ )
171
+
172
+ input_cost = float(cost_data.get("input_cost_usd", 0.0))
173
+ output_cost = float(cost_data.get("output_cost_usd", 0.0))
174
+ total_cost = float(cost_data.get("total_cost_usd", 0.0))
175
+
176
+ token_data = payload.get("token_usage", {})
177
+ input_tokens = int(token_data.get("input_tokens", 0))
178
+ output_tokens = int(token_data.get("output_tokens", 0))
179
+
180
+ if model_name not in by_model:
181
+ by_model[model_name] = {
182
+ "input_cost": 0.0,
183
+ "output_cost": 0.0,
184
+ "total_cost": 0.0,
185
+ "input_tokens": 0,
186
+ "output_tokens": 0,
187
+ "calls": 0,
188
+ }
189
+ by_model[model_name]["input_cost"] += input_cost
190
+ by_model[model_name]["output_cost"] += output_cost
191
+ by_model[model_name]["total_cost"] += total_cost
192
+ by_model[model_name]["input_tokens"] += input_tokens
193
+ by_model[model_name]["output_tokens"] += output_tokens
194
+ by_model[model_name]["calls"] += 1
195
+
196
+ total_usd += total_cost
197
+ total_input_tokens += input_tokens
198
+ total_output_tokens += output_tokens
199
+
200
+ agent_name = "unknown"
201
+ run_status = "unknown"
202
+ run_duration_ms = 0.0
203
+ if agent_run_event:
204
+ run_payload = agent_run_event.get("payload", {})
205
+ agent_name = run_payload.get("agent_name", "unknown")
206
+ run_status = run_payload.get("status", "unknown")
207
+ run_duration_ms = float(run_payload.get("duration_ms", 0.0))
208
+ run_cost = run_payload.get("total_cost", {})
209
+ if run_cost:
210
+ total_usd = max(total_usd, float(run_cost.get("total_cost_usd", total_usd)))
211
+
212
+ lines: list[str] = []
213
+ lines.append("=" * 62)
214
+ lines.append(" SpanForge Per-Run Cost Report")
215
+ lines.append("=" * 62)
216
+ lines.append(f" Run ID : {run_id}")
217
+ lines.append(f" Agent : {agent_name}")
218
+ lines.append(f" Status : {run_status}")
219
+ if run_duration_ms > 0:
220
+ lines.append(f" Duration : {run_duration_ms:,.1f} ms")
221
+ lines.append(f" Total cost : ${total_usd:.6f}")
222
+ lines.append(f" Input tokens : {total_input_tokens:,}")
223
+ lines.append(f" Output tokens : {total_output_tokens:,}")
224
+ lines.append(f" LLM calls : {len(cost_events)}")
225
+ lines.append("-" * 62)
226
+
227
+ if by_model:
228
+ lines.append(" Cost by model:")
229
+ lines.append(
230
+ f" {'Model':<30s} {'Calls':>5s} {'Input $':>9s} {'Output $':>9s} {'Total $':>10s}"
231
+ )
232
+ lines.append(f" {'-' * 30} {'-' * 5} {'-' * 9} {'-' * 9} {'-' * 10}")
233
+ for model_name, data in sorted(
234
+ by_model.items(), key=lambda kv: kv[1]["total_cost"], reverse=True
235
+ ):
236
+ lines.append(
237
+ f" {model_name:<30s} {int(data['calls']):>5d} "
238
+ f"${data['input_cost']:>8.6f} ${data['output_cost']:>8.6f} ${data['total_cost']:>9.6f}"
239
+ )
240
+
241
+ lines.append("=" * 62)
242
+ print("\n".join(lines))
243
+ return 0