unalloc 0.2.1__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.1 → unalloc-0.2.2}/.gitignore +2 -0
  2. unalloc-0.2.2/CHANGELOG.md +53 -0
  3. {unalloc-0.2.1 → unalloc-0.2.2}/PKG-INFO +32 -7
  4. {unalloc-0.2.1 → unalloc-0.2.2}/README.md +30 -6
  5. {unalloc-0.2.1 → unalloc-0.2.2}/pyproject.toml +8 -3
  6. {unalloc-0.2.1 → 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_numbers.py +112 -0
  9. unalloc-0.2.1/CHANGELOG.md +0 -35
  10. {unalloc-0.2.1 → unalloc-0.2.2}/LICENSE +0 -0
  11. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/cli.py +0 -0
  12. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/core/__init__.py +0 -0
  13. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/core/attribute.py +0 -0
  14. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/core/models.py +0 -0
  15. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/core/normalize.py +0 -0
  16. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/core/reconcile.py +0 -0
  17. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/fixtures/anthropic_cost_report.json +0 -0
  18. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/fixtures/litellm_spend.json +0 -0
  19. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/fixtures/openai_costs.json +0 -0
  20. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/fixtures/opencost_allocation.json +0 -0
  21. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/render/__init__.py +0 -0
  22. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/render/table.py +0 -0
  23. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/__init__.py +0 -0
  24. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/anthropic.py +0 -0
  25. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/base.py +0 -0
  26. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/litellm.py +0 -0
  27. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/openai.py +0 -0
  28. {unalloc-0.2.1 → unalloc-0.2.2}/src/unalloc/sources/opencost.py +0 -0
  29. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_attribute.py +0 -0
  30. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_cli.py +0 -0
  31. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_fetch.py +0 -0
  32. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_normalize.py +0 -0
  33. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_paper_lint.py +0 -0
  34. {unalloc-0.2.1 → unalloc-0.2.2}/tests/test_reconcile.py +0 -0
  35. {unalloc-0.2.1 → 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.1
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
  | --- | --- | --- |
@@ -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
  ![Share of a shared vLLM pod's bill under five metering rules](https://raw.githubusercontent.com/timurista/unalloc/main/paper/figures/kv_shares.png)
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
- 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:
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.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
+
253
278
  ## License
254
279
 
255
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
  | --- | --- | --- |
@@ -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
  ![Share of a shared vLLM pod's bill under five metering rules](https://raw.githubusercontent.com/timurista/unalloc/main/paper/figures/kv_shares.png)
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
- 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:
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.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
+
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.1"
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.1"
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,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,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