code-meter 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.
@@ -0,0 +1,78 @@
1
+ """Default seed pricing data for Anthropic models."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ DEFAULT_ANTHROPIC_PRICING: List[Dict[str, Any]] = [
6
+ {
7
+ "provider": "claude_code",
8
+ "model": "claude-3-7-sonnet-20250219",
9
+ "input_price_per_million": 3.00,
10
+ "output_price_per_million": 15.00,
11
+ "cache_read_price_per_million": 0.30,
12
+ "cache_write_price_per_million": 3.75,
13
+ "effective_from": "2025-02-19T00:00:00Z",
14
+ },
15
+ {
16
+ "provider": "claude_code",
17
+ "model": "claude-3-5-sonnet-20241022",
18
+ "input_price_per_million": 3.00,
19
+ "output_price_per_million": 15.00,
20
+ "cache_read_price_per_million": 0.30,
21
+ "cache_write_price_per_million": 3.75,
22
+ "effective_from": "2024-10-22T00:00:00Z",
23
+ },
24
+ {
25
+ "provider": "claude_code",
26
+ "model": "claude-3-5-sonnet-20240620",
27
+ "input_price_per_million": 3.00,
28
+ "output_price_per_million": 15.00,
29
+ "cache_read_price_per_million": 0.30,
30
+ "cache_write_price_per_million": 3.75,
31
+ "effective_from": "2024-06-20T00:00:00Z",
32
+ },
33
+ {
34
+ "provider": "claude_code",
35
+ "model": "claude-sonnet",
36
+ "input_price_per_million": 3.00,
37
+ "output_price_per_million": 15.00,
38
+ "cache_read_price_per_million": 0.30,
39
+ "cache_write_price_per_million": 3.75,
40
+ "effective_from": "2024-01-01T00:00:00Z",
41
+ },
42
+ {
43
+ "provider": "claude_code",
44
+ "model": "claude-3-5-haiku-20241022",
45
+ "input_price_per_million": 0.80,
46
+ "output_price_per_million": 4.00,
47
+ "cache_read_price_per_million": 0.08,
48
+ "cache_write_price_per_million": 1.00,
49
+ "effective_from": "2024-10-22T00:00:00Z",
50
+ },
51
+ {
52
+ "provider": "claude_code",
53
+ "model": "claude-haiku",
54
+ "input_price_per_million": 0.80,
55
+ "output_price_per_million": 4.00,
56
+ "cache_read_price_per_million": 0.08,
57
+ "cache_write_price_per_million": 1.00,
58
+ "effective_from": "2024-01-01T00:00:00Z",
59
+ },
60
+ {
61
+ "provider": "claude_code",
62
+ "model": "claude-3-opus-20240229",
63
+ "input_price_per_million": 15.00,
64
+ "output_price_per_million": 75.00,
65
+ "cache_read_price_per_million": 1.50,
66
+ "cache_write_price_per_million": 18.75,
67
+ "effective_from": "2024-02-29T00:00:00Z",
68
+ },
69
+ {
70
+ "provider": "claude_code",
71
+ "model": "claude-opus",
72
+ "input_price_per_million": 15.00,
73
+ "output_price_per_million": 75.00,
74
+ "cache_read_price_per_million": 1.50,
75
+ "cache_write_price_per_million": 18.75,
76
+ "effective_from": "2024-01-01T00:00:00Z",
77
+ },
78
+ ]
@@ -0,0 +1,97 @@
1
+ """Dynamic Pricing Engine for calculating USD estimated cost of AI token usage."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Dict, List, Optional
5
+ from code_meter.pricing.anthropic import DEFAULT_ANTHROPIC_PRICING
6
+ from code_meter.pricing.google import DEFAULT_GOOGLE_PRICING
7
+ from code_meter.pricing.openai import DEFAULT_OPENAI_PRICING
8
+ from code_meter.storage.repository import UsageRepository
9
+
10
+ DEFAULT_PRICING = DEFAULT_ANTHROPIC_PRICING + DEFAULT_OPENAI_PRICING + DEFAULT_GOOGLE_PRICING
11
+
12
+
13
+ class PricingEngine:
14
+ """Calculates estimated token usage costs based on model pricing rules."""
15
+
16
+ def __init__(self, repository: Optional[UsageRepository] = None):
17
+ self.repository = repository
18
+ self._pricing_cache: List[Dict[str, Any]] = []
19
+ self.reload_pricing()
20
+
21
+ def reload_pricing(self) -> None:
22
+ """Seed initial pricing if needed and load active pricing records."""
23
+ if self.repository:
24
+ db_pricing = self.repository.get_pricing_table()
25
+ if not db_pricing:
26
+ self.repository.save_pricing(DEFAULT_PRICING)
27
+ db_pricing = self.repository.get_pricing_table()
28
+ self._pricing_cache = db_pricing
29
+ else:
30
+ self._pricing_cache = DEFAULT_PRICING
31
+
32
+ def calculate_cost(
33
+ self,
34
+ provider: str,
35
+ model: str,
36
+ input_tokens: int,
37
+ output_tokens: int,
38
+ cache_read_tokens: int = 0,
39
+ cache_write_tokens: int = 0,
40
+ timestamp: Optional[datetime] = None,
41
+ ) -> Optional[float]:
42
+ """Calculate USD estimated cost. Returns None if model pricing is unknown."""
43
+ rule = self._find_matching_rule(provider, model, timestamp)
44
+ if not rule:
45
+ return None
46
+
47
+ input_cost = (input_tokens / 1_000_000.0) * rule["input_price_per_million"]
48
+ output_cost = (output_tokens / 1_000_000.0) * rule["output_price_per_million"]
49
+ cache_read_cost = (cache_read_tokens / 1_000_000.0) * rule.get("cache_read_price_per_million", 0.0)
50
+ cache_write_cost = (cache_write_tokens / 1_000_000.0) * rule.get("cache_write_price_per_million", 0.0)
51
+
52
+ return round(input_cost + output_cost + cache_read_cost + cache_write_cost, 6)
53
+
54
+ def _find_matching_rule(
55
+ self,
56
+ provider: str,
57
+ model: str,
58
+ timestamp: Optional[datetime] = None,
59
+ ) -> Optional[Dict[str, Any]]:
60
+ """Find best matching pricing rule for provider and model based on string matching and effective date."""
61
+ if not model or model == "<synthetic>" or model == "unknown":
62
+ return None
63
+
64
+ norm_model = model.lower().strip()
65
+ norm_provider = provider.lower().strip() if provider else ""
66
+
67
+ # Filter by provider if applicable
68
+ provider_cache = [
69
+ p for p in self._pricing_cache
70
+ if not norm_provider or p.get("provider", "").lower().strip() == norm_provider
71
+ ]
72
+ search_pool = provider_cache if provider_cache else self._pricing_cache
73
+
74
+ # 1. Exact match
75
+ exact_matches = [p for p in search_pool if p["model"].lower().strip() == norm_model]
76
+ if exact_matches:
77
+ exact_matches.sort(key=lambda x: x.get("effective_from", ""), reverse=True)
78
+ return exact_matches[0]
79
+
80
+ # 2. Substring/family matches
81
+ candidates = []
82
+ for p in search_pool:
83
+ p_model = p["model"].lower().strip()
84
+ if p_model in norm_model or norm_model in p_model:
85
+ candidates.append(p)
86
+ elif "sonnet" in norm_model and "sonnet" in p_model:
87
+ candidates.append(p)
88
+ elif "haiku" in norm_model and "haiku" in p_model:
89
+ candidates.append(p)
90
+ elif "opus" in norm_model and "opus" in p_model:
91
+ candidates.append(p)
92
+
93
+ if not candidates:
94
+ return None
95
+
96
+ candidates.sort(key=lambda x: x.get("effective_from", ""), reverse=True)
97
+ return candidates[0]
@@ -0,0 +1,60 @@
1
+ """Default seed pricing data for Google / Antigravity / Gemini models."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ DEFAULT_GOOGLE_PRICING: List[Dict[str, Any]] = [
6
+ {
7
+ "provider": "antigravity",
8
+ "model": "gemini-2.5-flash",
9
+ "input_price_per_million": 0.15,
10
+ "output_price_per_million": 0.60,
11
+ "cache_read_price_per_million": 0.0375,
12
+ "cache_write_price_per_million": 0.00,
13
+ "effective_from": "2025-01-01T00:00:00Z",
14
+ },
15
+ {
16
+ "provider": "antigravity",
17
+ "model": "gemini-3.6-flash",
18
+ "input_price_per_million": 0.15,
19
+ "output_price_per_million": 0.60,
20
+ "cache_read_price_per_million": 0.0375,
21
+ "cache_write_price_per_million": 0.00,
22
+ "effective_from": "2026-01-01T00:00:00Z",
23
+ },
24
+ {
25
+ "provider": "antigravity",
26
+ "model": "gemini-2.5-pro",
27
+ "input_price_per_million": 1.25,
28
+ "output_price_per_million": 5.00,
29
+ "cache_read_price_per_million": 0.3125,
30
+ "cache_write_price_per_million": 0.00,
31
+ "effective_from": "2025-01-01T00:00:00Z",
32
+ },
33
+ {
34
+ "provider": "antigravity",
35
+ "model": "gemini-1.5-pro",
36
+ "input_price_per_million": 1.25,
37
+ "output_price_per_million": 5.00,
38
+ "cache_read_price_per_million": 0.3125,
39
+ "cache_write_price_per_million": 0.00,
40
+ "effective_from": "2024-05-01T00:00:00Z",
41
+ },
42
+ {
43
+ "provider": "antigravity",
44
+ "model": "gemini-1.5-flash",
45
+ "input_price_per_million": 0.075,
46
+ "output_price_per_million": 0.30,
47
+ "cache_read_price_per_million": 0.01875,
48
+ "cache_write_price_per_million": 0.00,
49
+ "effective_from": "2024-05-01T00:00:00Z",
50
+ },
51
+ {
52
+ "provider": "antigravity",
53
+ "model": "antigravity",
54
+ "input_price_per_million": 0.15,
55
+ "output_price_per_million": 0.60,
56
+ "cache_read_price_per_million": 0.0375,
57
+ "cache_write_price_per_million": 0.00,
58
+ "effective_from": "2024-01-01T00:00:00Z",
59
+ },
60
+ ]
@@ -0,0 +1,69 @@
1
+ """Default seed pricing data for OpenAI / Codex models."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ DEFAULT_OPENAI_PRICING: List[Dict[str, Any]] = [
6
+ {
7
+ "provider": "codex",
8
+ "model": "gpt-4o",
9
+ "input_price_per_million": 2.50,
10
+ "output_price_per_million": 10.00,
11
+ "cache_read_price_per_million": 1.25,
12
+ "cache_write_price_per_million": 0.00,
13
+ "effective_from": "2024-05-13T00:00:00Z",
14
+ },
15
+ {
16
+ "provider": "codex",
17
+ "model": "gpt-4o-mini",
18
+ "input_price_per_million": 0.15,
19
+ "output_price_per_million": 0.60,
20
+ "cache_read_price_per_million": 0.075,
21
+ "cache_write_price_per_million": 0.00,
22
+ "effective_from": "2024-07-18T00:00:00Z",
23
+ },
24
+ {
25
+ "provider": "codex",
26
+ "model": "o1",
27
+ "input_price_per_million": 15.00,
28
+ "output_price_per_million": 60.00,
29
+ "cache_read_price_per_million": 7.50,
30
+ "cache_write_price_per_million": 0.00,
31
+ "effective_from": "2024-12-05T00:00:00Z",
32
+ },
33
+ {
34
+ "provider": "codex",
35
+ "model": "o3-mini",
36
+ "input_price_per_million": 1.10,
37
+ "output_price_per_million": 4.40,
38
+ "cache_read_price_per_million": 0.55,
39
+ "cache_write_price_per_million": 0.00,
40
+ "effective_from": "2025-01-31T00:00:00Z",
41
+ },
42
+ {
43
+ "provider": "codex",
44
+ "model": "gpt-4-turbo",
45
+ "input_price_per_million": 10.00,
46
+ "output_price_per_million": 30.00,
47
+ "cache_read_price_per_million": 0.00,
48
+ "cache_write_price_per_million": 0.00,
49
+ "effective_from": "2024-04-09T00:00:00Z",
50
+ },
51
+ {
52
+ "provider": "codex",
53
+ "model": "code-davinci-002",
54
+ "input_price_per_million": 20.00,
55
+ "output_price_per_million": 20.00,
56
+ "cache_read_price_per_million": 0.00,
57
+ "cache_write_price_per_million": 0.00,
58
+ "effective_from": "2022-01-01T00:00:00Z",
59
+ },
60
+ {
61
+ "provider": "codex",
62
+ "model": "codex",
63
+ "input_price_per_million": 2.50,
64
+ "output_price_per_million": 10.00,
65
+ "cache_read_price_per_million": 1.25,
66
+ "cache_write_price_per_million": 0.00,
67
+ "effective_from": "2024-01-01T00:00:00Z",
68
+ },
69
+ ]
@@ -0,0 +1,15 @@
1
+ """Providers package for token usage scanners."""
2
+
3
+ from code_meter.providers.antigravity import AntigravityProvider
4
+ from code_meter.providers.base import FileScanState, ScanResult, UsageProvider
5
+ from code_meter.providers.claude_code import ClaudeCodeProvider
6
+ from code_meter.providers.codex import CodexProvider
7
+
8
+ __all__ = [
9
+ "UsageProvider",
10
+ "ScanResult",
11
+ "FileScanState",
12
+ "ClaudeCodeProvider",
13
+ "CodexProvider",
14
+ "AntigravityProvider",
15
+ ]
@@ -0,0 +1,358 @@
1
+ """Antigravity provider for scanning local Google Antigravity / AGY transcript and session logs (~/.gemini/antigravity-ide)."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
12
+ from code_meter.providers.base import FileScanState, ScanResult, UsageProvider
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AntigravityProvider(UsageProvider):
18
+ """Scanner for Google Antigravity / AGY session transcript files."""
19
+
20
+ def __init__(self, antigravity_dir: Path):
21
+ self.antigravity_dir = Path(os.path.expanduser(antigravity_dir)).resolve()
22
+
23
+ @property
24
+ def provider_name(self) -> str:
25
+ return "antigravity"
26
+
27
+ def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
28
+ """Incrementally scan session files and transcript logs in Antigravity directory."""
29
+ existing = existing_states or {}
30
+ records: List[UsageRecord] = []
31
+ prompts: List[SessionPrompt] = []
32
+ code_changes: List[SessionCodeChange] = []
33
+ updated_states: List[FileScanState] = []
34
+ files_scanned = 0
35
+ malformed_lines = 0
36
+
37
+ if not self.antigravity_dir.exists():
38
+ logger.warning(f"Antigravity directory does not exist: {self.antigravity_dir}")
39
+ return ScanResult(records=[], prompts=[], code_changes=[], updated_states=[], files_scanned=0, malformed_lines=0)
40
+
41
+ transcript_files = self._find_transcript_files()
42
+
43
+ for file_path in transcript_files:
44
+ try:
45
+ stat = file_path.stat()
46
+ file_str = str(file_path.resolve())
47
+ size = stat.st_size
48
+ mtime = stat.st_mtime
49
+
50
+ prev_state = existing.get(file_str)
51
+
52
+ if prev_state:
53
+ if size == prev_state.file_size and mtime == prev_state.modified_time:
54
+ updated_states.append(prev_state)
55
+ continue
56
+
57
+ start_offset = 0 if size < prev_state.file_size else prev_state.read_offset
58
+ else:
59
+ start_offset = 0
60
+
61
+ files_scanned += 1
62
+ new_records, new_prompts, new_changes, new_offset, malformed = self._scan_file(
63
+ file_path=file_path,
64
+ start_offset=start_offset,
65
+ )
66
+
67
+ records.extend(new_records)
68
+ prompts.extend(new_prompts)
69
+ code_changes.extend(new_changes)
70
+ malformed_lines += malformed
71
+
72
+ updated_states.append(
73
+ FileScanState(
74
+ file_path=file_str,
75
+ file_size=size,
76
+ modified_time=mtime,
77
+ read_offset=new_offset,
78
+ )
79
+ )
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error scanning Antigravity file {file_path}: {e}", exc_info=True)
83
+
84
+ return ScanResult(
85
+ records=records,
86
+ prompts=prompts,
87
+ code_changes=code_changes,
88
+ updated_states=updated_states,
89
+ files_scanned=files_scanned,
90
+ malformed_lines=malformed_lines,
91
+ )
92
+
93
+ def _find_transcript_files(self) -> List[Path]:
94
+ """Find transcript files and session jsonl logs recursively."""
95
+ files: List[Path] = []
96
+ try:
97
+ for root, _, filenames in os.walk(self.antigravity_dir):
98
+ for fname in filenames:
99
+ if fname.endswith(".jsonl") or fname.endswith(".json"):
100
+ files.append(Path(root) / fname)
101
+ except PermissionError as e:
102
+ logger.warning(f"Permission denied accessing {self.antigravity_dir}: {e}")
103
+ return files
104
+
105
+ def _scan_file(
106
+ self, file_path: Path, start_offset: int
107
+ ) -> Tuple[List[UsageRecord], List[SessionPrompt], List[SessionCodeChange], int, int]:
108
+ """Scan transcript file from start_offset."""
109
+ records: List[UsageRecord] = []
110
+ prompts: List[SessionPrompt] = []
111
+ changes: List[SessionCodeChange] = []
112
+ malformed = 0
113
+ current_offset = start_offset
114
+
115
+ session_id_from_path = self._infer_session_id(file_path)
116
+ project_name = self._infer_project_name(file_path)
117
+
118
+ line_index = 0
119
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
120
+ if start_offset > 0:
121
+ f.seek(start_offset)
122
+
123
+ while True:
124
+ line = f.readline()
125
+ if not line:
126
+ break
127
+
128
+ current_offset = f.tell()
129
+ line_index += 1
130
+ line_str = line.strip()
131
+ if not line_str:
132
+ continue
133
+
134
+ try:
135
+ data = json.loads(line_str)
136
+ except Exception:
137
+ malformed += 1
138
+ continue
139
+
140
+ rec, pmt, chgs = self._process_data_item(data, str(file_path.resolve()), session_id_from_path, project_name, line_index)
141
+ if rec:
142
+ records.append(rec)
143
+ if pmt:
144
+ prompts.append(pmt)
145
+ if chgs:
146
+ changes.extend(chgs)
147
+
148
+ return records, prompts, changes, current_offset, malformed
149
+
150
+ def _process_data_item(
151
+ self,
152
+ data: dict,
153
+ file_path: str,
154
+ session_id_from_path: str,
155
+ project_name: Optional[str],
156
+ line_index: int,
157
+ ) -> Tuple[Optional[UsageRecord], Optional[SessionPrompt], List[SessionCodeChange]]:
158
+ if not isinstance(data, dict):
159
+ return None, None, []
160
+
161
+ session_id = data.get("conversation_id") or data.get("session_id") or session_id_from_path
162
+ cwd = data.get("cwd") or data.get("project_path")
163
+ p_name = data.get("project") or (os.path.basename(cwd) if cwd else project_name)
164
+
165
+ rec = self._parse_usage_record(data, file_path, session_id, cwd, p_name)
166
+ pmt = self._parse_user_prompt(data, session_id, p_name, line_index)
167
+ chgs = self._parse_code_changes(data, session_id, p_name, line_index)
168
+
169
+ return rec, pmt, chgs
170
+
171
+ def _parse_usage_record(
172
+ self,
173
+ data: dict,
174
+ file_path: str,
175
+ session_id: str,
176
+ cwd: Optional[str],
177
+ project_name: Optional[str],
178
+ ) -> Optional[UsageRecord]:
179
+ usage = data.get("usage") or data.get("token_usage") or data.get("usage_metadata")
180
+ if not isinstance(usage, dict):
181
+ # Check if stored in item directly
182
+ if "input_tokens" in data or "prompt_token_count" in data:
183
+ usage = data
184
+
185
+ if not isinstance(usage, dict):
186
+ return None
187
+
188
+ input_tokens = int(usage.get("input_tokens") or usage.get("prompt_token_count") or 0)
189
+ output_tokens = int(usage.get("output_tokens") or usage.get("candidates_token_count") or 0)
190
+ cache_read_tokens = int(usage.get("cache_read_tokens") or usage.get("cached_content_token_count") or 0)
191
+ cache_write_tokens = int(usage.get("cache_write_tokens") or 0)
192
+
193
+ if (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) == 0:
194
+ return None
195
+
196
+ model = data.get("model") or usage.get("model") or "gemini-2.5-flash"
197
+ if not isinstance(model, str):
198
+ model = str(model)
199
+
200
+ raw_ts = data.get("timestamp") or data.get("created_at") or data.get("time")
201
+ ts = self._parse_timestamp(raw_ts)
202
+
203
+ request_id = data.get("id") or data.get("step_index") or data.get("request_id")
204
+ if not request_id or not isinstance(request_id, str):
205
+ request_id = self._generate_deterministic_id(
206
+ file_path=file_path,
207
+ timestamp=ts,
208
+ model=model,
209
+ input_tokens=input_tokens,
210
+ output_tokens=output_tokens,
211
+ cache_read_tokens=cache_read_tokens,
212
+ cache_write_tokens=cache_write_tokens,
213
+ )
214
+
215
+ return UsageRecord(
216
+ provider="antigravity",
217
+ request_id=str(request_id),
218
+ session_id=session_id,
219
+ timestamp=ts,
220
+ user_id=data.get("user_id"),
221
+ project_id=project_name,
222
+ project_path=cwd,
223
+ model=model,
224
+ input_tokens=input_tokens,
225
+ output_tokens=output_tokens,
226
+ cache_read_tokens=cache_read_tokens,
227
+ cache_write_tokens=cache_write_tokens,
228
+ metadata={"file_path": file_path, "type": data.get("type")},
229
+ )
230
+
231
+ def _parse_user_prompt(
232
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
233
+ ) -> Optional[SessionPrompt]:
234
+ line_type = data.get("type")
235
+ source = data.get("source")
236
+
237
+ if line_type not in ["USER_INPUT", "user", "prompt"] and source != "USER_EXPLICIT":
238
+ return None
239
+
240
+ content = data.get("content") or data.get("text") or data.get("prompt")
241
+ prompt_text = ""
242
+
243
+ if isinstance(content, str):
244
+ prompt_text = content.strip()
245
+ elif isinstance(content, list):
246
+ texts = []
247
+ for item in content:
248
+ if isinstance(item, str):
249
+ texts.append(item)
250
+ elif isinstance(item, dict) and item.get("text"):
251
+ texts.append(item.get("text", ""))
252
+ prompt_text = "\n".join(texts).strip()
253
+
254
+ if not prompt_text:
255
+ return None
256
+
257
+ raw_ts = data.get("timestamp") or data.get("created_at")
258
+ ts = self._parse_timestamp(raw_ts)
259
+
260
+ prompt_id = data.get("prompt_id") or data.get("id") or f"agy_prompt_{session_id}_{line_index}"
261
+
262
+ return SessionPrompt(
263
+ provider="antigravity",
264
+ session_id=session_id,
265
+ prompt_id=str(prompt_id),
266
+ timestamp=ts,
267
+ prompt_text=prompt_text,
268
+ project_id=project_id,
269
+ )
270
+
271
+ def _parse_code_changes(
272
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
273
+ ) -> List[SessionCodeChange]:
274
+ changes: List[SessionCodeChange] = []
275
+ raw_ts = data.get("timestamp") or data.get("created_at")
276
+ ts = self._parse_timestamp(raw_ts)
277
+
278
+ tool_calls = data.get("tool_calls")
279
+ if isinstance(tool_calls, list):
280
+ for idx, call in enumerate(tool_calls):
281
+ if isinstance(call, dict):
282
+ name = call.get("name") or call.get("function", {}).get("name", "")
283
+ args = call.get("args") or call.get("arguments") or call.get("function", {}).get("arguments", {})
284
+ if isinstance(args, str):
285
+ try:
286
+ args = json.loads(args)
287
+ except Exception:
288
+ args = {}
289
+
290
+ if isinstance(args, dict) and name in [
291
+ "replace_file_content",
292
+ "multi_replace_file_content",
293
+ "write_to_file",
294
+ "edit_file",
295
+ "write_file",
296
+ ]:
297
+ fpath = args.get("TargetFile") or args.get("file_path") or args.get("path") or "unknown_file"
298
+ target_content = args.get("TargetContent") or args.get("old_content") or ""
299
+ replacement_content = args.get("ReplacementContent") or args.get("CodeContent") or args.get("new_content") or ""
300
+
301
+ diff_lines = []
302
+ if target_content:
303
+ diff_lines.append(f"- {target_content[:150]}")
304
+ if replacement_content:
305
+ diff_lines.append(f"+ {replacement_content[:150]}")
306
+ diff_summary = "\n".join(diff_lines) if diff_lines else f"Antigravity action: {name}"
307
+
308
+ change_id = call.get("id") or f"agy_edit_{session_id}_{line_index}_{idx}"
309
+ changes.append(
310
+ SessionCodeChange(
311
+ provider="antigravity",
312
+ session_id=session_id,
313
+ change_id=str(change_id),
314
+ timestamp=ts,
315
+ file_path=str(fpath),
316
+ change_type=name.lower(),
317
+ diff_summary=diff_summary,
318
+ project_id=project_id,
319
+ )
320
+ )
321
+
322
+ return changes
323
+
324
+ def _infer_session_id(self, file_path: Path) -> str:
325
+ for parent in file_path.parents:
326
+ if len(parent.name) == 36 and "-" in parent.name:
327
+ return parent.name
328
+ return file_path.stem
329
+
330
+ def _infer_project_name(self, file_path: Path) -> Optional[str]:
331
+ for parent in file_path.parents:
332
+ if parent.name not in ["brain", "logs", ".system_generated", "antigravity-ide"]:
333
+ return parent.name
334
+ return None
335
+
336
+ def _parse_timestamp(self, raw_ts: Optional[str]) -> datetime:
337
+ if not raw_ts:
338
+ return datetime.now(timezone.utc)
339
+ if isinstance(raw_ts, (int, float)):
340
+ return datetime.fromtimestamp(raw_ts if raw_ts < 1e11 else raw_ts / 1000.0, tz=timezone.utc)
341
+ try:
342
+ ts_str = str(raw_ts).replace("Z", "+00:00")
343
+ return datetime.fromisoformat(ts_str)
344
+ except ValueError:
345
+ return datetime.now(timezone.utc)
346
+
347
+ def _generate_deterministic_id(
348
+ self,
349
+ file_path: str,
350
+ timestamp: datetime,
351
+ model: str,
352
+ input_tokens: int,
353
+ output_tokens: int,
354
+ cache_read_tokens: int,
355
+ cache_write_tokens: int,
356
+ ) -> str:
357
+ seed = f"antigravity:{file_path}:{timestamp.isoformat()}:{model}:{input_tokens}:{output_tokens}:{cache_read_tokens}:{cache_write_tokens}"
358
+ return "gen_agy_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]