unalloc 0.2.1__tar.gz → 0.2.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.
- {unalloc-0.2.1 → unalloc-0.2.3}/.gitignore +2 -0
- unalloc-0.2.3/CHANGELOG.md +63 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/PKG-INFO +32 -7
- {unalloc-0.2.1 → unalloc-0.2.3}/README.md +30 -6
- {unalloc-0.2.1 → unalloc-0.2.3}/pyproject.toml +8 -3
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/__init__.py +1 -1
- unalloc-0.2.3/tests/test_docs_build.py +33 -0
- unalloc-0.2.3/tests/test_paper_numbers.py +162 -0
- unalloc-0.2.1/CHANGELOG.md +0 -35
- {unalloc-0.2.1 → unalloc-0.2.3}/LICENSE +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/cli.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/core/__init__.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/core/attribute.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/core/models.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/core/normalize.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/core/reconcile.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/fixtures/anthropic_cost_report.json +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/fixtures/litellm_spend.json +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/fixtures/openai_costs.json +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/fixtures/opencost_allocation.json +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/render/__init__.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/render/table.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/__init__.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/anthropic.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/base.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/litellm.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/openai.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/src/unalloc/sources/opencost.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_attribute.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_cli.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_fetch.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_normalize.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_paper_lint.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_reconcile.py +0 -0
- {unalloc-0.2.1 → unalloc-0.2.3}/tests/test_sources.py +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.3 — 2026-09-21
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Figure 9's within-run spread is drawn as its own vertical bar rather than an error bar hung off the whole-run point. The old form had to clip whenever the whole-run share fell outside the window quartiles, which it does at three of the eight plotted points — the whole-run share is weighted by each window's traffic, so it need not lie inside the quartiles of its own windows.
|
|
7
|
+
- §9 no longer presents the window statistics as a bootstrap confidence interval or as a bound on uncertainty. Consecutive windows of one run are not independent observations, so the paragraph now reports median, interquartile range and sign as descriptive statistics of that run, and the cross-reference matches what the figure actually draws. `bootstrap_ci` is renamed `resample_range`, and the metrics key `divergence_mean_ci95` becomes `divergence_mean_resample_range`.
|
|
8
|
+
- arXiv kit refreshed against the built PDF: 14 pages, AI-use disclosure on page 13, and the concept DOI in the Comments field. The standalone abstract gained the synthetic-scenario qualification and the session-initial arrival-rate clarification that the paper's abstract already carried.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `tests/test_paper_numbers.py` also checks the submission kit: the page, figure and table counts against the committed PDF, the concept DOI in both the kit and the paper, and the standalone abstract's length and scope wording.
|
|
12
|
+
|
|
13
|
+
## 0.2.2 — 2026-09-21
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Paper Table 3: the time-share entries at the 8 and 16 requests/s loads printed the raw shares while the caption promised overhead redistributed; they are 4.7% and 5.3%. The 12–14 point headline result is unaffected — the figure and the divergence metric already redistributed.
|
|
17
|
+
- Paper: the configured load is now stated as the arrival rate of session-initial requests, distinguished throughout from completed throughput (3.7–26.9 requests/s), which includes the follow-up turns of multi-turn sessions.
|
|
18
|
+
- Paper: "none waited in the queue" is now scoped to what was measured — no queued request in any half-second telemetry sample.
|
|
19
|
+
- Paper: the CPU-versus-GPU gap is reported as a difference between two experiments rather than an effect with a measured cause; the fixed-overhead explanation is labelled a hypothesis, since model, software, hardware and workload all differ.
|
|
20
|
+
- Paper: the abstract marks the 66% and 61% distribution results as a constructed scenario built on synthetic allocations.
|
|
21
|
+
- Paper and README: OpenCost's OpenAI plugin and inference accounting are acknowledged, and the novelty claim narrowed to the implementation and the reproducible characterization of the failure modes. FOCUS added as related work.
|
|
22
|
+
- Paper: Shapley is described as a reference for a specified cost function rather than a ground truth for rental bills; the Pope, DistServe, ABACUS and Cost-Governed RAG comparisons are bounded to what those works support; CPU collective fractions are no longer called an upper bound for NVLink GPUs.
|
|
23
|
+
- Bibliography: official proceedings links, retrieval dates for mutable documentation, Shapley page range, and the ABACUS preprint dated to its 22 December 2024 submission.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `bench.py --repeats n`: independent runs per load level, each with a fresh seed, with the load levels interleaved so session drift does not land on one of them. `analyze.py` groups repeats by rate and reports the median and range across them.
|
|
27
|
+
- Within-run stability: both meters recomputed in ten-second windows of each run, with quartiles and a bootstrap confidence interval for the mean, warm-up window excluded. The published single runs now carry error bars in the GPU figure and a confidence interval in §9; none of the intervals approaches zero.
|
|
28
|
+
- `tests/test_paper_numbers.py`: the GPU results table, its prose ranges and its scope claims are checked against `metrics.json`, so a stale table fails the build. This is the test that would have caught the redistribution error above.
|
|
29
|
+
- `paper/REFERENCE-AUDIT.md`: the 23-reference source audit behind the corrections.
|
|
30
|
+
|
|
31
|
+
## 0.2.1 — 2026-09-14
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Paper prepared for arXiv: abstract leads with the attribution failures at the seams between ledgers and the H100 result; metering results are framed as disagreement between rules rather than over-charging, since none is a ground truth; new Related Work section (JouleShare, LLMVisor, PrefixShield, Cost-Governed RAG and others); defect catalogue moved to Appendix A; use of AI tools disclosed.
|
|
35
|
+
- `paper/build.py` fails on Typst that compiles but renders wrong (`~`, `;` after a citation, unescaped dollar amounts).
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- `case_studies/gpu_validation`: benchmark client, fake server, remote setup script, analysis and evidence renderer for validating the metering results with real vLLM on a GPU, plus a DigitalOcean runbook. First run: H100, vLLM 0.29.0, Qwen2.5-7B-Instruct, 6,241 requests, 0 errors.
|
|
39
|
+
- Blog addendum with the verified GPU specs, results and bring-up/teardown evidence.
|
|
40
|
+
- Paper §9, "Validation on a datacenter GPU", with the H100 results table and figure; abstract, methodology table and limitations updated to match.
|
|
41
|
+
|
|
42
|
+
## 0.2.0 — 2026-09-13
|
|
43
|
+
|
|
44
|
+
Validated against five inference case studies; see `paper/unalloc-case-studies.pdf`.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- OpenAI and Anthropic adapters lost their default API URLs whenever the CLI passed an unset environment variable, so every live fetch failed.
|
|
48
|
+
- The Anthropic Admin API is now called with `x-api-key` and `anthropic-version` instead of a Bearer token.
|
|
49
|
+
- Provider cost APIs are paged with `has_more` / `next_page`; previously only the first page was read.
|
|
50
|
+
- OpenCost component costs are summed as `Decimal` when `totalCost` is absent, never through `float`.
|
|
51
|
+
- Stacked label prefixes are all stripped (`label_app_kubernetes_io_name` → `name`).
|
|
52
|
+
- Canonical-key collisions resolve by a fixed precedence instead of arrival order (explicit key, alias, rewritten key, path-derived key).
|
|
53
|
+
- OpenCost annotations no longer override labels with the same key.
|
|
54
|
+
- `unalloc labels` honours `--fallback`, and exits 1 when no rows load instead of printing "Nothing unallocated".
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- `report --budget PCT` exits 2 when the unallocated share exceeds `PCT`, for CI gates.
|
|
58
|
+
- Reports show the spend attributed only through a fallback key (`fallback_usd` in `--json`).
|
|
59
|
+
- Case studies (`case_studies/`), a Typst paper (`paper/`), a ledger explorer, a companion notebook and a dev container with Postgres.
|
|
60
|
+
|
|
61
|
+
## 0.1.0
|
|
62
|
+
|
|
63
|
+
Initial release: OpenCost, LiteLLM, OpenAI and Anthropic adapters; `report`, `labels` and `reconcile`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unalloc
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Find the AI spend nobody owns. Joins OpenCost allocation data with LLM provider bills and reports what's unattributed.
|
|
5
5
|
Project-URL: Homepage, https://github.com/timurista/unalloc
|
|
6
6
|
Project-URL: Issues, https://github.com/timurista/unalloc/issues
|
|
@@ -30,6 +30,7 @@ Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
|
30
30
|
Provides-Extra: research
|
|
31
31
|
Requires-Dist: ipykernel>=6.29; extra == 'research'
|
|
32
32
|
Requires-Dist: jupyterlab>=4.2; extra == 'research'
|
|
33
|
+
Requires-Dist: markdown>=3.6; extra == 'research'
|
|
33
34
|
Requires-Dist: matplotlib>=3.8; extra == 'research'
|
|
34
35
|
Requires-Dist: nbclient>=0.10; extra == 'research'
|
|
35
36
|
Requires-Dist: nbformat>=5.10; extra == 'research'
|
|
@@ -47,9 +48,12 @@ Description-Content-Type: text/markdown
|
|
|
47
48
|
[](https://github.com/timurista/unalloc/blob/main/LICENSE)
|
|
48
49
|
[](https://github.com/timurista/unalloc/blob/main/pyproject.toml)
|
|
49
50
|
[](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)
|
|
50
|
-
[](https://timurista.github.io/unalloc/)
|
|
51
|
+
[](https://timurista.github.io/unalloc/explorer.html)
|
|
52
|
+
[](https://doi.org/10.5281/zenodo.22761012)
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
**Project site: [timurista.github.io/unalloc](https://timurista.github.io/unalloc/)**, with the findings, case studies, [paper (PDF)](https://timurista.github.io/unalloc/paper/unalloc-case-studies.pdf), [blog post](https://timurista.github.io/unalloc/blog/who-pays-for-the-kv-cache.html), [GPU addendum](https://timurista.github.io/unalloc/blog/gpu-validation.html) and [ledger explorer](https://timurista.github.io/unalloc/explorer.html).
|
|
55
|
+
|
|
56
|
+
OpenCost tells you what your Kubernetes workloads cost. Your provider dashboard tells you what your OpenAI and Anthropic calls cost. Getting both into one view is possible today — OpenCost has an OpenAI plugin — but the answer still depends on a join nobody checks: the same dollar can arrive through both your gateway and your provider bill, and the labels that say who owns it are set per workload, not per pod template. `unalloc` pulls all four sources into one normalized ledger, joins them on a label dimension you choose, and reports the number your finance team keeps asking for: how much of this month's AI spend can't be attributed to any team — and how much of the rest was only rescued by a fallback key.
|
|
53
57
|
|
|
54
58
|
## The output
|
|
55
59
|
|
|
@@ -189,7 +193,7 @@ Two design choices worth calling out:
|
|
|
189
193
|
|
|
190
194
|
## Case studies and paper
|
|
191
195
|
|
|
192
|
-
|
|
196
|
+
Six studies push real (or realistically simulated) inference workloads through unalloc's own adapters, including a run with vLLM on an NVIDIA H100. They exposed nine defects in the tool along the way, all fixed with regression tests, and they're written up as a paper: **[paper/unalloc-case-studies.pdf](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)**. For the readable version, see the [blog post](https://timurista.github.io/unalloc/blog/who-pays-for-the-kv-cache.html) and the [GPU addendum](https://timurista.github.io/unalloc/blog/gpu-validation.html) on the [project site](https://timurista.github.io/unalloc/).
|
|
193
197
|
|
|
194
198
|
| Study | What runs | Headline |
|
|
195
199
|
| --- | --- | --- |
|
|
@@ -201,7 +205,8 @@ Five studies push real (or realistically simulated) inference workloads through
|
|
|
201
205
|
| [`gpu_validation`](https://github.com/timurista/unalloc/blob/main/case_studies/gpu_validation) | Real vLLM 0.29 serving Qwen2.5-7B on a DigitalOcean H100, driven with the simulator's tenants ([runbook](https://github.com/timurista/unalloc/blob/main/case_studies/gpu_validation/RUNBOOK.md), [evidence](https://github.com/timurista/unalloc/tree/main/case_studies/results/gpu_validation/evidence)) | Token metering over-charges search by 12–14 points vs time share; GPU "utilization" reads 97% at 2 req/s and 99% at 16 req/s |
|
|
202
206
|
|
|
203
207
|
```bash
|
|
204
|
-
make research # CPU torch, notebook tooling, typst
|
|
208
|
+
make research # build ./.venv-research: CPU torch, notebook tooling, typst
|
|
209
|
+
# (the targets below find it on their own; or pass PY=...)
|
|
205
210
|
make case-studies # all five, full size (a few minutes)
|
|
206
211
|
make paper # figures + PDF
|
|
207
212
|
make notebook # execute case_studies/unalloc_case_studies.ipynb
|
|
@@ -210,7 +215,7 @@ make ui # ledger explorer on http://127.0.0.1:8765
|
|
|
210
215
|
|
|
211
216
|

|
|
212
217
|
|
|
213
|
-
The **[ledger explorer](https://timurista.github.io/unalloc/)** (or `python -m case_studies.ui` locally) shows every dataset row by row: pick the label that means "owner", add fallbacks, and watch each row resolve to a team, a fallback value, or the backlog. `python -m case_studies.ui --export explorer.html` writes a self-contained copy you can share.
|
|
218
|
+
The **[ledger explorer](https://timurista.github.io/unalloc/explorer.html)** (or `python -m case_studies.ui` locally) shows every dataset row by row: pick the label that means "owner", add fallbacks, and watch each row resolve to a team, a fallback value, or the backlog. `python -m case_studies.ui --export explorer.html` writes a self-contained copy you can share.
|
|
214
219
|
|
|
215
220
|
## Development environment
|
|
216
221
|
|
|
@@ -225,7 +230,9 @@ make devcontainer-check # lint, tests and every case study at smoke size, ins
|
|
|
225
230
|
|
|
226
231
|
OpenCost 1.121.0 added inference cost tracking for vLLM and llm-d deployments: cost per million tokens, KV-cache-corrected pricing, shared infrastructure attribution. That is excellent and `unalloc` does not reimplement it. Those rows arrive through the same `/allocation` shape and land in this ledger tagged `unalloc_layer=inference`.
|
|
227
232
|
|
|
228
|
-
|
|
233
|
+
OpenCost can also bring external provider spend alongside Kubernetes cost through its plugin interface, including an OpenAI plugin announced in November 2024. So "Kubernetes cost and provider cost in one place" is not a gap `unalloc` invented, and it is not the claim here.
|
|
234
|
+
|
|
235
|
+
What `unalloc` does is narrower: read all four sources yourself — OpenCost, a LiteLLM gateway, and the OpenAI and Anthropic billing APIs directly — and answer one question about the join. Which dollars carry no owner, which were rescued only by a fallback key that may be pointing at a Helm chart rather than a team, and which arrived twice because the gateway and the provider both reported them. That is the seam it lives in, and nowhere else. It is:
|
|
229
236
|
|
|
230
237
|
- **not** a dashboard — it prints a number and exits (the explorer is a case-study companion, not part of the CLI)
|
|
231
238
|
- **not** an optimizer — it makes no recommendations about rightsizing or commitments
|
|
@@ -250,6 +257,24 @@ pip install -e ".[dev]"
|
|
|
250
257
|
ruff check . && pytest
|
|
251
258
|
```
|
|
252
259
|
|
|
260
|
+
## Citing
|
|
261
|
+
|
|
262
|
+
If you use unalloc or its case studies, please cite the archived release. GitHub's "Cite this repository" button produces the same entry from [`CITATION.cff`](https://github.com/timurista/unalloc/blob/main/CITATION.cff).
|
|
263
|
+
|
|
264
|
+
- **All versions:** [doi:10.5281/zenodo.22761012](https://doi.org/10.5281/zenodo.22761012), which always resolves to the latest release — currently 0.2.3, the code the paper describes
|
|
265
|
+
- **Version 0.2.1:** [doi:10.5281/zenodo.22761013](https://doi.org/10.5281/zenodo.22761013)
|
|
266
|
+
|
|
267
|
+
```bibtex
|
|
268
|
+
@software{urista_unalloc_2026,
|
|
269
|
+
author = {Urista, Timothy},
|
|
270
|
+
title = {unalloc: find the AI spend nobody owns},
|
|
271
|
+
version = {0.2.3},
|
|
272
|
+
year = {2026},
|
|
273
|
+
doi = {10.5281/zenodo.22761012},
|
|
274
|
+
url = {https://github.com/timurista/unalloc}
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
253
278
|
## License
|
|
254
279
|
|
|
255
280
|
Apache 2.0, matching OpenCost.
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
[](https://github.com/timurista/unalloc/blob/main/LICENSE)
|
|
7
7
|
[](https://github.com/timurista/unalloc/blob/main/pyproject.toml)
|
|
8
8
|
[](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)
|
|
9
|
-
[](https://timurista.github.io/unalloc/)
|
|
9
|
+
[](https://timurista.github.io/unalloc/explorer.html)
|
|
10
|
+
[](https://doi.org/10.5281/zenodo.22761012)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
**Project site: [timurista.github.io/unalloc](https://timurista.github.io/unalloc/)**, with the findings, case studies, [paper (PDF)](https://timurista.github.io/unalloc/paper/unalloc-case-studies.pdf), [blog post](https://timurista.github.io/unalloc/blog/who-pays-for-the-kv-cache.html), [GPU addendum](https://timurista.github.io/unalloc/blog/gpu-validation.html) and [ledger explorer](https://timurista.github.io/unalloc/explorer.html).
|
|
13
|
+
|
|
14
|
+
OpenCost tells you what your Kubernetes workloads cost. Your provider dashboard tells you what your OpenAI and Anthropic calls cost. Getting both into one view is possible today — OpenCost has an OpenAI plugin — but the answer still depends on a join nobody checks: the same dollar can arrive through both your gateway and your provider bill, and the labels that say who owns it are set per workload, not per pod template. `unalloc` pulls all four sources into one normalized ledger, joins them on a label dimension you choose, and reports the number your finance team keeps asking for: how much of this month's AI spend can't be attributed to any team — and how much of the rest was only rescued by a fallback key.
|
|
12
15
|
|
|
13
16
|
## The output
|
|
14
17
|
|
|
@@ -148,7 +151,7 @@ Two design choices worth calling out:
|
|
|
148
151
|
|
|
149
152
|
## Case studies and paper
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
Six studies push real (or realistically simulated) inference workloads through unalloc's own adapters, including a run with vLLM on an NVIDIA H100. They exposed nine defects in the tool along the way, all fixed with regression tests, and they're written up as a paper: **[paper/unalloc-case-studies.pdf](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)**. For the readable version, see the [blog post](https://timurista.github.io/unalloc/blog/who-pays-for-the-kv-cache.html) and the [GPU addendum](https://timurista.github.io/unalloc/blog/gpu-validation.html) on the [project site](https://timurista.github.io/unalloc/).
|
|
152
155
|
|
|
153
156
|
| Study | What runs | Headline |
|
|
154
157
|
| --- | --- | --- |
|
|
@@ -160,7 +163,8 @@ Five studies push real (or realistically simulated) inference workloads through
|
|
|
160
163
|
| [`gpu_validation`](https://github.com/timurista/unalloc/blob/main/case_studies/gpu_validation) | Real vLLM 0.29 serving Qwen2.5-7B on a DigitalOcean H100, driven with the simulator's tenants ([runbook](https://github.com/timurista/unalloc/blob/main/case_studies/gpu_validation/RUNBOOK.md), [evidence](https://github.com/timurista/unalloc/tree/main/case_studies/results/gpu_validation/evidence)) | Token metering over-charges search by 12–14 points vs time share; GPU "utilization" reads 97% at 2 req/s and 99% at 16 req/s |
|
|
161
164
|
|
|
162
165
|
```bash
|
|
163
|
-
make research # CPU torch, notebook tooling, typst
|
|
166
|
+
make research # build ./.venv-research: CPU torch, notebook tooling, typst
|
|
167
|
+
# (the targets below find it on their own; or pass PY=...)
|
|
164
168
|
make case-studies # all five, full size (a few minutes)
|
|
165
169
|
make paper # figures + PDF
|
|
166
170
|
make notebook # execute case_studies/unalloc_case_studies.ipynb
|
|
@@ -169,7 +173,7 @@ make ui # ledger explorer on http://127.0.0.1:8765
|
|
|
169
173
|
|
|
170
174
|

|
|
171
175
|
|
|
172
|
-
The **[ledger explorer](https://timurista.github.io/unalloc/)** (or `python -m case_studies.ui` locally) shows every dataset row by row: pick the label that means "owner", add fallbacks, and watch each row resolve to a team, a fallback value, or the backlog. `python -m case_studies.ui --export explorer.html` writes a self-contained copy you can share.
|
|
176
|
+
The **[ledger explorer](https://timurista.github.io/unalloc/explorer.html)** (or `python -m case_studies.ui` locally) shows every dataset row by row: pick the label that means "owner", add fallbacks, and watch each row resolve to a team, a fallback value, or the backlog. `python -m case_studies.ui --export explorer.html` writes a self-contained copy you can share.
|
|
173
177
|
|
|
174
178
|
## Development environment
|
|
175
179
|
|
|
@@ -184,7 +188,9 @@ make devcontainer-check # lint, tests and every case study at smoke size, ins
|
|
|
184
188
|
|
|
185
189
|
OpenCost 1.121.0 added inference cost tracking for vLLM and llm-d deployments: cost per million tokens, KV-cache-corrected pricing, shared infrastructure attribution. That is excellent and `unalloc` does not reimplement it. Those rows arrive through the same `/allocation` shape and land in this ledger tagged `unalloc_layer=inference`.
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
OpenCost can also bring external provider spend alongside Kubernetes cost through its plugin interface, including an OpenAI plugin announced in November 2024. So "Kubernetes cost and provider cost in one place" is not a gap `unalloc` invented, and it is not the claim here.
|
|
192
|
+
|
|
193
|
+
What `unalloc` does is narrower: read all four sources yourself — OpenCost, a LiteLLM gateway, and the OpenAI and Anthropic billing APIs directly — and answer one question about the join. Which dollars carry no owner, which were rescued only by a fallback key that may be pointing at a Helm chart rather than a team, and which arrived twice because the gateway and the provider both reported them. That is the seam it lives in, and nowhere else. It is:
|
|
188
194
|
|
|
189
195
|
- **not** a dashboard — it prints a number and exits (the explorer is a case-study companion, not part of the CLI)
|
|
190
196
|
- **not** an optimizer — it makes no recommendations about rightsizing or commitments
|
|
@@ -209,6 +215,24 @@ pip install -e ".[dev]"
|
|
|
209
215
|
ruff check . && pytest
|
|
210
216
|
```
|
|
211
217
|
|
|
218
|
+
## Citing
|
|
219
|
+
|
|
220
|
+
If you use unalloc or its case studies, please cite the archived release. GitHub's "Cite this repository" button produces the same entry from [`CITATION.cff`](https://github.com/timurista/unalloc/blob/main/CITATION.cff).
|
|
221
|
+
|
|
222
|
+
- **All versions:** [doi:10.5281/zenodo.22761012](https://doi.org/10.5281/zenodo.22761012), which always resolves to the latest release — currently 0.2.3, the code the paper describes
|
|
223
|
+
- **Version 0.2.1:** [doi:10.5281/zenodo.22761013](https://doi.org/10.5281/zenodo.22761013)
|
|
224
|
+
|
|
225
|
+
```bibtex
|
|
226
|
+
@software{urista_unalloc_2026,
|
|
227
|
+
author = {Urista, Timothy},
|
|
228
|
+
title = {unalloc: find the AI spend nobody owns},
|
|
229
|
+
version = {0.2.3},
|
|
230
|
+
year = {2026},
|
|
231
|
+
doi = {10.5281/zenodo.22761012},
|
|
232
|
+
url = {https://github.com/timurista/unalloc}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
212
236
|
## License
|
|
213
237
|
|
|
214
238
|
Apache 2.0, matching OpenCost.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unalloc"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Find the AI spend nobody owns. Joins OpenCost allocation data with LLM provider bills and reports what's unattributed."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -40,6 +40,7 @@ research = [
|
|
|
40
40
|
"ipykernel>=6.29",
|
|
41
41
|
"jupyterlab>=4.2",
|
|
42
42
|
"psycopg[binary]>=3.2",
|
|
43
|
+
"markdown>=3.6",
|
|
43
44
|
]
|
|
44
45
|
|
|
45
46
|
[project.urls]
|
|
@@ -69,8 +70,12 @@ extend-exclude = ["*.ipynb"]
|
|
|
69
70
|
[tool.ruff.lint]
|
|
70
71
|
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
71
72
|
ignore = ["B008"]
|
|
72
|
-
# Figure labels use real typographic signs ("36×", "÷").
|
|
73
|
-
allowed-confusables = ["×", "÷"]
|
|
73
|
+
# Figure labels and page copy use real typographic signs ("36×", "÷", "12–14").
|
|
74
|
+
allowed-confusables = ["×", "÷", "–"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
# HTML page templates; wrapping them for line length hurts more than it helps.
|
|
78
|
+
"docs_build/__main__.py" = ["E501"]
|
|
74
79
|
|
|
75
80
|
[tool.pytest.ini_options]
|
|
76
81
|
testpaths = ["tests"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The Pages site builds from the repository with every local link resolving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("markdown")
|
|
13
|
+
|
|
14
|
+
from docs_build.__main__ import _rewrite, build
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_site_builds_with_no_broken_links(tmp_path):
|
|
18
|
+
written = build(tmp_path / "docs")
|
|
19
|
+
names = {p.relative_to(tmp_path / "docs").as_posix() for p in written}
|
|
20
|
+
assert {"index.html", "explorer.html", "blog/who-pays-for-the-kv-cache.html",
|
|
21
|
+
"blog/gpu-validation.html"} <= names
|
|
22
|
+
assert (tmp_path / "docs" / "paper" / "unalloc-case-studies.pdf").stat().st_size > 100_000
|
|
23
|
+
landing = (tmp_path / "docs" / "index.html").read_text()
|
|
24
|
+
assert "paper/unalloc-case-studies.pdf" in landing
|
|
25
|
+
post = (tmp_path / "docs" / "blog" / "who-pays-for-the-kv-cache.html").read_text()
|
|
26
|
+
assert 'href="../explorer.html"' in post
|
|
27
|
+
assert ".md\"" not in post
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_unknown_blog_link_fails_the_build():
|
|
31
|
+
assert _rewrite("../paper/figures/kv_shares.png") == "../assets/figures/kv_shares.png"
|
|
32
|
+
with pytest.raises(SystemExit):
|
|
33
|
+
_rewrite("notes/draft.md")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Every number in the GPU results table must still match the measured run.
|
|
2
|
+
|
|
3
|
+
The published table once printed two time-share entries without redistributing
|
|
4
|
+
overhead, which no test could see: the figure code redistributes, the table was
|
|
5
|
+
typed by hand. This reads the numbers back out of the Typst source and compares
|
|
6
|
+
them against `metrics.json`, so a stale table fails the build rather than a
|
|
7
|
+
reviewer's arithmetic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
SOURCE = ROOT / "paper" / "unalloc.typ"
|
|
20
|
+
METRICS = ROOT / "case_studies" / "results" / "gpu_validation" / "metrics.json"
|
|
21
|
+
|
|
22
|
+
pytestmark = pytest.mark.skipif(not METRICS.exists(), reason="GPU metrics not present")
|
|
23
|
+
|
|
24
|
+
# [2], [446 (3.7)], [773], [67%], [28 / 44], [6.3], [97%], [469], [16.5%], [4.8%],
|
|
25
|
+
ROW = re.compile(
|
|
26
|
+
r"^\s*\[(?P<load>\d+)\], \[(?P<reqs>[\d,]+) \((?P<done>[\d.]+)\)\], \[(?P<out>[\d,]+)\], "
|
|
27
|
+
r"\[(?P<cache>\d+)%\], \[(?P<ttft50>[\d.]+) / (?P<ttft95>[\d.]+)\], \[(?P<tpot>[\d.]+)\], "
|
|
28
|
+
r"\[(?P<util>\d+)%\], \[(?P<power>\d+)\], \[(?P<tok>[\d.]+)%\], \[(?P<time>[\d.]+)%\],\s*$",
|
|
29
|
+
re.MULTILINE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _num(text: str) -> float:
|
|
34
|
+
return float(text.replace(",", ""))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _close(printed: float, actual: float, places: int) -> bool:
|
|
38
|
+
"""Printed to `places` decimals is honest if it is within half a unit there."""
|
|
39
|
+
return abs(printed - actual) <= 0.5 * 10**-places + 1e-9
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture(scope="module")
|
|
43
|
+
def rows():
|
|
44
|
+
runs = {r["rate_rps"]: r for r in json.loads(METRICS.read_text())["runs"]}
|
|
45
|
+
printed = [m.groupdict() for m in ROW.finditer(SOURCE.read_text())]
|
|
46
|
+
assert len(printed) == len(runs), f"table has {len(printed)} rows, data has {len(runs)}"
|
|
47
|
+
return [(p, runs[float(p["load"])]) for p in printed]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_table_reports_completed_requests_and_throughput(rows):
|
|
51
|
+
for p, run in rows:
|
|
52
|
+
assert _num(p["reqs"]) == run["requests"]
|
|
53
|
+
assert _close(_num(p["done"]), run["requests"] / run["wall_s"], 1), p["load"]
|
|
54
|
+
assert _close(_num(p["out"]), run["output_tokens_per_s"], 0), p["load"]
|
|
55
|
+
assert _close(_num(p["cache"]), run["cache_hit_rate_usage"] * 100, 0), p["load"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_table_latency_and_device_columns(rows):
|
|
59
|
+
for p, run in rows:
|
|
60
|
+
lat = run["latency"]["all"]
|
|
61
|
+
assert _close(_num(p["ttft50"]), lat["ttft_s"]["p50"] * 1000, 0), p["load"]
|
|
62
|
+
assert _close(_num(p["ttft95"]), lat["ttft_s"]["p95"] * 1000, 0), p["load"]
|
|
63
|
+
assert _close(_num(p["tpot"]), lat["tpot_s"]["p50"] * 1000, 1), p["load"]
|
|
64
|
+
assert _close(_num(p["util"]), run["gpu"]["gpu_util_pct_mean"], 0), p["load"]
|
|
65
|
+
assert _close(_num(p["power"]), run["gpu"]["power_w_mean"], 0), p["load"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_meter_shares_are_printed_with_overhead_redistributed(rows):
|
|
69
|
+
"""The caption promises redistribution; both meter columns must honour it."""
|
|
70
|
+
for p, run in rows:
|
|
71
|
+
for column, meter in (("tok", "tokens"), ("time", "time_share")):
|
|
72
|
+
split = run["meters"][meter]
|
|
73
|
+
redistributed = split["search"] / (1 - split["__overhead__"]) * 100
|
|
74
|
+
assert _close(_num(p[column]), redistributed, 1), (
|
|
75
|
+
f"load {p['load']} {meter}: paper prints {p[column]}%, "
|
|
76
|
+
f"redistributed is {redistributed:.3f}% "
|
|
77
|
+
f"(raw {split['search'] * 100:.3f}%)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_prose_ranges_match_the_table(rows):
|
|
82
|
+
text = " ".join(SOURCE.read_text().split())
|
|
83
|
+
tok = [_num(p["tok"]) for p, _ in rows]
|
|
84
|
+
share = [_num(p["time"]) for p, _ in rows]
|
|
85
|
+
assert f"search {min(tok):.1f}–{max(tok):.1f}% of the bill" in text
|
|
86
|
+
assert f"time-share meter assigns {min(share):.1f}–{max(share):.1f}%" in text
|
|
87
|
+
|
|
88
|
+
divergence = [run["divergence_search_tokens_vs_time_pts"] for _, run in rows]
|
|
89
|
+
lo, hi = min(divergence), max(divergence)
|
|
90
|
+
assert f"a disagreement of {lo:.1f}–{hi:.1f} points" in text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_totals_and_error_free_claim(rows):
|
|
94
|
+
flowed = " ".join(SOURCE.read_text().split())
|
|
95
|
+
total = sum(run["requests"] for _, run in rows)
|
|
96
|
+
assert f"All {total:,} requests completed without error" in flowed
|
|
97
|
+
assert all(run["errors"] == 0 for _, run in rows)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_queue_claim_is_scoped_to_the_sampling_interval(rows):
|
|
101
|
+
"""max_waiting is a half-second Prometheus sample, not a continuous guarantee."""
|
|
102
|
+
text = " ".join(SOURCE.read_text().split())
|
|
103
|
+
assert all(run["max_waiting"] == 0 for _, run in rows)
|
|
104
|
+
assert "no queued request was observed in any half-second telemetry sample" in text
|
|
105
|
+
assert "none waited in the queue" not in text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_configured_rate_is_distinguished_from_completed_traffic(rows):
|
|
109
|
+
text = " ".join(SOURCE.read_text().split())
|
|
110
|
+
assert "session-initial" in text
|
|
111
|
+
done = [run["requests"] / run["wall_s"] for _, run in rows]
|
|
112
|
+
assert f"{min(done):.1f} to {max(done):.1f} completed requests per second" in text
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- the arXiv submission kit must describe the PDF that is actually committed ---
|
|
116
|
+
|
|
117
|
+
PDF = ROOT / "paper" / "unalloc-case-studies.pdf"
|
|
118
|
+
KIT = ROOT / "paper" / "arxiv" / "SUBMISSION.md"
|
|
119
|
+
ABSTRACT = ROOT / "paper" / "arxiv" / "abstract.txt"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _pdf_pages(path: Path) -> int:
|
|
123
|
+
"""Page count from the PDF's page tree, without a PDF library."""
|
|
124
|
+
blob = path.read_bytes()
|
|
125
|
+
counts = re.findall(rb"/Type\s*/Pages[^>]*?/Count\s+(\d+)", blob)
|
|
126
|
+
assert counts, "no page tree found in the PDF"
|
|
127
|
+
return max(int(c) for c in counts)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.skipif(not PDF.exists() or not KIT.exists(), reason="paper artifacts not present")
|
|
131
|
+
def test_submission_comments_match_the_built_pdf():
|
|
132
|
+
kit = KIT.read_text()
|
|
133
|
+
pages = _pdf_pages(PDF)
|
|
134
|
+
assert f"| Comments | {pages} pages," in kit, (
|
|
135
|
+
f"SUBMISSION.md Comments claims a different length; the PDF has {pages} pages"
|
|
136
|
+
)
|
|
137
|
+
source = SOURCE.read_text()
|
|
138
|
+
figures = source.count("#figure(image(")
|
|
139
|
+
tables = len(re.findall(r"^#figure\($", source, re.MULTILINE))
|
|
140
|
+
assert f"{pages} pages, {figures} figures, {tables} tables" in kit
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.skipif(not KIT.exists(), reason="submission kit not present")
|
|
144
|
+
def test_submission_kit_cites_the_concept_doi():
|
|
145
|
+
"""A version DOI goes stale on the next release; the concept DOI does not."""
|
|
146
|
+
kit = KIT.read_text()
|
|
147
|
+
concept = "10.5281/zenodo.22761012"
|
|
148
|
+
assert f"Software: doi:{concept}" in kit
|
|
149
|
+
assert concept in SOURCE.read_text(), "the paper itself should cite the same DOI"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.skipif(not ABSTRACT.exists(), reason="abstract not present")
|
|
153
|
+
def test_standalone_abstract_carries_the_same_qualifications_as_the_paper():
|
|
154
|
+
text = ABSTRACT.read_text().strip()
|
|
155
|
+
assert len(text) <= 1920, f"arXiv caps the abstract at 1920 characters; this is {len(text)}"
|
|
156
|
+
# The two scope corrections must survive in the copy that readers see first.
|
|
157
|
+
assert "constructed multi-pod deployment scenario" in text
|
|
158
|
+
assert "synthetic OpenCost allocations" in text
|
|
159
|
+
assert "session-initial" in text
|
|
160
|
+
assert "completed requests per second" in text
|
|
161
|
+
# The bare claim the review flagged must not come back.
|
|
162
|
+
assert "97-99% from 2 to 16 requests per second" not in text
|
unalloc-0.2.1/CHANGELOG.md
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.2.1 — 2026-09-14
|
|
4
|
-
|
|
5
|
-
### Changed
|
|
6
|
-
- Paper prepared for arXiv: abstract leads with the attribution failures at the seams between ledgers and the H100 result; metering results are framed as disagreement between rules rather than over-charging, since none is a ground truth; new Related Work section (JouleShare, LLMVisor, PrefixShield, Cost-Governed RAG and others); defect catalogue moved to Appendix A; use of AI tools disclosed.
|
|
7
|
-
- `paper/build.py` fails on Typst that compiles but renders wrong (`~`, `;` after a citation, unescaped dollar amounts).
|
|
8
|
-
|
|
9
|
-
### Added
|
|
10
|
-
- `case_studies/gpu_validation`: benchmark client, fake server, remote setup script, analysis and evidence renderer for validating the metering results with real vLLM on a GPU, plus a DigitalOcean runbook. First run: H100, vLLM 0.29.0, Qwen2.5-7B-Instruct, 6,241 requests, 0 errors.
|
|
11
|
-
- Blog addendum with the verified GPU specs, results and bring-up/teardown evidence.
|
|
12
|
-
- Paper §9, "Validation on a datacenter GPU", with the H100 results table and figure; abstract, methodology table and limitations updated to match.
|
|
13
|
-
|
|
14
|
-
## 0.2.0 — 2026-09-13
|
|
15
|
-
|
|
16
|
-
Validated against five inference case studies; see `paper/unalloc-case-studies.pdf`.
|
|
17
|
-
|
|
18
|
-
### Fixed
|
|
19
|
-
- OpenAI and Anthropic adapters lost their default API URLs whenever the CLI passed an unset environment variable, so every live fetch failed.
|
|
20
|
-
- The Anthropic Admin API is now called with `x-api-key` and `anthropic-version` instead of a Bearer token.
|
|
21
|
-
- Provider cost APIs are paged with `has_more` / `next_page`; previously only the first page was read.
|
|
22
|
-
- OpenCost component costs are summed as `Decimal` when `totalCost` is absent, never through `float`.
|
|
23
|
-
- Stacked label prefixes are all stripped (`label_app_kubernetes_io_name` → `name`).
|
|
24
|
-
- Canonical-key collisions resolve by a fixed precedence instead of arrival order (explicit key, alias, rewritten key, path-derived key).
|
|
25
|
-
- OpenCost annotations no longer override labels with the same key.
|
|
26
|
-
- `unalloc labels` honours `--fallback`, and exits 1 when no rows load instead of printing "Nothing unallocated".
|
|
27
|
-
|
|
28
|
-
### Added
|
|
29
|
-
- `report --budget PCT` exits 2 when the unallocated share exceeds `PCT`, for CI gates.
|
|
30
|
-
- Reports show the spend attributed only through a fallback key (`fallback_usd` in `--json`).
|
|
31
|
-
- Case studies (`case_studies/`), a Typst paper (`paper/`), a ledger explorer, a companion notebook and a dev container with Postgres.
|
|
32
|
-
|
|
33
|
-
## 0.1.0
|
|
34
|
-
|
|
35
|
-
Initial release: OpenCost, LiteLLM, OpenAI and Anthropic adapters; `report`, `labels` and `reconcile`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|