unalloc 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """unalloc — find the AI spend nobody owns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
6
+ __all__ = ["__version__"]
unalloc/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """unalloc command line interface.
2
+
3
+ Three commands, one question each:
4
+ report how much of our AI spend is unattributed?
5
+ labels which cost objects should we fix first?
6
+ reconcile does the ledger agree with the invoice?
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from datetime import UTC, datetime, timedelta
13
+ from decimal import Decimal
14
+ from pathlib import Path
15
+
16
+ import typer
17
+
18
+ from unalloc import __version__
19
+ from unalloc.core.attribute import attribute, unallocated_rows
20
+ from unalloc.core.models import CostRow, Invoice
21
+ from unalloc.core.normalize import dimensions
22
+ from unalloc.core.reconcile import DEFAULT_TOLERANCE_PCT, reconcile
23
+ from unalloc.render import table as render
24
+ from unalloc.sources import REGISTRY
25
+
26
+ app = typer.Typer(
27
+ add_completion=False,
28
+ help="Find the AI spend nobody owns. Joins Kubernetes allocation data with "
29
+ "LLM provider bills and reports what is unattributed.",
30
+ )
31
+
32
+ # Fixtures ship inside the package so `--fixtures` works from an installed
33
+ # wheel, not just from a clone. A clean-venv install is the only way to
34
+ # catch this, so `make check-wheel` does exactly that.
35
+ FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
36
+
37
+ FIXTURE_FILES = {
38
+ "opencost": "opencost_allocation.json",
39
+ "litellm": "litellm_spend.json",
40
+ "openai": "openai_costs.json",
41
+ "anthropic": "anthropic_cost_report.json",
42
+ }
43
+
44
+ ENV_URL = {
45
+ "opencost": "UNALLOC_OPENCOST_URL",
46
+ "litellm": "UNALLOC_LITELLM_URL",
47
+ "openai": "UNALLOC_OPENAI_URL",
48
+ "anthropic": "UNALLOC_ANTHROPIC_URL",
49
+ }
50
+
51
+ ENV_TOKEN = {
52
+ "litellm": "UNALLOC_LITELLM_KEY",
53
+ "openai": "OPENAI_ADMIN_KEY",
54
+ "anthropic": "ANTHROPIC_ADMIN_KEY",
55
+ }
56
+
57
+
58
+ def _window(days: int) -> tuple[datetime, datetime]:
59
+ end = datetime.now(tz=UTC)
60
+ return end - timedelta(days=days), end
61
+
62
+
63
+ def _load(
64
+ sources: list[str],
65
+ *,
66
+ fixtures: bool,
67
+ days: int,
68
+ fixture_dir: Path,
69
+ ) -> list[CostRow]:
70
+ start, end = _window(days)
71
+ rows: list[CostRow] = []
72
+ for name in sources:
73
+ if name not in REGISTRY:
74
+ raise typer.BadParameter(
75
+ f"unknown source '{name}'. Known: {', '.join(sorted(REGISTRY))}"
76
+ )
77
+ adapter = REGISTRY[name](
78
+ base_url=os.environ.get(ENV_URL.get(name, "")) or None,
79
+ token=os.environ.get(ENV_TOKEN.get(name, "")) or None,
80
+ )
81
+ if fixtures:
82
+ path = fixture_dir / FIXTURE_FILES[name]
83
+ if not path.exists():
84
+ typer.secho(f"skipping {name}: no fixture at {path}", fg="yellow")
85
+ continue
86
+ rows.extend(adapter.from_fixture(path))
87
+ else:
88
+ try:
89
+ rows.extend(adapter.fetch(start, end))
90
+ except Exception as exc:
91
+ typer.secho(f"{name}: {exc}", fg="red", err=True)
92
+ return rows
93
+
94
+
95
+ SourcesOpt = typer.Option(
96
+ ["opencost", "litellm", "openai", "anthropic"],
97
+ "--source",
98
+ "-s",
99
+ help="Source to pull. Repeat the flag for several.",
100
+ )
101
+ FixturesOpt = typer.Option(
102
+ False, "--fixtures", help="Run against bundled sample payloads. No infra needed."
103
+ )
104
+ DaysOpt = typer.Option(30, "--days", "-d", help="Lookback window in days.")
105
+ DimensionOpt = typer.Option(
106
+ "team", "--dimension", "-D", help="Label key to attribute on."
107
+ )
108
+ FallbackOpt = typer.Option(
109
+ [],
110
+ "--fallback",
111
+ "-f",
112
+ help="Second-choice label keys, tried in order before a row is called "
113
+ "unallocated. Keep this short and defensible.",
114
+ )
115
+ JsonOpt = typer.Option(False, "--json", help="Emit JSON instead of tables.")
116
+ FixtureDirOpt = typer.Option(FIXTURE_DIR, "--fixture-dir", help="Where fixtures live.")
117
+
118
+
119
+ @app.command()
120
+ def report(
121
+ source: list[str] = SourcesOpt,
122
+ dimension: str = DimensionOpt,
123
+ fallback: list[str] = FallbackOpt,
124
+ days: int = DaysOpt,
125
+ fixtures: bool = FixturesOpt,
126
+ fixture_dir: Path = FixtureDirOpt,
127
+ top: int = typer.Option(20, "--top", help="Rows to show."),
128
+ as_json: bool = JsonOpt,
129
+ budget: float | None = typer.Option(
130
+ None,
131
+ "--budget",
132
+ help="Exit with code 2 when the unallocated share exceeds this percent. "
133
+ "Lets CI fail a deploy that ships unlabeled spend.",
134
+ ),
135
+ ) -> None:
136
+ """Attribute joined spend and report the unallocated share."""
137
+ rows = _load(source, fixtures=fixtures, days=days, fixture_dir=fixture_dir)
138
+ if not rows:
139
+ typer.secho("No cost rows loaded. Try --fixtures.", fg="yellow")
140
+ raise typer.Exit(code=1)
141
+
142
+ result = attribute(rows, dimension, fallback_dimensions=tuple(fallback))
143
+ if as_json:
144
+ typer.echo(
145
+ render.to_json(
146
+ {
147
+ "dimension": result.dimension,
148
+ "total_usd": result.total_usd,
149
+ "unallocated_usd": result.unallocated_usd,
150
+ "unallocated_pct": result.unallocated_pct,
151
+ "fallback_dimensions": result.fallback_dimensions,
152
+ "fallback_usd": result.fallback_usd,
153
+ "by_source": result.by_source,
154
+ "buckets": result.buckets,
155
+ }
156
+ )
157
+ )
158
+ else:
159
+ render.render_report(result, top=top)
160
+
161
+ if budget is not None and result.unallocated_pct > Decimal(str(budget)):
162
+ typer.secho(
163
+ f"unallocated {result.unallocated_pct}% exceeds budget {budget}%",
164
+ fg="red",
165
+ err=True,
166
+ )
167
+ raise typer.Exit(code=2)
168
+
169
+
170
+ @app.command()
171
+ def labels(
172
+ source: list[str] = SourcesOpt,
173
+ dimension: str = DimensionOpt,
174
+ fallback: list[str] = FallbackOpt,
175
+ days: int = DaysOpt,
176
+ fixtures: bool = FixturesOpt,
177
+ fixture_dir: Path = FixtureDirOpt,
178
+ limit: int = typer.Option(20, "--limit", help="Rows in the backlog."),
179
+ available: bool = typer.Option(
180
+ False, "--available", help="List label keys present in the data instead."
181
+ ),
182
+ ) -> None:
183
+ """Show the labeling backlog: unattributed cost objects, priciest first."""
184
+ rows = _load(source, fixtures=fixtures, days=days, fixture_dir=fixture_dir)
185
+ if not rows:
186
+ # Without this, an unreachable source reads as "Nothing unallocated".
187
+ typer.secho("No cost rows loaded. Try --fixtures.", fg="yellow")
188
+ raise typer.Exit(code=1)
189
+ if available:
190
+ for key, count in dimensions(rows).items():
191
+ typer.echo(f"{count:>6} {key}")
192
+ return
193
+ backlog = unallocated_rows(
194
+ rows, dimension, fallback_dimensions=tuple(fallback), limit=limit
195
+ )
196
+ render.render_unallocated(backlog, dimension)
197
+
198
+
199
+ @app.command(name="reconcile")
200
+ def reconcile_cmd(
201
+ source: list[str] = SourcesOpt,
202
+ days: int = DaysOpt,
203
+ fixtures: bool = FixturesOpt,
204
+ fixture_dir: Path = FixtureDirOpt,
205
+ invoice: list[str] = typer.Option(
206
+ [],
207
+ "--invoice",
208
+ "-i",
209
+ help="Billed total per source as SOURCE=AMOUNT, e.g. openai=4210.55. "
210
+ "Repeat the flag per source.",
211
+ ),
212
+ tolerance: float = typer.Option(
213
+ float(DEFAULT_TOLERANCE_PCT),
214
+ "--tolerance",
215
+ help="Percent delta treated as rounding rather than a real gap.",
216
+ ),
217
+ ) -> None:
218
+ """Check the ledger against what each provider actually billed."""
219
+ rows = _load(source, fixtures=fixtures, days=days, fixture_dir=fixture_dir)
220
+ start, end = _window(days)
221
+
222
+ invoices: list[Invoice] = []
223
+ for entry in invoice:
224
+ if "=" not in entry:
225
+ raise typer.BadParameter(f"expected SOURCE=AMOUNT, got '{entry}'")
226
+ name, _, amount = entry.partition("=")
227
+ invoices.append(
228
+ Invoice(
229
+ source=name.strip(),
230
+ amount_usd=Decimal(amount.strip()),
231
+ start=start,
232
+ end=end,
233
+ )
234
+ )
235
+
236
+ render.render_reconciliation(
237
+ reconcile(rows, invoices, tolerance_pct=Decimal(str(tolerance)))
238
+ )
239
+
240
+
241
+ @app.command()
242
+ def version() -> None:
243
+ """Print the installed version."""
244
+ typer.echo(__version__)
245
+
246
+
247
+ def main() -> None:
248
+ app()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,140 @@
1
+ """Attribution: group a mixed ledger on one dimension and expose the gap.
2
+
3
+ Everything here is deliberately boring. The interesting claim unalloc makes is
4
+ not the grouping, it is that infra rows and LLM API rows can sit in the same
5
+ grouping at all.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Sequence
11
+ from dataclasses import dataclass, field
12
+ from decimal import Decimal
13
+
14
+ from unalloc.core.models import UNALLOCATED, CostRow
15
+
16
+ ZERO = Decimal("0")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Bucket:
21
+ """Spend for one value of the attribution dimension."""
22
+
23
+ key: str
24
+ amount_usd: Decimal
25
+ by_source: dict[str, Decimal] = field(default_factory=dict)
26
+ row_count: int = 0
27
+
28
+ @property
29
+ def is_unallocated(self) -> bool:
30
+ return self.key == UNALLOCATED
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class AttributionReport:
35
+ dimension: str
36
+ total_usd: Decimal
37
+ buckets: list[Bucket]
38
+ by_source: dict[str, Decimal]
39
+ unallocated_usd: Decimal
40
+ row_count: int
41
+ fallback_dimensions: tuple[str, ...] = ()
42
+ fallback_usd: Decimal = ZERO
43
+ """Spend that counts as allocated only because a fallback key matched.
44
+ Reported separately so a fallback onto something that is not an owner
45
+ (a chart name, a namespace shared by teams) cannot hide inside the
46
+ headline percentage."""
47
+
48
+ @property
49
+ def unallocated_pct(self) -> Decimal:
50
+ """The headline number. Zero total means zero gap, not a crash."""
51
+ if self.total_usd == ZERO:
52
+ return ZERO
53
+ return (self.unallocated_usd / self.total_usd * 100).quantize(Decimal("0.1"))
54
+
55
+ @property
56
+ def allocated_usd(self) -> Decimal:
57
+ return self.total_usd - self.unallocated_usd
58
+
59
+
60
+ def attribute(
61
+ rows: Iterable[CostRow],
62
+ dimension: str,
63
+ *,
64
+ fallback_dimensions: Sequence[str] = (),
65
+ ) -> AttributionReport:
66
+ """Group `rows` by `dimension`, bucketing label-less spend as unallocated.
67
+
68
+ `fallback_dimensions` lets you accept a second-best key before giving up on
69
+ a row: attributing on `team`, falling back to `namespace`, is usually more
70
+ honest than reporting 60% unallocated when the data is merely inconsistent.
71
+ Fallback hits are still counted as allocated, so keep the list short and
72
+ defensible.
73
+ """
74
+ rows = list(rows)
75
+ totals: dict[str, Decimal] = {}
76
+ per_source: dict[str, dict[str, Decimal]] = {}
77
+ counts: dict[str, int] = {}
78
+ by_source: dict[str, Decimal] = {}
79
+ total = ZERO
80
+ via_fallback = ZERO
81
+
82
+ for row in rows:
83
+ key = row.label(dimension)
84
+ if key is None:
85
+ for alt in fallback_dimensions:
86
+ key = row.label(alt)
87
+ if key is not None:
88
+ via_fallback += row.amount_usd
89
+ break
90
+ key = key or UNALLOCATED
91
+
92
+ totals[key] = totals.get(key, ZERO) + row.amount_usd
93
+ counts[key] = counts.get(key, 0) + 1
94
+ per_source.setdefault(key, {})
95
+ per_source[key][row.source] = per_source[key].get(row.source, ZERO) + row.amount_usd
96
+ by_source[row.source] = by_source.get(row.source, ZERO) + row.amount_usd
97
+ total += row.amount_usd
98
+
99
+ buckets = [
100
+ Bucket(
101
+ key=key,
102
+ amount_usd=amount,
103
+ by_source=dict(sorted(per_source[key].items())),
104
+ row_count=counts[key],
105
+ )
106
+ for key, amount in totals.items()
107
+ ]
108
+ # Largest spend first, but the unallocated bucket always sorts last so it
109
+ # reads as the punchline rather than getting lost mid-table.
110
+ buckets.sort(key=lambda b: (b.is_unallocated, -b.amount_usd))
111
+
112
+ return AttributionReport(
113
+ dimension=dimension,
114
+ total_usd=total,
115
+ buckets=buckets,
116
+ by_source=dict(sorted(by_source.items())),
117
+ unallocated_usd=totals.get(UNALLOCATED, ZERO),
118
+ row_count=len(rows),
119
+ fallback_dimensions=tuple(fallback_dimensions),
120
+ fallback_usd=via_fallback,
121
+ )
122
+
123
+
124
+ def unallocated_rows(
125
+ rows: Iterable[CostRow],
126
+ dimension: str,
127
+ *,
128
+ fallback_dimensions: Sequence[str] = (),
129
+ limit: int = 20,
130
+ ) -> list[CostRow]:
131
+ """The rows that have no value for `dimension`, most expensive first.
132
+
133
+ This is the actionable output: a labeling backlog sorted by dollars. It
134
+ honours the same fallbacks as `attribute`, so the backlog always sums to
135
+ the unallocated bucket of the matching report.
136
+ """
137
+ keys = (dimension, *fallback_dimensions)
138
+ missing = [row for row in rows if all(row.label(key) is None for key in keys)]
139
+ missing.sort(key=lambda r: r.amount_usd, reverse=True)
140
+ return missing[:limit]
unalloc/core/models.py ADDED
@@ -0,0 +1,72 @@
1
+ """The one data structure every cost source normalizes into.
2
+
3
+ The entire design of unalloc rests on this: an adapter's only job is to turn a
4
+ provider's payload into CostRows. Attribution, reconciliation and rendering all
5
+ operate on CostRows and know nothing about where they came from.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from decimal import Decimal
13
+ from typing import Any
14
+
15
+ # Label key used to bucket spend that carries no value for the requested
16
+ # attribution dimension. This is the number the tool exists to produce.
17
+ UNALLOCATED = "<unallocated>"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CostRow:
22
+ """A single unit of spend, normalized across infra and LLM providers.
23
+
24
+ Money is Decimal, never float. Cost attribution that disagrees with the
25
+ invoice by a penny per row is how you lose an argument with finance.
26
+ """
27
+
28
+ source: str
29
+ """Adapter that produced this row: opencost, litellm, openai, anthropic."""
30
+
31
+ name: str
32
+ """Human-readable identity of the cost object (pod, key, project, model)."""
33
+
34
+ amount_usd: Decimal
35
+ """Cost in USD for the window [start, end)."""
36
+
37
+ start: datetime
38
+ end: datetime
39
+
40
+ labels: dict[str, str] = field(default_factory=dict)
41
+ """Normalized, lowercased label keys -> values. The attribution dimension
42
+ is looked up here."""
43
+
44
+ quantity: Decimal | None = None
45
+ """Optional usage amount (tokens, core-hours). Not used for attribution,
46
+ but carried through so unit-economics math is possible later."""
47
+
48
+ unit: str | None = None
49
+ """Unit for `quantity`, e.g. "tokens", "core-hours", "gpu-hours"."""
50
+
51
+ meta: dict[str, Any] = field(default_factory=dict)
52
+ """Anything source-specific worth keeping for debugging. Never read by
53
+ core logic."""
54
+
55
+ def label(self, dimension: str) -> str | None:
56
+ """Return the value for `dimension`, or None if this row is unallocated."""
57
+ value = self.labels.get(dimension)
58
+ if value is None:
59
+ return None
60
+ value = value.strip()
61
+ return value or None
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Invoice:
66
+ """What the provider says you owe, for reconciliation against the ledger."""
67
+
68
+ source: str
69
+ amount_usd: Decimal
70
+ start: datetime
71
+ end: datetime
72
+ note: str = ""
@@ -0,0 +1,143 @@
1
+ """Label canonicalization.
2
+
3
+ The whole premise of unalloc is joining ledgers that never agreed on a key.
4
+ OpenCost gives you `label_cost_center` or `labels.costCenter`. LiteLLM gives you
5
+ `team_id` or a request tag. OpenAI gives you a project name. None of them will
6
+ match on a naive dict lookup, so every adapter runs its labels through here
7
+ before emitting a CostRow.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Iterable, Mapping
14
+
15
+ _NON_ALNUM = re.compile(r"[^a-z0-9]+")
16
+
17
+ # Prefixes providers bolt onto label keys that carry no meaning for us.
18
+ _STRIP_PREFIXES = (
19
+ "label_",
20
+ "labels_",
21
+ "annotation_",
22
+ "kubernetes_io_",
23
+ "app_kubernetes_io_",
24
+ "tag_",
25
+ "request_tag_",
26
+ )
27
+
28
+ # Default alias table: many spellings of the same business dimension.
29
+ # Extend this per-org via `aliases` in config rather than editing code.
30
+ DEFAULT_ALIASES: dict[str, str] = {
31
+ "costcenter": "cost_center",
32
+ "cost_centre": "cost_center",
33
+ "cc": "cost_center",
34
+ "team_id": "team",
35
+ "teamid": "team",
36
+ "owning_team": "team",
37
+ "owner": "team",
38
+ "squad": "team",
39
+ "project_id": "project",
40
+ "projectid": "project",
41
+ "proj": "project",
42
+ "workspace_id": "workspace",
43
+ "svc": "service",
44
+ "app": "service",
45
+ "application": "service",
46
+ "env": "environment",
47
+ "stage": "environment",
48
+ }
49
+
50
+
51
+ def canonical_key(key: str) -> str:
52
+ """Normalize one label key to snake_case with provider noise stripped.
53
+
54
+ >>> canonical_key("label_costCenter")
55
+ 'cost_center'
56
+ >>> canonical_key("app.kubernetes.io/name")
57
+ 'name'
58
+ """
59
+ key = key.strip()
60
+ # camelCase -> camel_case before lowercasing, or we lose word boundaries.
61
+ key = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key)
62
+ key = key.lower()
63
+ # Path-style keys: app.kubernetes.io/name -> name
64
+ if "/" in key:
65
+ key = key.rsplit("/", 1)[-1]
66
+ key = _NON_ALNUM.sub("_", key).strip("_")
67
+ # Strip until nothing matches: OpenCost's Prometheus-style keys stack
68
+ # prefixes (label_app_kubernetes_io_name), and stopping after one made the
69
+ # same Kubernetes label canonicalize differently depending on the endpoint.
70
+ stripped = True
71
+ while stripped:
72
+ stripped = False
73
+ for prefix in _STRIP_PREFIXES:
74
+ if key.startswith(prefix) and len(key) > len(prefix):
75
+ key = key[len(prefix) :]
76
+ stripped = True
77
+ break
78
+ return key
79
+
80
+
81
+ def _precedence(raw_key: str, canon: str, aliased: bool) -> tuple[int, str]:
82
+ """Rank competing raw keys that land on the same canonical key.
83
+
84
+ Lower wins: a key already written canonically, then an alias, then a key
85
+ that needed rewriting (prefix, camelCase), then a path-derived key. Ties
86
+ break on the raw key itself, so the result never depends on the order a
87
+ provider happened to serialize its labels in.
88
+ """
89
+ written = raw_key.strip().lower()
90
+ if written == canon and not aliased:
91
+ rank = 0
92
+ elif aliased:
93
+ rank = 1
94
+ elif "/" not in written:
95
+ rank = 2
96
+ else:
97
+ rank = 3
98
+ return rank, written
99
+
100
+
101
+ def normalize_labels(
102
+ raw: Mapping[str, object] | None,
103
+ aliases: Mapping[str, str] | None = None,
104
+ ) -> dict[str, str]:
105
+ """Canonicalize keys, apply the alias table, drop empty values.
106
+
107
+ When several raw keys collapse onto one canonical key, `_precedence`
108
+ decides deterministically: an explicit `team` label wins over a `team_id`
109
+ that aliases onto it, and `app.kubernetes.io/name` vs
110
+ `leaderworkerset.sigs.k8s.io/name` resolves the same way whatever order
111
+ the provider sent them in.
112
+ """
113
+ if not raw:
114
+ return {}
115
+ table = {**DEFAULT_ALIASES, **(aliases or {})}
116
+ best: dict[str, tuple[tuple[int, str], str]] = {}
117
+ for key, value in raw.items():
118
+ if value is None:
119
+ continue
120
+ text = str(value).strip()
121
+ if not text:
122
+ continue
123
+ shaped = canonical_key(str(key))
124
+ if not shaped:
125
+ continue
126
+ canon = table.get(shaped, shaped)
127
+ rank = _precedence(str(key), canon, aliased=canon != shaped)
128
+ if canon not in best or rank < best[canon][0]:
129
+ best[canon] = (rank, text)
130
+ return {canon: text for canon, (_, text) in best.items()}
131
+
132
+
133
+ def dimensions(rows: Iterable) -> dict[str, int]:
134
+ """Count how many rows carry each label key.
135
+
136
+ Useful for `unalloc labels`: it tells you which dimensions are actually
137
+ viable to attribute on before you pick one.
138
+ """
139
+ counts: dict[str, int] = {}
140
+ for row in rows:
141
+ for key in row.labels:
142
+ counts[key] = counts.get(key, 0) + 1
143
+ return dict(sorted(counts.items(), key=lambda kv: -kv[1]))