activegraph 1.0.0rc2__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 (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,339 @@
1
+ """Reference provider: Anthropic.
2
+
3
+ CONTRACT v0.6 #4. The SDK is imported lazily so the rest of the LLM
4
+ package works without `pip install anthropic`. API key comes from
5
+ ANTHROPIC_API_KEY — never from code, never from a checked-in config.
6
+
7
+ What this provider does:
8
+ * `complete()` — single non-streaming `messages.create` call.
9
+ Structured output is handled by the wrapper, not the provider —
10
+ we just hand back raw text plus token counts.
11
+ * `count_tokens()` — Anthropic's official `count_tokens` API.
12
+ Network roundtrip; the runtime only calls it when
13
+ `budget.max_cost_usd` is set AND no cached response was found
14
+ (decision-4 adjustment).
15
+ * `estimate_cost()` — table-driven, USD Decimals. Pricing as of
16
+ the model-family rates current in 2026; override via the
17
+ `pricing=` constructor kwarg to keep it accurate over time.
18
+
19
+ No tool-use, no streaming, no multi-message orchestration. v0.6
20
+ keeps the surface narrow on purpose.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import time
28
+ from decimal import Decimal
29
+ from typing import Any, Mapping, Optional
30
+
31
+ from activegraph.llm.errors import LLMBehaviorError
32
+ from activegraph.llm.provider import LLMProvider
33
+ from activegraph.llm.types import LLMMessage, LLMResponse, ToolCall
34
+
35
+
36
+ # Per-million-token pricing in USD. Tracks the rates of the Claude 4.x
37
+ # family available in May 2026. Provide your own `pricing=` to override.
38
+ _DEFAULT_PRICING: dict[str, dict[str, str]] = {
39
+ "claude-opus-4": {"input": "15", "output": "75"},
40
+ "claude-sonnet-4": {"input": "3", "output": "15"},
41
+ "claude-haiku-4-5": {"input": "1", "output": "5"},
42
+ }
43
+
44
+
45
+ def _pricing_for(model: str, pricing: Mapping[str, Mapping[str, str]]) -> tuple[Decimal, Decimal]:
46
+ """Lookup by longest matching family prefix.
47
+
48
+ `claude-sonnet-4-6` resolves to the `claude-sonnet-4` family entry.
49
+ Unknown models fall back to sonnet-4 pricing and emit a warning
50
+ via the returned `Decimal` (caller can detect by comparing to
51
+ family default).
52
+ """
53
+
54
+ best_key: Optional[str] = None
55
+ for key in pricing:
56
+ if model.startswith(key) and (best_key is None or len(key) > len(best_key)):
57
+ best_key = key
58
+ if best_key is None:
59
+ best_key = "claude-sonnet-4"
60
+ entry = pricing[best_key]
61
+ return Decimal(str(entry["input"])), Decimal(str(entry["output"]))
62
+
63
+
64
+ class AnthropicProvider(LLMProvider):
65
+ def __init__(
66
+ self,
67
+ *,
68
+ api_key_env: str = "ANTHROPIC_API_KEY",
69
+ client: Any = None,
70
+ pricing: Optional[Mapping[str, Mapping[str, str]]] = None,
71
+ ) -> None:
72
+ self._api_key_env = api_key_env
73
+ self._client_override = client
74
+ self._pricing: dict[str, dict[str, str]] = dict(pricing or _DEFAULT_PRICING)
75
+ self._client_cached: Any = None
76
+
77
+ # ---- client lazy-load ----
78
+
79
+ def _client(self) -> Any:
80
+ if self._client_override is not None:
81
+ return self._client_override
82
+ if self._client_cached is not None:
83
+ return self._client_cached
84
+ try:
85
+ from anthropic import Anthropic # type: ignore
86
+ except ImportError as e:
87
+ raise RuntimeError(
88
+ "AnthropicProvider requires the `anthropic` SDK. "
89
+ "Install with `pip install activegraph[llm]` "
90
+ "or `pip install anthropic`."
91
+ ) from e
92
+ import os
93
+
94
+ if os.environ.get(self._api_key_env) is None:
95
+ raise RuntimeError(
96
+ f"AnthropicProvider needs {self._api_key_env} in the environment."
97
+ )
98
+ self._client_cached = Anthropic()
99
+ return self._client_cached
100
+
101
+ # ---- LLMProvider methods ----
102
+
103
+ def complete(
104
+ self,
105
+ *,
106
+ system: str,
107
+ messages: list[LLMMessage],
108
+ model: str,
109
+ max_tokens: int,
110
+ temperature: float,
111
+ top_p: float,
112
+ output_schema: Optional[type],
113
+ timeout_seconds: float,
114
+ tools: Optional[list[dict[str, Any]]] = None,
115
+ ) -> LLMResponse:
116
+ client = self._client()
117
+ kwargs: dict[str, Any] = {
118
+ "model": model,
119
+ "max_tokens": int(max_tokens),
120
+ "messages": [_message_to_anthropic(m) for m in messages],
121
+ "temperature": float(temperature),
122
+ }
123
+ if system:
124
+ kwargs["system"] = system
125
+ # top_p of 1.0 is the model default; only forward when narrowing.
126
+ if top_p < 1.0:
127
+ kwargs["top_p"] = float(top_p)
128
+ if tools:
129
+ # Anthropic's tools shape: {"name", "description", "input_schema"}.
130
+ # Our Tool.to_definition() already emits this shape.
131
+ kwargs["tools"] = list(tools)
132
+
133
+ t0 = time.monotonic()
134
+ try:
135
+ raw = client.messages.create(timeout=timeout_seconds, **kwargs)
136
+ except Exception as e:
137
+ # Map provider exceptions to reason codes per CONTRACT v0.6 #11.
138
+ reason = _classify_provider_exception(e)
139
+ extras: dict[str, Any] = {
140
+ "model": model,
141
+ "exception_type": type(e).__name__,
142
+ "message": str(e),
143
+ }
144
+ ra = _retry_after_seconds(e)
145
+ if ra is not None:
146
+ extras["retry_after_seconds"] = ra
147
+ raise LLMBehaviorError(reason, str(e), payload_extras=extras) from e
148
+ latency = time.monotonic() - t0
149
+
150
+ text = _extract_text(raw)
151
+ tool_calls = _extract_tool_calls(raw)
152
+ parsed: Any = None
153
+ # If the model returned tool_use blocks the loop isn't done yet
154
+ # — the runtime will dispatch the tools and re-call. Parsing
155
+ # structured output happens on the final turn, not mid-loop.
156
+ if output_schema is not None and not tool_calls:
157
+ parsed = _parse_structured(text, output_schema)
158
+
159
+ in_tok = int(getattr(raw.usage, "input_tokens", 0) or 0)
160
+ out_tok = int(getattr(raw.usage, "output_tokens", 0) or 0)
161
+ cost = self.estimate_cost(
162
+ input_tokens=in_tok, output_tokens=out_tok, model=model
163
+ )
164
+ return LLMResponse(
165
+ raw_text=text,
166
+ parsed=parsed,
167
+ input_tokens=in_tok,
168
+ output_tokens=out_tok,
169
+ cost_usd=cost,
170
+ latency_seconds=latency,
171
+ model=getattr(raw, "model", model),
172
+ finish_reason=str(getattr(raw, "stop_reason", "end_turn") or "end_turn"),
173
+ seed=None, # Anthropic messages API has no seed parameter.
174
+ cache_hit=False,
175
+ tool_calls=tool_calls or None,
176
+ )
177
+
178
+ def estimate_cost(
179
+ self,
180
+ *,
181
+ input_tokens: int,
182
+ output_tokens: int,
183
+ model: str,
184
+ ) -> Decimal:
185
+ in_price, out_price = _pricing_for(model, self._pricing)
186
+ million = Decimal("1000000")
187
+ return (Decimal(input_tokens) * in_price / million) + (
188
+ Decimal(output_tokens) * out_price / million
189
+ )
190
+
191
+ def count_tokens(
192
+ self,
193
+ *,
194
+ system: str,
195
+ messages: list[LLMMessage],
196
+ model: str,
197
+ ) -> int:
198
+ client = self._client()
199
+ kwargs: dict[str, Any] = {
200
+ "model": model,
201
+ "messages": [m.to_dict() for m in messages],
202
+ }
203
+ if system:
204
+ kwargs["system"] = system
205
+ result = client.messages.count_tokens(**kwargs)
206
+ return int(getattr(result, "input_tokens", 0) or 0)
207
+
208
+
209
+ # ---- helpers ---------------------------------------------------------------
210
+
211
+
212
+ def _extract_text(raw: Any) -> str:
213
+ """Concatenate text from a `Message.content` block list."""
214
+ content = getattr(raw, "content", None)
215
+ if content is None:
216
+ return ""
217
+ parts: list[str] = []
218
+ for block in content:
219
+ text = getattr(block, "text", None)
220
+ if isinstance(text, str):
221
+ parts.append(text)
222
+ return "".join(parts)
223
+
224
+
225
+ def _extract_tool_calls(raw: Any) -> list[ToolCall]:
226
+ """Extract `tool_use` blocks from a `Message.content` list. v0.7."""
227
+ content = getattr(raw, "content", None)
228
+ if content is None:
229
+ return []
230
+ out: list[ToolCall] = []
231
+ for block in content:
232
+ block_type = getattr(block, "type", None)
233
+ if block_type != "tool_use":
234
+ continue
235
+ call_id = getattr(block, "id", None) or ""
236
+ name = getattr(block, "name", None) or ""
237
+ args = getattr(block, "input", None) or {}
238
+ if isinstance(args, str):
239
+ try:
240
+ args = json.loads(args)
241
+ except Exception:
242
+ args = {"_raw": args}
243
+ out.append(ToolCall(id=call_id, name=name, args=dict(args)))
244
+ return out
245
+
246
+
247
+ def _message_to_anthropic(m: LLMMessage) -> dict[str, Any]:
248
+ """Convert an LLMMessage to Anthropic message-list shape.
249
+
250
+ For role="tool", Anthropic wants a "user" message with a
251
+ `tool_result` content block. For role in {"user","assistant"} the
252
+ standard {role, content: str} shape works.
253
+ """
254
+ if m.role == "tool":
255
+ return {
256
+ "role": "user",
257
+ "content": [
258
+ {
259
+ "type": "tool_result",
260
+ "tool_use_id": m.tool_use_id or "",
261
+ "content": m.content,
262
+ }
263
+ ],
264
+ }
265
+ return {"role": m.role, "content": m.content}
266
+
267
+
268
+ _FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", re.DOTALL)
269
+ _BRACE_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL)
270
+
271
+
272
+ def _parse_structured(text: str, schema: type) -> Any:
273
+ """Parse JSON out of an LLM response and validate against `schema`.
274
+
275
+ Strategy: try direct JSON first; on failure, extract a fenced
276
+ ```json``` block; on failure, grab the first {...}/[...] span.
277
+ Two distinct failure modes flow back as `LLMBehaviorError`:
278
+
279
+ reason=llm.parse_error — no JSON found / json.loads failed
280
+ reason=llm.schema_violation — JSON found but Pydantic rejected it
281
+ """
282
+
283
+ candidate = text.strip()
284
+ obj: Any = None
285
+ parse_err: Optional[Exception] = None
286
+ try:
287
+ obj = json.loads(candidate)
288
+ except Exception as e:
289
+ parse_err = e
290
+ if obj is None:
291
+ m = _FENCED_JSON_RE.search(text) or _BRACE_RE.search(text)
292
+ if m:
293
+ try:
294
+ obj = json.loads(m.group(1))
295
+ parse_err = None
296
+ except Exception as e:
297
+ parse_err = e
298
+ if obj is None:
299
+ raise LLMBehaviorError(
300
+ "llm.parse_error",
301
+ f"no JSON found in response: {parse_err}",
302
+ payload_extras={"raw_text": text, "underlying": str(parse_err)},
303
+ )
304
+
305
+ try:
306
+ return schema.model_validate(obj)
307
+ except Exception as e:
308
+ raise LLMBehaviorError(
309
+ "llm.schema_violation",
310
+ f"response did not match schema {schema.__name__}: {e}",
311
+ payload_extras={
312
+ "raw_text": text,
313
+ "schema": schema.__name__,
314
+ "validation_errors": str(e),
315
+ },
316
+ ) from e
317
+
318
+
319
+ def _classify_provider_exception(e: Exception) -> str:
320
+ name = type(e).__name__.lower()
321
+ if "ratelimit" in name or "429" in str(e):
322
+ return "llm.rate_limited"
323
+ if "timeout" in name or "connect" in name:
324
+ return "llm.network_error"
325
+ return "llm.network_error"
326
+
327
+
328
+ def _retry_after_seconds(e: Exception) -> Optional[float]:
329
+ response = getattr(e, "response", None)
330
+ headers = getattr(response, "headers", None) if response is not None else None
331
+ if headers is None:
332
+ return None
333
+ ra = headers.get("retry-after") if hasattr(headers, "get") else None
334
+ if ra is None:
335
+ return None
336
+ try:
337
+ return float(ra)
338
+ except (TypeError, ValueError):
339
+ return None
@@ -0,0 +1,180 @@
1
+ """Replay LLM cache.
2
+
3
+ CONTRACT v0.6 #8. The cache is keyed by prompt hash (content-match),
4
+ not by event id — that's what lets a fork's regenerated prompts hit
5
+ the same recorded responses. The originating `llm.requested` event id
6
+ is stored alongside the cached response for trace lineage but is not
7
+ the lookup key.
8
+
9
+ Population paths:
10
+
11
+ * `LLMCache.from_events(events)` — used at `Runtime.load(...,
12
+ replay_llm_cache=True)` and `runtime.fork(...,
13
+ replay_llm_cache=True)`. Walks the recorded log and harvests every
14
+ `llm.responded` event whose preceding `llm.requested` carries a
15
+ `prompt_hash`.
16
+
17
+ * `cache.record(prompt_hash, response, requesting_event_id)` —
18
+ called inline from the LLM-behavior invocation path so that
19
+ same-run repeat prompts hit the cache too (cheap insurance).
20
+
21
+ `replay_strict=True` semantics layer on top: if a recorded
22
+ `llm.responded` event references a `prompt_hash` that the live
23
+ re-assembled prompt does NOT produce, the runtime raises
24
+ `ReplayDivergenceError(event_id=<llm.requested id>, expected=<recorded
25
+ hash>, actual=<rebuilt hash>)` — same divergence-pinning pattern as
26
+ the existing event-stream comparison. See decision-2 adjustment.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from decimal import Decimal
33
+ from typing import Iterable, Optional
34
+
35
+ from activegraph.core.event import Event
36
+ from activegraph.llm.types import LLMResponse
37
+
38
+
39
+ @dataclass
40
+ class CachedEntry:
41
+ response: LLMResponse
42
+ requested_event_id: Optional[str]
43
+
44
+
45
+ class LLMCache:
46
+ def __init__(self) -> None:
47
+ self._by_hash: dict[str, CachedEntry] = {}
48
+
49
+ # ---- read ----
50
+
51
+ def get(self, prompt_hash: str) -> Optional[LLMResponse]:
52
+ entry = self._by_hash.get(prompt_hash)
53
+ if entry is None:
54
+ return None
55
+ # Mark cache_hit on a copy so we don't mutate the stored
56
+ # response (a second hit on the same hash should also report
57
+ # cache_hit=True).
58
+ r = entry.response
59
+ return LLMResponse(
60
+ raw_text=r.raw_text,
61
+ parsed=r.parsed,
62
+ input_tokens=r.input_tokens,
63
+ output_tokens=r.output_tokens,
64
+ cost_usd=r.cost_usd,
65
+ latency_seconds=r.latency_seconds,
66
+ model=r.model,
67
+ finish_reason=r.finish_reason,
68
+ seed=r.seed,
69
+ cache_hit=True,
70
+ provider_meta=dict(r.provider_meta),
71
+ # v0.7: tool_calls must round-trip so the turn loop sees
72
+ # the same shape live vs cached.
73
+ tool_calls=list(r.tool_calls) if r.tool_calls else None,
74
+ )
75
+
76
+ def has(self, prompt_hash: str) -> bool:
77
+ return prompt_hash in self._by_hash
78
+
79
+ def __len__(self) -> int:
80
+ return len(self._by_hash)
81
+
82
+ # ---- write ----
83
+
84
+ def record(
85
+ self,
86
+ prompt_hash: str,
87
+ response: LLMResponse,
88
+ *,
89
+ requesting_event_id: Optional[str] = None,
90
+ ) -> None:
91
+ # Store an un-flagged copy so subsequent `get()` calls
92
+ # consistently set cache_hit=True.
93
+ # v0.7: tolerate test-provider responses without tool_calls.
94
+ tool_calls = getattr(response, "tool_calls", None)
95
+ clean = LLMResponse(
96
+ raw_text=response.raw_text,
97
+ parsed=response.parsed,
98
+ input_tokens=response.input_tokens,
99
+ output_tokens=response.output_tokens,
100
+ cost_usd=response.cost_usd,
101
+ latency_seconds=response.latency_seconds,
102
+ model=response.model,
103
+ finish_reason=response.finish_reason,
104
+ seed=response.seed,
105
+ cache_hit=False,
106
+ provider_meta=dict(response.provider_meta),
107
+ tool_calls=list(tool_calls) if tool_calls else None,
108
+ )
109
+ self._by_hash[prompt_hash] = CachedEntry(
110
+ response=clean, requested_event_id=requesting_event_id
111
+ )
112
+
113
+ # ---- bulk-load from a recorded event log ----
114
+
115
+ @classmethod
116
+ def from_events(cls, events: Iterable[Event]) -> "LLMCache":
117
+ """Walk the log and harvest every `llm.responded` whose
118
+ preceding `llm.requested` carries a `prompt_hash`.
119
+
120
+ The pairing rule: an `llm.responded.caused_by` points at its
121
+ `llm.requested.id`. The `prompt_hash` is on the `llm.requested`
122
+ payload.
123
+ """
124
+
125
+ cache = cls()
126
+ events_list = list(events)
127
+ by_id: dict[str, Event] = {e.id: e for e in events_list}
128
+ for e in events_list:
129
+ if e.type != "llm.responded":
130
+ continue
131
+ request_id = e.caused_by
132
+ if request_id is None:
133
+ continue
134
+ request = by_id.get(request_id)
135
+ if request is None or request.type != "llm.requested":
136
+ continue
137
+ prompt_hash = request.payload.get("prompt_hash")
138
+ if not prompt_hash:
139
+ continue
140
+ response = _response_from_event_payload(e.payload)
141
+ cache.record(
142
+ prompt_hash, response, requesting_event_id=request_id
143
+ )
144
+ return cache
145
+
146
+
147
+ def _response_from_event_payload(payload: dict) -> LLMResponse:
148
+ from activegraph.llm.types import ToolCall
149
+
150
+ cost = payload.get("cost_usd", "0")
151
+ if not isinstance(cost, Decimal):
152
+ cost = Decimal(str(cost))
153
+ # v0.7: tool_calls round-trip through the event payload as plain
154
+ # dicts; hydrate back into ToolCall instances so the runtime's
155
+ # turn loop sees a uniform shape.
156
+ tc_payload = payload.get("tool_calls")
157
+ tool_calls = None
158
+ if tc_payload:
159
+ tool_calls = [
160
+ ToolCall(
161
+ id=tc.get("id", ""),
162
+ name=tc.get("name", ""),
163
+ args=dict(tc.get("args", {})),
164
+ )
165
+ for tc in tc_payload
166
+ ]
167
+ return LLMResponse(
168
+ raw_text=payload.get("raw_text", ""),
169
+ parsed=payload.get("parsed"),
170
+ input_tokens=int(payload.get("input_tokens", 0) or 0),
171
+ output_tokens=int(payload.get("output_tokens", 0) or 0),
172
+ cost_usd=cost,
173
+ latency_seconds=float(payload.get("latency_seconds", 0.0) or 0.0),
174
+ model=payload.get("model", "?"),
175
+ finish_reason=payload.get("finish_reason", "?"),
176
+ seed=payload.get("seed"),
177
+ cache_hit=False,
178
+ provider_meta=dict(payload.get("provider_meta", {}) or {}),
179
+ tool_calls=tool_calls,
180
+ )