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 +6 -0
- unalloc/cli.py +252 -0
- unalloc/core/__init__.py +1 -0
- unalloc/core/attribute.py +140 -0
- unalloc/core/models.py +72 -0
- unalloc/core/normalize.py +143 -0
- unalloc/core/reconcile.py +101 -0
- unalloc/fixtures/anthropic_cost_report.json +12 -0
- unalloc/fixtures/litellm_spend.json +34 -0
- unalloc/fixtures/openai_costs.json +13 -0
- unalloc/fixtures/opencost_allocation.json +72 -0
- unalloc/render/__init__.py +1 -0
- unalloc/render/table.py +159 -0
- unalloc/sources/__init__.py +25 -0
- unalloc/sources/anthropic.py +73 -0
- unalloc/sources/base.py +140 -0
- unalloc/sources/litellm.py +75 -0
- unalloc/sources/openai.py +68 -0
- unalloc/sources/opencost.py +127 -0
- unalloc-0.2.0.dist-info/METADATA +254 -0
- unalloc-0.2.0.dist-info/RECORD +24 -0
- unalloc-0.2.0.dist-info/WHEEL +4 -0
- unalloc-0.2.0.dist-info/entry_points.txt +2 -0
- unalloc-0.2.0.dist-info/licenses/LICENSE +202 -0
unalloc/__init__.py
ADDED
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()
|
unalloc/core/__init__.py
ADDED
|
@@ -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]))
|