unalloc 0.2.0__tar.gz → 0.2.2__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.
Files changed (35) hide show
  1. {unalloc-0.2.0 → unalloc-0.2.2}/.gitignore +2 -0
  2. unalloc-0.2.2/CHANGELOG.md +53 -0
  3. {unalloc-0.2.0 → unalloc-0.2.2}/PKG-INFO +33 -7
  4. {unalloc-0.2.0 → unalloc-0.2.2}/README.md +31 -6
  5. {unalloc-0.2.0 → unalloc-0.2.2}/pyproject.toml +8 -3
  6. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/__init__.py +1 -1
  7. unalloc-0.2.2/tests/test_docs_build.py +33 -0
  8. unalloc-0.2.2/tests/test_paper_lint.py +30 -0
  9. unalloc-0.2.2/tests/test_paper_numbers.py +112 -0
  10. unalloc-0.2.0/CHANGELOG.md +0 -24
  11. {unalloc-0.2.0 → unalloc-0.2.2}/LICENSE +0 -0
  12. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/cli.py +0 -0
  13. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/__init__.py +0 -0
  14. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/attribute.py +0 -0
  15. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/models.py +0 -0
  16. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/normalize.py +0 -0
  17. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/reconcile.py +0 -0
  18. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/anthropic_cost_report.json +0 -0
  19. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/litellm_spend.json +0 -0
  20. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/openai_costs.json +0 -0
  21. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/opencost_allocation.json +0 -0
  22. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/render/__init__.py +0 -0
  23. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/render/table.py +0 -0
  24. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/__init__.py +0 -0
  25. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/anthropic.py +0 -0
  26. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/base.py +0 -0
  27. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/litellm.py +0 -0
  28. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/openai.py +0 -0
  29. {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/opencost.py +0 -0
  30. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_attribute.py +0 -0
  31. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_cli.py +0 -0
  32. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_fetch.py +0 -0
  33. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_normalize.py +0 -0
  34. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_reconcile.py +0 -0
  35. {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_sources.py +0 -0
@@ -2,6 +2,7 @@ __pycache__/
2
2
  *.py[cod]
3
3
  .venv/
4
4
  venv/
5
+ .venv-research/
5
6
  dist/
6
7
  build/
7
8
  *.egg-info/
@@ -16,3 +17,4 @@ build/
16
17
  .claude/settings.local.json
17
18
  .ipynb_checkpoints/
18
19
  .cache/
20
+ .tmp/
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## 0.2.2 — 2026-09-21
4
+
5
+ ### Fixed
6
+ - 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.
7
+ - 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.
8
+ - Paper: "none waited in the queue" is now scoped to what was measured — no queued request in any half-second telemetry sample.
9
+ - 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.
10
+ - Paper: the abstract marks the 66% and 61% distribution results as a constructed scenario built on synthetic allocations.
11
+ - 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.
12
+ - 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.
13
+ - Bibliography: official proceedings links, retrieval dates for mutable documentation, Shapley page range, and the ABACUS preprint dated to its 22 December 2024 submission.
14
+
15
+ ### Added
16
+ - `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.
17
+ - 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.
18
+ - `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.
19
+ - `paper/REFERENCE-AUDIT.md`: the 23-reference source audit behind the corrections.
20
+
21
+ ## 0.2.1 — 2026-09-14
22
+
23
+ ### Changed
24
+ - 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.
25
+ - `paper/build.py` fails on Typst that compiles but renders wrong (`~`, `;` after a citation, unescaped dollar amounts).
26
+
27
+ ### Added
28
+ - `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.
29
+ - Blog addendum with the verified GPU specs, results and bring-up/teardown evidence.
30
+ - Paper §9, "Validation on a datacenter GPU", with the H100 results table and figure; abstract, methodology table and limitations updated to match.
31
+
32
+ ## 0.2.0 — 2026-09-13
33
+
34
+ Validated against five inference case studies; see `paper/unalloc-case-studies.pdf`.
35
+
36
+ ### Fixed
37
+ - OpenAI and Anthropic adapters lost their default API URLs whenever the CLI passed an unset environment variable, so every live fetch failed.
38
+ - The Anthropic Admin API is now called with `x-api-key` and `anthropic-version` instead of a Bearer token.
39
+ - Provider cost APIs are paged with `has_more` / `next_page`; previously only the first page was read.
40
+ - OpenCost component costs are summed as `Decimal` when `totalCost` is absent, never through `float`.
41
+ - Stacked label prefixes are all stripped (`label_app_kubernetes_io_name` → `name`).
42
+ - Canonical-key collisions resolve by a fixed precedence instead of arrival order (explicit key, alias, rewritten key, path-derived key).
43
+ - OpenCost annotations no longer override labels with the same key.
44
+ - `unalloc labels` honours `--fallback`, and exits 1 when no rows load instead of printing "Nothing unallocated".
45
+
46
+ ### Added
47
+ - `report --budget PCT` exits 2 when the unallocated share exceeds `PCT`, for CI gates.
48
+ - Reports show the spend attributed only through a fallback key (`fallback_usd` in `--json`).
49
+ - Case studies (`case_studies/`), a Typst paper (`paper/`), a ledger explorer, a companion notebook and a dev container with Postgres.
50
+
51
+ ## 0.1.0
52
+
53
+ 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.0
3
+ Version: 0.2.2
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
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/timurista/unalloc/blob/main/LICENSE)
48
49
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://github.com/timurista/unalloc/blob/main/pyproject.toml)
49
50
  [![Paper](https://img.shields.io/badge/paper-PDF-1f5b45.svg)](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)
50
- [![Explorer](https://img.shields.io/badge/explorer-live-1f5b45.svg)](https://timurista.github.io/unalloc/)
51
+ [![Explorer](https://img.shields.io/badge/explorer-live-1f5b45.svg)](https://timurista.github.io/unalloc/explorer.html)
52
+ [![DOI](https://zenodo.org/badge/1368528624.svg)](https://doi.org/10.5281/zenodo.22761012)
51
53
 
52
- OpenCost tells you what your Kubernetes workloads cost. Your provider dashboard tells you what your OpenAI and Anthropic calls cost. Neither tells you what a single AI feature costs, because the two live in different systems with different keys. `unalloc` pulls both 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.
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
- Five studies push real (or realistically simulated) inference workloads through unalloc's own adapters. 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)**.
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
  | --- | --- | --- |
@@ -198,9 +202,11 @@ Five studies push real (or realistically simulated) inference workloads through
198
202
  | [`distributed`](https://github.com/timurista/unalloc/blob/main/case_studies/distributed) | Tensor and pipeline parallel on `torch.distributed` (gloo), verified against a single-process reference | Owner label on LeaderWorkerSet leaders only: 66% unallocated; falling back to `name` "fixes" it by sending 61% to a Helm chart name |
199
203
  | [`hybrid_e2e`](https://github.com/timurista/unalloc/blob/main/case_studies/hybrid_e2e) | The real CLI as a subprocess against live mock OpenCost / LiteLLM / OpenAI / Anthropic APIs (Postgres-backed LiteLLM in the dev container) | Enabling every source double counts $11.8K; reading only page one of the billing APIs reports 25% of spend |
200
204
  | [`use_cases`](https://github.com/timurista/unalloc/blob/main/case_studies/use_cases) | Labeling Pareto, per-feature unit economics, self-host break-even, CI budget gate | Three label fixes take a 67%-unallocated org under 10% |
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 |
201
206
 
202
207
  ```bash
203
- 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=...)
204
210
  make case-studies # all five, full size (a few minutes)
205
211
  make paper # figures + PDF
206
212
  make notebook # execute case_studies/unalloc_case_studies.ipynb
@@ -209,7 +215,7 @@ make ui # ledger explorer on http://127.0.0.1:8765
209
215
 
210
216
  ![Share of a shared vLLM pod's bill under five metering rules](https://raw.githubusercontent.com/timurista/unalloc/main/paper/figures/kv_shares.png)
211
217
 
212
- 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.
213
219
 
214
220
  ## Development environment
215
221
 
@@ -224,7 +230,9 @@ make devcontainer-check # lint, tests and every case study at smoke size, ins
224
230
 
225
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`.
226
232
 
227
- What OpenCost structurally cannot do is see your OpenAI and Anthropic bills, because they aren't Kubernetes objects. What LiteLLM cannot do is see your GPU nodes. `unalloc` lives in exactly that seam and nowhere else. It is:
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:
228
236
 
229
237
  - **not** a dashboard — it prints a number and exits (the explorer is a case-study companion, not part of the CLI)
230
238
  - **not** an optimizer — it makes no recommendations about rightsizing or commitments
@@ -249,6 +257,24 @@ pip install -e ".[dev]"
249
257
  ruff check . && pytest
250
258
  ```
251
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.2, 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.2},
272
+ year = {2026},
273
+ doi = {10.5281/zenodo.22761012},
274
+ url = {https://github.com/timurista/unalloc}
275
+ }
276
+ ```
277
+
252
278
  ## License
253
279
 
254
280
  Apache 2.0, matching OpenCost.
@@ -6,9 +6,12 @@
6
6
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/timurista/unalloc/blob/main/LICENSE)
7
7
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://github.com/timurista/unalloc/blob/main/pyproject.toml)
8
8
  [![Paper](https://img.shields.io/badge/paper-PDF-1f5b45.svg)](https://github.com/timurista/unalloc/blob/main/paper/unalloc-case-studies.pdf)
9
- [![Explorer](https://img.shields.io/badge/explorer-live-1f5b45.svg)](https://timurista.github.io/unalloc/)
9
+ [![Explorer](https://img.shields.io/badge/explorer-live-1f5b45.svg)](https://timurista.github.io/unalloc/explorer.html)
10
+ [![DOI](https://zenodo.org/badge/1368528624.svg)](https://doi.org/10.5281/zenodo.22761012)
10
11
 
11
- OpenCost tells you what your Kubernetes workloads cost. Your provider dashboard tells you what your OpenAI and Anthropic calls cost. Neither tells you what a single AI feature costs, because the two live in different systems with different keys. `unalloc` pulls both 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.
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
- Five studies push real (or realistically simulated) inference workloads through unalloc's own adapters. 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)**.
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
  | --- | --- | --- |
@@ -157,9 +160,11 @@ Five studies push real (or realistically simulated) inference workloads through
157
160
  | [`distributed`](https://github.com/timurista/unalloc/blob/main/case_studies/distributed) | Tensor and pipeline parallel on `torch.distributed` (gloo), verified against a single-process reference | Owner label on LeaderWorkerSet leaders only: 66% unallocated; falling back to `name` "fixes" it by sending 61% to a Helm chart name |
158
161
  | [`hybrid_e2e`](https://github.com/timurista/unalloc/blob/main/case_studies/hybrid_e2e) | The real CLI as a subprocess against live mock OpenCost / LiteLLM / OpenAI / Anthropic APIs (Postgres-backed LiteLLM in the dev container) | Enabling every source double counts $11.8K; reading only page one of the billing APIs reports 25% of spend |
159
162
  | [`use_cases`](https://github.com/timurista/unalloc/blob/main/case_studies/use_cases) | Labeling Pareto, per-feature unit economics, self-host break-even, CI budget gate | Three label fixes take a 67%-unallocated org under 10% |
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 |
160
164
 
161
165
  ```bash
162
- 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=...)
163
168
  make case-studies # all five, full size (a few minutes)
