gdmcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/cost_tracker.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Cost tracker — per-turn token and USD cost accounting.
|
|
2
|
+
|
|
3
|
+
Supports all three providers (Grok / Gemini / Codex) and all model tiers.
|
|
4
|
+
Persists a running session total. Cheap to call — no I/O on hot path.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
tracker = CostTracker(session_id="abc", provider="grok")
|
|
9
|
+
tracker.record(tier="coder", input_tokens=1200, output_tokens=450)
|
|
10
|
+
tracker.record_tool_call("web_search")
|
|
11
|
+
print(tracker.session_total_usd) # → 0.00063
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
__all__ = ["CostTracker", "TurnCost", "ModelTier", "ProviderName"]
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Type aliases
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
ModelTier = Literal["scout", "coder", "thinker", "reasoner", "debate", "standard", "heavy"]
|
|
28
|
+
ProviderName = Literal["grok", "gemini", "codex"]
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Price tables (USD per 1 million tokens)
|
|
32
|
+
# Updated: 2026-04 — update when providers change pricing
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
# fmt: off
|
|
36
|
+
_GROK_PRICES: dict[str, tuple[float, float, float]] = {
|
|
37
|
+
# tier: (input, output, cached_input)
|
|
38
|
+
"scout": (0.20, 0.50, 0.05),
|
|
39
|
+
"coder": (0.20, 0.50, 0.05),
|
|
40
|
+
"thinker": (2.00, 6.00, 0.20),
|
|
41
|
+
"reasoner": (2.00, 6.00, 0.20),
|
|
42
|
+
"debate": (2.00, 6.00, 0.20),
|
|
43
|
+
"standard": (3.00, 15.00, 0.20),
|
|
44
|
+
"heavy": (6.00, 30.00, 0.40),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_GEMINI_PRICES: dict[str, tuple[float, float, float]] = {
|
|
48
|
+
"scout": (0.15, 0.60, 0.04), # gemini-2.5-flash (free tier: 1K/day)
|
|
49
|
+
"coder": (0.075, 0.30, 0.02), # gemini-2.5-flash-8b
|
|
50
|
+
"thinker": (1.25, 10.00, 0.31), # gemini-2.5-pro ≤200K
|
|
51
|
+
"reasoner": (2.50, 10.00, 0.63), # gemini-2.5-pro >200K
|
|
52
|
+
"debate": (2.50, 10.00, 0.63), # falls back to Reasoner
|
|
53
|
+
"standard": (1.25, 10.00, 0.31),
|
|
54
|
+
"heavy": (2.50, 10.00, 0.63),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_CODEX_PRICES: dict[str, tuple[float, float, float]] = {
|
|
58
|
+
"scout": (0.40, 1.60, 0.10), # gpt-4.1-mini
|
|
59
|
+
"coder": (2.00, 8.00, 0.50), # gpt-4.1
|
|
60
|
+
"thinker": (1.10, 4.40, 0.28), # o4-mini
|
|
61
|
+
"reasoner": (2.00, 8.00, 0.50), # o3
|
|
62
|
+
"debate": (2.00, 8.00, 0.50), # o3 ×4 parallel
|
|
63
|
+
"standard": (2.00, 8.00, 0.50),
|
|
64
|
+
"heavy": (10.00, 40.00, 2.50), # o3-pro
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Tool call costs (per 1,000 calls)
|
|
68
|
+
_TOOL_CALL_COSTS_PER_K: dict[str, float] = {
|
|
69
|
+
"web_search": 5.00,
|
|
70
|
+
"x_search": 5.00,
|
|
71
|
+
"document_query": 2.50,
|
|
72
|
+
"file_attachment_search": 10.00,
|
|
73
|
+
}
|
|
74
|
+
# fmt: on
|
|
75
|
+
|
|
76
|
+
_PROVIDER_PRICES: dict[str, dict[str, tuple[float, float, float]]] = {
|
|
77
|
+
"grok": _GROK_PRICES,
|
|
78
|
+
"gemini": _GEMINI_PRICES,
|
|
79
|
+
"codex": _CODEX_PRICES,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Data classes
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TurnCost:
|
|
89
|
+
"""Cost breakdown for one agent turn."""
|
|
90
|
+
|
|
91
|
+
provider: ProviderName
|
|
92
|
+
tier: ModelTier
|
|
93
|
+
input_tokens: int
|
|
94
|
+
output_tokens: int
|
|
95
|
+
cached_tokens: int = 0
|
|
96
|
+
tool_calls: dict[str, int] = field(default_factory=dict) # name → count
|
|
97
|
+
usd: float = 0.0
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if self.usd == 0.0:
|
|
101
|
+
self.usd = _compute_usd(
|
|
102
|
+
provider=self.provider,
|
|
103
|
+
tier=self.tier,
|
|
104
|
+
input_tokens=self.input_tokens,
|
|
105
|
+
output_tokens=self.output_tokens,
|
|
106
|
+
cached_tokens=self.cached_tokens,
|
|
107
|
+
tool_calls=self.tool_calls,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Core class
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class CostTracker:
|
|
116
|
+
"""Tracks token usage and USD cost across an agent session.
|
|
117
|
+
|
|
118
|
+
Lightweight — no I/O on the hot path. Call ``flush_to_db()`` at
|
|
119
|
+
session end to persist the total.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, session_id: str, provider: ProviderName) -> None:
|
|
123
|
+
self._session_id = session_id
|
|
124
|
+
self._provider = provider
|
|
125
|
+
self._turns: list[TurnCost] = []
|
|
126
|
+
self._session_total_usd: float = 0.0
|
|
127
|
+
self._total_input: int = 0
|
|
128
|
+
self._total_output: int = 0
|
|
129
|
+
self._total_cached: int = 0
|
|
130
|
+
self._pending_tool_calls: dict[str, int] = {}
|
|
131
|
+
self._alerted_50: bool = False
|
|
132
|
+
self._alerted_80: bool = False
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Recording
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def record(
|
|
139
|
+
self,
|
|
140
|
+
tier: ModelTier,
|
|
141
|
+
input_tokens: int,
|
|
142
|
+
output_tokens: int,
|
|
143
|
+
cached_tokens: int = 0,
|
|
144
|
+
tool_calls: dict[str, int] | None = None,
|
|
145
|
+
) -> TurnCost:
|
|
146
|
+
"""Record one turn's token usage and return its TurnCost."""
|
|
147
|
+
turn = TurnCost(
|
|
148
|
+
provider=self._provider,
|
|
149
|
+
tier=tier,
|
|
150
|
+
input_tokens=input_tokens,
|
|
151
|
+
output_tokens=output_tokens,
|
|
152
|
+
cached_tokens=cached_tokens,
|
|
153
|
+
tool_calls=tool_calls or {},
|
|
154
|
+
)
|
|
155
|
+
self._turns.append(turn)
|
|
156
|
+
self._session_total_usd += turn.usd
|
|
157
|
+
self._total_input += input_tokens
|
|
158
|
+
self._total_output += output_tokens
|
|
159
|
+
self._total_cached += cached_tokens
|
|
160
|
+
log.debug(
|
|
161
|
+
"turn cost: provider=%s tier=%s in=%d out=%d cached=%d → $%.5f",
|
|
162
|
+
self._provider, tier, input_tokens, output_tokens, cached_tokens, turn.usd,
|
|
163
|
+
)
|
|
164
|
+
return turn
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Queries
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def session_total_usd(self) -> float:
|
|
172
|
+
"""Running session cost in USD."""
|
|
173
|
+
return self._session_total_usd
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def turn_count(self) -> int:
|
|
177
|
+
return len(self._turns)
|
|
178
|
+
|
|
179
|
+
def summary(self) -> dict[str, object]:
|
|
180
|
+
"""Return a summary dict suitable for display."""
|
|
181
|
+
return {
|
|
182
|
+
"session_id": self._session_id,
|
|
183
|
+
"provider": self._provider,
|
|
184
|
+
"turns": self.turn_count,
|
|
185
|
+
"input_tokens": self._total_input,
|
|
186
|
+
"output_tokens": self._total_output,
|
|
187
|
+
"cached_tokens": self._total_cached,
|
|
188
|
+
"total_usd": round(self._session_total_usd, 6),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def exceeds(self, limit_usd: float) -> bool:
|
|
192
|
+
"""True if session cost has exceeded *limit_usd*."""
|
|
193
|
+
return self._session_total_usd >= limit_usd
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Tool call tracking
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def record_tool_call(self, tool_name: str, count: int = 1) -> None:
|
|
200
|
+
"""Accumulate a tool call for inclusion in the next flush_event_to_db call."""
|
|
201
|
+
self._pending_tool_calls[tool_name] = (
|
|
202
|
+
self._pending_tool_calls.get(tool_name, 0) + count
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Budget enforcement
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def check_budget(self, db: object) -> None: # type: ignore[type-arg]
|
|
210
|
+
"""Raise BudgetExceededError if any hard-stop budget limit is breached.
|
|
211
|
+
|
|
212
|
+
Emits WARNING logs at 50 % and 80 % thresholds before raising.
|
|
213
|
+
No-ops if db has no applicable budget_limits rows.
|
|
214
|
+
"""
|
|
215
|
+
from src.exceptions import BudgetExceededError
|
|
216
|
+
limit = self._effective_limit(db)
|
|
217
|
+
if limit is None:
|
|
218
|
+
return
|
|
219
|
+
pct = self._session_total_usd / limit if limit > 0 else 0.0
|
|
220
|
+
if pct >= 0.80 and not self._alerted_80:
|
|
221
|
+
self._alerted_80 = True
|
|
222
|
+
log.warning(
|
|
223
|
+
"⚠ Budget 80%% alert: spent $%.4f of $%.2f limit",
|
|
224
|
+
self._session_total_usd, limit,
|
|
225
|
+
)
|
|
226
|
+
elif pct >= 0.50 and not self._alerted_50:
|
|
227
|
+
self._alerted_50 = True
|
|
228
|
+
log.warning(
|
|
229
|
+
"⚠ Budget 50%% alert: spent $%.4f of $%.2f limit",
|
|
230
|
+
self._session_total_usd, limit,
|
|
231
|
+
)
|
|
232
|
+
if self._session_total_usd >= limit:
|
|
233
|
+
raise BudgetExceededError(
|
|
234
|
+
f"Budget limit ${limit:.2f} exceeded (spent ${self._session_total_usd:.2f})"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _effective_limit(self, db: object) -> float | None: # type: ignore[type-arg]
|
|
238
|
+
"""Return the most restrictive session-scoped hard-stop limit, or None."""
|
|
239
|
+
try:
|
|
240
|
+
row = db.execute_one( # type: ignore[union-attr]
|
|
241
|
+
"""
|
|
242
|
+
SELECT limit_usd FROM budget_limits
|
|
243
|
+
WHERE scope_type = 'session' AND scope_id = ?
|
|
244
|
+
AND period = 'session' AND hard_stop = 1
|
|
245
|
+
ORDER BY limit_usd ASC LIMIT 1
|
|
246
|
+
""",
|
|
247
|
+
(self._session_id,),
|
|
248
|
+
)
|
|
249
|
+
return float(row["limit_usd"]) if row is not None else None
|
|
250
|
+
except Exception as exc: # noqa: BLE001
|
|
251
|
+
log.debug("_effective_limit query failed: %s", exc)
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Persistence
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def flush_event_to_db(
|
|
259
|
+
self,
|
|
260
|
+
db: object, # type: ignore[type-arg]
|
|
261
|
+
tier: str,
|
|
262
|
+
input_tokens: int,
|
|
263
|
+
output_tokens: int,
|
|
264
|
+
cached_tokens: int,
|
|
265
|
+
cost_usd: float,
|
|
266
|
+
event_id: str | None = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Persist one turn's cost to the cost_events append-only ledger.
|
|
269
|
+
|
|
270
|
+
Clears ``_pending_tool_calls`` after writing. Also updates the running
|
|
271
|
+
session total on the sessions table.
|
|
272
|
+
"""
|
|
273
|
+
import json
|
|
274
|
+
try:
|
|
275
|
+
db.cost_event_insert( # type: ignore[union-attr]
|
|
276
|
+
session_id=self._session_id,
|
|
277
|
+
provider=self._provider,
|
|
278
|
+
tier=tier,
|
|
279
|
+
input_tokens=input_tokens,
|
|
280
|
+
output_tokens=output_tokens,
|
|
281
|
+
cached_tokens=cached_tokens,
|
|
282
|
+
cost_usd=cost_usd,
|
|
283
|
+
tool_calls_json=json.dumps(self._pending_tool_calls),
|
|
284
|
+
event_id=event_id,
|
|
285
|
+
)
|
|
286
|
+
self._pending_tool_calls.clear()
|
|
287
|
+
self.flush_to_db(db)
|
|
288
|
+
except Exception as exc: # noqa: BLE001
|
|
289
|
+
log.warning("flush_event_to_db failed: %s", exc)
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def monthly_spend(db: object, project_id: str | None = None) -> float: # type: ignore[type-arg]
|
|
293
|
+
"""Return the rolling 30-day total spend from the cost_events ledger."""
|
|
294
|
+
try:
|
|
295
|
+
return db.cost_monthly_spend(project_id) # type: ignore[union-attr]
|
|
296
|
+
except Exception as exc: # noqa: BLE001
|
|
297
|
+
log.warning("monthly_spend failed: %s", exc)
|
|
298
|
+
return 0.0
|
|
299
|
+
|
|
300
|
+
def flush_to_db(self, db: object) -> None: # type: ignore[type-arg]
|
|
301
|
+
"""Persist session cost total to the database (idempotent SET, not increment).
|
|
302
|
+
|
|
303
|
+
Safe to call multiple times — always writes the current absolute total.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
db: GdmDatabase instance (imported lazily to avoid circular dep).
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
db.execute( # type: ignore[union-attr]
|
|
310
|
+
"UPDATE sessions SET cost_usd = ? WHERE session_id = ?",
|
|
311
|
+
(self._session_total_usd, self._session_id),
|
|
312
|
+
)
|
|
313
|
+
except Exception as exc: # noqa: BLE001
|
|
314
|
+
log.warning("flush_to_db failed: %s", exc)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# Private helpers
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def _compute_usd(
|
|
322
|
+
provider: str,
|
|
323
|
+
tier: str,
|
|
324
|
+
input_tokens: int,
|
|
325
|
+
output_tokens: int,
|
|
326
|
+
cached_tokens: int,
|
|
327
|
+
tool_calls: dict[str, int],
|
|
328
|
+
) -> float:
|
|
329
|
+
"""Compute USD cost for one turn."""
|
|
330
|
+
prices = _PROVIDER_PRICES.get(provider, _GROK_PRICES)
|
|
331
|
+
tier_prices = prices.get(tier, prices.get("coder", (2.00, 6.00, 0.20)))
|
|
332
|
+
in_price, out_price, cached_price = tier_prices
|
|
333
|
+
|
|
334
|
+
# Non-cached input tokens = total input − cached
|
|
335
|
+
real_input = max(0, input_tokens - cached_tokens)
|
|
336
|
+
|
|
337
|
+
usd = (
|
|
338
|
+
real_input * in_price / 1_000_000
|
|
339
|
+
+ cached_tokens * cached_price / 1_000_000
|
|
340
|
+
+ output_tokens * out_price / 1_000_000
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Add tool call costs
|
|
344
|
+
for tool_name, count in tool_calls.items():
|
|
345
|
+
cost_per_k = _TOOL_CALL_COSTS_PER_K.get(tool_name, 0.0)
|
|
346
|
+
usd += cost_per_k * count / 1_000
|
|
347
|
+
|
|
348
|
+
return usd
|
src/db/__init__.py
ADDED
src/db/migrations.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Central migration registry for GdmDatabase schema changes.
|
|
2
|
+
|
|
3
|
+
All schema migrations must register here. No module may hardcode an
|
|
4
|
+
absolute schema version number outside this file.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from src.db.migrations import registry, Migration
|
|
9
|
+
|
|
10
|
+
registry.register(Migration(
|
|
11
|
+
version=7,
|
|
12
|
+
description="Add my_table",
|
|
13
|
+
upgrade=lambda conn: conn.execute("ALTER TABLE ..."),
|
|
14
|
+
))
|
|
15
|
+
|
|
16
|
+
The GdmDatabase calls registry.apply_pending(conn) on every open.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import sqlite3
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
__all__ = ["Migration", "MigrationRegistry", "registry"]
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Migration:
|
|
32
|
+
"""A single schema migration step."""
|
|
33
|
+
|
|
34
|
+
version: int
|
|
35
|
+
description: str
|
|
36
|
+
upgrade: Callable[[sqlite3.Connection], None]
|
|
37
|
+
downgrade: Callable[[sqlite3.Connection], None] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MigrationRegistry:
|
|
41
|
+
"""Central registry for ordered, idempotent schema migrations.
|
|
42
|
+
|
|
43
|
+
Migrations are applied in ascending version order. Each migration is
|
|
44
|
+
applied exactly once and tracked in the schema_version table.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._migrations: dict[int, Migration] = {}
|
|
49
|
+
|
|
50
|
+
def register(self, migration: Migration) -> None:
|
|
51
|
+
"""Register a migration. Raises ValueError on duplicate version."""
|
|
52
|
+
if migration.version in self._migrations:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Duplicate migration version {migration.version}: "
|
|
55
|
+
f"'{self._migrations[migration.version].description}' "
|
|
56
|
+
f"vs '{migration.description}'"
|
|
57
|
+
)
|
|
58
|
+
self._migrations[migration.version] = migration
|
|
59
|
+
|
|
60
|
+
def current_version(self, conn: sqlite3.Connection) -> int:
|
|
61
|
+
"""Return the highest applied migration version, or 0 if none."""
|
|
62
|
+
conn.execute(
|
|
63
|
+
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)"
|
|
64
|
+
)
|
|
65
|
+
row = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()
|
|
66
|
+
return int(row[0]) if row and row[0] is not None else 0
|
|
67
|
+
|
|
68
|
+
def apply_pending(self, conn: sqlite3.Connection) -> None:
|
|
69
|
+
"""Apply all registered migrations not yet applied. Idempotent."""
|
|
70
|
+
current = self.current_version(conn)
|
|
71
|
+
pending = sorted(v for v in self._migrations if v > current)
|
|
72
|
+
for version in pending:
|
|
73
|
+
migration = self._migrations[version]
|
|
74
|
+
log.info("Applying migration v%d: %s", version, migration.description)
|
|
75
|
+
try:
|
|
76
|
+
migration.upgrade(conn)
|
|
77
|
+
conn.execute(
|
|
78
|
+
"INSERT OR IGNORE INTO schema_version (version) VALUES (?)",
|
|
79
|
+
(version,),
|
|
80
|
+
)
|
|
81
|
+
conn.commit()
|
|
82
|
+
log.info("Migration v%d applied successfully", version)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
log.error("Migration v%d failed: %s", version, exc)
|
|
85
|
+
try:
|
|
86
|
+
conn.execute("ROLLBACK")
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
def versions(self) -> list[int]:
|
|
92
|
+
"""Return sorted list of all registered migration versions."""
|
|
93
|
+
return sorted(self._migrations)
|
|
94
|
+
|
|
95
|
+
def latest_version(self) -> int:
|
|
96
|
+
"""Return the highest registered migration version, or 0 if empty."""
|
|
97
|
+
return max(self._migrations, default=0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Module-level singleton registry
|
|
102
|
+
# All modules register their migrations here at import time.
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
registry = MigrationRegistry()
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Baseline migrations (extracted from the existing GdmDatabase ad-hoc system)
|
|
108
|
+
# These represent the schema as it exists at v6.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _noop(conn: sqlite3.Connection) -> None:
|
|
113
|
+
"""No-op upgrade for baseline migrations where DDL is handled by CREATE IF NOT EXISTS."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# v1: Initial schema (handled by DDL in GdmDatabase)
|
|
118
|
+
registry.register(Migration(version=1, description="Initial schema (projects, sessions, memory, file_cache, conventions, code_index, errors, permissions, checkpoints, tasks, audit_log, btw_queue, spinner_state)", upgrade=_noop))
|
|
119
|
+
|
|
120
|
+
# v2: Add memory.tool_calls_json
|
|
121
|
+
def _upgrade_v2(conn: sqlite3.Connection) -> None:
|
|
122
|
+
try:
|
|
123
|
+
conn.execute("ALTER TABLE memory ADD COLUMN tool_calls_json TEXT")
|
|
124
|
+
except Exception:
|
|
125
|
+
pass # Already exists on fresh DBs
|
|
126
|
+
|
|
127
|
+
registry.register(Migration(version=2, description="Add memory.tool_calls_json", upgrade=_upgrade_v2))
|
|
128
|
+
|
|
129
|
+
# v3: Add session_events, tool_call_log, patch_log tables (DDL handles this)
|
|
130
|
+
registry.register(Migration(version=3, description="Add session_events, tool_call_log, patch_log tables", upgrade=_noop))
|
|
131
|
+
|
|
132
|
+
# v4: Add cost_events, budget_limits, budget_usage tables (DDL handles this)
|
|
133
|
+
registry.register(Migration(version=4, description="Add cost_events, budget_limits, budget_usage, daemon_jobs tables", upgrade=_noop))
|
|
134
|
+
|
|
135
|
+
# v5: Add session_events.annotation column
|
|
136
|
+
def _upgrade_v5(conn: sqlite3.Connection) -> None:
|
|
137
|
+
try:
|
|
138
|
+
conn.execute("ALTER TABLE session_events ADD COLUMN annotation TEXT")
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
registry.register(Migration(version=5, description="Add session_events.annotation column", upgrade=_upgrade_v5))
|
|
143
|
+
|
|
144
|
+
# v6: Add memory.turn_index (with backfill), unique index, sessions.status column
|
|
145
|
+
def _upgrade_v6(conn: sqlite3.Connection) -> None:
|
|
146
|
+
try:
|
|
147
|
+
conn.execute("ALTER TABLE memory ADD COLUMN turn_index INTEGER NOT NULL DEFAULT 0")
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
try:
|
|
151
|
+
conn.execute(
|
|
152
|
+
"UPDATE memory SET turn_index = ("
|
|
153
|
+
" SELECT COUNT(*) FROM memory m2"
|
|
154
|
+
" WHERE m2.session_id = memory.session_id AND m2.id < memory.id"
|
|
155
|
+
")"
|
|
156
|
+
)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
conn.execute(
|
|
161
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_session_turn "
|
|
162
|
+
"ON memory(session_id, turn_index)"
|
|
163
|
+
)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
try:
|
|
167
|
+
conn.execute(
|
|
168
|
+
"ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
|
|
169
|
+
)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
registry.register(Migration(version=6, description="Add memory.turn_index, UNIQUE index, sessions.status", upgrade=_upgrade_v6))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# v7: Add artifacts, artifact_versions, artifact_links, artifact_fts tables
|
|
177
|
+
def _upgrade_v7(conn: sqlite3.Connection) -> None:
|
|
178
|
+
conn.executescript("""
|
|
179
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
180
|
+
artifact_id TEXT PRIMARY KEY,
|
|
181
|
+
name TEXT NOT NULL,
|
|
182
|
+
type TEXT NOT NULL CHECK(type IN (
|
|
183
|
+
'diagram','report','plan','coverage','diff','custom'
|
|
184
|
+
)),
|
|
185
|
+
description TEXT,
|
|
186
|
+
session_id TEXT REFERENCES sessions(session_id),
|
|
187
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
188
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
189
|
+
visibility TEXT NOT NULL DEFAULT 'private'
|
|
190
|
+
CHECK(visibility IN ('private','project','public')),
|
|
191
|
+
UNIQUE(name)
|
|
192
|
+
);
|
|
193
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_type ON artifacts(type);
|
|
195
|
+
|
|
196
|
+
CREATE TABLE IF NOT EXISTS artifact_versions (
|
|
197
|
+
version_id TEXT PRIMARY KEY,
|
|
198
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(artifact_id) ON DELETE CASCADE,
|
|
199
|
+
version_num INTEGER NOT NULL,
|
|
200
|
+
content TEXT NOT NULL,
|
|
201
|
+
content_hash TEXT NOT NULL,
|
|
202
|
+
byte_size INTEGER NOT NULL DEFAULT 0,
|
|
203
|
+
event_id TEXT REFERENCES session_events(event_id),
|
|
204
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
205
|
+
UNIQUE(artifact_id, version_num)
|
|
206
|
+
);
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_av_artifact ON artifact_versions(artifact_id, version_num DESC);
|
|
208
|
+
|
|
209
|
+
CREATE TABLE IF NOT EXISTS artifact_links (
|
|
210
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
211
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(artifact_id) ON DELETE CASCADE,
|
|
212
|
+
link_type TEXT NOT NULL CHECK(link_type IN (
|
|
213
|
+
'session','turn','file','commit','pr','evidence_node'
|
|
214
|
+
)),
|
|
215
|
+
ref_value TEXT NOT NULL,
|
|
216
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
217
|
+
);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_artifact_links ON artifact_links(artifact_id, link_type);
|
|
219
|
+
|
|
220
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(
|
|
221
|
+
name, description, content
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
CREATE TRIGGER IF NOT EXISTS artifact_fts_insert
|
|
225
|
+
AFTER INSERT ON artifact_versions BEGIN
|
|
226
|
+
INSERT INTO artifact_fts(rowid, name, description, content)
|
|
227
|
+
SELECT new.rowid,
|
|
228
|
+
(SELECT name FROM artifacts WHERE artifact_id = new.artifact_id),
|
|
229
|
+
(SELECT description FROM artifacts WHERE artifact_id = new.artifact_id),
|
|
230
|
+
new.content;
|
|
231
|
+
END;
|
|
232
|
+
|
|
233
|
+
CREATE TRIGGER IF NOT EXISTS artifact_fts_delete
|
|
234
|
+
AFTER DELETE ON artifact_versions BEGIN
|
|
235
|
+
DELETE FROM artifact_fts WHERE rowid = old.rowid;
|
|
236
|
+
END;
|
|
237
|
+
""")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
registry.register(Migration(version=7, description="Add artifact tables (artifacts, artifact_versions, artifact_links, artifact_fts)", upgrade=_upgrade_v7))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# v8: Add checkpoints.turn_id column for per-turn rollback journal
|
|
244
|
+
def _upgrade_v8(conn: sqlite3.Connection) -> None:
|
|
245
|
+
try:
|
|
246
|
+
conn.execute("ALTER TABLE checkpoints ADD COLUMN turn_id TEXT")
|
|
247
|
+
except Exception:
|
|
248
|
+
pass # Already exists on fresh DBs
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
registry.register(Migration(version=8, description="Add checkpoints.turn_id for per-turn rollback journal", upgrade=_upgrade_v8))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# v9: Add edit_nodes, evidence_nodes, graph_edges for VerificationGraph
|
|
256
|
+
def _upgrade_v9(conn):
|
|
257
|
+
conn.executescript("""
|
|
258
|
+
CREATE TABLE IF NOT EXISTS edit_nodes (
|
|
259
|
+
node_id TEXT PRIMARY KEY,
|
|
260
|
+
session_id TEXT NOT NULL,
|
|
261
|
+
turn_index INTEGER NOT NULL,
|
|
262
|
+
file_path TEXT NOT NULL,
|
|
263
|
+
patch_ref TEXT,
|
|
264
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
265
|
+
UNIQUE(session_id, turn_index, file_path)
|
|
266
|
+
);
|
|
267
|
+
CREATE INDEX IF NOT EXISTS idx_edit_nodes_session ON edit_nodes(session_id);
|
|
268
|
+
CREATE INDEX IF NOT EXISTS idx_edit_nodes_file ON edit_nodes(file_path);
|
|
269
|
+
CREATE TABLE IF NOT EXISTS evidence_nodes (
|
|
270
|
+
evidence_id TEXT PRIMARY KEY,
|
|
271
|
+
node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
|
|
272
|
+
kind TEXT NOT NULL CHECK(kind IN (
|
|
273
|
+
'TEST','LINT','TYPE_CHECK','REVIEW','RUN','SECURITY','PERF','COVERAGE'
|
|
274
|
+
)),
|
|
275
|
+
verdict TEXT NOT NULL CHECK(verdict IN (
|
|
276
|
+
'PASS','FAIL','WARNING','PENDING','SKIPPED'
|
|
277
|
+
)),
|
|
278
|
+
detail_json TEXT NOT NULL DEFAULT '{}',
|
|
279
|
+
tool TEXT,
|
|
280
|
+
duration_ms INTEGER,
|
|
281
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
282
|
+
);
|
|
283
|
+
CREATE INDEX IF NOT EXISTS idx_evidence_node ON evidence_nodes(node_id, kind);
|
|
284
|
+
CREATE TABLE IF NOT EXISTS graph_edges (
|
|
285
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
286
|
+
from_node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
|
|
287
|
+
to_node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
|
|
288
|
+
UNIQUE(from_node_id, to_node_id)
|
|
289
|
+
);
|
|
290
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edges_from ON graph_edges(from_node_id);
|
|
291
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edges_to ON graph_edges(to_node_id);
|
|
292
|
+
""")
|
|
293
|
+
|
|
294
|
+
registry.register(Migration(
|
|
295
|
+
version=9,
|
|
296
|
+
description="Add edit_nodes, evidence_nodes, graph_edges for VerificationGraph",
|
|
297
|
+
upgrade=_upgrade_v9,
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# v10: Add mtime column to code_index for incremental re-indexing
|
|
302
|
+
def _upgrade_v10(conn: sqlite3.Connection) -> None:
|
|
303
|
+
try:
|
|
304
|
+
conn.execute("ALTER TABLE code_index ADD COLUMN mtime REAL DEFAULT 0")
|
|
305
|
+
except Exception:
|
|
306
|
+
pass # Already exists on fresh DBs
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
registry.register(Migration(
|
|
310
|
+
version=10,
|
|
311
|
+
description="Add code_index.mtime for incremental re-indexing",
|
|
312
|
+
upgrade=_upgrade_v10,
|
|
313
|
+
))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# v11: Add confidence_outcomes table for ide-003 calibration persistence
|
|
317
|
+
def _upgrade_v11(conn: sqlite3.Connection) -> None:
|
|
318
|
+
conn.executescript("""
|
|
319
|
+
CREATE TABLE IF NOT EXISTS confidence_outcomes (
|
|
320
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
321
|
+
session_id TEXT NOT NULL,
|
|
322
|
+
hunk_hash TEXT NOT NULL,
|
|
323
|
+
score INTEGER NOT NULL,
|
|
324
|
+
reasons_json TEXT NOT NULL DEFAULT '[]',
|
|
325
|
+
verified INTEGER NOT NULL DEFAULT 0,
|
|
326
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
327
|
+
);
|
|
328
|
+
CREATE INDEX IF NOT EXISTS idx_co_session ON confidence_outcomes(session_id);
|
|
329
|
+
CREATE INDEX IF NOT EXISTS idx_co_hunk ON confidence_outcomes(hunk_hash);
|
|
330
|
+
""")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
registry.register(Migration(
|
|
334
|
+
version=11,
|
|
335
|
+
description="Add confidence_outcomes table for calibration persistence",
|
|
336
|
+
upgrade=_upgrade_v11,
|
|
337
|
+
))
|