codespy-ai 1.2.3__tar.gz → 2.0.0__tar.gz

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 (133) hide show
  1. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/PKG-INFO +4 -3
  2. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/README.md +1 -1
  3. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/pyproject.toml +3 -2
  4. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/__init__.py +1 -1
  5. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/context_safe.py +2 -2
  6. codespy_ai-2.0.0/src/codespy/agents/cost_tracker.py +573 -0
  7. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/dspy_config.py +129 -47
  8. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/__init__.py +3 -0
  9. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/cerebral.py +1223 -0
  10. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/cost.py +509 -0
  11. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/delta_ops_compat.py +197 -0
  12. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/orphans.py +107 -0
  13. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/routines.py +230 -0
  14. codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/structured_output.py +611 -0
  15. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/__init__.py +4 -5
  16. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/budget.py +6 -8
  17. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/cartographer.py +10 -10
  18. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/context_memory.py +42 -8
  19. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/distiller.py +13 -14
  20. codespy_ai-2.0.0/src/codespy/agents/memory/hippocampus/episode.py +246 -0
  21. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/hippocampus.py +65 -60
  22. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/postgres.py +313 -151
  23. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/__init__.py +46 -0
  24. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/facets.py +213 -0
  25. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/format.py +224 -0
  26. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/prefrontal.py +604 -0
  27. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/query.py +161 -0
  28. codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/reach.py +183 -0
  29. codespy_ai-2.0.0/src/codespy/agents/memory/recall.py +137 -0
  30. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/audit/agent.py +84 -50
  31. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/code_review/agent.py +54 -43
  32. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/agent.py +49 -43
  33. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/doc_extractor.py +35 -2
  34. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/helpers.py +9 -1
  35. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/models.py +7 -2
  36. codespy_ai-2.0.0/src/codespy/agents/review/scope/__init__.py +15 -0
  37. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/agent.py +226 -143
  38. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/summary/agent.py +46 -23
  39. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/supply_chain/agent.py +59 -45
  40. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli.py +7 -7
  41. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_local.py +30 -18
  42. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_remote.py +40 -22
  43. codespy_ai-2.0.0/src/codespy/config.py +540 -0
  44. codespy_ai-2.0.0/src/codespy/config_dspy.py +108 -0
  45. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_llm.py +18 -41
  46. codespy_ai-2.0.0/src/codespy/config_memory.py +1006 -0
  47. codespy_ai-2.0.0/src/codespy/config_utils.py +205 -0
  48. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/server.py +2 -6
  49. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/base.py +34 -1
  50. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/github_client.py +42 -7
  51. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/gitlab_client.py +40 -4
  52. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/server.py +2 -6
  53. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/mcp_utils.py +12 -18
  54. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/server.py +2 -6
  55. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/server.py +2 -6
  56. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/server.py +2 -6
  57. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/server.py +2 -6
  58. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/server.py +2 -6
  59. codespy_ai-2.0.0/src/codespy/workflows/review/models.py +486 -0
  60. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/pipeline.py +237 -42
  61. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/git.py +172 -44
  62. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/server.py +17 -15
  63. codespy_ai-1.2.3/src/codespy/agents/cost_tracker.py +0 -344
  64. codespy_ai-1.2.3/src/codespy/agents/memory/hippocampus/episode.py +0 -125
  65. codespy_ai-1.2.3/src/codespy/agents/review/scope/__init__.py +0 -2
  66. codespy_ai-1.2.3/src/codespy/config.py +0 -600
  67. codespy_ai-1.2.3/src/codespy/config_dspy.py +0 -192
  68. codespy_ai-1.2.3/src/codespy/config_memory.py +0 -401
  69. codespy_ai-1.2.3/src/codespy/config_utils.py +0 -21
  70. codespy_ai-1.2.3/src/codespy/tools/git/patch_utils.py +0 -438
  71. codespy_ai-1.2.3/src/codespy/workflows/review/models.py +0 -271
  72. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/LICENSE +0 -0
  73. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/__init__.py +0 -0
  74. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/__init__.py +0 -0
  75. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/pg0_manager.py +0 -0
  76. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/__init__.py +0 -0
  77. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/audit/__init__.py +0 -0
  78. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/code_review/__init__.py +0 -0
  79. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/__init__.py +0 -0
  80. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/manifest_parser.py +0 -0
  81. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/models.py +0 -0
  82. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/summary/__init__.py +0 -0
  83. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/supply_chain/__init__.py +0 -0
  84. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_mcp_server.py +0 -0
  85. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_git.py +0 -0
  86. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_io.py +0 -0
  87. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/__init__.py +0 -0
  88. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/__init__.py +0 -0
  89. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/__init__.py +0 -0
  90. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/client.py +0 -0
  91. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/models.py +0 -0
  92. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/__init__.py +0 -0
  93. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/client.py +0 -0
  94. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/local_diff.py +0 -0
  95. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/models.py +0 -0
  96. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/__init__.py +0 -0
  97. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
  98. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
  99. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
  100. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
  101. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
  102. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/bash.py +0 -0
  103. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/cpp.py +0 -0
  104. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/csharp.py +0 -0
  105. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
  106. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
  107. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
  108. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
  109. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
  110. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/php.py +0 -0
  111. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
  112. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/ripgrep_fallback.py +0 -0
  113. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/ruby.py +0 -0
  114. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
  115. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
  116. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
  117. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/models.py +0 -0
  118. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
  119. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/__init__.py +0 -0
  120. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/base.py +0 -0
  121. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/__init__.py +0 -0
  122. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/client.py +0 -0
  123. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/models.py +0 -0
  124. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/__init__.py +0 -0
  125. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/client.py +0 -0
  126. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/__init__.py +0 -0
  127. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/client.py +0 -0
  128. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/models.py +0 -0
  129. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/__init__.py +0 -0
  130. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/__init__.py +0 -0
  131. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/__init__.py +0 -0
  132. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/base.py +0 -0
  133. {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/stdout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespy-ai
3
- Version: 1.2.3
3
+ Version: 2.0.0
4
4
  Summary: Code review agent powered by DSPy
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -23,11 +23,12 @@ Requires-Dist: cloudpickle (>=3.1.2,<4.0.0)
23
23
  Requires-Dist: ddgs (>=8.0.0)
24
24
  Requires-Dist: dspy[mcp] (>=3.3.0,<4.0.0)
25
25
  Requires-Dist: gitpython (>=3.1.42)
26
+ Requires-Dist: hindsight-api-slim (>=0.10.1)
26
27
  Requires-Dist: httpx (>=0.28.0)
27
28
  Requires-Dist: json-repair (>=0.60.1)
28
29
  Requires-Dist: litellm (>=1.84.0,<2.0.0)
29
30
  Requires-Dist: markdownify (>=0.15.0)
30
- Requires-Dist: mcp (>=1.29.0,<2.0.0)
31
+ Requires-Dist: mcp (>=2.2,<3)
31
32
  Requires-Dist: pg0-embedded (>=0.15)
32
33
  Requires-Dist: psycopg[binary,pool] (>=3.1)
33
34
  Requires-Dist: pydantic (>=2.10.0)
@@ -204,7 +205,7 @@ codespy auto-discovers credentials from standard locations (`~/.aws/credentials`
204
205
  | **[Usage](docs/usage.md)** | CLI commands, Docker, GitHub Action, MCP server, output formats |
205
206
  | **[Configuration](docs/configuration.md)** | Environment variables, YAML config, model strategy, per-signature settings |
206
207
  | **[Architecture](docs/architecture.md)** | Pipeline design, DSPy signatures, supported languages |
207
- | **[Memory System](docs/memory.md)** | Hippocampus episodic memory for cross-review knowledge |
208
+ | **[Memory System](docs/memory.md)** | Hippocampus (episodic), Cerebral (semantic store), Prefrontal (recall) |
208
209
  | **[Development](docs/development.md)** | Setup, build, test, lint |
209
210
 
210
211
  ---
@@ -151,7 +151,7 @@ codespy auto-discovers credentials from standard locations (`~/.aws/credentials`
151
151
  | **[Usage](docs/usage.md)** | CLI commands, Docker, GitHub Action, MCP server, output formats |
152
152
  | **[Configuration](docs/configuration.md)** | Environment variables, YAML config, model strategy, per-signature settings |
153
153
  | **[Architecture](docs/architecture.md)** | Pipeline design, DSPy signatures, supported languages |
154
- | **[Memory System](docs/memory.md)** | Hippocampus episodic memory for cross-review knowledge |
154
+ | **[Memory System](docs/memory.md)** | Hippocampus (episodic), Cerebral (semantic store), Prefrontal (recall) |
155
155
  | **[Development](docs/development.md)** | Setup, build, test, lint |
156
156
 
157
157
  ---
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "codespy-ai"
3
- version = "1.2.3"
3
+ version = "2.0.0"
4
4
  description = "Code review agent powered by DSPy"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -47,12 +47,13 @@ tree-sitter-kotlin = ">=1.0"
47
47
  tree-sitter-objc = ">=3.0"
48
48
  tree-sitter-rust = ">=0.23"
49
49
  tree-sitter-hcl = ">=1.2.0"
50
- mcp = ">=1.29.0,<2.0.0"
50
+ mcp = ">=2.2,<3"
51
51
  psycopg = {version = ">=3.1", extras = ["binary", "pool"]}
52
52
  beautifulsoup4 = ">=4.12.0"
53
53
  markdownify = ">=0.15.0"
54
54
  ddgs = ">=8.0.0"
55
55
  pg0-embedded = ">=0.15"
56
+ hindsight-api-slim = ">=0.10.1"
56
57
 
57
58
  [tool.poetry.group.dev.dependencies]
58
59
  pytest = ">=8.0.0"
@@ -1,3 +1,3 @@
1
1
  """codespy - Code review agent powered by DSPy."""
2
2
 
3
- __version__ = "1.2.3"
3
+ __version__ = "2.0.0"
@@ -60,7 +60,7 @@ class ContextSafe(dspy.Module):
60
60
  - Try/catch: if the inner module raises a context overflow error (unknown
61
61
  models where pre-flight can't estimate), catches it and retries with RLM.
62
62
 
63
- Transparent to inject_context_memory — delegates .signature to inner module.
63
+ Transparent to signature injection — delegates .signature to inner module.
64
64
  """
65
65
 
66
66
  def __init__(
@@ -186,7 +186,7 @@ class ContextSafe(dspy.Module):
186
186
  return False, ""
187
187
 
188
188
  def _get_current_signature(self):
189
- """Get current signature (may include context_memory if inject_context_memory was called)."""
189
+ """Get current signature."""
190
190
  sig = getattr(self.module, "signature", None)
191
191
  if sig is not None:
192
192
  return sig
@@ -0,0 +1,573 @@
1
+ """Thread-safe cost tracking for LLM calls with per-signature attribution.
2
+
3
+ Uses DSPy's internal LM history mechanism for reliable per-signature attribution,
4
+ even during parallel execution with dspy.Parallel.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass
12
+ from types import TracebackType
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ import dspy # type: ignore[import-untyped]
16
+
17
+ if TYPE_CHECKING:
18
+ from codespy.agents.dspy_config import LMScope
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class SignatureStats:
25
+ """Statistics for a single signature's LLM usage."""
26
+
27
+ name: str
28
+ cost: float = 0.0
29
+ tokens: int = 0
30
+ call_count: int = 0
31
+ start_time: float | None = None
32
+ end_time: float | None = None
33
+ input_tokens: int = 0
34
+ output_tokens: int = 0
35
+ input_cost: float = 0.0
36
+ output_cost: float = 0.0
37
+ external_duration_seconds: float = 0.0
38
+
39
+ @property
40
+ def duration_seconds(self) -> float:
41
+ """Get duration in seconds, or 0 if not completed.
42
+
43
+ For signatures tracked via SignatureContext, this is wall-clock time
44
+ from start to end. For external calls (e.g., cerebral), this is the
45
+ sum of per-call work durations (not wall-clock), consistent with how
46
+ cost/tokens are summed.
47
+ """
48
+ wall = 0.0
49
+ if self.start_time is not None:
50
+ end = self.end_time if self.end_time is not None else time.time()
51
+ wall = end - self.start_time
52
+ return wall + self.external_duration_seconds
53
+
54
+ def to_dict(self) -> dict:
55
+ """Convert to dictionary for serialization."""
56
+ return {
57
+ "name": self.name,
58
+ "cost": self.cost,
59
+ "tokens": self.tokens,
60
+ "call_count": self.call_count,
61
+ "duration_seconds": self.duration_seconds,
62
+ "input_tokens": self.input_tokens,
63
+ "output_tokens": self.output_tokens,
64
+ "input_cost": self.input_cost,
65
+ "output_cost": self.output_cost,
66
+ "external_duration_seconds": self.external_duration_seconds,
67
+ }
68
+
69
+
70
+ class CostTracker:
71
+ """Track LLM costs across multiple calls with per-signature attribution.
72
+
73
+ Uses DSPy's LM history for per-signature tracking, which works reliably
74
+ even during parallel execution.
75
+ """
76
+
77
+ def __init__(self) -> None:
78
+ """Initialize the cost tracker."""
79
+ self._lock = threading.Lock()
80
+ self._signature_stats: dict[str, SignatureStats] = {}
81
+
82
+ def reset(self) -> None:
83
+ """Reset all tracking."""
84
+ with self._lock:
85
+ self._signature_stats.clear()
86
+
87
+ def start_signature(self, signature_name: str) -> None:
88
+ """Mark the start of a signature's execution.
89
+
90
+ Args:
91
+ signature_name: Name of the signature starting execution
92
+ """
93
+ with self._lock:
94
+ if signature_name not in self._signature_stats:
95
+ self._signature_stats[signature_name] = SignatureStats(name=signature_name)
96
+ self._signature_stats[signature_name].start_time = time.time()
97
+ self._signature_stats[signature_name].end_time = None
98
+
99
+ def end_signature(
100
+ self,
101
+ signature_name: str,
102
+ cost: float,
103
+ tokens: int,
104
+ call_count: int,
105
+ input_tokens: int = 0,
106
+ output_tokens: int = 0,
107
+ input_cost: float = 0.0,
108
+ output_cost: float = 0.0,
109
+ ) -> None:
110
+ """Mark the end of a signature's execution with its costs.
111
+
112
+ Args:
113
+ signature_name: Name of the signature ending execution
114
+ cost: Total cost for this signature's LLM calls
115
+ tokens: Total tokens used by this signature
116
+ call_count: Number of LLM calls made by this signature
117
+ input_tokens: Input/prompt tokens used
118
+ output_tokens: Output/completion tokens used
119
+ input_cost: Cost for input tokens
120
+ output_cost: Cost for output tokens
121
+ """
122
+ with self._lock:
123
+ if signature_name not in self._signature_stats:
124
+ self._signature_stats[signature_name] = SignatureStats(name=signature_name)
125
+ stats = self._signature_stats[signature_name]
126
+ stats.end_time = time.time()
127
+ stats.cost += cost
128
+ stats.tokens += tokens
129
+ stats.call_count += call_count
130
+ stats.input_tokens += input_tokens
131
+ stats.output_tokens += output_tokens
132
+ stats.input_cost += input_cost
133
+ stats.output_cost += output_cost
134
+
135
+ def add_external_call(
136
+ self,
137
+ name: str,
138
+ cost: float,
139
+ tokens: int,
140
+ calls: int = 1,
141
+ input_tokens: int = 0,
142
+ output_tokens: int = 0,
143
+ input_cost: float = 0.0,
144
+ output_cost: float = 0.0,
145
+ duration: float = 0.0,
146
+ ) -> None:
147
+ """Accumulate costs from external LLM calls (not tracked by DSPy).
148
+
149
+ Creates a SignatureStats entry if missing, without touching
150
+ start_time/end_time. Duration is summed per-call work time for the
151
+ bucket (not wall-clock), consistent with how cost/tokens are summed.
152
+
153
+ Args:
154
+ name: Bucket name for the external call (e.g., "memory_retain")
155
+ cost: Cost in USD for the call(s)
156
+ tokens: Total tokens used
157
+ calls: Number of calls (default 1)
158
+ input_tokens: Input/prompt tokens used
159
+ output_tokens: Output/completion tokens used
160
+ input_cost: Cost for input tokens
161
+ output_cost: Cost for output tokens
162
+ duration: Duration in seconds for this call (default 0.0)
163
+ """
164
+ with self._lock:
165
+ if name not in self._signature_stats:
166
+ self._signature_stats[name] = SignatureStats(name=name)
167
+ stats = self._signature_stats[name]
168
+ stats.cost += cost
169
+ stats.tokens += tokens
170
+ stats.call_count += calls
171
+ stats.input_tokens += input_tokens
172
+ stats.output_tokens += output_tokens
173
+ stats.input_cost += input_cost
174
+ stats.output_cost += output_cost
175
+ stats.external_duration_seconds += duration
176
+
177
+ @property
178
+ def total_cost(self) -> float:
179
+ """Get total cost in USD across all signatures."""
180
+ with self._lock:
181
+ return sum(s.cost for s in self._signature_stats.values())
182
+
183
+ @property
184
+ def total_tokens(self) -> int:
185
+ """Get total tokens used across all signatures."""
186
+ with self._lock:
187
+ return sum(s.tokens for s in self._signature_stats.values())
188
+
189
+ @property
190
+ def call_count(self) -> int:
191
+ """Get total number of LLM calls across all signatures."""
192
+ with self._lock:
193
+ return sum(s.call_count for s in self._signature_stats.values())
194
+
195
+ def get_signature_stats(self, signature_name: str) -> SignatureStats | None:
196
+ """Get stats for a specific signature.
197
+
198
+ Args:
199
+ signature_name: Name of the signature
200
+
201
+ Returns:
202
+ SignatureStats or None if signature not found
203
+ """
204
+ with self._lock:
205
+ return self._signature_stats.get(signature_name)
206
+
207
+ def get_all_signature_stats(self) -> dict[str, SignatureStats]:
208
+ """Get stats for all signatures.
209
+
210
+ Returns:
211
+ Dictionary of signature name to SignatureStats
212
+ """
213
+ with self._lock:
214
+ # Return a copy to avoid concurrent modification issues
215
+ return {
216
+ k: SignatureStats(
217
+ name=v.name,
218
+ cost=v.cost,
219
+ tokens=v.tokens,
220
+ call_count=v.call_count,
221
+ start_time=v.start_time,
222
+ end_time=v.end_time,
223
+ input_tokens=v.input_tokens,
224
+ output_tokens=v.output_tokens,
225
+ input_cost=v.input_cost,
226
+ output_cost=v.output_cost,
227
+ external_duration_seconds=v.external_duration_seconds,
228
+ )
229
+ for k, v in self._signature_stats.items()
230
+ }
231
+
232
+
233
+ def _get_history_entries(lm: Any | None = None) -> list[dict]:
234
+ """Get current LM history entries from DSPy.
235
+
236
+ Args:
237
+ lm: Optional LM to read history from. If None, uses dspy.settings.lm.
238
+
239
+ Returns:
240
+ List of history entries, or empty list if LM not configured
241
+ """
242
+ try:
243
+ if lm is None:
244
+ lm = dspy.settings.lm
245
+ if lm is not None and hasattr(lm, "history"):
246
+ return lm.history
247
+ except Exception:
248
+ pass
249
+ return []
250
+
251
+
252
+ def _get_history_uuids(lm: Any | None = None) -> set[str]:
253
+ """Get UUIDs of current history entries.
254
+
255
+ Args:
256
+ lm: Optional LM to read history from. If None, uses dspy.settings.lm.
257
+
258
+ Returns:
259
+ Set of UUIDs from current history
260
+ """
261
+ entries = _get_history_entries(lm)
262
+ return {entry.get("uuid", "") for entry in entries if entry.get("uuid")}
263
+
264
+
265
+ def _as_number(value: object) -> float:
266
+ """Coerce a history field to a number, yielding 0.0 for anything unusable.
267
+
268
+ History entries are raw provider/LiteLLM payloads, so ``cost`` and the
269
+ ``usage`` counters are only *conventionally* numeric. Coercing here keeps a
270
+ provider-shape surprise from turning accounting into an exception.
271
+
272
+ Args:
273
+ value: A value read from an LM history entry.
274
+
275
+ Returns:
276
+ The value as a float, or 0.0 if it is missing or not numeric.
277
+ """
278
+ if isinstance(value, bool) or value is None:
279
+ return 0.0
280
+ if isinstance(value, (int, float)):
281
+ return float(value)
282
+ try: # Some providers report numbers as strings.
283
+ return float(value) # type: ignore[arg-type]
284
+ except (TypeError, ValueError):
285
+ return 0.0
286
+
287
+
288
+ def _calculate_costs_from_entries(
289
+ entries: list[dict], exclude_uuids: set[str]
290
+ ) -> tuple[float, int, int, int, int, float, float]:
291
+ """Calculate costs from history entries, excluding specific UUIDs.
292
+
293
+ Every field is read defensively: cost accounting is observability, so a
294
+ malformed entry degrades that entry to zero rather than failing the review
295
+ that produced it.
296
+
297
+ Args:
298
+ entries: List of history entries
299
+ exclude_uuids: Set of UUIDs to exclude from calculation
300
+
301
+ Returns:
302
+ Tuple of (total_cost, total_tokens, call_count, input_tokens, output_tokens,
303
+ input_cost, output_cost)
304
+ """
305
+ total_cost = 0.0
306
+ total_tokens = 0
307
+ call_count = 0
308
+ input_tokens = 0
309
+ output_tokens = 0
310
+ input_cost = 0.0
311
+ output_cost = 0.0
312
+
313
+ for entry in entries:
314
+ if not isinstance(entry, dict):
315
+ continue
316
+
317
+ entry_uuid = entry.get("uuid", "")
318
+ if entry_uuid and entry_uuid not in exclude_uuids:
319
+ # Skip DSPy cache hits (not billed)
320
+ response = entry.get("response")
321
+ if response and response.get("cache_hit"):
322
+ continue
323
+
324
+ entry_cost = _as_number(entry.get("cost"))
325
+ total_cost += entry_cost
326
+
327
+ # Get tokens from usage
328
+ usage = entry.get("usage")
329
+ entry_input_tokens = 0
330
+ entry_output_tokens = 0
331
+ if isinstance(usage, dict):
332
+ entry_input_tokens = int(_as_number(usage.get("prompt_tokens")))
333
+ entry_output_tokens = int(_as_number(usage.get("completion_tokens")))
334
+ input_tokens += entry_input_tokens
335
+ output_tokens += entry_output_tokens
336
+ total_tokens += entry_input_tokens + entry_output_tokens
337
+
338
+ # Calculate split costs using litellm if model is available
339
+ entry_input_cost, entry_output_cost = _calculate_split_cost(
340
+ entry, entry_input_tokens, entry_output_tokens, entry_cost
341
+ )
342
+ input_cost += entry_input_cost
343
+ output_cost += entry_output_cost
344
+
345
+ call_count += 1
346
+
347
+ return (
348
+ total_cost,
349
+ total_tokens,
350
+ call_count,
351
+ input_tokens,
352
+ output_tokens,
353
+ input_cost,
354
+ output_cost,
355
+ )
356
+
357
+
358
+ def _calculate_split_cost(
359
+ entry: dict, prompt_tokens: int, completion_tokens: int, fallback_cost: float
360
+ ) -> tuple[float, float]:
361
+ """Calculate split input/output cost for a history entry.
362
+
363
+ Uses litellm.cost_per_token if model is available. When entry["cost"] is
364
+ a number, we compute output_cost from list price and input_cost = cost -
365
+ output_cost (so row sums to Total). When cost is None, we use the list
366
+ split for both and the row cost equals the list split.
367
+
368
+ Args:
369
+ entry: History entry dict
370
+ prompt_tokens: Number of prompt tokens
371
+ completion_tokens: Number of completion tokens
372
+ fallback_cost: Fallback total cost for proportional split
373
+
374
+ Returns:
375
+ Tuple of (input_cost, output_cost)
376
+ """
377
+ model = entry.get("model")
378
+ if not model:
379
+ # Fall back to proportional split by token ratio
380
+ return _proportional_split(prompt_tokens, completion_tokens, fallback_cost)
381
+
382
+ try:
383
+ import litellm
384
+
385
+ prompt_cost, completion_cost = litellm.cost_per_token(
386
+ model=model,
387
+ prompt_tokens=prompt_tokens,
388
+ completion_tokens=completion_tokens,
389
+ )
390
+ list_input_cost = float(prompt_cost)
391
+ list_output_cost = float(completion_cost)
392
+ list_total = list_input_cost + list_output_cost
393
+
394
+ # Check if we have a billed cost (could be 0.0 which is falsy, so check for None explicitly)
395
+ billed_cost = entry.get("cost")
396
+ if billed_cost is None:
397
+ # No billed cost: use list split (row will sum to list_total)
398
+ return list_input_cost, list_output_cost
399
+
400
+ # Have billed cost: output_cost is list price, input_cost = billed - output
401
+ billed = float(billed_cost)
402
+ # If list_total is 0 (rare), use proportional split
403
+ if list_total == 0:
404
+ return _proportional_split(prompt_tokens, completion_tokens, billed)
405
+
406
+ # Cap output_cost at billed (don't let input_cost go negative)
407
+ output_cost = min(list_output_cost, billed)
408
+ input_cost = billed - output_cost
409
+ return input_cost, output_cost
410
+
411
+ except Exception:
412
+ # Model may not have pricing or be available; use proportional split
413
+ return _proportional_split(prompt_tokens, completion_tokens, fallback_cost)
414
+
415
+
416
+ def _proportional_split(
417
+ input_tokens: int, output_tokens: int, total_cost: float
418
+ ) -> tuple[float, float]:
419
+ """Split cost proportionally by token count.
420
+
421
+ Args:
422
+ input_tokens: Number of input tokens
423
+ output_tokens: Number of output tokens
424
+ total_cost: Total cost to split
425
+
426
+ Returns:
427
+ Tuple of (input_cost, output_cost)
428
+ """
429
+ total = input_tokens + output_tokens
430
+ if total == 0:
431
+ return 0.0, 0.0
432
+ input_ratio = input_tokens / total
433
+ return total_cost * input_ratio, total_cost * (1 - input_ratio)
434
+
435
+
436
+ class SignatureContext:
437
+ """Context manager scoping one named unit of LLM work.
438
+
439
+ Two responsibilities, both keyed off the same name:
440
+
441
+ 1. **LM selection** — applies the model, temperature, and reasoning effort
442
+ configured for this name (``review.<name>`` or ``memory.hippocampus.<field>``
443
+ for ``memory_<field>``), falling back to the top-level defaults.
444
+ 2. **Cost attribution** — uses DSPy's LM history to attribute costs
445
+ reliably, even during parallel execution with dspy.Parallel, by
446
+ recording history UUIDs on entry and summing only the new entries.
447
+
448
+ Usage:
449
+ with SignatureContext("code_review", cost_tracker):
450
+ # Runs on code_review's configured model, costs attributed to it
451
+ result = await agent.acall(...)
452
+ """
453
+
454
+ def __init__(self, signature_name: str, tracker: "CostTracker") -> None:
455
+ """Initialize the signature context.
456
+
457
+ Args:
458
+ signature_name: Name of the signature
459
+ tracker: CostTracker instance
460
+ """
461
+ self.signature_name = signature_name
462
+ self.tracker = tracker
463
+ self._before_uuids: set[str] = set()
464
+ self._before_extraction_uuids: set[str] = set()
465
+ # Annotated so the None default doesn't narrow the attribute to
466
+ # ``None``, which would hide the enter/exit calls from type checking.
467
+ self._lm_scope: LMScope | None = None
468
+
469
+ def __enter__(self) -> "SignatureContext":
470
+ """Enter the context, applying the LM and capturing history state.
471
+
472
+ If the bookkeeping that follows the LM swap fails, the swap is rolled
473
+ back before propagating: Python does not call ``__exit__`` when
474
+ ``__enter__`` raises, so without this the overridden LM would stay
475
+ installed for the rest of the thread.
476
+ """
477
+ # Imported here to avoid a circular import at module load time
478
+ # (dspy_config imports codespy.config, which must not import agents).
479
+ from codespy.agents.dspy_config import lm_context
480
+
481
+ # Apply this name's LM first, so the history we snapshot below belongs
482
+ # to the LM that will actually serve the enclosed calls.
483
+ self._lm_scope = lm_context(self.signature_name)
484
+ self._lm_scope.__enter__()
485
+ try:
486
+ # Snapshot UUIDs from both main LM and extraction LM
487
+ self._before_uuids = _get_history_uuids(self._lm_scope.lm)
488
+ self._before_extraction_uuids = _get_history_uuids(self._lm_scope.extraction_lm)
489
+ self.tracker.start_signature(self.signature_name)
490
+ except BaseException:
491
+ self._lm_scope.__exit__(*sys.exc_info())
492
+ self._lm_scope = None
493
+ raise
494
+ return self
495
+
496
+ def __exit__(
497
+ self,
498
+ exc_type: type[BaseException] | None,
499
+ exc_val: BaseException | None,
500
+ exc_tb: TracebackType | None,
501
+ ) -> None:
502
+ """Exit the context, calculating costs from new history entries.
503
+
504
+ Cost calculation failures are logged at WARNING level but never
505
+ propagated — bookkeeping must not mask application errors.
506
+
507
+ The LM context is released in a ``finally``: a leaked
508
+ ``dspy.context`` does not raise, it silently leaves the overridden LM
509
+ installed for the remainder of the thread, so every later predictor
510
+ would run on the wrong model and be attributed to the wrong signature.
511
+ Guaranteeing the exit keeps an accounting failure loud and local
512
+ instead of quiet and global.
513
+ """
514
+ try:
515
+ # Read history from both LMs before leaving the context
516
+ main_entries = _get_history_entries(self._lm_scope.lm if self._lm_scope else None)
517
+ extraction_entries = _get_history_entries(
518
+ self._lm_scope.extraction_lm if self._lm_scope else None
519
+ )
520
+
521
+ # Calculate costs from both histories
522
+ main_result = _calculate_costs_from_entries(main_entries, self._before_uuids)
523
+ extraction_result = _calculate_costs_from_entries(
524
+ extraction_entries, self._before_extraction_uuids
525
+ )
526
+
527
+ # Combine results: sum all numeric fields
528
+ cost = main_result[0] + extraction_result[0]
529
+ tokens = main_result[1] + extraction_result[1]
530
+ call_count = main_result[2] + extraction_result[2]
531
+ input_tokens = main_result[3] + extraction_result[3]
532
+ output_tokens = main_result[4] + extraction_result[4]
533
+ input_cost = main_result[5] + extraction_result[5]
534
+ output_cost = main_result[6] + extraction_result[6]
535
+
536
+ self.tracker.end_signature(
537
+ self.signature_name,
538
+ cost=cost,
539
+ tokens=tokens,
540
+ call_count=call_count,
541
+ input_tokens=input_tokens,
542
+ output_tokens=output_tokens,
543
+ input_cost=input_cost,
544
+ output_cost=output_cost,
545
+ )
546
+ except Exception as e:
547
+ logger.warning("Cost calculation failed for %s: %s", self.signature_name, e)
548
+ finally:
549
+ if self._lm_scope is not None:
550
+ self._lm_scope.__exit__(exc_type, exc_val, exc_tb)
551
+ self._lm_scope = None
552
+
553
+ async def __aenter__(self) -> "SignatureContext":
554
+ """Async enter the context."""
555
+ return self.__enter__()
556
+
557
+ async def __aexit__(
558
+ self,
559
+ exc_type: type[BaseException] | None,
560
+ exc_val: BaseException | None,
561
+ exc_tb: TracebackType | None,
562
+ ) -> None:
563
+ """Async exit the context."""
564
+ self.__exit__(exc_type, exc_val, exc_tb)
565
+
566
+
567
+ # Global cost tracker instance
568
+ _cost_tracker = CostTracker()
569
+
570
+
571
+ def get_cost_tracker() -> CostTracker:
572
+ """Get the global cost tracker instance."""
573
+ return _cost_tracker