cli-consumption 0.4.3__tar.gz → 0.4.5__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.
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/CHANGELOG.md +18 -1
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/PKG-INFO +12 -9
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/README.md +9 -6
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/pyproject.toml +5 -2
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/mistral_vibe.py +18 -10
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/registry.py +5 -3
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/cli.py +186 -5
- cli_consumption-0.4.5/src/cli_consumption/frontend.py +153 -0
- cli_consumption-0.4.5/src/cli_consumption/web_runtime.zip +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/.gitignore +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/LICENSE +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/NOTICE +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/INTER_FONT_LICENSE.txt +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/__init__.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/_shared.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/aider.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/amazon_q.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/amp.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/cline.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/continue_cli.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/copilot.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/crush.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/cursor.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/gemini.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/goose.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/grok.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/kimi.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/openhands.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/plandex.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/qwen.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/api.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_layouts.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_react.css +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_react.js +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/__init__.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/env.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/__init__.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0006_dashboard_layouts.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0007_dashboard_layout_revision.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/qualifications.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/reporting.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/reporting_api.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/retention.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/schema.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/snapshot_extraction.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/snapshot_files.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/timestamps.py +0 -0
|
@@ -6,6 +6,21 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.5] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `serve --front` now launches the FastAPI service and a bundled production Next.js
|
|
14
|
+
dashboard from the Python distribution, without requiring a repository checkout or
|
|
15
|
+
npm installation at runtime.
|
|
16
|
+
|
|
17
|
+
## [0.4.4] - 2026-09-08
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Mistral Vibe now counts each persisted assistant message as a model call while
|
|
22
|
+
retaining cumulative session tokens as one unsplit aggregate.
|
|
23
|
+
|
|
9
24
|
## [0.4.3] - 2026-09-05
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -194,7 +209,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
194
209
|
|
|
195
210
|
- Refreshed the provider guide for the first minor release ([#26]).
|
|
196
211
|
|
|
197
|
-
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.
|
|
212
|
+
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.5...HEAD
|
|
213
|
+
[0.4.5]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.4...v0.4.5
|
|
214
|
+
[0.4.4]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.3...v0.4.4
|
|
198
215
|
[0.4.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.2...v0.4.3
|
|
199
216
|
[0.4.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.1...v0.4.2
|
|
200
217
|
[0.4.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...v0.4.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cli-consumption
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
4
4
|
Summary: Analyze and consolidate AI coding CLI consumption across machines.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
|
|
6
6
|
Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
|
|
@@ -23,14 +23,14 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
23
23
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
24
|
Requires-Python: >=3.11
|
|
25
25
|
Requires-Dist: alembic>=1.14
|
|
26
|
+
Requires-Dist: fastapi>=0.115
|
|
26
27
|
Requires-Dist: pydantic>=2.10
|
|
27
28
|
Requires-Dist: sqlalchemy>=2.0
|
|
28
29
|
Requires-Dist: typer>=0.15
|
|
30
|
+
Requires-Dist: uvicorn>=0.34
|
|
29
31
|
Provides-Extra: postgres
|
|
30
32
|
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
|
|
31
33
|
Provides-Extra: server
|
|
32
|
-
Requires-Dist: fastapi>=0.115; extra == 'server'
|
|
33
|
-
Requires-Dist: uvicorn>=0.34; extra == 'server'
|
|
34
34
|
Provides-Extra: snapshots
|
|
35
35
|
Requires-Dist: cryptography>=45; extra == 'snapshots'
|
|
36
36
|
Provides-Extra: sync
|
|
@@ -72,16 +72,14 @@ CLI Consumption requires Python 3.11 or newer. Run it directly from PyPI with `u
|
|
|
72
72
|
uv tool run cli-consumption providers
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
The default package covers local collection, SQLite storage, and
|
|
76
|
-
the optional runtime capabilities you use:
|
|
75
|
+
The default package covers local collection, SQLite storage, exports, and the local
|
|
76
|
+
HTTP server. Install only the optional runtime capabilities you use:
|
|
77
77
|
|
|
78
78
|
- `cli-consumption[sync]` for the sync client;
|
|
79
|
-
- `cli-consumption[server]` for the collector service;
|
|
80
79
|
- `cli-consumption[postgres]` for PostgreSQL;
|
|
81
80
|
- `cli-consumption[snapshots]` for signed, compressed offline snapshot files.
|
|
82
81
|
|
|
83
|
-
Extras can be combined, for example `cli-consumption[
|
|
84
|
-
collector.
|
|
82
|
+
Extras can be combined, for example `cli-consumption[postgres]` on a central collector.
|
|
85
83
|
|
|
86
84
|
## Quick start
|
|
87
85
|
|
|
@@ -100,7 +98,7 @@ tables are generated only when `--csv` is passed.
|
|
|
100
98
|
Dashboard development lives in the locked npm workspace under `packages/`. It builds
|
|
101
99
|
provider-neutral ESM analytics, shared React presentation primitives, and deterministic
|
|
102
100
|
React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
|
|
103
|
-
only offline renderer;
|
|
101
|
+
only offline renderer; collection and offline reporting do not require Node.js.
|
|
104
102
|
|
|
105
103
|
The authenticated persistent dashboard lives in `apps/web/`. It reads the same
|
|
106
104
|
minimized reporting contract through a server-side Next.js BFF: the browser never
|
|
@@ -186,6 +184,11 @@ Run `uv run cli-consumption COMMAND --help` for every option. The
|
|
|
186
184
|
[usage guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
187
185
|
contains copy-ready examples.
|
|
188
186
|
|
|
187
|
+
To run the API and bundled persistent dashboard together, install Node.js 20.9 or
|
|
188
|
+
newer and use `uv tool run cli-consumption serve --front`. The command asks for a
|
|
189
|
+
dashboard password when `CLI_CONSUMPTION_DASHBOARD_PASSWORD` is not set; it does not
|
|
190
|
+
require a repository checkout or npm install.
|
|
191
|
+
|
|
189
192
|
## Documentation
|
|
190
193
|
|
|
191
194
|
- [Usage and operations](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
@@ -33,16 +33,14 @@ CLI Consumption requires Python 3.11 or newer. Run it directly from PyPI with `u
|
|
|
33
33
|
uv tool run cli-consumption providers
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The default package covers local collection, SQLite storage, and
|
|
37
|
-
the optional runtime capabilities you use:
|
|
36
|
+
The default package covers local collection, SQLite storage, exports, and the local
|
|
37
|
+
HTTP server. Install only the optional runtime capabilities you use:
|
|
38
38
|
|
|
39
39
|
- `cli-consumption[sync]` for the sync client;
|
|
40
|
-
- `cli-consumption[server]` for the collector service;
|
|
41
40
|
- `cli-consumption[postgres]` for PostgreSQL;
|
|
42
41
|
- `cli-consumption[snapshots]` for signed, compressed offline snapshot files.
|
|
43
42
|
|
|
44
|
-
Extras can be combined, for example `cli-consumption[
|
|
45
|
-
collector.
|
|
43
|
+
Extras can be combined, for example `cli-consumption[postgres]` on a central collector.
|
|
46
44
|
|
|
47
45
|
## Quick start
|
|
48
46
|
|
|
@@ -61,7 +59,7 @@ tables are generated only when `--csv` is passed.
|
|
|
61
59
|
Dashboard development lives in the locked npm workspace under `packages/`. It builds
|
|
62
60
|
provider-neutral ESM analytics, shared React presentation primitives, and deterministic
|
|
63
61
|
React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
|
|
64
|
-
only offline renderer;
|
|
62
|
+
only offline renderer; collection and offline reporting do not require Node.js.
|
|
65
63
|
|
|
66
64
|
The authenticated persistent dashboard lives in `apps/web/`. It reads the same
|
|
67
65
|
minimized reporting contract through a server-side Next.js BFF: the browser never
|
|
@@ -147,6 +145,11 @@ Run `uv run cli-consumption COMMAND --help` for every option. The
|
|
|
147
145
|
[usage guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
148
146
|
contains copy-ready examples.
|
|
149
147
|
|
|
148
|
+
To run the API and bundled persistent dashboard together, install Node.js 20.9 or
|
|
149
|
+
newer and use `uv tool run cli-consumption serve --front`. The command asks for a
|
|
150
|
+
dashboard password when `CLI_CONSUMPTION_DASHBOARD_PASSWORD` is not set; it does not
|
|
151
|
+
require a repository checkout or npm install.
|
|
152
|
+
|
|
150
153
|
## Documentation
|
|
151
154
|
|
|
152
155
|
- [Usage and operations](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-consumption"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.5"
|
|
8
8
|
description = "Analyze and consolidate AI coding CLI consumption across machines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -25,14 +25,16 @@ classifiers = [
|
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
27
|
"alembic>=1.14",
|
|
28
|
+
"fastapi>=0.115",
|
|
28
29
|
"pydantic>=2.10",
|
|
29
30
|
"sqlalchemy>=2.0",
|
|
30
31
|
"typer>=0.15",
|
|
32
|
+
"uvicorn>=0.34",
|
|
31
33
|
]
|
|
32
34
|
|
|
33
35
|
[project.optional-dependencies]
|
|
34
36
|
postgres = ["psycopg[binary]>=3.2"]
|
|
35
|
-
server = [
|
|
37
|
+
server = []
|
|
36
38
|
sync = ["httpx>=0.27"]
|
|
37
39
|
snapshots = [
|
|
38
40
|
"cryptography>=45",
|
|
@@ -108,6 +110,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "S"]
|
|
|
108
110
|
"src/cli_consumption/api.py" = ["S608"]
|
|
109
111
|
"src/cli_consumption/cli.py" = ["S107"]
|
|
110
112
|
"src/cli_consumption/dashboard.py" = ["E501", "S608"]
|
|
113
|
+
"src/cli_consumption/frontend.py" = ["S603"]
|
|
111
114
|
"tests/**" = ["S101"]
|
|
112
115
|
"tests/smoke_minimal_install.py" = ["S603", "S607"]
|
|
113
116
|
"tests/test_codex_adapter.py" = ["S608"]
|
{cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/mistral_vibe.py
RENAMED
|
@@ -84,6 +84,7 @@ class MistralVibeAdapter:
|
|
|
84
84
|
started_at = timestamp(source.metadata.get("start_time"))
|
|
85
85
|
ended_at = timestamp(source.metadata.get("end_time"))
|
|
86
86
|
active_turn: dict[str, Any] | None = None
|
|
87
|
+
assistant_turn_ids: list[str | None] = []
|
|
87
88
|
turn_number = 0
|
|
88
89
|
tool_number = 0
|
|
89
90
|
compaction_number = 0
|
|
@@ -125,6 +126,8 @@ class MistralVibeAdapter:
|
|
|
125
126
|
continue
|
|
126
127
|
if active_turn is not None:
|
|
127
128
|
active_turn["status"] = "completed"
|
|
129
|
+
active_turn["model_calls"] += 1
|
|
130
|
+
assistant_turn_ids.append(active_turn["id"] if active_turn else None)
|
|
128
131
|
raw_tool_calls = message.get("tool_calls")
|
|
129
132
|
if not isinstance(raw_tool_calls, list):
|
|
130
133
|
continue
|
|
@@ -160,20 +163,25 @@ class MistralVibeAdapter:
|
|
|
160
163
|
)
|
|
161
164
|
config = mapping(source.metadata.get("config"))
|
|
162
165
|
model = label(config.get("active_model"), 255) or "unknown"
|
|
163
|
-
model_calls = 0
|
|
164
166
|
totals = empty_tokens()
|
|
165
|
-
if usage["total_tokens"] > 0
|
|
166
|
-
model_calls = 1
|
|
167
|
+
if usage["total_tokens"] > 0:
|
|
167
168
|
add_tokens(totals, usage)
|
|
169
|
+
|
|
170
|
+
for sequence, turn_id in enumerate(assistant_turn_ids, 1):
|
|
171
|
+
call_usage = (
|
|
172
|
+
usage if sequence == len(assistant_turn_ids) else empty_tokens()
|
|
173
|
+
)
|
|
168
174
|
snapshot.model_calls.append(
|
|
169
175
|
{
|
|
170
|
-
"id": f"{conversation_id}:model:
|
|
176
|
+
"id": f"{conversation_id}:model:{sequence}",
|
|
171
177
|
"conversation_id": conversation_id,
|
|
172
|
-
"turn_id":
|
|
173
|
-
"sequence":
|
|
174
|
-
"timestamp": iso(ended_at)
|
|
178
|
+
"turn_id": turn_id,
|
|
179
|
+
"sequence": sequence,
|
|
180
|
+
"timestamp": iso(ended_at)
|
|
181
|
+
if sequence == len(assistant_turn_ids)
|
|
182
|
+
else None,
|
|
175
183
|
"model": model,
|
|
176
|
-
**
|
|
184
|
+
**call_usage,
|
|
177
185
|
}
|
|
178
186
|
)
|
|
179
187
|
|
|
@@ -193,9 +201,9 @@ class MistralVibeAdapter:
|
|
|
193
201
|
"ended_at": iso(ended_at),
|
|
194
202
|
"duration_seconds": _duration(started_at, ended_at),
|
|
195
203
|
"source": "local-session-json",
|
|
196
|
-
"models": [model] if
|
|
204
|
+
"models": [model] if assistant_turn_ids else [],
|
|
197
205
|
"iterations": turn_number,
|
|
198
|
-
"model_calls":
|
|
206
|
+
"model_calls": len(assistant_turn_ids),
|
|
199
207
|
"tool_calls": tool_number,
|
|
200
208
|
"compactions": compaction_number,
|
|
201
209
|
"event_count": source.event_count,
|
|
@@ -346,10 +346,12 @@ ADAPTER_SPECS = (
|
|
|
346
346
|
token_semantics="conversation-aggregate",
|
|
347
347
|
qualification=_qualification(
|
|
348
348
|
"mistral-vibe",
|
|
349
|
-
"CLI 2.
|
|
349
|
+
"CLI 2.25.0",
|
|
350
350
|
"session meta JSON and messages JSONL",
|
|
351
|
-
"https://github.com/mistralai/mistral-vibe/tree/v2.
|
|
352
|
-
"
|
|
351
|
+
"https://github.com/mistralai/mistral-vibe/tree/v2.25.0",
|
|
352
|
+
"Assistant-message call counts with session-aggregate tokens; no timing "
|
|
353
|
+
"or historical model attribution.",
|
|
354
|
+
qualified_on="2026-09-08",
|
|
353
355
|
),
|
|
354
356
|
),
|
|
355
357
|
AdapterSpec(
|
|
@@ -3,11 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import platform
|
|
6
|
+
import secrets
|
|
7
|
+
import subprocess
|
|
6
8
|
import tempfile
|
|
7
|
-
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
8
11
|
from datetime import UTC, datetime, timedelta
|
|
9
12
|
from pathlib import Path
|
|
10
|
-
from
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Annotated, Never, Protocol, TextIO, TypedDict, cast
|
|
11
15
|
|
|
12
16
|
import typer
|
|
13
17
|
from sqlalchemy.engine import Engine
|
|
@@ -79,6 +83,12 @@ class IncrementalIngestion(TypedDict):
|
|
|
79
83
|
batch_duplicates: int
|
|
80
84
|
|
|
81
85
|
|
|
86
|
+
class _ServerProcess(Protocol):
|
|
87
|
+
should_exit: bool
|
|
88
|
+
|
|
89
|
+
def run(self) -> None: ...
|
|
90
|
+
|
|
91
|
+
|
|
82
92
|
class _BoundedStagingWriter:
|
|
83
93
|
"""Count UTF-8 metadata bytes before writing them to strict staging."""
|
|
84
94
|
|
|
@@ -1038,17 +1048,42 @@ def serve(
|
|
|
1038
1048
|
help="Environment variable containing the dashboard layout mutation token."
|
|
1039
1049
|
),
|
|
1040
1050
|
] = "CLI_CONSUMPTION_LAYOUT_TOKEN",
|
|
1051
|
+
front: Annotated[
|
|
1052
|
+
bool,
|
|
1053
|
+
typer.Option(help="Also run the bundled persistent Next.js dashboard."),
|
|
1054
|
+
] = False,
|
|
1055
|
+
front_host: Annotated[
|
|
1056
|
+
str,
|
|
1057
|
+
typer.Option(help="Loopback address for the bundled dashboard."),
|
|
1058
|
+
] = "127.0.0.1",
|
|
1059
|
+
front_port: Annotated[
|
|
1060
|
+
int,
|
|
1061
|
+
typer.Option(help="Port for the bundled dashboard."),
|
|
1062
|
+
] = 3000,
|
|
1063
|
+
front_password_env: Annotated[
|
|
1064
|
+
str,
|
|
1065
|
+
typer.Option(
|
|
1066
|
+
help="Environment variable containing the dashboard login password."
|
|
1067
|
+
),
|
|
1068
|
+
] = "CLI_CONSUMPTION_DASHBOARD_PASSWORD",
|
|
1041
1069
|
) -> None:
|
|
1042
|
-
"""Run the
|
|
1070
|
+
"""Run the central HTTP collector and optionally its dashboard."""
|
|
1043
1071
|
try:
|
|
1044
1072
|
import uvicorn
|
|
1045
1073
|
|
|
1046
1074
|
from cli_consumption.api import create_app
|
|
1047
1075
|
except ModuleNotFoundError:
|
|
1048
1076
|
raise typer.BadParameter(
|
|
1049
|
-
"serve requires
|
|
1077
|
+
"serve requires FastAPI and Uvicorn; reinstall cli-consumption"
|
|
1050
1078
|
) from None
|
|
1051
1079
|
|
|
1080
|
+
if front and front_host not in {"127.0.0.1", "localhost", "::1"}:
|
|
1081
|
+
raise typer.BadParameter("--front-host must be a loopback address.")
|
|
1082
|
+
if front and port == front_port:
|
|
1083
|
+
raise typer.BadParameter("--port and --front-port must be different.")
|
|
1084
|
+
if not 1 <= port <= 65535 or not 1 <= front_port <= 65535:
|
|
1085
|
+
raise typer.BadParameter("Server ports must be between 1 and 65535.")
|
|
1086
|
+
|
|
1052
1087
|
token = os.environ.get(token_env)
|
|
1053
1088
|
read_token = os.environ.get(read_token_env)
|
|
1054
1089
|
export_token = os.environ.get(export_token_env)
|
|
@@ -1057,6 +1092,10 @@ def serve(
|
|
|
1057
1092
|
raise typer.BadParameter(
|
|
1058
1093
|
"Configured token environment variables must be non-empty."
|
|
1059
1094
|
)
|
|
1095
|
+
if front:
|
|
1096
|
+
read_token = read_token or secrets.token_urlsafe(32)
|
|
1097
|
+
export_token = export_token or secrets.token_urlsafe(32)
|
|
1098
|
+
layout_token = layout_token or secrets.token_urlsafe(32)
|
|
1060
1099
|
if (
|
|
1061
1100
|
token is None
|
|
1062
1101
|
and read_token is None
|
|
@@ -1090,11 +1129,153 @@ def serve(
|
|
|
1090
1129
|
layout_token=layout_token,
|
|
1091
1130
|
)
|
|
1092
1131
|
)
|
|
1093
|
-
|
|
1132
|
+
if not front:
|
|
1133
|
+
uvicorn.run(application, host=host, port=port, access_log=False)
|
|
1134
|
+
return
|
|
1135
|
+
_serve_with_frontend(
|
|
1136
|
+
uvicorn,
|
|
1137
|
+
application,
|
|
1138
|
+
host=host,
|
|
1139
|
+
port=port,
|
|
1140
|
+
front_host=front_host,
|
|
1141
|
+
front_port=front_port,
|
|
1142
|
+
front_password_env=front_password_env,
|
|
1143
|
+
read_token=cast(str, read_token),
|
|
1144
|
+
export_token=cast(str, export_token),
|
|
1145
|
+
layout_token=cast(str, layout_token),
|
|
1146
|
+
)
|
|
1094
1147
|
finally:
|
|
1095
1148
|
engine.dispose()
|
|
1096
1149
|
|
|
1097
1150
|
|
|
1151
|
+
def _serve_with_frontend(
|
|
1152
|
+
uvicorn: ModuleType,
|
|
1153
|
+
application: object,
|
|
1154
|
+
*,
|
|
1155
|
+
host: str,
|
|
1156
|
+
port: int,
|
|
1157
|
+
front_host: str,
|
|
1158
|
+
front_port: int,
|
|
1159
|
+
front_password_env: str,
|
|
1160
|
+
read_token: str,
|
|
1161
|
+
export_token: str,
|
|
1162
|
+
layout_token: str,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
from cli_consumption.frontend import (
|
|
1165
|
+
FrontendRuntimeError,
|
|
1166
|
+
find_node_runtime,
|
|
1167
|
+
frontend_environment,
|
|
1168
|
+
materialize_frontend_runtime,
|
|
1169
|
+
start_frontend,
|
|
1170
|
+
stop_frontend,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
password = os.environ.get(front_password_env)
|
|
1174
|
+
if password is None:
|
|
1175
|
+
password = typer.prompt("Dashboard password", hide_input=True)
|
|
1176
|
+
if len(password) < 12:
|
|
1177
|
+
raise typer.BadParameter(
|
|
1178
|
+
f"{front_password_env} must contain at least 12 characters."
|
|
1179
|
+
)
|
|
1180
|
+
session_secret = os.environ.get("CLI_CONSUMPTION_SESSION_SECRET")
|
|
1181
|
+
if session_secret is not None and len(session_secret.encode("utf-8")) < 32:
|
|
1182
|
+
raise typer.BadParameter(
|
|
1183
|
+
"CLI_CONSUMPTION_SESSION_SECRET must contain at least 32 bytes."
|
|
1184
|
+
)
|
|
1185
|
+
session_secret = session_secret or secrets.token_urlsafe(32)
|
|
1186
|
+
api_host = {
|
|
1187
|
+
"0.0.0.0": "127.0.0.1", # noqa: S104 - converts an explicit bind address
|
|
1188
|
+
"::": "::1",
|
|
1189
|
+
}.get(host, host)
|
|
1190
|
+
api_origin = _http_origin(api_host, port)
|
|
1191
|
+
front_origin = _http_origin(front_host, front_port)
|
|
1192
|
+
|
|
1193
|
+
try:
|
|
1194
|
+
node = find_node_runtime()
|
|
1195
|
+
environment = frontend_environment(
|
|
1196
|
+
api_url=api_origin,
|
|
1197
|
+
origin=front_origin,
|
|
1198
|
+
host=front_host,
|
|
1199
|
+
port=front_port,
|
|
1200
|
+
password=password,
|
|
1201
|
+
read_token=read_token,
|
|
1202
|
+
export_token=export_token,
|
|
1203
|
+
layout_token=layout_token,
|
|
1204
|
+
session_secret=session_secret,
|
|
1205
|
+
)
|
|
1206
|
+
with materialize_frontend_runtime() as runtime:
|
|
1207
|
+
frontend = start_frontend(node, runtime, environment)
|
|
1208
|
+
typer.echo(f"Dashboard: {front_origin}")
|
|
1209
|
+
_supervise_servers(
|
|
1210
|
+
uvicorn,
|
|
1211
|
+
application,
|
|
1212
|
+
frontend,
|
|
1213
|
+
host=host,
|
|
1214
|
+
port=port,
|
|
1215
|
+
stop_frontend=stop_frontend,
|
|
1216
|
+
)
|
|
1217
|
+
except FrontendRuntimeError as error:
|
|
1218
|
+
messages = {
|
|
1219
|
+
"frontend_node_missing": "serve --front requires Node.js 20.9 or newer.",
|
|
1220
|
+
"frontend_node_invalid": "The Node.js runtime could not be validated.",
|
|
1221
|
+
"frontend_node_unsupported": (
|
|
1222
|
+
"serve --front requires Node.js 20.9 or newer."
|
|
1223
|
+
),
|
|
1224
|
+
"frontend_runtime_missing": "The bundled dashboard runtime is missing.",
|
|
1225
|
+
"frontend_runtime_invalid": "The bundled dashboard runtime is invalid.",
|
|
1226
|
+
"frontend_start_failed": "The bundled dashboard could not be started.",
|
|
1227
|
+
"frontend_stop_failed": "The bundled dashboard could not be stopped.",
|
|
1228
|
+
}
|
|
1229
|
+
typer.echo(f"Error: {messages[str(error)]}", err=True)
|
|
1230
|
+
raise typer.Exit(1) from None
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _supervise_servers(
|
|
1234
|
+
uvicorn: ModuleType,
|
|
1235
|
+
application: object,
|
|
1236
|
+
frontend: subprocess.Popen[bytes],
|
|
1237
|
+
*,
|
|
1238
|
+
host: str,
|
|
1239
|
+
port: int,
|
|
1240
|
+
stop_frontend: Callable[[subprocess.Popen[bytes]], None],
|
|
1241
|
+
) -> None:
|
|
1242
|
+
server = uvicorn.Server(
|
|
1243
|
+
uvicorn.Config(application, host=host, port=port, access_log=False)
|
|
1244
|
+
)
|
|
1245
|
+
failures: list[BaseException] = []
|
|
1246
|
+
|
|
1247
|
+
def run_backend() -> None:
|
|
1248
|
+
try:
|
|
1249
|
+
server.run()
|
|
1250
|
+
except BaseException as error:
|
|
1251
|
+
failures.append(error)
|
|
1252
|
+
|
|
1253
|
+
backend = threading.Thread(target=run_backend, name="cli-consumption-api")
|
|
1254
|
+
backend.start()
|
|
1255
|
+
frontend_failed = False
|
|
1256
|
+
try:
|
|
1257
|
+
while backend.is_alive() and frontend.poll() is None:
|
|
1258
|
+
backend.join(timeout=0.1)
|
|
1259
|
+
frontend_failed = frontend.poll() is not None and backend.is_alive()
|
|
1260
|
+
except KeyboardInterrupt:
|
|
1261
|
+
pass
|
|
1262
|
+
finally:
|
|
1263
|
+
server.should_exit = True
|
|
1264
|
+
stop_frontend(frontend)
|
|
1265
|
+
backend.join(timeout=10)
|
|
1266
|
+
if backend.is_alive() or failures:
|
|
1267
|
+
typer.echo("Error: The API server stopped unexpectedly.", err=True)
|
|
1268
|
+
raise typer.Exit(1)
|
|
1269
|
+
if frontend_failed:
|
|
1270
|
+
typer.echo("Error: The dashboard server stopped unexpectedly.", err=True)
|
|
1271
|
+
raise typer.Exit(1)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _http_origin(host: str, port: int) -> str:
|
|
1275
|
+
rendered_host = f"[{host}]" if ":" in host else host
|
|
1276
|
+
return f"http://{rendered_host}:{port}"
|
|
1277
|
+
|
|
1278
|
+
|
|
1098
1279
|
def _collect_incrementally(
|
|
1099
1280
|
provider: str,
|
|
1100
1281
|
source_values: list[str] | None,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from collections.abc import Iterator, Mapping
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
MAX_RUNTIME_FILES = 5_000
|
|
16
|
+
MAX_RUNTIME_BYTES = 128 * 1024 * 1024
|
|
17
|
+
MAX_RUNTIME_ARCHIVE_BYTES = 32 * 1024 * 1024
|
|
18
|
+
MINIMUM_NODE = (20, 9)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FrontendRuntimeError(RuntimeError):
|
|
22
|
+
"""A fixed, content-free dashboard runtime failure."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_node_runtime() -> str:
|
|
26
|
+
node = shutil.which("node")
|
|
27
|
+
if node is None:
|
|
28
|
+
raise FrontendRuntimeError("frontend_node_missing")
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[node, "--version"],
|
|
32
|
+
check=False,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=5,
|
|
36
|
+
)
|
|
37
|
+
except (OSError, subprocess.SubprocessError):
|
|
38
|
+
raise FrontendRuntimeError("frontend_node_invalid") from None
|
|
39
|
+
match = re.fullmatch(r"v(\d+)\.(\d+)\.\d+\s*", result.stdout)
|
|
40
|
+
if result.returncode != 0 or match is None:
|
|
41
|
+
raise FrontendRuntimeError("frontend_node_invalid")
|
|
42
|
+
if (int(match.group(1)), int(match.group(2))) < MINIMUM_NODE:
|
|
43
|
+
raise FrontendRuntimeError("frontend_node_unsupported")
|
|
44
|
+
return node
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def materialize_frontend_runtime() -> Iterator[Path]:
|
|
49
|
+
try:
|
|
50
|
+
payload = files("cli_consumption").joinpath("web_runtime.zip").read_bytes()
|
|
51
|
+
except (FileNotFoundError, OSError):
|
|
52
|
+
raise FrontendRuntimeError("frontend_runtime_missing") from None
|
|
53
|
+
|
|
54
|
+
if len(payload) > MAX_RUNTIME_ARCHIVE_BYTES:
|
|
55
|
+
raise FrontendRuntimeError("frontend_runtime_invalid")
|
|
56
|
+
|
|
57
|
+
with tempfile.TemporaryDirectory(prefix="cli-consumption-web-") as directory:
|
|
58
|
+
destination = Path(directory)
|
|
59
|
+
try:
|
|
60
|
+
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
|
61
|
+
members = archive.infolist()
|
|
62
|
+
if (
|
|
63
|
+
len(members) > MAX_RUNTIME_FILES
|
|
64
|
+
or len({member.filename for member in members}) != len(members)
|
|
65
|
+
or sum(member.file_size for member in members) > MAX_RUNTIME_BYTES
|
|
66
|
+
or any(not _safe_runtime_member(member) for member in members)
|
|
67
|
+
):
|
|
68
|
+
raise FrontendRuntimeError("frontend_runtime_invalid")
|
|
69
|
+
for member in members:
|
|
70
|
+
target = destination.joinpath(*PurePosixPath(member.filename).parts)
|
|
71
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
with archive.open(member) as source, target.open("wb") as output:
|
|
73
|
+
shutil.copyfileobj(source, output)
|
|
74
|
+
except (OSError, zipfile.BadZipFile):
|
|
75
|
+
raise FrontendRuntimeError("frontend_runtime_invalid") from None
|
|
76
|
+
runtime = destination / "runtime" / "apps" / "web"
|
|
77
|
+
if not (runtime / "server.js").is_file():
|
|
78
|
+
raise FrontendRuntimeError("frontend_runtime_invalid")
|
|
79
|
+
yield runtime
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def frontend_environment(
|
|
83
|
+
*,
|
|
84
|
+
api_url: str,
|
|
85
|
+
origin: str,
|
|
86
|
+
host: str,
|
|
87
|
+
port: int,
|
|
88
|
+
password: str,
|
|
89
|
+
read_token: str,
|
|
90
|
+
export_token: str,
|
|
91
|
+
layout_token: str,
|
|
92
|
+
session_secret: str,
|
|
93
|
+
) -> dict[str, str]:
|
|
94
|
+
environment = {
|
|
95
|
+
name: value
|
|
96
|
+
for name in ("LANG", "LC_ALL", "PATH", "TMPDIR")
|
|
97
|
+
if (value := os.environ.get(name)) is not None
|
|
98
|
+
}
|
|
99
|
+
environment.update(
|
|
100
|
+
{
|
|
101
|
+
"CLI_CONSUMPTION_API_URL": api_url,
|
|
102
|
+
"CLI_CONSUMPTION_DASHBOARD_ORIGIN": origin,
|
|
103
|
+
"CLI_CONSUMPTION_DASHBOARD_PASSWORD": password,
|
|
104
|
+
"CLI_CONSUMPTION_EXPORT_TOKEN": export_token,
|
|
105
|
+
"CLI_CONSUMPTION_LAYOUT_TOKEN": layout_token,
|
|
106
|
+
"CLI_CONSUMPTION_READ_TOKEN": read_token,
|
|
107
|
+
"CLI_CONSUMPTION_SESSION_SECRET": session_secret,
|
|
108
|
+
"HOSTNAME": host,
|
|
109
|
+
"NEXT_TELEMETRY_DISABLED": "1",
|
|
110
|
+
"PORT": str(port),
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return environment
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start_frontend(
|
|
117
|
+
node: str, runtime: Path, environment: Mapping[str, str]
|
|
118
|
+
) -> subprocess.Popen[bytes]:
|
|
119
|
+
try:
|
|
120
|
+
return subprocess.Popen(
|
|
121
|
+
[node, "server.js"],
|
|
122
|
+
cwd=runtime,
|
|
123
|
+
env=dict(environment),
|
|
124
|
+
)
|
|
125
|
+
except OSError:
|
|
126
|
+
raise FrontendRuntimeError("frontend_start_failed") from None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stop_frontend(process: subprocess.Popen[bytes]) -> None:
|
|
130
|
+
try:
|
|
131
|
+
if process.poll() is not None:
|
|
132
|
+
return
|
|
133
|
+
process.terminate()
|
|
134
|
+
try:
|
|
135
|
+
process.wait(timeout=5)
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
process.kill()
|
|
138
|
+
process.wait(timeout=5)
|
|
139
|
+
except (OSError, subprocess.SubprocessError):
|
|
140
|
+
raise FrontendRuntimeError("frontend_stop_failed") from None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_runtime_member(member: zipfile.ZipInfo) -> bool:
|
|
144
|
+
path = PurePosixPath(member.filename)
|
|
145
|
+
file_type = member.external_attr >> 16 & 0o170000
|
|
146
|
+
return (
|
|
147
|
+
not member.is_dir()
|
|
148
|
+
and "\\" not in member.filename
|
|
149
|
+
and not path.is_absolute()
|
|
150
|
+
and path.parts[:1] == ("runtime",)
|
|
151
|
+
and ".." not in path.parts
|
|
152
|
+
and file_type in {0, 0o100000}
|
|
153
|
+
)
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/continue_cli.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|