meterline-anthropic-admin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meterline_anthropic_admin/__init__.py +18 -0
- meterline_anthropic_admin/cli.py +596 -0
- meterline_anthropic_admin/client.py +191 -0
- meterline_anthropic_admin/fetch.py +196 -0
- meterline_anthropic_admin/normalise.py +420 -0
- meterline_anthropic_admin/testing.py +288 -0
- meterline_anthropic_admin-0.1.0.dist-info/METADATA +263 -0
- meterline_anthropic_admin-0.1.0.dist-info/RECORD +12 -0
- meterline_anthropic_admin-0.1.0.dist-info/WHEEL +5 -0
- meterline_anthropic_admin-0.1.0.dist-info/entry_points.txt +2 -0
- meterline_anthropic_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- meterline_anthropic_admin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""meterline-anthropic-admin: reads the Anthropic Admin Usage and Cost API of a Console organisation and emits meterline records."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError as _NotInstalled, version as _version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _version("meterline-anthropic-admin") # the one source is pyproject.toml
|
|
7
|
+
except _NotInstalled: # a source tree that is not installed
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
|
10
|
+
# after __version__: client.py reads it
|
|
11
|
+
from meterline import RunResult # noqa: E402
|
|
12
|
+
|
|
13
|
+
from .cli import run # noqa: E402
|
|
14
|
+
from .client import ApiError, PaginationError # noqa: E402
|
|
15
|
+
|
|
16
|
+
# The public interface is the command line, ``run`` and its ``RunResult``, the error classes below, the records the
|
|
17
|
+
# commands write (README, "Public interface") and the documented ``testing`` module; every other module is internal.
|
|
18
|
+
__all__ = ["ApiError", "PaginationError", "RunResult", "__version__", "run"]
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Command line: ``meterline-anthropic-admin backfill | daily | rebuild``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 1 API or transport failure (state kept, a re-run resumes), 2 checks failed, a response of a shape
|
|
4
|
+
the connector does not know, or a configuration error.
|
|
5
|
+
|
|
6
|
+
The key is read from ``ANTHROPIC_ADMIN_KEY`` in ``run`` (or comes from its ``credentials`` argument) and nowhere
|
|
7
|
+
else; it is never read from a keychain. The key
|
|
8
|
+
goes to the client and nowhere further: not to a file, a log line, a manifest or the state file.
|
|
9
|
+
|
|
10
|
+
Layout under the data directory::
|
|
11
|
+
|
|
12
|
+
raw/<endpoint>/<window>/<pulled_at>.json.gz every response page as received
|
|
13
|
+
runs/<batch_id>.json one manifest per run
|
|
14
|
+
records/<kind>/<batch_id>.jsonl published records
|
|
15
|
+
state.json what is loaded, plus a ``pending`` block
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from datetime import date, datetime, timedelta, timezone
|
|
26
|
+
from decimal import Decimal
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from meterline import RunResult, note_failure, result_of_run, run_manifests
|
|
31
|
+
from meterline.run import (
|
|
32
|
+
EXIT_PERMANENT,
|
|
33
|
+
EXIT_TRANSIENT,
|
|
34
|
+
WritePhase,
|
|
35
|
+
exit_code_of_status,
|
|
36
|
+
unknown_fields_of,
|
|
37
|
+
warn_unknown_fields,
|
|
38
|
+
)
|
|
39
|
+
from meterline.state import STATE_NAME as STATE_NAME
|
|
40
|
+
from meterline.state import StateError as StateError
|
|
41
|
+
from meterline.state import is_day_list, is_relative_path, read_state, state_damaged_message, write_state
|
|
42
|
+
from meterline.writers import write_jsonl, write_parquet
|
|
43
|
+
|
|
44
|
+
from .client import MAX_RETRY_AFTER_SECONDS, AdminClient, ApiError, PaginationError
|
|
45
|
+
from .fetch import (
|
|
46
|
+
COST,
|
|
47
|
+
USAGE,
|
|
48
|
+
RawFileExists,
|
|
49
|
+
RunManifest,
|
|
50
|
+
backward_windows,
|
|
51
|
+
batch_id_for,
|
|
52
|
+
fetch_endpoint,
|
|
53
|
+
forward_windows,
|
|
54
|
+
window_label,
|
|
55
|
+
)
|
|
56
|
+
from .normalise import (
|
|
57
|
+
KINDS,
|
|
58
|
+
NormalisedRun,
|
|
59
|
+
drop_unchanged,
|
|
60
|
+
first_open_day,
|
|
61
|
+
is_final_day,
|
|
62
|
+
load_published,
|
|
63
|
+
normalise_run,
|
|
64
|
+
read_raw,
|
|
65
|
+
run_checks,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger("meterline_anthropic_admin.cli")
|
|
69
|
+
|
|
70
|
+
KEY_ENV = "ANTHROPIC_ADMIN_KEY"
|
|
71
|
+
# the evidence level of the shapes, said where a user meets the command (docs/glossary.md)
|
|
72
|
+
SHAPES_NOTE = ("Evidence level: verified live, on a small Console organisation (one model, two days of usage); "
|
|
73
|
+
"cache tokens, priority tier and several workspaces not yet seen. If a live response differs, "
|
|
74
|
+
"follow Response shapes in the package README.")
|
|
75
|
+
DEFAULT_DATA_DIR = "exports/meterline-anthropic-admin"
|
|
76
|
+
PRIVATE_UMASK = 0o077 # files 0600, folders 0700
|
|
77
|
+
REREAD_DAYS = 3 # a day is final 48 hours after it ended, so the three days before today are read once more
|
|
78
|
+
|
|
79
|
+
EXIT_OK = 0
|
|
80
|
+
EXIT_API = 1
|
|
81
|
+
EXIT_CHECKS = 2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- state ------------------------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _empty_state() -> Dict[str, Any]:
|
|
88
|
+
return {"days_loaded": [], "pending": None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_state(state: Any) -> None:
|
|
92
|
+
"""Raise StateError (naming the first thing wrong, never a value) unless ``state`` has the shape ``_write_state`` writes."""
|
|
93
|
+
if not isinstance(state, dict):
|
|
94
|
+
raise StateError("it does not hold a JSON object")
|
|
95
|
+
for key in _empty_state():
|
|
96
|
+
if key not in state:
|
|
97
|
+
raise StateError("the key %s is missing" % key)
|
|
98
|
+
if not is_day_list(state["days_loaded"]):
|
|
99
|
+
raise StateError("days_loaded is not a list of YYYY-MM-DD dates")
|
|
100
|
+
pending = state["pending"]
|
|
101
|
+
if pending is None:
|
|
102
|
+
return
|
|
103
|
+
if not isinstance(pending, dict) or not isinstance(pending.get("command"), str):
|
|
104
|
+
raise StateError("pending is not a block with a command")
|
|
105
|
+
steps = pending.get("steps")
|
|
106
|
+
if not isinstance(steps, dict) or not all(isinstance(k, str) and is_relative_path(v) for k, v in steps.items()):
|
|
107
|
+
raise StateError("pending.steps is not a map of step names to relative file paths")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_state(data_dir: Path) -> Optional[Dict[str, Any]]:
|
|
111
|
+
"""The state file as a dict, None when there is none. Raises StateError
|
|
112
|
+
when it is there but damaged."""
|
|
113
|
+
return read_state(data_dir, _check_state)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _state_damaged(data_dir: Path, exc: StateError) -> int:
|
|
117
|
+
print(state_damaged_message(data_dir, exc), file=sys.stderr)
|
|
118
|
+
return EXIT_CHECKS
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_write_state = write_state
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Stepper:
|
|
125
|
+
"""Runs the fetch steps of one run. A step that finished in an earlier, failed run of the same command is reused,
|
|
126
|
+
not requested again."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, data_dir: Path, state: Dict[str, Any], command: str) -> None:
|
|
129
|
+
self.data_dir = data_dir
|
|
130
|
+
self.state = state
|
|
131
|
+
self.command = command
|
|
132
|
+
self.done: Dict[str, str] = {}
|
|
133
|
+
pending = state.get("pending")
|
|
134
|
+
if isinstance(pending, dict) and pending.get("command") == command:
|
|
135
|
+
self.done = dict(pending.get("steps") or {})
|
|
136
|
+
self.order: List[str] = []
|
|
137
|
+
|
|
138
|
+
def has(self, label: str) -> bool:
|
|
139
|
+
return label in self.done
|
|
140
|
+
|
|
141
|
+
def path(self, label: str) -> Path:
|
|
142
|
+
self.order.append(label)
|
|
143
|
+
return self.data_dir / self.done[label]
|
|
144
|
+
|
|
145
|
+
def record(self, label: str, path: Path) -> Path:
|
|
146
|
+
self.order.append(label)
|
|
147
|
+
self.done[label] = Path(path).relative_to(self.data_dir).as_posix()
|
|
148
|
+
self.state["pending"] = {"command": self.command, "steps": dict(sorted(self.done.items()))}
|
|
149
|
+
_write_state(self.data_dir, self.state)
|
|
150
|
+
return Path(path)
|
|
151
|
+
|
|
152
|
+
def raw_files(self) -> List[str]:
|
|
153
|
+
return [self.done[label] for label in self.order]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---- collecting and publishing ---------------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _collect(client: AdminClient, data_dir: Path, stepper: _Stepper, pulled_at: datetime, windows: Sequence[Tuple[date, date]],
|
|
160
|
+
provider_account_id: str, workspace_id: Optional[str]) -> None:
|
|
161
|
+
"""Fetch everything one run needs: both reports for every window."""
|
|
162
|
+
for first, last in windows:
|
|
163
|
+
for endpoint in (USAGE, COST):
|
|
164
|
+
label = "%s:%s" % (endpoint, window_label(first, last))
|
|
165
|
+
if stepper.has(label):
|
|
166
|
+
stepper.path(label)
|
|
167
|
+
continue
|
|
168
|
+
pull = fetch_endpoint(client, data_dir, endpoint, first=first, last=last, pulled_at=pulled_at, provider_account_id=provider_account_id,
|
|
169
|
+
workspace_id=workspace_id)
|
|
170
|
+
stepper.record(label, pull.path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _totals_text(run: NormalisedRun) -> Dict[str, str]:
|
|
174
|
+
"""Token and cost figures per day and model (or product), as text: what the run report compares with the Console."""
|
|
175
|
+
out: Dict[str, str] = {}
|
|
176
|
+
for rec in run.org_totals:
|
|
177
|
+
who = rec.model or rec.product
|
|
178
|
+
for name in ("tokens_input_uncached", "tokens_cache_read", "tokens_cache_write", "tokens_output", "cost"):
|
|
179
|
+
value = getattr(rec, name)
|
|
180
|
+
if value is not None:
|
|
181
|
+
out["%s.%s.%s" % (rec.period_start.isoformat(), who, name)] = format(value, "f") if isinstance(value, Decimal) else str(value)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _publish(run: NormalisedRun, published: Dict[str, Dict[tuple, Any]], records_root: Path, batch_id: str,
|
|
186
|
+
parquet_for: Callable[[str], bool]) -> Dict[str, int]:
|
|
187
|
+
counts: Dict[str, int] = {}
|
|
188
|
+
for kind, cls in KINDS.items():
|
|
189
|
+
changed = drop_unchanged(run.records(kind), published.get(kind, {}))
|
|
190
|
+
counts[kind] = len(changed)
|
|
191
|
+
if not changed:
|
|
192
|
+
continue
|
|
193
|
+
target = records_root / kind / (batch_id + ".jsonl")
|
|
194
|
+
write_jsonl(changed, target)
|
|
195
|
+
if parquet_for(kind):
|
|
196
|
+
write_parquet(changed, target.with_suffix(".parquet"), cls)
|
|
197
|
+
return counts
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fail_run(command: str, batch_id: str, pulled_at: datetime, raw_rel: Sequence[str], failures: List[str], data_dir: Path,
|
|
201
|
+
state: Dict[str, Any]) -> None:
|
|
202
|
+
RunManifest(command=command, batch_id=batch_id, pulled_at=pulled_at, status="failed", raw_files=list(raw_rel), failures=failures).write(data_dir)
|
|
203
|
+
state["pending"] = None
|
|
204
|
+
_write_state(data_dir, state)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _loadable(covered: Sequence[Tuple[date, date]], present: Set[date], pulled_at: datetime) -> Tuple[Set[date], int]:
|
|
208
|
+
"""The days a run may record as loaded, and how many of them came from an answer with no rows.
|
|
209
|
+
|
|
210
|
+
A day with rows is loaded. A day without rows is loaded only once it is final (48 hours after it ended): before that
|
|
211
|
+
the provider may still fill it in, and the day stays unloaded."""
|
|
212
|
+
loaded: Set[date] = set()
|
|
213
|
+
empty = 0
|
|
214
|
+
for first, last in covered:
|
|
215
|
+
for n in range((last - first).days + 1):
|
|
216
|
+
day = first + timedelta(days=n)
|
|
217
|
+
if day in present or is_final_day(day, pulled_at):
|
|
218
|
+
loaded.add(day)
|
|
219
|
+
empty += day not in present
|
|
220
|
+
return loaded, empty
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _finish(command: str, data_dir: Path, state: Dict[str, Any], stepper: _Stepper, pulled_at: datetime,
|
|
224
|
+
covered: Sequence[Tuple[date, date]], parquet: bool) -> int:
|
|
225
|
+
batch_id = batch_id_for(pulled_at)
|
|
226
|
+
raw_rel = stepper.raw_files()
|
|
227
|
+
published = load_published(data_dir)
|
|
228
|
+
try:
|
|
229
|
+
run = normalise_run([data_dir / p for p in raw_rel], batch_id, pulled_at)
|
|
230
|
+
except ValueError as exc: # ShapeError is a ValueError: a response that is not what the shapes promise
|
|
231
|
+
_fail_run(command, batch_id, pulled_at, raw_rel, ["raw data not usable: " + str(exc)], data_dir, state)
|
|
232
|
+
print("raw data not usable: nothing published (%s)" % exc, file=sys.stderr)
|
|
233
|
+
return EXIT_CHECKS
|
|
234
|
+
failures = run_checks(run, pulled_at.date())
|
|
235
|
+
if failures:
|
|
236
|
+
_fail_run(command, batch_id, pulled_at, raw_rel, failures, data_dir, state)
|
|
237
|
+
print("checks failed: nothing published (%d failures)" % len(failures), file=sys.stderr)
|
|
238
|
+
for message in failures:
|
|
239
|
+
print(" - " + message, file=sys.stderr)
|
|
240
|
+
return EXIT_CHECKS
|
|
241
|
+
|
|
242
|
+
unknown = unknown_fields_of(run.unknown_fields)
|
|
243
|
+
warn_unknown_fields("meterline-anthropic-admin", unknown)
|
|
244
|
+
with WritePhase(data_dir, batch_id) as phase:
|
|
245
|
+
counts = _publish(run, published, data_dir / "records", batch_id, lambda _kind: parquet)
|
|
246
|
+
pages = sum(len(read_raw(data_dir / rel)["pages"]) for rel in raw_rel)
|
|
247
|
+
newly_loaded, empty_days = _loadable(covered, run.data_days, pulled_at)
|
|
248
|
+
RunManifest(
|
|
249
|
+
command=command, batch_id=batch_id, pulled_at=pulled_at, status="published", windows=list(stepper.order), pages=pages,
|
|
250
|
+
row_counts=dict(run.row_counts), totals=_totals_text(run), empty_days=empty_days, raw_files=list(raw_rel),
|
|
251
|
+
published_counts=counts, unknown_fields=unknown,
|
|
252
|
+
).write(data_dir)
|
|
253
|
+
loaded = {date.fromisoformat(d) for d in state.get("days_loaded") or []} | newly_loaded
|
|
254
|
+
state["days_loaded"] = sorted(d.isoformat() for d in loaded)
|
|
255
|
+
state["pending"] = None
|
|
256
|
+
_write_state(data_dir, state)
|
|
257
|
+
if phase.failed:
|
|
258
|
+
return EXIT_TRANSIENT
|
|
259
|
+
print("ok: %s published %s" % (command, ", ".join("%s=%d" % kv for kv in sorted(counts.items()))), file=sys.stdout)
|
|
260
|
+
return EXIT_OK
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _same_second(data_dir: Path, pulled_at: datetime) -> bool:
|
|
264
|
+
"""True, with a message, when a finished run already carries this second as its batch id."""
|
|
265
|
+
if (data_dir / "runs" / (batch_id_for(pulled_at) + ".json")).exists():
|
|
266
|
+
print("a run already finished in the same second (batch %s); wait a second and run again. Nothing was fetched or "
|
|
267
|
+
"changed." % batch_id_for(pulled_at), file=sys.stderr)
|
|
268
|
+
return True
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---- commands ---------------------------------------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _read_now(now: Callable[[], datetime]) -> datetime:
|
|
276
|
+
value = now()
|
|
277
|
+
if value.tzinfo is None:
|
|
278
|
+
raise ValueError("now() must return a timezone-aware datetime")
|
|
279
|
+
# whole seconds: the batch id and the manifest carry no fraction, and a rebuild must reproduce the pulled_at
|
|
280
|
+
# of the published records exactly
|
|
281
|
+
return value.astimezone(timezone.utc).replace(microsecond=0)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _run(command: str, client: AdminClient, data_dir: Path, windows: Sequence[Tuple[date, date]], pulled_at: datetime, provider_account_id: str,
|
|
285
|
+
workspace_id: Optional[str], parquet: bool) -> int:
|
|
286
|
+
try:
|
|
287
|
+
state = _read_state(data_dir) or _empty_state()
|
|
288
|
+
except StateError as exc:
|
|
289
|
+
return _state_damaged(data_dir, exc)
|
|
290
|
+
if _same_second(data_dir, pulled_at):
|
|
291
|
+
return EXIT_CHECKS
|
|
292
|
+
stepper = _Stepper(data_dir, state, command)
|
|
293
|
+
try:
|
|
294
|
+
_collect(client, data_dir, stepper, pulled_at, windows, provider_account_id, workspace_id)
|
|
295
|
+
except RawFileExists as exc:
|
|
296
|
+
print("%s; nothing was published. Wait a second and run again." % exc, file=sys.stderr)
|
|
297
|
+
return EXIT_CHECKS
|
|
298
|
+
except ApiError as exc:
|
|
299
|
+
_write_state(data_dir, state)
|
|
300
|
+
return _api_failed(exc.status)
|
|
301
|
+
except PaginationError as exc: # the pages of a query do not add up: nothing from it is used
|
|
302
|
+
_fail_run(command, batch_id_for(pulled_at), pulled_at, stepper.raw_files(), ["pagination: " + str(exc)], data_dir, state)
|
|
303
|
+
print("pagination: nothing published (%s)" % exc, file=sys.stderr)
|
|
304
|
+
return EXIT_CHECKS
|
|
305
|
+
return _finish(command, data_dir, state, stepper, pulled_at, windows, parquet)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _api_failed(status: int) -> int:
|
|
309
|
+
"""Exit 1 (transient) or 3 (permanent) for an API failure that stopped the run; the status goes to the RunResult."""
|
|
310
|
+
code = exit_code_of_status(status)
|
|
311
|
+
note_failure(status)
|
|
312
|
+
if code == EXIT_PERMANENT:
|
|
313
|
+
print(
|
|
314
|
+
"permanent: the API refused the request (HTTP status %d); fix the key, its scope or the identifier it "
|
|
315
|
+
"names, then run the same command again. Progress is kept." % status,
|
|
316
|
+
file=sys.stderr,
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
print(
|
|
320
|
+
"transient: re-run later. API request failed (HTTP status %d); progress is kept, run the same command "
|
|
321
|
+
"again to resume" % status,
|
|
322
|
+
file=sys.stderr,
|
|
323
|
+
)
|
|
324
|
+
return code
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def run_backfill(client: AdminClient, data_dir: Path, since: date, *, provider_account_id: str, now: Callable[[], datetime],
|
|
328
|
+
workspace_id: Optional[str] = None, parquet: bool = False) -> int:
|
|
329
|
+
pulled_at = _read_now(now)
|
|
330
|
+
yesterday = pulled_at.date() - timedelta(days=1)
|
|
331
|
+
windows = backward_windows(since, yesterday) if since <= yesterday else []
|
|
332
|
+
return _run("backfill", client, Path(data_dir), windows, pulled_at, provider_account_id, workspace_id, parquet)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _daily_start(data_dir: Path, state: Optional[Dict[str, Any]], since: Optional[date], today: date) -> Optional[date]:
|
|
336
|
+
"""The first day ``daily`` reads, or None (with a message) when it cannot tell."""
|
|
337
|
+
if state is None:
|
|
338
|
+
# without it, daily cannot tell what is loaded and would quietly fetch a long stretch again
|
|
339
|
+
if since is None:
|
|
340
|
+
print("no state file %s: daily cannot tell which days are already loaded. Pass --since YYYY-MM-DD to start from that "
|
|
341
|
+
"day, or run backfill --since YYYY-MM-DD." % (data_dir / STATE_NAME), file=sys.stderr)
|
|
342
|
+
return None
|
|
343
|
+
print("note: no state file %s; starting at %s" % (data_dir / STATE_NAME, since.isoformat()), file=sys.stderr)
|
|
344
|
+
return since
|
|
345
|
+
if since is not None:
|
|
346
|
+
print("note: the state file exists, so --since is ignored", file=sys.stderr)
|
|
347
|
+
loaded = [date.fromisoformat(d) for d in state.get("days_loaded") or []]
|
|
348
|
+
start = (max(loaded) + timedelta(days=1)) if loaded else today - timedelta(days=REREAD_DAYS)
|
|
349
|
+
open_day = first_open_day(load_published(data_dir)) # a day published before it was final is read until it is
|
|
350
|
+
return min(start, today - timedelta(days=REREAD_DAYS), open_day or start)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run_daily(client: AdminClient, data_dir: Path, *, provider_account_id: str, now: Callable[[], datetime], workspace_id: Optional[str] = None,
|
|
354
|
+
parquet: bool = False, since: Optional[date] = None) -> int:
|
|
355
|
+
data_dir = Path(data_dir)
|
|
356
|
+
pulled_at = _read_now(now)
|
|
357
|
+
today = pulled_at.date()
|
|
358
|
+
try:
|
|
359
|
+
state = _read_state(data_dir)
|
|
360
|
+
except StateError as exc:
|
|
361
|
+
return _state_damaged(data_dir, exc)
|
|
362
|
+
start = _daily_start(data_dir, state, since, today)
|
|
363
|
+
if start is None:
|
|
364
|
+
return EXIT_CHECKS
|
|
365
|
+
windows = forward_windows(start, today - timedelta(days=1))
|
|
366
|
+
return _run("daily", client, data_dir, windows, pulled_at, provider_account_id, workspace_id, parquet)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def run_rebuild(data_dir: Path, parquet: bool = False) -> int:
|
|
370
|
+
"""Re-create ``records/`` from the raw store and the run manifests. No network, no key."""
|
|
371
|
+
data_dir = Path(data_dir)
|
|
372
|
+
runs_dir = data_dir / "runs"
|
|
373
|
+
manifests: List[RunManifest] = []
|
|
374
|
+
if runs_dir.is_dir():
|
|
375
|
+
manifests = [m for m in (RunManifest.read(p) for p in sorted(runs_dir.glob("*.json"))) if m.status == "published"]
|
|
376
|
+
manifests.sort(key=lambda m: m.batch_id)
|
|
377
|
+
|
|
378
|
+
work = data_dir / ".rebuild"
|
|
379
|
+
if work.exists():
|
|
380
|
+
shutil.rmtree(work)
|
|
381
|
+
new_records = work / "records"
|
|
382
|
+
new_records.mkdir(parents=True)
|
|
383
|
+
old_records = data_dir / "records"
|
|
384
|
+
|
|
385
|
+
totals: Dict[str, int] = {kind: 0 for kind in KINDS}
|
|
386
|
+
for manifest in manifests:
|
|
387
|
+
paths = [data_dir / rel for rel in manifest.raw_files]
|
|
388
|
+
missing = [p for p in paths if not p.is_file()]
|
|
389
|
+
if missing:
|
|
390
|
+
print("rebuild: %d raw files missing for run %s" % (len(missing), manifest.batch_id), file=sys.stderr)
|
|
391
|
+
shutil.rmtree(work)
|
|
392
|
+
return EXIT_CHECKS
|
|
393
|
+
published = load_published(work)
|
|
394
|
+
try:
|
|
395
|
+
run = normalise_run(paths, manifest.batch_id, manifest.pulled_at)
|
|
396
|
+
except ValueError as exc: # ShapeError is a ValueError
|
|
397
|
+
print("rebuild: raw data not usable for run %s (%s)" % (manifest.batch_id, exc), file=sys.stderr)
|
|
398
|
+
shutil.rmtree(work)
|
|
399
|
+
return EXIT_CHECKS
|
|
400
|
+
failures = run_checks(run, manifest.pulled_at.astimezone(timezone.utc).date())
|
|
401
|
+
if failures:
|
|
402
|
+
print("rebuild: checks failed for run %s (%d failures); records left as they were" % (manifest.batch_id, len(failures)),
|
|
403
|
+
file=sys.stderr)
|
|
404
|
+
shutil.rmtree(work)
|
|
405
|
+
return EXIT_CHECKS
|
|
406
|
+
|
|
407
|
+
def want_parquet(kind: str, batch: str = manifest.batch_id) -> bool:
|
|
408
|
+
return parquet or (old_records / kind / (batch + ".parquet")).exists()
|
|
409
|
+
|
|
410
|
+
for kind, n in _publish(run, published, new_records, manifest.batch_id, want_parquet).items():
|
|
411
|
+
totals[kind] += n
|
|
412
|
+
|
|
413
|
+
retired = data_dir / "records.old"
|
|
414
|
+
if retired.exists():
|
|
415
|
+
shutil.rmtree(retired)
|
|
416
|
+
if old_records.exists():
|
|
417
|
+
os.replace(old_records, retired)
|
|
418
|
+
os.replace(new_records, old_records)
|
|
419
|
+
if retired.exists():
|
|
420
|
+
shutil.rmtree(retired)
|
|
421
|
+
shutil.rmtree(work)
|
|
422
|
+
print("ok: rebuild of %d runs wrote %s" % (len(manifests), ", ".join("%s=%d" % kv for kv in sorted(totals.items()))), file=sys.stdout)
|
|
423
|
+
return EXIT_OK
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---- entry point -----------------------------------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _parse_date(text: str) -> date:
|
|
430
|
+
try:
|
|
431
|
+
return date.fromisoformat(text)
|
|
432
|
+
except ValueError:
|
|
433
|
+
raise argparse.ArgumentTypeError("expected a date as YYYY-MM-DD, got %r" % text) from None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
437
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
438
|
+
common.add_argument("--data-dir", default=DEFAULT_DATA_DIR, help="where raw, runs, records and state live")
|
|
439
|
+
common.add_argument("--parquet", action="store_true", help="also write <batch_id>.parquet (needs the parquet extra)")
|
|
440
|
+
pull = argparse.ArgumentParser(add_help=False)
|
|
441
|
+
pull.add_argument("--provider-account-id", required=True,
|
|
442
|
+
help="a label for this organisation; becomes provider_account_id (not the Anthropic organisation id)")
|
|
443
|
+
pull.add_argument("--workspace-id", default=None, help="only this workspace (optional; the default is every workspace)")
|
|
444
|
+
pull.add_argument("--rate-per-minute", type=int, default=20, help="request budget per minute (default 20)")
|
|
445
|
+
pull.add_argument("--max-retry-after", type=int, default=MAX_RETRY_AFTER_SECONDS,
|
|
446
|
+
help="longest wait in seconds a Retry-After header can cause (default 300)")
|
|
447
|
+
|
|
448
|
+
parser = argparse.ArgumentParser(
|
|
449
|
+
prog="meterline-anthropic-admin",
|
|
450
|
+
description="Read the Anthropic Admin Usage and Cost API of a Console organisation and write meterline records. "
|
|
451
|
+
"The Admin key is read from %s." % KEY_ENV,
|
|
452
|
+
epilog=SHAPES_NOTE,
|
|
453
|
+
)
|
|
454
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
455
|
+
back = sub.add_parser("backfill", parents=[common, pull], help="walk 31-day windows back from yesterday; resumes after a failure")
|
|
456
|
+
back.add_argument("--since", type=_parse_date, required=True, help="first day to load, YYYY-MM-DD")
|
|
457
|
+
daily = sub.add_parser("daily", parents=[common, pull], help="every closed day since the last one loaded, and every day not yet final")
|
|
458
|
+
daily.add_argument("--since", type=_parse_date, default=None, help="first day to load; needed only when there is no state file")
|
|
459
|
+
sub.add_parser("rebuild", parents=[common], help="re-create records from the raw store; no API calls")
|
|
460
|
+
return parser
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _utc_now() -> datetime:
|
|
464
|
+
return datetime.now(timezone.utc)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
Credentials = Union[Mapping[str, str], Callable[[], Mapping[str, str]], None]
|
|
468
|
+
|
|
469
|
+
_PULL_OPTIONS = ("provider_account_id", "workspace_id", "rate_per_minute", "max_retry_after", "parquet")
|
|
470
|
+
_COMMAND_OPTIONS = {
|
|
471
|
+
"backfill": _PULL_OPTIONS + ("since",),
|
|
472
|
+
"daily": _PULL_OPTIONS + ("since",),
|
|
473
|
+
"rebuild": ("parquet",),
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _date_option(name: str, value: Any) -> Optional[date]:
|
|
478
|
+
if value is None or (isinstance(value, date) and not isinstance(value, datetime)):
|
|
479
|
+
return value
|
|
480
|
+
if isinstance(value, str):
|
|
481
|
+
try:
|
|
482
|
+
return date.fromisoformat(value)
|
|
483
|
+
except ValueError:
|
|
484
|
+
pass
|
|
485
|
+
raise ValueError("%s must be a date or a string YYYY-MM-DD" % name)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def run(
|
|
489
|
+
command: str,
|
|
490
|
+
*,
|
|
491
|
+
data_dir: Union[str, Path],
|
|
492
|
+
credentials: Credentials = None,
|
|
493
|
+
transport: Any = None,
|
|
494
|
+
clock: Callable[[], Any] = time.monotonic,
|
|
495
|
+
sleep: Callable[[Any], Any] = time.sleep,
|
|
496
|
+
now: Optional[Callable[[], datetime]] = None,
|
|
497
|
+
jitter: Callable[[], Any] = random.random,
|
|
498
|
+
wall: Callable[[], Any] = time.time,
|
|
499
|
+
**options: Any,
|
|
500
|
+
) -> RunResult:
|
|
501
|
+
"""Run one command (``backfill``, ``daily`` or ``rebuild``) and return its ``RunResult``.
|
|
502
|
+
|
|
503
|
+
``options`` are the command line options under their Python names (``since``, ``provider_account_id``, ``workspace_id``,
|
|
504
|
+
``rate_per_minute``, ``max_retry_after``, ``parquet``), with the same defaults; an unknown name raises
|
|
505
|
+
``TypeError``. ``credentials`` is a mapping ``{"ANTHROPIC_ADMIN_KEY": key}`` or a function returning one: when it
|
|
506
|
+
is given the environment is not read for the key, and a name missing from it counts as unset. A failed run does
|
|
507
|
+
not raise: ``RunResult.exit_code`` is the code the command line exits with, after the same messages on stderr.
|
|
508
|
+
Everything written under the data directory is readable by its owner only (files 0600, folders 0700), whatever
|
|
509
|
+
the caller's umask: the process umask is 077 for the duration of the command and is put back afterwards."""
|
|
510
|
+
if command not in _COMMAND_OPTIONS:
|
|
511
|
+
raise ValueError("command must be one of backfill, daily, rebuild, not %r" % (command,))
|
|
512
|
+
unknown = sorted(set(options) - set(_COMMAND_OPTIONS[command]))
|
|
513
|
+
if unknown:
|
|
514
|
+
raise TypeError("run(%r) got unexpected option(s): %s" % (command, ", ".join(unknown)))
|
|
515
|
+
data_path = Path(data_dir)
|
|
516
|
+
secrets = credentials() if callable(credentials) else credentials
|
|
517
|
+
before = run_manifests(data_path)
|
|
518
|
+
old_umask = os.umask(PRIVATE_UMASK)
|
|
519
|
+
try:
|
|
520
|
+
code = _run_command(command, data_path, secrets, credentials is not None, options, transport=transport, clock=clock,
|
|
521
|
+
sleep=sleep, now=now, jitter=jitter, wall=wall)
|
|
522
|
+
finally:
|
|
523
|
+
os.umask(old_umask)
|
|
524
|
+
return result_of_run(data_path, code, before)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _run_command(command: str, data_dir: Path, secrets: Optional[Mapping[str, str]], given: bool, options: Dict[str, Any], *,
|
|
528
|
+
transport: Any, clock: Callable[[], Any], sleep: Callable[[Any], Any], now: Optional[Callable[[], datetime]],
|
|
529
|
+
jitter: Callable[[], Any], wall: Callable[[], Any]) -> int:
|
|
530
|
+
parquet = bool(options.get("parquet", False))
|
|
531
|
+
if command == "rebuild":
|
|
532
|
+
return run_rebuild(data_dir, parquet=parquet)
|
|
533
|
+
|
|
534
|
+
since = _date_option("since", options.get("since"))
|
|
535
|
+
provider_account_id = options.get("provider_account_id")
|
|
536
|
+
if provider_account_id is None:
|
|
537
|
+
raise TypeError("run(%r) needs the option provider_account_id" % command)
|
|
538
|
+
if command == "backfill" and since is None:
|
|
539
|
+
raise TypeError("run('backfill') needs the option since")
|
|
540
|
+
rate_per_minute = options.get("rate_per_minute", 20)
|
|
541
|
+
max_retry_after = options.get("max_retry_after", MAX_RETRY_AFTER_SECONDS)
|
|
542
|
+
if rate_per_minute < 1:
|
|
543
|
+
print("--rate-per-minute must be at least 1", file=sys.stderr)
|
|
544
|
+
return EXIT_CHECKS
|
|
545
|
+
if max_retry_after < 0:
|
|
546
|
+
print("--max-retry-after must not be negative", file=sys.stderr)
|
|
547
|
+
return EXIT_CHECKS
|
|
548
|
+
key = (secrets or {}).get(KEY_ENV, "") if given else os.environ.get(KEY_ENV, "")
|
|
549
|
+
if not key:
|
|
550
|
+
print("%s must be set in the environment (an Admin key, sk-ant-admin..., of the organisation)" % KEY_ENV, file=sys.stderr)
|
|
551
|
+
return EXIT_CHECKS
|
|
552
|
+
if not provider_account_id.strip():
|
|
553
|
+
print("--provider-account-id must not be empty", file=sys.stderr)
|
|
554
|
+
return EXIT_CHECKS
|
|
555
|
+
|
|
556
|
+
workspace_id = options.get("workspace_id")
|
|
557
|
+
clock_now = now if now is not None else _utc_now
|
|
558
|
+
client = AdminClient(
|
|
559
|
+
key, transport=transport, rate_per_minute=rate_per_minute, clock=clock, sleep=sleep, jitter=jitter, wall=wall,
|
|
560
|
+
max_retry_after=max_retry_after,
|
|
561
|
+
)
|
|
562
|
+
with client:
|
|
563
|
+
if command == "backfill":
|
|
564
|
+
return run_backfill(client, data_dir, since, provider_account_id=provider_account_id, now=clock_now,
|
|
565
|
+
workspace_id=workspace_id, parquet=parquet)
|
|
566
|
+
return run_daily(client, data_dir, provider_account_id=provider_account_id, now=clock_now, workspace_id=workspace_id,
|
|
567
|
+
parquet=parquet, since=since)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def main(
|
|
571
|
+
argv: Optional[Sequence[str]] = None,
|
|
572
|
+
*,
|
|
573
|
+
transport: Any = None,
|
|
574
|
+
clock: Callable[[], Any] = time.monotonic,
|
|
575
|
+
sleep: Callable[[Any], Any] = time.sleep,
|
|
576
|
+
now: Optional[Callable[[], datetime]] = None,
|
|
577
|
+
jitter: Callable[[], Any] = random.random,
|
|
578
|
+
wall: Callable[[], Any] = time.time,
|
|
579
|
+
) -> int:
|
|
580
|
+
"""Run one command from the command line and return its exit code; the work is ``run``. The key is read from the
|
|
581
|
+
environment."""
|
|
582
|
+
args = _build_parser().parse_args(argv)
|
|
583
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
584
|
+
for noisy in ("httpx", "httpcore"): # the HTTP libraries log every request URL
|
|
585
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
586
|
+
options: Dict[str, Any] = {"parquet": args.parquet}
|
|
587
|
+
if args.command != "rebuild":
|
|
588
|
+
options.update(provider_account_id=args.provider_account_id, workspace_id=args.workspace_id, rate_per_minute=args.rate_per_minute,
|
|
589
|
+
max_retry_after=args.max_retry_after, since=args.since)
|
|
590
|
+
result = run(args.command, data_dir=args.data_dir, credentials=None, transport=transport, clock=clock, sleep=sleep, now=now,
|
|
591
|
+
jitter=jitter, wall=wall, **options)
|
|
592
|
+
return result.exit_code
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
if __name__ == "__main__": # pragma: no cover
|
|
596
|
+
sys.exit(main())
|