codex-meter 0.3.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.
codex_meter/models.py ADDED
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from dataclasses import dataclass, field
5
+ from decimal import Decimal
6
+ from pathlib import Path
7
+
8
+
9
+ def _safe_int(value: object) -> int:
10
+ try:
11
+ return int(value or 0)
12
+ except (TypeError, ValueError):
13
+ return 0
14
+
15
+
16
+ def decimal_value(value: object) -> Decimal:
17
+ if isinstance(value, Decimal):
18
+ return value
19
+ return Decimal(str(value))
20
+
21
+
22
+ def decimal_string(value: object) -> str:
23
+ amount = decimal_value(value)
24
+ if amount == 0:
25
+ return "0"
26
+ return format(amount.normalize(), "f")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Usage:
31
+ input_tokens: int = 0
32
+ cached_input_tokens: int = 0
33
+ output_tokens: int = 0
34
+ reasoning_output_tokens: int = 0
35
+ total_tokens: int = 0
36
+
37
+ @property
38
+ def uncached_input_tokens(self) -> int:
39
+ return max(0, self.input_tokens - self.cached_input_tokens)
40
+
41
+ def is_zero(self) -> bool:
42
+ return not (
43
+ self.input_tokens
44
+ or self.cached_input_tokens
45
+ or self.output_tokens
46
+ or self.reasoning_output_tokens
47
+ or self.total_tokens
48
+ )
49
+
50
+ @classmethod
51
+ def from_dict(cls, raw: object) -> Usage:
52
+ if not isinstance(raw, dict):
53
+ return cls()
54
+ return cls(
55
+ input_tokens=_safe_int(raw.get("input_tokens")),
56
+ cached_input_tokens=_safe_int(raw.get("cached_input_tokens")),
57
+ output_tokens=_safe_int(raw.get("output_tokens")),
58
+ reasoning_output_tokens=_safe_int(raw.get("reasoning_output_tokens")),
59
+ total_tokens=_safe_int(raw.get("total_tokens")),
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Rates:
65
+ input: Decimal | float
66
+ cached_input: Decimal | float
67
+ output: Decimal | float
68
+ reasoning_output: Decimal | float | None = None
69
+
70
+ def __post_init__(self) -> None:
71
+ object.__setattr__(self, "input", decimal_value(self.input))
72
+ object.__setattr__(self, "cached_input", decimal_value(self.cached_input))
73
+ object.__setattr__(self, "output", decimal_value(self.output))
74
+ if self.reasoning_output is not None:
75
+ object.__setattr__(self, "reasoning_output", decimal_value(self.reasoning_output))
76
+
77
+ @property
78
+ def effective_reasoning_output(self) -> Decimal:
79
+ return self.reasoning_output if self.reasoning_output is not None else self.output
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LongContextRule:
84
+ threshold: int
85
+ input_mult: float
86
+ output_mult: float
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class PricingSource:
91
+ name: str
92
+ url: str
93
+ checked: str
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class RuntimeOptions:
98
+ session_root: Path
99
+ state_db: Path
100
+ config_path: Path
101
+ start: dt.datetime
102
+ end: dt.datetime
103
+ timezone: str
104
+ pricing_mode: str
105
+ service_tier: str
106
+ unknown_service_tier: str
107
+ tier_overrides: Path | None
108
+ rates_file: Path | None
109
+ dedupe: bool
110
+ parse_cache: bool
111
+ default_model: str
112
+ show_prompts: bool
113
+ offline: bool
114
+ compact: bool
115
+ width: int | None
116
+ top_threads: int
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ThreadMeta:
121
+ rollout_path: str = ""
122
+ title: str = ""
123
+ first_user_message: str = ""
124
+ cwd: str = ""
125
+ git_branch: str = ""
126
+ git_origin_url: str = ""
127
+ model: str = ""
128
+ reasoning_effort: str = ""
129
+ created_at: int = 0
130
+ updated_at: int = 0
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class TierOverride:
135
+ service_tier: str
136
+ session: str | None = None
137
+ start: dt.datetime | None = None
138
+ end: dt.datetime | None = None
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class UsageEvent:
143
+ timestamp: dt.datetime
144
+ path: Path
145
+ session_id: str
146
+ usage: Usage
147
+ model: str
148
+ service_tier: str
149
+ tier_source: str
150
+ thread: ThreadMeta
151
+ usage_source: str = "last_token_usage"
152
+ model_context_window: int = 0
153
+ plan_type: str = ""
154
+ credits: object = None
155
+ primary_used_percent: object = None
156
+ primary_window_minutes: object = None
157
+ primary_resets_at: object = None
158
+ secondary_used_percent: object = None
159
+ secondary_window_minutes: object = None
160
+ secondary_resets_at: object = None
161
+ rate_limit_reached_type: str = ""
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class RateLimitSample:
166
+ timestamp: dt.datetime
167
+ path: Path
168
+ session_id: str
169
+ plan_type: str = ""
170
+ credits: object = None
171
+ primary_used_percent: object = None
172
+ primary_window_minutes: object = None
173
+ primary_resets_at: object = None
174
+ secondary_used_percent: object = None
175
+ secondary_window_minutes: object = None
176
+ secondary_resets_at: object = None
177
+ rate_limit_reached_type: str = ""
178
+
179
+
180
+ @dataclass
181
+ class CostTotals:
182
+ api_dollars: Decimal | float = Decimal("0")
183
+ standard_credits: Decimal | float = Decimal("0")
184
+ adjusted_credits: Decimal | float = Decimal("0")
185
+ api_unpriced_events: int = 0
186
+ credit_unpriced_events: int = 0
187
+ estimated_events: int = 0
188
+ ambiguous_reasoning_events: int = 0
189
+ local_override_events: int = 0
190
+
191
+ def __post_init__(self) -> None:
192
+ self.api_dollars = decimal_value(self.api_dollars)
193
+ self.standard_credits = decimal_value(self.standard_credits)
194
+ self.adjusted_credits = decimal_value(self.adjusted_credits)
195
+
196
+ @property
197
+ def unpriced_events(self) -> int:
198
+ return max(self.api_unpriced_events, self.credit_unpriced_events)
199
+
200
+ def add(self, other: CostTotals) -> None:
201
+ self.api_dollars += other.api_dollars
202
+ self.standard_credits += other.standard_credits
203
+ self.adjusted_credits += other.adjusted_credits
204
+ self.api_unpriced_events += other.api_unpriced_events
205
+ self.credit_unpriced_events += other.credit_unpriced_events
206
+ self.estimated_events += other.estimated_events
207
+ self.ambiguous_reasoning_events += other.ambiguous_reasoning_events
208
+ self.local_override_events += other.local_override_events
209
+
210
+
211
+ @dataclass
212
+ class TokenTotals:
213
+ events: int = 0
214
+ input_tokens: int = 0
215
+ cached_input_tokens: int = 0
216
+ output_tokens: int = 0
217
+ reasoning_output_tokens: int = 0
218
+ total_tokens: int = 0
219
+
220
+ @property
221
+ def uncached_input_tokens(self) -> int:
222
+ return max(0, self.input_tokens - self.cached_input_tokens)
223
+
224
+ def add_usage(self, usage: Usage) -> None:
225
+ self.events += 1
226
+ self.input_tokens += usage.input_tokens
227
+ self.cached_input_tokens += usage.cached_input_tokens
228
+ self.output_tokens += usage.output_tokens
229
+ self.reasoning_output_tokens += usage.reasoning_output_tokens
230
+ self.total_tokens += usage.total_tokens
231
+
232
+
233
+ @dataclass
234
+ class Aggregate:
235
+ key: str
236
+ label: str
237
+ totals: TokenTotals = field(default_factory=TokenTotals)
238
+ costs: CostTotals = field(default_factory=CostTotals)
239
+ cache_savings: CostTotals = field(default_factory=CostTotals)
240
+ models: set[str] = field(default_factory=set)
241
+ service_tiers: set[str] = field(default_factory=set)
242
+ plan_types: set[str] = field(default_factory=set)
243
+ usage_sources: set[str] = field(default_factory=set)
244
+ model_context_window: int = 0
245
+ long_context_events: int = 0
246
+ unknown_model_events: int = 0
247
+ unknown_tier_events: int = 0
248
+
249
+ def add_event(
250
+ self,
251
+ event: UsageEvent,
252
+ costs: CostTotals,
253
+ cache_savings: CostTotals,
254
+ long_context: bool,
255
+ unknown_model: bool,
256
+ unknown_tier: bool,
257
+ ) -> None:
258
+ self.totals.add_usage(event.usage)
259
+ self.costs.add(costs)
260
+ self.cache_savings.add(cache_savings)
261
+ if event.model:
262
+ self.models.add(event.model)
263
+ if event.service_tier:
264
+ self.service_tiers.add(event.service_tier)
265
+ if event.plan_type:
266
+ self.plan_types.add(event.plan_type)
267
+ if event.usage_source:
268
+ self.usage_sources.add(event.usage_source)
269
+ self.model_context_window = max(self.model_context_window, event.model_context_window)
270
+ self.long_context_events += int(long_context)
271
+ self.unknown_model_events += int(unknown_model)
272
+ self.unknown_tier_events += int(unknown_tier)
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class LoadResult:
277
+ events: list[UsageEvent]
278
+ duplicates: int
279
+ tier_sources: dict[str, int]
280
+ plan_types: set[str]
281
+ credit_samples: list[RateLimitSample]
282
+ warnings: list[str]
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ from contextlib import closing
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ from codex_meter.models import RateLimitSample, ThreadMeta, Usage, UsageEvent
12
+
13
+
14
+ def default_cache_path() -> Path:
15
+ override = os.environ.get("CODEX_METER_CACHE_DIR")
16
+ if override:
17
+ return Path(override).expanduser() / "parse_cache.sqlite"
18
+ xdg = os.environ.get("XDG_CACHE_HOME")
19
+ if xdg:
20
+ return Path(xdg).expanduser() / "codex-meter" / "parse_cache.sqlite"
21
+ return Path.home() / ".cache" / "codex-meter" / "parse_cache.sqlite"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CacheStats:
26
+ path: Path
27
+ hits: int = 0
28
+ misses: int = 0
29
+
30
+
31
+ class ParseCache:
32
+ def __init__(self, path: Path | None = None) -> None:
33
+ self.path = path or default_cache_path()
34
+ self.hits = 0
35
+ self.misses = 0
36
+ self.path.parent.mkdir(parents=True, exist_ok=True)
37
+ with closing(sqlite3.connect(self.path)) as conn, conn:
38
+ conn.execute(
39
+ """
40
+ create table if not exists parsed_sessions (
41
+ path text not null,
42
+ signature text not null,
43
+ mtime_ns integer not null,
44
+ size integer not null,
45
+ byte_offset integer not null,
46
+ payload blob not null,
47
+ primary key (path, signature)
48
+ )
49
+ """
50
+ )
51
+
52
+ @classmethod
53
+ def default(cls) -> ParseCache:
54
+ return cls(default_cache_path())
55
+
56
+ def get(self, path: Path, signature: str):
57
+ stat = path.stat()
58
+ with closing(sqlite3.connect(self.path)) as conn:
59
+ row = conn.execute(
60
+ """
61
+ select payload from parsed_sessions
62
+ where path = ? and signature = ? and mtime_ns = ? and size = ? and byte_offset = ?
63
+ """,
64
+ (str(path), signature, stat.st_mtime_ns, stat.st_size, stat.st_size),
65
+ ).fetchone()
66
+ if row is None:
67
+ self.misses += 1
68
+ return None
69
+ parsed = _decode_payload(row[0])
70
+ if parsed is None:
71
+ self.misses += 1
72
+ return None
73
+ self.hits += 1
74
+ return parsed
75
+
76
+ def put(self, path: Path, signature: str, parsed) -> None:
77
+ stat = path.stat()
78
+ payload = _encode_payload(parsed)
79
+ with closing(sqlite3.connect(self.path)) as conn, conn:
80
+ conn.execute(
81
+ """
82
+ insert or replace into parsed_sessions
83
+ (path, signature, mtime_ns, size, byte_offset, payload)
84
+ values (?, ?, ?, ?, ?, ?)
85
+ """,
86
+ (str(path), signature, stat.st_mtime_ns, stat.st_size, stat.st_size, payload),
87
+ )
88
+
89
+ def stats(self) -> CacheStats:
90
+ return CacheStats(path=self.path, hits=self.hits, misses=self.misses)
91
+
92
+
93
+ def _encode_datetime(value: dt.datetime) -> str:
94
+ return value.astimezone(dt.UTC).isoformat()
95
+
96
+
97
+ def _decode_datetime(value: str) -> dt.datetime:
98
+ return dt.datetime.fromisoformat(value).astimezone(dt.UTC)
99
+
100
+
101
+ def _usage_to_dict(usage: Usage) -> dict:
102
+ return asdict(usage)
103
+
104
+
105
+ def _usage_from_dict(raw: dict) -> Usage:
106
+ return Usage.from_dict(raw)
107
+
108
+
109
+ def _thread_to_dict(thread: ThreadMeta) -> dict:
110
+ return asdict(thread)
111
+
112
+
113
+ def _thread_from_dict(raw: dict) -> ThreadMeta:
114
+ return ThreadMeta(**raw)
115
+
116
+
117
+ def _event_to_dict(event: UsageEvent) -> dict:
118
+ item = asdict(event)
119
+ item["timestamp"] = _encode_datetime(event.timestamp)
120
+ item["path"] = str(event.path)
121
+ item["usage"] = _usage_to_dict(event.usage)
122
+ item["thread"] = _thread_to_dict(event.thread)
123
+ return item
124
+
125
+
126
+ def _event_from_dict(raw: dict) -> UsageEvent:
127
+ item = dict(raw)
128
+ item["timestamp"] = _decode_datetime(str(item["timestamp"]))
129
+ item["path"] = Path(str(item["path"]))
130
+ item["usage"] = _usage_from_dict(item["usage"])
131
+ item["thread"] = _thread_from_dict(item["thread"])
132
+ return UsageEvent(**item)
133
+
134
+
135
+ def _sample_to_dict(sample: RateLimitSample) -> dict:
136
+ item = asdict(sample)
137
+ item["timestamp"] = _encode_datetime(sample.timestamp)
138
+ item["path"] = str(sample.path)
139
+ return item
140
+
141
+
142
+ def _sample_from_dict(raw: dict) -> RateLimitSample:
143
+ item = dict(raw)
144
+ item["timestamp"] = _decode_datetime(str(item["timestamp"]))
145
+ item["path"] = Path(str(item["path"]))
146
+ return RateLimitSample(**item)
147
+
148
+
149
+ def _encode_payload(parsed) -> bytes:
150
+ records = []
151
+ for event, reset, sample in parsed:
152
+ records.append(
153
+ {
154
+ "event": _event_to_dict(event) if event is not None else None,
155
+ "reset": bool(reset),
156
+ "sample": _sample_to_dict(sample) if sample is not None else None,
157
+ }
158
+ )
159
+ return json.dumps({"version": 1, "records": records}, separators=(",", ":")).encode("utf-8")
160
+
161
+
162
+ def _decode_payload(raw) -> list[tuple[UsageEvent | None, bool, RateLimitSample | None]] | None:
163
+ try:
164
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
165
+ payload = json.loads(text)
166
+ except (AttributeError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
167
+ return None
168
+ if not isinstance(payload, dict) or payload.get("version") != 1:
169
+ return None
170
+ records = payload.get("records")
171
+ if not isinstance(records, list):
172
+ return None
173
+ parsed: list[tuple[UsageEvent | None, bool, RateLimitSample | None]] = []
174
+ try:
175
+ for record in records:
176
+ if not isinstance(record, dict):
177
+ return None
178
+ event_raw = record.get("event")
179
+ sample_raw = record.get("sample")
180
+ parsed.append(
181
+ (
182
+ _event_from_dict(event_raw) if isinstance(event_raw, dict) else None,
183
+ bool(record.get("reset")),
184
+ _sample_from_dict(sample_raw) if isinstance(sample_raw, dict) else None,
185
+ )
186
+ )
187
+ except (KeyError, TypeError, ValueError):
188
+ return None
189
+ return parsed