cli-consumption 0.3.3__tar.gz → 0.4.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.
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/.gitignore +6 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/CHANGELOG.md +36 -1
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/PKG-INFO +22 -2
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/README.md +21 -1
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/pyproject.toml +2 -2
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/api.py +137 -17
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/cli.py +261 -6
- cli_consumption-0.4.0/src/cli_consumption/dashboard.py +813 -0
- cli_consumption-0.4.0/src/cli_consumption/dashboard_react.css +2 -0
- cli_consumption-0.4.0/src/cli_consumption/dashboard_react.js +9 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/reporting.py +109 -23
- cli_consumption-0.4.0/src/cli_consumption/reporting_api.py +750 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/schema.py +138 -33
- cli_consumption-0.4.0/src/cli_consumption/snapshot_extraction.py +252 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/sync.py +44 -2
- cli_consumption-0.3.3/src/cli_consumption/dashboard.py +0 -813
- cli_consumption-0.3.3/src/cli_consumption/dashboard_calculations.js +0 -543
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/LICENSE +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/NOTICE +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/__init__.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/_shared.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/aider.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/amazon_q.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/amp.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/cline.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/continue_cli.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/copilot.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/crush.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/cursor.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/gemini.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/goose.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/grok.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/kimi.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/mistral_vibe.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/openhands.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/plandex.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/qwen.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/registry.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/__init__.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/env.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/__init__.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/qualifications.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/retention.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/snapshot_files.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/timestamps.py +0 -0
|
@@ -12,6 +12,12 @@ reports/
|
|
|
12
12
|
*.sqlite
|
|
13
13
|
*.sqlite3
|
|
14
14
|
.env
|
|
15
|
+
node_modules/
|
|
16
|
+
apps/*/.next/
|
|
17
|
+
playwright-report/
|
|
18
|
+
test-results/
|
|
19
|
+
*.tsbuildinfo
|
|
20
|
+
packages/*/dist/
|
|
15
21
|
|
|
16
22
|
# Machine-specific orchestration context. These files must never be committed.
|
|
17
23
|
.agents/orchestrator.md
|
|
@@ -6,6 +6,39 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-09-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added a blocking browser CI gate that opens detailed and share-safe dashboards
|
|
14
|
+
directly from `file://`, exercises their controls, and rejects network activity.
|
|
15
|
+
- Defined the versioned, scoped, and bounded contracts for database upload, persistent
|
|
16
|
+
dashboard reporting, conversation pagination, and standalone web export.
|
|
17
|
+
- Added read-only, migration-free extraction of bounded snapshot-schema-v1 payloads
|
|
18
|
+
from current local SQLite collection databases, including WAL-consistent reads,
|
|
19
|
+
exact schema verification, provider grouping, and privacy-safe failures.
|
|
20
|
+
- Added `upload-db` with stable per-snapshot replay keys, required capability
|
|
21
|
+
negotiation, bounded retries, deterministic partial results, resumable uploads, and
|
|
22
|
+
strict fail-fast automation.
|
|
23
|
+
- Added scoped `read` and `export` bearer credentials plus strict, bounded reporting
|
|
24
|
+
endpoints for dashboard datasets, filter options, opaque stable pagination,
|
|
25
|
+
conversation detail, and self-contained HTML downloads.
|
|
26
|
+
- Added a locked TypeScript workspace with versioned dashboard contracts, pure shared
|
|
27
|
+
analytics, presentation helpers, Vitest parity coverage, ESM output, and a
|
|
28
|
+
reproducible network-free browser bundle included in Python wheels.
|
|
29
|
+
- Added an authenticated responsive Next.js dashboard with a server-only collector
|
|
30
|
+
credential, bounded reporting BFF, URL-safe filters, conversation pagination and
|
|
31
|
+
detail, shared analytics, accessibility checks, and desktop/mobile browser coverage.
|
|
32
|
+
- Replaced the classic offline renderer with the self-contained React/Tailwind runtime,
|
|
33
|
+
shared web primitives, streamed bounded dataset injection, reproducible packaged
|
|
34
|
+
assets, and detailed/share-safe `file://` browser coverage. The temporary
|
|
35
|
+
`--renderer` migration option was removed.
|
|
36
|
+
- Added persistent-dashboard offline downloads for the exact visible selection, with
|
|
37
|
+
detailed/share-safe profiles, a credential-isolating bounded BFF, private temporary
|
|
38
|
+
cleanup, fixed failures, and self-contained browser coverage.
|
|
39
|
+
|
|
40
|
+
## [0.3.3] - 2026-08-31
|
|
41
|
+
|
|
9
42
|
### Added
|
|
10
43
|
|
|
11
44
|
- Added deterministic, compressed offline snapshot files authenticated with Ed25519,
|
|
@@ -131,7 +164,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
131
164
|
|
|
132
165
|
- Refreshed the provider guide for the first minor release ([#26]).
|
|
133
166
|
|
|
134
|
-
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.
|
|
167
|
+
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...HEAD
|
|
168
|
+
[0.4.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.3...v0.4.0
|
|
169
|
+
[0.3.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...v0.3.3
|
|
135
170
|
[0.3.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.1...v0.3.2
|
|
136
171
|
[0.3.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.0...v0.3.1
|
|
137
172
|
[0.3.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.2.1...v0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cli-consumption
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -97,6 +97,20 @@ uv run cli-consumption export --output reports
|
|
|
97
97
|
Open `reports/dashboard.html` locally. It makes no network requests. Detailed CSV
|
|
98
98
|
tables are generated only when `--csv` is passed.
|
|
99
99
|
|
|
100
|
+
Dashboard development lives in the locked npm workspace under `packages/`. It builds
|
|
101
|
+
provider-neutral ESM analytics, shared React presentation primitives, and deterministic
|
|
102
|
+
React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
|
|
103
|
+
only offline renderer; installing or using the Python CLI does not require Node.js.
|
|
104
|
+
|
|
105
|
+
The authenticated persistent dashboard lives in `apps/web/`. It reads the same
|
|
106
|
+
minimized reporting contract through a server-side Next.js BFF: the browser never
|
|
107
|
+
receives a collector credential or a database connection string. Its **Export
|
|
108
|
+
offline** action downloads the exact visible selection as a self-contained detailed or
|
|
109
|
+
share-safe HTML file. For a production
|
|
110
|
+
setup, required environment variables, reverse-proxy constraints, and startup commands
|
|
111
|
+
are documented in the
|
|
112
|
+
[deployment guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/deployment.md#run-the-persistent-dashboard).
|
|
113
|
+
|
|
100
114
|
To collect one provider or select another database:
|
|
101
115
|
|
|
102
116
|
```bash
|
|
@@ -157,7 +171,8 @@ for ingestion, idempotency, migrations, report limits, and collector behavior.
|
|
|
157
171
|
| `collect` | Collect local or copied provider data into SQL. |
|
|
158
172
|
| `snapshot` | Create or ingest signed, compressed offline snapshot files. |
|
|
159
173
|
| `sync` | Collect and send metadata-only snapshots to a central API. |
|
|
160
|
-
| `
|
|
174
|
+
| `upload-db` | Upload validated snapshots reconstructed from a local collect database. |
|
|
175
|
+
| `serve` | Run the scoped central ingestion, reporting, and export API. |
|
|
161
176
|
| `export` | Write the HTML dashboard and optional CSV tables. |
|
|
162
177
|
| `providers` | List provider names and compatibility status. |
|
|
163
178
|
| `retention` | Preview or apply deletion outside a retention window. |
|
|
@@ -188,6 +203,11 @@ uv run ruff check .
|
|
|
188
203
|
uv run ty check
|
|
189
204
|
uv run pytest --cov --cov-report=term-missing
|
|
190
205
|
uv build
|
|
206
|
+
npm ci
|
|
207
|
+
npm run verify
|
|
208
|
+
npm audit --audit-level=high
|
|
209
|
+
npm run build:web
|
|
210
|
+
npm run test:e2e
|
|
191
211
|
```
|
|
192
212
|
|
|
193
213
|
Development uses short-lived branches and squash-merged pull requests into protected
|
|
@@ -58,6 +58,20 @@ uv run cli-consumption export --output reports
|
|
|
58
58
|
Open `reports/dashboard.html` locally. It makes no network requests. Detailed CSV
|
|
59
59
|
tables are generated only when `--csv` is passed.
|
|
60
60
|
|
|
61
|
+
Dashboard development lives in the locked npm workspace under `packages/`. It builds
|
|
62
|
+
provider-neutral ESM analytics, shared React presentation primitives, and deterministic
|
|
63
|
+
React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
|
|
64
|
+
only offline renderer; installing or using the Python CLI does not require Node.js.
|
|
65
|
+
|
|
66
|
+
The authenticated persistent dashboard lives in `apps/web/`. It reads the same
|
|
67
|
+
minimized reporting contract through a server-side Next.js BFF: the browser never
|
|
68
|
+
receives a collector credential or a database connection string. Its **Export
|
|
69
|
+
offline** action downloads the exact visible selection as a self-contained detailed or
|
|
70
|
+
share-safe HTML file. For a production
|
|
71
|
+
setup, required environment variables, reverse-proxy constraints, and startup commands
|
|
72
|
+
are documented in the
|
|
73
|
+
[deployment guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/deployment.md#run-the-persistent-dashboard).
|
|
74
|
+
|
|
61
75
|
To collect one provider or select another database:
|
|
62
76
|
|
|
63
77
|
```bash
|
|
@@ -118,7 +132,8 @@ for ingestion, idempotency, migrations, report limits, and collector behavior.
|
|
|
118
132
|
| `collect` | Collect local or copied provider data into SQL. |
|
|
119
133
|
| `snapshot` | Create or ingest signed, compressed offline snapshot files. |
|
|
120
134
|
| `sync` | Collect and send metadata-only snapshots to a central API. |
|
|
121
|
-
| `
|
|
135
|
+
| `upload-db` | Upload validated snapshots reconstructed from a local collect database. |
|
|
136
|
+
| `serve` | Run the scoped central ingestion, reporting, and export API. |
|
|
122
137
|
| `export` | Write the HTML dashboard and optional CSV tables. |
|
|
123
138
|
| `providers` | List provider names and compatibility status. |
|
|
124
139
|
| `retention` | Preview or apply deletion outside a retention window. |
|
|
@@ -149,6 +164,11 @@ uv run ruff check .
|
|
|
149
164
|
uv run ty check
|
|
150
165
|
uv run pytest --cov --cov-report=term-missing
|
|
151
166
|
uv build
|
|
167
|
+
npm ci
|
|
168
|
+
npm run verify
|
|
169
|
+
npm audit --audit-level=high
|
|
170
|
+
npm run build:web
|
|
171
|
+
npm run test:e2e
|
|
152
172
|
```
|
|
153
173
|
|
|
154
174
|
Development uses short-lived branches and squash-merged pull requests into protected
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-consumption"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Analyze and consolidate AI coding CLI consumption across machines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -54,6 +54,7 @@ dev = [
|
|
|
54
54
|
"httpx>=0.27",
|
|
55
55
|
"hypothesis>=6.165.10",
|
|
56
56
|
"pip-audit>=2.10.1",
|
|
57
|
+
"playwright>=1.55",
|
|
57
58
|
"pre-commit>=4.6.2",
|
|
58
59
|
"psycopg[binary]>=3.2",
|
|
59
60
|
"pytest>=8.3",
|
|
@@ -110,6 +111,5 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "S"]
|
|
|
110
111
|
"tests/**" = ["S101"]
|
|
111
112
|
"tests/smoke_minimal_install.py" = ["S603", "S607"]
|
|
112
113
|
"tests/test_codex_adapter.py" = ["S608"]
|
|
113
|
-
"tests/test_dashboard_calculations.py" = ["S607"]
|
|
114
114
|
"tests/test_demo.py" = ["S603"]
|
|
115
115
|
"tests/test_packaging.py" = ["S603", "S607"]
|
|
@@ -27,6 +27,22 @@ from cli_consumption.models import (
|
|
|
27
27
|
SnapshotPayload,
|
|
28
28
|
SnapshotValidationError,
|
|
29
29
|
)
|
|
30
|
+
from cli_consumption.reporting_api import (
|
|
31
|
+
CACHE_HEADERS,
|
|
32
|
+
EXPORT_TIMEOUT_SECONDS,
|
|
33
|
+
MAX_CONCURRENT_EXPORTS,
|
|
34
|
+
MAX_CONCURRENT_REPORTS,
|
|
35
|
+
MAX_CONVERSATION_PAGE_SIZE,
|
|
36
|
+
MAX_DASHBOARD_RESPONSE_BYTES,
|
|
37
|
+
MAX_EXPORT_RESPONSE_BYTES,
|
|
38
|
+
MAX_FILTER_VALUES,
|
|
39
|
+
MAX_PAGINATION_SESSION_BYTES,
|
|
40
|
+
MAX_REPORTING_RECORDS,
|
|
41
|
+
MAX_REPORTING_SCALAR_BYTES,
|
|
42
|
+
REPORTING_REQUEST_BYTES,
|
|
43
|
+
REPORTING_TIMEOUT_SECONDS,
|
|
44
|
+
install_reporting_routes,
|
|
45
|
+
)
|
|
30
46
|
from cli_consumption.schema import CURRENT_DATABASE_REVISION
|
|
31
47
|
from cli_consumption.storage import (
|
|
32
48
|
create_postgresql_readiness_engine,
|
|
@@ -73,6 +89,11 @@ SAFE_ROUTES = frozenset(
|
|
|
73
89
|
"/ready",
|
|
74
90
|
"/api/v1/capabilities",
|
|
75
91
|
"/api/v1/snapshots",
|
|
92
|
+
"/api/v1/reporting/dashboard",
|
|
93
|
+
"/api/v1/reporting/filters",
|
|
94
|
+
"/api/v1/reporting/conversations",
|
|
95
|
+
"/api/v1/reporting/conversation",
|
|
96
|
+
"/api/v1/reporting/export",
|
|
76
97
|
}
|
|
77
98
|
)
|
|
78
99
|
SAFE_EXCEPTION_TYPES = frozenset(
|
|
@@ -311,10 +332,13 @@ class SafeExceptionBoundary:
|
|
|
311
332
|
error=error,
|
|
312
333
|
)
|
|
313
334
|
if not response_started:
|
|
335
|
+
headers = {"X-Request-ID": request_id}
|
|
336
|
+
if str(scope.get("path", "")).startswith("/api/v1/reporting/"):
|
|
337
|
+
headers.update(CACHE_HEADERS)
|
|
314
338
|
response = JSONResponse(
|
|
315
339
|
status_code=500,
|
|
316
340
|
content={"detail": "internal_server_error"},
|
|
317
|
-
headers=
|
|
341
|
+
headers=headers,
|
|
318
342
|
)
|
|
319
343
|
await response(scope, receive, send)
|
|
320
344
|
|
|
@@ -330,11 +354,16 @@ class RequestSizeLimitMiddleware:
|
|
|
330
354
|
if scope["type"] != "http":
|
|
331
355
|
await self.app(scope, receive, send)
|
|
332
356
|
return
|
|
357
|
+
maximum = (
|
|
358
|
+
REPORTING_REQUEST_BYTES
|
|
359
|
+
if str(scope.get("path", "")).startswith("/api/v1/reporting/")
|
|
360
|
+
else self.maximum
|
|
361
|
+
)
|
|
333
362
|
headers = dict(scope.get("headers", []))
|
|
334
363
|
content_length = headers.get(b"content-length")
|
|
335
364
|
if content_length is not None:
|
|
336
365
|
try:
|
|
337
|
-
if int(content_length) >
|
|
366
|
+
if int(content_length) > maximum:
|
|
338
367
|
await self._reject(scope, receive, send)
|
|
339
368
|
return
|
|
340
369
|
except ValueError:
|
|
@@ -348,7 +377,7 @@ class RequestSizeLimitMiddleware:
|
|
|
348
377
|
message = await receive()
|
|
349
378
|
if message["type"] == "http.request":
|
|
350
379
|
received += len(message.get("body", b""))
|
|
351
|
-
if received >
|
|
380
|
+
if received > maximum:
|
|
352
381
|
raise RequestTooLarge
|
|
353
382
|
return message
|
|
354
383
|
|
|
@@ -359,14 +388,56 @@ class RequestSizeLimitMiddleware:
|
|
|
359
388
|
|
|
360
389
|
@staticmethod
|
|
361
390
|
async def _reject(scope: Scope, receive: Receive, send: Send) -> None:
|
|
391
|
+
headers = (
|
|
392
|
+
CACHE_HEADERS
|
|
393
|
+
if str(scope.get("path", "")).startswith("/api/v1/reporting/")
|
|
394
|
+
else None
|
|
395
|
+
)
|
|
362
396
|
response = JSONResponse(
|
|
363
397
|
status_code=413,
|
|
364
398
|
content={"detail": "request_too_large"},
|
|
399
|
+
headers=headers,
|
|
365
400
|
)
|
|
366
401
|
await response(scope, receive, send)
|
|
367
402
|
|
|
368
403
|
|
|
369
|
-
def
|
|
404
|
+
def _configured_credentials(
|
|
405
|
+
api_token: str | None,
|
|
406
|
+
read_token: str | None,
|
|
407
|
+
export_token: str | None,
|
|
408
|
+
) -> list[tuple[str, frozenset[str]]]:
|
|
409
|
+
credentials: list[tuple[str, frozenset[str]]] = []
|
|
410
|
+
configured_values: set[str] = set()
|
|
411
|
+
for credential, scopes in (
|
|
412
|
+
(api_token, frozenset({"ingest"})),
|
|
413
|
+
(read_token, frozenset({"read"})),
|
|
414
|
+
(export_token, frozenset({"read", "export"})),
|
|
415
|
+
):
|
|
416
|
+
if credential is None:
|
|
417
|
+
continue
|
|
418
|
+
if (
|
|
419
|
+
not credential
|
|
420
|
+
or len(credential.encode("utf-8")) > 4_096
|
|
421
|
+
or any(
|
|
422
|
+
character.isspace() or ord(character) < 32 for character in credential
|
|
423
|
+
)
|
|
424
|
+
):
|
|
425
|
+
raise ValueError("invalid API credential configuration")
|
|
426
|
+
if credential in configured_values:
|
|
427
|
+
raise ValueError("invalid API credential configuration")
|
|
428
|
+
configured_values.add(credential)
|
|
429
|
+
credentials.append((credential, scopes))
|
|
430
|
+
return credentials
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def create_app(
|
|
434
|
+
engine: Engine,
|
|
435
|
+
api_token: str | None = None,
|
|
436
|
+
*,
|
|
437
|
+
read_token: str | None = None,
|
|
438
|
+
export_token: str | None = None,
|
|
439
|
+
) -> SafeExceptionBoundary:
|
|
440
|
+
credentials = _configured_credentials(api_token, read_token, export_token)
|
|
370
441
|
initialize_database(engine)
|
|
371
442
|
if engine.dialect.name == "postgresql":
|
|
372
443
|
probe_engine = create_postgresql_readiness_engine(
|
|
@@ -386,8 +457,14 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
|
|
|
386
457
|
|
|
387
458
|
@app.exception_handler(RequestValidationError)
|
|
388
459
|
async def invalid_request(
|
|
389
|
-
|
|
460
|
+
request: Request, error: RequestValidationError
|
|
390
461
|
) -> JSONResponse:
|
|
462
|
+
if request.url.path.startswith("/api/v1/reporting/"):
|
|
463
|
+
return JSONResponse(
|
|
464
|
+
status_code=422,
|
|
465
|
+
content={"detail": "invalid_reporting_request"},
|
|
466
|
+
headers=CACHE_HEADERS,
|
|
467
|
+
)
|
|
391
468
|
code = (
|
|
392
469
|
"unsupported_schema_version"
|
|
393
470
|
if any(
|
|
@@ -403,21 +480,42 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
|
|
|
403
480
|
|
|
404
481
|
@app.exception_handler(Exception)
|
|
405
482
|
async def internal_error(request: Request, error: Exception) -> JSONResponse:
|
|
483
|
+
headers = {"X-Request-ID": request.state.request_id}
|
|
484
|
+
if request.url.path.startswith("/api/v1/reporting/"):
|
|
485
|
+
headers.update(CACHE_HEADERS)
|
|
406
486
|
return JSONResponse(
|
|
407
487
|
status_code=500,
|
|
408
488
|
content={"detail": "internal_server_error"},
|
|
409
|
-
headers=
|
|
489
|
+
headers=headers,
|
|
410
490
|
)
|
|
411
491
|
|
|
412
|
-
def
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
492
|
+
def require_scopes(*required: str) -> Any:
|
|
493
|
+
required_scopes = frozenset(required)
|
|
494
|
+
|
|
495
|
+
def authorize(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
496
|
+
if not credentials:
|
|
497
|
+
return
|
|
498
|
+
scheme, separator, supplied = (authorization or "").partition(" ")
|
|
499
|
+
if not separator or scheme.casefold() != "bearer":
|
|
500
|
+
supplied = ""
|
|
501
|
+
matched_scopes: set[str] = set()
|
|
502
|
+
for credential, scopes in credentials:
|
|
503
|
+
if secrets.compare_digest(supplied, credential):
|
|
504
|
+
matched_scopes.update(scopes)
|
|
505
|
+
if not matched_scopes:
|
|
506
|
+
raise HTTPException(
|
|
507
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
508
|
+
detail="authentication_required",
|
|
509
|
+
headers=CACHE_HEADERS,
|
|
510
|
+
)
|
|
511
|
+
if not required_scopes.issubset(matched_scopes):
|
|
512
|
+
raise HTTPException(
|
|
513
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
514
|
+
detail="authorization_denied",
|
|
515
|
+
headers=CACHE_HEADERS,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return authorize
|
|
421
519
|
|
|
422
520
|
@app.get("/health")
|
|
423
521
|
def health() -> dict[str, int | str]:
|
|
@@ -446,16 +544,31 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
|
|
|
446
544
|
return JSONResponse(content={"status": "ready"})
|
|
447
545
|
|
|
448
546
|
@app.get("/api/v1/capabilities")
|
|
449
|
-
def capabilities() -> dict[str,
|
|
547
|
+
def capabilities() -> dict[str, Any]:
|
|
450
548
|
return {
|
|
451
549
|
"snapshot_schema_min": MIN_SUPPORTED_SNAPSHOT_SCHEMA,
|
|
452
550
|
"snapshot_schema_max": CURRENT_SNAPSHOT_SCHEMA,
|
|
453
551
|
"max_request_bytes": MAX_REQUEST_BYTES,
|
|
454
552
|
"max_snapshot_records": MAX_SNAPSHOT_RECORDS,
|
|
455
553
|
"idempotent_snapshot_uploads": True,
|
|
554
|
+
"dashboard_query_versions": [1],
|
|
555
|
+
"dashboard_dataset_versions": [1],
|
|
556
|
+
"cursor_versions": [1],
|
|
557
|
+
"max_reporting_request_bytes": REPORTING_REQUEST_BYTES,
|
|
558
|
+
"max_reporting_filter_values": MAX_FILTER_VALUES,
|
|
559
|
+
"max_reporting_records": MAX_REPORTING_RECORDS,
|
|
560
|
+
"max_reporting_scalar_bytes": MAX_REPORTING_SCALAR_BYTES,
|
|
561
|
+
"max_dashboard_response_bytes": MAX_DASHBOARD_RESPONSE_BYTES,
|
|
562
|
+
"max_export_response_bytes": MAX_EXPORT_RESPONSE_BYTES,
|
|
563
|
+
"max_conversation_page_size": MAX_CONVERSATION_PAGE_SIZE,
|
|
564
|
+
"max_pagination_session_bytes": MAX_PAGINATION_SESSION_BYTES,
|
|
565
|
+
"max_concurrent_reporting_reads": MAX_CONCURRENT_REPORTS,
|
|
566
|
+
"max_concurrent_exports": MAX_CONCURRENT_EXPORTS,
|
|
567
|
+
"reporting_timeout_seconds": REPORTING_TIMEOUT_SECONDS,
|
|
568
|
+
"export_timeout_seconds": EXPORT_TIMEOUT_SECONDS,
|
|
456
569
|
}
|
|
457
570
|
|
|
458
|
-
@app.post("/api/v1/snapshots", dependencies=[Depends(
|
|
571
|
+
@app.post("/api/v1/snapshots", dependencies=[Depends(require_scopes("ingest"))])
|
|
459
572
|
def receive_snapshot(
|
|
460
573
|
payload: SnapshotPayload,
|
|
461
574
|
idempotency_key: Annotated[
|
|
@@ -481,4 +594,11 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
|
|
|
481
594
|
"skipped": result.skipped,
|
|
482
595
|
}
|
|
483
596
|
|
|
597
|
+
install_reporting_routes(
|
|
598
|
+
app,
|
|
599
|
+
engine,
|
|
600
|
+
authorize_read=require_scopes("read"),
|
|
601
|
+
authorize_export=require_scopes("read", "export"),
|
|
602
|
+
)
|
|
603
|
+
|
|
484
604
|
return SafeExceptionBoundary(app)
|