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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Reconcile the ledger against what the provider actually billed.
|
|
2
|
+
|
|
3
|
+
An attribution report that doesn't tie back to the invoice is a guess. This is
|
|
4
|
+
the step most tools skip, and it is the one that makes a FinOps team trust the
|
|
5
|
+
number.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
|
|
14
|
+
from unalloc.core.models import CostRow, Invoice
|
|
15
|
+
|
|
16
|
+
ZERO = Decimal("0")
|
|
17
|
+
|
|
18
|
+
# Below this, a delta is rounding and metric-scrape jitter, not a real gap.
|
|
19
|
+
DEFAULT_TOLERANCE_PCT = Decimal("1.0")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class SourceReconciliation:
|
|
24
|
+
source: str
|
|
25
|
+
ledger_usd: Decimal
|
|
26
|
+
invoice_usd: Decimal | None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def delta_usd(self) -> Decimal | None:
|
|
30
|
+
if self.invoice_usd is None:
|
|
31
|
+
return None
|
|
32
|
+
return self.ledger_usd - self.invoice_usd
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def delta_pct(self) -> Decimal | None:
|
|
36
|
+
if self.invoice_usd is None or self.invoice_usd == ZERO:
|
|
37
|
+
return None
|
|
38
|
+
return ((self.ledger_usd - self.invoice_usd) / self.invoice_usd * 100).quantize(
|
|
39
|
+
Decimal("0.1")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def status(self, tolerance_pct: Decimal = DEFAULT_TOLERANCE_PCT) -> str:
|
|
43
|
+
"""One of: matched, over, under, no-invoice."""
|
|
44
|
+
if self.invoice_usd is None:
|
|
45
|
+
return "no-invoice"
|
|
46
|
+
pct = self.delta_pct
|
|
47
|
+
if pct is None:
|
|
48
|
+
return "no-invoice"
|
|
49
|
+
if abs(pct) <= tolerance_pct:
|
|
50
|
+
return "matched"
|
|
51
|
+
return "over" if pct > 0 else "under"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ReconciliationReport:
|
|
56
|
+
per_source: list[SourceReconciliation]
|
|
57
|
+
tolerance_pct: Decimal
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def ledger_total(self) -> Decimal:
|
|
61
|
+
return sum((r.ledger_usd for r in self.per_source), ZERO)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def invoice_total(self) -> Decimal:
|
|
65
|
+
return sum(
|
|
66
|
+
(r.invoice_usd for r in self.per_source if r.invoice_usd is not None), ZERO
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def clean(self) -> bool:
|
|
71
|
+
"""True when every source with an invoice ties out within tolerance."""
|
|
72
|
+
return all(
|
|
73
|
+
r.status(self.tolerance_pct) in {"matched", "no-invoice"}
|
|
74
|
+
for r in self.per_source
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reconcile(
|
|
79
|
+
rows: Iterable[CostRow],
|
|
80
|
+
invoices: Iterable[Invoice],
|
|
81
|
+
*,
|
|
82
|
+
tolerance_pct: Decimal = DEFAULT_TOLERANCE_PCT,
|
|
83
|
+
) -> ReconciliationReport:
|
|
84
|
+
ledger: dict[str, Decimal] = {}
|
|
85
|
+
for row in rows:
|
|
86
|
+
ledger[row.source] = ledger.get(row.source, ZERO) + row.amount_usd
|
|
87
|
+
|
|
88
|
+
billed: dict[str, Decimal] = {}
|
|
89
|
+
for invoice in invoices:
|
|
90
|
+
billed[invoice.source] = billed.get(invoice.source, ZERO) + invoice.amount_usd
|
|
91
|
+
|
|
92
|
+
sources = sorted(set(ledger) | set(billed))
|
|
93
|
+
per_source = [
|
|
94
|
+
SourceReconciliation(
|
|
95
|
+
source=source,
|
|
96
|
+
ledger_usd=ledger.get(source, ZERO),
|
|
97
|
+
invoice_usd=billed.get(source),
|
|
98
|
+
)
|
|
99
|
+
for source in sources
|
|
100
|
+
]
|
|
101
|
+
return ReconciliationReport(per_source=per_source, tolerance_pct=tolerance_pct)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"starting_at": "2026-08-01T00:00:00Z", "ending_at": "2026-09-01T00:00:00Z",
|
|
5
|
+
"results": [
|
|
6
|
+
{"amount": "4102.18", "currency": "USD", "workspace_id": "wrkspc_platform", "workspace_name": "platform", "description": "claude-sonnet-4-6 input", "token_type": "input"},
|
|
7
|
+
{"amount": "2890.55", "currency": "USD", "workspace_id": "wrkspc_platform", "workspace_name": "platform", "description": "claude-sonnet-4-6 output", "token_type": "output"},
|
|
8
|
+
{"amount": "1744.30", "currency": "USD", "workspace_id": null, "workspace_name": null, "description": "default workspace", "token_type": "input"}
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"request_id": "req_01", "model": "gpt-4o", "spend": "1840.22",
|
|
5
|
+
"startTime": "2026-08-03T11:02:11Z", "endTime": "2026-08-03T11:02:14Z",
|
|
6
|
+
"prompt_tokens": 812000, "completion_tokens": 240100, "total_tokens": 1052100,
|
|
7
|
+
"api_key_alias": "search-rerank", "team_id": "search",
|
|
8
|
+
"request_tags": ["feature:rerank", "environment:prod"],
|
|
9
|
+
"metadata": {"service": "search-api"}, "cache_hit": false
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"request_id": "req_02", "model": "claude-sonnet-4-6", "spend": "3204.90",
|
|
13
|
+
"startTime": "2026-08-07T09:41:00Z", "endTime": "2026-08-07T09:41:06Z",
|
|
14
|
+
"prompt_tokens": 2140000, "completion_tokens": 512300, "total_tokens": 2652300,
|
|
15
|
+
"api_key_alias": "agent-runtime", "team_id": "platform",
|
|
16
|
+
"request_tags": ["feature:agent-loop", "environment:prod"],
|
|
17
|
+
"metadata": {"service": "agent-runtime"}, "cache_hit": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"request_id": "req_03", "model": "gpt-4o-mini", "spend": "986.40",
|
|
21
|
+
"startTime": "2026-08-12T14:20:00Z", "endTime": "2026-08-12T14:20:02Z",
|
|
22
|
+
"prompt_tokens": 9400000, "completion_tokens": 1100000, "total_tokens": 10500000,
|
|
23
|
+
"api_key_alias": "sandbox-shared", "request_tags": [],
|
|
24
|
+
"metadata": {}, "cache_hit": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"request_id": "req_04", "model": "claude-opus-5", "spend": "5412.75",
|
|
28
|
+
"startTime": "2026-08-19T17:55:00Z", "endTime": "2026-08-19T17:55:22Z",
|
|
29
|
+
"prompt_tokens": 1802000, "completion_tokens": 940400, "total_tokens": 2742400,
|
|
30
|
+
"api_key_alias": "unknown-legacy-key", "request_tags": ["environment:prod"],
|
|
31
|
+
"metadata": {}, "cache_hit": false
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"object": "page",
|
|
3
|
+
"data": [
|
|
4
|
+
{
|
|
5
|
+
"object": "bucket", "start_time": 1785600000, "end_time": 1788278400,
|
|
6
|
+
"results": [
|
|
7
|
+
{"object": "organization.costs.result", "amount": {"value": 2210.44, "currency": "usd"}, "project_id": "proj_search", "project_name": "search", "line_item": "gpt-4o"},
|
|
8
|
+
{"object": "organization.costs.result", "amount": {"value": 1180.02, "currency": "usd"}, "project_id": "proj_default", "project_name": null, "line_item": "gpt-4o-mini"},
|
|
9
|
+
{"object": "organization.costs.result", "amount": {"value": 640.9, "currency": "usd"}, "project_id": null, "project_name": null, "line_item": "text-embedding-3-large"}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"code": 200,
|
|
3
|
+
"data": [
|
|
4
|
+
{
|
|
5
|
+
"inference/llm-d-gateway": {
|
|
6
|
+
"name": "inference/llm-d-gateway",
|
|
7
|
+
"start": "2026-08-01T00:00:00Z",
|
|
8
|
+
"end": "2026-09-01T00:00:00Z",
|
|
9
|
+
"properties": {
|
|
10
|
+
"namespace": "inference",
|
|
11
|
+
"controller": "llm-d-gateway",
|
|
12
|
+
"controllerKind": "deployment",
|
|
13
|
+
"cluster": "prod-us-west",
|
|
14
|
+
"labels": {"team": "platform", "environment": "prod", "app_kubernetes_io_name": "llm-d"}
|
|
15
|
+
},
|
|
16
|
+
"cpuCost": 412.18, "ramCost": 188.44, "gpuCost": 9120.5, "sharedCost": 240.0,
|
|
17
|
+
"gpuHours": 2880.0, "totalCost": 9961.12, "totalEfficiency": 0.61
|
|
18
|
+
},
|
|
19
|
+
"inference/vllm-llama-70b": {
|
|
20
|
+
"name": "inference/vllm-llama-70b",
|
|
21
|
+
"start": "2026-08-01T00:00:00Z",
|
|
22
|
+
"end": "2026-09-01T00:00:00Z",
|
|
23
|
+
"properties": {
|
|
24
|
+
"namespace": "inference",
|
|
25
|
+
"controller": "vllm-llama-70b",
|
|
26
|
+
"controllerKind": "statefulset",
|
|
27
|
+
"cluster": "prod-us-west",
|
|
28
|
+
"labels": {"environment": "prod", "costCenter": "CC-4471"},
|
|
29
|
+
"modelName": "llama-3.3-70b"
|
|
30
|
+
},
|
|
31
|
+
"cpuCost": 290.0, "ramCost": 144.2, "gpuCost": 18402.9, "sharedCost": 310.0,
|
|
32
|
+
"gpuHours": 5760.0, "totalCost": 19147.1, "totalEfficiency": 0.34
|
|
33
|
+
},
|
|
34
|
+
"batch/embeddings-worker": {
|
|
35
|
+
"name": "batch/embeddings-worker",
|
|
36
|
+
"start": "2026-08-01T00:00:00Z",
|
|
37
|
+
"end": "2026-09-01T00:00:00Z",
|
|
38
|
+
"properties": {
|
|
39
|
+
"namespace": "batch",
|
|
40
|
+
"controller": "embeddings-worker",
|
|
41
|
+
"controllerKind": "cronjob",
|
|
42
|
+
"cluster": "prod-us-west",
|
|
43
|
+
"labels": {}
|
|
44
|
+
},
|
|
45
|
+
"cpuCost": 980.4, "ramCost": 402.1, "gpuCost": 3120.0, "sharedCost": 96.0,
|
|
46
|
+
"gpuHours": 960.0, "totalCost": 4598.5, "totalEfficiency": 0.08
|
|
47
|
+
},
|
|
48
|
+
"search/api": {
|
|
49
|
+
"name": "search/api",
|
|
50
|
+
"start": "2026-08-01T00:00:00Z",
|
|
51
|
+
"end": "2026-09-01T00:00:00Z",
|
|
52
|
+
"properties": {
|
|
53
|
+
"namespace": "search",
|
|
54
|
+
"controller": "api",
|
|
55
|
+
"controllerKind": "deployment",
|
|
56
|
+
"cluster": "prod-us-west",
|
|
57
|
+
"labels": {"team": "search", "environment": "prod"}
|
|
58
|
+
},
|
|
59
|
+
"cpuCost": 1840.2, "ramCost": 620.8, "gpuCost": 0, "sharedCost": 180.0,
|
|
60
|
+
"totalCost": 2641.0, "totalEfficiency": 0.71
|
|
61
|
+
},
|
|
62
|
+
"__idle__": {
|
|
63
|
+
"name": "__idle__",
|
|
64
|
+
"start": "2026-08-01T00:00:00Z",
|
|
65
|
+
"end": "2026-09-01T00:00:00Z",
|
|
66
|
+
"properties": {"cluster": "prod-us-west", "labels": {}},
|
|
67
|
+
"cpuCost": 610.0, "ramCost": 220.0, "gpuCost": 5240.0,
|
|
68
|
+
"totalCost": 6070.0, "totalEfficiency": 0.0
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
unalloc/render/table.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Rendering. The unallocated percentage is the thing people screenshot, so it
|
|
2
|
+
gets a banner rather than a row buried in a table.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import json
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from unalloc.core.attribute import AttributionReport
|
|
18
|
+
from unalloc.core.models import UNALLOCATED, CostRow
|
|
19
|
+
from unalloc.core.reconcile import ReconciliationReport
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def usd(amount: Decimal) -> str:
|
|
25
|
+
return f"${amount:,.2f}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _severity(pct: Decimal) -> str:
|
|
29
|
+
if pct >= 25:
|
|
30
|
+
return "bold red"
|
|
31
|
+
if pct >= 10:
|
|
32
|
+
return "bold yellow"
|
|
33
|
+
return "bold green"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_report(report: AttributionReport, *, top: int = 20) -> None:
|
|
37
|
+
style = _severity(report.unallocated_pct)
|
|
38
|
+
console.print(
|
|
39
|
+
Panel(
|
|
40
|
+
Text.assemble(
|
|
41
|
+
(f"{report.unallocated_pct}%", style),
|
|
42
|
+
(" of ", "dim"),
|
|
43
|
+
(usd(report.total_usd), "bold"),
|
|
44
|
+
(" AI spend is unattributed", "dim"),
|
|
45
|
+
("\n", ""),
|
|
46
|
+
(f"{usd(report.unallocated_usd)} has no '{report.dimension}' label", "dim"),
|
|
47
|
+
*(
|
|
48
|
+
[
|
|
49
|
+
("\n", ""),
|
|
50
|
+
(
|
|
51
|
+
f"{usd(report.fallback_usd)} attributed only via fallback "
|
|
52
|
+
f"({', '.join(report.fallback_dimensions)})",
|
|
53
|
+
"yellow",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
if report.fallback_usd
|
|
57
|
+
else []
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
title="unalloc",
|
|
61
|
+
border_style=style.split()[-1],
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
table = Table(title=f"Spend by {report.dimension}", header_style="bold")
|
|
66
|
+
table.add_column(report.dimension)
|
|
67
|
+
table.add_column("Cost", justify="right")
|
|
68
|
+
table.add_column("Share", justify="right")
|
|
69
|
+
table.add_column("Sources")
|
|
70
|
+
table.add_column("Rows", justify="right")
|
|
71
|
+
|
|
72
|
+
for bucket in report.buckets[:top]:
|
|
73
|
+
share = (
|
|
74
|
+
(bucket.amount_usd / report.total_usd * 100).quantize(Decimal("0.1"))
|
|
75
|
+
if report.total_usd
|
|
76
|
+
else Decimal("0")
|
|
77
|
+
)
|
|
78
|
+
label = (
|
|
79
|
+
Text(UNALLOCATED, style="bold red")
|
|
80
|
+
if bucket.is_unallocated
|
|
81
|
+
else Text(bucket.key)
|
|
82
|
+
)
|
|
83
|
+
table.add_row(
|
|
84
|
+
label,
|
|
85
|
+
usd(bucket.amount_usd),
|
|
86
|
+
f"{share}%",
|
|
87
|
+
", ".join(bucket.by_source),
|
|
88
|
+
str(bucket.row_count),
|
|
89
|
+
)
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
sources = Table(title="By source", header_style="bold")
|
|
93
|
+
sources.add_column("Source")
|
|
94
|
+
sources.add_column("Cost", justify="right")
|
|
95
|
+
for source, amount in report.by_source.items():
|
|
96
|
+
sources.add_row(source, usd(amount))
|
|
97
|
+
sources.add_row(Text("total", style="bold"), Text(usd(report.total_usd), style="bold"))
|
|
98
|
+
console.print(sources)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_unallocated(rows: list[CostRow], dimension: str) -> None:
|
|
102
|
+
table = Table(
|
|
103
|
+
title=f"Labeling backlog — rows missing '{dimension}', by cost",
|
|
104
|
+
header_style="bold",
|
|
105
|
+
)
|
|
106
|
+
table.add_column("#", justify="right", style="dim")
|
|
107
|
+
table.add_column("Source")
|
|
108
|
+
table.add_column("Cost object")
|
|
109
|
+
table.add_column("Cost", justify="right")
|
|
110
|
+
table.add_column("Labels it does have", style="dim")
|
|
111
|
+
|
|
112
|
+
for index, row in enumerate(rows, start=1):
|
|
113
|
+
available = ", ".join(sorted(row.labels)) or "none"
|
|
114
|
+
table.add_row(str(index), row.source, row.name, usd(row.amount_usd), available)
|
|
115
|
+
console.print(table)
|
|
116
|
+
if not rows:
|
|
117
|
+
console.print("[green]Nothing unallocated. Ship it.[/green]")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_reconciliation(report: ReconciliationReport) -> None:
|
|
121
|
+
table = Table(title="Ledger vs invoice", header_style="bold")
|
|
122
|
+
table.add_column("Source")
|
|
123
|
+
table.add_column("Ledger", justify="right")
|
|
124
|
+
table.add_column("Invoice", justify="right")
|
|
125
|
+
table.add_column("Delta", justify="right")
|
|
126
|
+
table.add_column("Status")
|
|
127
|
+
|
|
128
|
+
palette = {"matched": "green", "over": "red", "under": "yellow", "no-invoice": "dim"}
|
|
129
|
+
for item in report.per_source:
|
|
130
|
+
status = item.status(report.tolerance_pct)
|
|
131
|
+
delta = item.delta_usd
|
|
132
|
+
table.add_row(
|
|
133
|
+
item.source,
|
|
134
|
+
usd(item.ledger_usd),
|
|
135
|
+
usd(item.invoice_usd) if item.invoice_usd is not None else "—",
|
|
136
|
+
f"{usd(delta)} ({item.delta_pct}%)" if delta is not None else "—",
|
|
137
|
+
Text(status, style=palette[status]),
|
|
138
|
+
)
|
|
139
|
+
console.print(table)
|
|
140
|
+
if not report.clean:
|
|
141
|
+
console.print(
|
|
142
|
+
"[yellow]The ledger does not tie out. Attribution built on this "
|
|
143
|
+
"is not defensible yet.[/yellow]"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_json(payload: Any) -> str:
|
|
148
|
+
def default(value: Any) -> Any:
|
|
149
|
+
if isinstance(value, Decimal):
|
|
150
|
+
# String, not float: JSON floats would reintroduce the precision
|
|
151
|
+
# loss the whole ledger is built to avoid.
|
|
152
|
+
return str(value)
|
|
153
|
+
if hasattr(value, "isoformat"):
|
|
154
|
+
return value.isoformat()
|
|
155
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
156
|
+
return dataclasses.asdict(value)
|
|
157
|
+
raise TypeError(type(value))
|
|
158
|
+
|
|
159
|
+
return json.dumps(payload, indent=2, default=default)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cost source adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unalloc.sources.anthropic import AnthropicSource
|
|
6
|
+
from unalloc.sources.base import Source
|
|
7
|
+
from unalloc.sources.litellm import LiteLLMSource
|
|
8
|
+
from unalloc.sources.openai import OpenAISource
|
|
9
|
+
from unalloc.sources.opencost import OpenCostSource
|
|
10
|
+
|
|
11
|
+
REGISTRY: dict[str, type[Source]] = {
|
|
12
|
+
OpenCostSource.name: OpenCostSource,
|
|
13
|
+
LiteLLMSource.name: LiteLLMSource,
|
|
14
|
+
OpenAISource.name: OpenAISource,
|
|
15
|
+
AnthropicSource.name: AnthropicSource,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"REGISTRY",
|
|
20
|
+
"AnthropicSource",
|
|
21
|
+
"LiteLLMSource",
|
|
22
|
+
"OpenAISource",
|
|
23
|
+
"OpenCostSource",
|
|
24
|
+
"Source",
|
|
25
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Anthropic organization cost report adapter.
|
|
2
|
+
|
|
3
|
+
Shape mirrors the OpenAI adapter closely: time buckets containing per-workspace
|
|
4
|
+
cost results. Workspace is the attribution dimension available here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from unalloc.core.models import CostRow
|
|
13
|
+
from unalloc.core.normalize import normalize_labels
|
|
14
|
+
from unalloc.sources.base import Source, money, parse_ts
|
|
15
|
+
|
|
16
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnthropicSource(Source):
|
|
20
|
+
name = "anthropic"
|
|
21
|
+
default_base_url = "https://api.anthropic.com/v1"
|
|
22
|
+
|
|
23
|
+
def _headers(self) -> dict[str, str]:
|
|
24
|
+
# The Admin API authenticates with x-api-key, not a Bearer token.
|
|
25
|
+
headers = {"Accept": "application/json", "anthropic-version": ANTHROPIC_VERSION}
|
|
26
|
+
if self.token:
|
|
27
|
+
headers["x-api-key"] = self.token
|
|
28
|
+
return headers
|
|
29
|
+
|
|
30
|
+
def fetch(self, start: datetime, end: datetime) -> list[CostRow]:
|
|
31
|
+
payload = self._get_pages(
|
|
32
|
+
"/organizations/cost_report",
|
|
33
|
+
params={
|
|
34
|
+
"starting_at": start.date().isoformat(),
|
|
35
|
+
"ending_at": end.date().isoformat(),
|
|
36
|
+
"group_by[]": "workspace_id",
|
|
37
|
+
"limit": 180,
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
return self.parse(payload)
|
|
41
|
+
|
|
42
|
+
def parse(self, payload: Any) -> list[CostRow]:
|
|
43
|
+
buckets = payload.get("data", []) if isinstance(payload, dict) else payload
|
|
44
|
+
if not isinstance(buckets, list):
|
|
45
|
+
return []
|
|
46
|
+
rows: list[CostRow] = []
|
|
47
|
+
for bucket in buckets:
|
|
48
|
+
if not isinstance(bucket, dict):
|
|
49
|
+
continue
|
|
50
|
+
start = parse_ts(bucket.get("starting_at"))
|
|
51
|
+
end = parse_ts(bucket.get("ending_at"), default=start)
|
|
52
|
+
for result in bucket.get("results") or []:
|
|
53
|
+
if not isinstance(result, dict):
|
|
54
|
+
continue
|
|
55
|
+
rows.append(self._row(result, start, end))
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
def _row(self, result: dict[str, Any], start: datetime, end: datetime) -> CostRow:
|
|
59
|
+
raw_labels = {
|
|
60
|
+
"workspace_id": result.get("workspace_id"),
|
|
61
|
+
"workspace": result.get("workspace_name"),
|
|
62
|
+
"description": result.get("description"),
|
|
63
|
+
"provider": "anthropic",
|
|
64
|
+
}
|
|
65
|
+
return CostRow(
|
|
66
|
+
source=self.name,
|
|
67
|
+
name=str(result.get("description") or result.get("workspace_id") or "anthropic-usage"),
|
|
68
|
+
amount_usd=money(result.get("amount")),
|
|
69
|
+
start=start,
|
|
70
|
+
end=end,
|
|
71
|
+
labels=normalize_labels(raw_labels, self.aliases),
|
|
72
|
+
meta={"currency": result.get("currency"), "token_type": result.get("token_type")},
|
|
73
|
+
)
|
unalloc/sources/base.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Adapter contract.
|
|
2
|
+
|
|
3
|
+
An adapter does exactly two things: fetch a provider payload, and turn it into
|
|
4
|
+
CostRows. It must not aggregate, filter by cost, or make attribution decisions.
|
|
5
|
+
Keeping adapters dumb is what lets new providers be added in an afternoon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from decimal import Decimal, InvalidOperation
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from unalloc.core.models import CostRow
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT = 30.0
|
|
22
|
+
|
|
23
|
+
# Hard stop for cursor pagination. A provider that keeps saying has_more is a
|
|
24
|
+
# bug on their side, and looping forever against a billing API is worse.
|
|
25
|
+
MAX_PAGES = 1000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Source(ABC):
|
|
29
|
+
"""Base class for every cost source."""
|
|
30
|
+
|
|
31
|
+
name: str = "unknown"
|
|
32
|
+
|
|
33
|
+
default_base_url: str | None = None
|
|
34
|
+
"""Public API root, for providers that have one. An unset or empty
|
|
35
|
+
`base_url` falls back to this, so a blank env var does not disable it."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
base_url: str | None = None,
|
|
41
|
+
token: str | None = None,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
aliases: dict[str, str] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.base_url = (base_url or self.default_base_url or "").rstrip("/")
|
|
46
|
+
self.token = token
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self.aliases = aliases or {}
|
|
49
|
+
|
|
50
|
+
# --- to implement per provider -------------------------------------
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def fetch(self, start: datetime, end: datetime) -> list[CostRow]:
|
|
54
|
+
"""Call the provider and return normalized rows."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def parse(self, payload: Any) -> list[CostRow]:
|
|
58
|
+
"""Turn a raw provider payload into CostRows.
|
|
59
|
+
|
|
60
|
+
Kept separate from `fetch` so the fixtures in examples/ are a real test
|
|
61
|
+
of the parsing contract and the tool is demoable with zero infra.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# --- shared plumbing ------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def from_fixture(self, path: str | Path) -> list[CostRow]:
|
|
67
|
+
with Path(path).open() as handle:
|
|
68
|
+
return self.parse(json.load(handle))
|
|
69
|
+
|
|
70
|
+
def _headers(self) -> dict[str, str]:
|
|
71
|
+
"""Request headers. Override when a provider does not take Bearer auth."""
|
|
72
|
+
headers = {"Accept": "application/json"}
|
|
73
|
+
if self.token:
|
|
74
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
78
|
+
if not self.base_url:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"{self.name}: no base_url configured. Set UNALLOC_{self.name.upper()}_URL "
|
|
81
|
+
f"or run with --fixtures to demo without infrastructure."
|
|
82
|
+
)
|
|
83
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
84
|
+
response = client.get(
|
|
85
|
+
f"{self.base_url}{path}", params=params, headers=self._headers()
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response.json()
|
|
89
|
+
|
|
90
|
+
def _get_pages(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
"""Follow `has_more` / `next_page` cursors and concatenate `data`.
|
|
92
|
+
|
|
93
|
+
OpenAI's and Anthropic's admin reporting APIs both page this way. A
|
|
94
|
+
30-day window grouped by project easily exceeds one page, and silently
|
|
95
|
+
reading only the first page under-reports spend.
|
|
96
|
+
"""
|
|
97
|
+
data: list[Any] = []
|
|
98
|
+
query = dict(params)
|
|
99
|
+
for _ in range(MAX_PAGES):
|
|
100
|
+
payload = self._get(path, params=query)
|
|
101
|
+
if not isinstance(payload, dict):
|
|
102
|
+
break
|
|
103
|
+
data.extend(payload.get("data") or [])
|
|
104
|
+
cursor = payload.get("next_page")
|
|
105
|
+
if not payload.get("has_more") or not cursor:
|
|
106
|
+
break
|
|
107
|
+
query["page"] = cursor
|
|
108
|
+
return {"data": data}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def money(value: Any) -> Decimal:
|
|
112
|
+
"""Coerce a provider's cost field to Decimal without ever going via float.
|
|
113
|
+
|
|
114
|
+
Providers are inconsistent: OpenCost sends JSON floats, LiteLLM sends
|
|
115
|
+
strings, some send None for zero. str() before Decimal keeps float noise
|
|
116
|
+
(0.30000000000000004) out of the ledger.
|
|
117
|
+
"""
|
|
118
|
+
if value is None:
|
|
119
|
+
return Decimal("0")
|
|
120
|
+
try:
|
|
121
|
+
return Decimal(str(value))
|
|
122
|
+
except (InvalidOperation, ValueError):
|
|
123
|
+
return Decimal("0")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_ts(value: Any, default: datetime | None = None) -> datetime:
|
|
127
|
+
"""Parse the assorted timestamp formats providers emit, as UTC-aware."""
|
|
128
|
+
if isinstance(value, datetime):
|
|
129
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
130
|
+
if isinstance(value, (int, float)):
|
|
131
|
+
return datetime.fromtimestamp(value, tz=UTC)
|
|
132
|
+
if isinstance(value, str) and value:
|
|
133
|
+
text = value.replace("Z", "+00:00")
|
|
134
|
+
try:
|
|
135
|
+
parsed = datetime.fromisoformat(text)
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
else:
|
|
139
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
140
|
+
return default or datetime.now(tz=UTC)
|