hypermind 0.11.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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""LLM cost tracking — token accounting + USD estimation.
|
|
2
|
+
|
|
3
|
+
Every OpenAI/Anthropic/OpenRouter call carries a ``usage`` payload with
|
|
4
|
+
``prompt_tokens`` and ``completion_tokens``. The :class:`TokenCostTracker`
|
|
5
|
+
captures these, converts to USD via :data:`PRICE_TABLE`, and emits
|
|
6
|
+
Prometheus counters and recorder events so dashboards can watch spend in
|
|
7
|
+
real time.
|
|
8
|
+
|
|
9
|
+
Patterns:
|
|
10
|
+
* One :class:`TokenCostTracker` per :class:`_NamespaceWorld`.
|
|
11
|
+
* Each LLM responder (OpenAIResponder, AnthropicResponder,
|
|
12
|
+
ToolCallingResponder) accepts an optional ``cost_tracker``.
|
|
13
|
+
* The tracker emits ``llm_token_cost`` events with full per-call
|
|
14
|
+
detail (model, agent_kid, tokens, cost) and increments
|
|
15
|
+
``hypermind_llm_tokens_total{model,direction}`` counters.
|
|
16
|
+
|
|
17
|
+
Pricing notes:
|
|
18
|
+
* Prices are USD per 1 million tokens, sourced from public model pages
|
|
19
|
+
on 2026-05-01. Prices update regularly — operators can override via
|
|
20
|
+
``TokenCostTracker(price_table_override={...})``.
|
|
21
|
+
* Prices for OpenRouter pass-through models match the underlying
|
|
22
|
+
provider's quoted price. OpenRouter's small markup (~5%) is not
|
|
23
|
+
accounted for; use this as an estimate, not as billing input.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from hypermind.responders.structured import TokenUsage
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Price table — USD per 1M tokens (input, output)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
#: Update version stamp — bump when prices change so deployments can
|
|
39
|
+
#: detect stale tables.
|
|
40
|
+
PRICE_TABLE_VERSION = "2026-05-01"
|
|
41
|
+
|
|
42
|
+
#: ``model_id -> (input_per_1m_usd, output_per_1m_usd)``.
|
|
43
|
+
#:
|
|
44
|
+
#: The dict is keyed by *canonical* model ID. OpenRouter prefixes
|
|
45
|
+
#: (``openai/``, ``anthropic/``, ``meta-llama/``, …) are accepted in
|
|
46
|
+
#: addition to bare names so callers don't have to normalise.
|
|
47
|
+
PRICE_TABLE: dict[str, tuple[float, float]] = {
|
|
48
|
+
# OpenAI
|
|
49
|
+
"gpt-4o": (2.50, 10.00),
|
|
50
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
51
|
+
"gpt-4-turbo": (10.00, 30.00),
|
|
52
|
+
"openai/gpt-4o": (2.50, 10.00),
|
|
53
|
+
"openai/gpt-4o-mini": (0.15, 0.60),
|
|
54
|
+
"openai/gpt-4-turbo": (10.00, 30.00),
|
|
55
|
+
# Anthropic
|
|
56
|
+
"claude-opus-4-7": (15.00, 75.00),
|
|
57
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
58
|
+
"claude-sonnet-4": (3.00, 15.00),
|
|
59
|
+
"claude-haiku-4-5": (0.80, 4.00),
|
|
60
|
+
"claude-haiku-3-5": (0.80, 4.00),
|
|
61
|
+
"anthropic/claude-opus-4-7": (15.00, 75.00),
|
|
62
|
+
"anthropic/claude-sonnet-4-6": (3.00, 15.00),
|
|
63
|
+
"anthropic/claude-sonnet-4": (3.00, 15.00),
|
|
64
|
+
"anthropic/claude-haiku-4-5": (0.80, 4.00),
|
|
65
|
+
# Google
|
|
66
|
+
"google/gemini-2.0-flash": (0.075, 0.30),
|
|
67
|
+
"google/gemini-1.5-pro": (1.25, 5.00),
|
|
68
|
+
# Open-weights via OpenRouter
|
|
69
|
+
"meta-llama/llama-3.3-70b-instruct": (0.18, 0.18),
|
|
70
|
+
"meta-llama/llama-3.1-70b-instruct": (0.18, 0.18),
|
|
71
|
+
"deepseek/deepseek-chat": (0.27, 1.10),
|
|
72
|
+
"qwen/qwen-2.5-72b-instruct": (0.20, 0.20),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cost_usd(
|
|
77
|
+
model: str,
|
|
78
|
+
prompt_tokens: int,
|
|
79
|
+
completion_tokens: int,
|
|
80
|
+
*,
|
|
81
|
+
price_table: dict[str, tuple[float, float]] | None = None,
|
|
82
|
+
) -> float | None:
|
|
83
|
+
"""Compute USD cost for one LLM call.
|
|
84
|
+
|
|
85
|
+
Returns None if the model isn't in the price table — operators see
|
|
86
|
+
the warning in the recorder rather than a wrong cost figure.
|
|
87
|
+
"""
|
|
88
|
+
table = price_table if price_table is not None else PRICE_TABLE
|
|
89
|
+
prices = table.get(model)
|
|
90
|
+
if prices is None:
|
|
91
|
+
return None
|
|
92
|
+
input_per_1m, output_per_1m = prices
|
|
93
|
+
return (prompt_tokens / 1_000_000) * input_per_1m + (
|
|
94
|
+
completion_tokens / 1_000_000
|
|
95
|
+
) * output_per_1m
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# TokenCostTracker
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TokenCostTracker:
|
|
104
|
+
"""Aggregates LLM cost events across all responders attached to a world.
|
|
105
|
+
|
|
106
|
+
Thread-safe (used from coroutines under a single event loop, but
|
|
107
|
+
OpenAI / Anthropic SDKs may schedule retries on threads).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
recorder: Any = None,
|
|
114
|
+
price_table_override: dict[str, tuple[float, float]] | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._records: list[dict[str, Any]] = []
|
|
117
|
+
self._lock = threading.Lock()
|
|
118
|
+
self.recorder = recorder
|
|
119
|
+
self.price_table = (
|
|
120
|
+
dict(price_table_override) if price_table_override is not None else PRICE_TABLE
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Write path
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def record(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
model: str,
|
|
131
|
+
prompt_tokens: int,
|
|
132
|
+
completion_tokens: int,
|
|
133
|
+
agent_kid: bytes | None = None,
|
|
134
|
+
topic: str | None = None,
|
|
135
|
+
ts_wall_ms: int | None = None,
|
|
136
|
+
) -> TokenUsage:
|
|
137
|
+
"""Record one LLM call and return its :class:`TokenUsage`."""
|
|
138
|
+
ts = int(time.time() * 1000) if ts_wall_ms is None else int(ts_wall_ms)
|
|
139
|
+
c = cost_usd(model, prompt_tokens, completion_tokens, price_table=self.price_table)
|
|
140
|
+
usage = TokenUsage(
|
|
141
|
+
model=model,
|
|
142
|
+
prompt_tokens=prompt_tokens,
|
|
143
|
+
completion_tokens=completion_tokens,
|
|
144
|
+
cost_usd=c,
|
|
145
|
+
)
|
|
146
|
+
rec = {
|
|
147
|
+
"ts_wall_ms": ts,
|
|
148
|
+
"model": model,
|
|
149
|
+
"prompt_tokens": prompt_tokens,
|
|
150
|
+
"completion_tokens": completion_tokens,
|
|
151
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
152
|
+
"cost_usd": c,
|
|
153
|
+
"agent_kid_hex": agent_kid.hex() if agent_kid else None,
|
|
154
|
+
"topic": topic,
|
|
155
|
+
}
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._records.append(rec)
|
|
158
|
+
|
|
159
|
+
# Fan-out to recorder (best-effort)
|
|
160
|
+
if self.recorder is not None:
|
|
161
|
+
try:
|
|
162
|
+
self.recorder.emit(
|
|
163
|
+
event_type="llm_token_cost",
|
|
164
|
+
severity="info",
|
|
165
|
+
fields=rec,
|
|
166
|
+
)
|
|
167
|
+
self.recorder.incr(
|
|
168
|
+
"hypermind_llm_tokens_total",
|
|
169
|
+
by=prompt_tokens + completion_tokens,
|
|
170
|
+
model=model,
|
|
171
|
+
)
|
|
172
|
+
if c is not None:
|
|
173
|
+
self.recorder.incr(
|
|
174
|
+
"hypermind_llm_cost_usd_total",
|
|
175
|
+
by=int(c * 100_000), # store milli-cents to keep integer counter
|
|
176
|
+
model=model,
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
self.recorder.emit(
|
|
180
|
+
event_type="llm_unknown_model_pricing",
|
|
181
|
+
severity="warn",
|
|
182
|
+
fields={"model": model, "tokens": prompt_tokens + completion_tokens},
|
|
183
|
+
)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
return usage
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Read path
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def total_usd(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
agent_kid: bytes | None = None,
|
|
197
|
+
model: str | None = None,
|
|
198
|
+
) -> float:
|
|
199
|
+
"""Sum of cost_usd across matching records (None costs ignored)."""
|
|
200
|
+
with self._lock:
|
|
201
|
+
rows = list(self._records)
|
|
202
|
+
out = 0.0
|
|
203
|
+
for r in rows:
|
|
204
|
+
if r["cost_usd"] is None:
|
|
205
|
+
continue
|
|
206
|
+
if agent_kid is not None and r.get("agent_kid_hex") != agent_kid.hex():
|
|
207
|
+
continue
|
|
208
|
+
if model is not None and r.get("model") != model:
|
|
209
|
+
continue
|
|
210
|
+
out += r["cost_usd"]
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
def total_tokens(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
agent_kid: bytes | None = None,
|
|
217
|
+
model: str | None = None,
|
|
218
|
+
) -> int:
|
|
219
|
+
with self._lock:
|
|
220
|
+
rows = list(self._records)
|
|
221
|
+
out = 0
|
|
222
|
+
for r in rows:
|
|
223
|
+
if agent_kid is not None and r.get("agent_kid_hex") != agent_kid.hex():
|
|
224
|
+
continue
|
|
225
|
+
if model is not None and r.get("model") != model:
|
|
226
|
+
continue
|
|
227
|
+
out += r["total_tokens"]
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
231
|
+
"""Return a defensive copy of every recorded call."""
|
|
232
|
+
with self._lock:
|
|
233
|
+
return [dict(r) for r in self._records]
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict[str, Any]:
|
|
236
|
+
"""Aggregate summary suitable for the /v1/cost endpoint."""
|
|
237
|
+
snap = self.snapshot()
|
|
238
|
+
# Per-model breakdown
|
|
239
|
+
by_model: dict[str, dict[str, Any]] = {}
|
|
240
|
+
total_usd = 0.0
|
|
241
|
+
total_tok = 0
|
|
242
|
+
for r in snap:
|
|
243
|
+
m = r["model"]
|
|
244
|
+
slot = by_model.setdefault(
|
|
245
|
+
m,
|
|
246
|
+
{
|
|
247
|
+
"calls": 0,
|
|
248
|
+
"total_tokens": 0,
|
|
249
|
+
"cost_usd": 0.0,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
slot["calls"] += 1
|
|
253
|
+
slot["total_tokens"] += r["total_tokens"]
|
|
254
|
+
if r["cost_usd"] is not None:
|
|
255
|
+
slot["cost_usd"] += r["cost_usd"]
|
|
256
|
+
total_usd += r["cost_usd"]
|
|
257
|
+
total_tok += r["total_tokens"]
|
|
258
|
+
return {
|
|
259
|
+
"n_calls": len(snap),
|
|
260
|
+
"total_tokens": total_tok,
|
|
261
|
+
"total_usd": total_usd,
|
|
262
|
+
"by_model": by_model,
|
|
263
|
+
"price_table_version": PRICE_TABLE_VERSION,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
def reset(self) -> None:
|
|
267
|
+
"""Clear all records — for tests or per-session resets."""
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._records.clear()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"PRICE_TABLE",
|
|
274
|
+
"PRICE_TABLE_VERSION",
|
|
275
|
+
"TokenCostTracker",
|
|
276
|
+
"cost_usd",
|
|
277
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""OpenTelemetry exporter — real-time OTLP push of consult/dispute spans.
|
|
2
|
+
|
|
3
|
+
The :class:`OtelExporter` listens for recorder events and emits OTel
|
|
4
|
+
spans for the verbs that matter to operators:
|
|
5
|
+
|
|
6
|
+
* ``publish_claim`` → span "hypermind.publish"
|
|
7
|
+
* ``dispute`` → span "hypermind.dispute"
|
|
8
|
+
* ``consult_panel`` → parent span "hypermind.consult"
|
|
9
|
+
* ``llm_token_cost`` → child span "hypermind.llm_call"
|
|
10
|
+
* ``oracle_resolve`` → span "hypermind.oracle"
|
|
11
|
+
* ``tool_call`` → child span "hypermind.tool_call"
|
|
12
|
+
|
|
13
|
+
Soft-depends on ``opentelemetry-api`` + ``opentelemetry-sdk``. When
|
|
14
|
+
those packages aren't installed the exporter degrades gracefully — it
|
|
15
|
+
records nothing but never raises.
|
|
16
|
+
|
|
17
|
+
The exporter is *attached*, not constructed inline: callers build an
|
|
18
|
+
OTel ``TracerProvider`` (with whatever exporter they prefer — OTLP
|
|
19
|
+
gRPC, Jaeger, etc.) and pass it. We don't pin a specific transport
|
|
20
|
+
because operators have wildly different deployment shapes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OtelExporter:
|
|
29
|
+
"""Bridges :class:`Recorder` events to OpenTelemetry spans.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
34
|
+
from opentelemetry.sdk.trace.export import (
|
|
35
|
+
BatchSpanProcessor,
|
|
36
|
+
ConsoleSpanExporter,
|
|
37
|
+
)
|
|
38
|
+
from hypermind.observability.otel_export import OtelExporter
|
|
39
|
+
|
|
40
|
+
provider = TracerProvider()
|
|
41
|
+
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
42
|
+
|
|
43
|
+
exporter = OtelExporter(tracer_provider=provider, service_name="hypermind")
|
|
44
|
+
exporter.attach(agent._world.recorder)
|
|
45
|
+
|
|
46
|
+
For an OTLP push (Jaeger/Tempo), substitute the OTLPSpanExporter::
|
|
47
|
+
|
|
48
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
49
|
+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=...)))
|
|
50
|
+
|
|
51
|
+
The exporter never owns the TracerProvider — operators control its
|
|
52
|
+
lifecycle, including shutdown.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
tracer_provider: Any = None,
|
|
59
|
+
service_name: str = "hypermind",
|
|
60
|
+
) -> None:
|
|
61
|
+
self.service_name = service_name
|
|
62
|
+
self._tracer = None
|
|
63
|
+
self._available = False
|
|
64
|
+
|
|
65
|
+
if tracer_provider is None:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
self._tracer = tracer_provider.get_tracer(service_name)
|
|
70
|
+
self._available = True
|
|
71
|
+
except Exception:
|
|
72
|
+
self._tracer = None
|
|
73
|
+
self._available = False
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Available — operators check this to know whether OTel is hot
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def available(self) -> bool:
|
|
81
|
+
return self._available
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Attach to a recorder — listen for events and emit spans
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def attach(self, recorder: Any) -> None:
|
|
88
|
+
"""Subscribe to a recorder so subsequent events emit OTel spans.
|
|
89
|
+
|
|
90
|
+
We patch the recorder's ``emit()`` method to also call our
|
|
91
|
+
:meth:`_handle_event`. This keeps the exporter optional — if
|
|
92
|
+
nobody attaches one, recorder behaviour is unchanged.
|
|
93
|
+
"""
|
|
94
|
+
if not self._available or recorder is None:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
original_emit = recorder.emit
|
|
98
|
+
|
|
99
|
+
def emit_with_otel(*args: Any, **kwargs: Any) -> None:
|
|
100
|
+
try:
|
|
101
|
+
original_emit(*args, **kwargs)
|
|
102
|
+
finally:
|
|
103
|
+
try:
|
|
104
|
+
self._handle_event(kwargs)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass # never break the recorder
|
|
107
|
+
|
|
108
|
+
recorder.emit = emit_with_otel # type: ignore[method-assign]
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Event → span mapping
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
_EVENT_TO_SPAN: ClassVar[dict[str, str]] = {
|
|
115
|
+
"seal": "hypermind.publish",
|
|
116
|
+
"dispute_open": "hypermind.dispute",
|
|
117
|
+
"consult_panel": "hypermind.consult",
|
|
118
|
+
"consult_deliberation": "hypermind.deliberation",
|
|
119
|
+
"consult_responder_error": "hypermind.consult.responder_error",
|
|
120
|
+
"llm_token_cost": "hypermind.llm_call",
|
|
121
|
+
"tool_call": "hypermind.tool_call",
|
|
122
|
+
"router_fallback": "hypermind.router.fallback",
|
|
123
|
+
"oracle_resolve": "hypermind.oracle",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _handle_event(self, emit_kwargs: dict[str, Any]) -> None:
|
|
127
|
+
if self._tracer is None:
|
|
128
|
+
return
|
|
129
|
+
event_type = emit_kwargs.get("event_type", "")
|
|
130
|
+
span_name = self._EVENT_TO_SPAN.get(event_type)
|
|
131
|
+
if span_name is None:
|
|
132
|
+
return # not an event we surface to OTel
|
|
133
|
+
|
|
134
|
+
# Span attributes — flatten the fields dict into "field.<k>=<v>"
|
|
135
|
+
# attribute keys so OTel UIs can filter on them.
|
|
136
|
+
attrs: dict[str, Any] = {
|
|
137
|
+
"service.name": self.service_name,
|
|
138
|
+
"hypermind.event_type": event_type,
|
|
139
|
+
}
|
|
140
|
+
ns = emit_kwargs.get("namespace")
|
|
141
|
+
if ns:
|
|
142
|
+
attrs["hypermind.namespace"] = ns
|
|
143
|
+
agent_id = emit_kwargs.get("agent_id")
|
|
144
|
+
if agent_id:
|
|
145
|
+
attrs["hypermind.agent_id"] = agent_id
|
|
146
|
+
fields = emit_kwargs.get("fields", {}) or {}
|
|
147
|
+
for k, v in fields.items():
|
|
148
|
+
if isinstance(v, (str, bool, int, float)):
|
|
149
|
+
attrs[f"hypermind.field.{k}"] = v
|
|
150
|
+
else:
|
|
151
|
+
attrs[f"hypermind.field.{k}"] = repr(v)[:240]
|
|
152
|
+
|
|
153
|
+
# We don't know the actual operation duration here, so we emit
|
|
154
|
+
# a zero-duration "instant" span. Production callers that want
|
|
155
|
+
# parent/child relationships should use the explicit
|
|
156
|
+
# span_consult() context manager instead.
|
|
157
|
+
with self._tracer.start_as_current_span(
|
|
158
|
+
name=span_name,
|
|
159
|
+
attributes=attrs,
|
|
160
|
+
) as _:
|
|
161
|
+
# No-op — we just want the span recorded.
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Explicit span context managers — for callers that want correct durations
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def span_consult(self, query: str, *, panel_size: int) -> Any:
|
|
169
|
+
"""Start a parent span for one consult call.
|
|
170
|
+
|
|
171
|
+
Usage::
|
|
172
|
+
|
|
173
|
+
with exporter.span_consult(query="...", panel_size=8):
|
|
174
|
+
result = await agent.consult(...)
|
|
175
|
+
"""
|
|
176
|
+
if self._tracer is None:
|
|
177
|
+
return _NoopSpan()
|
|
178
|
+
return self._tracer.start_as_current_span(
|
|
179
|
+
name="hypermind.consult",
|
|
180
|
+
attributes={
|
|
181
|
+
"hypermind.consult.panel_size": int(panel_size),
|
|
182
|
+
"hypermind.consult.query_len": len(query),
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _NoopSpan:
|
|
188
|
+
"""Stand-in for an OTel span when the exporter isn't available."""
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> _NoopSpan:
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc: Any) -> None:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def set_attribute(self, *args: Any, **kwargs: Any) -> None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = ["OtelExporter"]
|