differix 0.1.0a1__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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
@@ -0,0 +1,478 @@
1
+ """
2
+ tools/contract_validator.py
3
+ ----------------------------
4
+ Pre-write AST validation against the current structure index.
5
+
6
+ Runs before any edit is written to disk. If a proposed change set contains
7
+ calls to methods that don't exist, or imports that resolve to nothing, it
8
+ returns a structured report. main.py decides whether to block the write.
9
+
10
+ Design principles:
11
+ 1. Fail-open on internal error. A bug in the validator must never block
12
+ legitimate work; the sandbox is still the authority.
13
+ 2. Confidence-scored checks. Anything below 0.9 is a warning, not an error.
14
+ 3. Escape hatch: `# vegex: noqa-contract` on a line disables checks for
15
+ that line. `# vegex: ignore-dynamic` at module top disables the
16
+ dynamic-dispatch heuristics entirely.
17
+ 4. Fresh index, per Option-1 override — validator builds its own
18
+ StructureIndex.rebuild() internally so there's no staleness window.
19
+
20
+ Public API:
21
+ - ContractValidator(project_root).validate_change_set(change_set) -> ValidationReport
22
+ - ContractValidationError (raised only by strict mode; default is off)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+ import logging
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Dict, List, Optional, Set, Tuple
33
+
34
+ from tools.structure_index import StructureIndex, ModuleInfo, _path_to_module
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class ContractValidationError(Exception):
40
+ """Raised by strict mode when the report contains blocking errors."""
41
+
42
+
43
+ @dataclass
44
+ class ValidationIssue:
45
+ path: str
46
+ line: int
47
+ col: int
48
+ code: str
49
+ message: str
50
+ severity: str # "error" | "warning"
51
+ confidence: float
52
+
53
+ def as_dict(self) -> Dict:
54
+ return {
55
+ "path": self.path,
56
+ "line": self.line,
57
+ "col": self.col,
58
+ "code": self.code,
59
+ "message": self.message,
60
+ "severity": self.severity,
61
+ "confidence": round(self.confidence, 3),
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class ValidationReport:
67
+ issues: List[ValidationIssue] = field(default_factory=list)
68
+ files_checked: int = 0
69
+ elapsed_ms: float = 0.0
70
+ internal_error: Optional[str] = None
71
+
72
+ @property
73
+ def errors(self) -> List[ValidationIssue]:
74
+ return [i for i in self.issues if i.severity == "error"]
75
+
76
+ @property
77
+ def warnings(self) -> List[ValidationIssue]:
78
+ return [i for i in self.issues if i.severity == "warning"]
79
+
80
+ @property
81
+ def ok(self) -> bool:
82
+ return not self.errors and not self.internal_error
83
+
84
+ def as_dict(self) -> Dict:
85
+ return {
86
+ "ok": self.ok,
87
+ "errors": [i.as_dict() for i in self.errors],
88
+ "warnings": [i.as_dict() for i in self.warnings],
89
+ "files_checked": self.files_checked,
90
+ "elapsed_ms": round(self.elapsed_ms, 2),
91
+ "internal_error": self.internal_error,
92
+ }
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Builtin names we never flag
97
+ # ---------------------------------------------------------------------------
98
+ import builtins as _builtins_module
99
+ _BUILTIN_NAMES: Set[str] = set(dir(_builtins_module))
100
+
101
+ _COMMON_NAMES: Set[str] = {
102
+ "self", "cls", "True", "False", "None", "NotImplemented", "Ellipsis",
103
+ "__name__", "__file__", "__doc__", "__package__", "__spec__", "__loader__",
104
+ "__builtins__", "__debug__", "__all__",
105
+ }
106
+
107
+
108
+ class ContractValidator:
109
+ """
110
+ Validates proposed change sets against a freshly-built structure index.
111
+
112
+ The validator rebuilds the index on every call. Cost: ~20-200ms for a
113
+ typical project. Benefit: no staleness window, no LLM retries triggered
114
+ by a stale contract check.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ project_root: str,
120
+ min_error_confidence: float = 0.9,
121
+ rebuild_index_every_call: bool = True,
122
+ ) -> None:
123
+ self.project_root = str(Path(project_root).resolve())
124
+ self.min_error_confidence = min_error_confidence
125
+ self.rebuild_index_every_call = rebuild_index_every_call
126
+ self._cached_index: Optional[StructureIndex] = None
127
+
128
+ # ------------------------------------------------------------------ #
129
+ # Public entry point
130
+ # ------------------------------------------------------------------ #
131
+ def validate_change_set(self, change_set) -> ValidationReport:
132
+ """
133
+ Validate a ChangeSet. Never raises unless the caller sets strict
134
+ mode (default: fail-open, always returns a report).
135
+
136
+ `change_set` must implement `dry_apply(project_root) -> Dict[path, content]`.
137
+ """
138
+ t0 = time.perf_counter()
139
+ report = ValidationReport()
140
+
141
+ try:
142
+ post_apply = change_set.dry_apply(self.project_root)
143
+ except Exception as exc:
144
+ report.internal_error = f"dry_apply failed: {exc}"
145
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
146
+ return report
147
+
148
+ if not post_apply:
149
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
150
+ return report
151
+
152
+ try:
153
+ index = self._get_index()
154
+ except Exception as exc:
155
+ report.internal_error = f"index build failed: {exc}"
156
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
157
+ return report
158
+
159
+ report.files_checked = len(post_apply)
160
+
161
+ for rel_path, content in post_apply.items():
162
+ if not rel_path.endswith(".py"):
163
+ continue
164
+ try:
165
+ self._validate_python_file(rel_path, content, index, report)
166
+ except Exception as exc:
167
+ logger.warning("Validation error on %s: %s", rel_path, exc)
168
+
169
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
170
+ return report
171
+
172
+ # ------------------------------------------------------------------ #
173
+ # Index management
174
+ # ------------------------------------------------------------------ #
175
+ def _get_index(self) -> StructureIndex:
176
+ if self.rebuild_index_every_call or self._cached_index is None:
177
+ self._cached_index = StructureIndex.rebuild(self.project_root)
178
+ return self._cached_index
179
+
180
+ # ------------------------------------------------------------------ #
181
+ # Python file validation
182
+ # ------------------------------------------------------------------ #
183
+ def _validate_python_file(
184
+ self,
185
+ rel_path: str,
186
+ content: str,
187
+ index: StructureIndex,
188
+ report: ValidationReport,
189
+ ) -> None:
190
+ if _module_has_dynamic_opt_out(content):
191
+ return
192
+
193
+ try:
194
+ tree = ast.parse(content, filename=rel_path)
195
+ except SyntaxError as exc:
196
+ report.issues.append(ValidationIssue(
197
+ path=rel_path,
198
+ line=exc.lineno or 1,
199
+ col=exc.offset or 1,
200
+ code="syntax_error",
201
+ message=f"Syntax error: {exc.msg}",
202
+ severity="error",
203
+ confidence=1.0,
204
+ ))
205
+ return
206
+
207
+ local_names = _collect_local_names(tree)
208
+ imported_symbols = _collect_imported_symbols(tree)
209
+ import_module_aliases = _collect_import_module_aliases(tree)
210
+
211
+ for node in ast.walk(tree):
212
+ if _has_noqa(node, content):
213
+ continue
214
+ if isinstance(node, ast.Call):
215
+ self._check_call(
216
+ node, rel_path, index,
217
+ local_names, imported_symbols, import_module_aliases,
218
+ report,
219
+ )
220
+
221
+ # ------------------------------------------------------------------ #
222
+ # Call-site checks
223
+ # ------------------------------------------------------------------ #
224
+ def _check_call(
225
+ self,
226
+ node: ast.Call,
227
+ rel_path: str,
228
+ index: StructureIndex,
229
+ local_names: Set[str],
230
+ imported_symbols: Dict[str, Tuple[str, str]],
231
+ module_aliases: Dict[str, str],
232
+ report: ValidationReport,
233
+ ) -> None:
234
+ func = node.func
235
+
236
+ # Case A: bare Name — e.g. `foo(...)`
237
+ if isinstance(func, ast.Name):
238
+ name = func.id
239
+ if name in _BUILTIN_NAMES or name in _COMMON_NAMES:
240
+ return
241
+ if name in local_names:
242
+ return
243
+ if name in imported_symbols:
244
+ src_module, original_name = imported_symbols[name]
245
+ src_mod_info = index.modules.get(src_module)
246
+ if src_mod_info is None or src_mod_info.syntax_error:
247
+ return # can't check → fail-open
248
+ if not _module_exports(src_mod_info, original_name):
249
+ alias_note = (
250
+ f" (imported as '{name}')" if name != original_name else ""
251
+ )
252
+ report.issues.append(ValidationIssue(
253
+ path=rel_path,
254
+ line=node.lineno,
255
+ col=node.col_offset,
256
+ code="imported_name_missing",
257
+ message=(
258
+ f"Name '{original_name}' is imported from "
259
+ f"'{src_module}'{alias_note}, but that module "
260
+ f"does not define or export it."
261
+ ),
262
+ severity="error",
263
+ confidence=0.9,
264
+ ))
265
+ return
266
+ # Unknown name — could be dynamic; skip.
267
+
268
+ # Case B: Attribute — e.g. `module.func(...)` or `obj.method(...)`
269
+ elif isinstance(func, ast.Attribute):
270
+ chain = _attr_chain(func)
271
+ if chain is None or len(chain) < 2:
272
+ return
273
+ root = chain[0]
274
+ if root in module_aliases:
275
+ module_path = module_aliases[root]
276
+ target_module = index.modules.get(module_path)
277
+ if target_module is None or target_module.syntax_error:
278
+ return
279
+ attr_name = chain[-1]
280
+ if not _module_exports(target_module, attr_name):
281
+ report.issues.append(ValidationIssue(
282
+ path=rel_path,
283
+ line=node.lineno,
284
+ col=node.col_offset,
285
+ code="call_to_missing_name",
286
+ message=(
287
+ f"Call to '{'.'.join(chain)}' but "
288
+ f"'{module_path}' does not define '{attr_name}'."
289
+ ),
290
+ severity="error",
291
+ confidence=0.85,
292
+ ))
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Helpers
297
+ # ---------------------------------------------------------------------------
298
+ def _module_has_dynamic_opt_out(content: str) -> bool:
299
+ """Check first 3 lines for `# vegex: ignore-dynamic`."""
300
+ for i, line in enumerate(content.splitlines()):
301
+ if i > 3:
302
+ break
303
+ if "vegex: ignore-dynamic" in line:
304
+ return True
305
+ return False
306
+
307
+
308
+ def _has_noqa(node: ast.AST, content: str) -> bool:
309
+ """Check whether the node's source line contains `# vegex: noqa-contract`."""
310
+ lineno = getattr(node, "lineno", None)
311
+ if lineno is None:
312
+ return False
313
+ lines = content.splitlines()
314
+ if 1 <= lineno <= len(lines):
315
+ return "vegex: noqa-contract" in lines[lineno - 1]
316
+ return False
317
+
318
+
319
+ def _collect_local_names(tree: ast.Module) -> Set[str]:
320
+ names: Set[str] = set()
321
+
322
+ class _Collector(ast.NodeVisitor):
323
+ def visit_FunctionDef(self, node):
324
+ names.add(node.name)
325
+ for arg in _iter_all_args(node.args):
326
+ names.add(arg.arg)
327
+ self.generic_visit(node)
328
+
329
+ def visit_AsyncFunctionDef(self, node):
330
+ names.add(node.name)
331
+ for arg in _iter_all_args(node.args):
332
+ names.add(arg.arg)
333
+ self.generic_visit(node)
334
+
335
+ def visit_ClassDef(self, node):
336
+ names.add(node.name)
337
+ self.generic_visit(node)
338
+
339
+ def visit_Assign(self, node):
340
+ for t in node.targets:
341
+ for sub in ast.walk(t):
342
+ if isinstance(sub, ast.Name):
343
+ names.add(sub.id)
344
+ self.generic_visit(node)
345
+
346
+ def visit_AnnAssign(self, node):
347
+ if isinstance(node.target, ast.Name):
348
+ names.add(node.target.id)
349
+ self.generic_visit(node)
350
+
351
+ def visit_For(self, node):
352
+ for sub in ast.walk(node.target):
353
+ if isinstance(sub, ast.Name):
354
+ names.add(sub.id)
355
+ self.generic_visit(node)
356
+
357
+ def visit_With(self, node):
358
+ for item in node.items:
359
+ if item.optional_vars is not None:
360
+ for sub in ast.walk(item.optional_vars):
361
+ if isinstance(sub, ast.Name):
362
+ names.add(sub.id)
363
+ self.generic_visit(node)
364
+
365
+ def visit_ExceptHandler(self, node):
366
+ if node.name:
367
+ names.add(node.name)
368
+ self.generic_visit(node)
369
+
370
+ def visit_comprehension(self, node):
371
+ for sub in ast.walk(node.target):
372
+ if isinstance(sub, ast.Name):
373
+ names.add(sub.id)
374
+ self.generic_visit(node)
375
+
376
+ def visit_Lambda(self, node):
377
+ for arg in _iter_all_args(node.args):
378
+ names.add(arg.arg)
379
+ self.generic_visit(node)
380
+
381
+ _Collector().visit(tree)
382
+ return names
383
+
384
+
385
+ def _iter_all_args(args: ast.arguments):
386
+ yield from args.posonlyargs
387
+ yield from args.args
388
+ yield from args.kwonlyargs
389
+ if args.vararg:
390
+ yield args.vararg
391
+ if args.kwarg:
392
+ yield args.kwarg
393
+
394
+
395
+ def _collect_imported_symbols(tree: ast.Module) -> Dict[str, Tuple[str, str]]:
396
+ """
397
+ Map local imported name -> (source_module, original_symbol_name).
398
+
399
+ Handles both `from X import Y` and `from X import Y as Z`:
400
+ - For `from X import Y`, out["Y"] = ("X", "Y")
401
+ - For `from X import Y as Z`, out["Z"] = ("X", "Y")
402
+
403
+ Only absolute imports are tracked. Relative imports (level > 0) fail open
404
+ because we cannot resolve them without package context.
405
+ """
406
+ out: Dict[str, Tuple[str, str]] = {}
407
+
408
+ for node in ast.walk(tree):
409
+ if not isinstance(node, ast.ImportFrom):
410
+ continue
411
+ if node.level and not node.module:
412
+ continue
413
+ module = node.module or ""
414
+ if node.level:
415
+ # Relative imports: skip to avoid false positives
416
+ continue
417
+ for alias in node.names:
418
+ local = alias.asname or alias.name
419
+ original = alias.name
420
+ out[local] = (module, original)
421
+ return out
422
+
423
+
424
+ def _collect_import_module_aliases(tree: ast.Module) -> Dict[str, str]:
425
+ """
426
+ Map local alias -> module path for `import X`, `import X as Y`,
427
+ `import a.b.c as Y`.
428
+
429
+ For `import X.Y` without alias, we register only the root: X -> "X.Y".
430
+ That matches Python's own binding behavior.
431
+ """
432
+ out: Dict[str, str] = {}
433
+ for node in ast.walk(tree):
434
+ if isinstance(node, ast.Import):
435
+ for alias in node.names:
436
+ local = alias.asname or alias.name.split(".")[0]
437
+ out[local] = alias.name
438
+ return out
439
+
440
+
441
+ def _module_exports(mod: ModuleInfo, name: str) -> bool:
442
+ """
443
+ Does `mod` define or export `name`?
444
+
445
+ Checks:
446
+ - top-level def/class/assignment
447
+ - explicit names in __all__ (best-effort via top_level_names)
448
+ - names re-imported into the module (re-exports)
449
+ """
450
+ if name in mod.top_level_names:
451
+ return True
452
+ if name in mod.functions or name in mod.classes:
453
+ return True
454
+ for imp in mod.imports:
455
+ if name in imp.names:
456
+ return True
457
+ if name in imp.alias_of_names:
458
+ return True
459
+ return False
460
+
461
+
462
+ def _attr_chain(node: ast.AST) -> Optional[Tuple[str, ...]]:
463
+ parts: List[str] = []
464
+ while isinstance(node, ast.Attribute):
465
+ parts.append(node.attr)
466
+ node = node.value
467
+ if isinstance(node, ast.Name):
468
+ parts.append(node.id)
469
+ return tuple(reversed(parts))
470
+ return None
471
+
472
+
473
+ __all__ = [
474
+ "ContractValidator",
475
+ "ContractValidationError",
476
+ "ValidationReport",
477
+ "ValidationIssue",
478
+ ]
@@ -0,0 +1,141 @@
1
+ """
2
+ tools/cost_predictor.py
3
+ -----------------------
4
+ Lightweight pre-flight cost estimator for the VegeX pipeline.
5
+
6
+ Given a researcher plan (a list of step dicts) it routes each step to a
7
+ model via the same complexity assessment used at call time, prices the
8
+ expected token budget with that model's published rates, and returns a
9
+ per-step breakdown plus a padded grand total.
10
+
11
+ Public API:
12
+ predict_cost(plan: list[dict], task_prompt: str = "") -> dict
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List
18
+
19
+ from tools.llm_selector import (
20
+ assess_task_complexity,
21
+ select_model_for_task,
22
+ get_model_details,
23
+ )
24
+
25
+
26
+ INPUT_TOKENS_FIXED: int = 6000
27
+ DEFAULT_ESTIMATED_LINES: int = 120
28
+ MIN_ESTIMATED_LINES: int = 50
29
+ TOKENS_PER_LINE: int = 20
30
+ OVERHEAD_MULTIPLIER: float = 1.3
31
+ DEFAULT_COMPLEXITY: str = "medium"
32
+
33
+
34
+ __all__ = ["predict_cost"]
35
+
36
+
37
+ def _safe_complexity(text: str) -> Any:
38
+ try:
39
+ result = assess_task_complexity(text)
40
+ except Exception:
41
+ return DEFAULT_COMPLEXITY
42
+ if result is None:
43
+ return DEFAULT_COMPLEXITY
44
+ return result
45
+
46
+
47
+ def _safe_model(complexity: Any) -> str:
48
+ try:
49
+ model = select_model_for_task(complexity)
50
+ except Exception:
51
+ return ""
52
+ return model or ""
53
+
54
+
55
+ def _safe_pricing(model: str) -> Dict[str, Any]:
56
+ if not model:
57
+ return {}
58
+ try:
59
+ pricing = get_model_details(model)
60
+ except Exception:
61
+ return {}
62
+ if not isinstance(pricing, dict):
63
+ return {}
64
+ return pricing
65
+
66
+
67
+ def _safe_float(value: Any) -> float:
68
+ try:
69
+ if value is None:
70
+ return 0.0
71
+ return float(value)
72
+ except (TypeError, ValueError):
73
+ return 0.0
74
+
75
+
76
+ def _resolve_estimated_lines(raw: Any) -> int:
77
+ try:
78
+ value = int(raw) if raw is not None else None
79
+ except (TypeError, ValueError):
80
+ value = None
81
+ if not value:
82
+ value = DEFAULT_ESTIMATED_LINES
83
+ return max(value, MIN_ESTIMATED_LINES)
84
+
85
+
86
+ def _step_fields(step: Any) -> tuple:
87
+ if isinstance(step, dict):
88
+ return step.get("title"), step.get("id"), step.get("estimated_lines")
89
+ return None, None, None
90
+
91
+
92
+ def predict_cost(plan: List[Dict[str, Any]], task_prompt: str = "") -> Dict[str, Any]:
93
+ if not isinstance(plan, (list, tuple)):
94
+ return {"total_usd": 0.0, "by_step": []}
95
+
96
+ by_step: List[Dict[str, Any]] = []
97
+
98
+ for index, step in enumerate(plan):
99
+ title, step_id, raw_lines = _step_fields(step)
100
+
101
+ if title is None or not str(title).strip():
102
+ task_text = task_prompt or ""
103
+ display_title = ""
104
+ else:
105
+ task_text = str(title)
106
+ display_title = str(title)
107
+
108
+ if step_id is None:
109
+ step_id = index + 1
110
+
111
+ complexity = _safe_complexity(task_text)
112
+ model = _safe_model(complexity)
113
+ pricing = _safe_pricing(model)
114
+
115
+ input_tokens = INPUT_TOKENS_FIXED
116
+ estimated_lines = _resolve_estimated_lines(raw_lines)
117
+ output_tokens = estimated_lines * TOKENS_PER_LINE
118
+
119
+ price_input = _safe_float(pricing.get("price_input_1m"))
120
+ price_output = _safe_float(pricing.get("price_output_1m"))
121
+
122
+ cost_usd = (
123
+ (input_tokens * price_input / 1_000_000.0)
124
+ + (output_tokens * price_output / 1_000_000.0)
125
+ )
126
+
127
+ by_step.append(
128
+ {
129
+ "step_id": step_id,
130
+ "title": display_title,
131
+ "model": model,
132
+ "complexity": complexity,
133
+ "input_tokens": input_tokens,
134
+ "output_tokens": output_tokens,
135
+ "cost_usd": cost_usd,
136
+ }
137
+ )
138
+
139
+ total_usd = sum(entry["cost_usd"] for entry in by_step) * OVERHEAD_MULTIPLIER
140
+
141
+ return {"total_usd": total_usd, "by_step": by_step}