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.
- {unalloc-0.2.0 → unalloc-0.2.2}/.gitignore +2 -0
- unalloc-0.2.2/CHANGELOG.md +53 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/PKG-INFO +33 -7
- {unalloc-0.2.0 → unalloc-0.2.2}/README.md +31 -6
- {unalloc-0.2.0 → unalloc-0.2.2}/pyproject.toml +8 -3
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/__init__.py +1 -1
- unalloc-0.2.2/tests/test_docs_build.py +33 -0
- unalloc-0.2.2/tests/test_paper_lint.py +30 -0
- unalloc-0.2.2/tests/test_paper_numbers.py +112 -0
- unalloc-0.2.0/CHANGELOG.md +0 -24
- {unalloc-0.2.0 → unalloc-0.2.2}/LICENSE +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/cli.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/__init__.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/attribute.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/models.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/normalize.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/core/reconcile.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/anthropic_cost_report.json +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/litellm_spend.json +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/openai_costs.json +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/fixtures/opencost_allocation.json +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/render/__init__.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/render/table.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/__init__.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/anthropic.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/base.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/litellm.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/openai.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/src/unalloc/sources/opencost.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_attribute.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_cli.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_fetch.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_normalize.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_reconcile.py +0 -0
- {unalloc-0.2.0 → unalloc-0.2.2}/tests/test_sources.py +0 -0
|
@@ -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.
|
|
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
|
[](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
|
| --- | --- | --- |
|
|
@@ -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
|

|
|
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
|
-
|
|
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
|
[](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
|
| --- | --- | --- |
|
|
@@ -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
|

|
|
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
|
-
|
|
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.
|
|
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"]
|
|
@@ -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
|
unalloc-0.2.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
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
|