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.
@@ -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
@@ -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
+ )
@@ -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)