164
169
  make paper # figures + PDF
165
170
  make notebook # execute case_studies/unalloc_case_studies.ipynb
@@ -168,7 +173,7 @@ make ui # ledger explorer on http://127.0.0.1:8765
168
173
 
169
174
  ![Share of a shared vLLM pod's bill under five metering rules](https://raw.githubusercontent.com/timurista/unalloc/main/paper/figures/kv_shares.png)
170
175
 
171
- 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.
172
177
 
173
178
  ## Development environment
174
179
 
@@ -183,7 +188,9 @@ make devcontainer-check # lint, tests and every case study at smoke size, ins
183
188
 
184
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`.
185
190
 
186
- What OpenCost structurally cannot do is see your OpenAI and Anthropic bills, because they aren't Kubernetes objects. What LiteLLM cannot do is see your GPU nodes. `unalloc` lives in exactly that seam and nowhere else. It is:
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:
187
194
 
188
195
  - **not** a dashboard — it prints a number and exits (the explorer is a case-study companion, not part of the CLI)
189
196
  - **not** an optimizer — it makes no recommendations about rightsizing or commitments
@@ -208,6 +215,24 @@ pip install -e ".[dev]"
208
215
  ruff check . && pytest
209
216
  ```
210
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.2, 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.2},
230
+ year = {2026},
231
+ doi = {10.5281/zenodo.22761012},
232
+ url = {https://github.com/timurista/unalloc}
233
+ }
234
+ ```
235
+
211
236
  ## License
212
237
 
213
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.0"
7
+ version = "0.2.2"
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"]
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.2.2"
6
6
  __all__ = ["__version__"]
@@ -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,30 @@
1
+ """The paper build refuses Typst that compiles but renders wrong."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+ from paper.build import SOURCE, lint_source # noqa: E402
12
+
13
+
14
+ def test_published_paper_source_is_clean():
15
+ assert lint_source(SOURCE.read_text()) == []
16
+
17
+
18
+ def test_tilde_is_flagged_in_prose_but_not_in_code():
19
+ assert lint_source("reads only ~25% of spend") == [
20
+ "line 1: `~` renders as a non-breaking space; write ≈ or 'about'"
21
+ ]
22
+ assert lint_source("run `ls ~/.ssh` first") == []
23
+ assert lint_source("```\ncd ~/unalloc\n```") == []
24
+
25
+
26
+ def test_semicolon_after_citation_and_bare_dollar_are_flagged():
27
+ assert lint_source('prefills #c("sarathi"); step latency')
28
+ assert lint_source("costs $5,703 per month")
29
+ assert lint_source("costs \\$5,703 per month") == []
30
+ assert lint_source("where $U > P$ holds") == []
@@ -0,0 +1,112 @@
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
@@ -1,24 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.2.0 — 2026-09-13
4
-
5
- Validated against five inference case studies; see `paper/unalloc-case-studies.pdf`.
6
-
7
- ### Fixed
8
- - OpenAI and Anthropic adapters lost their default API URLs whenever the CLI passed an unset environment variable, so every live fetch failed.
9
- - The Anthropic Admin API is now called with `x-api-key` and `anthropic-version` instead of a Bearer token.
10
- - Provider cost APIs are paged with `has_more` / `next_page`; previously only the first page was read.
11
- - OpenCost component costs are summed as `Decimal` when `totalCost` is absent, never through `float`.
12
- - Stacked label prefixes are all stripped (`label_app_kubernetes_io_name` → `name`).
13
- - Canonical-key collisions resolve by a fixed precedence instead of arrival order (explicit key, alias, rewritten key, path-derived key).
14
- - OpenCost annotations no longer override labels with the same key.
15
- - `unalloc labels` honours `--fallback`, and exits 1 when no rows load instead of printing "Nothing unallocated".
16
-
17
- ### Added
18
- - `report --budget PCT` exits 2 when the unallocated share exceeds `PCT`, for CI gates.
19
- - Reports show the spend attributed only through a fallback key (`fallback_usd` in `--json`).
20
- - Case studies (`case_studies/`), a Typst paper (`paper/`), a ledger explorer, a companion notebook and a dev container with Postgres.
21
-
22
- ## 0.1.0
23
-
24
- 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