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.
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/PKG-INFO +4 -3
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/README.md +1 -1
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/pyproject.toml +3 -2
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/__init__.py +1 -1
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/context_safe.py +2 -2
- codespy_ai-2.0.0/src/codespy/agents/cost_tracker.py +573 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/dspy_config.py +129 -47
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/__init__.py +3 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/cerebral.py +1223 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/cost.py +509 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/delta_ops_compat.py +197 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/orphans.py +107 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/routines.py +230 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/cerebral/structured_output.py +611 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/__init__.py +4 -5
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/budget.py +6 -8
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/cartographer.py +10 -10
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/context_memory.py +42 -8
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/distiller.py +13 -14
- codespy_ai-2.0.0/src/codespy/agents/memory/hippocampus/episode.py +246 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/hippocampus/hippocampus.py +65 -60
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/postgres.py +313 -151
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/__init__.py +46 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/facets.py +213 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/format.py +224 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/prefrontal.py +604 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/query.py +161 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/prefrontal/reach.py +183 -0
- codespy_ai-2.0.0/src/codespy/agents/memory/recall.py +137 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/audit/agent.py +84 -50
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/code_review/agent.py +54 -43
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/agent.py +49 -43
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/doc_extractor.py +35 -2
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/helpers.py +9 -1
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/models.py +7 -2
- codespy_ai-2.0.0/src/codespy/agents/review/scope/__init__.py +15 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/agent.py +226 -143
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/summary/agent.py +46 -23
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/supply_chain/agent.py +59 -45
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli.py +7 -7
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_local.py +30 -18
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_remote.py +40 -22
- codespy_ai-2.0.0/src/codespy/config.py +540 -0
- codespy_ai-2.0.0/src/codespy/config_dspy.py +108 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_llm.py +18 -41
- codespy_ai-2.0.0/src/codespy/config_memory.py +1006 -0
- codespy_ai-2.0.0/src/codespy/config_utils.py +205 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/base.py +34 -1
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/github_client.py +42 -7
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/gitlab_client.py +40 -4
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/mcp_utils.py +12 -18
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/server.py +2 -6
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/server.py +2 -6
- codespy_ai-2.0.0/src/codespy/workflows/review/models.py +486 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/pipeline.py +237 -42
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/git.py +172 -44
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/server.py +17 -15
- codespy_ai-1.2.3/src/codespy/agents/cost_tracker.py +0 -344
- codespy_ai-1.2.3/src/codespy/agents/memory/hippocampus/episode.py +0 -125
- codespy_ai-1.2.3/src/codespy/agents/review/scope/__init__.py +0 -2
- codespy_ai-1.2.3/src/codespy/config.py +0 -600
- codespy_ai-1.2.3/src/codespy/config_dspy.py +0 -192
- codespy_ai-1.2.3/src/codespy/config_memory.py +0 -401
- codespy_ai-1.2.3/src/codespy/config_utils.py +0 -21
- codespy_ai-1.2.3/src/codespy/tools/git/patch_utils.py +0 -438
- codespy_ai-1.2.3/src/codespy/workflows/review/models.py +0 -271
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/LICENSE +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/memory/pg0_manager.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/audit/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/code_review/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/doc/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/manifest_parser.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/scope/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/summary/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/agents/review/supply_chain/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/cli_mcp_server.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_git.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/config_io.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/cyber/osv/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/local_diff.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/git/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/bash.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/cpp.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/csharp.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/php.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/ripgrep_fallback.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/ruby.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/base.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/filesystem/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/storage/s3/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/client.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/tools/web/models.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/__init__.py +0 -0
- {codespy_ai-1.2.3 → codespy_ai-2.0.0}/src/codespy/workflows/review/reporters/base.py +0 -0
- {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:
|
|
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 (>=
|
|
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
|
|
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
|
|
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 = "
|
|
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 = ">=
|
|
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"
|
|
@@ -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
|
|
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
|
|
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
|