codex-usage-tracking 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_usage_tracker/__init__.py +7 -0
- codex_usage_tracker/__main__.py +6 -0
- codex_usage_tracker/allowance.py +759 -0
- codex_usage_tracker/api_payloads.py +90 -0
- codex_usage_tracker/cli.py +1326 -0
- codex_usage_tracker/context.py +410 -0
- codex_usage_tracker/costing.py +176 -0
- codex_usage_tracker/dashboard.py +389 -0
- codex_usage_tracker/diagnostics.py +624 -0
- codex_usage_tracker/formatting.py +225 -0
- codex_usage_tracker/json_contracts.py +350 -0
- codex_usage_tracker/mcp_server.py +371 -0
- codex_usage_tracker/models.py +92 -0
- codex_usage_tracker/parser.py +491 -0
- codex_usage_tracker/paths.py +18 -0
- codex_usage_tracker/plugin_data/__init__.py +1 -0
- codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
- codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
- codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
- codex_usage_tracker/plugin_installer.py +312 -0
- codex_usage_tracker/pricing.py +57 -0
- codex_usage_tracker/pricing_config.py +223 -0
- codex_usage_tracker/pricing_estimates.py +44 -0
- codex_usage_tracker/pricing_openai.py +253 -0
- codex_usage_tracker/projects.py +347 -0
- codex_usage_tracker/recommendations.py +270 -0
- codex_usage_tracker/reports.py +637 -0
- codex_usage_tracker/schema.py +71 -0
- codex_usage_tracker/server.py +400 -0
- codex_usage_tracker/store.py +666 -0
- codex_usage_tracker/support.py +147 -0
- codex_usage_tracker/threads.py +183 -0
- codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
- codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
- codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
- codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
- codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
- codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Derived project and workflow attribution from aggregate cwd fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from codex_usage_tracker.paths import DEFAULT_PROJECTS_PATH
|
|
13
|
+
|
|
14
|
+
PROJECT_CONFIG_TEMPLATE: dict[str, object] = {
|
|
15
|
+
"aliases": {},
|
|
16
|
+
"ignored_paths": [],
|
|
17
|
+
"tags": {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
PRIVACY_MODE_CHOICES = ("normal", "redacted", "strict")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ProjectConfig:
|
|
25
|
+
path: Path
|
|
26
|
+
aliases: dict[str, str]
|
|
27
|
+
ignored_paths: list[str]
|
|
28
|
+
tags: dict[str, list[str]]
|
|
29
|
+
loaded: bool = False
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_project_config(path: Path = DEFAULT_PROJECTS_PATH) -> ProjectConfig:
|
|
34
|
+
"""Load local project attribution aliases, ignored paths, and tags."""
|
|
35
|
+
|
|
36
|
+
path = path.expanduser()
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return ProjectConfig(path=path, aliases={}, ignored_paths=[], tags={})
|
|
39
|
+
try:
|
|
40
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
return ProjectConfig(path=path, aliases={}, ignored_paths=[], tags={}, error=str(exc))
|
|
43
|
+
if not isinstance(payload, dict):
|
|
44
|
+
return ProjectConfig(
|
|
45
|
+
path=path,
|
|
46
|
+
aliases={},
|
|
47
|
+
ignored_paths=[],
|
|
48
|
+
tags={},
|
|
49
|
+
error="Project config must be a JSON object.",
|
|
50
|
+
)
|
|
51
|
+
aliases = {
|
|
52
|
+
str(key): str(value)
|
|
53
|
+
for key, value in (payload.get("aliases") or {}).items()
|
|
54
|
+
if isinstance(key, str) and isinstance(value, str)
|
|
55
|
+
} if isinstance(payload.get("aliases") or {}, dict) else {}
|
|
56
|
+
ignored_paths = [
|
|
57
|
+
str(value)
|
|
58
|
+
for value in payload.get("ignored_paths") or []
|
|
59
|
+
if isinstance(value, str)
|
|
60
|
+
] if isinstance(payload.get("ignored_paths") or [], list) else []
|
|
61
|
+
tags = {
|
|
62
|
+
str(key): [str(tag) for tag in value if isinstance(tag, str)]
|
|
63
|
+
for key, value in (payload.get("tags") or {}).items()
|
|
64
|
+
if isinstance(key, str) and isinstance(value, list)
|
|
65
|
+
} if isinstance(payload.get("tags") or {}, dict) else {}
|
|
66
|
+
return ProjectConfig(
|
|
67
|
+
path=path,
|
|
68
|
+
aliases=aliases,
|
|
69
|
+
ignored_paths=ignored_paths,
|
|
70
|
+
tags=tags,
|
|
71
|
+
loaded=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_project_template(path: Path = DEFAULT_PROJECTS_PATH, force: bool = False) -> Path:
|
|
76
|
+
"""Write a local project attribution template."""
|
|
77
|
+
|
|
78
|
+
path = path.expanduser()
|
|
79
|
+
if path.exists() and not force:
|
|
80
|
+
raise FileExistsError(f"{path} already exists. Use --force to replace it.")
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
path.write_text(
|
|
83
|
+
json.dumps(PROJECT_CONFIG_TEMPLATE, indent=2, sort_keys=True) + "\n",
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
)
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def annotate_rows_with_project_identity(
|
|
90
|
+
rows: list[dict[str, Any]],
|
|
91
|
+
config: ProjectConfig | None = None,
|
|
92
|
+
) -> list[dict[str, Any]]:
|
|
93
|
+
"""Attach derived project identity fields to copied aggregate rows."""
|
|
94
|
+
|
|
95
|
+
project_config = config or load_project_config()
|
|
96
|
+
cache: dict[str, dict[str, Any]] = {}
|
|
97
|
+
annotated: list[dict[str, Any]] = []
|
|
98
|
+
for row in rows:
|
|
99
|
+
copy = dict(row)
|
|
100
|
+
cwd = str(copy.get("cwd") or "")
|
|
101
|
+
if cwd not in cache:
|
|
102
|
+
cache[cwd] = project_identity_for_cwd(cwd, project_config)
|
|
103
|
+
copy.update(cache[cwd])
|
|
104
|
+
annotated.append(copy)
|
|
105
|
+
return annotated
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_project_privacy_to_rows(
|
|
109
|
+
rows: list[dict[str, Any]],
|
|
110
|
+
privacy_mode: str = "normal",
|
|
111
|
+
) -> list[dict[str, Any]]:
|
|
112
|
+
"""Return copied rows with sensitive project metadata redacted when requested."""
|
|
113
|
+
|
|
114
|
+
mode = validate_privacy_mode(privacy_mode)
|
|
115
|
+
if mode == "normal":
|
|
116
|
+
return [dict(row) for row in rows]
|
|
117
|
+
return [_apply_project_privacy_to_row(dict(row), mode) for row in rows]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def apply_project_privacy_to_summary_rows(
|
|
121
|
+
rows: list[dict[str, Any]],
|
|
122
|
+
*,
|
|
123
|
+
group_by: str,
|
|
124
|
+
privacy_mode: str = "normal",
|
|
125
|
+
) -> list[dict[str, Any]]:
|
|
126
|
+
"""Redact summary group labels that are derived from local paths."""
|
|
127
|
+
|
|
128
|
+
mode = validate_privacy_mode(privacy_mode)
|
|
129
|
+
if mode == "normal" or group_by != "cwd":
|
|
130
|
+
return [dict(row) for row in rows]
|
|
131
|
+
redacted: list[dict[str, Any]] = []
|
|
132
|
+
for row in rows:
|
|
133
|
+
copy = dict(row)
|
|
134
|
+
copy["group_key"] = _redacted_cwd_label(copy.get("group_key"))
|
|
135
|
+
copy["privacy_mode"] = mode
|
|
136
|
+
redacted.append(copy)
|
|
137
|
+
return redacted
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def project_privacy_metadata(privacy_mode: str = "normal") -> dict[str, Any]:
|
|
141
|
+
"""Return dashboard/report metadata for the active project privacy mode."""
|
|
142
|
+
|
|
143
|
+
mode = validate_privacy_mode(privacy_mode)
|
|
144
|
+
return {
|
|
145
|
+
"mode": mode,
|
|
146
|
+
"project_names_redacted": mode in {"redacted", "strict"},
|
|
147
|
+
"cwd_redacted": mode in {"redacted", "strict"},
|
|
148
|
+
"source_paths_redacted": mode in {"redacted", "strict"},
|
|
149
|
+
"relative_cwd_hidden": mode == "strict",
|
|
150
|
+
"git_branch_hidden": mode == "strict",
|
|
151
|
+
"git_remote_label_hidden": mode in {"redacted", "strict"},
|
|
152
|
+
"tags_hidden": mode == "strict",
|
|
153
|
+
"aliases_preserved": mode in {"redacted", "strict"},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_privacy_mode(privacy_mode: str = "normal") -> str:
|
|
158
|
+
"""Normalize and validate a project metadata privacy mode."""
|
|
159
|
+
|
|
160
|
+
mode = str(privacy_mode or "normal")
|
|
161
|
+
if mode not in PRIVACY_MODE_CHOICES:
|
|
162
|
+
allowed = ", ".join(PRIVACY_MODE_CHOICES)
|
|
163
|
+
raise ValueError(f"privacy_mode must be one of: {allowed}")
|
|
164
|
+
return mode
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def project_identity_for_cwd(cwd: str, config: ProjectConfig | None = None) -> dict[str, Any]:
|
|
168
|
+
"""Derive project identity from one cwd string."""
|
|
169
|
+
|
|
170
|
+
project_config = config or load_project_config()
|
|
171
|
+
path = Path(cwd).expanduser() if cwd else None
|
|
172
|
+
ignored = _is_ignored(path, project_config.ignored_paths)
|
|
173
|
+
git_root = _find_git_root(path) if path and not ignored else None
|
|
174
|
+
project_root = git_root or path
|
|
175
|
+
project_key = _project_key(project_root)
|
|
176
|
+
default_name = project_root.name if project_root else "Unknown project"
|
|
177
|
+
project_name, alias_configured = _alias_for(
|
|
178
|
+
project_root, project_key, default_name, project_config.aliases
|
|
179
|
+
)
|
|
180
|
+
tags = _tags_for(project_root, project_key, project_name, project_config.tags)
|
|
181
|
+
git = _git_metadata(git_root) if git_root else {}
|
|
182
|
+
return {
|
|
183
|
+
"project_name": project_name,
|
|
184
|
+
"project_key": project_key,
|
|
185
|
+
"project_root_hash": project_key,
|
|
186
|
+
"project_alias_configured": alias_configured,
|
|
187
|
+
"project_relative_cwd": _relative_cwd(path, project_root),
|
|
188
|
+
"project_ignored": ignored,
|
|
189
|
+
"project_tags": tags,
|
|
190
|
+
"git_branch": git.get("branch"),
|
|
191
|
+
"git_remote_hash": git.get("remote_hash"),
|
|
192
|
+
"git_remote_label": git.get("remote_label"),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_git_root(path: Path | None) -> Path | None:
|
|
197
|
+
if path is None:
|
|
198
|
+
return None
|
|
199
|
+
current = path if path.is_dir() else path.parent
|
|
200
|
+
for candidate in [current, *current.parents]:
|
|
201
|
+
if (candidate / ".git").exists():
|
|
202
|
+
return candidate
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _git_metadata(root: Path) -> dict[str, str | None]:
|
|
207
|
+
git_dir = root / ".git"
|
|
208
|
+
if git_dir.is_file():
|
|
209
|
+
return {}
|
|
210
|
+
branch = _git_branch(git_dir)
|
|
211
|
+
remote = _git_remote_origin(git_dir)
|
|
212
|
+
return {
|
|
213
|
+
"branch": branch,
|
|
214
|
+
"remote_hash": _stable_hash(remote) if remote else None,
|
|
215
|
+
"remote_label": _remote_label(remote) if remote else None,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _git_branch(git_dir: Path) -> str | None:
|
|
220
|
+
head = git_dir / "HEAD"
|
|
221
|
+
try:
|
|
222
|
+
text = head.read_text(encoding="utf-8").strip()
|
|
223
|
+
except OSError:
|
|
224
|
+
return None
|
|
225
|
+
if text.startswith("ref: refs/heads/"):
|
|
226
|
+
return text.removeprefix("ref: refs/heads/")
|
|
227
|
+
if text:
|
|
228
|
+
return "detached"
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _git_remote_origin(git_dir: Path) -> str | None:
|
|
233
|
+
config_path = git_dir / "config"
|
|
234
|
+
parser = configparser.ConfigParser()
|
|
235
|
+
try:
|
|
236
|
+
parser.read(config_path)
|
|
237
|
+
except configparser.Error:
|
|
238
|
+
return None
|
|
239
|
+
section = 'remote "origin"'
|
|
240
|
+
if not parser.has_section(section):
|
|
241
|
+
return None
|
|
242
|
+
url = parser.get(section, "url", fallback=None)
|
|
243
|
+
return url.strip() if isinstance(url, str) and url.strip() else None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _remote_label(remote: str) -> str:
|
|
247
|
+
cleaned = remote.rstrip("/").removesuffix(".git")
|
|
248
|
+
name = cleaned.rsplit("/", 1)[-1]
|
|
249
|
+
return name or "origin"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_ignored(path: Path | None, ignored_paths: list[str]) -> bool:
|
|
253
|
+
if path is None:
|
|
254
|
+
return False
|
|
255
|
+
resolved = _safe_resolve(path)
|
|
256
|
+
for ignored in ignored_paths:
|
|
257
|
+
ignored_path = _safe_resolve(Path(ignored).expanduser())
|
|
258
|
+
if resolved == ignored_path or ignored_path in resolved.parents:
|
|
259
|
+
return True
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _alias_for(
|
|
264
|
+
root: Path | None,
|
|
265
|
+
key: str,
|
|
266
|
+
default: str,
|
|
267
|
+
aliases: dict[str, str],
|
|
268
|
+
) -> tuple[str, bool]:
|
|
269
|
+
candidates = [key, default]
|
|
270
|
+
if root:
|
|
271
|
+
resolved = str(_safe_resolve(root))
|
|
272
|
+
candidates.extend([resolved, str(root)])
|
|
273
|
+
for candidate in candidates:
|
|
274
|
+
if candidate in aliases:
|
|
275
|
+
return aliases[candidate], True
|
|
276
|
+
return default, False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tags_for(
|
|
280
|
+
root: Path | None,
|
|
281
|
+
key: str,
|
|
282
|
+
name: str,
|
|
283
|
+
tags: dict[str, list[str]],
|
|
284
|
+
) -> list[str]:
|
|
285
|
+
values: list[str] = []
|
|
286
|
+
candidates = [key, name]
|
|
287
|
+
if root:
|
|
288
|
+
resolved = str(_safe_resolve(root))
|
|
289
|
+
candidates.extend([resolved, str(root)])
|
|
290
|
+
for candidate in candidates:
|
|
291
|
+
values.extend(tags.get(candidate, []))
|
|
292
|
+
return sorted(set(values))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _relative_cwd(path: Path | None, root: Path | None) -> str | None:
|
|
296
|
+
if path is None or root is None:
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
relative = _safe_resolve(path).relative_to(_safe_resolve(root))
|
|
300
|
+
except ValueError:
|
|
301
|
+
return None
|
|
302
|
+
return "." if not relative.parts else relative.as_posix()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _project_key(path: Path | None) -> str:
|
|
306
|
+
return _stable_hash(str(_safe_resolve(path))) if path else "unknown"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _stable_hash(value: str) -> str:
|
|
310
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _apply_project_privacy_to_row(row: dict[str, Any], mode: str) -> dict[str, Any]:
|
|
314
|
+
project_key = str(row.get("project_key") or _stable_hash(str(row.get("cwd") or "unknown")))
|
|
315
|
+
short_key = project_key[:8] if project_key and project_key != "unknown" else "unknown"
|
|
316
|
+
alias_configured = bool(row.get("project_alias_configured"))
|
|
317
|
+
if not alias_configured:
|
|
318
|
+
row["project_name"] = f"Project {short_key}"
|
|
319
|
+
row["cwd"] = _redacted_cwd_label(project_key)
|
|
320
|
+
row["source_file"] = _redacted_source_label(row.get("source_file"))
|
|
321
|
+
row["git_remote_label"] = None
|
|
322
|
+
row["project_metadata_redacted"] = True
|
|
323
|
+
row["privacy_mode"] = mode
|
|
324
|
+
if mode == "strict":
|
|
325
|
+
row["project_relative_cwd"] = None
|
|
326
|
+
row["git_branch"] = None
|
|
327
|
+
row["project_tags"] = []
|
|
328
|
+
return row
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _redacted_cwd_label(value: object) -> str:
|
|
332
|
+
key = str(value or "unknown")
|
|
333
|
+
digest = key[:8] if len(key) >= 8 and all(ch in "0123456789abcdef" for ch in key[:8].lower()) else _stable_hash(key)[:8]
|
|
334
|
+
return f"[redacted cwd:{digest}]"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _redacted_source_label(value: object) -> str | None:
|
|
338
|
+
if not value:
|
|
339
|
+
return None
|
|
340
|
+
return f"[redacted source:{_stable_hash(str(value))[:8]}]"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _safe_resolve(path: Path) -> Path:
|
|
344
|
+
try:
|
|
345
|
+
return path.resolve()
|
|
346
|
+
except OSError:
|
|
347
|
+
return path.absolute()
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Aggregate-only recommendations and review thresholds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from codex_usage_tracker.paths import DEFAULT_THRESHOLDS_PATH
|
|
11
|
+
|
|
12
|
+
DEFAULT_THRESHOLDS: dict[str, float] = {
|
|
13
|
+
"low_cache_ratio": 0.30,
|
|
14
|
+
"high_context_percent": 0.60,
|
|
15
|
+
"elevated_context_percent": 0.50,
|
|
16
|
+
"high_uncached_input_tokens": 10_000,
|
|
17
|
+
"large_cumulative_tokens": 200_000,
|
|
18
|
+
"high_reasoning_ratio": 0.75,
|
|
19
|
+
"reasoning_min_output_tokens": 100,
|
|
20
|
+
"expensive_low_output_total_tokens": 20_000,
|
|
21
|
+
"low_output_tokens": 100,
|
|
22
|
+
"high_cost_usd": 1.00,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
SEVERITY_POINTS = {
|
|
26
|
+
"high": 80,
|
|
27
|
+
"medium": 45,
|
|
28
|
+
"review": 20,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ThresholdConfig:
|
|
34
|
+
path: Path
|
|
35
|
+
thresholds: dict[str, float]
|
|
36
|
+
loaded: bool = False
|
|
37
|
+
error: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_threshold_config(path: Path = DEFAULT_THRESHOLDS_PATH) -> ThresholdConfig:
|
|
41
|
+
"""Load user-overridden recommendation thresholds."""
|
|
42
|
+
|
|
43
|
+
path = path.expanduser()
|
|
44
|
+
thresholds = dict(DEFAULT_THRESHOLDS)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return ThresholdConfig(path=path, thresholds=thresholds)
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
50
|
+
return ThresholdConfig(path=path, thresholds=thresholds, loaded=False, error=str(exc))
|
|
51
|
+
if not isinstance(payload, dict):
|
|
52
|
+
return ThresholdConfig(
|
|
53
|
+
path=path,
|
|
54
|
+
thresholds=thresholds,
|
|
55
|
+
loaded=False,
|
|
56
|
+
error="Threshold config must be a JSON object.",
|
|
57
|
+
)
|
|
58
|
+
for key, value in payload.items():
|
|
59
|
+
if key in thresholds and isinstance(value, int | float) and not isinstance(value, bool):
|
|
60
|
+
thresholds[key] = float(value)
|
|
61
|
+
return ThresholdConfig(path=path, thresholds=thresholds, loaded=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_threshold_template(path: Path = DEFAULT_THRESHOLDS_PATH, force: bool = False) -> Path:
|
|
65
|
+
"""Write a local template for recommendation thresholds."""
|
|
66
|
+
|
|
67
|
+
path = path.expanduser()
|
|
68
|
+
if path.exists() and not force:
|
|
69
|
+
raise FileExistsError(f"{path} already exists. Use --force to replace it.")
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(json.dumps(DEFAULT_THRESHOLDS, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def annotate_rows_with_recommendations(
|
|
76
|
+
rows: list[dict[str, Any]],
|
|
77
|
+
thresholds: ThresholdConfig | None = None,
|
|
78
|
+
) -> list[dict[str, Any]]:
|
|
79
|
+
"""Attach aggregate-only action recommendations to dashboard rows."""
|
|
80
|
+
|
|
81
|
+
config = thresholds or load_threshold_config()
|
|
82
|
+
annotated: list[dict[str, Any]] = []
|
|
83
|
+
for row in rows:
|
|
84
|
+
copy = dict(row)
|
|
85
|
+
recommendations = action_recommendations(copy, config.thresholds)
|
|
86
|
+
copy["action_recommendations"] = recommendations
|
|
87
|
+
copy["primary_recommendation"] = recommendations[0] if recommendations else None
|
|
88
|
+
copy["secondary_recommendations"] = recommendations[1:]
|
|
89
|
+
copy["primary_signal"] = recommendations[0]["key"] if recommendations else None
|
|
90
|
+
copy["secondary_signals"] = [
|
|
91
|
+
recommendation["key"] for recommendation in recommendations[1:]
|
|
92
|
+
]
|
|
93
|
+
copy["recommendation_score"] = recommendation_severity_score(copy, recommendations)
|
|
94
|
+
copy["recommended_action"] = (
|
|
95
|
+
recommendations[0]["action"]
|
|
96
|
+
if recommendations
|
|
97
|
+
else "No aggregate action is flagged; continue monitoring usage patterns."
|
|
98
|
+
)
|
|
99
|
+
copy["flag_explanations"] = [recommendation["why"] for recommendation in recommendations]
|
|
100
|
+
annotated.append(copy)
|
|
101
|
+
return annotated
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def action_recommendations(
|
|
105
|
+
row: dict[str, Any],
|
|
106
|
+
thresholds: dict[str, float] | None = None,
|
|
107
|
+
) -> list[dict[str, Any]]:
|
|
108
|
+
"""Return ranked recommendations for one aggregate usage row."""
|
|
109
|
+
|
|
110
|
+
limits = thresholds or DEFAULT_THRESHOLDS
|
|
111
|
+
recommendations: list[dict[str, str]] = []
|
|
112
|
+
total_tokens = _number(row.get("total_tokens"))
|
|
113
|
+
output_tokens = _number(row.get("output_tokens"))
|
|
114
|
+
uncached_input = _number(row.get("uncached_input_tokens"))
|
|
115
|
+
input_tokens = _number(row.get("input_tokens"))
|
|
116
|
+
cache_ratio = _number(row.get("cache_ratio"))
|
|
117
|
+
context = _number(row.get("context_window_percent"))
|
|
118
|
+
reasoning = _number(row.get("reasoning_output_ratio"))
|
|
119
|
+
cumulative = _number(row.get("cumulative_total_tokens"))
|
|
120
|
+
cost = row.get("estimated_cost_usd")
|
|
121
|
+
|
|
122
|
+
if not row.get("pricing_model"):
|
|
123
|
+
recommendations.append(
|
|
124
|
+
_recommendation(
|
|
125
|
+
"pricing-gap",
|
|
126
|
+
"review",
|
|
127
|
+
"Pricing gap",
|
|
128
|
+
"This model call has no configured price, so cost totals understate visible usage.",
|
|
129
|
+
"Update pricing or add a local alias before trusting cost totals.",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
elif row.get("pricing_estimated"):
|
|
133
|
+
recommendations.append(
|
|
134
|
+
_recommendation(
|
|
135
|
+
"estimated-pricing",
|
|
136
|
+
"review",
|
|
137
|
+
"Estimated pricing",
|
|
138
|
+
"This cost uses an inferred model mapping rather than a direct pricing row.",
|
|
139
|
+
"Review pricing coverage and pin or override the model rate if this call matters.",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if isinstance(cost, int | float) and cost >= limits["high_cost_usd"]:
|
|
143
|
+
recommendations.append(
|
|
144
|
+
_recommendation(
|
|
145
|
+
"high-cost",
|
|
146
|
+
"high",
|
|
147
|
+
"High estimated cost",
|
|
148
|
+
"This call crossed the configured high-cost threshold.",
|
|
149
|
+
"Open the thread timeline and inspect the preceding turn before continuing.",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
if context >= limits["high_context_percent"]:
|
|
153
|
+
recommendations.append(
|
|
154
|
+
_recommendation(
|
|
155
|
+
"context-bloat",
|
|
156
|
+
"high",
|
|
157
|
+
"High context pressure",
|
|
158
|
+
"This call is using a large share of the model context window.",
|
|
159
|
+
"Consider starting a fresh Codex thread if older context is no longer relevant.",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
elif context >= limits["elevated_context_percent"]:
|
|
163
|
+
recommendations.append(
|
|
164
|
+
_recommendation(
|
|
165
|
+
"elevated-context",
|
|
166
|
+
"medium",
|
|
167
|
+
"Elevated context pressure",
|
|
168
|
+
"Context use is elevated and may become costly in later turns.",
|
|
169
|
+
"Check whether the thread can be narrowed before adding more work.",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if input_tokens > 0 and cache_ratio < limits["low_cache_ratio"] and uncached_input >= limits["high_uncached_input_tokens"]:
|
|
173
|
+
recommendations.append(
|
|
174
|
+
_recommendation(
|
|
175
|
+
"low-cache",
|
|
176
|
+
"medium",
|
|
177
|
+
"Low cache reuse",
|
|
178
|
+
"Fresh uncached input is high while cache reuse is low.",
|
|
179
|
+
"Check whether files, tool output, or broad context were reintroduced unnecessarily.",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if reasoning >= limits["high_reasoning_ratio"] and output_tokens >= limits["reasoning_min_output_tokens"]:
|
|
183
|
+
recommendations.append(
|
|
184
|
+
_recommendation(
|
|
185
|
+
"reasoning-spike",
|
|
186
|
+
"medium",
|
|
187
|
+
"High reasoning share",
|
|
188
|
+
"Reasoning output dominates visible output for this call.",
|
|
189
|
+
"Review whether this task needs the selected reasoning effort.",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
if total_tokens >= limits["expensive_low_output_total_tokens"] and output_tokens <= limits["low_output_tokens"]:
|
|
193
|
+
recommendations.append(
|
|
194
|
+
_recommendation(
|
|
195
|
+
"low-output",
|
|
196
|
+
"medium",
|
|
197
|
+
"Large low-output call",
|
|
198
|
+
"The call consumed many tokens but produced little output.",
|
|
199
|
+
"Inspect aggregate context first; load raw context only if the cause is unclear.",
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
if cumulative >= limits["large_cumulative_tokens"]:
|
|
203
|
+
recommendations.append(
|
|
204
|
+
_recommendation(
|
|
205
|
+
"large-thread",
|
|
206
|
+
"medium",
|
|
207
|
+
"Large cumulative thread",
|
|
208
|
+
"The session cumulative total is high enough to make later turns expensive.",
|
|
209
|
+
"Prefer a new thread for unrelated follow-up work.",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
if row.get("thread_source") == "subagent" or row.get("parent_session_id"):
|
|
213
|
+
recommendations.append(
|
|
214
|
+
_recommendation(
|
|
215
|
+
"subagent-attribution",
|
|
216
|
+
"review",
|
|
217
|
+
"Subagent attribution",
|
|
218
|
+
"This call is attached to delegated work and may explain parent-thread growth.",
|
|
219
|
+
"Compare direct calls with attached subagent or review calls before changing workflow.",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return recommendations
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def recommendation_severity_score(
|
|
226
|
+
row: dict[str, Any],
|
|
227
|
+
recommendations: list[dict[str, Any]] | None = None,
|
|
228
|
+
) -> float:
|
|
229
|
+
"""Return a stable aggregate severity score for recommendation ranking."""
|
|
230
|
+
|
|
231
|
+
recs = recommendations if recommendations is not None else action_recommendations(row)
|
|
232
|
+
if not recs:
|
|
233
|
+
return 0.0
|
|
234
|
+
base = sum(SEVERITY_POINTS.get(str(rec.get("severity")), 0) for rec in recs)
|
|
235
|
+
cost = min(_number(row.get("estimated_cost_usd")) * 25, 60)
|
|
236
|
+
credits = min(_number(row.get("usage_credits")) * 2.5, 80)
|
|
237
|
+
context = min(_number(row.get("context_window_percent")) * 60, 60)
|
|
238
|
+
uncached = min(_number(row.get("uncached_input_tokens")) / 500, 50)
|
|
239
|
+
cumulative = min(_number(row.get("cumulative_total_tokens")) / 10_000, 50)
|
|
240
|
+
return round(base + cost + credits + context + uncached + cumulative, 2)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _recommendation(
|
|
244
|
+
key: str,
|
|
245
|
+
severity: str,
|
|
246
|
+
title: str,
|
|
247
|
+
why: str,
|
|
248
|
+
action: str,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
"key": key,
|
|
252
|
+
"severity": severity,
|
|
253
|
+
"score": SEVERITY_POINTS.get(severity, 0),
|
|
254
|
+
"title": title,
|
|
255
|
+
"why": why,
|
|
256
|
+
"action": action,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _number(value: object) -> float:
|
|
261
|
+
if isinstance(value, bool):
|
|
262
|
+
return float(int(value))
|
|
263
|
+
if isinstance(value, int | float):
|
|
264
|
+
return float(value)
|
|
265
|
+
if isinstance(value, str) and value.strip():
|
|
266
|
+
try:
|
|
267
|
+
return float(value)
|
|
268
|
+
except ValueError:
|
|
269
|
+
return 0.0
|
|
270
|
+
return 0.0
|