cli-consumption 0.4.1__tar.gz → 0.4.3__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.1 → cli_consumption-0.4.3}/CHANGELOG.md +25 -1
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/PKG-INFO +7 -2
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/README.md +6 -1
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/pyproject.toml +1 -1
- cli_consumption-0.4.3/src/cli_consumption/INTER_FONT_LICENSE.txt +93 -0
- cli_consumption-0.4.3/src/cli_consumption/adapters/base.py +43 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/codex.py +144 -34
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/api.py +23 -3
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/cli.py +327 -24
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/dashboard.py +37 -1
- cli_consumption-0.4.3/src/cli_consumption/dashboard_layouts.py +345 -0
- cli_consumption-0.4.3/src/cli_consumption/dashboard_react.css +2 -0
- cli_consumption-0.4.3/src/cli_consumption/dashboard_react.js +9 -0
- cli_consumption-0.4.3/src/cli_consumption/migrations/versions/v0006_dashboard_layouts.py +28 -0
- cli_consumption-0.4.3/src/cli_consumption/migrations/versions/v0007_dashboard_layout_revision.py +39 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/reporting_api.py +117 -18
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/schema.py +83 -8
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/storage.py +50 -15
- cli_consumption-0.4.1/src/cli_consumption/adapters/base.py +0 -22
- cli_consumption-0.4.1/src/cli_consumption/dashboard_react.css +0 -2
- cli_consumption-0.4.1/src/cli_consumption/dashboard_react.js +0 -9
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/.gitignore +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/LICENSE +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/NOTICE +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/__init__.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/_shared.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/aider.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/amazon_q.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/amp.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/cline.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/continue_cli.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/copilot.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/crush.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/cursor.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/gemini.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/goose.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/grok.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/kimi.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/mistral_vibe.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/openhands.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/plandex.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/qwen.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/registry.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/__init__.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/env.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/__init__.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/qualifications.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/reporting.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/retention.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/snapshot_extraction.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/snapshot_files.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/timestamps.py +0 -0
|
@@ -6,6 +6,28 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.3] - 2026-09-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added opt-in `collect --incremental` automatic bounded batching for large Codex
|
|
14
|
+
stores, with restart-safe ingestion, strict metadata-only preflight staging,
|
|
15
|
+
deterministic duplicate convergence, privacy-safe partial results, and unchanged
|
|
16
|
+
per-file and per-line safety limits; authoritative subagent graphs remain untouched.
|
|
17
|
+
|
|
18
|
+
## [0.4.2] - 2026-09-05
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added reviewed autonomous web exports that freeze the visible dashboard layout,
|
|
23
|
+
bounded selection, privacy profile, and theme into a CSP-locked standalone file while
|
|
24
|
+
preserving query-only API and CLI export compatibility.
|
|
25
|
+
- Added the provider-neutral `DashboardLayout v1` contract, a bounded widget registry,
|
|
26
|
+
deterministic defaults and retired-widget recovery, plus SQLite/PostgreSQL
|
|
27
|
+
persistence for the single authenticated dashboard operator.
|
|
28
|
+
- Added an explicit, accessible dashboard edit mode with bounded undo/redo, deterministic
|
|
29
|
+
placement, and ETag/`If-Match` conflict protection for concurrent save and reset.
|
|
30
|
+
|
|
9
31
|
## [0.4.1] - 2026-09-01
|
|
10
32
|
|
|
11
33
|
### Changed
|
|
@@ -172,7 +194,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
172
194
|
|
|
173
195
|
- Refreshed the provider guide for the first minor release ([#26]).
|
|
174
196
|
|
|
175
|
-
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.
|
|
197
|
+
[Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.3...HEAD
|
|
198
|
+
[0.4.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.2...v0.4.3
|
|
199
|
+
[0.4.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.1...v0.4.2
|
|
176
200
|
[0.4.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...v0.4.1
|
|
177
201
|
[0.4.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.3...v0.4.0
|
|
178
202
|
[0.3.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...v0.3.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cli-consumption
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
|
@@ -117,6 +117,10 @@ To collect one provider or select another database:
|
|
|
117
117
|
uv run cli-consumption collect --provider codex --database usage.sqlite
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
For a Codex store that exceeds aggregate collection limits, add `--incremental`.
|
|
121
|
+
The command automatically writes bounded, restart-safe batches to the same database;
|
|
122
|
+
individual file and line safety limits remain enforced.
|
|
123
|
+
|
|
120
124
|
Use `--source [LABEL=]PATH` for trusted offline copies and repeated
|
|
121
125
|
`--project NAME=PATH_PREFIX` mappings for stable project labels. See the
|
|
122
126
|
[usage and operations guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
@@ -145,7 +149,8 @@ uv run cli-consumption providers --json
|
|
|
145
149
|
|
|
146
150
|
- Provider files are untrusted. Collection enforces discovery, file, line, SQLite row,
|
|
147
151
|
structured-field, and total normalized-record limits; direct provider-file symlinks
|
|
148
|
-
are refused.
|
|
152
|
+
are refused. Opt-in Codex incremental collection resets only aggregate per-batch
|
|
153
|
+
limits and keeps a separate hard batch-count ceiling.
|
|
149
154
|
- `collect --strict` and `sync --strict` refuse a batch when any provider skipped
|
|
150
155
|
malformed records. Collection distinguishes `provider_limit_exceeded`,
|
|
151
156
|
`provider_format_incompatible`, `invalid_snapshot`, and the unexpected-failure
|
|
@@ -78,6 +78,10 @@ To collect one provider or select another database:
|
|
|
78
78
|
uv run cli-consumption collect --provider codex --database usage.sqlite
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
For a Codex store that exceeds aggregate collection limits, add `--incremental`.
|
|
82
|
+
The command automatically writes bounded, restart-safe batches to the same database;
|
|
83
|
+
individual file and line safety limits remain enforced.
|
|
84
|
+
|
|
81
85
|
Use `--source [LABEL=]PATH` for trusted offline copies and repeated
|
|
82
86
|
`--project NAME=PATH_PREFIX` mappings for stable project labels. See the
|
|
83
87
|
[usage and operations guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
|
|
@@ -106,7 +110,8 @@ uv run cli-consumption providers --json
|
|
|
106
110
|
|
|
107
111
|
- Provider files are untrusted. Collection enforces discovery, file, line, SQLite row,
|
|
108
112
|
structured-field, and total normalized-record limits; direct provider-file symlinks
|
|
109
|
-
are refused.
|
|
113
|
+
are refused. Opt-in Codex incremental collection resets only aggregate per-batch
|
|
114
|
+
limits and keeps a separate hard batch-count ceiling.
|
|
110
115
|
- `collect --strict` and `sync --strict` refuse a batch when any provider skipped
|
|
111
116
|
malformed records. Collection distinguishes `provider_limit_exceeded`,
|
|
112
117
|
`provider_format_incompatible`, `invalid_snapshot`, and the unexpected-failure
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from cli_consumption.models import Snapshot
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class CollectionBatch:
|
|
13
|
+
"""One snapshot plus an optional subagent-scope authority override."""
|
|
14
|
+
|
|
15
|
+
snapshot: Snapshot
|
|
16
|
+
authoritative_subagent_scopes: frozenset[tuple[str, str]] | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsupportedProviderFormat(ValueError):
|
|
20
|
+
"""Raised when a detected provider store has an incompatible schema."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Adapter(Protocol):
|
|
24
|
+
"""Contract implemented by every supported AI CLI."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def collect(
|
|
29
|
+
self,
|
|
30
|
+
sources: list[tuple[str, Path]],
|
|
31
|
+
project_mappings: list[tuple[str, str]],
|
|
32
|
+
) -> Snapshot: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class IncrementalAdapter(Adapter, Protocol):
|
|
37
|
+
"""Optional adapter contract for bounded, independently ingestible batches."""
|
|
38
|
+
|
|
39
|
+
def collect_incrementally(
|
|
40
|
+
self,
|
|
41
|
+
sources: list[tuple[str, Path]],
|
|
42
|
+
project_mappings: list[tuple[str, str]],
|
|
43
|
+
) -> Iterator[CollectionBatch]: ...
|
|
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import math
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import sqlite3
|
|
9
|
+
from collections.abc import Iterator
|
|
8
10
|
from datetime import UTC, datetime
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import Any
|
|
@@ -13,11 +15,18 @@ from cli_consumption.adapters._shared import (
|
|
|
13
15
|
MAX_BIGINT as MAX_BIGINT,
|
|
14
16
|
)
|
|
15
17
|
from cli_consumption.adapters._shared import (
|
|
18
|
+
ProviderDataLimitError,
|
|
16
19
|
ProviderInputBudget,
|
|
17
20
|
iter_bounded_jsonl_bytes,
|
|
18
21
|
open_provider_sqlite,
|
|
19
22
|
)
|
|
20
|
-
from cli_consumption.
|
|
23
|
+
from cli_consumption.adapters.base import CollectionBatch
|
|
24
|
+
from cli_consumption.models import (
|
|
25
|
+
TOKEN_FIELDS,
|
|
26
|
+
Snapshot,
|
|
27
|
+
SnapshotValidationError,
|
|
28
|
+
empty_tokens,
|
|
29
|
+
)
|
|
21
30
|
|
|
22
31
|
OUTSIDE_PROJECT = "outside-project"
|
|
23
32
|
TOOL_PATTERN = re.compile(r"(?:tools|collaboration)\.([A-Za-z][A-Za-z0-9_]*)\s*\(")
|
|
@@ -85,6 +94,24 @@ AGENT_ROLE_ALIASES = {
|
|
|
85
94
|
"worker": "worker",
|
|
86
95
|
}
|
|
87
96
|
SAFE_DIMENSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
|
|
97
|
+
INCREMENTAL_CANDIDATES_PER_BATCH = 1_000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _iter_session_files(root: Path) -> Iterator[Path]:
|
|
101
|
+
"""Walk a provider tree deterministically without materializing every path."""
|
|
102
|
+
pending = [root]
|
|
103
|
+
while pending:
|
|
104
|
+
directory = pending.pop()
|
|
105
|
+
with os.scandir(directory) as entries:
|
|
106
|
+
ordered = sorted(entries, key=lambda entry: entry.name)
|
|
107
|
+
child_directories: list[Path] = []
|
|
108
|
+
for entry in ordered:
|
|
109
|
+
path = Path(entry.path)
|
|
110
|
+
if entry.is_dir(follow_symlinks=False):
|
|
111
|
+
child_directories.append(path)
|
|
112
|
+
elif entry.name.endswith(".jsonl"):
|
|
113
|
+
yield path
|
|
114
|
+
pending.extend(reversed(child_directories))
|
|
88
115
|
|
|
89
116
|
|
|
90
117
|
def parse_timestamp(value: object) -> datetime | None:
|
|
@@ -166,6 +193,73 @@ class CodexAdapter:
|
|
|
166
193
|
)
|
|
167
194
|
return snapshot
|
|
168
195
|
|
|
196
|
+
def collect_incrementally(
|
|
197
|
+
self,
|
|
198
|
+
sources: list[tuple[str, Path]],
|
|
199
|
+
project_mappings: list[tuple[str, str]] | None = None,
|
|
200
|
+
) -> Iterator[CollectionBatch]:
|
|
201
|
+
"""Yield deterministic, bounded snapshots from arbitrarily many rollouts."""
|
|
202
|
+
mappings = project_mappings or []
|
|
203
|
+
for machine, codex_home in sources:
|
|
204
|
+
sessions = codex_home / "sessions"
|
|
205
|
+
if not sessions.is_dir():
|
|
206
|
+
raise ValueError("Missing Codex sessions directory")
|
|
207
|
+
|
|
208
|
+
yielded_sessions = False
|
|
209
|
+
batch: list[tuple[str, Path]] = []
|
|
210
|
+
for path in _iter_session_files(sessions):
|
|
211
|
+
batch.append((machine, path))
|
|
212
|
+
if len(batch) == INCREMENTAL_CANDIDATES_PER_BATCH:
|
|
213
|
+
yielded_sessions = True
|
|
214
|
+
yield from self._collect_incremental_batch(batch, mappings)
|
|
215
|
+
batch = []
|
|
216
|
+
if batch:
|
|
217
|
+
yielded_sessions = True
|
|
218
|
+
yield from self._collect_incremental_batch(batch, mappings)
|
|
219
|
+
|
|
220
|
+
if not yielded_sessions:
|
|
221
|
+
yield CollectionBatch(Snapshot(provider=self.name), frozenset())
|
|
222
|
+
|
|
223
|
+
def _collect_incremental_batch(
|
|
224
|
+
self,
|
|
225
|
+
candidates: list[tuple[str, Path]],
|
|
226
|
+
mappings: list[tuple[str, str]],
|
|
227
|
+
) -> Iterator[CollectionBatch]:
|
|
228
|
+
try:
|
|
229
|
+
yield CollectionBatch(
|
|
230
|
+
self._collect_candidates(candidates, mappings), frozenset()
|
|
231
|
+
)
|
|
232
|
+
except ProviderDataLimitError as error:
|
|
233
|
+
if str(error) != "provider_read_limit_exceeded" or len(candidates) == 1:
|
|
234
|
+
raise
|
|
235
|
+
midpoint = len(candidates) // 2
|
|
236
|
+
yield from self._collect_incremental_batch(candidates[:midpoint], mappings)
|
|
237
|
+
yield from self._collect_incremental_batch(candidates[midpoint:], mappings)
|
|
238
|
+
except SnapshotValidationError as error:
|
|
239
|
+
if error.code != "snapshot_too_large" or len(candidates) == 1:
|
|
240
|
+
raise
|
|
241
|
+
midpoint = len(candidates) // 2
|
|
242
|
+
yield from self._collect_incremental_batch(candidates[:midpoint], mappings)
|
|
243
|
+
yield from self._collect_incremental_batch(candidates[midpoint:], mappings)
|
|
244
|
+
|
|
245
|
+
def _collect_candidates(
|
|
246
|
+
self,
|
|
247
|
+
candidates: list[tuple[str, Path]],
|
|
248
|
+
mappings: list[tuple[str, str]],
|
|
249
|
+
) -> Snapshot:
|
|
250
|
+
budget = ProviderInputBudget()
|
|
251
|
+
selected, duplicates, malformed = self._discover_candidates(candidates, budget)
|
|
252
|
+
snapshot = Snapshot(
|
|
253
|
+
provider=self.name,
|
|
254
|
+
duplicate_conversations=duplicates,
|
|
255
|
+
malformed_records=malformed,
|
|
256
|
+
)
|
|
257
|
+
for machine, path, event_count, digest in selected:
|
|
258
|
+
self._read_rollout(
|
|
259
|
+
snapshot, machine, path, event_count, digest, mappings, budget
|
|
260
|
+
)
|
|
261
|
+
return snapshot
|
|
262
|
+
|
|
169
263
|
def _read_subagents(
|
|
170
264
|
self,
|
|
171
265
|
state_path: Path,
|
|
@@ -215,46 +309,62 @@ class CodexAdapter:
|
|
|
215
309
|
def _discover(
|
|
216
310
|
self, sources: list[tuple[str, Path]], budget: ProviderInputBudget
|
|
217
311
|
) -> tuple[list[tuple[str, Path, int, str]], int, int]:
|
|
218
|
-
|
|
219
|
-
duplicates = 0
|
|
220
|
-
malformed = 0
|
|
312
|
+
candidates: list[tuple[str, Path]] = []
|
|
221
313
|
for machine, codex_home in sources:
|
|
222
314
|
sessions = codex_home / "sessions"
|
|
223
315
|
if not sessions.is_dir():
|
|
224
316
|
raise ValueError(f"Missing Codex sessions directory: {sessions}")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
317
|
+
candidates.extend(
|
|
318
|
+
(machine, path)
|
|
319
|
+
for path in budget.sorted_paths(sessions.rglob("*.jsonl"))
|
|
320
|
+
)
|
|
321
|
+
return self._discover_candidates(candidates, budget, charge_candidates=False)
|
|
322
|
+
|
|
323
|
+
def _discover_candidates(
|
|
324
|
+
self,
|
|
325
|
+
candidates: list[tuple[str, Path]],
|
|
326
|
+
budget: ProviderInputBudget,
|
|
327
|
+
*,
|
|
328
|
+
charge_candidates: bool = True,
|
|
329
|
+
) -> tuple[list[tuple[str, Path, int, str]], int, int]:
|
|
330
|
+
selected: dict[str, tuple[str, Path, int, str]] = {}
|
|
331
|
+
duplicates = 0
|
|
332
|
+
malformed = 0
|
|
333
|
+
for machine, path in candidates:
|
|
334
|
+
if charge_candidates:
|
|
335
|
+
budget.item()
|
|
336
|
+
event_count = 0
|
|
337
|
+
conversation_id = ""
|
|
338
|
+
digest = hashlib.sha256()
|
|
339
|
+
for raw_line in iter_bounded_jsonl_bytes(path, budget):
|
|
340
|
+
digest.update(raw_line)
|
|
341
|
+
try:
|
|
342
|
+
event = json.loads(raw_line)
|
|
343
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
344
|
+
malformed += 1
|
|
345
|
+
continue
|
|
346
|
+
if not isinstance(event, dict):
|
|
347
|
+
malformed += 1
|
|
348
|
+
continue
|
|
349
|
+
event_count += 1
|
|
350
|
+
if event.get("type") == "session_meta":
|
|
351
|
+
payload = event.get("payload")
|
|
352
|
+
if not isinstance(payload, dict):
|
|
237
353
|
malformed += 1
|
|
238
354
|
continue
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
candidate
|
|
251
|
-
previous = selected.get(conversation_id)
|
|
252
|
-
if previous is None:
|
|
355
|
+
candidate_id = _safe_dimension(payload.get("id"), 512)
|
|
356
|
+
if candidate_id and not conversation_id:
|
|
357
|
+
conversation_id = candidate_id
|
|
358
|
+
content_hash = digest.hexdigest()
|
|
359
|
+
conversation_id = conversation_id or f"content-{content_hash}"
|
|
360
|
+
candidate = (machine, path, event_count, content_hash)
|
|
361
|
+
previous = selected.get(conversation_id)
|
|
362
|
+
if previous is None:
|
|
363
|
+
selected[conversation_id] = candidate
|
|
364
|
+
else:
|
|
365
|
+
duplicates += 1
|
|
366
|
+
if candidate[2:] > previous[2:]:
|
|
253
367
|
selected[conversation_id] = candidate
|
|
254
|
-
else:
|
|
255
|
-
duplicates += 1
|
|
256
|
-
if candidate[2:] > previous[2:]:
|
|
257
|
-
selected[conversation_id] = candidate
|
|
258
368
|
return list(selected.values()), duplicates, malformed
|
|
259
369
|
|
|
260
370
|
def _read_rollout(
|
|
@@ -19,6 +19,10 @@ from sqlalchemy.engine import Engine
|
|
|
19
19
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
20
20
|
|
|
21
21
|
from cli_consumption import __version__
|
|
22
|
+
from cli_consumption.dashboard_layouts import (
|
|
23
|
+
DASHBOARD_LAYOUT_VERSION,
|
|
24
|
+
MAX_DASHBOARD_LAYOUT_BYTES,
|
|
25
|
+
)
|
|
22
26
|
from cli_consumption.models import (
|
|
23
27
|
CURRENT_SNAPSHOT_SCHEMA,
|
|
24
28
|
MAX_SNAPSHOT_RECORDS,
|
|
@@ -29,6 +33,7 @@ from cli_consumption.models import (
|
|
|
29
33
|
)
|
|
30
34
|
from cli_consumption.reporting_api import (
|
|
31
35
|
CACHE_HEADERS,
|
|
36
|
+
EXPORT_REQUEST_BYTES,
|
|
32
37
|
EXPORT_TIMEOUT_SECONDS,
|
|
33
38
|
MAX_CONCURRENT_EXPORTS,
|
|
34
39
|
MAX_CONCURRENT_REPORTS,
|
|
@@ -69,6 +74,7 @@ READINESS_TABLES = (
|
|
|
69
74
|
"subagent_scopes",
|
|
70
75
|
"ingestion_runs",
|
|
71
76
|
"sync_receipts",
|
|
77
|
+
"dashboard_layouts",
|
|
72
78
|
)
|
|
73
79
|
READINESS_RESPONSE_TIMEOUT_SECONDS = 2.0
|
|
74
80
|
READINESS_CONNECT_TIMEOUT_SECONDS = 2
|
|
@@ -94,6 +100,7 @@ SAFE_ROUTES = frozenset(
|
|
|
94
100
|
"/api/v1/reporting/conversations",
|
|
95
101
|
"/api/v1/reporting/conversation",
|
|
96
102
|
"/api/v1/reporting/export",
|
|
103
|
+
"/api/v1/reporting/layout",
|
|
97
104
|
}
|
|
98
105
|
)
|
|
99
106
|
SAFE_EXCEPTION_TYPES = frozenset(
|
|
@@ -354,9 +361,12 @@ class RequestSizeLimitMiddleware:
|
|
|
354
361
|
if scope["type"] != "http":
|
|
355
362
|
await self.app(scope, receive, send)
|
|
356
363
|
return
|
|
364
|
+
path = str(scope.get("path", ""))
|
|
357
365
|
maximum = (
|
|
358
|
-
|
|
359
|
-
if
|
|
366
|
+
EXPORT_REQUEST_BYTES
|
|
367
|
+
if path == "/api/v1/reporting/export"
|
|
368
|
+
else REPORTING_REQUEST_BYTES
|
|
369
|
+
if path.startswith("/api/v1/reporting/")
|
|
360
370
|
else self.maximum
|
|
361
371
|
)
|
|
362
372
|
headers = dict(scope.get("headers", []))
|
|
@@ -405,6 +415,7 @@ def _configured_credentials(
|
|
|
405
415
|
api_token: str | None,
|
|
406
416
|
read_token: str | None,
|
|
407
417
|
export_token: str | None,
|
|
418
|
+
layout_token: str | None,
|
|
408
419
|
) -> list[tuple[str, frozenset[str]]]:
|
|
409
420
|
credentials: list[tuple[str, frozenset[str]]] = []
|
|
410
421
|
configured_values: set[str] = set()
|
|
@@ -412,6 +423,7 @@ def _configured_credentials(
|
|
|
412
423
|
(api_token, frozenset({"ingest"})),
|
|
413
424
|
(read_token, frozenset({"read"})),
|
|
414
425
|
(export_token, frozenset({"read", "export"})),
|
|
426
|
+
(layout_token, frozenset({"layout"})),
|
|
415
427
|
):
|
|
416
428
|
if credential is None:
|
|
417
429
|
continue
|
|
@@ -436,8 +448,11 @@ def create_app(
|
|
|
436
448
|
*,
|
|
437
449
|
read_token: str | None = None,
|
|
438
450
|
export_token: str | None = None,
|
|
451
|
+
layout_token: str | None = None,
|
|
439
452
|
) -> SafeExceptionBoundary:
|
|
440
|
-
credentials = _configured_credentials(
|
|
453
|
+
credentials = _configured_credentials(
|
|
454
|
+
api_token, read_token, export_token, layout_token
|
|
455
|
+
)
|
|
441
456
|
initialize_database(engine)
|
|
442
457
|
if engine.dialect.name == "postgresql":
|
|
443
458
|
probe_engine = create_postgresql_readiness_engine(
|
|
@@ -553,8 +568,12 @@ def create_app(
|
|
|
553
568
|
"idempotent_snapshot_uploads": True,
|
|
554
569
|
"dashboard_query_versions": [1],
|
|
555
570
|
"dashboard_dataset_versions": [1],
|
|
571
|
+
"dashboard_layout_versions": [DASHBOARD_LAYOUT_VERSION],
|
|
572
|
+
"dashboard_layout_mutation_scope": "layout",
|
|
573
|
+
"max_dashboard_layout_bytes": MAX_DASHBOARD_LAYOUT_BYTES,
|
|
556
574
|
"cursor_versions": [1],
|
|
557
575
|
"max_reporting_request_bytes": REPORTING_REQUEST_BYTES,
|
|
576
|
+
"max_export_request_bytes": EXPORT_REQUEST_BYTES,
|
|
558
577
|
"max_reporting_filter_values": MAX_FILTER_VALUES,
|
|
559
578
|
"max_reporting_records": MAX_REPORTING_RECORDS,
|
|
560
579
|
"max_reporting_scalar_bytes": MAX_REPORTING_SCALAR_BYTES,
|
|
@@ -599,6 +618,7 @@ def create_app(
|
|
|
599
618
|
engine,
|
|
600
619
|
authorize_read=require_scopes("read"),
|
|
601
620
|
authorize_export=require_scopes("read", "export"),
|
|
621
|
+
authorize_layout=require_scopes("layout"),
|
|
602
622
|
)
|
|
603
623
|
|
|
604
624
|
return SafeExceptionBoundary(app)
|