unalloc 0.3.1__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unalloc-0.3.1 → unalloc-0.3.3}/CHANGELOG.md +20 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/PKG-INFO +43 -9
- {unalloc-0.3.1 → unalloc-0.3.3}/README.md +42 -8
- unalloc-0.3.3/case_studies/environment_lifetime/README.md +184 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/pyproject.toml +1 -1
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/__init__.py +1 -1
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/cli.py +48 -1
- unalloc-0.3.3/src/unalloc/fixtures/README.md +16 -0
- unalloc-0.3.3/src/unalloc/fixtures/invoices.json +7 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/fixtures/litellm_spend.json +4 -4
- unalloc-0.3.3/tests/test_cli.py +71 -0
- unalloc-0.3.1/tests/test_cli.py +0 -33
- {unalloc-0.3.1 → unalloc-0.3.3}/.gitignore +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/LICENSE +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/__init__.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/attribute.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/models.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/normalize.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/provenance.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/core/reconcile.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/fixtures/anthropic_cost_report.json +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/fixtures/openai_costs.json +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/fixtures/opencost_allocation.json +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/render/__init__.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/render/table.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/__init__.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/anthropic.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/base.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/litellm.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/openai.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/src/unalloc/sources/opencost.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_attribute.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_docs_build.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_fetch.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_normalize.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_paper_lint.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_paper_numbers.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_provenance.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_reconcile.py +0 -0
- {unalloc-0.3.1 → unalloc-0.3.3}/tests/test_sources.py +0 -0
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 — 2026-11-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Repository-only synthetic environment-lifetime memory allocation case study: per-environment overlap handling, exact equal-share allocation, explicit uncovered residual, and missing-telemetry comparison.
|
|
7
|
+
- 43 focused tests and reproducible output checks in CI. The synthetic fixture reconciles 270 GB-seconds as 120 assigned plus 150 residual; omitting a request still reconciles, illustrating why conservation does not prove correct attribution.
|
|
8
|
+
|
|
9
|
+
No production CLI or source-adapter behavior changes. This is not AWS ingestion, an observed AWS bill, CPU billing, or a savings claim. The case study lives in the GitHub source tree and source archive, not the PyPI wheel/sdist. The package patch includes updated README metadata and version identity.
|
|
10
|
+
|
|
11
|
+
## 0.3.2 — 2026-10-05
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `unalloc --version` (and `-V`), alongside the existing `unalloc version` command.
|
|
15
|
+
- `reconcile --fixtures` reconciles against bundled sample invoices when no `--invoice` is given: Anthropic ties out within tolerance and OpenAI shows an $381.44 (8.6%) gap, so the invoice check can be demoed without inventing a number. An explicit `--invoice` replaces the bundled ones.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- The bundled LiteLLM fixture now routes to Azure OpenAI, Vertex AI and Bedrock instead of the OpenAI and Anthropic APIs, so the default four-source demo no longer counts gateway spend that the direct provider fixtures could also bill. Dollar amounts are unchanged: the fixture headline is still 73.5% of $66,630.38. `src/unalloc/fixtures/README.md` documents what each fixture covers.
|
|
19
|
+
- The quick start shows `--fallback cost_center` and `reconcile --fixtures`; `make demo` uses the bundled invoices.
|
|
20
|
+
|
|
21
|
+
Fixture attribution results are unchanged. The LiteLLM fixture bytes changed, so its provenance digest differs from 0.3.1.
|
|
22
|
+
|
|
3
23
|
## 0.3.1 — 2026-10-05
|
|
4
24
|
|
|
5
25
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unalloc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Find the AI spend nobody owns. Joins OpenCost allocation data with LLM provider bills and reports what's unattributed.
|
|
5
5
|
Project-URL: Homepage, https://github.com/timurista/unalloc
|
|
6
6
|
Project-URL: Issues, https://github.com/timurista/unalloc/issues
|
|
@@ -55,7 +55,7 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
|
|
56
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.
|
|
57
57
|
|
|
58
|
-
> **Avoid double counting before interpreting totals.** By default, the CLI requests all four sources: OpenCost, LiteLLM, OpenAI and Anthropic. It appends their normalized `CostRow`s into one ledger; it does **not** automatically deduplicate the same economic spend across sources. Gateway charges can also appear in provider bills (or OpenCost plugin output). Choose non-overlapping sources for your setup. For traffic fully covered by LiteLLM, use `--source opencost --source litellm` only if OpenCost excludes those same provider charges. Direct-provider-only traffic needs a separate coverage plan. Fixture totals
|
|
58
|
+
> **Avoid double counting before interpreting totals.** By default, the CLI requests all four sources: OpenCost, LiteLLM, OpenAI and Anthropic. It appends their normalized `CostRow`s into one ledger; it does **not** automatically deduplicate the same economic spend across sources. Gateway charges can also appear in provider bills (or OpenCost plugin output). Choose non-overlapping sources for your setup. For traffic fully covered by LiteLLM, use `--source opencost --source litellm` only if OpenCost excludes those same provider charges. Direct-provider-only traffic needs a separate coverage plan. The bundled fixtures are built not to overlap: their LiteLLM rows route to Azure OpenAI, Vertex AI and Bedrock, which unalloc does not pull directly, and their OpenCost rows are self-hosted workloads only ([fixture notes](https://github.com/timurista/unalloc/blob/main/src/unalloc/fixtures/README.md)). Fixture totals are synthetic demonstrations, not validated unique spend.
|
|
59
59
|
|
|
60
60
|
## The output
|
|
61
61
|
|
|
@@ -88,11 +88,11 @@ $ unalloc labels --fixtures --dimension team
|
|
|
88
88
|
┡━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
89
89
|
│ 1 │ opencost │ inference/vllm-llama-70b │ $19,147.10 │ cluster, controller, ... │
|
|
90
90
|
│ 2 │ opencost │ __idle__ │ $6,070.00 │ cluster │
|
|
91
|
-
│ 3 │ litellm │ claude-opus-5
|
|
91
|
+
│ 3 │ litellm │ bedrock/claude-opus-5 │ $5,412.75 │ environment, model │
|
|
92
92
|
└────┴──────────┴───────────────────────────┴────────────┴────────────────────────────┘
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
`inference/vllm-llama-70b` is the whole pitch: $19K of GPU spend carrying a `costCenter` label but no `team`, sitting next to $5K of Claude
|
|
95
|
+
`inference/vllm-llama-70b` is the whole pitch: $19K of GPU spend carrying a `costCenter` label but no `team`, sitting next to $5K of Claude spend routed through the gateway to Bedrock on an unlabeled legacy key. Two systems, one gap, no existing tool that shows them in the same table.
|
|
96
96
|
|
|
97
97
|
Add a fallback and unalloc says how much of the "allocated" spend only got there through it, so a fallback onto something that is not an owner can't hide inside the headline:
|
|
98
98
|
|
|
@@ -103,11 +103,45 @@ $ unalloc report --fixtures -D team --fallback namespace
|
|
|
103
103
|
│ $23,745.60 attributed only via fallback (namespace) │
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Then check the ledger against what the providers billed. With `--fixtures`, the bundled sample invoices are used:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
$ unalloc reconcile --fixtures
|
|
110
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
|
111
|
+
┃ Source ┃ Ledger ┃ Invoice ┃ Delta ┃ Status ┃
|
|
112
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
|
113
|
+
│ anthropic │ $8,737.03 │ $8,779.84 │ $-42.81 (-0.5%) │ matched │
|
|
114
|
+
│ litellm │ $11,444.27 │ — │ — │ no-invoice │
|
|
115
|
+
│ openai │ $4,031.36 │ $4,412.80 │ $-381.44 (-8.6%) │ under │
|
|
116
|
+
│ opencost │ $42,417.72 │ — │ — │ no-invoice │
|
|
117
|
+
└───────────┴────────────┴───────────┴──────────────────┴────────────┘
|
|
118
|
+
The ledger does not tie out. Attribution built on this is not defensible yet.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Anthropic agrees within the 1% tolerance. OpenAI billed $381 more than its costs endpoint reported, so any attribution built on that ledger understates OpenAI spend until the gap is explained.
|
|
122
|
+
|
|
123
|
+
## Environment-lifetime accounting example
|
|
124
|
+
|
|
125
|
+
A new [repository-only case study](case_studies/environment_lifetime/README.md) separates
|
|
126
|
+
summed request duration from environment-lifetime memory in a synthetic, offline fixture.
|
|
127
|
+
It splits overlapping requests within each environment, preserves uncovered capacity as a
|
|
128
|
+
residual, and shows that missing request telemetry can change attribution while totals still
|
|
129
|
+
reconcile exactly. Equal sharing and constant memory are explicit modeling choices, not
|
|
130
|
+
measured AWS ownership. No AWS ingestion, pricing, savings, or production CLI integration
|
|
131
|
+
is claimed. Clone the repository to run it; case studies are not installed by `pip install unalloc`.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
python3 -m case_studies.environment_lifetime
|
|
135
|
+
python3 -m unittest discover -s case_studies/environment_lifetime -t . -v
|
|
136
|
+
```
|
|
137
|
+
|
|
106
138
|
## Install
|
|
107
139
|
|
|
108
140
|
```bash
|
|
109
|
-
pip install unalloc
|
|
110
|
-
unalloc report --fixtures
|
|
141
|
+
pip install unalloc # or: uv tool install unalloc
|
|
142
|
+
unalloc report --fixtures --fallback cost_center # works immediately, no infrastructure needed
|
|
143
|
+
unalloc reconcile --fixtures # ledger vs. sample invoices
|
|
144
|
+
unalloc --version
|
|
111
145
|
```
|
|
112
146
|
|
|
113
147
|
From a clone:
|
|
@@ -121,11 +155,11 @@ make check # ruff + pytest, same as CI
|
|
|
121
155
|
Or as a container, which needs no Python on the host:
|
|
122
156
|
|
|
123
157
|
```bash
|
|
124
|
-
docker build -t unalloc
|
|
125
|
-
docker run --rm unalloc
|
|
158
|
+
docker build -t unalloc .
|
|
159
|
+
docker run --rm unalloc report --fixtures -D team
|
|
126
160
|
|
|
127
161
|
# against real systems, reachable from the container
|
|
128
|
-
docker run --rm --network host --env-file .env unalloc
|
|
162
|
+
docker run --rm --network host --env-file .env unalloc report -D team
|
|
129
163
|
```
|
|
130
164
|
|
|
131
165
|
Copy `.env.example` to `.env` for the source configuration. unalloc stores
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
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.
|
|
15
15
|
|
|
16
|
-
> **Avoid double counting before interpreting totals.** By default, the CLI requests all four sources: OpenCost, LiteLLM, OpenAI and Anthropic. It appends their normalized `CostRow`s into one ledger; it does **not** automatically deduplicate the same economic spend across sources. Gateway charges can also appear in provider bills (or OpenCost plugin output). Choose non-overlapping sources for your setup. For traffic fully covered by LiteLLM, use `--source opencost --source litellm` only if OpenCost excludes those same provider charges. Direct-provider-only traffic needs a separate coverage plan. Fixture totals
|
|
16
|
+
> **Avoid double counting before interpreting totals.** By default, the CLI requests all four sources: OpenCost, LiteLLM, OpenAI and Anthropic. It appends their normalized `CostRow`s into one ledger; it does **not** automatically deduplicate the same economic spend across sources. Gateway charges can also appear in provider bills (or OpenCost plugin output). Choose non-overlapping sources for your setup. For traffic fully covered by LiteLLM, use `--source opencost --source litellm` only if OpenCost excludes those same provider charges. Direct-provider-only traffic needs a separate coverage plan. The bundled fixtures are built not to overlap: their LiteLLM rows route to Azure OpenAI, Vertex AI and Bedrock, which unalloc does not pull directly, and their OpenCost rows are self-hosted workloads only ([fixture notes](https://github.com/timurista/unalloc/blob/main/src/unalloc/fixtures/README.md)). Fixture totals are synthetic demonstrations, not validated unique spend.
|
|
17
17
|
|
|
18
18
|
## The output
|
|
19
19
|
|
|
@@ -46,11 +46,11 @@ $ unalloc labels --fixtures --dimension team
|
|
|
46
46
|
┡━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
47
47
|
│ 1 │ opencost │ inference/vllm-llama-70b │ $19,147.10 │ cluster, controller, ... │
|
|
48
48
|
│ 2 │ opencost │ __idle__ │ $6,070.00 │ cluster │
|
|
49
|
-
│ 3 │ litellm │ claude-opus-5
|
|
49
|
+
│ 3 │ litellm │ bedrock/claude-opus-5 │ $5,412.75 │ environment, model │
|
|
50
50
|
└────┴──────────┴───────────────────────────┴────────────┴────────────────────────────┘
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`inference/vllm-llama-70b` is the whole pitch: $19K of GPU spend carrying a `costCenter` label but no `team`, sitting next to $5K of Claude
|
|
53
|
+
`inference/vllm-llama-70b` is the whole pitch: $19K of GPU spend carrying a `costCenter` label but no `team`, sitting next to $5K of Claude spend routed through the gateway to Bedrock on an unlabeled legacy key. Two systems, one gap, no existing tool that shows them in the same table.
|
|
54
54
|
|
|
55
55
|
Add a fallback and unalloc says how much of the "allocated" spend only got there through it, so a fallback onto something that is not an owner can't hide inside the headline:
|
|
56
56
|
|
|
@@ -61,11 +61,45 @@ $ unalloc report --fixtures -D team --fallback namespace
|
|
|
61
61
|
│ $23,745.60 attributed only via fallback (namespace) │
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
Then check the ledger against what the providers billed. With `--fixtures`, the bundled sample invoices are used:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ unalloc reconcile --fixtures
|
|
68
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
|
69
|
+
┃ Source ┃ Ledger ┃ Invoice ┃ Delta ┃ Status ┃
|
|
70
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
|
71
|
+
│ anthropic │ $8,737.03 │ $8,779.84 │ $-42.81 (-0.5%) │ matched │
|
|
72
|
+
│ litellm │ $11,444.27 │ — │ — │ no-invoice │
|
|
73
|
+
│ openai │ $4,031.36 │ $4,412.80 │ $-381.44 (-8.6%) │ under │
|
|
74
|
+
│ opencost │ $42,417.72 │ — │ — │ no-invoice │
|
|
75
|
+
└───────────┴────────────┴───────────┴──────────────────┴────────────┘
|
|
76
|
+
The ledger does not tie out. Attribution built on this is not defensible yet.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Anthropic agrees within the 1% tolerance. OpenAI billed $381 more than its costs endpoint reported, so any attribution built on that ledger understates OpenAI spend until the gap is explained.
|
|
80
|
+
|
|
81
|
+
## Environment-lifetime accounting example
|
|
82
|
+
|
|
83
|
+
A new [repository-only case study](case_studies/environment_lifetime/README.md) separates
|
|
84
|
+
summed request duration from environment-lifetime memory in a synthetic, offline fixture.
|
|
85
|
+
It splits overlapping requests within each environment, preserves uncovered capacity as a
|
|
86
|
+
residual, and shows that missing request telemetry can change attribution while totals still
|
|
87
|
+
reconcile exactly. Equal sharing and constant memory are explicit modeling choices, not
|
|
88
|
+
measured AWS ownership. No AWS ingestion, pricing, savings, or production CLI integration
|
|
89
|
+
is claimed. Clone the repository to run it; case studies are not installed by `pip install unalloc`.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python3 -m case_studies.environment_lifetime
|
|
93
|
+
python3 -m unittest discover -s case_studies/environment_lifetime -t . -v
|
|
94
|
+
```
|
|
95
|
+
|
|
64
96
|
## Install
|
|
65
97
|
|
|
66
98
|
```bash
|
|
67
|
-
pip install unalloc
|
|
68
|
-
unalloc report --fixtures
|
|
99
|
+
pip install unalloc # or: uv tool install unalloc
|
|
100
|
+
unalloc report --fixtures --fallback cost_center # works immediately, no infrastructure needed
|
|
101
|
+
unalloc reconcile --fixtures # ledger vs. sample invoices
|
|
102
|
+
unalloc --version
|
|
69
103
|
```
|
|
70
104
|
|
|
71
105
|
From a clone:
|
|
@@ -79,11 +113,11 @@ make check # ruff + pytest, same as CI
|
|
|
79
113
|
Or as a container, which needs no Python on the host:
|
|
80
114
|
|
|
81
115
|
```bash
|
|
82
|
-
docker build -t unalloc
|
|
83
|
-
docker run --rm unalloc
|
|
116
|
+
docker build -t unalloc .
|
|
117
|
+
docker run --rm unalloc report --fixtures -D team
|
|
84
118
|
|
|
85
119
|
# against real systems, reachable from the container
|
|
86
|
-
docker run --rm --network host --env-file .env unalloc
|
|
120
|
+
docker run --rm --network host --env-file .env unalloc report -D team
|
|
87
121
|
```
|
|
88
122
|
|
|
89
123
|
Copy `.env.example` to `.env` for the source configuration. unalloc stores
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Environment-lifetime memory allocation (synthetic)
|
|
2
|
+
|
|
3
|
+
This is a small example that runs offline and uses only the standard library. It splits each
|
|
4
|
+
environment's **lifetime memory GB-seconds** among the requests that ran in that
|
|
5
|
+
environment. Any time not covered by a request becomes a residual.
|
|
6
|
+
|
|
7
|
+
> **Scope.** The example does synthetic arithmetic on a hand-written fixture. It is
|
|
8
|
+
> **not** validated AWS ingestion, **not** an AWS bill, and **not** a CPU billing model.
|
|
9
|
+
> It contains no prices and no dollar or savings figures. The case study is not wired into the `unalloc` CLI or its sources,
|
|
10
|
+
> and it changes no existing product behavior.
|
|
11
|
+
|
|
12
|
+
## Why environment lifetime and not request duration
|
|
13
|
+
|
|
14
|
+
The AWS Lambda docs (checked 2026-11-13) describe two facts this example models:
|
|
15
|
+
|
|
16
|
+
- [Lambda web functions](https://docs.aws.amazon.com/lambda/latest/dg/web-functions.html):
|
|
17
|
+
memory is billed over the life of the environment.
|
|
18
|
+
- [Environment lifecycle](https://docs.aws.amazon.com/lambda/latest/dg/web-functions-env-lifecycle.html):
|
|
19
|
+
an environment has no guaranteed lifetime.
|
|
20
|
+
|
|
21
|
+
So the sum of request durations is not the quantity that memory accrues over. Concurrent
|
|
22
|
+
requests share one environment's memory, and memory also accrues while no request is
|
|
23
|
+
running. This example only shows the arithmetic for those two effects. It reads no AWS data.
|
|
24
|
+
|
|
25
|
+
## Model
|
|
26
|
+
|
|
27
|
+
For each environment, the fixture gives:
|
|
28
|
+
|
|
29
|
+
- An observed lifetime `[lifetime_start_s, lifetime_end_s]`, which must have positive length.
|
|
30
|
+
- A synthetic constant memory capacity `memory_gb`, which must be greater than 0.
|
|
31
|
+
- `lifetime_gb_seconds = memory_gb × (lifetime_end_s − lifetime_start_s)`.
|
|
32
|
+
|
|
33
|
+
For each request, the fixture gives:
|
|
34
|
+
|
|
35
|
+
- An `environment` that must exist.
|
|
36
|
+
- A span `[start_s, end_s]` that must lie inside that environment's lifetime.
|
|
37
|
+
|
|
38
|
+
The example integrates each environment independently:
|
|
39
|
+
|
|
40
|
+
1. Cut the lifetime into non-overlapping slices at every lifetime boundary and every request
|
|
41
|
+
boundary.
|
|
42
|
+
2. Check how many requests cover each slice:
|
|
43
|
+
- If `n ≥ 1` requests cover the slice, the slice counts as busy. Each covering request
|
|
44
|
+
receives `memory_gb × width / n`.
|
|
45
|
+
- If no request covers the slice, it counts as uncovered. Its GB-seconds go to the
|
|
46
|
+
**residual**.
|
|
47
|
+
3. Never share a slice across environments. Two requests on different environments get
|
|
48
|
+
separate allocations, even when their wall-clock spans overlap.
|
|
49
|
+
|
|
50
|
+
### Reported quantities
|
|
51
|
+
|
|
52
|
+
The example reports each quantity per environment and as a total (except `overlap_seconds`, which is per-environment only):
|
|
53
|
+
|
|
54
|
+
| Field | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `raw_request_seconds` | Sum of request durations. Counts overlapping time more than once. |
|
|
57
|
+
| `union_busy_seconds` | Per-environment union of request spans. The total sums these environment-seconds, not a global wall-clock union. |
|
|
58
|
+
| `overlap_seconds` | `raw_request_seconds − union_busy_seconds`. |
|
|
59
|
+
| `uncovered_seconds` | Lifetime seconds that no request covers. |
|
|
60
|
+
| `lifetime_gb_seconds` | `memory_gb × lifetime seconds`. |
|
|
61
|
+
| `allocated_gb_seconds` | Sum of per-request allocations, equal to `memory_gb × union_busy_seconds`. |
|
|
62
|
+
| `residual_gb_seconds` | `memory_gb × uncovered_seconds`. |
|
|
63
|
+
| `reconciliation` | Checks that `allocated + residual == lifetime` exactly. The `difference` field must be `"0"`. |
|
|
64
|
+
|
|
65
|
+
The output also lists `allocated_gb_seconds` for each request.
|
|
66
|
+
|
|
67
|
+
### Residual: unobserved unless the fixture claims completeness
|
|
68
|
+
|
|
69
|
+
By default, the example labels the residual `unobserved`. A gap in request telemetry might
|
|
70
|
+
mean the environment sat idle, or it might mean a request went unrecorded.
|
|
71
|
+
|
|
72
|
+
The residual is labelled `idle_by_complete_synthetic_fixture` only when the fixture sets both
|
|
73
|
+
`"synthetic": true` and `"telemetry_complete": true`. The bundled fixture sets both, because
|
|
74
|
+
it is hand-written and lists every request by construction. When you run with
|
|
75
|
+
`--drop-request`, the example always sets `telemetry_complete` to false. In that case, the
|
|
76
|
+
residual is labelled `unobserved`.
|
|
77
|
+
|
|
78
|
+
### Zero-length requests
|
|
79
|
+
|
|
80
|
+
A request with `start_s == end_s` is accepted if it lies inside its environment's
|
|
81
|
+
lifetime, including the lifetime's endpoints. A zero-length request has the following
|
|
82
|
+
properties:
|
|
83
|
+
|
|
84
|
+
- It covers no slice.
|
|
85
|
+
- It receives exactly `0` GB-seconds.
|
|
86
|
+
- It adds `0` to `raw_request_seconds`.
|
|
87
|
+
- It is flagged `"zero_duration": true`.
|
|
88
|
+
|
|
89
|
+
### Exactness and validation
|
|
90
|
+
|
|
91
|
+
All arithmetic uses `fractions.Fraction`. The example accepts numbers in these forms:
|
|
92
|
+
|
|
93
|
+
- Decimal strings, such as `"1.5"`.
|
|
94
|
+
- Integers.
|
|
95
|
+
- JSON numbers, which the example parses as `Decimal`, so `0.1` stays exactly 1/10.
|
|
96
|
+
- In-memory `Decimal` or `Fraction` values.
|
|
97
|
+
|
|
98
|
+
The output writes each value as an exact string. A value that terminates in base 10 is written
|
|
99
|
+
as a decimal, such as `"28.75"`. Any other value is written as `"numerator/denominator"`, such
|
|
100
|
+
as `"155/6"`.
|
|
101
|
+
|
|
102
|
+
The example rejects the following inputs with a `FixtureError` that names the offending field:
|
|
103
|
+
|
|
104
|
+
- Binary floats, booleans, `NaN`, `sNaN`, `Infinity`, and the JSON `NaN` or `Infinity`
|
|
105
|
+
literals.
|
|
106
|
+
- Malformed number strings.
|
|
107
|
+
- Negative times, and a memory capacity of 0 or less.
|
|
108
|
+
- Lifetimes where `end <= start`, and requests where `end < start`.
|
|
109
|
+
- Requests that fall outside their environment's lifetime.
|
|
110
|
+
- Unknown environments.
|
|
111
|
+
- Duplicate environment IDs, duplicate request IDs (checked across all environments), and
|
|
112
|
+
duplicate JSON keys.
|
|
113
|
+
- IDs that are empty, not a string, or padded with whitespace.
|
|
114
|
+
- Missing or unknown fields, and a wrong `schema` value.
|
|
115
|
+
|
|
116
|
+
## Bundled fixture
|
|
117
|
+
|
|
118
|
+
The fixture is [`fixture.json`](fixture.json). All values are synthetic.
|
|
119
|
+
|
|
120
|
+
| Environment | Memory (GB) | Lifetime (s) | Requests | What it shows |
|
|
121
|
+
|---|---|---|---|---|
|
|
122
|
+
| `env-a` | 1.5 | [0, 100] | `a-1` [10,40], `a-2` [20,50], `a-3` [30,35], `a-4` [60,60], `a-5` [70,90] | Three-way overlap, a zero-length request, and gaps |
|
|
123
|
+
| `env-b` | 0.5 | [5, 65] | `b-1` [5,65], `b-2` [15,25], `b-3` [20,30] | Up to 3 concurrent requests. The thirds don't terminate as decimals. |
|
|
124
|
+
| `env-c` | 3 | [0, 30] | none | The whole lifetime is residual |
|
|
125
|
+
|
|
126
|
+
The bundled fixture produces these results, from [`fixture_output.json`](fixture_output.json):
|
|
127
|
+
|
|
128
|
+
| Environment | Raw request s | Union busy s | Lifetime GB-s | Allocated GB-s | Residual GB-s |
|
|
129
|
+
|---|---|---|---|---|---|
|
|
130
|
+
| `env-a` | 85 | 60 | 150 | 90 | 60 |
|
|
131
|
+
| `env-b` | 80 | 60 | 30 | 30 | 0 |
|
|
132
|
+
| `env-c` | 0 | 0 | 90 | 0 | 90 |
|
|
133
|
+
| **total** | **165** | **120** | **270** | **120** | **150** |
|
|
134
|
+
|
|
135
|
+
The per-request allocations are:
|
|
136
|
+
|
|
137
|
+
- `a-1` 28.75, `a-2` 28.75, `a-3` 2.5, `a-4` 0, `a-5` 30.
|
|
138
|
+
- `b-1` 155/6, `b-2` 25/12, `b-3` 25/12.
|
|
139
|
+
|
|
140
|
+
### Missing telemetry
|
|
141
|
+
|
|
142
|
+
The file [`fixture_output_missing_a-2.json`](fixture_output_missing_a-2.json) shows the result
|
|
143
|
+
when request `a-2` is removed:
|
|
144
|
+
|
|
145
|
+
- The residual for `env-a` grows from 60 to **75** GB-s. The extra 15 GB-s comes from
|
|
146
|
+
[40, 50], the 10 seconds when only `a-2` was running, at 1.5 GB.
|
|
147
|
+
- The slices that `a-2` shared now go to `a-1` (41.25) and `a-3` (3.75).
|
|
148
|
+
- The lifetime GB-seconds don't change, and the reconciliation is still exact.
|
|
149
|
+
- The residual is labelled `unobserved`.
|
|
150
|
+
|
|
151
|
+
## Reproduce
|
|
152
|
+
|
|
153
|
+
Run these commands from the repository root. They need Python 3.9 or later, no third-party
|
|
154
|
+
packages, and no network access.
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
# Print the result JSON for the bundled fixture.
|
|
158
|
+
python3 -m case_studies.environment_lifetime
|
|
159
|
+
|
|
160
|
+
# Check that the stored outputs match the current code byte for byte. The command exits 1 on a mismatch.
|
|
161
|
+
python3 -m case_studies.environment_lifetime --check case_studies/environment_lifetime/fixture_output.json
|
|
162
|
+
python3 -m case_studies.environment_lifetime --drop-request a-2 \
|
|
163
|
+
--check case_studies/environment_lifetime/fixture_output_missing_a-2.json
|
|
164
|
+
|
|
165
|
+
# Regenerate the stored outputs.
|
|
166
|
+
python3 -m case_studies.environment_lifetime --out case_studies/environment_lifetime/fixture_output.json
|
|
167
|
+
python3 -m case_studies.environment_lifetime --drop-request a-2 \
|
|
168
|
+
--out case_studies/environment_lifetime/fixture_output_missing_a-2.json
|
|
169
|
+
|
|
170
|
+
# Run the tests with unittest from the standard library. pytest is not required.
|
|
171
|
+
python3 -m unittest discover -s case_studies/environment_lifetime -t . -v
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The repository's `pytest` configuration only collects tests from `tests/`, so this suite
|
|
175
|
+
runs only when you invoke the `unittest` command above.
|
|
176
|
+
|
|
177
|
+
## Files
|
|
178
|
+
|
|
179
|
+
- `lifetime.py`: parsing, validation, exact integration, and reconciliation.
|
|
180
|
+
- `__main__.py`: the command-line interface (`--fixture`, `--drop-request`, `--out`, and
|
|
181
|
+
`--check`).
|
|
182
|
+
- `fixture.json`: the synthetic input.
|
|
183
|
+
- `fixture_output.json` and `fixture_output_missing_a-2.json`: the stored outputs.
|
|
184
|
+
- `test_lifetime.py`: the `unittest` suite.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unalloc"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Find the AI spend nobody owns. Joins OpenCost allocation data with LLM provider bills and reports what's unattributed."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -53,6 +53,10 @@ FIXTURE_FILES = {
|
|
|
53
53
|
"anthropic": "anthropic_cost_report.json",
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
# What the providers billed for the fixture month, so `reconcile --fixtures`
|
|
57
|
+
# has something to reconcile against without the reader inventing a number.
|
|
58
|
+
FIXTURE_INVOICES = "invoices.json"
|
|
59
|
+
|
|
56
60
|
ENV_URL = {
|
|
57
61
|
"opencost": "UNALLOC_OPENCOST_URL",
|
|
58
62
|
"litellm": "UNALLOC_LITELLM_URL",
|
|
@@ -67,6 +71,46 @@ ENV_TOKEN = {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
|
|
74
|
+
def _print_version(value: bool) -> None:
|
|
75
|
+
if value:
|
|
76
|
+
typer.echo(__version__)
|
|
77
|
+
raise typer.Exit()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.callback()
|
|
81
|
+
def _root(
|
|
82
|
+
version: bool = typer.Option(
|
|
83
|
+
False,
|
|
84
|
+
"--version",
|
|
85
|
+
"-V",
|
|
86
|
+
callback=_print_version,
|
|
87
|
+
is_eager=True,
|
|
88
|
+
help="Print the installed version and exit.",
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fixture_invoices(fixture_dir: Path) -> list[Invoice]:
|
|
95
|
+
path = fixture_dir / FIXTURE_INVOICES
|
|
96
|
+
if not path.exists():
|
|
97
|
+
return []
|
|
98
|
+
data = json.loads(path.read_text())
|
|
99
|
+
period = data.get("period") or {}
|
|
100
|
+
start = datetime.fromisoformat(period["start"])
|
|
101
|
+
end = datetime.fromisoformat(period["end"])
|
|
102
|
+
return [
|
|
103
|
+
Invoice(
|
|
104
|
+
source=item["source"],
|
|
105
|
+
amount_usd=Decimal(str(item["amount_usd"])),
|
|
106
|
+
start=start,
|
|
107
|
+
end=end,
|
|
108
|
+
note=item.get("note", ""),
|
|
109
|
+
)
|
|
110
|
+
for item in data.get("invoices", [])
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
70
114
|
def _window(days: int) -> tuple[datetime, datetime]:
|
|
71
115
|
end = datetime.now(tz=UTC)
|
|
72
116
|
return end - timedelta(days=days), end
|
|
@@ -281,7 +325,8 @@ def reconcile_cmd(
|
|
|
281
325
|
"--invoice",
|
|
282
326
|
"-i",
|
|
283
327
|
help="Billed total per source as SOURCE=AMOUNT, e.g. openai=4210.55. "
|
|
284
|
-
"Repeat the flag per source."
|
|
328
|
+
"Repeat the flag per source. With --fixtures and no --invoice, the "
|
|
329
|
+
"bundled sample invoices are used.",
|
|
285
330
|
),
|
|
286
331
|
tolerance: float = typer.Option(
|
|
287
332
|
float(DEFAULT_TOLERANCE_PCT),
|
|
@@ -307,6 +352,8 @@ def reconcile_cmd(
|
|
|
307
352
|
end=end,
|
|
308
353
|
)
|
|
309
354
|
)
|
|
355
|
+
if fixtures and not invoice:
|
|
356
|
+
invoices = _fixture_invoices(fixture_dir)
|
|
310
357
|
|
|
311
358
|
render.render_reconciliation(
|
|
312
359
|
reconcile(rows, invoices, tolerance_pct=Decimal(str(tolerance)))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Bundled fixtures
|
|
2
|
+
|
|
3
|
+
Synthetic payloads for `--fixtures`. They describe one month (August 2026) of a
|
|
4
|
+
made-up organization and are built so that **no dollar appears in two sources**:
|
|
5
|
+
|
|
6
|
+
| Source | Covers |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `opencost_allocation.json` | Self-hosted GPU and CPU workloads in the cluster (vLLM, llm-d, batch jobs, idle). |
|
|
9
|
+
| `litellm_spend.json` | Gateway traffic routed to Azure OpenAI, Vertex AI and Bedrock. unalloc has no direct adapter for those, so the gateway is their only record. |
|
|
10
|
+
| `openai_costs.json` | Direct OpenAI API usage that does not go through the gateway. |
|
|
11
|
+
| `anthropic_cost_report.json` | Direct Anthropic API usage that does not go through the gateway. |
|
|
12
|
+
| `invoices.json` | What OpenAI and Anthropic billed for the month, used by `reconcile --fixtures`. |
|
|
13
|
+
|
|
14
|
+
Real deployments rarely split this cleanly. If your gateway proxies calls to
|
|
15
|
+
OpenAI or Anthropic, pulling the gateway and the direct provider sources together
|
|
16
|
+
counts that spend twice; choose sources with `--source`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"period": {"start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z"},
|
|
3
|
+
"invoices": [
|
|
4
|
+
{"source": "anthropic", "amount_usd": "8779.84", "note": "August invoice; within rounding of the cost report"},
|
|
5
|
+
{"source": "openai", "amount_usd": "4412.80", "note": "August invoice; $381.44 more than the costs endpoint returned"}
|
|
6
|
+
]
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"data": [
|
|
3
3
|
{
|
|
4
|
-
"request_id": "req_01", "model": "gpt-4o", "spend": "1840.22",
|
|
4
|
+
"request_id": "req_01", "model": "azure/gpt-4o", "spend": "1840.22",
|
|
5
5
|
"startTime": "2026-08-03T11:02:11Z", "endTime": "2026-08-03T11:02:14Z",
|
|
6
6
|
"prompt_tokens": 812000, "completion_tokens": 240100, "total_tokens": 1052100,
|
|
7
7
|
"api_key_alias": "search-rerank", "team_id": "search",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"metadata": {"service": "search-api"}, "cache_hit": false
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"request_id": "req_02", "model": "claude-sonnet-4-6", "spend": "3204.90",
|
|
12
|
+
"request_id": "req_02", "model": "vertex_ai/claude-sonnet-4-6", "spend": "3204.90",
|
|
13
13
|
"startTime": "2026-08-07T09:41:00Z", "endTime": "2026-08-07T09:41:06Z",
|
|
14
14
|
"prompt_tokens": 2140000, "completion_tokens": 512300, "total_tokens": 2652300,
|
|
15
15
|
"api_key_alias": "agent-runtime", "team_id": "platform",
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
"metadata": {"service": "agent-runtime"}, "cache_hit": true
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
"request_id": "req_03", "model": "gpt-4o-mini", "spend": "986.40",
|
|
20
|
+
"request_id": "req_03", "model": "azure/gpt-4o-mini", "spend": "986.40",
|
|
21
21
|
"startTime": "2026-08-12T14:20:00Z", "endTime": "2026-08-12T14:20:02Z",
|
|
22
22
|
"prompt_tokens": 9400000, "completion_tokens": 1100000, "total_tokens": 10500000,
|
|
23
23
|
"api_key_alias": "sandbox-shared", "request_tags": [],
|
|
24
24
|
"metadata": {}, "cache_hit": false
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
|
-
"request_id": "req_04", "model": "claude-opus-5", "spend": "5412.75",
|
|
27
|
+
"request_id": "req_04", "model": "bedrock/claude-opus-5", "spend": "5412.75",
|
|
28
28
|
"startTime": "2026-08-19T17:55:00Z", "endTime": "2026-08-19T17:55:22Z",
|
|
29
29
|
"prompt_tokens": 1802000, "completion_tokens": 940400, "total_tokens": 2742400,
|
|
30
30
|
"api_key_alias": "unknown-legacy-key", "request_tags": ["environment:prod"],
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from unalloc.cli import app
|
|
8
|
+
|
|
9
|
+
runner = CliRunner()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_budget_gate_fails_ci_when_unallocated_share_is_too_high():
|
|
13
|
+
# Bundled fixtures are 73.5% unallocated on `team`.
|
|
14
|
+
over = runner.invoke(app, ["report", "--fixtures", "-D", "team", "--json", "--budget", "50"])
|
|
15
|
+
assert over.exit_code == 2
|
|
16
|
+
assert "exceeds budget" in over.stderr
|
|
17
|
+
|
|
18
|
+
under = runner.invoke(app, ["report", "--fixtures", "-D", "team", "--json", "--budget", "80"])
|
|
19
|
+
assert under.exit_code == 0
|
|
20
|
+
assert json.loads(under.stdout)["unallocated_pct"] == "73.5"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_labels_backlog_honours_fallbacks():
|
|
24
|
+
plain = runner.invoke(app, ["labels", "--fixtures", "-D", "team", "--limit", "50"])
|
|
25
|
+
rescued = runner.invoke(
|
|
26
|
+
app, ["labels", "--fixtures", "-D", "team", "-f", "namespace", "--limit", "50"]
|
|
27
|
+
)
|
|
28
|
+
assert plain.exit_code == rescued.exit_code == 0
|
|
29
|
+
# Three OpenCost rows lack `team`; two of them (the vLLM pod and the
|
|
30
|
+
# embeddings job) carry a namespace, so only __idle__ stays on the backlog.
|
|
31
|
+
# Count rows rather than match names: rich truncates to terminal width.
|
|
32
|
+
assert plain.stdout.count("│ opencost") == 3
|
|
33
|
+
assert rescued.stdout.count("│ opencost") == 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_version_flag_matches_version_command():
|
|
37
|
+
from unalloc import __version__
|
|
38
|
+
|
|
39
|
+
for args in (["--version"], ["-V"], ["version"]):
|
|
40
|
+
result = runner.invoke(app, args)
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
assert result.stdout.strip() == __version__
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_reconcile_fixtures_uses_bundled_invoices():
|
|
46
|
+
result = runner.invoke(app, ["reconcile", "--fixtures"])
|
|
47
|
+
assert result.exit_code == 0
|
|
48
|
+
# Anthropic ties out within tolerance; OpenAI billed more than its
|
|
49
|
+
# costs endpoint returned, so the demo shows a real gap.
|
|
50
|
+
assert "matched" in result.stdout
|
|
51
|
+
assert "under" in result.stdout
|
|
52
|
+
assert "does not tie out" in result.stdout
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_explicit_invoice_replaces_bundled_invoices():
|
|
56
|
+
result = runner.invoke(app, ["reconcile", "--fixtures", "-i", "openai=4031.36"])
|
|
57
|
+
assert result.exit_code == 0
|
|
58
|
+
assert "under" not in result.stdout
|
|
59
|
+
assert "does not tie out" not in result.stdout
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_fixture_sources_do_not_overlap():
|
|
63
|
+
# The default demo pulls all four sources, so the gateway fixture must not
|
|
64
|
+
# carry traffic that the direct provider fixtures also bill.
|
|
65
|
+
from unalloc.cli import FIXTURE_DIR, FIXTURE_FILES
|
|
66
|
+
|
|
67
|
+
gateway = json.loads((FIXTURE_DIR / FIXTURE_FILES["litellm"]).read_text())["data"]
|
|
68
|
+
direct = ("openai/", "anthropic/")
|
|
69
|
+
for record in gateway:
|
|
70
|
+
model = record["model"]
|
|
71
|
+
assert "/" in model and not model.startswith(direct), model
|
unalloc-0.3.1/tests/test_cli.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
from typer.testing import CliRunner
|
|
6
|
-
|
|
7
|
-
from unalloc.cli import app
|
|
8
|
-
|
|
9
|
-
runner = CliRunner()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_budget_gate_fails_ci_when_unallocated_share_is_too_high():
|
|
13
|
-
# Bundled fixtures are 73.5% unallocated on `team`.
|
|
14
|
-
over = runner.invoke(app, ["report", "--fixtures", "-D", "team", "--json", "--budget", "50"])
|
|
15
|
-
assert over.exit_code == 2
|
|
16
|
-
assert "exceeds budget" in over.stderr
|
|
17
|
-
|
|
18
|
-
under = runner.invoke(app, ["report", "--fixtures", "-D", "team", "--json", "--budget", "80"])
|
|
19
|
-
assert under.exit_code == 0
|
|
20
|
-
assert json.loads(under.stdout)["unallocated_pct"] == "73.5"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_labels_backlog_honours_fallbacks():
|
|
24
|
-
plain = runner.invoke(app, ["labels", "--fixtures", "-D", "team", "--limit", "50"])
|
|
25
|
-
rescued = runner.invoke(
|
|
26
|
-
app, ["labels", "--fixtures", "-D", "team", "-f", "namespace", "--limit", "50"]
|
|
27
|
-
)
|
|
28
|
-
assert plain.exit_code == rescued.exit_code == 0
|
|
29
|
-
# Three OpenCost rows lack `team`; two of them (the vLLM pod and the
|
|
30
|
-
# embeddings job) carry a namespace, so only __idle__ stays on the backlog.
|
|
31
|
-
# Count rows rather than match names: rich truncates to terminal width.
|
|
32
|
-
assert plain.stdout.count("│ opencost") == 3
|
|
33
|
-
assert rescued.stdout.count("│ opencost") == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|