prompt-analytics-for-claude-code 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prompt_analytics_for_claude_code-0.3.0/.env.example +6 -0
- prompt_analytics_for_claude_code-0.3.0/.gitattributes +13 -0
- prompt_analytics_for_claude_code-0.3.0/.github/workflows/ci.yml +100 -0
- prompt_analytics_for_claude_code-0.3.0/.github/workflows/pricing-drift.yml +157 -0
- prompt_analytics_for_claude_code-0.3.0/.github/workflows/release.yml +32 -0
- prompt_analytics_for_claude_code-0.3.0/.gitignore +25 -0
- prompt_analytics_for_claude_code-0.3.0/.streamlit/config.toml +18 -0
- prompt_analytics_for_claude_code-0.3.0/CHANGELOG.md +50 -0
- prompt_analytics_for_claude_code-0.3.0/CONTRIBUTING.md +111 -0
- prompt_analytics_for_claude_code-0.3.0/LICENSE +21 -0
- prompt_analytics_for_claude_code-0.3.0/PKG-INFO +348 -0
- prompt_analytics_for_claude_code-0.3.0/README.md +301 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/categories.csv +799 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/config.yml +4 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/prompts.csv +799 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/quota_log.csv +181 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/requests.csv +2766 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/sessions.csv +81 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/token_types.csv +7 -0
- prompt_analytics_for_claude_code-0.3.0/demo_data/tokens.csv +4325 -0
- prompt_analytics_for_claude_code-0.3.0/docs/architecture.md +162 -0
- prompt_analytics_for_claude_code-0.3.0/docs/dashboard.md +115 -0
- prompt_analytics_for_claude_code-0.3.0/docs/screenshots/cli-by-category.png +0 -0
- prompt_analytics_for_claude_code-0.3.0/docs/screenshots/dashboard-home.png +0 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/__init__.py +5 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/__main__.py +18 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/analytics.py +2206 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/categorize.py +1021 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/cli.py +1123 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/config.py +105 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/__init__.py +1 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/app.py +377 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/data.py +449 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/echarts.py +350 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/filters.py +463 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/10_how_it_works.py +256 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/11_explorer.py +375 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/1_overview.py +431 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/2_models.py +322 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/3_prompts.py +406 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/4_session_depth.py +353 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/5_sessions.py +418 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/6_optimize.py +323 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/7_quotas.py +512 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/pages/__init__.py +1 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/dashboard/theme.py +329 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/data/__init__.py +0 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/data/pricing.yml +270 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/extract.py +1086 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/paths.py +60 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/pricing.py +247 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/py.typed +0 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/render.py +143 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/schema.py +295 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/snapshot.py +203 -0
- prompt_analytics_for_claude_code-0.3.0/prompt_analytics/storage.py +107 -0
- prompt_analytics_for_claude_code-0.3.0/pyproject.toml +130 -0
- prompt_analytics_for_claude_code-0.3.0/requirements.txt +9 -0
- prompt_analytics_for_claude_code-0.3.0/scripts/capture_fixture.py +238 -0
- prompt_analytics_for_claude_code-0.3.0/scripts/fetch_copilot_pricing.py +200 -0
- prompt_analytics_for_claude_code-0.3.0/scripts/generate_demo_data.py +527 -0
- prompt_analytics_for_claude_code-0.3.0/scripts/reconcile_ccusage.py +235 -0
- prompt_analytics_for_claude_code-0.3.0/tests/__init__.py +1 -0
- prompt_analytics_for_claude_code-0.3.0/tests/conftest.py +71 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/.gitkeep +0 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/README.md +33 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/agent_delta.jsonl +2 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/claude-code-2.1.173/demo-project/d42b86da-418a-49a0-842a-43af6212339e.jsonl +94 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/session_alpha.jsonl +6 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/session_beta.jsonl +4 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/session_delta.jsonl +17 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/session_delta_resumed.jsonl +4 -0
- prompt_analytics_for_claude_code-0.3.0/tests/fixtures/session_gamma.jsonl +2 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_analytics.py +548 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_analytics_power.py +368 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_analytics_requests.py +368 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_categorize.py +878 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_cli.py +560 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_config.py +47 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_dashboard.py +597 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_demo_data.py +99 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_extract.py +692 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_fixtures_versioned.py +54 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_pricing.py +405 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_snapshot.py +259 -0
- prompt_analytics_for_claude_code-0.3.0/tests/test_storage.py +132 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Lock line endings to LF in the repository regardless of each contributor's
|
|
2
|
+
# core.autocrlf: fixtures (.jsonl) and demo CSVs are test *data* whose bytes
|
|
3
|
+
# must be stable across checkouts (cross-platform audit 2026-06-11, M1).
|
|
4
|
+
* text=auto eol=lf
|
|
5
|
+
*.py text eol=lf
|
|
6
|
+
*.md text eol=lf
|
|
7
|
+
*.yml text eol=lf
|
|
8
|
+
*.yaml text eol=lf
|
|
9
|
+
*.toml text eol=lf
|
|
10
|
+
*.json text eol=lf
|
|
11
|
+
*.jsonl text eol=lf
|
|
12
|
+
*.csv text eol=lf
|
|
13
|
+
*.png binary
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ci-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: test (py${{ matrix.python-version }} · ${{ matrix.os }})
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
os: [ubuntu-latest, windows-latest]
|
|
21
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
22
|
+
include:
|
|
23
|
+
- os: macos-latest
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python-version }}
|
|
32
|
+
enable-cache: true
|
|
33
|
+
cache-dependency-glob: "pyproject.toml"
|
|
34
|
+
|
|
35
|
+
- name: Install project (all extras)
|
|
36
|
+
run: uv sync --all-extras
|
|
37
|
+
|
|
38
|
+
- name: Ruff lint
|
|
39
|
+
run: uv run ruff check .
|
|
40
|
+
|
|
41
|
+
- name: Ruff format check
|
|
42
|
+
run: uv run ruff format --check .
|
|
43
|
+
|
|
44
|
+
- name: Type check (package + dashboard + tests)
|
|
45
|
+
run: uv run mypy prompt_analytics tests
|
|
46
|
+
|
|
47
|
+
- name: Tests with coverage (fails under 85%)
|
|
48
|
+
run: uv run pytest tests/ -q
|
|
49
|
+
|
|
50
|
+
package:
|
|
51
|
+
name: build · twine check · clean-venv smoke test (${{ matrix.os }})
|
|
52
|
+
runs-on: ${{ matrix.os }}
|
|
53
|
+
strategy:
|
|
54
|
+
fail-fast: false
|
|
55
|
+
matrix:
|
|
56
|
+
os: [ubuntu-latest, windows-latest]
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
|
|
60
|
+
- name: Install uv
|
|
61
|
+
uses: astral-sh/setup-uv@v5
|
|
62
|
+
with:
|
|
63
|
+
python-version: "3.12"
|
|
64
|
+
enable-cache: true
|
|
65
|
+
cache-dependency-glob: "pyproject.toml"
|
|
66
|
+
|
|
67
|
+
- name: Build sdist + wheel
|
|
68
|
+
run: uv build
|
|
69
|
+
|
|
70
|
+
- name: twine check
|
|
71
|
+
run: uvx twine check dist/*
|
|
72
|
+
|
|
73
|
+
- name: Install the wheel into a clean virtualenv and smoke-test it
|
|
74
|
+
shell: bash
|
|
75
|
+
run: |
|
|
76
|
+
set -euxo pipefail
|
|
77
|
+
python -m venv clean
|
|
78
|
+
# bin/ on POSIX, Scripts/ on Windows (Git Bash on the Windows runner)
|
|
79
|
+
if [ -d clean/bin ]; then BIN=clean/bin; else BIN=clean/Scripts; fi
|
|
80
|
+
"$BIN/pip" install dist/*.whl
|
|
81
|
+
"$BIN/prompt-analytics" --help
|
|
82
|
+
# Analysis commands must not crash on an empty history; exit 1 = "no
|
|
83
|
+
# data" is expected here. Assert the exact code: exit 2 would be a
|
|
84
|
+
# traceback, which is a bug.
|
|
85
|
+
rc=0
|
|
86
|
+
"$BIN/prompt-analytics" summary --output-dir empty_out || rc=$?
|
|
87
|
+
if [ "$rc" -ne 1 ]; then
|
|
88
|
+
echo "Expected exit 1 from summary on empty dir, got $rc" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
# Timezone canary: tzdata must resolve Europe/Paris on the bare wheel
|
|
92
|
+
# (m5 — the Windows runner needs tzdata bundled as a dep).
|
|
93
|
+
# Exit 0 (empty data written) or 1 (no data) are both OK; exit 2
|
|
94
|
+
# means a ValueError / ImportError and is a bug.
|
|
95
|
+
tz=0
|
|
96
|
+
"$BIN/prompt-analytics" extract --timezone Europe/Paris --output-dir tz_out || tz=$?
|
|
97
|
+
if [ "$tz" -eq 2 ]; then
|
|
98
|
+
echo "extract --timezone Europe/Paris exited 2 (timezone not found on bare wheel)" >&2
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
name: Pricing drift check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
# Every Monday at 08:00 UTC
|
|
6
|
+
- cron: "0 8 * * 1"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
pull-requests: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
drift:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install pyyaml requests
|
|
28
|
+
|
|
29
|
+
- name: Fetch LiteLLM model prices
|
|
30
|
+
run: |
|
|
31
|
+
python - << 'EOF'
|
|
32
|
+
import json, urllib.request, sys, yaml, pathlib
|
|
33
|
+
|
|
34
|
+
LITELLM_URL = (
|
|
35
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/"
|
|
36
|
+
"model_prices_and_context_window.json"
|
|
37
|
+
)
|
|
38
|
+
with urllib.request.urlopen(LITELLM_URL, timeout=30) as r:
|
|
39
|
+
litellm: dict = json.loads(r.read().decode())
|
|
40
|
+
|
|
41
|
+
# Claude model keys in LiteLLM use the form "claude/claude-opus-4-8" or
|
|
42
|
+
# just "claude-opus-4-8"; we normalise to the bare model ID.
|
|
43
|
+
def price_per_mtok(v, key):
|
|
44
|
+
val = v.get(key)
|
|
45
|
+
return round(float(val) * 1_000_000, 6) if val is not None else None
|
|
46
|
+
|
|
47
|
+
drift = []
|
|
48
|
+
pricing_yml = pathlib.Path("prompt_analytics/data/pricing.yml")
|
|
49
|
+
data = yaml.safe_load(pricing_yml.read_text(encoding="utf-8"))
|
|
50
|
+
embedded = data.get("providers", {}).get("anthropic", {}).get("models", {})
|
|
51
|
+
|
|
52
|
+
for model_id, entry in embedded.items():
|
|
53
|
+
# Try common LiteLLM key forms
|
|
54
|
+
candidates = [model_id, f"claude/{model_id}", f"anthropic/{model_id}"]
|
|
55
|
+
litellm_entry = None
|
|
56
|
+
for c in candidates:
|
|
57
|
+
if c in litellm:
|
|
58
|
+
litellm_entry = litellm[c]
|
|
59
|
+
break
|
|
60
|
+
if litellm_entry is None:
|
|
61
|
+
print(f" skip {model_id}: not in LiteLLM", flush=True)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
checks = [
|
|
65
|
+
("input", "input_cost_per_token"),
|
|
66
|
+
("output", "output_cost_per_token"),
|
|
67
|
+
("cache_read", "cache_read_input_token_cost"),
|
|
68
|
+
("cache_write_5m", "cache_creation_input_token_cost"),
|
|
69
|
+
]
|
|
70
|
+
for our_key, ll_key in checks:
|
|
71
|
+
ll_val = price_per_mtok(litellm_entry, ll_key)
|
|
72
|
+
if ll_val is None:
|
|
73
|
+
continue
|
|
74
|
+
our_val = entry.get(our_key)
|
|
75
|
+
if our_val is None:
|
|
76
|
+
continue
|
|
77
|
+
# Allow 1 % tolerance for rounding
|
|
78
|
+
if abs(ll_val - our_val) / max(abs(ll_val), 1e-9) > 0.01:
|
|
79
|
+
drift.append(
|
|
80
|
+
f" {model_id}/{our_key}: ours={our_val}, LiteLLM={ll_val}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if drift:
|
|
84
|
+
print("DRIFT DETECTED (Anthropic vs LiteLLM):", flush=True)
|
|
85
|
+
for line in drift:
|
|
86
|
+
print(line, flush=True)
|
|
87
|
+
pathlib.Path("drift_litellm.txt").write_text("\n".join(drift) + "\n", encoding="utf-8")
|
|
88
|
+
else:
|
|
89
|
+
print("No drift vs LiteLLM.", flush=True)
|
|
90
|
+
EOF
|
|
91
|
+
|
|
92
|
+
- name: Fetch Copilot pricing
|
|
93
|
+
run: |
|
|
94
|
+
pip install pyyaml # already installed but be explicit
|
|
95
|
+
python scripts/fetch_copilot_pricing.py --output /tmp/copilot_fresh.yml
|
|
96
|
+
|
|
97
|
+
- name: Compare Copilot pricing
|
|
98
|
+
run: |
|
|
99
|
+
python - << 'EOF'
|
|
100
|
+
import yaml, pathlib, sys
|
|
101
|
+
|
|
102
|
+
fresh = yaml.safe_load(
|
|
103
|
+
pathlib.Path("/tmp/copilot_fresh.yml").read_text(encoding="utf-8")
|
|
104
|
+
)
|
|
105
|
+
pricing_yml = pathlib.Path("prompt_analytics/data/pricing.yml")
|
|
106
|
+
data = yaml.safe_load(pricing_yml.read_text(encoding="utf-8"))
|
|
107
|
+
embedded = data.get("providers", {}).get("copilot", {}).get("models", {})
|
|
108
|
+
fresh_claude = fresh.get("vendors", {}).get("anthropic", {})
|
|
109
|
+
|
|
110
|
+
drift = []
|
|
111
|
+
for model_id, fresh_entry in fresh_claude.items():
|
|
112
|
+
our_entry = embedded.get(model_id)
|
|
113
|
+
if our_entry is None:
|
|
114
|
+
drift.append(f" NEW model {model_id}: not in embedded copilot grid")
|
|
115
|
+
continue
|
|
116
|
+
for key in ("input", "output", "cache_read"):
|
|
117
|
+
fv = fresh_entry.get(key)
|
|
118
|
+
ov = our_entry.get(key)
|
|
119
|
+
if fv is None or ov is None:
|
|
120
|
+
continue
|
|
121
|
+
if abs(fv - ov) / max(abs(fv), 1e-9) > 0.01:
|
|
122
|
+
drift.append(f" {model_id}/{key}: ours={ov}, Copilot page={fv}")
|
|
123
|
+
# cache_write in fresh → compare against cache_write_5m
|
|
124
|
+
fw = fresh_entry.get("cache_write")
|
|
125
|
+
ow = our_entry.get("cache_write_5m")
|
|
126
|
+
if fw is not None and ow is not None:
|
|
127
|
+
if abs(fw - ow) / max(abs(fw), 1e-9) > 0.01:
|
|
128
|
+
drift.append(f" {model_id}/cache_write_5m: ours={ow}, Copilot page={fw}")
|
|
129
|
+
|
|
130
|
+
if drift:
|
|
131
|
+
print("DRIFT DETECTED (Copilot):", flush=True)
|
|
132
|
+
for line in drift:
|
|
133
|
+
print(line, flush=True)
|
|
134
|
+
with open("drift_litellm.txt", "a", encoding="utf-8") as f:
|
|
135
|
+
f.write("\n".join(drift) + "\n")
|
|
136
|
+
else:
|
|
137
|
+
print("No drift vs Copilot page.", flush=True)
|
|
138
|
+
EOF
|
|
139
|
+
|
|
140
|
+
- name: Open PR if drift detected
|
|
141
|
+
if: ${{ hashFiles('drift_litellm.txt') != '' }}
|
|
142
|
+
env:
|
|
143
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
144
|
+
run: |
|
|
145
|
+
BRANCH="pricing-drift-$(date +%Y%m%d)"
|
|
146
|
+
git config user.name "github-actions[bot]"
|
|
147
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
148
|
+
git checkout -b "$BRANCH"
|
|
149
|
+
git add -A
|
|
150
|
+
git commit -m "chore: pricing drift detected $(date +%Y-%m-%d)" --allow-empty
|
|
151
|
+
git push origin "$BRANCH"
|
|
152
|
+
DRIFT=$(cat drift_litellm.txt)
|
|
153
|
+
gh pr create \
|
|
154
|
+
--title "Pricing drift detected $(date +%Y-%m-%d)" \
|
|
155
|
+
--body "$(printf '## Pricing drift\n\nThe weekly pricing check found the following differences between \`pricing.yml\` and the upstream sources (LiteLLM / Copilot page).\n\n```\n%s\n```\n\nPlease update \`prompt_analytics/data/pricing.yml\` accordingly.' "$DRIFT")" \
|
|
156
|
+
--base main \
|
|
157
|
+
--head "$BRANCH"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Build and publish to PyPI on a version tag, using PyPI trusted publishing
|
|
4
|
+
# (OIDC — no API token stored). The PyPI project's trusted publisher must match:
|
|
5
|
+
# owner: romainfjgaspard
|
|
6
|
+
# repository: prompt-analytics-for-claude-code
|
|
7
|
+
# workflow: release.yml
|
|
8
|
+
# environment: pypi
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
tags: ["v*"]
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
pypi:
|
|
15
|
+
name: Build & publish to PyPI
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
environment: pypi
|
|
18
|
+
permissions:
|
|
19
|
+
id-token: write # required for trusted publishing
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
|
|
28
|
+
- name: Build sdist + wheel
|
|
29
|
+
run: uv build
|
|
30
|
+
|
|
31
|
+
- name: Publish to PyPI (trusted publishing)
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
output/
|
|
2
|
+
!output/.gitkeep
|
|
3
|
+
.env
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.pyc
|
|
6
|
+
*.log
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
.coverage.*
|
|
12
|
+
htmlcov/
|
|
13
|
+
.idea/
|
|
14
|
+
.claude/
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
.venv/
|
|
18
|
+
build/
|
|
19
|
+
# Not committed: Streamlit Community Cloud prioritizes uv.lock over
|
|
20
|
+
# requirements.txt and `uv sync` skips the `dashboard` extra, so the hosted demo
|
|
21
|
+
# would miss streamlit-echarts. Cloud uses requirements.txt (.[dashboard]); CI
|
|
22
|
+
# and local dev resolve from pyproject.toml.
|
|
23
|
+
uv.lock
|
|
24
|
+
.streamlit/secrets.toml
|
|
25
|
+
scripts/reconcile_dump.json
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Dashboard theme — dark, forced (no light/dark toggle).
|
|
2
|
+
#
|
|
3
|
+
# A single dark theme (deep navy, Anthropic coral accent, Space Grotesk). Defining
|
|
4
|
+
# only [theme] (no [theme.light]/[theme.dark] pair) forces dark for every visitor
|
|
5
|
+
# and removes the in-app theme switch — deliberate: this is a dark-first showcase
|
|
6
|
+
# (a [theme.light]/[theme.dark] pair would instead follow the visitor's OS).
|
|
7
|
+
[theme]
|
|
8
|
+
base = "dark"
|
|
9
|
+
primaryColor = "#D97757"
|
|
10
|
+
backgroundColor = "#0B1220"
|
|
11
|
+
secondaryBackgroundColor = "#111827"
|
|
12
|
+
textColor = "#F8FAFC"
|
|
13
|
+
borderColor = "#2B3954"
|
|
14
|
+
font = "'Space Grotesk':https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap"
|
|
15
|
+
headingFont = "Space Grotesk"
|
|
16
|
+
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400..600&display=swap"
|
|
17
|
+
baseFontSize = 14
|
|
18
|
+
showWidgetBorder = true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — kept at
|
|
6
|
+
`0.x` on purpose: the upstream Claude Code JSONL format is unstable, so parsing
|
|
7
|
+
breakage is treated as expected and reflected in the version.
|
|
8
|
+
|
|
9
|
+
## [0.3.0] — 2026-06-15 — Initial public release
|
|
10
|
+
|
|
11
|
+
Prompt-level analytics for Claude Code: every prompt in your local
|
|
12
|
+
`~/.claude/projects/**/*.jsonl` logs becomes one priced row — no account, no API
|
|
13
|
+
key, nothing leaves your machine. Two surfaces on the same data: a terminal CLI
|
|
14
|
+
and a Streamlit dashboard.
|
|
15
|
+
|
|
16
|
+
### Highlights
|
|
17
|
+
- **Per-prompt dataset** — tokens and cost per prompt, project, model, category
|
|
18
|
+
and session, with a Pareto view of where the spend concentrates.
|
|
19
|
+
- **Power-user analyses at the request grain** — `context` (accumulated context
|
|
20
|
+
by session depth), `ttl` (cache-TTL expiry losses), `compactions`, `overhead`,
|
|
21
|
+
`by-token-type` (the **context-rent** share of the bill), `model-category
|
|
22
|
+
--whatif`, `recommend`, `burn-rate`, and `break-even` (is a Pro/Max plan worth
|
|
23
|
+
it vs the API?).
|
|
24
|
+
- **Automatic categorization** — a local, zero-dependency FR+EN heuristic labels
|
|
25
|
+
every prompt across eleven categories and scores its *observed* complexity 1–5;
|
|
26
|
+
an optional LLM pass (Anthropic / OpenRouter / local Ollama) refines it.
|
|
27
|
+
- **Streamlit dashboard** — Apache ECharts on a dark-by-default theme, global
|
|
28
|
+
cross-filtering (click to filter, brush a date range), an Explorer drill-down
|
|
29
|
+
(day → session → prompt), and a public synthetic-data demo.
|
|
30
|
+
- **Accurate by construction** — global cross-file deduplication on
|
|
31
|
+
`message.id + requestId` (fixes the ~2.5× token inflation and double-counted
|
|
32
|
+
resumed / `--resume` sessions), fake-prompt filtering, an explicit subagent
|
|
33
|
+
policy, and cache writes split by TTL (5m vs 1h). Totals reconcile
|
|
34
|
+
bucket-for-bucket with [ccusage](https://github.com/ryoppippi/ccusage) on real
|
|
35
|
+
history.
|
|
36
|
+
- **Generic multi-provider pricing** — `pricing.yml` ships `anthropic` and
|
|
37
|
+
`copilot` grids and accepts any rate card; costs are computed at read time from
|
|
38
|
+
raw counts, so a pricing change never needs a re-extract.
|
|
39
|
+
- **Inspectable exports** — relational CSVs keyed by `prompt_id` / `session_id`,
|
|
40
|
+
`--format table|csv|json` on every command, and `export --flat` for Excel/BI.
|
|
41
|
+
- **`snapshot`** — records plan quota utilization over time via the OAuth usage
|
|
42
|
+
endpoint Claude Code already uses (kept out of any public API; fails
|
|
43
|
+
gracefully).
|
|
44
|
+
|
|
45
|
+
### Privacy
|
|
46
|
+
Fully local by default: `extract` and every analysis command touch only your
|
|
47
|
+
local logs. `snapshot` calls Anthropic's own OAuth usage endpoint with your
|
|
48
|
+
existing token; `categorize --llm` sends prompt excerpts only to the provider you
|
|
49
|
+
choose (OpenRouter is a third party). See the README's Privacy section for the
|
|
50
|
+
full read/write/network breakdown.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in `prompt-analytics-for-claude-code`. This is a small,
|
|
4
|
+
quasi-stdlib tool with a strict quality bar (typed, tested, linted). PRs are
|
|
5
|
+
welcome — please run the checks below before submitting.
|
|
6
|
+
|
|
7
|
+
## Dev setup
|
|
8
|
+
|
|
9
|
+
The project uses [uv](https://docs.astral.sh/uv/). Clone, then sync all extras
|
|
10
|
+
(core + `categorize` + `dashboard` + `dev`):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv sync --all-extras
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run the full local CI — the same steps the GitHub workflow runs:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv run ruff check .
|
|
20
|
+
uv run ruff format --check .
|
|
21
|
+
uv run mypy prompt_analytics tests
|
|
22
|
+
uv run pytest # coverage gate: 85%
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Architecture and the data flow (`extract → analytics → {cli, dashboard, csv}`)
|
|
26
|
+
are documented in [`docs/architecture.md`](docs/architecture.md). The CSV column
|
|
27
|
+
contract lives in one place, [`prompt_analytics/schema.py`](prompt_analytics/schema.py)
|
|
28
|
+
— change a column there and every writer/reader follows.
|
|
29
|
+
|
|
30
|
+
A few ground rules:
|
|
31
|
+
|
|
32
|
+
- **The core stays light.** Core depends only on `python-dotenv`, `pyyaml`, and
|
|
33
|
+
`rich`. Heavy deps belong in extras: `anthropic`/`openai` in `categorize`,
|
|
34
|
+
`streamlit`/`plotly`/`pandas`/`numpy` in `dashboard`. Don't import an extra
|
|
35
|
+
from core code.
|
|
36
|
+
- **`tokens.csv` stores raw counts, never costs.** Costs are computed at read
|
|
37
|
+
time in `analytics.py` from the pricing grid, so a pricing change never
|
|
38
|
+
requires a re-extract. Keep it that way.
|
|
39
|
+
- **Every bug fix gets a regression test.** The happy path is not enough.
|
|
40
|
+
|
|
41
|
+
## Adding a pricing provider
|
|
42
|
+
|
|
43
|
+
Pricing is a generic multi-provider grid in
|
|
44
|
+
[`prompt_analytics/data/pricing.yml`](prompt_analytics/data/pricing.yml). To add
|
|
45
|
+
a provider (your company's internal rates, a Bedrock tier, …), add a key under
|
|
46
|
+
`providers:`. All prices are **USD per 1,000,000 tokens**:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
providers:
|
|
50
|
+
my-company:
|
|
51
|
+
models:
|
|
52
|
+
claude-opus-4-8:
|
|
53
|
+
input: 4.50 # negotiated rate
|
|
54
|
+
output: 22.50
|
|
55
|
+
cache_read: 0.45
|
|
56
|
+
cache_write_5m: 5.625
|
|
57
|
+
cache_write_1h: 9.00
|
|
58
|
+
fallbacks: # matched by longest model-name prefix
|
|
59
|
+
claude-opus:
|
|
60
|
+
input: 4.50
|
|
61
|
+
output: 22.50
|
|
62
|
+
cache_read: 0.45
|
|
63
|
+
cache_write_5m: 5.625
|
|
64
|
+
cache_write_1h: 9.00
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then use it with `--providers anthropic,my-company` on `compare`, or `--provider
|
|
68
|
+
my-company` on the other commands. Users can keep their grid out of the repo and
|
|
69
|
+
pass it with `--pricing ./my-pricing.yml`.
|
|
70
|
+
|
|
71
|
+
Lookup rules (see `pricing.get_model_pricing`): an exact model id wins; otherwise
|
|
72
|
+
the **longest matching prefix** under `fallbacks` is used; `[1m]` and
|
|
73
|
+
long-context suffixes are stripped before lookup. An unpriced model is never
|
|
74
|
+
silently zeroed — it surfaces in the extraction/analytics report so you know to
|
|
75
|
+
add an entry.
|
|
76
|
+
|
|
77
|
+
The bundled `anthropic` and `copilot` grids are validated weekly by CI
|
|
78
|
+
(`.github/workflows/pricing-drift.yml`): the job diffs `anthropic` against
|
|
79
|
+
LiteLLM's `model_prices_and_context_window.json` and re-runs
|
|
80
|
+
`scripts/fetch_copilot_pricing.py` against the live Copilot pricing page, opening
|
|
81
|
+
a PR if either drifts. Update those two grids through that job, not by hand.
|
|
82
|
+
|
|
83
|
+
## Capturing a test fixture
|
|
84
|
+
|
|
85
|
+
Claude Code's JSONL format changes without notice, so parsing is pinned against
|
|
86
|
+
fixture files **per Claude Code version**, under
|
|
87
|
+
`tests/fixtures/claude-code-<version>/`. When you hit a new format, capture one
|
|
88
|
+
from your own logs — anonymized, so it is safe to commit:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run python scripts/capture_fixture.py path/to/real/session.jsonl
|
|
92
|
+
# --version 2.1.180 override the auto-detected version label
|
|
93
|
+
# --project demo-app generic project folder name in the fixture
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`capture_fixture.py` rewrites the log **character by character** (letters → `x`,
|
|
97
|
+
digits → `0`, punctuation and length preserved) while keeping everything the
|
|
98
|
+
parser counts: structure, ids, the `uuid`/`parentUuid` attribution chain,
|
|
99
|
+
`message.usage`, `model`, `timestamp`, `version`, and the filtering markers
|
|
100
|
+
(`<command-name>`, `[Request interrupted…`). It scrubs your username, paths and
|
|
101
|
+
prompt text — verify the result before committing. `test_fixtures_versioned.py`
|
|
102
|
+
then acts as a drift canary: it asserts the fixture parses cleanly (no invalid
|
|
103
|
+
lines, no unknown event types) and deterministically.
|
|
104
|
+
|
|
105
|
+
## Submitting
|
|
106
|
+
|
|
107
|
+
- Branch off `main`, keep commits focused, and describe the *why* in the PR.
|
|
108
|
+
- Add or update tests and docs (README / `docs/`) alongside the code.
|
|
109
|
+
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]`.
|
|
110
|
+
- Make sure the four checks above pass locally — CI runs them on Python
|
|
111
|
+
3.10–3.14 across Ubuntu and Windows.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Romain Gaspard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|